service-data.mjs 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  1. import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
  2. import { join } from 'node:path'
  3. import { createHash } from 'node:crypto'
  4. import { DatabaseSync } from 'node:sqlite'
  5. // These are process/desktop state, not Web application data. Runner databases
  6. // have their own owner and must never be restored by a Web update.
  7. const excluded = name => ['updates', 'desktop', 'runner-state'].includes(name) || name.startsWith('service-instance')
  8. export function databaseSchema(home) {
  9. const result = {}
  10. for (const name of readdirSync(home).sort()) {
  11. if (excluded(name) || !/\.(?:sqlite3?|db)$/.test(name) || lstatSync(join(home, name)).isSymbolicLink()) continue
  12. const db = new DatabaseSync(join(home, name), { readOnly: true })
  13. try {
  14. 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() }
  15. } finally { db.close() }
  16. }
  17. return createHash('sha256').update(JSON.stringify(result)).digest('hex')
  18. }
  19. export function assertRollbackCompatible(home, record) {
  20. if (!record.beforeSchema || record.beforeSchema !== record.afterSchema || databaseSchema(home) !== record.afterSchema)
  21. throw new Error('数据库结构已变化或缺少兼容性记录,不能直接降级;请使用独立的数据恢复流程')
  22. }
  23. export function backupData(home, directory) {
  24. mkdirSync(directory, { recursive: true, mode: 0o700 })
  25. const names = readdirSync(home).filter(name => !excluded(name))
  26. for (const name of names) cpSync(join(home, name), join(directory, name), { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true })
  27. const schema = databaseSchema(home)
  28. writeFileSync(join(directory, '.snapshot.json'), JSON.stringify({ names, schema }), { mode: 0o600 })
  29. return schema
  30. }
  31. export function restoreData(home, directory) {
  32. const { names } = JSON.parse(readFileSync(join(directory, '.snapshot.json'), 'utf8'))
  33. if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || name.includes('/') || name === '..' || excluded(name))) throw new Error('数据备份清单无效')
  34. for (const name of readdirSync(home)) if (!excluded(name)) rmSync(join(home, name), { recursive: true, force: true })
  35. for (const name of names) cpSync(join(directory, name), join(home, name), { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true })
  36. }
  37. /** Conservative admission for pre-maintenance releases. No cache means an
  38. * existing ordinary-chat workload cannot be proven idle. */
  39. export function legacyDataIdle(home) {
  40. const workspace = join(home, 'workspace.sqlite3')
  41. if (existsSync(workspace)) {
  42. const db = new DatabaseSync(workspace, { readOnly: true })
  43. try {
  44. 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
  45. } finally { db.close() }
  46. }
  47. const cache = join(home, 'chat-cache.sqlite3')
  48. if (!existsSync(cache)) return !existsSync(workspace)
  49. const db = new DatabaseSync(cache, { readOnly: true })
  50. try {
  51. // Compare event sequence within each session, never across sessions.
  52. 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')
  53. 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
  54. AND done.event_type='message.complete' AND done.received_at>=e.received_at) LIMIT 1`).get()
  55. } finally { db.close() }
  56. }