Files
goodbuddy/tests/support/portable-storage-upgrade.mjs
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

336 lines
21 KiB
JavaScript

// Run with Node 24+: node tests/support/portable-storage-upgrade.mjs --launch
// --resume reuses an interrupted run's backup; --report prints aggregate evidence.
import assert from 'node:assert/strict'
import process from 'node:process'
import console from 'node:console'
import { Buffer } from 'node:buffer'
import { performance } from 'node:perf_hooks'
import { setTimeout, clearTimeout, setInterval, clearInterval } from 'node:timers'
import { createHash } from 'node:crypto'
import { createReadStream, openSync, closeSync } from 'node:fs'
import { cp, mkdir, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { DatabaseSync, backup } from 'node:sqlite'
import { spawn, execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { cpus, totalmem, platform, release } from 'node:os'
import { build } from 'esbuild'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const source = join(root, 'dist/GoodBuddy-windows-x64/data')
const temporary = join(root, 'temp/goodbuddy-storage-upgrade')
const run = join(temporary, process.argv.includes('--fresh') ? 'fresh-run' : 'run')
const resume = process.argv.includes('--resume')
if (process.argv.includes('--report')) {
const result = JSON.parse(await readFile(join(temporary, 'summary.json'), 'utf8'))
const canonicalContentUnchanged = result.comparison && Object.values(result.comparison).every(value => value.changed.length === 0)
const databases = Object.fromEntries(Object.entries(result.metadata ?? {}).map(([name, metadata]) => [name, {
sourceBytes: result.sourceBefore[name]?.bytes, sourceWalBytes: result.sourceBefore[name + '-wal']?.bytes,
backupBytes: result.backup?.[name]?.file.bytes, workBytes: result.workFiles?.[name]?.bytes,
versionBefore: metadata.version, versionAfter: result.after?.[name]?.version,
freePagesBefore: metadata.freePages, freePagesAfter: result.after?.[name]?.freePages,
tables: Object.fromEntries(Object.entries(metadata.tables).map(([table, entry]) => [table, entry.count])),
snapshotBeforeMs: result.backup?.[name]?.snapshot.durationMs, snapshotAfterMs: result.after?.[name]?.durationMs,
comparison: result.comparison?.[name], integrity: result.finalIntegrity?.[name]
}]))
console.log(JSON.stringify({ startedAt: result.startedAt, environment: result.environment,
status: result.status, canonicalContentUnchanged, failure: result.failure, sourceUnchanged: result.sourceUnchanged, copiesRemoved: result.copiesRemoved,
durationMs: result.durationMs, databases, initialization: result.initialization, exercise: result.exercise,
attachments: result.attachments, notes: result.notesBefore, notesUnchanged: result.notesUnchanged }, null, 2))
process.exit(0)
}
if (process.argv.includes('--launch')) {
await mkdir(temporary, { recursive: true })
const log = openSync(join(temporary, 'execution.log'), 'w')
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--supervise', ...process.argv.slice(2).filter(arg => arg !== '--launch')], { detached: true, stdio: ['ignore', log, log] })
closeSync(log)
child.unref()
console.log(`Launched verification supervisor ${child.pid}`)
process.exit(0)
}
if (process.argv.includes('--supervise')) {
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2).filter(arg => arg !== '--supervise')], { stdio: 'inherit' })
const timer = setTimeout(() => {
console.log('Overall verification deadline exceeded')
if (process.platform === 'win32') execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { timeout: 10_000, stdio: 'ignore' })
else child.kill('SIGKILL')
}, 900_000)
child.once('error', () => { clearTimeout(timer); process.exit(1) })
child.once('exit', code => { clearTimeout(timer); process.exit(code ?? 1) })
await new Promise(() => {})
}
if (process.argv.includes('--cleanup-interrupted')) {
console.log('Removing interrupted validation copies')
for (const name of ['assistant.sqlite', 'assistant.sqlite-wal', 'assistant.sqlite-shm']) {
try { await unlink(join(run, 'work', name)); console.log(`Removed ${name}`) }
catch (error) { console.log(`Cleanup ${name}: ${error.code}`) }
}
await rm(run, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
console.log('Interrupted validation copies removed')
process.exit(0)
}
const names = ['assistant.sqlite', 'knowledge.sqlite', 'conversation-attachments.sqlite', 'remote-runtime-bindings.sqlite']
const quote = value => `"${value.replaceAll('"', '""')}"`
const hash = value => createHash('sha256').update(value).digest('hex')
const canonical = value => JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? String(item) : item instanceof Uint8Array ? Buffer.from(item).toString('base64') : item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
async function fingerprint(path) {
const info = await stat(path).catch(error => { if (error.code !== 'ENOENT') throw error })
if (!info) return null
const digest = createHash('sha256')
for await (const chunk of createReadStream(path)) digest.update(chunk)
return { bytes: info.size, mtimeMs: info.mtimeMs, sha256: digest.digest('hex') }
}
async function sourceFingerprint() {
return Object.fromEntries(await Promise.all(names.flatMap(name => ['', '-wal', '-shm'].map(async suffix => [name + suffix, await fingerprint(join(source, name + suffix))]))))
}
async function contentFingerprint(directory) {
const entries = []
async function visit(path, relative) {
for (const entry of await readdir(path, { withFileTypes: true })) {
assert.ok(!entry.isSymbolicLink(), 'file-backed content must be isolated')
const name = join(relative, entry.name)
if (entry.isDirectory()) await visit(join(path, entry.name), name)
else {
const file = await fingerprint(join(path, entry.name))
entries.push({ name, bytes: file.bytes, sha256: file.sha256 })
}
}
}
await visit(directory, '')
return { files: entries.length, bytes: entries.reduce((sum, file) => sum + file.bytes, 0), sha256: hash(canonical(entries.sort((a, b) => a.name.localeCompare(b.name)))) }
}
function inspect(path) {
const db = new DatabaseSync(path, { readOnly: true })
try {
db.exec('BEGIN')
const tables = {}
for (const { name } of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) {
const columns = db.prepare(`PRAGMA table_info(${quote(name)})`).all().map(column => column.name)
tables[name] = { count: db.prepare(`SELECT COUNT(*) AS n FROM ${quote(name)}`).get().n, columns }
}
return { version: db.prepare('PRAGMA user_version').get().user_version,
pageCount: db.prepare('PRAGMA page_count').get().page_count, pageSize: db.prepare('PRAGMA page_size').get().page_size,
freePages: db.prepare('PRAGMA freelist_count').get().freelist_count, tables }
} finally { db.close() }
}
// Hash every existing table/column, including IDs, order columns, provenance and references.
// Sorting row digests makes physical SQLite row order irrelevant without retaining bodies.
function snapshot(path, restoreSubagentPayload) {
const started = performance.now()
const blockDigests = new WeakMap()
const progressDigests = new WeakMap()
const db = new DatabaseSync(path, { readOnly: true })
try {
db.exec('BEGIN')
assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check, 'ok', 'integrity check failed')
const metadata = inspect(path)
const tables = {}
for (const [name, table] of Object.entries(metadata.tables)) {
console.log(`Hashing ${name}: ${table.count} rows`)
const rows = []
let previousTask, blocks = new Map()
const order = name === 'task_events' ? ' ORDER BY task_id, id' : ''
for (const row of db.prepare(`SELECT * FROM ${quote(name)}${order}`).iterate()) {
if (name === 'task_events' && row.kind === 'subagent') {
if (previousTask !== row.task_id) { blocks = new Map(); previousTask = row.task_id }
const restored = restoreSubagentPayload(JSON.parse(row.payload_json), blocks)
if (Array.isArray(restored?.progress)) {
// Production restoration shares unchanged immutable blocks. Hash their
// canonical content once, preserving the full ordered progress digest.
let progressDigest = progressDigests.get(restored.progress)
if (!progressDigest) {
progressDigest = hash(restored.progress.map(block => {
let digest = blockDigests.get(block)
if (!digest) { digest = hash(canonical(block)); blockDigests.set(block, digest) }
return digest
}).join('\n'))
progressDigests.set(restored.progress, progressDigest)
}
row.payload_json = canonical({ ...restored, progress: { count: restored.progress.length, sha256: progressDigest } })
} else row.payload_json = canonical(restored)
}
rows.push(hash(canonical(row)))
if (rows.length % 100_000 === 0) {
console.log(`Hashed ${name}: ${rows.length}/${table.count}`)
assert.ok(performance.now() - started < 600_000, 'snapshot deadline exceeded')
}
}
tables[name] = { count: table.count, sha256: hash(rows.sort().join('\n')) }
}
const violations = db.prepare('PRAGMA foreign_key_check').all()
return { ...metadata, tables, durationMs: performance.now() - started, integrity: 'ok', foreignKeyViolations: violations.length,
foreignKeyDigest: hash(canonical(violations)) }
} finally { db.close() }
}
if (process.argv.includes('--verify-source')) {
const result = JSON.parse(await readFile(join(temporary, 'summary.json'), 'utf8'))
assert.equal(canonical(await sourceFingerprint()), canonical(result.sourceBefore), 'original database files changed')
if (result.notesBefore) assert.deepEqual(await contentFingerprint(join(source, 'notes')), result.notesBefore, 'original notes changed')
assert.ok(Object.values(result.comparison).every(value => value.changed.length === 0))
console.log('Original DB/WAL/SHM fingerprints and notes still match; saved canonical comparisons have no changed tables')
process.exit(0)
}
const summary = { startedAt: new Date().toISOString(), externalModelCalls: 0,
environment: { platform: platform(), release: release(), cpu: cpus()[0]?.model, memoryBytes: totalmem(), node: process.version,
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 10_000 }).trim(), dirtyWorktree: true },
caveat: 'Per-database read-only SQLite online backup includes committed WAL. Sequential databases/files are not a cross-database transaction. Live application writes may change fingerprints; no source checkpoint or initialization is performed.' }
let before
let ownsRun = false
try {
console.log('Fingerprinting original database files')
await mkdir(temporary, { recursive: true })
before = await sourceFingerprint()
summary.sourceBefore = before
await writeFile(join(temporary, 'source-before.json'), JSON.stringify(before, null, 2))
console.log('Inspecting database metadata')
if (resume) summary.metadata = Object.fromEntries(names.map(name => [name, inspect(join(run, 'backup', name))]))
if (process.argv.includes('--inspect')) {
console.log(JSON.stringify(summary, null, 2))
} else {
// Never remove a previous run implicitly: it may belong to another validator.
if (!resume) await mkdir(run)
ownsRun = true
if (!resume) await mkdir(join(run, 'backup'))
if (resume) await rm(join(run, 'work'), { recursive: true, force: true })
await mkdir(join(run, 'work'))
for (const name of names) {
console.log(`${resume ? 'Reusing backup' : 'Backing up'} ${name}`)
if (!resume) {
// Capture main + WAL without letting SQLite touch the original SHM. Require
// stable source fingerprints across capture before using the online backup.
await mkdir(join(run, 'capture'), { recursive: true })
await cp(join(source, name), join(run, 'capture', name))
if (before[name + '-wal']) await cp(join(source, name + '-wal'), join(run, 'capture', name + '-wal'))
assert.deepEqual(await fingerprint(join(source, name)), before[name], 'source changed during capture')
assert.deepEqual(await fingerprint(join(source, name + '-wal')), before[name + '-wal'], 'WAL changed during capture')
const db = new DatabaseSync(join(run, 'capture', name), { readOnly: true })
try { await backup(db, join(run, 'backup', name)) } finally { db.close() }
}
await cp(join(run, 'backup', name), join(run, 'work', name))
}
summary.metadata = Object.fromEntries(names.map(name => [name, inspect(join(run, 'backup', name))]))
assert.ok(summary.metadata['assistant.sqlite'].tables.messages.count > 0, 'real messages required')
// Notes use relative file-backed content. No settings/credential files are copied.
if (await stat(join(source, 'notes')).catch(() => undefined)) {
summary.notesBefore = await contentFingerprint(join(source, 'notes'))
await cp(join(source, 'notes'), join(run, 'work/notes'), { recursive: true })
assert.deepEqual(await contentFingerprint(join(run, 'work/notes')), summary.notesBefore, 'note copy differs')
}
const assets = new DatabaseSync(join(run, 'backup/conversation-attachments.sqlite'), { readOnly: true })
summary.attachments = { referenced: 0, copied: 0, missing: 0, outsideRoot: 0, verifiedBytes: 0 }
const attachmentFiles = []
try {
for (const row of assets.prepare('SELECT path FROM attachments').iterate()) {
summary.attachments.referenced++
const relative = row.path.replaceAll('\\', '/')
const from = resolve(source, relative)
if (!from.startsWith(source + '/'.replace('/', process.platform === 'win32' ? '\\' : '/'))) { summary.attachments.outsideRoot++; continue }
const info = await stat(from).catch(() => undefined)
if (!info) { summary.attachments.missing++; continue }
await cp(from, join(run, 'work', relative), { recursive: true })
const directory = info.isDirectory()
const file = await (directory ? contentFingerprint(from) : fingerprint(from))
assert.equal((await (directory ? contentFingerprint(join(run, 'work', relative)) : fingerprint(join(run, 'work', relative)))).sha256, file.sha256, 'attachment copy differs')
attachmentFiles.push({ relative, file, directory })
summary.attachments.verifiedBytes += file.bytes
summary.attachments.copied++
}
} finally { assets.close() }
for (const [entry, output, format] of [
['src/main/desktop-storage-entry.ts', 'desktop-storage-entry.mjs', 'esm'],
['src/main/readonly-query-worker.ts', 'readonly-query-worker.cjs', 'cjs'],
['src/main/assistant-storage-worker.ts', 'assistant-storage-worker.cjs', 'cjs'],
['src/main/assistant/subagent-progress-storage.ts', 'canonical.mjs', 'esm'],
['tests/support/portable-storage-upgrade-fixture.ts', 'main.mjs', 'esm']
]) await build({ entryPoints: [join(root, entry)], outfile: join(run, output), bundle: true, platform: 'node', format,
external: ['electron', '@napi-rs/canvas'], logLevel: 'silent' })
const { restoreSubagentPayload } = await import(pathToFileURL(join(run, 'canonical.mjs')).href)
summary.backup = Object.fromEntries(await Promise.all(names.map(async name => [name, { file: await fingerprint(join(run, 'backup', name)), snapshot: snapshot(join(run, 'backup', name), restoreSubagentPayload) }])))
await writeFile(join(temporary, 'summary.json'), JSON.stringify(summary, null, 2))
const env = { ...process.env, GB_PORTABLE_UPGRADE_ROOT: run, TEMP: run, TMP: run, TMPDIR: run }
delete env.ELECTRON_RUN_AS_NODE
async function fixture(phase) {
console.log(`Production fixture: ${phase}`)
await rm(join(run, 'fixture-summary.json'), { force: true })
const child = spawn(createRequire(import.meta.url)('electron'), [join(run, 'main.mjs')], { env: { ...env, GB_PORTABLE_UPGRADE_PHASE: phase }, stdio: 'ignore' })
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
if (process.platform === 'win32') execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { timeout: 10_000, stdio: 'ignore' })
else child.kill('SIGKILL')
}, 180_000)
const progress = setInterval(() => {
void readFile(join(run, 'fixture-summary.json'), 'utf8').then(text => console.log(`Fixture stage: ${JSON.parse(text).stage}`)).catch(() => {})
}, 10_000)
const code = await new Promise((resolveExit, reject) => { child.once('exit', resolveExit); child.once('error', reject) }).finally(() => { clearTimeout(timer); clearInterval(progress) })
const result = JSON.parse(await readFile(join(run, 'fixture-summary.json'), 'utf8'))
summary.fixture = result
assert.equal(timedOut, false, 'production fixture deadline exceeded')
assert.equal(code, 0, 'production fixture failed; see sanitized stage')
return result
}
summary.initialization = await fixture('initialize')
summary.after = Object.fromEntries(names.map(name => [name, snapshot(join(run, 'work', name), restoreSubagentPayload)]))
summary.comparison = {}
for (const name of names) {
const original = summary.backup[name].snapshot
const current = summary.after[name]
summary.comparison[name] = { unchanged: [], changed: [], added: Object.keys(current.tables).filter(table => !original.tables[table]) }
for (const [table, value] of Object.entries(original.tables)) {
summary.comparison[name][canonical(value) === canonical(current.tables[table]) ? 'unchanged' : 'changed'].push(table)
}
assert.equal((await fingerprint(join(run, 'backup', name))).sha256, summary.backup[name].file.sha256, 'backup changed')
assert.equal(current.foreignKeyDigest, original.foreignKeyDigest, 'foreign key violations changed')
assert.equal(summary.comparison[name].changed.length, 0, 'existing content changed; review startup recovery before accepting')
}
summary.exercise = await fixture('exercise')
summary.finalIntegrity = {}
for (const name of names) {
const db = new DatabaseSync(join(run, 'work', name), { readOnly: true })
try {
const integrity = db.prepare('PRAGMA integrity_check').get().integrity_check
const violations = db.prepare('PRAGMA foreign_key_check').all()
assert.equal(integrity, 'ok')
assert.equal(hash(canonical(violations)), summary.backup[name].snapshot.foreignKeyDigest)
summary.finalIntegrity[name] = { integrity, foreignKeyViolations: violations.length }
} finally { db.close() }
}
for (const { relative, file, directory } of attachmentFiles) {
const read = directory ? contentFingerprint : fingerprint
assert.deepEqual(await read(join(source, relative)), file, 'original attachment changed')
assert.equal((await read(join(run, 'work', relative)))?.sha256, file.sha256, 'work attachment changed')
}
summary.attachments.unchangedAfter = true
if (summary.notesBefore) {
assert.deepEqual(await contentFingerprint(join(source, 'notes')), summary.notesBefore, 'original notes changed')
assert.deepEqual(await contentFingerprint(join(run, 'work/notes')), summary.notesBefore, 'work notes changed')
summary.notesUnchanged = true
}
summary.workFiles = Object.fromEntries(await Promise.all(names.map(async name => [name, await fingerprint(join(run, 'work', name))])))
summary.status = 'passed'
}
} catch (error) {
summary.status = 'failed'
// Error messages, stacks and assertions can contain private row data or paths.
summary.failure = { name: error?.name, code: error?.code, stage: summary.fixture?.stage ?? (summary.backup ? 'validation' : 'backup/setup') }
process.exitCode = 1
} finally {
if (before) {
summary.sourceAfter = await sourceFingerprint()
summary.sourceUnchanged = canonical(before) === canonical(summary.sourceAfter)
if (!summary.sourceUnchanged) { summary.status = 'failed: source fingerprints changed'; process.exitCode = 1 }
}
if (!process.argv.includes('--inspect')) {
if (ownsRun) await rm(run, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
summary.copiesRemoved = ownsRun
summary.durationMs = Date.now() - Date.parse(summary.startedAt)
summary.reusedBackup = resume
await writeFile(join(temporary, 'summary.json'), JSON.stringify(summary, null, 2))
console.log(JSON.stringify({ status: summary.status, failure: summary.failure, sourceUnchanged: summary.sourceUnchanged, copiesRemoved: summary.copiesRemoved }))
}
}