Procházet zdrojové kódy

改由 9119 自动更新夭夭插件

LSamien před 2 měsíci
rodič
revize
4a7f7b1c5c

+ 18 - 11
README.md

@@ -104,14 +104,14 @@ Content-Type: application/json
 接口时,也会返回 `409 yaoyao_storage_migration_required`;此时先按上面的
 `dashboard/` 局部同步方式安装一次当前兼容版本并重启,之后才能使用一键升级。
 
-本机受管部署会在安装后由 8800 重启 9119;未启用 Dashboard 监督或使用远程
-9119 时,响应中的 `restartRequired` 为 `true`,需要由目标环境的服务管理器
-完成重启。
+安装、覆盖、启用和加载均由 9119 自身处理;8800 不重启 9119,也不读写 Hermes
+插件目录。登录 Web 后还会比较发布清单与 9119 的实际插件版本,落后时自动通过
+同一接口更新。
 
 ### Web 与插件配套升级
 
-macOS 受管服务可在左下角 Agent 菜单中打开“系统更新”。它把一个 Git 发布标签
-中的 Web 与 Dashboard 插件视为同一发布单元,版本关系来自 `release.json`。
+macOS 受管服务可在左下角 Agent 菜单中打开“系统更新”。Git 发布标签负责 Web
+版本,`release.json` 同时声明兼容的插件版本;插件本身始终通过 9119 独立同步。
 
 升级接口固定读取 `HERMES_YAOYAO_RELEASE_SOURCE`,只接受发布源中最新的
 `vX.Y.Z` 标签,并在下载后核对标签解析出的 Git 提交和发布清单。浏览器不能传入
@@ -126,10 +126,11 @@ macOS 受管服务可在左下角 Agent 菜单中打开“系统更新”。它
 └── current -> releases/<版本>-<提交>
 ```
 
-独立 updater 会先下载、构建和备份插件,再停止 8800 与 9119、原子切换插件和
-`current`、重装 LaunchAgent,最后验证 `/healthz`、`/readyz` 和实际插件版本。
-验证失败会自动恢复上一套 Web 与插件;用户数据始终留在
-`~/.hermes-yaoyao` 和 `~/.hermes/plugin-data/yaoyao`,不进入版本目录。
+插件会先由已登录的 Web 会话通过 9119 更新并核对版本。独立 updater 只下载、
+构建和切换 Web 的 `current`,不会停止 9119、调用 `hermes config` 或读写
+`HERMES_HOME`。Web 验证失败时只回滚 Web;插件继续由 9119 保持最新版本。
+用户数据始终留在 `~/.hermes-yaoyao` 和
+`~/.hermes/plugin-data/yaoyao`,不进入版本目录。
 
 相关接口:
 
@@ -213,7 +214,11 @@ node bin/hermes-yaoyao.mjs service uninstall
 
 ## Docker 部署(仅夭夭 Web 8800)
 
-Docker 镜像运行时只启动本项目的夭夭 Web,不安装或监督 Hermes Dashboard。`9119` 必须已经由项目外部的 Hermes 实例提供;Compose 会强制关闭 `HERMES_YAOYAO_SUPERVISE_DASHBOARD`,因此启动、停止或重建 Web 容器都不会操作 9119。为支持离线部署,镜像额外携带插件安装材料,位于 `/opt/hermes-yaoyao-plugin/dashboard`;它不会自行写入另一个 Hermes 容器。
+Docker 镜像运行时只启动本项目的夭夭 Web,不监督或重启 Hermes Dashboard。
+`9119` 必须已经由项目外部的 Hermes 实例提供;Compose 会强制关闭
+`HERMES_YAOYAO_SUPERVISE_DASHBOARD`。用户登录 Web 后,8800 会通过 9119 的
+插件安装接口自动补齐落后的夭夭插件,不会直接操作 Hermes 容器或数据卷。为支持
+离线部署,镜像仍携带 `/opt/hermes-yaoyao-plugin/dashboard` 作为手动安装材料。
 
 复制 Docker 环境示例并启动:
 
@@ -256,7 +261,9 @@ docker compose --env-file docker.env down
 docker compose --env-file docker.env up -d --build
 ```
 
-只有显式执行 `docker compose down --volumes` 才会删除夭夭 Web 的命名卷。Dashboard 插件仍需按前文或 [Agent 安装手册](docs/agent-install.md) 独立安装到外部 Hermes 数据目录。
+只有显式执行 `docker compose down --volumes` 才会删除夭夭 Web 的命名卷。在线
+环境会在登录后通过 9119 自动更新插件;下方手动步骤仅用于离线安装或旧插件缺少
+安全迁移接口的兼容处理。
 
 ### Docker Hermes 容器的插件安装
 

