Files
goodbuddy/scripts/final-regression.mjs
T
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

146 lines
6.2 KiB
JavaScript

/* global process, console, setTimeout, clearTimeout */
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const script = fileURLToPath(import.meta.url)
const root = resolve(dirname(script), '..')
const base = join(root, 'temp', 'goodbuddy-final-tests-final')
const stateFile = join(base, 'status.json')
const lockFile = join(base, 'runner.lock')
const npm = join(root, 'node_modules', 'npm', 'bin', 'npm-cli.js')
const [mode = 'status', selection = 'all', ...filters] = process.argv.slice(2)
const alive = (pid) => {
try { process.kill(pid, 0); return true } catch { return false }
}
const readState = () => JSON.parse(readFileSync(stateFile, 'utf8'))
const killTree = (pid) => {
if (!pid || !alive(pid)) return
if (process.platform === 'win32') {
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 15000 })
} else {
try { process.kill(-pid, 'SIGKILL') } catch { /* Already exited. */ }
}
}
if (mode === 'start') {
if (!['all', 'test', 'typecheck', 'lint'].includes(selection)) throw new Error('Unknown validation selection')
if (!statSync(join(root, 'temp')).isDirectory()) throw new Error('Repository temp directory is required')
mkdirSync(base, { recursive: true })
if (existsSync(lockFile)) {
const pid = Number(readFileSync(lockFile, 'utf8'))
if (alive(pid)) throw new Error(`Validation runner already active: ${pid}`)
rmSync(lockFile)
}
const lock = openSync(lockFile, 'wx')
writeFileSync(lock, String(process.pid))
closeSync(lock)
const log = openSync(join(base, 'runner.log'), 'a')
try {
const child = spawn(process.execPath, [script, 'worker', selection, ...filters], {
cwd: root, detached: true, windowsHide: true, stdio: ['ignore', log, log]
})
await new Promise((resolveSpawn, reject) => {
child.once('spawn', resolveSpawn)
child.once('error', reject)
})
writeFileSync(lockFile, String(child.pid))
child.unref()
console.log(JSON.stringify({ runnerPid: child.pid, selection, logs: base }))
} catch (error) {
rmSync(lockFile, { force: true })
throw error
} finally { closeSync(log) }
} else if (mode === 'status') {
const state = readState()
console.log(JSON.stringify({ ...state, runnerAlive: alive(state.pid) }, null, 2))
} else if (mode === 'stop') {
const state = readState()
if (state.status === 'running') {
killTree(state.childPid)
killTree(state.pid)
state.status = 'stopped'
state.endedAt = new Date().toISOString()
writeFileSync(stateFile, JSON.stringify(state, null, 2))
rmSync(lockFile, { force: true })
}
} else if (mode === 'worker') {
const run = join(base, new Date().toISOString().replace(/[:.]/g, '-'))
const tmp = join(run, 'tmp')
mkdirSync(tmp, { recursive: true })
if (!statSync(tmp).isDirectory()) throw new Error('Child temp directory was not created')
const state = { pid: process.pid, status: 'running', startedAt: new Date().toISOString(), run, results: [] }
const save = () => writeFileSync(stateFile, JSON.stringify(state, null, 2))
let child
let interrupted = false
const interrupt = (reason) => {
interrupted = true
state.reason = reason
killTree(child?.pid)
}
process.on('SIGINT', () => interrupt('SIGINT'))
process.on('SIGTERM', () => interrupt('SIGTERM'))
process.on('exit', () => killTree(child?.pid))
const deadline = setTimeout(() => interrupt('30-minute overall deadline exceeded'), 30 * 60 * 1000)
save()
try {
for (const job of selection === 'all' ? ['test', 'typecheck', 'lint'] : [selection]) {
if (interrupted) break
const started = Date.now()
const report = join(run, 'tests.json')
const args = job === 'test'
? ['test', '--', ...filters, '--reporter=default', '--reporter=json', `--outputFile.json=${report}`]
: ['run', job]
const logPath = join(run, `${job}.log`)
const log = openSync(logPath, 'w')
try {
child = spawn(process.execPath, [npm, ...args], {
cwd: root, windowsHide: true, detached: process.platform !== 'win32',
env: { ...process.env, TEMP: join(tmpdir(), 'goodbuddy-final-tests-final'), TMP: join(tmpdir(), 'goodbuddy-final-tests-final'), TMPDIR: join(tmpdir(), 'goodbuddy-final-tests-final'), NO_COLOR: '1' },
stdio: ['ignore', log, log]
})
state.active = job
state.childPid = child.pid
save()
const result = await new Promise((resolveExit, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolveExit({ code, signal }))
})
const entry = { job, ...result, seconds: (Date.now() - started) / 1000, log: logPath }
if (job === 'test' && existsSync(report)) {
const data = JSON.parse(readFileSync(report, 'utf8'))
entry.totals = Object.fromEntries(Object.entries(data).filter(([key]) => key.startsWith('num') || key === 'success'))
entry.failedFiles = data.testResults.filter((file) => file.status === 'failed').map((file) => ({
file: file.name.replace(root, '').replaceAll('\\', '/'),
tests: file.assertionResults.filter((test) => test.status === 'failed').map((test) => test.fullName)
}))
}
state.results.push(entry)
} finally {
killTree(child?.pid)
child = undefined
closeSync(log)
delete state.childPid
delete state.active
save()
}
}
state.status = interrupted ? 'interrupted' : 'completed'
} catch (error) {
state.status = 'error'
state.reason = error.message
} finally {
clearTimeout(deadline)
killTree(child?.pid)
try { rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }) }
catch (error) { state.cleanupError = error.code }
state.endedAt = new Date().toISOString()
save()
rmSync(lockFile, { force: true })
}
} else {
throw new Error('Usage: node scripts/final-regression.mjs start [all|test|typecheck|lint] [test filters...] | status | stop')
}