| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657 |
- 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() }
- }
|