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.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
/* 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')
|
||||
}
|
||||
Reference in New Issue
Block a user