Browse Source

实现 App 首次启动同步本机 Web 并保护升级回退数据

LSamien 1 month ago
parent
commit
5725e9081b

+ 1 - 0
.gitignore

@@ -25,3 +25,4 @@ hermes-plugins/**/*.py[cod]
 hermes-plugins/**/.pytest_cache/
 hermes-plugins/**/data/
 .runner-build/
+build-info.json

+ 33 - 0
bin/desktop-service-sync.mjs

@@ -0,0 +1,33 @@
+import { join, resolve } from 'node:path'
+import { homedir } from 'node:os'
+import { createHash } from 'node:crypto'
+import { mkdirSync } from 'node:fs'
+import { LaunchAgentService, synchronizeDesktop } from './lib/service-update.mjs'
+
+// Losing the App's progress pipe must not interrupt a durable service transaction.
+process.stdout.on('error', error => { if (!['EPIPE', 'EIO'].includes(error.code)) process.exitCode = 1 })
+
+let input = ''
+for await (const bytes of process.stdin) { input += bytes; if (input.length > 32768) throw new Error('同步参数过大') }
+try {
+  const options = JSON.parse(input)
+  const home = resolve(options.home), port = Number(options.port)
+  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('本机服务端口无效')
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  // Fixture mode isolates *all* service/update state, including launchd labels.
+  const fixture = options.fixture === true
+  const releaseRoot = fixture ? join(home, 'updates', 'fixture-releases') : join(homedir(), '.local', 'share', 'hermes-yaoyao')
+  const label = fixture ? `cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0, 16)}` : 'com.samien.hermes-yaoyao'
+  const driver = new LaunchAgentService({ home, port, releaseRoot, label,
+    plistPath: fixture ? join(home, 'updates', 'fixture-service.plist') : undefined,
+    environment: options.environment,
+  })
+  const result = await synchronizeDesktop({ home, runtimeRoot: resolve(options.runtimeRoot), releaseRoot, driver,
+    onProgress: message => process.stdout.write(JSON.stringify({ message }) + '\n') })
+  const current = result.current
+  process.stdout.write(JSON.stringify({ result: { action: result.action, current: current && {
+    version: current.version, commit: current.commit, buildNumber: current.buildNumber, artifactDigest: current.artifactDigest,
+  } } }) + '\n')
+} catch (error) {
+  process.stdout.write(JSON.stringify({ error: error.message }) + '\n'); process.exitCode = 1
+}

+ 27 - 62
bin/hermes-yaoyao-updater.mjs