+ 10 - 131
bin/hermes-yaoyao-updater.mjs

@@ -2,8 +2,6 @@
 
 import { execFileSync } from 'node:child_process'
 import {
-  copyFileSync,
-  cpSync,
   existsSync,
   lstatSync,
   mkdirSync,
@@ -120,59 +118,6 @@ function stopService() {
   if (loaded()) run('launchctl', ['bootout', `${domain}/${label}`])
 }
 
-function stopDashboard() {
-  try { run('hermes', ['dashboard', '--stop'], { timeout: 30_000 }) } catch { /* already stopped */ }
-}
-
-export function enabledPlugins(runCommand = run) {
-  const raw = runCommand('hermes', ['config', 'get', '--json', 'plugins.enabled']).trim()
-  if (!raw) return []
-  let value
-  try {
-    value = JSON.parse(raw)
-  } catch {
-    fail('Hermes plugins.enabled 未返回有效 JSON')
-  }
-  if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
-    fail('Hermes plugins.enabled 不是字符串数组')
-  }
-  return value
-}
-
-function setEnabledPlugins(value) {
-  run('hermes', ['config', 'set', 'plugins.enabled', JSON.stringify(value)])
-}
-
-function ensureYaoyaoEnabled(previous) {
-  if (!previous.includes('yaoyao')) setEnabledPlugins([...previous, 'yaoyao'])
-}
-
-function atomicDirectory(source, target, scratchRoot, token) {
-  const parent = dirname(target)
-  mkdirSync(parent, { recursive: true })
-  const incoming = join(parent, `.${token}-incoming`)
-  const previous = join(parent, `.${token}-previous`)
-  removeInside(incoming, scratchRoot)
-  removeInside(previous, scratchRoot)
-  cpSync(source, incoming, { recursive: true, preserveTimestamps: true })
-  if (existsSync(target)) renameSync(target, previous)
-  renameSync(incoming, target)
-  return previous
-}
-
-function atomicFile(source, target, scratchRoot, token) {
-  const parent = dirname(target)
-  mkdirSync(parent, { recursive: true })
-  const incoming = join(parent, `.${token}-incoming.yaml`)
-  const previous = join(parent, `.${token}-previous.yaml`)
-  removeInside(incoming, scratchRoot)
-  removeInside(previous, scratchRoot)
-  copyFileSync(source, incoming)
-  if (existsSync(target)) renameSync(target, previous)
-  renameSync(incoming, target)
-  return previous
-}
-
 export function currentTarget(releaseRoot) {
   const current = join(releaseRoot, 'current')
   try {
@@ -227,20 +172,15 @@ async function responseJSON(url) {
   return response.json()
 }
 
-async function verifyRuntime(pluginVersion, timeoutMs = 45_000) {
+async function verifyRuntime(timeoutMs = 45_000) {
   const deadline = Date.now() + timeoutMs
   let lastError = ''
   while (Date.now() < deadline) {
     try {
       const health = await responseJSON('http://127.0.0.1:8800/healthz')
       const ready = await responseJSON('http://127.0.0.1:8800/readyz')
-      const plugins = await responseJSON('http://127.0.0.1:9119/api/dashboard/plugins')
-      const plugin = Array.isArray(plugins) ? plugins.find(item => item?.name === 'yaoyao') : undefined
-      const pluginMatches = pluginVersion ? plugin?.version === pluginVersion : plugin === undefined
-      if (health.ok === true && ready.ok === true && pluginMatches) return
-      lastError = pluginVersion
-        ? `运行版本不匹配:期望插件 ${pluginVersion}`
-        : '上一版本未安装夭夭插件,但回滚后仍检测到插件'
+      if (health.ok === true && ready.ok === true) return
+      lastError = 'Web 服务尚未就绪'
     } catch (error) {
       lastError = error instanceof Error ? error.message : String(error)
     }
@@ -249,37 +189,6 @@ async function verifyRuntime(pluginVersion, timeoutMs = 45_000) {
   fail(`升级后健康检查失败:${lastError}`)
 }
 
-export function backupPlugin(plan, jobID) {
-  const pluginRoot = join(plan.hermesHome, 'plugins', 'yaoyao')
-  const backupRoot = join(plan.dataHome, 'backups', 'system-update', jobID)
-  const pluginPreviouslyInstalled = existsSync(join(pluginRoot, 'dashboard', 'manifest.json'))
-  mkdirSync(backupRoot, { recursive: true, mode: 0o700 })
-  if (existsSync(join(pluginRoot, 'dashboard'))) {
-    cpSync(join(pluginRoot, 'dashboard'), join(backupRoot, 'dashboard'), { recursive: true, preserveTimestamps: true })
-  }
-  if (existsSync(join(pluginRoot, 'plugin.yaml'))) copyFileSync(join(pluginRoot, 'plugin.yaml'), join(backupRoot, 'plugin.yaml'))
-  return { pluginRoot, backupRoot, pluginPreviouslyInstalled }
-}
-
-export function restorePlugin(pluginRoot, backupRoot, token, pluginPreviouslyInstalled = true) {
-  const dashboardBackup = join(backupRoot, 'dashboard')
-  const manifestBackup = join(backupRoot, 'plugin.yaml')
-  if (!pluginPreviouslyInstalled) {
-    removeInside(join(pluginRoot, 'dashboard'), pluginRoot)
-    removeInside(join(pluginRoot, 'plugin.yaml'), pluginRoot)
-    return
-  }
-  if (!existsSync(dashboardBackup) || !existsSync(manifestBackup)) fail('插件回滚备份不完整')
-  const oldDashboard = atomicDirectory(dashboardBackup, join(pluginRoot, 'dashboard'), pluginRoot, `${token}-restore-dashboard`)
-  const oldManifest = atomicFile(manifestBackup, join(pluginRoot, 'plugin.yaml'), pluginRoot, `${token}-restore-manifest`)
-  removeInside(oldDashboard, pluginRoot)
-  removeInside(oldManifest, pluginRoot)
-}
-
-function backupPluginVersion(backupRoot) {
-  return String(readJSON(join(backupRoot, 'dashboard', 'manifest.json')).version ?? '')
-}
-
 function stageRelease(job, jobPath) {
   const { plan, target, id } = job
   if (!target) fail('升级任务缺少目标版本')
@@ -317,54 +226,32 @@ async function runUpdate(jobPath, job) {
   if (pluginManifest.version !== target.pluginVersion) fail('构建产物中的插件版本与发布清单不一致')
 
   const previousCurrentTarget = currentTarget(plan.releaseRoot)
-  const { pluginRoot, backupRoot, pluginPreviouslyInstalled } = backupPlugin(plan, id)
-  const previousPluginVersion = pluginPreviouslyInstalled ? backupPluginVersion(backupRoot) : ''
-  const previousEnabledPlugins = enabledPlugins()
   let transitioned = false
-  let previousDashboard = ''
-  let previousPluginYaml = ''
   try {
-    updateJob(jobPath, { state: 'installing', message: `正在安装插件 ${target.pluginVersion}` })
+    updateJob(jobPath, { state: 'installing', message: `正在切换 Web ${target.webVersion}` })
     stopService()
-    stopDashboard()
     transitioned = true
-    previousDashboard = atomicDirectory(
-      join(pluginSource, 'dashboard'), join(pluginRoot, 'dashboard'), pluginRoot, `${id}-dashboard`,
-    )
-    previousPluginYaml = atomicFile(
-      join(pluginSource, 'plugin.yaml'), join(pluginRoot, 'plugin.yaml'), pluginRoot, `${id}-manifest`,
-    )
-    ensureYaoyaoEnabled(previousEnabledPlugins)
     switchCurrent(plan.releaseRoot, finalRoot, id)
 
     updateJob(jobPath, { state: 'restarting', message: '正在启动新版本服务' })
     startService(join(plan.releaseRoot, 'current'), plan, true)
-    updateJob(jobPath, { state: 'verifying', message: '正在验证 8800、9119 和插件版本' })
-    await verifyRuntime(target.pluginVersion)
-    removeInside(previousDashboard, pluginRoot)
-    removeInside(previousPluginYaml, pluginRoot)
+    updateJob(jobPath, { state: 'verifying', message: '正在验证 Web 服务' })
+    await verifyRuntime()
     writeJSON(join(dirname(jobPath), 'last-success.json'), {
       jobId: id,
       previousCurrentTarget,
       previousServiceRoot: plan.previousServiceRoot,
-      previousPluginVersion,
-      pluginPreviouslyInstalled,
-      previousEnabledPlugins,
-      backupRoot,
       target,
       finalRoot,
       commit,
     })
-    updateJob(jobPath, { state: 'succeeded', message: `已升级到 Web ${target.webVersion} + 插件 ${target.pluginVersion}` })
+    updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${target.webVersion};插件由 9119 管理` })
   } catch (error) {
     const message = error instanceof Error ? error.message : String(error)
     if (transitioned) {
       updateJob(jobPath, { state: 'rolling_back', message: '升级失败,正在自动恢复上一版本', error: message })
       try {
         stopService()
-        stopDashboard()
-        restorePlugin(pluginRoot, backupRoot, id, pluginPreviouslyInstalled)
-        setEnabledPlugins(previousEnabledPlugins)
         if (previousCurrentTarget) {
           switchCurrent(plan.releaseRoot, previousCurrentTarget, `${id}-rollback`)
           startService(join(plan.releaseRoot, 'current'), plan, true)
@@ -372,7 +259,7 @@ async function runUpdate(jobPath, job) {
           removeCurrent(plan.releaseRoot)
           startService(plan.previousServiceRoot, plan, false)
         }
-        await verifyRuntime(previousPluginVersion)
+        await verifyRuntime()
         updateJob(jobPath, { state: 'failed', message: '升级失败,已自动恢复上一版本', error: message })
       } catch (rollbackError) {
         const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
@@ -391,14 +278,6 @@ async function runRollback(jobPath, job) {
   const record = readJSON(recordPath)
   updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
   stopService()
-  stopDashboard()
-  restorePlugin(
-    join(plan.hermesHome, 'plugins', 'yaoyao'),
-    record.backupRoot,
-    id,
-    record.pluginPreviouslyInstalled !== false,
-  )
-  if (Array.isArray(record.previousEnabledPlugins)) setEnabledPlugins(record.previousEnabledPlugins)
   if (record.previousCurrentTarget) {
     switchCurrent(plan.releaseRoot, record.previousCurrentTarget, id)
     startService(join(plan.releaseRoot, 'current'), plan, true)
@@ -406,9 +285,9 @@ async function runRollback(jobPath, job) {
     removeCurrent(plan.releaseRoot)
     startService(record.previousServiceRoot, plan, false)
   }
-  await verifyRuntime(record.previousPluginVersion)
+  await verifyRuntime()
   rmSync(recordPath, { force: true })
-  updateJob(jobPath, { state: 'rolled_back', message: `已回滚到插件 ${record.previousPluginVersion}` })
+  updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务;插件继续由 9119 管理' })
 }
 
 async function main() {

+ 5 - 1
src/client/App.vue

@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth'
 import { useChatStore } from '@/stores/chat'
 import { useGroupsStore } from '@/stores/groups'
 import { useThemeStore } from '@/stores/theme'
+import { reconcileYaoyaoPlugin } from '@/api/systemUpdate'
 
 const auth = useAuthStore()
 const chat = useChatStore()
@@ -32,7 +33,10 @@ watch(() => theme.resolvedTheme, value => {
 watch(pageTitle, value => { document.title = value }, { immediate: true })
 
 watch(() => auth.isAuthenticated, authenticated => {
-  if (authenticated) void chat.connect().catch(() => undefined)
+  if (authenticated) {
+    void reconcileYaoyaoPlugin().catch(() => undefined)
+    void chat.connect().catch(() => undefined)
+  }
 })
 
 onMounted(() => auth.bootstrap())

+ 13 - 0
src/client/api/systemUpdate.ts

@@ -71,3 +71,16 @@ export function systemUpdateJob(jobID: string): Promise<UpdateJob> {
 export function rollbackSystemUpdate(): Promise<UpdateJob> {
   return apiRequest('/api/app/system/update/rollback', { method: 'POST', body: {} })
 }
+
+export function reconcileYaoyaoPlugin(): Promise<{
+  ok: boolean
+  updated: boolean
+  installedPluginVersion?: string
+  expectedPluginVersion: string
+}> {
+  return apiRequest('/api/app/plugins/yaoyao/reconcile', {
+    method: 'POST',
+    body: {},
+    timeoutMs: 90_000,
+  })
+}

+ 4 - 4
src/client/components/app/SystemUpdateDialog.vue

@@ -61,7 +61,7 @@ async function poll(next: UpdateJob) {
 
 async function applyUpdate() {
   const target = status.value?.latest
-  if (!target || !window.confirm(`升级到 Web ${target.webVersion} + 插件 ${target.pluginVersion}?运行服务会短暂重启。`)) return
+  if (!target || !window.confirm(`升级 Web 到 ${target.webVersion}?插件 ${target.pluginVersion} 将通过 9119 更新。`)) return
   busy.value = true
   error.value = ''
   try {
@@ -74,7 +74,7 @@ async function applyUpdate() {
 }
 
 async function rollback() {
-  if (!window.confirm('回滚到上一次升级前的 Web 与插件版本?运行服务会短暂重启。')) return
+  if (!window.confirm('回滚到上一次 Web 版本?插件继续由 9119 保持最新版本。')) return
   busy.value = true
   error.value = ''
   try {
@@ -111,7 +111,7 @@ onBeforeUnmount(stopPolling)
             <button class="icon-button" type="button" aria-label="关闭系统更新" :disabled="!!active" @click="emit('close')"><AppIcon name="close" /></button>
           </header>
 
-          <p class="system-update-intro">Web 服务与 Hermes Dashboard 插件按兼容组合一起更新;用户数据保留在独立目录中。</p>
+          <p class="system-update-intro">Web 服务独立升级;Hermes Dashboard 插件由 9119 自动更新到最新兼容版本。</p>
           <p v-if="error" class="system-update-error"><AppIcon name="alert" :size="15" />{{ error }}</p>
 
           <section v-if="status" class="version-grid">
@@ -121,7 +121,7 @@ onBeforeUnmount(stopPolling)
             <article><small>配套插件</small><strong>{{ status.latest?.pluginVersion || status.current.pluginVersion }}</strong></article>
           </section>
 
-          <p v-if="status && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="14" />当前插件与 Web 发布清单不匹配,升级时会恢复为配套组合。</p>
+          <p v-if="status && !status.versionsMatch" class="version-warning"><AppIcon name="alert" :size="14" />当前插件版本落后,登录后会自动通过 9119 更新。</p>
           <p v-if="status?.installationMode === 'source'" class="mode-note">首次升级会把运行服务迁移到可回滚的版本目录;Git 工作区不会被覆盖。</p>
           <p v-if="status && !status.supported" class="mode-note">{{ status.unsupportedReason }}</p>
 

+ 0 - 2
src/server/app.ts

@@ -29,7 +29,6 @@ export interface ApplicationOptions {
   leases?: RealtimeLeaseStore
   pairings?: NodePairingStore
   uploads?: UploadStore
-  restartDashboard?: () => Promise<void>
   updates?: SystemUpdateManager
 }
 
@@ -151,7 +150,6 @@ export function createApplication(options: ApplicationOptions = {}): Application
     pairings,
     uploads,
     updates,
-    restartDashboard: options.restartDashboard,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))

+ 0 - 3
src/server/index.ts

@@ -12,9 +12,6 @@ const dashboardSupervisor = config.superviseDashboard
   : undefined
 const runtime = createApplication({
   config,
-  ...(dashboardSupervisor
-    ? { restartDashboard: () => dashboardSupervisor.restart() }
-    : {}),
 })
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined

+ 101 - 8
src/server/routes.ts

@@ -9,6 +9,7 @@ import type { ServerConfig } from './config.js'
 import { DEFAULT_YAOYAO_PLUGIN_SOURCE, isLoopbackHost, isLoopbackUpstream } from './config.js'
 import { HttpError } from './errors.js'
 import { canonicalEpoch, groupCursor, type RealtimeChannel, RealtimeLeaseStore } from './leases.js'
+import { compareReleaseVersions } from './releases.js'
 import {
   bearerToken,
   DEFAULT_NODE_SCOPES,
@@ -38,7 +39,6 @@ export interface RouteDependencies {
   pairings: NodePairingStore
   uploads: UploadStore
   updates: SystemUpdateManager
-  restartDashboard?: () => Promise<void>
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -213,6 +213,83 @@ async function requireYaoyaoStorageReady(
   }
 }
 
+let yaoyaoReconcileInFlight: Promise<JsonObject> | undefined
+
+function pluginVersionAtLeast(installed: string | undefined, expected: string): boolean {
+  if (!installed) return false
+  try {
+    return compareReleaseVersions(installed, expected) >= 0
+  } catch {
+    return installed === expected
+  }
+}
+
+async function reconcileYaoyaoPlugin(
+  dependencies: RouteDependencies,
+  jar: CookieJar,
+  clientAddress: string | undefined,
+  expectedVersion: string,
+): Promise<JsonObject> {
+  if (yaoyaoReconcileInFlight) return yaoyaoReconcileInFlight
+  const task = (async () => {
+    const installedVersion = await yaoyaoPluginVersion(dependencies, jar)
+    if (pluginVersionAtLeast(installedVersion, expectedVersion)) {
+      return {
+        ok: true,
+        updated: false,
+        installedPluginVersion: installedVersion,
+        expectedPluginVersion: expectedVersion,
+      }
+    }
+
+    await requireYaoyaoStorageReady(dependencies, jar, installedVersion)
+    const pluginSource = dependencies.config.yaoyaoPluginSource
+      ?? DEFAULT_YAOYAO_PLUGIN_SOURCE
+    const installedResult = requireSuccess(await dependencies.upstream.request(
+      '/api/dashboard/agent-plugins/install',
+      jar,
+      {
+        method: 'POST',
+        body: {
+          identifier: pluginSource,
+          force: Boolean(installedVersion),
+          enable: true,
+        },
+        clientAddress,
+      },
+    ))
+
+    let actualVersion: string | undefined
+    for (let attempt = 0; attempt < 20; attempt += 1) {
+      actualVersion = await yaoyaoPluginVersion(dependencies, jar)
+      if (pluginVersionAtLeast(actualVersion, expectedVersion)) break
+      await new Promise(resolvePromise => setTimeout(resolvePromise, 250))
+    }
+    if (!pluginVersionAtLeast(actualVersion, expectedVersion)) {
+      throw new HttpError(
+        502,
+        `9119 插件更新后版本仍不匹配:期望 ${expectedVersion},实际 ${actualVersion || '未安装'}`,
+        'yaoyao_plugin_version_mismatch',
+      )
+    }
+    return {
+      ...installedResult,
+      source: pluginSource,
+      updated: true,
+      installedPluginVersion: actualVersion,
+      expectedPluginVersion: expectedVersion,
+      restarted: false,
+      restartRequired: false,
+    }
+  })()
+  yaoyaoReconcileInFlight = task
+  try {
+    return await task
+  } finally {
+    if (yaoyaoReconcileInFlight === task) yaoyaoReconcileInFlight = undefined
+  }
+}
+
 export function systemUpdateRequestAllowed(address: string, allowRemoteUpdate = false): boolean {
   if (allowRemoteUpdate) return true
   return isLoopbackHost(address.replace(/^::ffff:/, ''))
@@ -980,6 +1057,16 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       const installedPluginVersion = await yaoyaoPluginVersion(dependencies, jar)
       await requireYaoyaoStorageReady(dependencies, jar, installedPluginVersion)
       try {
+        const release = await dependencies.updates.check(installedPluginVersion)
+        if (!release.latest || release.latest.releaseVersion !== targetVersion) {
+          throw new Error('目标版本不是当前发布源的最新版本')
+        }
+        await reconcileYaoyaoPlugin(
+          dependencies,
+          jar,
+          ctx.req.socket.remoteAddress,
+          release.latest.pluginVersion,
+        )
         json(ctx, 202, await dependencies.updates.startUpdate(targetVersion, installedPluginVersion))
       } catch (error) {
         throw updateFailure(error)
@@ -1005,6 +1092,17 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       }
     })
   })
+  router.post('/api/app/plugins/yaoyao/reconcile', async (ctx) => {
+    await withJar(ctx, async (jar) => {
+      await requireGatewayAuthentication(ctx, dependencies, jar)
+      json(ctx, 200, await reconcileYaoyaoPlugin(
+        dependencies,
+        jar,
+        ctx.req.socket.remoteAddress,
+        dependencies.updates.currentManifest().pluginVersion,
+      ))
+    })
+  })
   router.post('/api/app/plugins/yaoyao/install', async (ctx) => {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
@@ -1064,16 +1162,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
         return
       }
       const installedResult = parseJson(installResponse)
-      let restarted = false
-      if (dependencies.restartDashboard) {
-        await dependencies.restartDashboard()
-        restarted = true
-      }
       json(ctx, 200, {
         ...installedResult,
         source: pluginSource,
-        restarted,
-        restartRequired: !restarted,
+        restarted: false,
+        restartRequired: false,
       })
     })
   })

+ 0 - 4
src/server/updateManager.ts

@@ -49,8 +49,6 @@ export interface UpdateJob {
 interface UpdatePlan {
   source: string
   releaseRoot: string
-  dataHome: string
-  hermesHome: string
   previousServiceRoot: string
   target?: ReleaseManifest
   targetCommit?: string
@@ -333,8 +331,6 @@ export class SystemUpdateManager {
       plan: {
         source: this.releaseSource,
         releaseRoot: this.releaseRoot,
-        dataHome: this.config.home,
-        hermesHome: resolve(process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')),
         previousServiceRoot: this.projectRoot,
         target,
         targetCommit,

+ 3 - 0
tests/client/systemUpdate.test.ts

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
   applySystemUpdate,
   checkSystemUpdate,
+  reconcileYaoyaoPlugin,
   rollbackSystemUpdate,
   systemUpdateJob,
   systemUpdateStatus,
@@ -32,6 +33,7 @@ describe('system update client protocol', () => {
     await applySystemUpdate('0.3.0')
     await systemUpdateJob('11111111-1111-4111-8111-111111111111')
     await rollbackSystemUpdate()
+    await reconcileYaoyaoPlugin()
 
     expect(calls.map(call => call.path)).toEqual([
       '/api/app/system/update/status',
@@ -39,6 +41,7 @@ describe('system update client protocol', () => {
       '/api/app/system/update/apply',
       '/api/app/system/update/jobs/11111111-1111-4111-8111-111111111111',
       '/api/app/system/update/rollback',
+      '/api/app/plugins/yaoyao/reconcile',
     ])
     expect(new Headers(calls[1]!.init?.headers).get('X-CSRF-Token')).toBe('csrf-system-update')
     expect(calls[2]!.init?.body).toBe(JSON.stringify({ targetVersion: '0.3.0' }))

+ 53 - 5
tests/server/app.test.ts

@@ -116,7 +116,7 @@ describe('8800 BFF', () => {
     expect(restarted.csrf.verify(cookies, bootstrap.body.csrfToken)).toBe(true)
   })
 
-  it('installs a fresh Yaoyao plugin through authenticated Hermes and restarts supervised 9119', async () => {
+  it('installs a fresh Yaoyao plugin through authenticated 9119 without restarting it', async () => {
     const records: RecordedRequest[] = []
     const baseGateway = fakeGateway(records)
     const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
@@ -135,11 +135,9 @@ describe('8800 BFF', () => {
       }
       return baseGateway(input, init)
     }) as typeof fetch
-    let restarts = 0
     const runtime = createApplication({
       config: makeConfig(),
       fetchImpl,
-      restartDashboard: async () => { restarts += 1 },
     })
     runtimes.push(runtime)
     const bootstrap = await request(runtime.app.callback())
@@ -160,15 +158,65 @@ describe('8800 BFF', () => {
       force: false,
       enable: true,
     })
-    expect(restarts).toBe(1)
     expect(response.body).toMatchObject({
       ok: true,
       plugin_name: 'yaoyao',
-      restarted: true,
+      restarted: false,
       restartRequired: false,
     })
   })
 
+  it('automatically reconciles an older Yaoyao plugin through 9119', async () => {
+    const records: RecordedRequest[] = []
+    const baseGateway = fakeGateway(records)
+    let pluginVersion = '1.7.1'
+    const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+      const url = new URL(input instanceof Request ? input.url : String(input))
+      if (url.pathname === '/api/dashboard/plugins') {
+        return jsonResponse([{ name: 'yaoyao', version: pluginVersion }])
+      }
+      if (url.pathname === '/api/plugins/yaoyao/maintenance/storage') {
+        return jsonResponse({ ready: true })
+      }
+      if (url.pathname === '/api/dashboard/agent-plugins/install') {
+        const rawBody = typeof init?.body === 'string' ? init.body : '{}'
+        records.push({
+          path: url.pathname,
+          method: init?.method ?? 'GET',
+          search: url.searchParams,
+          headers: new Headers(init?.headers),
+          body: JSON.parse(rawBody),
+        })
+        pluginVersion = '1.7.2'
+        return jsonResponse({ ok: true, plugin_name: 'yaoyao', enabled: true })
+      }
+      return baseGateway(input, init)
+    }) as typeof fetch
+    const runtime = createApplication({ config: makeConfig(), fetchImpl })
+    runtimes.push(runtime)
+    const bootstrap = await request(runtime.app.callback())
+      .get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+
+    const response = await request(runtime.app.callback())
+      .post('/api/app/plugins/yaoyao/reconcile')
+      .set('Host', '127.0.0.1:8800')
+      .set('Origin', 'http://127.0.0.1:8800')
+      .set('Cookie', cookieHeader(bootstrap))
+      .set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({})
+      .expect(200)
+
+    expect(response.body).toMatchObject({
+      updated: true,
+      installedPluginVersion: '1.7.2',
+      expectedPluginVersion: '1.7.2',
+      restarted: false,
+      restartRequired: false,
+    })
+    expect(records.find(entry => entry.path === '/api/dashboard/agent-plugins/install')?.body)
+      .toMatchObject({ force: true, enable: true })
+  })
+
   it('refuses to replace an existing Yaoyao before durable storage is ready', async () => {
     const records: RecordedRequest[] = []
     const baseGateway = fakeGateway(records)

+ 6 - 1
tests/server/systemUpdateRoutes.test.ts

@@ -60,14 +60,19 @@ describe('system update routes', () => {
       launchUpdater: path => { launched.push(path) },
       platform: 'darwin',
     })
+    let pluginVersion = '1.7.1'
     const fetchImpl = (async (input: string | URL | Request) => {
       const path = new URL(input instanceof Request ? input.url : String(input)).pathname
       if (path === '/api/status') return json({ auth_required: true }, { headers: { 'set-cookie': 'hermes_session_at=session; Path=/; HttpOnly' } })
       if (path === '/api/auth/me') return json({ user_id: 'admin', display_name: '管理员' })
       if (path === '/api/profiles') return json({ profiles: [{ name: 'default', is_default: true }] })
       if (path === '/api/plugins/yaoyao/profiles') return json({ profiles: [] })
-      if (path === '/api/dashboard/plugins') return json([{ name: 'yaoyao', version: '1.7.1' }])
+      if (path === '/api/dashboard/plugins') return json([{ name: 'yaoyao', version: pluginVersion }])
       if (path === '/api/plugins/yaoyao/maintenance/storage') return json({ ready: true })
+      if (path === '/api/dashboard/agent-plugins/install') {
+        pluginVersion = '1.8.0'
+        return json({ ok: true, plugin_name: 'yaoyao', enabled: true })
+      }
       return json({ ok: true })
     }) as typeof fetch
     const runtime = createApplication({ config, fetchImpl, updates })

+ 2 - 38
tests/updater.test.ts

@@ -1,8 +1,8 @@
-import { lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { lstatSync, mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
-import { backupPlugin, currentTarget, enabledPlugins, formatCommandFailure, restorePlugin, switchCurrent, validateManifest } from '../bin/hermes-yaoyao-updater.mjs'
+import { currentTarget, formatCommandFailure, switchCurrent, validateManifest } from '../bin/hermes-yaoyao-updater.mjs'
 
 const roots: string[] = []
 afterEach(() => {
@@ -48,40 +48,4 @@ describe('standalone updater primitives', () => {
     expect(message).not.toContain('\u001B')
   })
 
-  it('requests Hermes plugin configuration as JSON', () => {
-    const calls: Array<{ command: string, args: string[] }> = []
-    const plugins = enabledPlugins((command: string, args: string[]) => {
-      calls.push({ command, args })
-      return '["yaoyao", "other"]\n'
-    })
-
-    expect(plugins).toEqual(['yaoyao', 'other'])
-    expect(calls).toEqual([{
-      command: 'hermes',
-      args: ['config', 'get', '--json', 'plugins.enabled'],
-    }])
-    expect(() => enabledPlugins(() => '- yaoyao\n')).toThrow('未返回有效 JSON')
-  })
-
-  it('backs up and restores plugin code without touching durable plugin data', () => {
-    const root = mkdtempSync(join(tmpdir(), 'hermes-updater-plugin-'))
-    roots.push(root)
-    const hermesHome = join(root, 'hermes')
-    const dataHome = join(root, 'web-data')
-    const dashboard = join(hermesHome, 'plugins', 'yaoyao', 'dashboard')
-    const durable = join(hermesHome, 'plugin-data', 'yaoyao')
-    mkdirSync(dashboard, { recursive: true })
-    mkdirSync(durable, { recursive: true })
-    writeFileSync(join(dashboard, 'manifest.json'), JSON.stringify({ version: '1.7.1' }))
-    writeFileSync(join(dashboard, 'plugin.py'), 'old plugin')
-    writeFileSync(join(hermesHome, 'plugins', 'yaoyao', 'plugin.yaml'), 'version: "1.7.1"\n')
-    writeFileSync(join(durable, 'archive.db'), 'durable data')
-
-    const backup = backupPlugin({ hermesHome, dataHome }, '11111111-1111-4111-8111-111111111111')
-    writeFileSync(join(dashboard, 'plugin.py'), 'new plugin')
-    restorePlugin(backup.pluginRoot, backup.backupRoot, 'restore', true)
-
-    expect(readFileSync(join(dashboard, 'plugin.py'), 'utf8')).toBe('old plugin')
-    expect(readFileSync(join(durable, 'archive.db'), 'utf8')).toBe('durable data')
-  })
 })