Files
goodbuddy/tests/support/portable-storage-upgrade-fixture.ts
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

131 lines
7.6 KiB
TypeScript

import assert from 'node:assert/strict'
import { join } from 'node:path'
import { writeFile } from 'node:fs/promises'
import { randomUUID, createHash } from 'node:crypto'
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
import { app } from 'electron'
import { DesktopStorageClient } from '../../src/main/desktop-storage-client'
import { supervisionGraphViewSchema } from '../../src/shared/supervision-contracts'
const root = process.env.GB_PORTABLE_UPGRADE_ROOT!
const work = join(root, 'work')
app.setPath('userData', join(root, 'electron'))
const report: Record<string, unknown> = { stage: 'startup', electron: process.versions.electron }
const options = { assistantPath: join(work, 'assistant.sqlite'), knowledgePath: join(work, 'knowledge.sqlite'),
defaultRootPath: work, userDataPath: work, entryPath: join(root, 'desktop-storage-entry.mjs'),
readerWorkerPath: join(root, 'readonly-query-worker.cjs'), upgradeWorkerPath: join(root, 'assistant-storage-worker.cjs') }
let storage: DesktopStorageClient | undefined
const digest = (value: unknown): string => createHash('sha256').update(JSON.stringify(value)).digest('hex')
async function stage(value: string): Promise<void> {
report.stage = value
await writeFile(join(root, 'fixture-summary.json'), JSON.stringify(report))
}
async function open(): Promise<DesktopStorageClient> {
const client = new DesktopStorageClient(options)
storage = client
await client.ready
return client
}
async function run(): Promise<void> {
await stage('startup')
await app.whenReady()
const started = performance.now()
let client = await open()
report.initializeMs = performance.now() - started
const projects = await client.call('assistant', 'listProjects', [])
assert.ok(projects.length > 0)
const original = await client.call('assistant', 'listSupervisionResults', [20])
assert.ok(original.length > 0, 'real supervision data required')
const graph = await client.call('assistant', 'getSupervisionGraph', [{ resultId: original[0]!.id }])
report.realRead = { projects: projects.length, results: original.length, graphSha256: digest(graph) }
await stage('reopen')
await client.close()
const reopenedAt = performance.now()
client = await open()
report.reopenMs = performance.now() - reopenedAt
assert.equal(digest(await client.call('assistant', 'getSupervisionGraph', [{ resultId: original[0]!.id }])), digest(graph))
if (process.env.GB_PORTABLE_UPGRADE_PHASE === 'exercise') {
await stage('task-event-dedupe')
const taskId = randomUUID()
await client.call('assistant', 'createTask', [{ id: taskId, title: 'Storage validation', instructions: 'Local deterministic validation', status: 'completed' }])
const event = { taskId, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0,
kind: 'output', payload: { text: 'deterministic validation output' } }
assert.equal(await client.call('assistant', 'appendRemoteTaskEventOnce', [event]), true)
assert.equal(await client.call('assistant', 'appendRemoteTaskEventOnce', [event]), false)
await assert.rejects(client.call('assistant', 'appendRemoteTaskEventOnce', [{ ...event, payload: { text: 'conflict' } }]))
assert.equal(await client.call('assistant', 'getHighestCommittedRemoteTaskEventSequence', [event.bindingId, event.operationId]), '1')
await client.close()
client = await open()
assert.equal(await client.call('assistant', 'appendRemoteTaskEventOnce', [event]), false)
assert.equal(await client.call('assistant', 'getHighestCommittedRemoteTaskEventSequence', [event.bindingId, event.operationId]), '1')
report.dedupe = { insert: true, replay: true, conflict: true, reopenReplay: true }
await stage('overlapping-review-reads-publication')
// Fixed, bounded local workload; not the full C03/UI acceptance fixture.
const delay = monitorEventLoopDelay({ resolution: 1 })
const publicationMs: number[] = [], readMs: number[] = []
const at = '2026-10-05T00:00:00.000Z'
let highWater = 0
delay.enable()
const began = performance.now()
await Promise.all([
(async () => {
for (let batch = 0; batch < 100; batch++) {
const time = performance.now()
await client.call('assistant', 'saveSupervisionResult', [{
request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: at, to: at } },
evidence: Array.from({ length: 20 }, (_, index) => ({ id: `source-${index}`, sourceType: 'task' as const,
sourceId: taskId, title: 'Deterministic source', content: 'x'.repeat(2048), occurredAt: at })),
output: { summary: 'Deterministic review', changeDigest: '', openItems: [], entities: [], entityChanges: [], relations: [],
events: Array.from({ length: 20 }, (_, index) => ({ title: 'Deterministic event', description: '', occurredAt: at,
eventType: 'decision' as const, sourceReferenceIds: [`source-${index}`], entityIds: [] })) }
}])
publicationMs.push(performance.now() - time)
highWater = Math.max(highWater, client.pendingCount)
}
})(),
(async () => {
for (let index = 0; index < 100; index++) {
const time = performance.now()
await client.call('assistant', 'getSupervisionGraph', [{ resultId: original[index % original.length]!.id }])
await client.call('assistant', 'listSupervisionResults', [20])
readMs.push(performance.now() - time)
highWater = Math.max(highWater, client.pendingCount)
}
})()
])
const durationMs = performance.now() - began
delay.disable()
const stats = (values: number[]) => { const sorted = [...values].sort((a, b) => a - b); return {
samples: values.length, p50: sorted[Math.ceil(sorted.length * .5) - 1], p95: sorted[Math.ceil(sorted.length * .95) - 1], max: sorted.at(-1) } }
report.performance = { classification: 'single-run source-harness baseline, not C03 acceptance or before/after improvement',
durationMs, publications: stats(publicationMs), reviewReadPairs: stats(readMs), mainDelayP99Ms: delay.percentile(99) / 1e6,
mainDelayMaxMs: delay.max / 1e6, delaySamples: delay.count, pendingHighWaterObserved: highWater, pendingAfter: client.pendingCount,
batches: 100, sourcesPerBatch: 20, sourceBytes: 2048, mainRssBytes: process.memoryUsage().rss }
const published = await client.call('assistant', 'listSupervisionResults', [1])
const publishedGraph = supervisionGraphViewSchema.parse(await client.call('assistant', 'getSupervisionGraph', [{ resultId: published[0]!.id }]))
assert.equal(publishedGraph.events.length, 20)
assert.equal(publishedGraph.sources.length, 20)
const eventIds = new Set(publishedGraph.events.map(event => event.id))
const sourceIds = new Set(publishedGraph.sources.map(source => source.id))
assert.equal(publishedGraph.eventSources.length, 20)
assert.ok(publishedGraph.eventSources.every(link => eventIds.has(link.event_id) && sourceIds.has(link.source_id)))
await client.close()
client = await open()
assert.equal(digest(supervisionGraphViewSchema.parse(await client.call('assistant', 'getSupervisionGraph', [{ resultId: published[0]!.id }]))), digest(publishedGraph))
report.publicationReopen = true
}
await client.close()
storage = undefined
report.stage = 'passed'
}
void run().then(async () => {
await writeFile(join(root, 'fixture-summary.json'), JSON.stringify(report))
app.exit(0)
}, async () => {
// Never serialize an exception: SQLite/IPC errors may contain private content.
try { await storage?.close() } catch { /* Preserve the failing stage. */ }
await writeFile(join(root, 'fixture-summary.json'), JSON.stringify(report))
app.exit(1)
})