@@ -16,6 +16,9 @@ import { dirname, join, relative, resolve, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
 const label = 'com.samien.hermes-yaoyao'
+import { LaunchAgentService, updateMutex, recoverTransition, transitionService } from './lib/service-update.mjs'
+import { assertRollbackCompatible } from './lib/service-data.mjs'
+
 const uid = process.getuid?.() ?? 501
 const domain = `gui/${uid}`
 
@@ -249,76 +252,36 @@ function stageRelease(job, jobPath) {
   return { finalRoot, commit }
 }
 
-async function runUpdate(jobPath, job) {
-  const { plan, target, id } = job
-  if (!target) fail('升级任务缺少目标发布清单')
-  const { finalRoot, commit } = stageRelease(job, jobPath)
+function serviceOptions(jobPath, job) {
+  const home = job.plan.home || resolve(dirname(jobPath), '..')
+  const options = { home, releaseRoot: job.plan.releaseRoot, port: job.plan.port || Number(process.env.HERMES_YAOYAO_PORT || 15300) }
+  return { ...options, driver: new LaunchAgentService(options), onProgress: message => updateJob(jobPath, { message }) }
+}
 
-  const previousCurrentTarget = currentTarget(plan.releaseRoot)
-  let transitioned = false
-  try {
-    updateJob(jobPath, { state: 'installing', message: `正在切换 Web ${target.webVersion}` })
-    stopService()
-    transitioned = true
-    switchCurrent(plan.releaseRoot, finalRoot, id)
-
-    updateJob(jobPath, { state: 'restarting', message: '正在启动新版本服务' })
-    startService(join(plan.releaseRoot, 'current'), plan, true)
-    updateJob(jobPath, { state: 'verifying', message: '正在验证 Web 服务' })
-    await verifyRuntime()
-    writeJSON(join(dirname(jobPath), 'last-success.json'), {
-      jobId: id,
-      previousCurrentTarget,
-      previousServiceRoot: plan.previousServiceRoot,
-      target,
-      finalRoot,
-      commit,
-    })
-    updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${target.webVersion}` })
-  } catch (error) {
-    const message = error instanceof Error ? error.message : String(error)
-    if (transitioned) {
-      updateJob(jobPath, { state: 'rolling_back', message: '升级失败,正在自动恢复上一版本', error: message })
-      try {
-        stopService()
-        restorePreviousService({
-          plan,
-          previousCurrentTarget,
-          previousServiceRoot: plan.previousServiceRoot,
-          lifecycleRoot: finalRoot,
-          token: `${id}-rollback`,
-        })
-        await verifyRuntime()
-        updateJob(jobPath, { state: 'failed', message: '升级失败,已自动恢复上一版本', error: message })
-      } catch (rollbackError) {
-        const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError)
-        updateJob(jobPath, { state: 'failed', message: '升级和自动回滚均失败,需要人工处理', error: `${message}; 回滚:${rollbackMessage}` })
-      }
-    } else {
-      updateJob(jobPath, { state: 'failed', message: '升级准备失败,现有服务未被修改', error: message })
-    }
-  }
+async function runUpdate(jobPath, job) {
+  const options = serviceOptions(jobPath, job)
+  await recoverTransition(options)
+  const { finalRoot } = stageRelease(job, jobPath)
+  updateJob(jobPath, { state: 'installing', message: '正在准备切换 Web 服务' })
+  await transitionService({ ...options, finalRoot })
+  updateJob(jobPath, { state: 'succeeded', message: `已升级 Web ${job.target.webVersion}` })
 }
 
 async function runRollback(jobPath, job) {
-  const { plan, id } = job
+  const options = serviceOptions(jobPath, job)
+  await recoverTransition(options)
   const recordPath = join(dirname(jobPath), 'last-success.json')
   if (!existsSync(recordPath)) fail('没有可回滚的上一版本')
   const record = readJSON(recordPath)
-  const lifecycleRoot = currentTarget(plan.releaseRoot)
-  if (!lifecycleRoot) fail('当前发布版本不可用,无法安全执行回滚')
+  // Manual rollback never restores an older snapshot over data written since
+  // the successful upgrade. Schema-changing releases need a separate recovery.
+  assertRollbackCompatible(options.home, record)
+  const finalRoot = record.previousCurrentTarget || record.previousServiceRoot
+  if (!finalRoot || !existsSync(finalRoot)) fail('上一版本的程序目录已不可用')
   updateJob(jobPath, { state: 'rolling_back', message: '正在恢复上一版本' })
-  stopService()
-  restorePreviousService({
-    plan,
-    previousCurrentTarget: record.previousCurrentTarget,
-    previousServiceRoot: record.previousServiceRoot,
-    lifecycleRoot,
-    token: id,
-  })
-  await verifyRuntime()
-  rmSync(recordPath, { force: true })
-  updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务' })
+  await transitionService({ ...options, finalRoot, rollback: true })
+  rmSync(recordPath)
+  updateJob(jobPath, { state: 'rolled_back', message: '已回滚 Web 服务,保留现有数据' })
 }
 
 async function main() {
@@ -326,6 +289,7 @@ async function main() {
   if (command !== 'run' || flag !== '--job' || !jobPathValue) fail('用法:hermes-yaoyao-updater run --job <path>')
   const jobPath = resolve(jobPathValue)
   const job = readJSON(jobPath)
+  const unlock = updateMutex(job.plan.home || resolve(dirname(jobPath), '..'))
   try {
     if (job.operation === 'update') await runUpdate(jobPath, job)
     else if (job.operation === 'rollback') await runRollback(jobPath, job)
@@ -344,6 +308,7 @@ async function main() {
       try { lockJobID = JSON.parse(raw).jobID } catch { /* legacy lock */ }
       if (lockJobID === job.id) rmSync(lockPath)
     } catch { /* already released */ }
+    unlock()
   }
 }
 

+ 78 - 0
bin/lib/runtime-release.mjs

@@ -0,0 +1,78 @@
+import { createHash } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'
+import { join, relative, resolve } from 'node:path'
+
+const digest = value => createHash('sha256').update(value).digest('hex')
+export const readJSON = path => JSON.parse(readFileSync(path, 'utf8'))
+export function sourceBuild(root) {
+  try {
+    const git = args => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
+    const ancestors = git(['rev-list', 'HEAD']).split('\n')
+    return { commit: ancestors[0], ancestors, buildNumber: ancestors.length, dirty: Boolean(git(['status', '--porcelain'])) }
+  } catch { return { commit: 'source-archive', ancestors: [], buildNumber: 0, dirty: true } }
+}
+export function readRuntime(root) {
+  const release = readJSON(join(root, 'release.json'))
+  if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(release.webVersion) || release.webVersion !== release.releaseVersion || release.gitTag !== `v${release.webVersion}`)
+    throw new Error('服务发布清单无效')
+  const build = existsSync(join(root, 'build-info.json')) ? readJSON(join(root, 'build-info.json')) : sourceBuild(root)
+  const manifest = existsSync(join(root, 'runtime-manifest.json')) ? readJSON(join(root, 'runtime-manifest.json')) : undefined
+  return { ...build, version: release.webVersion, release, hasBuildIdentity: Number.isSafeInteger(build.buildNumber) && existsSync(join(root, 'build-info.json')), artifactDigest: manifest?.artifactDigest }
+}
+export function compareVersions(a, b) {
+  const parts = value => { const split = value.indexOf('-'); return [value.slice(0, split < 0 ? undefined : split).split('.').map(Number), split < 0 ? undefined : value.slice(split + 1).split('.')] }
+  const [ac, as] = parts(a), [bc, bs] = parts(b)
+  for (let i = 0; i < 3; i++) if (ac[i] !== bc[i]) return ac[i] > bc[i] ? 1 : -1
+  if (!as || !bs) return as ? -1 : bs ? 1 : 0
+  for (let i = 0; i < Math.max(as.length, bs.length); i++) {
+    if (as[i] === bs[i]) continue
+    if (as[i] === undefined || bs[i] === undefined) return as[i] === undefined ? -1 : 1
+    const an = /^\d+$/.test(as[i]), bn = /^\d+$/.test(bs[i])
+    if (an && bn) return Number(as[i]) > Number(bs[i]) ? 1 : -1
+    if (an !== bn) return an ? -1 : 1
+    return as[i] > bs[i] ? 1 : -1
+  }
+  return 0
+}
+/** Commit hashes are identities, never sortable version numbers. Unknown branch
+ * ancestry must not turn an App install into an accidental downgrade. */
+export function syncDecision(target, current) {
+  if (!current) return 'install'
+  const version = compareVersions(target.version, current.version)
+  if (version) return version > 0 ? 'upgrade' : 'newer'
+  if (target.artifactDigest && target.artifactDigest === current.artifactDigest) return 'same'
+  if (target.commit === current.commit && !target.dirty && !current.dirty) return 'same'
+  if (target.ancestors?.includes(current.commit) && target.commit !== current.commit) return 'upgrade'
+  if (current.ancestors?.includes(target.commit) && target.commit !== current.commit) return 'newer'
+  return 'unknown'
+}
+export function runtimeFiles(root, prefix = '') {
+  const files = {}
+  for (const name of readdirSync(join(root, prefix)).sort()) {
+    const path = join(prefix, name), stat = lstatSync(join(root, path))
+    if (path === 'runtime-manifest.json') continue
+    if (stat.isSymbolicLink()) throw new Error('服务包不得包含符号链接')
+    if (stat.isDirectory()) Object.assign(files, runtimeFiles(root, path))
+    else if (stat.isFile()) files[path] = digest(readFileSync(join(root, path)))
+    else throw new Error('服务包包含不支持的文件')
+  }
+  return files
+}
+export function sealRuntime(root) {
+  const files = runtimeFiles(root), artifactDigest = digest(JSON.stringify(files))
+  writeFileSync(join(root, 'runtime-manifest.json'), JSON.stringify({ schemaVersion: 1, artifactDigest, files }, null, 2) + '\n')
+  return artifactDigest
+}
+export function verifyRuntimePackage(root) {
+  const manifest = readJSON(join(root, 'runtime-manifest.json')), files = runtimeFiles(root)
+  if (manifest.schemaVersion !== 1 || digest(JSON.stringify(files)) !== manifest.artifactDigest || JSON.stringify(files) !== JSON.stringify(manifest.files))
+    throw new Error('App 配套服务包校验失败,请重新安装完整 App')
+  for (const file of ['node', 'server.mjs', 'ui/index.html', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs', 'build-info.json'])
+    if (!files[file]) throw new Error(`App 配套服务包缺少 ${file}`)
+  return readRuntime(root)
+}
+export function inside(path, root) {
+  const result = relative(realpathSync(root), resolve(path))
+  return result !== '' && result !== '..' && !result.startsWith('../') && !result.startsWith('/')
+}

+ 57 - 0
bin/lib/service-data.mjs

@@ -0,0 +1,57 @@
+import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { createHash } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+
+// These are process/desktop state, not Web application data. Runner databases
+// have their own owner and must never be restored by a Web update.
+const excluded = name => ['updates', 'desktop', 'runner-state'].includes(name) || name.startsWith('service-instance')
+export function databaseSchema(home) {
+  const result = {}
+  for (const name of readdirSync(home).sort()) {
+    if (excluded(name) || !/\.(?:sqlite3?|db)$/.test(name) || lstatSync(join(home, name)).isSymbolicLink()) continue
+    const db = new DatabaseSync(join(home, name), { readOnly: true })
+    try {
+      result[name] = { schema: db.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").all(), version: db.prepare('PRAGMA user_version').get() }
+    } finally { db.close() }
+  }
+  return createHash('sha256').update(JSON.stringify(result)).digest('hex')
+}
+export function assertRollbackCompatible(home, record) {
+  if (!record.beforeSchema || record.beforeSchema !== record.afterSchema || databaseSchema(home) !== record.afterSchema)
+    throw new Error('数据库结构已变化或缺少兼容性记录,不能直接降级;请使用独立的数据恢复流程')
+}
+export function backupData(home, directory) {
+  mkdirSync(directory, { recursive: true, mode: 0o700 })
+  const names = readdirSync(home).filter(name => !excluded(name))
+  for (const name of names) cpSync(join(home, name), join(directory, name), { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true })
+  const schema = databaseSchema(home)
+  writeFileSync(join(directory, '.snapshot.json'), JSON.stringify({ names, schema }), { mode: 0o600 })
+  return schema
+}
+export function restoreData(home, directory) {
+  const { names } = JSON.parse(readFileSync(join(directory, '.snapshot.json'), 'utf8'))
+  if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name.includes('/') || name === '..' || excluded(name))) throw new Error('数据备份清单无效')
+  for (const name of readdirSync(home)) if (!excluded(name)) rmSync(join(home, name), { recursive: true, force: true })
+  for (const name of names) cpSync(join(directory, name), join(home, name), { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true })
+}
+/** Conservative admission for pre-maintenance releases. No cache means an
+ * existing ordinary-chat workload cannot be proven idle. */
+export function legacyDataIdle(home) {
+  const workspace = join(home, 'workspace.sqlite3')
+  if (existsSync(workspace)) {
+    const db = new DatabaseSync(workspace, { readOnly: true })
+    try {
+      if (db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()) return false
+    } finally { db.close() }
+  }
+  const cache = join(home, 'chat-cache.sqlite3')
+  if (!existsSync(cache)) return !existsSync(workspace)
+  const db = new DatabaseSync(cache, { readOnly: true })
+  try {
+    // Compare event sequence within each session, never across sessions.
+    return !db.prepare(`SELECT 1 FROM chat_events e WHERE e.event_type IN ('message.start','message.delta','approval.request','clarify.request','command:prompt.submit','command:session.steer')
+      AND NOT EXISTS(SELECT 1 FROM chat_events done WHERE done.owner=e.owner AND done.profile=e.profile AND done.session_id=e.session_id
+        AND done.event_type='message.complete' AND done.received_at>=e.received_at) LIMIT 1`).get()
+  } finally { db.close() }
+}

+ 313 - 0
bin/lib/service-update.mjs

@@ -0,0 +1,313 @@
+import { execFileSync } from 'node:child_process'
+import { randomUUID, createHash } from 'node:crypto'
+import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join, relative, resolve } from 'node:path'
+import { request as httpRequest } from 'node:http'
+import { request as httpsRequest } from 'node:https'
+import { DatabaseSync } from 'node:sqlite'
+import { readJSON, readRuntime, syncDecision, verifyRuntimePackage } from './runtime-release.mjs'
+import { backupData, databaseSchema, legacyDataIdle, restoreData } from './service-data.mjs'
+
+const sleep = ms => new Promise(done => setTimeout(done, ms))
+export const alive = pid => { try { process.kill(pid, 0); return true } catch (error) { return error.code !== 'ESRCH' } }
+export function writeJSON(path, value) {
+  mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
+  const temporary = `${path}.${randomUUID()}`
+  writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(temporary, path)
+}
+export function updateMutex(home) {
+  mkdirSync(join(home, 'updates'), { recursive: true, mode: 0o700 })
+  const db = new DatabaseSync(join(home, 'updates', 'mutex.sqlite3'))
+  chmodSync(join(home, 'updates', 'mutex.sqlite3'), 0o600)
+  try { db.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
+  catch { db.close(); throw new Error('已有系统更新正在执行,请稍后重试') }
+  return () => db.close()
+}
+export function reserveUpdate(home, jobID) {
+  const path = join(home, 'updates', 'active.lock')
+  if (existsSync(path)) {
+    const lock = readJSON(path)
+    if (Number.isSafeInteger(lock.pid) && alive(lock.pid)) throw new Error('已有系统更新正在执行,请稍后重试')
+    // Called only while the common SQLite update mutex is held.
+    rmSync(path)
+  }
+  writeFileSync(path, JSON.stringify({ jobID, pid: process.pid }), { flag: 'wx', mode: 0o600 })
+  return () => { if (existsSync(path) && readJSON(path).jobID === jobID) rmSync(path) }
+}
+export function localRequest(url, token, method = 'GET') {
+  const target = new URL(url)
+  if (!['http:', 'https:'].includes(target.protocol) || !['127.0.0.1', '[::1]', 'localhost'].includes(target.hostname) || target.username || target.password)
+    throw new Error('升级只支持已核对的本机服务')
+  return new Promise((done, reject) => {
+    const req = (target.protocol === 'https:' ? httpsRequest : httpRequest)(target, {
+      method, agent: false, headers: token ? { 'x-yaoyao-desktop-token': token } : {},
+    }, res => {
+      let data = ''
+      res.on('data', bytes => { data += bytes; if (data.length > 128 * 1024) req.destroy(new Error('服务响应过大')) })
+      res.on('end', () => { try { done({ status: res.statusCode, body: JSON.parse(data) }) } catch { reject(new Error('本地服务响应无效')) } })
+    })
+    req.on('error', reject); req.setTimeout(3000, () => req.destroy(new Error('本地服务请求超时'))); req.end()
+  })
+}
+function command(bin, args, input) {
+  try { return execFileSync(bin, args, { encoding: 'utf8', input, timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }) }
+  catch { throw new Error(`${bin.split('/').at(-1)} ${args[0]} 未完成`) }
+}
+export function currentRelease(releaseRoot) {
+  const path = join(releaseRoot, 'current')
+  if (!existsSync(path)) return undefined
+  if (!lstatSync(path).isSymbolicLink()) throw new Error('发布目录 current 必须是符号链接')
+  return realpathSync(path)
+}
+export function switchRelease(releaseRoot, target) {
+  mkdirSync(releaseRoot, { recursive: true, mode: 0o700 })
+  releaseRoot = realpathSync(releaseRoot)
+  if (target) target = realpathSync(target)
+  const path = join(releaseRoot, 'current')
+  if (existsSync(path) && !lstatSync(path).isSymbolicLink()) throw new Error('拒绝替换非符号链接发布目录')
+  if (!target) { rmSync(path, { force: true }); return }
+  const temp = join(releaseRoot, `.current-${randomUUID()}`)
+  symlinkSync(relative(releaseRoot, target), temp); renameSync(temp, path)
+}
+export class LaunchAgentService {
+  constructor({ home, port = 15300, releaseRoot, label = 'com.samien.hermes-yaoyao', plistPath, environment = {} }) {
+    this.home = realpathSync(home); this.port = port; this.releaseRoot = resolve(releaseRoot)
+    this.label = label; this.domain = `gui/${process.getuid()}`
+    this.plistPath = plistPath ?? join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`)
+    this.environment = environment
+  }
+  snapshot() {
+    if (!existsSync(this.plistPath)) {
+      // Never install a second service over an unregistered listener or owner.
+      if (this.listeners().size || this.record()) throw new Error('已有服务未注册为此 App 的本机后台服务,暂不接管')
+      return undefined
+    }
+    const plist = JSON.parse(command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', this.plistPath]))
+    const env = plist.EnvironmentVariables ?? {}, root = realpathSync(plist.WorkingDirectory)
+    this.tls = Boolean(env.HERMES_YAOYAO_TLS_CERT)
+    if (plist.Label !== this.label || realpathSync(env.HERMES_YAOYAO_HOME ?? join(homedir(), '.hermes-yaoyao')) !== this.home || Number(env.HERMES_YAOYAO_PORT ?? 15300) !== this.port)
+      throw new Error('已有服务的数据目录或端口与 App 不一致,暂不接管')
+    const args = plist.ProgramArguments
+    if (!Array.isArray(args) || args.length !== 2 || !['server.mjs', 'dist-server/server/index.js'].some(entry => realpathSync(args[1]) === join(root, entry)))
+      throw new Error('已有服务的启动入口无法核对')
+    readRuntime(root)
+    const pid = this.pid(), listeners = this.listeners()
+    if (listeners.size && (listeners.size !== 1 || !listeners.has(pid))) throw new Error('端口由另一个进程占用,暂不接管')
+    if (pid) {
+      const holders = this.holders(join(this.home, 'workspace.sqlite3'))
+      if (!holders.has(pid)) throw new Error('无法确认现有服务的数据归属')
+    }
+    return { plist, root, wasRunning: Boolean(pid) }
+  }
+  pid() {
+    try { return Number(execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).match(/^\s*pid = (\d+)$/m)?.[1]) || undefined }
+    catch { return undefined }
+  }
+  holders(path) {
+    try { return new Set(execFileSync('/usr/sbin/lsof', ['-t', path], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
+    catch { return new Set() }
+  }
+  listeners(established = false) {
+    try { return new Set(execFileSync('/usr/sbin/lsof', ['-nP', `-iTCP:${this.port}`, '-sTCP:' + (established ? 'ESTABLISHED' : 'LISTEN'), '-t'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().split(/\s+/).map(Number)) }
+    catch { return new Set() }
+  }
+  record() {
+    try {
+      const value = readJSON(join(this.home, 'service-instance.json'))
+      return value.protocol === 1 && value.dataKey === createHash('sha256').update(this.home).digest('hex') && Number.isSafeInteger(value.pid) && alive(value.pid) ? value : undefined
+    } catch { return undefined }
+  }
+  url(record = this.record()) {
+    if (!record?.url) return `${this.tls ? 'https' : 'http'}://127.0.0.1:${this.port}`
+    const url = new URL(record.url)
+    if (Number(url.port) !== this.port) throw new Error('本机服务端口发生变化')
+    if (url.hostname === '0.0.0.0') url.hostname = '127.0.0.1'
+    return url.origin
+  }
+  async quiesce(onProgress, timeout = 60000) {
+    if (!this.pid()) return
+    const deadline = Date.now() + timeout
+    do {
+      const record = this.record()
+      if (record?.maintenanceProtocol === 1) {
+        if (record.pid !== this.pid()) throw new Error('服务进程归属发生变化')
+        const response = await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'POST')
+        if (response.status === 200 && response.body.quiesced === true) return
+        if (response.status !== 409) throw new Error('服务未接受更新准备请求')
+      } else if (legacyDataIdle(this.home) && !this.listeners(true).size) {
+        return
+      }
+      onProgress('正在等待聊天和后台任务结束后同步 Web…')
+      await sleep(1000)
+    } while (Date.now() < deadline)
+    throw new Error('当前仍有任务或旧版客户端连接,已延后同步;任务结束后可重试')
+  }
+  async resume() {
+    const record = this.record()
+    if (record?.maintenanceProtocol === 1) await localRequest(`${this.url(record)}/desktop/service/quiesce`, record.token, 'DELETE').catch(() => {})
+  }
+  async stop() {
+    const pid = this.pid()
+    try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); command('launchctl', ['bootout', `${this.domain}/${this.label}`]) }
+    catch (error) { if (this.pid()) throw error }
+    const deadline = Date.now() + 15000
+    while (pid && alive(pid) && Date.now() < deadline) await sleep(100)
+    if ((pid && alive(pid)) || this.listeners().size || this.holders(join(this.home, 'workspace.sqlite3')).size)
+      throw new Error('旧服务尚未完全停止,未进行版本切换')
+  }
+  writePlist(plist) {
+    mkdirSync(dirname(this.plistPath), { recursive: true })
+    const xml = command('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', '--', '-'], JSON.stringify(plist))
+    const temporary = `${this.plistPath}.${randomUUID()}`
+    writeFileSync(temporary, xml, { mode: 0o600 }); renameSync(temporary, this.plistPath)
+  }
+  async boot() {
+    let loaded = false
+    try { execFileSync('launchctl', ['print', `${this.domain}/${this.label}`], { stdio: 'ignore' }); loaded = true } catch { /* Not registered yet. */ }
+    if (loaded) { command('launchctl', ['kickstart', `${this.domain}/${this.label}`]); return }
+    const deadline = Date.now() + 20000
+    for (;;) {
+      try { command('launchctl', ['bootstrap', this.domain, this.plistPath]); return }
+      catch (error) { if (Date.now() >= deadline) throw error; await sleep(500) }
+    }
+  }
+  async start(root, previous) {
+    const stable = join(this.releaseRoot, 'current')
+    const node = existsSync(join(root, 'node')) ? join(root, 'node') : previous?.plist.ProgramArguments[0] ?? process.execPath
+    const plist = previous ? structuredClone(previous.plist) : {
+      Label: this.label, RunAtLoad: true, KeepAlive: true, ThrottleInterval: 10, ProcessType: 'Interactive', Umask: 63,
+      StandardOutPath: join(this.home, 'service.log'), StandardErrorPath: join(this.home, 'service.log'),
+      EnvironmentVariables: { PATH: `${dirname(node)}:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, HERMES_YAOYAO_HOST: '127.0.0.1', ...this.environment },
+    }
+    plist.ProgramArguments = [node, join(stable, existsSync(join(root, 'server.mjs')) ? 'server.mjs' : 'dist-server/server/index.js')]
+    plist.WorkingDirectory = stable
+    Object.assign(plist.EnvironmentVariables, { NODE_ENV: 'production', HERMES_YAOYAO_HOME: this.home, HERMES_YAOYAO_PORT: String(this.port),
+      HERMES_YAOYAO_SERVICE_ROOT: stable, HERMES_YAOYAO_RELEASE_ROOT: this.releaseRoot, HERMES_YAOYAO_DESKTOP: '0',
+      HERMES_YAOYAO_STATIC_DIR: join(stable, existsSync(join(root, 'ui')) ? 'ui' : 'dist') })
+    delete plist.EnvironmentVariables.ELECTRON_RUN_AS_NODE
+    this.writePlist(plist); await this.boot()
+  }
+  async restore(previous) {
+    if (!previous) { rmSync(this.plistPath, { force: true }); return }
+    this.writePlist(previous.plist)
+    if (previous.wasRunning && !this.pid()) await this.boot()
+  }
+  async verify(expected, timeout = 45000) {
+    const deadline = Date.now() + timeout
+    do {
+      try {
+        const record = this.record(), url = this.url(record)
+        if (record?.build && record.pid === this.pid()) {
+          const identity = await localRequest(`${url}/desktop/service`, record.token)
+          const actual = identity.body
+          if (identity.status !== 200 || actual.instanceId !== record.instanceId || actual.pid !== record.pid || actual.dataKey !== record.dataKey) throw new Error('实例不匹配')
+          if (expected && (actual.version !== expected.version || actual.build.commit !== expected.commit || (expected.artifactDigest && actual.build.artifactDigest !== expected.artifactDigest))) throw new Error('运行构建不匹配')
+        } else if (expected?.hasBuildIdentity || expected?.artifactDigest || !this.pid()) throw new Error('新构建尚未就绪')
+        const health = await localRequest(`${url}/healthz`)
+        const status = await localRequest(`${url}/api/status`)
+        if (health.status === 200 && health.body.ok && status.status === 200 && status.body.server_kind === 'yaoyao-web') return
+      } catch { /* Startup and launchd recovery can take a moment. */ }
+      await sleep(300)
+    } while (Date.now() < deadline)
+    throw new Error('新服务的构建、健康或接口核验失败')
+  }
+}
+
+export async function recoverTransition({ home, releaseRoot, driver, onProgress = () => {} }) {
+  const path = join(home, 'updates', 'transition.json')
+  if (!existsSync(path)) return
+  const journal = readJSON(path)
+  if (journal.home !== realpathSync(home) || journal.releaseRoot !== resolve(releaseRoot)) throw new Error('未完成更新的归属不一致')
+  onProgress('正在恢复上一次未完成的更新…')
+  if (journal.phase !== 'restored') {
+    await driver.stop()
+    if (journal.snapshot) restoreData(home, journal.snapshot)
+    switchRelease(releaseRoot, journal.previousCurrent)
+    const success = join(home, 'updates', 'last-success.json')
+    if (journal.previousSuccess) writeJSON(success, journal.previousSuccess)
+    else rmSync(success, { force: true })
+    journal.phase = 'restored'; writeJSON(path, journal)
+  }
+  await driver.restore(journal.previous)
+  if (journal.previous?.wasRunning) await driver.verify(undefined)
+  rmSync(path)
+  await driver.resume()
+}
+export async function transitionService({ home, releaseRoot, driver, finalRoot, onProgress = () => {}, rollback = false }) {
+  const previous = driver.snapshot(), previousCurrent = currentRelease(releaseRoot)
+  const path = join(home, 'updates', 'transition.json'), id = randomUUID()
+  const success = join(home, 'updates', 'last-success.json')
+  const journal = { id, home: realpathSync(home), releaseRoot: resolve(releaseRoot), previous, previousCurrent, finalRoot,
+    previousSuccess: existsSync(success) ? readJSON(success) : undefined }
+  const expected = readRuntime(finalRoot)
+  try {
+    if (previous?.wasRunning) await driver.verify(undefined, 5000)
+    await driver.quiesce(onProgress)
+    onProgress('正在备份数据并切换 Web 服务…')
+    // Persist before stopping; recovery never mistakes a partial backup for a complete snapshot.
+    writeJSON(path, journal)
+    await driver.stop()
+    journal.snapshot = join(home, 'updates', 'backups', id)
+    let beforeSchema
+    try { beforeSchema = backupData(home, journal.snapshot) }
+    catch (error) { rmSync(journal.snapshot, { recursive: true, force: true }); throw error }
+    writeJSON(path, journal)
+    switchRelease(releaseRoot, finalRoot)
+    await driver.start(finalRoot, previous)
+    onProgress('正在核对 Web 构建及服务状态…')
+    await driver.verify(expected)
+    const afterSchema = databaseSchema(home)
+    // The new process rejects application traffic until the durable journal is removed.
+    const result = { previousCurrentTarget: previousCurrent, previousServiceRoot: previous?.root, previous,
+      finalRoot, commit: expected.commit, target: expected.release, snapshot: journal.snapshot, beforeSchema, afterSchema }
+    if (!rollback) writeJSON(join(home, 'updates', 'last-success.json'), result)
+    rmSync(path)
+    await driver.resume()
+    return result
+  } catch (error) {
+    if (existsSync(path)) {
+      try { await recoverTransition({ home, releaseRoot, driver, onProgress }) }
+      catch (recovery) { throw new Error(`${error.message};恢复未完成:${recovery.message}`) }
+    } else await driver.resume()
+    throw error
+  }
+}
+
+export async function synchronizeDesktop({ home, runtimeRoot, releaseRoot, driver, onProgress = () => {} }) {
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const unlock = updateMutex(home), id = randomUUID()
+  let release
+  try {
+    release = reserveUpdate(home, id)
+    await recoverTransition({ home, releaseRoot, driver, onProgress })
+    onProgress('正在核对 App 配套 Web 服务…')
+    const target = verifyRuntimePackage(runtimeRoot)
+    const marker = join(home, 'updates', 'desktop-sync.json')
+    const previous = driver.snapshot(), current = previous ? readRuntime(previous.root) : undefined
+    if (existsSync(marker) && readJSON(marker).artifactDigest === target.artifactDigest && previous) {
+      if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
+      await driver.verify(undefined)
+      return { action: 'already-synced', current }
+    }
+    const decision = syncDecision(target, current)
+    if (decision === 'unknown') throw new Error('App 与 Web 的构建先后关系无法确定,已保留当前服务;请使用明确的新发布版本')
+    if (decision === 'same' || decision === 'newer') {
+      if (!previous.wasRunning) { driver.writePlist(previous.plist); await driver.boot() }
+      await driver.verify(undefined)
+      writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
+      return { action: decision, current }
+    }
+    const finalRoot = join(releaseRoot, 'releases', `${target.version}-${target.artifactDigest.slice(0, 16)}`)
+    if (!existsSync(finalRoot)) {
+      const staging = join(releaseRoot, 'releases', `.staging-${id}`)
+      mkdirSync(dirname(staging), { recursive: true, mode: 0o700 })
+      try { cpSync(runtimeRoot, staging, { recursive: true }); verifyRuntimePackage(staging); chmodSync(join(staging, 'node'), 0o755); renameSync(staging, finalRoot) }
+      finally { rmSync(staging, { recursive: true, force: true }) }
+    }
+    verifyRuntimePackage(finalRoot)
+    await transitionService({ home, releaseRoot, driver, finalRoot, onProgress })
+    writeJSON(marker, { artifactDigest: target.artifactDigest, action: decision, completedAt: new Date().toISOString() })
+    return { action: decision, current: target }
+  } finally { release?.(); unlock() }
+}

+ 6 - 1
desktop/main.mjs

@@ -7,6 +7,7 @@ import { DesktopServiceManager } from './service-manager.mjs'
 import { DesktopRunnerManager } from './runner-manager.mjs'
 import { DesktopPreferences } from './preferences.mjs'
 import { DesktopCredentials } from './credentials.mjs'
+import { synchronizeLocalService } from './service-sync.mjs'
 
 const desktopRoot = dirname(fileURLToPath(import.meta.url))
 const fixtureHome = process.env.HERMES_YAOYAO_DESKTOP_TEST_HOME
@@ -120,6 +121,10 @@ else {
     const buildInfo=JSON.parse(readFileSync(join(root,'build-info.json'),'utf8'))
     app.setAboutPanelOptions({applicationName:'夭夭',applicationVersion:packageVersion,version:`${String(buildInfo.commit).slice(0,12)}${buildInfo.dirty?' · 工作区快照':''}`})
     manager = new DesktopServiceManager({ home, port, version: packageVersion, log, onState: stateChanged,
+      synchronize: (app.isPackaged && !fixtureHome) || (fixtureHome && process.env.HERMES_YAOYAO_DESKTOP_TEST_SYNC === '1')
+        ? onProgress => synchronizeLocalService({ home, port, root, fixture: Boolean(fixtureHome), onProgress,
+          environment: { HERMES_YAOYAO_UPSTREAM: process.env.HERMES_YAOYAO_UPSTREAM || 'http://127.0.0.1:9119', HERMES_YAOYAO_SUPERVISE_DASHBOARD: fixtureHome ? '0' : '1' } })
+        : undefined,
       fork: ({ home, port }) => {
         const env = { ...process.env, NODE_ENV: 'production', NODE_USE_ENV_PROXY: '0',
           PATH: [join(homedir(), '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin', process.env.PATH || '/usr/bin:/bin'].join(':'),
@@ -154,7 +159,7 @@ else {
           click:async item=>{try{app.setLoginItemSettings({openAtLogin:item.checked});item.checked=app.getLoginItemSettings().openAtLogin}catch(error){item.checked=app.getLoginItemSettings().openAtLogin;await dialog.showMessageBox(window,{type:'error',message:'无法修改登录启动设置',detail:error.message})}} },
         { id:'desktop-background', label:'登录启动时仅驻留菜单栏', type:'checkbox', checked:preferences.value.backgroundAtLogin,
           click:async item=>{try{await preferences.setBackgroundAtLogin(item.checked)}catch(error){item.checked=preferences.value.backgroundAtLogin;await dialog.showMessageBox(window,{type:'error',message:error.message})}} },
-        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`当前服务:${manager.state.version||'尚未就绪'}${manager.state.external?'(已有外部服务)':'(App 托管)'}\n\n更新:先停止任务并退出 App,备份数据目录,保留旧 App,再用经过签名验证的新 App 替换。新 App 打开后核对服务版本及任务状态。\n\n回退:退出新 App,恢复旧 App;若版本包含数据迁移,同时恢复更新前的数据备份。App 不会自动替换已有外部服务。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
+        { id:'desktop-update-help', label:'App 更新与回退…', click:()=>dialog.showMessageBox(window,{type:'info',message:`夭夭 App ${packageVersion}`,detail:`App 构建:${String(buildInfo.commit).slice(0,12)}\n当前 Web:${manager.state.version||'尚未就绪'}${manager.state.build?.commit?' · '+manager.state.build.commit.slice(0,12):''}${manager.state.external?'(独立后台服务)':'(开发模式)'}\n\n安装或更新 App 后首次启动,会将较旧的本机 Web 同步到配套构建。已有更新的 Web 会保留;同一个 App 构建不会重复同步。\n\nWeb 可以继续自行升级。独立 Web 服务在退出 App 后继续运行;App 管理的执行节点会停止。\n\n降级通过 Web 的回滚入口单独处理。数据库结构不兼容时,需要独立的数据恢复流程。\n\n数据目录:${home}`,buttons:['知道了','打开数据目录']}).then(result=>{if(result.response===1)shell.showItemInFolder(home)}) },
         { type: 'separator' },
         { role: 'hide', label: '隐藏夭夭' }, { role: 'hideOthers', label: '隐藏其他' }, { role: 'unhide', label: '显示全部' },
         { type: 'separator' }, { role: 'quit', label: '退出夭夭' }] },

+ 13 - 5
desktop/service-manager.mjs

@@ -97,6 +97,10 @@ export class DesktopServiceManager {
     try {
       await mkdir(this.options.home, { recursive: true, mode: 0o700 })
       this.root = await realpath(this.options.home)
+      if (this.options.synchronize) {
+        await this.options.synchronize(message => this.publish({ phase: 'starting', message }))
+        if (generation !== this.generation || this.stopping) return
+      }
       let record = await this.readRecord()
       if (generation !== this.generation || this.stopping) return
       if (record?.url) {
@@ -109,6 +113,7 @@ export class DesktopServiceManager {
       if (generation !== this.generation || this.stopping) return
       if (legacy) { this.publish({ phase: 'ready', message: '已连接已有后台服务', ...legacy }); return this.state }
       if (!record) {
+        if (this.options.synchronize) throw new Error('独立后台服务尚未就绪,请稍后重试')
         const child = this.options.fork({ home: this.root, port: this.options.port })
         child.desktopExited = false
         this.child = child
@@ -150,13 +155,13 @@ export class DesktopServiceManager {
   scheduleRestart(generation) {
     const now = Date.now()
     this.restarts = this.restarts.filter(time => now - time < 5 * 60_000)
-    if (this.restarts.length >= 3) { this.publish({ phase: 'error', message: '服务多次退出,已停止自动重启。请查看日志后重试。' }); return }
+    if (this.restarts.length >= (this.options.synchronize ? 12 : 3)) { this.publish({ phase: 'error', message: '服务多次断开,请查看日志后重试。' }); return }
     this.restarts.push(now)
     this.publish({ phase: 'restarting', message: '服务已断开,正在恢复…' })
     this.restartTimer = setTimeout(() => {
       if (this.stopping || generation !== this.generation) return
-      void this.start().catch(() => {})
-    }, 500 * this.restarts.length)
+      void this.start().catch(() => { if (this.options.synchronize && !this.stopping) this.scheduleRestart(this.generation) })
+    }, this.options.synchronize ? 5000 : 500 * this.restarts.length)
   }
   async check() {
     if (this.state.phase !== 'ready') return
@@ -168,11 +173,14 @@ export class DesktopServiceManager {
       } else {
         const record = await this.readRecord()
         if (!record) throw new Error('服务已停止')
-        await this.verify(record)
+        const service = await this.verify(record)
+        if (service.pid !== this.state.pid || service.version !== this.state.version || service.build?.commit !== this.state.build?.commit)
+          this.publish({ phase: 'ready', message: `已连接后台服务(${service.version})`, ...service, external: !this.child })
       }
     } catch {
       if (this.stopping || generation !== this.generation) return
-      this.publish({ phase: 'error', message: '本地服务连接已断开,请重试。' })
+      if (this.state.external) this.scheduleRestart(generation)
+      else this.publish({ phase: 'error', message: '本地服务连接已断开,请重试。' })
     }
   }
   async stopChild() {

+ 29 - 0
desktop/service-sync.mjs

@@ -0,0 +1,29 @@
+import { spawn } from 'node:child_process'
+import { join } from 'node:path'
+
+/** A native helper owns the update until completion, even if the window closes.
+ * The renderer never receives its configuration or a process-control bridge. */
+export function synchronizeLocalService({ home, port, root, fixture, environment, onProgress }) {
+  return new Promise((done, reject) => {
+    const runtimeRoot = join(root, 'web-service')
+    const helper = spawn(join(runtimeRoot, 'node'), [join(runtimeRoot, 'bin', 'desktop-service-sync.mjs')], {
+      cwd: runtimeRoot, detached: true, stdio: ['pipe', 'pipe', 'ignore'],
+      env: { HOME: process.env.HOME, PATH: process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin', NODE_USE_ENV_PROXY: '0' },
+    })
+    let buffer = '', result, failure
+    helper.stdout.on('data', bytes => {
+      buffer += bytes
+      if (buffer.length > 256 * 1024) { failure = '同步进程输出异常'; return }
+      for (;;) {
+        const index = buffer.indexOf('\n'); if (index < 0) break
+        const line = buffer.slice(0, index); buffer = buffer.slice(index + 1)
+        try { const value = JSON.parse(line); if (value.message) onProgress(value.message); if (value.result) result = value.result; if (value.error) failure = value.error }
+        catch { failure = '同步进程响应无效' }
+      }
+    })
+    helper.once('error', reject)
+    helper.once('exit', code => code === 0 && result ? done(result) : reject(new Error(failure || '本机 Web 同步未完成,请重试')))
+    helper.stdin.on('error', () => {})
+    helper.stdin.end(JSON.stringify({ home, port, runtimeRoot, fixture, environment }))
+  })
+}

+ 109 - 0
desktop/service-sync.test.mjs

@@ -0,0 +1,109 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, writeFile, rm, cp, realpath } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { createServer } from 'node:net'
+import { spawn } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { _electron as electron } from '@playwright/test'
+import { LaunchAgentService, localRequest, synchronizeDesktop } from '../bin/lib/service-update.mjs'
+import { sealRuntime, verifyRuntimePackage } from '../bin/lib/runtime-release.mjs'
+
+const root = resolve(import.meta.dirname, '..')
+const payloadRoot = process.env.DESKTOP_TEST_EXECUTABLE
+  ? resolve(dirname(process.env.DESKTOP_TEST_EXECUTABLE), '../Resources/runtime/web-service')
+  : join(root, '.desktop-build', 'web-service')
+async function freePort() {
+  const server = createServer(); await new Promise(done => server.listen(0, '127.0.0.1', done))
+  const port = server.address().port; await new Promise(done => server.close(done)); return port
+}
+async function until(read, accept, timeout = 60000) {
+  const deadline = Date.now() + timeout
+  while (Date.now() < deadline) {
+    try { const value = await read(); if (accept(value)) return value } catch {}
+    await new Promise(done => setTimeout(done, 200))
+  }
+  throw new Error('等待同步服务状态超时')
+}
+
+test('App first launch installs an independent Web; Web remains up after quit and a newer Web is retained', { timeout: 150000 }, async () => {
+  const home = await realpath(await mkdtemp(join(tmpdir(), 'yaoyao-sync-native-')))
+  const port = await freePort(), upstreamPort = await freePort()
+  const releaseRoot = join(home, 'updates', 'fixture-releases')
+  const label = `cn.samien.yaoyao.sync-test.${createHash('sha256').update(home).digest('hex').slice(0, 16)}`
+  const driver = new LaunchAgentService({ home, port, releaseRoot, label, plistPath: join(home, 'updates', 'fixture-service.plist') })
+  const upstream = spawn(process.execPath, [join(root, 'tests/fixtures/fake-hermes.mjs')], {
+    cwd: root, env: { ...process.env, FAKE_HERMES_PORT: String(upstreamPort), FAKE_HERMES_LOCAL_AUTH: '1' }, stdio: 'ignore',
+  })
+  let app
+  const launch = () => electron.launch({
+    ...(process.env.DESKTOP_TEST_EXECUTABLE ? { executablePath: process.env.DESKTOP_TEST_EXECUTABLE } : {}),
+    args: process.env.DESKTOP_TEST_EXECUTABLE ? [] : [root], cwd: root,
+    env: { ...process.env, HERMES_YAOYAO_DESKTOP_TEST_HOME: home, HERMES_YAOYAO_DESKTOP_PORT: String(port),
+      HERMES_YAOYAO_DESKTOP_TEST_SYNC: '1', HERMES_YAOYAO_UPSTREAM: `http://127.0.0.1:${upstreamPort}` }, timeout: 30000,
+  })
+  try {
+    await until(() => localRequest(`http://127.0.0.1:${upstreamPort}/api/status`), value => value.status === 200)
+    app = await launch()
+    const page = await app.firstWindow()
+    await page.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
+    const record = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
+    assert.equal(record.desktopOwned, false)
+    const identity = await localRequest(`http://127.0.0.1:${port}/desktop/service`, record.token)
+    const original = verifyRuntimePackage(payloadRoot)
+    assert.equal(identity.body.build.artifactDigest, original.artifactDigest)
+    assert.equal(identity.body.quiesced, false)
+    const support = await page.evaluate(async () => {
+      const bootstrap = await (await fetch('/api/app/bootstrap')).json()
+      const setup = await fetch('/api/app/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': bootstrap.csrfToken },
+        body: JSON.stringify({ username: 'sync-fixture', password: 'sync-fixture-password' }) })
+      if (!setup.ok) throw new Error('验收账号创建失败')
+      return (await fetch('/api/app/system/update/status')).json()
+    })
+    assert.equal(support.installationMode, 'release'); assert.equal(support.supported, true)
+    await app.close(); app = undefined
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok, true)
+    assert.equal(driver.pid(), record.pid, 'quitting the App must leave the independent Web running')
+    const saved = driver.snapshot().plist
+    saved.EnvironmentVariables.YAOYAO_TEST_PRESERVE = 'keep-existing-settings'
+    driver.writePlist(saved)
+
+    // Install a later release through the same transaction engine used by Web's updater.
+    const newer = join(home, 'updates', 'newer-package')
+    await cp(payloadRoot, newer, { recursive: true })
+    await writeFile(join(newer, 'release.json'), JSON.stringify({ schemaVersion: 1, releaseVersion: '0.3.33', webVersion: '0.3.33', gitTag: 'v0.3.33' }))
+    const build = JSON.parse(await readFile(join(newer, 'build-info.json'), 'utf8'))
+    await writeFile(join(newer, 'build-info.json'), JSON.stringify({ ...build, commit: 'c'.repeat(40), ancestors: [build.commit, ...build.ancestors], dirty: false }))
+    sealRuntime(newer)
+    await synchronizeDesktop({ home, runtimeRoot: newer, releaseRoot, driver })
+    const updated = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
+    assert.equal(updated.version, '0.3.33'); assert.notEqual(updated.pid, record.pid)
+    assert.equal(driver.snapshot().plist.EnvironmentVariables.YAOYAO_TEST_PRESERVE, 'keep-existing-settings')
+    app = await launch()
+    const reopened = await app.firstWindow(); await reopened.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
+    assert.equal(driver.pid(), updated.pid, 'older App must retain the newer Web without restarting it')
+    const marker = JSON.parse(await readFile(join(home, 'updates', 'desktop-sync.json'), 'utf8'))
+    assert.equal(marker.action, 'newer')
+    assert.equal(marker.artifactDigest, original.artifactDigest)
+    await app.close(); app = undefined
+    assert.equal((await localRequest(`http://127.0.0.1:${port}/healthz`)).body.ok, true)
+    await driver.stop()
+    app = await launch()
+    const restarted = await app.firstWindow(); await restarted.waitForURL(`http://127.0.0.1:${port}/**`, { timeout: 90000 })
+    const resumed = JSON.parse(await readFile(join(home, 'service-instance.json'), 'utf8'))
+    assert.equal(resumed.version, '0.3.33'); assert.equal(resumed.desktopOwned, false)
+    assert.notEqual(resumed.pid, updated.pid)
+    await app.close(); app = undefined
+  } catch (error) {
+    // Only this fixture's server log, which contains startup diagnostics.
+    const log = await readFile(join(home, 'service.log'), 'utf8').catch(() => '')
+    if (log) process.stderr.write(log.slice(-3000))
+    throw error
+  } finally {
+    await app?.close().catch(() => {})
+    await driver.stop()
+    upstream.kill('SIGTERM')
+    await rm(home, { recursive: true, force: true })
+  }
+})

+ 52 - 0
docs/app-web-sync.md

@@ -0,0 +1,52 @@
+# App 与 Web 版本同步
+
+正式 macOS App 在安装或更新后的首次启动同步本机 Web。只有复制 App、尚未打开时,不执行安装脚本。手机和远程服务器不参与这个过程。
+
+## 版本规则
+
+| 当前 Web | App 行为 |
+| --- | --- |
+| 尚未安装 | 使用 App 携带的预编译服务和 Node 安装独立后台服务 |
+| 版本较旧,或同版本且提交是 App 构建的祖先 | 等待空闲后向上同步 |
+| 同一构建/同一干净提交 | 直接连接 |
+| 发布版本或同版本提交更新 | 保留当前 Web |
+| 同版本分支关系不明、无法识别的工作区快照 | 保留服务并说明无法自动判断 |
+
+`build-info.json` 包含提交、构建序号、祖先提交、工作区状态和构建时间;构建序号仅用于展示,提交的先后必须有祖先关系,不能比较哈希字典序或文件时间。`runtime-manifest.json` 对完整服务包逐文件计算 SHA-256,并拒绝额外文件、丢失文件和符号链接。App 内置 Node 保持原签名,避免签名后改变已封存的服务包。许可证位于 `web-service/NODE-LICENSE.txt`。
+
+成功后在数据目录的 `updates/desktop-sync.json` 记录本次 App 服务包校验值。再次打开同一构建不会重新同步,允许 Web 继续自行升级或经兼容性检查后单独回滚。该记录属于数据目录,不是全局 App 偏好。失败不写成功记录,启动页可重试。
+
+## 统一后台服务
+
+App 同步和 Web 的更新/回滚共用 `bin/lib/service-update.mjs` 的事务、发布目录、SQLite 更新互斥和原有 `active.lock`。App 同步使用包内服务,不下载或现场运行 npm 构建;Web 的更新入口仍按发布源准备新版本。两条路径都切换同一个 `current`。
+
+首次接管时核对当前用户的 LaunchAgent、数据目录、端口、监听进程、SQLite 持有者和启动入口。已有地址、TLS、上游、通知凭据文件及其他环境配置保留。不接管未知监听进程、其他数据目录或远程服务器;TLS 身份无法正常验证时在停止前退出。
+
+新服务通过仅限本机、无 Origin、私有随机凭据的接口协商暂停。只有没有进行中的普通聊天、团队运行、Runner 操作及 HTTP 写请求时才暂停新请求;正在运行的任务继续完成。暂停租约在没有持久更新事务时 15 秒过期,避免更新器在切换前退出导致永久暂停。
+
+旧版没有暂停协议时,检查持久任务、普通聊天未完成事件和活动客户端连接。有未完成任务、活动连接或不能确认状态时,最多等待一分钟后延后同步。旧客户端关闭后可重试,不强行打断任务。
+
+## 数据与恢复
+
+1. 在公共更新锁内准备并校验完整服务包,确认原服务可访问。
+2. 等待空闲、暂停新请求,持久记录更新事务。
+3. 停止原服务并确认进程、监听和数据库句柄释放;备份 Web 数据及文件。
+4. 原子切换程序目录,启动新版本。验证完成前,新服务拒绝业务请求。
+5. 用私有实例信息核对 PID、数据目录、实际提交及服务包校验值,并检查 `/healthz`、`/api/status`。
+6. 验证成功后记录回退信息、解除暂停,最后记录 App 已同步。
+
+启动或迁移失败时,恢复原程序、数据库结构、数据文件和配置。备份排除 `updates`、桌面浏览器数据、独立 Runner 状态及进程锁;这些内容不会被 Web 回退覆盖。中断事务由下一次 App 同步或更新器运行先行恢复。已经恢复数据但尚未启动成功的事务不会重复用旧备份覆盖数据。
+
+成功升级后的手动回滚保留现有消息,要求升级前后及当前数据库结构一致。涉及结构变化或没有旧兼容性记录时拒绝直接降级,必须使用独立的数据恢复流程。失败更新期间的自动恢复只发生在业务请求尚未重新开放之前。
+
+退出 App 后独立 Web 继续运行。App 自己托管的 Runner 仍按原生命周期退出;若需要退出 App 后仍执行电脑任务,应使用独立 Runner。源码开发模式默认不接管正式服务,仍使用隔离的 App 子进程。
+
+## 验证入口
+
+- `tests/server/desktopSync.test.ts`:构建选择、包篡改、互斥、忙碌延后、一次同步、数据与结构恢复、中断恢复及降级兼容性。
+- `tests/server/serviceInstance.test.ts`:本机权限、空闲协商、更新期间拒绝写入、事务提交后恢复。
+- `desktop/service-sync.test.mjs`:实际 Electron App、包内 Node 和隔离 LaunchAgent 的安装、健康/构建核对、Web 更新入口、退出后继续运行及保留更新的 Web。
+
+原生验收使用临时数据目录、端口、发布目录和由目录哈希派生的测试 LaunchAgent 标签;清理只作用于该测试服务,不替换正式 15300 服务。
+
+2026-09-09 验证:类型检查和生产/桌面构建通过;全量测试 787 项通过、6 项按环境跳过。原有 8 项桌面测试通过,新增真实 App/LaunchAgent 同步流程通过,覆盖退出后服务保留、较新 Web 保留、旧配置保留以及服务停止后再次打开 App 恢复独立服务。开发签名 App 的严格签名、服务包完整性及实际同步流程均已验证。此次修改不包含替换本机已安装 App 或正式 Web 服务。

+ 11 - 8
docs/macos-app.md

@@ -1,13 +1,13 @@
 # macOS App 与本地服务
 
-App、Web 服务、Vue 界面和 Runner 同仓维护。打开 App 会先核对数据目录及已有监听服务;有可信的已有服务则连接,没有则启动包内服务。默认数据目录为 `~/.hermes-yaoyao`,默认 Web 端口为 15300。Hermes 是独立基础运行时,由本地服务检测并按既有托管逻辑启动;隔离电脑另需本机 Docker 或 Podman。
+App、Web 服务、Vue 界面和 Runner 同仓维护。正式 App 在每个构建首次启动时,将较旧的本机 Web 同步为配套构建;已有更新的 Web 保留。Web 由独立 LaunchAgent 运行,App 连接它。源码开发模式仍使用包内子进程。默认数据目录为 `~/.hermes-yaoyao`,默认 Web 端口为 15300。Hermes 是独立基础运行时,由本地服务检测并按既有托管逻辑启动;隔离电脑另需本机 Docker 或 Podman。
 
 ## 生命周期与权限
 
 - App 单实例;服务和 Runner 各自对实际数据目录持有 SQLite 独占锁。
 - 使用私有随机凭据核对服务实例、PID、数据目录、协议及版本。App 自己启动的服务必须匹配内置版本。连接旧版本外部服务时使用其界面和能力声明。
 - 老 CLI 服务没有描述文件时,只有确认它同时持有目标 SQLite 文件和监听端口才连接。
-- 关闭窗口保留任务;菜单栏和 Dock 可恢复。明确退出等待自己托管的 Runner 和服务结束,不停止外部服务。停止无法确认时显示实际清理错误。
+- 关闭窗口保留任务;菜单栏和 Dock 可恢复。正式 App 退出时停止自己托管的 Runner,独立 Web 服务继续运行。源码开发 App 仍清理自己的服务子进程。停止无法确认时显示实际清理错误。
 - 服务崩溃有界重启,睡眠恢复后重新核对连接;错误页面提供重试和日志。
 - Runner 配置由原生文件选择器导入。新配置用 AES-GCM 加密,密钥按数据目录保存在系统钥匙串中;加密助手仅允许所属、同签名团队的 App 调用,明文通过私有进程管道传递。旧 safeStorage 格式仍可在系统授权后读取。Web 页面没有配置解密或桌面启动权限,外部页面不能调用原生桥。
 
@@ -19,14 +19,17 @@ App、Web 服务、Vue 界面和 Runner 同仓维护。打开 App 会先核对
 
 ## 更新与回退
 
-App 内置服务随整个 App 更新,不使用源码部署的 LaunchAgent 更新器。菜单“App 更新与回退”显示当前 App/服务版本、托管归属和数据目录。当前采用手动替换 App 的流程,没有启用自动下载安装:
+正式 App 的同步在安装/更新后的**首次启动**执行;复制 App 文件本身不执行服务操作。详见 [App 与 Web 版本同步](app-web-sync.md)。
 
-1. 停止任务并退出 App。若连接已有外部服务,单独按它的原有方式停止,确认没有进程写入数据目录。
-2. 私有备份整个数据目录和正在使用的 App。电脑工作区可使用 `docs/computer-maintenance.md` 中的校验备份。
-3. 核对新包架构、签名和校验值,再替换 App;启动后核对服务实际版本、健康状态、会话和任务。
-4. 出现问题时退出新 App,恢复原 App;包含数据迁移的升级须同时恢复升级前的数据。App 不主动删除原镜像和工作区备份。
+- 同步仅针对当前用户、相同数据目录和端口的本机 LaunchAgent;保留已有服务的地址、TLS、上游及通知配置。
+- 使用版本、提交关系和完整服务包校验值识别构建。同版本不同提交可以向上同步;不同分支且无法确认先后时保留现有服务并提示原因。
+- App 携带独立 Node 运行时及预编译 Web 服务,首次同步不依赖联网、npm 或源码构建。Node 的许可证随服务包交付。
+- 同步成功按 App 服务包校验值记账;同一构建再次打开不会反复覆盖 Web。Web 可以继续通过原有更新入口升级。
+- 新版服务空闲时短暂暂停请求,停止后备份数据、切换版本,验证实际构建、健康及关键接口后开放请求。失败恢复程序与数据;进程中断由下一次同步或更新恢复。
+- 原有旧版服务缺少协作暂停接口时,需要确认无未完成任务及已建立的客户端连接,最长等待一分钟后延后同步,不强行中断聊天。不能验证身份、TLS 或数据归属的服务不接管。
+- 回退单独通过 Web 的回滚入口处理。只在数据库结构兼容时切回程序并保留现有数据;结构已变化或缺少兼容性记录时拒绝直接降级,不能用旧备份覆盖升级后的新消息。
 
-此流程保护签名后的 App 资源不被 Web 更新器改写。正式数据升级/回退必须在停止写入的条件下操作,不能拿运行中数据库的普通文件复制充当一致备份。
+正式后台服务的程序位于 `~/.local/share/hermes-yaoyao/releases`,`current` 指向启用的构建;数据仍在 `~/.hermes-yaoyao`。App 签名资源不被 Web 更新器改写。开发模式的包内服务仍随开发 App 管理,不提供服务内更新。
 
 ## 构建与验收
 

+ 4 - 0
electron-builder.yml

@@ -25,9 +25,13 @@ extraResources:
       - build-info.json
       - keychain-helper
       - ui/**/*
+      - web-service/**/*
 asar: true
 npmRebuild: false
 mac:
+  # Keep the independently signed Node runtime byte-identical to its sealed manifest.
+  signIgnore:
+    - /web-service/node$
   category: public.app-category.productivity
   icon: public/icons/icon-512.png
   target:

+ 1 - 1
package.json

@@ -14,7 +14,7 @@
   },
   "scripts": {
     "dev": "cross-env NODE_ENV=development tsx watch src/server/index.ts",
-    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b tsconfig.client.build.json && vite build && tsc -p tsconfig.server.json && node scripts/copy-worker.mjs",
+    "build": "npm run brand:verify && npm run release:verify && vue-tsc -b tsconfig.client.build.json && vite build && tsc -p tsconfig.server.json && node scripts/copy-worker.mjs && node scripts/write-build-info.mjs",
     "start": "cross-env NODE_ENV=production node dist-server/server/index.js",
     "typecheck": "vue-tsc -b && tsc --noEmit -p tsconfig.server.json",
     "test": "vitest run",

+ 12 - 4
scripts/build-desktop.mjs

@@ -1,8 +1,9 @@
 import { build } from 'esbuild'
 import { execFileSync } from 'node:child_process'
 import { buildRunner } from './build-runner.mjs'
-import { cp, mkdir, rm, readdir, readFile, writeFile } from 'node:fs/promises'
+import { cp, mkdir, rm, readdir, readFile, writeFile, realpath, chmod } from 'node:fs/promises'
 import { resolve } from 'node:path'
+import { sealRuntime } from '../bin/lib/runtime-release.mjs'
 
 const root = resolve(import.meta.dirname, '..'), out = resolve(root, '.desktop-build')
 await rm(out, { recursive: true, force: true })
@@ -25,10 +26,17 @@ for (const file of await readdir(resolve(root, 'desktop'))) {
   if (file.endsWith('.test.mjs') || !/\.(mjs|cjs|html|css|png)$/.test(file)) continue
   await cp(resolve(root, 'desktop', file), resolve(shell, file))
 }
-let commit='source-archive',dirty=false
-try{commit=execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim();dirty=!!execFileSync('git',['status','--porcelain'],{cwd:root,encoding:'utf8'}).trim()}catch{}
-await writeFile(resolve(out,'build-info.json'),JSON.stringify({commit,dirty,builtAt:new Date().toISOString()},null,2)+'\n')
+await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
+const service = resolve(out, 'web-service')
+await mkdir(service)
+for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
+await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
+await cp(await realpath(process.execPath), resolve(service, 'node'))
+await cp(resolve(await realpath(process.execPath), '../../LICENSE'), resolve(service, 'NODE-LICENSE.txt'))
+await chmod(resolve(service, 'node'), 0o755)
+await writeFile(resolve(service, 'package.json'), JSON.stringify({ name: 'hermes-yaoyao', version: manifest.webVersion, type: 'module' }))
+sealRuntime(service)
 await writeFile(resolve(shell, 'package.json'), JSON.stringify({ name: 'hermes-yaoyao-desktop', version: manifest.webVersion,
   productName: '夭夭', description: '夭夭:本地服务、多 Agent 团队与电脑工作空间',
   author: 'YaoYao contributors', license: 'BSL-1.1', main: 'main.mjs', type: 'module' }, null, 2))

+ 5 - 0
scripts/write-build-info.mjs

@@ -0,0 +1,5 @@
+import { writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { sourceBuild } from '../bin/lib/runtime-release.mjs'
+const root = resolve(import.meta.dirname, '..')
+writeFileSync(resolve(root, 'build-info.json'), JSON.stringify({ ...sourceBuild(root), builtAt: new Date().toISOString() }, null, 2) + '\n')

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

@@ -33,6 +33,7 @@ export interface UpdateJob {
 
 export interface SystemUpdateStatus {
   current: ReleaseManifest
+  build?: { commit: string; buildNumber: number; dirty: boolean; artifactDigest?: string }
   installationMode: 'source' | 'release' | 'desktop'
   latest?: ReleaseManifest
   updateAvailable: boolean

+ 3 - 2
src/server/app.ts

@@ -481,7 +481,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
 
 export interface NodeServerRuntime {
   server: HttpServer
-  close(): Promise<void>
+  close(closeConnections?: boolean): Promise<void>
 }
 
 export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
@@ -505,10 +505,11 @@ export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime
   const removeWebSockets = runtime.realtime.rejectLegacyUpgrades(server)
   return {
     server,
-    close: async () => {
+    close: async (closeConnections = false) => {
       removePushConfigurationListener()
       removeWebSockets()
       runtime.realtime.close()
+      if (closeConnections) server.closeAllConnections()
       await new Promise<void>((resolve, reject) => {
         if (!server.listening) {
           resolve()

+ 13 - 0
src/server/buildIdentity.ts

@@ -0,0 +1,13 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+export interface BuildIdentity { commit: string; buildNumber: number; dirty: boolean; artifactDigest?: string }
+export function readBuildIdentity(root: string): BuildIdentity | undefined {
+  try {
+    const build = JSON.parse(readFileSync(join(root, 'build-info.json'), 'utf8'))
+    if (typeof build.commit !== 'string' || !Number.isSafeInteger(build.buildNumber)) return undefined
+    let artifactDigest: string | undefined
+    try { artifactDigest = JSON.parse(readFileSync(join(root, 'runtime-manifest.json'), 'utf8')).artifactDigest } catch { /* Source deployment. */ }
+    return { commit: build.commit, buildNumber: build.buildNumber, dirty: build.dirty === true, artifactDigest }
+  } catch { return undefined }
+}

+ 7 - 3
src/server/index.ts

@@ -8,9 +8,10 @@ import { DashboardSupervisor } from './dashboardSupervisor.js'
 import { isLocalAuthorizationTarget } from './loopbackAuthorization.js'
 import { acquireServiceInstance } from './serviceInstance.js'
 import { readReleaseManifest } from './releases.js'
+import { readBuildIdentity } from './buildIdentity.js'
 
 const config = loadServerConfig()
-const instance = acquireServiceInstance(config.home, readReleaseManifest(resolve(process.cwd(), 'release.json')).webVersion, process.env.HERMES_YAOYAO_DESKTOP === '1')
+const instance = acquireServiceInstance(config.home, readReleaseManifest(resolve(process.cwd(), 'release.json')).webVersion, process.env.HERMES_YAOYAO_DESKTOP === '1', readBuildIdentity(process.cwd()))
 process.once('exit', () => instance.release())
 const runtime = createApplication({
   config,
@@ -22,7 +23,10 @@ const dashboardSupervisor = config.superviseDashboard
   : undefined
 const nodeRuntime = createNodeServer(runtime)
 let closeFrontend = async (): Promise<void> => undefined
-runtime.app.use(instance.middleware(shutdown))
+// Native update admission runs before routes (including streamed requests).
+// The capability is loopback-only and never shared with the Web renderer.
+runtime.app.middleware.unshift(instance.middleware(shutdown, () => runtime.realtime.broker.idleForUpdate
+  && runtime.runners.idleForUpdate && runtime.workspaceRuntime.idleForUpdate))
 
 if (runtime.config.production) {
   const dist = resolve(process.env.HERMES_YAOYAO_STATIC_DIR || resolve(process.cwd(), 'dist'))
@@ -92,7 +96,7 @@ async function shutdown(): Promise<void> {
   try {
     dashboardSupervisor?.stop()
     await closeFrontend()
-    await nodeRuntime.close()
+    await nodeRuntime.close(instance.quiesced)
     await dashboardSupervisor?.stopOwned()
   } finally {
     instance.release()

+ 3 - 0
src/server/realtimeBroker.ts

@@ -43,6 +43,9 @@ interface Upstream {
 
 /** Server-owned WS transports. Downstream detach never tears down an active upstream. */
 export class RealtimeBroker {
+  get idleForUpdate(): boolean {
+    return this.commands.size === 0 && [...this.upstreams.values()].every(upstream => upstream.pending.size === 0 && [...upstream.routes.values()].every(route => !route.active))
+  }
   protectedSession: (id: string) => boolean = () => false
   onNativeEvent: (owner: string, profile: string, storedId: string, frame: Frame) => void = () => {}
   onNativeGlobalEvent: (owner: string, type: string) => void = () => {}

+ 17 - 0
src/server/runnerHub.ts

@@ -19,6 +19,9 @@ interface Lease { runnerId:string; input:LeaseInput; calls:Map<string,{fingerpri
 
 /** Outbound-only machine transport. Browser auth and machine auth remain separate. */
 export class RunnerHub {
+  get idleForUpdate(): boolean { return this.imageActivity.size === 0 && [...this.pending.values()].every(commands => commands.size === 0) && [...this.artifacts.values()].every(upload => upload.result !== undefined) }
+  private imageActivity = new Map<string, { owner: string; runnerId: string }>()
+  private imageChecks = new Set<string>()
   controlAllowed:(id:string,runnerId:string)=>boolean=()=>false
   readonly epoch=randomUUID()
   private online = new Map<string,{instance:string;seen:number;epoch:string;features:string[];wake?:()=>void}>()
@@ -29,9 +32,18 @@ export class RunnerHub {
   private leases = new Map<string,Lease>()
   private watchdog: ReturnType<typeof setInterval>
   constructor(readonly store:WorkspaceStore,readonly auth:LocalAuthStore,readonly local:GatewayTarget) {
+    for (const grant of store.list<{ owner: string; runnerId: string; expiresAt: number }>('_system', 'image-management-grant'))
+      if (grant.expiresAt > Date.now()) this.imageActivity.set(`${grant.owner}:${grant.runnerId}`, { owner: grant.owner, runnerId: grant.runnerId })
     this.watchdog=setInterval(()=>{
       for(const [id,state] of this.online) if(Date.now()-state.seen>35_000)this.disconnect(id)
       for(const [id,upload] of this.artifacts)if(!this.connections.has(upload.connectionId))this.artifacts.delete(id)
+      for (const [key, activity] of this.imageActivity) {
+        if (this.imageChecks.has(key) || !this.online.has(activity.runnerId)) continue
+        this.imageChecks.add(key)
+        void this.request(activity.runnerId, 'images.manage', { op: 'status', owner: hash(activity.owner) }, () => this.store.get<RunnerRecord>('_system', 'runner', activity.runnerId)?.enabled === true)
+          .then(result => { if (result.busy === false && this.imageActivity.get(key) === activity) this.imageActivity.delete(key) })
+          .catch(() => {}).finally(() => this.imageChecks.delete(key))
+      }
     },5000);this.watchdog.unref()
   }
   imageAllowed:(id:string,runnerId:string)=>boolean=()=>false
@@ -40,8 +52,13 @@ export class RunnerHub {
     if(!record.enabled||!this.online.get(id)?.features.includes('image-management-v1'))throw new HttpError(409,'节点离线或尚不支持镜像管理,请更新并连接 Runner','image_management_unavailable')
     const valid=()=>this.auth.isAdminActive(owner)&&this.auth.pushAuthorizationVersion(owner)===version&&this.store.get<RunnerRecord>('_system','runner',id)?.enabled===true
     if(!valid())throw new HttpError(403,'镜像管理需要管理员权限','admin_required')
+    const key = `${owner}:${id}`
+    if (payload.op === 'start') this.imageActivity.set(key, { owner, runnerId: id })
+    const activity = this.imageActivity.get(key)
     const result=await this.request(id,'images.manage',{...payload,owner:hash(owner)},valid)
     if(!valid())throw new HttpError(403,'镜像管理授权已失效','admin_required')
+    if (payload.op === 'status' && result.busy === false && this.imageActivity.get(key) === activity) this.imageActivity.delete(key)
+    else if (payload.op === 'status' && result.busy === true && !activity) this.imageActivity.set(key, { owner, runnerId: id })
     return result
   }
   records():RunnerRecord[] {return this.store.list('_system','runner')}

+ 31 - 7
src/server/serviceInstance.ts

@@ -1,8 +1,9 @@
 import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
-import { chmodSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { DatabaseSync } from 'node:sqlite'
 import type Koa from 'koa'
+import type { BuildIdentity } from './buildIdentity.js'
 
 export interface ServiceRecord {
   protocol: 1
@@ -13,11 +14,13 @@ export interface ServiceRecord {
   url?: string
   token: string
   desktopOwned: boolean
+  build?: BuildIdentity
+  maintenanceProtocol: 1
 }
 
 /** Every executable entry takes this lock before opening any databases.
  * Process death can be recovered; an unknown or live owner always fails closed. */
-export function acquireServiceInstance(home: string, version: string, desktopOwned = false) {
+export function acquireServiceInstance(home: string, version: string, desktopOwned = false, build?: BuildIdentity) {
   mkdirSync(home, { recursive: true, mode: 0o700 })
   const root = realpathSync(home), file = join(root, 'service-instance.json')
   // SQLite holds an OS-level lock, released by the kernel on process death.
@@ -29,7 +32,7 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
   catch { lock.close(); throw new Error('这份数据已由另一个夭夭服务使用,请连接已有服务。') }
   const record: ServiceRecord = { protocol: 1, instanceId: randomUUID(), pid: process.pid,
     dataKey: createHash('sha256').update(root).digest('hex'), version,
-    token: randomBytes(32).toString('base64url'), desktopOwned }
+    token: randomBytes(32).toString('base64url'), desktopOwned, build, maintenanceProtocol: 1 }
   const write = () => {
     const temporary = join(root, `service-instance-${record.instanceId}.tmp`)
     writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 })
@@ -37,8 +40,12 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
   }
   try { write() } catch (error) { lock.close(); throw error }
   let released = false
+  let quiescedUntil = 0, pendingMutations = 0
+  const transition = join(root, 'updates', 'transition.json')
+  const quiesced = () => Date.now() < quiescedUntil || existsSync(transition)
   return {
     record,
+    get quiesced() { return quiesced() },
     publish(url: string) { record.url = url; write() },
     release() {
       if (released) return
@@ -51,17 +58,34 @@ export function acquireServiceInstance(home: string, version: string, desktopOwn
       } catch { /* Never remove a lock that can no longer be identified. */ }
       finally { lock.close() }
     },
-    middleware(shutdown: () => Promise<void>): Koa.Middleware {
+    middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true): Koa.Middleware {
       return async (ctx, next) => {
-        if (ctx.path !== '/desktop/service') return next()
+        const control = ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce'
+        if (!control) {
+          if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
+            ctx.status = 503; ctx.set('Retry-After', '3')
+            ctx.body = { error: 'Web 服务正在更新,请稍后重试', code: 'service_updating' }; return
+          }
+          const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(ctx.method) && !/^\/api\/runner\/.*\/(poll|heartbeat)$/.test(ctx.path)
+          if (mutation) pendingMutations++
+          try { await next() } finally { if (mutation) pendingMutations-- }
+          return
+        }
         const received = createHash('sha256').update(ctx.get('x-yaoyao-desktop-token')).digest()
         const expected = createHash('sha256').update(record.token).digest()
         const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
         if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
         ctx.set('Cache-Control', 'no-store')
-        if (ctx.method === 'GET') {
+        if (ctx.path.endsWith('/quiesce')) {
+          if (ctx.method === 'POST') {
+            if (pendingMutations || !idle()) { ctx.status = 409; ctx.body = { error: '正在等待任务完成', code: 'service_busy' }; return }
+            quiescedUntil = Date.now() + 15000; ctx.body = { quiesced: true }
+          } else if (ctx.method === 'DELETE' && !existsSync(transition)) {
+            quiescedUntil = 0; ctx.body = { quiesced: false }
+          } else { ctx.status = 405; ctx.body = { error: '更新事务尚未结束' } }
+        } else if (ctx.method === 'GET') {
           const { token: _token, ...publicRecord } = record
-          ctx.body = publicRecord
+          ctx.body = { ...publicRecord, quiesced: quiesced() }
         } else if (ctx.method === 'DELETE' && desktopOwned) {
           ctx.body = { stopping: true }
           setImmediate(() => { void shutdown() })

+ 17 - 2
src/server/updateManager.ts

@@ -1,5 +1,7 @@
 import { execFile } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
+import { DatabaseSync } from 'node:sqlite'
+import { readBuildIdentity, type BuildIdentity } from './buildIdentity.js'
 import {
   closeSync,
   existsSync,
@@ -47,6 +49,8 @@ export interface UpdateJob {
 }
 
 interface UpdatePlan {
+  home: string
+  port: number
   source: string
   releaseRoot: string
   previousServiceRoot: string
@@ -60,6 +64,7 @@ interface StoredUpdateJob extends UpdateJob {
 
 export interface SystemUpdateStatus {
   current: ReleaseManifest
+  build?: BuildIdentity
   installationMode: 'source' | 'release' | 'desktop'
   latest?: ReleaseManifest
   updateAvailable: boolean
@@ -260,6 +265,7 @@ export class SystemUpdateManager {
     const job = this.latestJob()
     return {
       current,
+      build: readBuildIdentity(this.projectRoot),
       installationMode: this.desktopOwned ? 'desktop' : this.projectRoot.startsWith(`${this.releaseRoot}/`) ? 'release' : 'source',
       latest,
       updateAvailable: Boolean(latest && compareReleaseVersions(latest.releaseVersion, current.releaseVersion) > 0),
@@ -279,10 +285,17 @@ export class SystemUpdateManager {
 
   private acquire(jobID: string): void {
     mkdirSync(this.updateHome, { recursive: true, mode: 0o700 })
+    const mutex = new DatabaseSync(join(this.updateHome, 'mutex.sqlite3'))
+    try { mutex.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
+    catch { mutex.close(); throw new Error('已有系统更新正在执行') }
+    try { this.acquireFileLock(jobID) } finally { mutex.close() }
+  }
+
+  private acquireFileLock(jobID: string): void {
     const lockPath = join(this.updateHome, 'active.lock')
     try {
       const descriptor = openSync(lockPath, 'wx', 0o600)
-      writeFileSync(descriptor, `${JSON.stringify({ jobID })}\n`)
+      writeFileSync(descriptor, `${JSON.stringify({ jobID, pid: process.pid })}\n`)
       closeSync(descriptor)
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
@@ -311,7 +324,7 @@ export class SystemUpdateManager {
       }
       rmSync(lockPath, { force: true })
       const descriptor = openSync(lockPath, 'wx', 0o600)
-      writeFileSync(descriptor, `${JSON.stringify({ jobID })}\n`)
+      writeFileSync(descriptor, `${JSON.stringify({ jobID, pid: process.pid })}\n`)
       closeSync(descriptor)
     }
   }
@@ -329,6 +342,8 @@ export class SystemUpdateManager {
       updatedAt: now,
       target,
       plan: {
+        home: this.config.home,
+        port: this.config.port,
         source: this.releaseSource,
         releaseRoot: this.releaseRoot,
         previousServiceRoot: this.projectRoot,

+ 3 - 0
src/server/workspaceRuntime.ts

@@ -60,6 +60,9 @@ export const sendInput = z
   .strict()
   .refine((b) => b.content.trim() || b.fileIds.length, '请输入消息或添加附件')
 export class WorkspaceRuntime extends WorkspaceScheduler {
+  get idleForUpdate(): boolean {
+    return this.live.size === 0 && this.executing.size === 0 && !this.store.db.prepare("SELECT 1 FROM workspace_entities WHERE kind IN ('run','turn') AND COALESCE(json_extract(data,'$.status'),'unknown') NOT IN ('complete','failed','interrupted') LIMIT 1").get()
+  }
   private live = new Map<string, LiveTurn>()
   readonly teamTools: WorkspaceTeamTools
   readonly tasks: WorkspaceTaskCoordinator

+ 164 - 0
tests/server/desktopSync.test.ts

@@ -0,0 +1,164 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it } from 'vitest'
+import { DatabaseSync } from 'node:sqlite'
+import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync, symlinkSync, realpathSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { syncDecision, sealRuntime, verifyRuntimePackage } from '../../bin/lib/runtime-release.mjs'
+import { assertRollbackCompatible, backupData, databaseSchema } from '../../bin/lib/service-data.mjs'
+import { currentRelease, reserveUpdate, switchRelease, synchronizeDesktop, transitionService, updateMutex, writeJSON, recoverTransition } from '../../bin/lib/service-update.mjs'
+import { RunnerHub } from '../../src/server/runnerHub'
+
+const homes: string[] = []
+afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) })
+const temporary = () => { const home = realpathSync(mkdtempSync(join(tmpdir(), 'yaoyao-sync-unit-'))); homes.push(home); return home }
+function runtime(root: string, commit = 'b'.repeat(40), version = '0.3.32', ancestors = ['a'.repeat(40)]) {
+  mkdirSync(join(root, 'bin'), { recursive: true }); mkdirSync(join(root, 'ui'))
+  for (const file of ['node', 'server.mjs', 'ui/index.html', 'bin/hermes-yaoyao.mjs', 'bin/hermes-yaoyao-updater.mjs']) writeFileSync(join(root, file), file)
+  writeJSON(join(root, 'release.json'), { schemaVersion: 1, releaseVersion: version, webVersion: version, gitTag: `v${version}` })
+  writeJSON(join(root, 'build-info.json'), { commit, ancestors, buildNumber: ancestors.length + 1, dirty: false })
+  sealRuntime(root); return verifyRuntimePackage(root)
+}
+const clean = (commit: string, ancestors: string[] = [], version = '0.3.32') => ({ commit, ancestors, version, dirty: false })
+
+describe('desktop release selection and integrity', () => {
+  it('orders same-version builds by ancestry, preserves newer Web, and refuses unrelated builds', () => {
+    expect(syncDecision(clean('b', ['a']), clean('a'))).toBe('upgrade')
+    expect(syncDecision(clean('a'), clean('b', ['a']))).toBe('newer')
+    expect(syncDecision(clean('b'), clean('b'))).toBe('same')
+    expect(syncDecision(clean('a', [], '0.3.32'), clean('b', [], '0.3.33'))).toBe('newer')
+    expect(syncDecision(clean('z'), clean('a'))).toBe('unknown')
+    expect(syncDecision({ ...clean('a'), dirty: true }, clean('a'))).toBe('unknown')
+    expect(syncDecision(clean('a', [], '0.4.0-10'), clean('b', [], '0.4.0-2'))).toBe('upgrade')
+  })
+  it('detects changed, added, removed and symlinked payload files before touching a service', () => {
+    const root = join(temporary(), 'runtime'); runtime(root)
+    writeFileSync(join(root, 'server.mjs'), 'tampered')
+    expect(() => verifyRuntimePackage(root)).toThrow('校验失败')
+    sealRuntime(root); writeFileSync(join(root, 'unexpected'), 'extra')
+    expect(() => verifyRuntimePackage(root)).toThrow('校验失败')
+    rmSync(join(root, 'unexpected')); sealRuntime(root); rmSync(join(root, 'node'))
+    expect(() => verifyRuntimePackage(root)).toThrow('校验失败')
+    symlinkSync('/etc/hosts', join(root, 'node'))
+    expect(() => verifyRuntimePackage(root)).toThrow('符号链接')
+  })
+  it('excludes competing desktop and Web updaters using the same OS lock and file reservation', () => {
+    const home = temporary(), unlock = updateMutex(home)
+    expect(() => updateMutex(home)).toThrow('正在执行')
+    const release = reserveUpdate(home, 'first')
+    expect(() => reserveUpdate(home, 'second')).toThrow('正在执行')
+    release(); unlock()
+    const next = updateMutex(home); next()
+  })
+  it('waits for asynchronous image jobs even after their start command has returned', async () => {
+    const store = { list: () => [], require: () => ({ enabled: true }), get: () => ({ enabled: true }) }
+    const auth = { pushAuthorizationVersion: () => 1, isAdminActive: () => true }
+    const hub = new RunnerHub(store as any, auth as any, {} as any)
+    try {
+      ;(hub as any).online.set('runner', { features: ['image-management-v1'], seen: Date.now() })
+      ;(hub as any).request = async (_id: string, _kind: string, body: any) => body.op === 'start' ? { job: { state: 'running' } } : { busy: false }
+      expect(hub.idleForUpdate).toBe(true)
+      await hub.images('owner', 'runner', { op: 'start' })
+      expect(hub.idleForUpdate).toBe(false)
+      await hub.images('owner', 'runner', { op: 'status' })
+      expect(hub.idleForUpdate).toBe(true)
+    } finally { hub.close() }
+  })
+})
+
+describe('transactional service switching', () => {
+  function fixture() {
+    const home = temporary(), releaseRoot = join(home, 'updates', 'releases')
+    mkdirSync(releaseRoot, { recursive: true })
+    const old = join(releaseRoot, 'old'), target = join(releaseRoot, 'new')
+    runtime(old, 'a'.repeat(40), '0.3.31'); runtime(target)
+    switchRelease(releaseRoot, old)
+    const db = new DatabaseSync(join(home, 'workspace.sqlite3'))
+    db.exec("PRAGMA journal_mode=WAL; CREATE TABLE messages(value TEXT); INSERT INTO messages VALUES('before')"); db.close()
+    writeFileSync(join(home, 'settings.json'), 'original')
+    let stops = 0, starts = 0, fail = false
+    const driver = {
+      snapshot: () => ({ root: currentRelease(releaseRoot), wasRunning: true }),
+      quiesce: async () => {}, resume: async () => {},
+      stop: async () => { stops++ },
+      start: async () => {
+        starts++
+        if (fail) {
+          const database = new DatabaseSync(join(home, 'workspace.sqlite3'))
+          database.exec("ALTER TABLE messages ADD COLUMN extra TEXT; UPDATE messages SET value='migrated'"); database.close()
+          writeFileSync(join(home, 'settings.json'), 'migrated'); writeFileSync(join(home, 'new-migration-file'), 'new')
+        }
+      },
+      verify: async (expected?: unknown) => { if (expected && fail) throw new Error('wrong running build') },
+      restore: async () => {},
+    }
+    return { home, releaseRoot, target, old, driver, counts: () => ({ stops, starts }), fail: () => { fail = true } }
+  }
+  it('restores both schema and data after a failed startup; preserves independent Runner state', async () => {
+    const f = fixture(), schema = databaseSchema(f.home)
+    mkdirSync(join(f.home, 'runner-state')); writeFileSync(join(f.home, 'runner-state', 'preserve'), 'runner')
+    f.fail()
+    await expect(transitionService({ ...f, finalRoot: f.target })).rejects.toThrow('wrong running build')
+    expect(currentRelease(f.releaseRoot)).toBe(f.old)
+    expect(databaseSchema(f.home)).toBe(schema)
+    const db = new DatabaseSync(join(f.home, 'workspace.sqlite3'), { readOnly: true })
+    expect(db.prepare('SELECT value FROM messages').get()?.value).toBe('before'); db.close()
+    expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('original')
+    expect(existsSync(join(f.home, 'new-migration-file'))).toBe(false)
+    expect(readFileSync(join(f.home, 'runner-state', 'preserve'), 'utf8')).toBe('runner')
+    expect(existsSync(join(f.home, 'updates', 'last-success.json'))).toBe(false)
+  })
+  it('does not stop or mark a busy service as synchronized', async () => {
+    const f = fixture(); f.driver.quiesce = async () => { throw new Error('busy') }
+    await expect(synchronizeDesktop({ ...f, runtimeRoot: f.target })).rejects.toThrow('busy')
+    expect(f.counts()).toEqual({ stops: 0, starts: 0 })
+    expect(existsSync(join(f.home, 'updates', 'desktop-sync.json'))).toBe(false)
+  })
+  it('synchronizes an App build once, allowing Web to move independently afterwards', async () => {
+    const f = fixture()
+    await synchronizeDesktop({ ...f, runtimeRoot: f.target })
+    expect(f.counts()).toEqual({ stops: 1, starts: 1 })
+    switchRelease(f.releaseRoot, f.old)
+    const result = await synchronizeDesktop({ ...f, runtimeRoot: f.target })
+    expect(result.action).toBe('already-synced')
+    expect(f.counts()).toEqual({ stops: 1, starts: 1 })
+  })
+  it('recovers an interrupted migration before another update and restores the previous rollback record', async () => {
+    const f = fixture(), snapshot = join(f.home, 'updates', 'backups', 'interrupted')
+    backupData(f.home, snapshot)
+    writeJSON(join(f.home, 'updates', 'transition.json'), { home: f.home, releaseRoot: f.releaseRoot, previousCurrent: f.old,
+      previous: { root: f.old, wasRunning: true }, snapshot, previousSuccess: { jobId: 'older-success' } })
+    switchRelease(f.releaseRoot, f.target)
+    writeFileSync(join(f.home, 'settings.json'), 'partial migration')
+    await recoverTransition(f)
+    expect(currentRelease(f.releaseRoot)).toBe(f.old)
+    expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('original')
+    expect(JSON.parse(readFileSync(join(f.home, 'updates', 'last-success.json'), 'utf8')).jobId).toBe('older-success')
+    expect(existsSync(join(f.home, 'updates', 'transition.json'))).toBe(false)
+  })
+  it('allows data-preserving rollback only when the schema remains compatible', () => {
+    const f = fixture(), schema = databaseSchema(f.home)
+    const record = { beforeSchema: schema, afterSchema: schema }
+    const db = new DatabaseSync(join(f.home, 'workspace.sqlite3'))
+    db.exec("INSERT INTO messages VALUES('new message after upgrade')")
+    expect(() => assertRollbackCompatible(f.home, record)).not.toThrow()
+    db.exec('ALTER TABLE messages ADD COLUMN migrated TEXT')
+    expect(() => assertRollbackCompatible(f.home, record)).toThrow('数据库结构')
+    expect(() => assertRollbackCompatible(f.home, {})).toThrow('兼容性记录')
+    expect(db.prepare('SELECT COUNT(*) AS count FROM messages').get()?.count).toBe(2)
+    db.close()
+  })
+  it('does not restore data twice when restarting the restored service needs another attempt', async () => {
+    const f = fixture(), snapshot = join(f.home, 'updates', 'backups', 'retry')
+    backupData(f.home, snapshot)
+    writeJSON(join(f.home, 'updates', 'transition.json'), { home: f.home, releaseRoot: f.releaseRoot, previousCurrent: f.old,
+      previous: { root: f.old, wasRunning: true }, snapshot })
+    f.driver.restore = async () => { throw new Error('restart pending') }
+    await expect(recoverTransition(f)).rejects.toThrow('restart pending')
+    writeFileSync(join(f.home, 'settings.json'), 'data after restore')
+    f.driver.restore = async () => {}
+    await recoverTransition(f)
+    expect(readFileSync(join(f.home, 'settings.json'), 'utf8')).toBe('data after restore')
+    expect(f.counts().stops).toBe(1)
+  })
+})

+ 33 - 2
tests/server/serviceInstance.test.ts

@@ -1,6 +1,6 @@
 // @vitest-environment node
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import Koa from 'koa'
@@ -41,4 +41,35 @@ describe('service ownership', () => {
     const app = new Koa().use(instance.middleware(async () => { throw new Error('must not stop') }))
     await request(app.callback()).delete('/desktop/service').set('x-yaoyao-desktop-token', instance.record.token).expect(405)
   })
+  it('admits updates only when idle and blocks application traffic until verification commits', async () => {
+    const instance = acquire(false)
+    let busy = true, writes = 0
+    const app = new Koa().use(instance.middleware(async () => {}, () => !busy)).use(ctx => { writes++; ctx.body = { ok: true } })
+    const api = request(app.callback()), auth = { 'x-yaoyao-desktop-token': instance.record.token }
+    await api.post('/desktop/service/quiesce').expect(403)
+    await api.post('/desktop/service/quiesce').set(auth).expect(409)
+    await api.post('/api/app/conversations').expect(200)
+    busy = false
+    await api.post('/desktop/service/quiesce').set(auth).expect(200)
+    await api.post('/api/app/conversations').expect(503)
+    expect(writes).toBe(1)
+    mkdirSync(join(home, 'updates')); writeFileSync(join(home, 'updates', 'transition.json'), '{}')
+    await api.delete('/desktop/service/quiesce').set(auth).expect(405)
+    await api.get('/healthz').expect(200)
+    rmSync(join(home, 'updates', 'transition.json'))
+    await api.delete('/desktop/service/quiesce').set(auth).expect(200)
+    await api.post('/api/app/conversations').expect(200)
+  })
+  it('expires a preparation lease if the updater exits before recording a transaction', async () => {
+    const instance = acquire(false), now = Date.now()
+    const clock = vi.spyOn(Date, 'now').mockReturnValue(now)
+    try {
+      const app = new Koa().use(instance.middleware(async () => {})).use(ctx => { ctx.body = { ok: true } })
+      const api = request(app.callback())
+      await api.post('/desktop/service/quiesce').set('x-yaoyao-desktop-token', instance.record.token).expect(200)
+      await api.post('/api/app/conversations').expect(503)
+      clock.mockReturnValue(now + 15001)
+      await api.post('/api/app/conversations').expect(200)
+    } finally { clock.mockRestore() }
+  })
 })