Files
goodbuddy/src/main/assistant/supervision-ui-reads.test.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

233 lines
16 KiB
TypeScript

// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { build } from 'esbuild'
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from './assistant-storage-upgrade'
import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
import type { SupervisionSummaryOutput } from '../../shared/supervision-contracts'
import { ReadonlyQueryReader } from '../readonly-query-reader'
let directory: string, workerPath: string
const cleanups: Array<() => void | Promise<void>> = []
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'supervision-ui-reads-'))
workerPath = join(directory, 'worker.cjs')
await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: workerPath,
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
}, 60_000)
afterEach(async () => { vi.restoreAllMocks(); for (const close of cleanups.splice(0)) await close() })
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10 }) })
const at = '2026-10-01T12:00:00.000Z'
const request = { trigger: 'manual' as const, scope: { kind: 'global' as const }, timeRange: { from: at, to: at } }
const empty: SupervisionSummaryOutput = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
function fixture() {
const path = join(directory, `${randomUUID()}.sqlite`)
const db = new AssistantDatabase(path)
db.initialize(directory)
const sql = (db as unknown as { database: DatabaseSync }).database
cleanups.push(() => db.close())
const project = db.listProjects()[0]!
const other = db.createProject({ name: 'Other', description: '', rootPath: directory })
db.saveSupervisionResult({ request,
evidence: Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, sourceType: 'conversation' as const, sourceId: 'conversation',
title: `Source ${i}`, content: `Full source ${i} ${'body'.repeat(2000)}`, occurredAt: at,
locator: { projectId: i < 3 ? project.id : other.id } })),
output: { ...empty, events: Array.from({ length: 6 }, (_, i) => ({ title: `Event ${i}`, description: 'Description', occurredAt: at,
eventType: 'decision' as const, sourceReferenceIds: [`s${i}`], entityIds: [] })) } })
const result = db.listSupervisionResults()[0]!
const events = sql.prepare('SELECT id, project_id FROM supervision_events ORDER BY title').all()
for (const [i, event] of events.entries()) {
sql.prepare(`INSERT INTO supervision_stories (id, project_id, level, name, created_at, updated_at)
VALUES (?, ?, 'feature', ?, ?, ?)`).run(`story${i}`, String(event.project_id), `Story ${i}`, at, at)
sql.prepare('INSERT INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, 1)').run(String(event.id), `story${i}`)
sql.prepare('INSERT INTO supervision_experiences (id, statement, created_at, updated_at) VALUES (?, ?, ?, ?)').run(`experience${i}`, `Experience ${i}`, at, at)
sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES (?, ?, 'formed')").run(`experience${i}`, String(event.id))
}
// A cross-project experience must retain all links, not only the selected project's links.
sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES ('experience0', ?, 'applied')").run(String(events[3]!.id))
return { db, sql, path, project, other, result, events }
}
it('returns source headers only in both graph paths while retaining full source detail and source order', () => {
const f = fixture()
const expected = f.sql.prepare('SELECT id, title, occurred_at FROM supervision_sources ORDER BY rowid').all()
for (const input of [{ resultId: f.result.id }, { storyLineId: f.result.storyLineId! }]) {
expect(f.db.getSupervisionGraph(input).sources).toEqual(expected)
}
for (const source of expected) expect(f.db.getSupervisionSource(String(source.id))?.content).toBe(`Full source ${String(source.title).slice(-1)} ${'body'.repeat(2000)}`)
})
it('reads story memberships and scoped experiences in a constant number of SQL statements', () => {
const f = fixture()
let queries = 0
const prepare = f.sql.prepare.bind(f.sql)
vi.spyOn(f.sql, 'prepare').mockImplementation(sql => {
const statement = prepare(sql)
const all = statement.all.bind(statement)
vi.spyOn(statement, 'all').mockImplementation((...args) => { queries++; return all(...args) })
return statement
})
const stories = f.db.supervisionStories().list({ kind: 'global' })
expect(stories.map(story => [story.name, story.events.map(event => event.title)])).toEqual(
Array.from({ length: 6 }, (_, i) => [`Story ${i}`, [`Event ${i}`]]))
expect(queries).toBe(2)
queries = 0
const experiences = f.db.supervisionExperiences().list([f.other.id])
expect(experiences.map(item => item.statement)).toEqual(['Experience 0', 'Experience 3', 'Experience 4', 'Experience 5'])
expect(experiences[0]!.events.map(event => [event.title, event.role, event.storyName])).toEqual(
[0, 3].sort((a, b) => String(f.events[a]!.id).localeCompare(String(f.events[b]!.id))).map(i => [`Event ${i}`, i ? 'applied' : 'formed', `Story ${i}`]))
expect(queries).toBe(2)
queries = 0
expect(f.db.supervisionExperiences().list(['unrelated'])).toEqual([])
expect(queries).toBe(1)
})
it('serves UI reads on the real worker and observes writes committed before the next read', async () => {
const f = fixture()
const overview = f.db.listSupervisionResults()
const graph = f.db.getSupervisionGraph({ resultId: f.result.id })
const stories = { stories: f.db.supervisionStories().list(request.scope), experiences: f.db.supervisionExperiences().list(),
unassigned: f.db.supervisionStories().unassignedCount(request.scope), canUndo: false }
const reader = new ReadonlyQueryReader('assistant', f.path, workerPath)
cleanups.unshift(() => reader.close())
const syncOverview = vi.spyOn(f.db, 'listSupervisionResults').mockImplementation(() => { throw new Error('Main overview') })
const syncGraph = vi.spyOn(f.db, 'getSupervisionGraph').mockImplementation(() => { throw new Error('Main graph') })
const syncStories = vi.spyOn(f.db, 'supervisionStories').mockImplementation(() => { throw new Error('Main stories') })
expect(await reader.call('supervisionOverview', [])).toEqual(overview)
expect(await reader.call('supervisionGraph', [{ resultId: f.result.id }])).toEqual(graph)
expect(await reader.call('supervisionStories', [request.scope])).toEqual(stories)
f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('Committed', f.result.id)
expect((await reader.call<ReturnType<AssistantDatabase['listSupervisionResults']>>('supervisionOverview', []))[0]!.summary).toBe('Committed')
await expect(reader.call('supervisionGraph', [{ resultId: f.result.id, storyLineId: 'wrong' }])).rejects.toThrow('不匹配')
await reader.terminateWorkerForTest()
await expect(reader.call('supervisionOverview', [])).rejects.toThrow('backing off')
await expect(reader.call('supervisionGraph', [{ resultId: f.result.id }])).rejects.toThrow('backing off')
await expect(reader.call('supervisionStories', [request.scope])).rejects.toThrow('backing off')
expect(syncOverview).not.toHaveBeenCalled()
expect(syncGraph).not.toHaveBeenCalled()
expect(syncStories).not.toHaveBeenCalled()
})
it('rejects missing production worker configuration instead of performing a synchronous UI read', async () => {
const f = fixture()
const reader = new ReadonlyQueryReader('assistant', f.path, join(directory, 'missing.cjs'))
cleanups.unshift(() => reader.close())
await expect(reader.call('supervisionOverview', [])).rejects.toThrow()
})
it('keeps a multi-statement UI read on one WAL snapshot while another connection commits', () => {
const f = fixture()
const reader = new AssistantDatabase(f.path)
reader.openReadOnly()
cleanups.push(() => reader.close())
reader.readSnapshot(() => {
expect(reader.listSupervisionResults()[0]!.summary).toBe('Review')
f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('New summary', f.result.id)
f.sql.prepare("UPDATE supervision_stories SET name = 'Renamed' WHERE id = 'story0'").run()
expect(reader.listSupervisionResults()[0]!.summary).toBe('Review')
expect(reader.getSupervisionStories(request.scope).stories[0]!.name).toBe('Story 0')
})
expect(reader.listSupervisionResults()[0]!.summary).toBe('New summary')
expect(reader.getSupervisionStories(request.scope).stories.some(story => story.name === 'Renamed')).toBe(true)
})
it('upgrades schema 61 indexes without changing rows, rowids, source order or reclaiming free pages', () => {
const f = fixture()
const tables = f.sql.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()
const contents = () => tables.map(row => [row.name, f.sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])
const before = contents()
const sources = f.db.getSupervisionGraph({ resultId: f.result.id }).sources
f.sql.exec(`DROP INDEX IF EXISTS supervision_sources_result;
DROP INDEX IF EXISTS supervision_results_created;
CREATE TABLE disposable (body BLOB); INSERT INTO disposable VALUES (zeroblob(1048576)); DROP TABLE disposable;
PRAGMA user_version = 61`)
expect(f.sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
f.db.close()
expect(getPendingAssistantStorageUpgrade(f.path)).toEqual({ migrateNotes: false, reclaimSpace: false })
const progress = vi.fn()
upgradeAssistantStorage(f.path, progress)
const reopened = new AssistantDatabase(f.path)
reopened.openReadOnly()
cleanups.push(() => reopened.close())
const sql = (reopened as unknown as { database: DatabaseSync }).database
expect(tables.map(row => [row.name, sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])).toEqual(before)
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(sql.prepare("SELECT name FROM sqlite_master WHERE name IN ('supervision_sources_result', 'supervision_results_created') ORDER BY name").all())
.toEqual([{ name: 'supervision_results_created' }, { name: 'supervision_sources_result' }])
expect(reopened.getSupervisionGraph({ resultId: f.result.id }).sources).toEqual(sources)
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
expect(sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
expect(progress.mock.calls.some(([value]) => value.stage === 'compacting' || value.stage === 'converting')).toBe(false)
expect(getPendingAssistantStorageUpgrade(f.path)).toBeUndefined()
upgradeAssistantStorage(f.path, progress)
})
it('publishes one final snapshot for multiple leaves and rolls back results and checkpoints together', () => {
const f = fixture()
const runId = f.db.startSupervisionRun(request)
const store = f.db.supervisionReviewStore()
f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) },
messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }])
store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' })
for (let i = 0; i < 2; i++) {
const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)!
store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at,
locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }],
{ ...empty, entities: [{ id: 'entity', label: `Entity ${i}`, description: 'Original', sourceReferenceIds: ['source'] }] })
}
f.sql.exec(`CREATE TRIGGER snapshot_once BEFORE UPDATE OF graph_snapshot_json ON supervision_results
WHEN OLD.graph_snapshot_json != '{"entities":[],"relations":[]}' BEGIN SELECT RAISE(ABORT, 'Repeated graph snapshot'); END;
CREATE TRIGGER fail_checkpoints BEFORE INSERT ON review_checkpoints BEGIN SELECT RAISE(ABORT, 'Checkpoint failed'); END`)
const publication = { request, runId, coverage: store.progress(runId), evidence: [], output: empty }
expect(() => f.db.saveSupervisionResult(publication)).toThrow('Checkpoint failed')
expect(f.db.listSupervisionResults()).toHaveLength(1)
expect(f.sql.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)!.status).toBe('running')
expect(f.sql.prepare('SELECT * FROM review_checkpoints').all()).toEqual([])
f.sql.exec('DROP TRIGGER fail_checkpoints')
f.db.saveSupervisionResult(publication)
const latest = f.db.listSupervisionResults()[0]!
const graph = f.db.getSupervisionGraph({ resultId: latest.id })
expect(graph.entities).toHaveLength(2)
expect(graph.sources).toHaveLength(2)
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(2)
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
})
it('retains first-leaf snapshot values and source references when persisted identities recur across leaves', () => {
const f = fixture()
f.db.saveSupervisionResult({ request, evidence: [], output: { ...empty,
entities: ['A', 'B'].map(id => ({ id, label: id, description: 'Original', sourceReferenceIds: [] })),
relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: 'Original', sourceReferenceIds: [] }] } })
const candidates = f.db.listSupervisionCandidates(request)
const a = candidates.find(item => item.label === 'A')!, b = candidates.find(item => item.label === 'B')!
f.db.applySupervisionEntityAction({ entityId: a.id, action: 'confirm' })
const runId = f.db.startSupervisionRun(request)
const store = f.db.supervisionReviewStore()
f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) },
messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }])
store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' })
for (let i = 0; i < 2; i++) {
const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)!
store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at,
locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }], { ...empty,
entities: [a, b].map(item => ({ id: item.label, persistedId: item.id, label: item.label, description: `Leaf ${i}`, sourceReferenceIds: ['source'] })),
relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: `Leaf ${i}`, sourceReferenceIds: ['source'] }] })
}
f.db.saveSupervisionResult({ request, runId, candidates, coverage: store.progress(runId), evidence: [], output: empty })
const result = f.db.listSupervisionResults()[0]!
const graph = f.db.getSupervisionGraph({ resultId: result.id })
const sourceId = (graph.sources as Array<{ id: string; title: string }>).find(source => source.title === 'Leaf 0')!.id
expect(graph.entities).toEqual([a, b].sort((x, y) => x.id < y.id ? -1 : 1).map(item => expect.objectContaining({ id: item.id,
description: item.id === a.id ? 'Original' : 'Leaf 0', confirmation_state: item.id === a.id ? 'confirmed' : 'automatic',
source_reference_ids_json: JSON.stringify([sourceId]) })))
expect(graph.relations).toEqual([expect.objectContaining({ reason: 'Leaf 0', source_reference_ids_json: JSON.stringify([sourceId]) })])
expect(f.sql.prepare('SELECT description FROM supervision_entities WHERE id = ?').get(b.id)!.description).toBe('Leaf 1')
})