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.
137 lines
9.5 KiB
TypeScript
137 lines
9.5 KiB
TypeScript
// @vitest-environment node
|
|
import { randomUUID } from 'node:crypto'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { afterEach, expect, it, vi } from 'vitest'
|
|
import { AssistantDatabase } from './assistant-database'
|
|
import { extractExperiences } from './experience-extraction-service'
|
|
import { assignStories } from './story-assignment-service'
|
|
|
|
const cleanups: Array<() => Promise<void>> = []
|
|
afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup() })
|
|
|
|
type Kind = 'decision' | 'change' | 'discussion' | 'milestone'
|
|
|
|
async function fixture() {
|
|
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-experiences-'))
|
|
const path = join(directory, 'assistant.sqlite')
|
|
const db = new AssistantDatabase(path)
|
|
db.initialize(directory)
|
|
const sql = new DatabaseSync(path)
|
|
cleanups.push(async () => { sql.close(); db.close(); await rm(directory, { recursive: true, force: true }) })
|
|
const project = db.listProjects()[0]!
|
|
// Publishes events of one project in time order, then places them all in one feature story.
|
|
// Every event mentions the entity `topic`; an application must share an entity with the formation.
|
|
const publish = async (items: Array<[string, Kind]>, day: number, story = 'Review scheduling', topic = 'Long jobs') => {
|
|
const request = { trigger: 'manual' as const, scope: { kind: 'projects' as const, projectIds: [project.id] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-10-01T00:00:00Z' } }
|
|
const candidates = db.listSupervisionCandidates(request)
|
|
const known = candidates.find(entity => entity.label === topic)
|
|
db.saveSupervisionResult({
|
|
request, candidates,
|
|
evidence: items.map(([title], index) => ({ id: `s${index}`, sourceType: 'conversation' as const, sourceId: 'c', title, content: title,
|
|
occurredAt: `2026-09-${day}T0${index}:00:00.000Z`, locator: { source: `message:${randomUUID()}`, revision: 'r', projectId: project.id, start: 0, end: title.length } })),
|
|
output: { summary: 'x', changeDigest: '', openItems: [], entityChanges: [], relations: [],
|
|
entities: [{ id: 'topic', label: topic, description: topic, sourceReferenceIds: ['s0'], ...(known ? { persistedId: known.id } : {}) }],
|
|
events: items.map(([title, eventType], index) => ({ title, description: title, occurredAt: `2026-09-${day}T0${index}:00:00.000Z`, eventType, entityIds: ['topic'], sourceReferenceIds: [`s${index}`] })) }
|
|
})
|
|
const existing = db.supervisionStories().candidates(project.id, false).find(row => row.name === story)
|
|
await assignStories(db.supervisionStories(), async prompt => {
|
|
const count = prompt.split('\n').filter(line => line.startsWith('{"ref":"e_')).length
|
|
return JSON.stringify(existing
|
|
? { assignments: Array.from({ length: count }, (_, i) => ({ event: `e_${i + 1}`, story: 'story_1' })) }
|
|
: { stories: [{ key: 'new_1', level: 'feature', name: story }], assignments: Array.from({ length: count }, (_, i) => ({ event: `e_${i + 1}`, story: 'new_1' })) })
|
|
}, { kind: 'global' }, { crossProject: false, threadEvents: 50, batchCharacters: 16000, concurrency: 1 })
|
|
}
|
|
return { db, sql, project, publish, store: db.supervisionExperiences() }
|
|
}
|
|
|
|
const options = { minEvents: 3, batchCharacters: 16000 }
|
|
|
|
it('extracts evidenced experiences from rule candidates, records later applications and skips quiet stories', async () => {
|
|
const f = await fixture()
|
|
await f.publish([['Pause reviews after 300 seconds', 'decision'], ['Users had to click continue repeatedly', 'discussion'], ['Run reviews until complete', 'change']], 20)
|
|
const first = vi.fn(async (prompt: string) => {
|
|
expect(prompt).toContain('Review scheduling')
|
|
expect(prompt).not.toContain('message:')
|
|
return JSON.stringify({ experiences: [{ key: 'new_1', statement: 'Let long jobs run; pause only on request', conditions: 'Background work', boundaries: '', formed: ['e_1', 'e_3'] }] })
|
|
})
|
|
await expect(extractExperiences(f.store, first, options)).resolves.toMatchObject({ calls: 1, candidates: 1, created: 1, applied: 0 })
|
|
// Nothing new: no call.
|
|
await expect(extractExperiences(f.store, first, options)).resolves.toMatchObject({ calls: 0, candidates: 0 })
|
|
// A later decision applies it; the model sees the existing experience as x_1.
|
|
await f.publish([['Keep heartbeat running until done', 'decision']], 25)
|
|
const later = vi.fn(async (prompt: string) => {
|
|
expect(prompt).toContain('"ref":"x_1"')
|
|
return JSON.stringify({ applications: [{ experience: 'x_1', event: 'e_4', note: 'No manual continue needed' }] })
|
|
})
|
|
await expect(extractExperiences(f.store, later, options)).resolves.toMatchObject({ calls: 1, created: 0, applied: 1 })
|
|
const [experience] = f.store.list()
|
|
expect(experience).toMatchObject({ statement: 'Let long jobs run; pause only on request', userEdited: false })
|
|
expect(experience!.events.map(event => [event.role, event.title, event.storyName])).toEqual([
|
|
['formed', 'Pause reviews after 300 seconds', 'Review scheduling'], ['formed', 'Run reviews until complete', 'Review scheduling'],
|
|
['applied', 'Keep heartbeat running until done', 'Review scheduling']])
|
|
expect(f.store.list([randomUUID()])).toEqual([])
|
|
})
|
|
|
|
it('makes no call for small stories or discussion-only progress', async () => {
|
|
const f = await fixture()
|
|
await f.publish([['Decide one thing', 'decision'], ['Talk', 'discussion']], 20)
|
|
const model = vi.fn(async () => '{}')
|
|
await expect(extractExperiences(f.store, model, options)).resolves.toMatchObject({ calls: 0, candidates: 0 })
|
|
// Growing past the minimum with discussion only still costs nothing: the earlier decision was already seen as small.
|
|
await f.publish([['More talk', 'discussion'], ['Even more', 'discussion']], 21)
|
|
await expect(extractExperiences(f.store, model, options)).resolves.toMatchObject({ calls: 0 })
|
|
expect(model).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects invented evidence and saves nothing from a rejected answer; drops applications that precede formation', async () => {
|
|
const f = await fixture()
|
|
await f.publish([['A', 'decision'], ['B', 'change'], ['C', 'milestone']], 20)
|
|
for (const bad of [
|
|
{ experiences: [{ key: 'new_1', statement: 'X', formed: ['e_9'] }] },
|
|
{ experiences: [{ key: 'new_1', statement: 'X', formed: [] }] },
|
|
{ applications: [{ experience: 'x_1', event: 'e_1' }] }
|
|
]) await expect(extractExperiences(f.store, async () => JSON.stringify(bad), options)).rejects.toThrow()
|
|
expect(f.store.list()).toEqual([])
|
|
expect(Number(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_experience_seen').get()!.n)).toBe(0)
|
|
await expect(extractExperiences(f.store, async () => JSON.stringify({ experiences: [{ key: 'new_1', statement: 'X', formed: ['e_2'] }],
|
|
applications: [{ experience: 'new_1', event: 'e_1' }, { experience: 'new_1', event: 'e_3', note: 'ok' }] }), options))
|
|
.resolves.toMatchObject({ created: 1, applied: 1 })
|
|
// An existing experience is checked against its stored evidence too.
|
|
await f.publish([['D', 'decision']], 19)
|
|
await extractExperiences(f.store, async () => JSON.stringify({ applications: [{ experience: 'x_1', event: 'e_1' }] }), options)
|
|
expect(f.store.list()[0]!.events.map(event => [event.role, event.title])).toEqual([['formed', 'B'], ['applied', 'C']])
|
|
// A later event about unrelated knowledge is not recorded as an application.
|
|
await f.publish([['Unrelated decision', 'decision']], 25, 'Review scheduling', 'Billing')
|
|
await expect(extractExperiences(f.store, async prompt => {
|
|
const ref = prompt.split('\n').find(line => line.includes('Unrelated decision'))!.match(/"ref":"(e_\d+)"/)![1]
|
|
return JSON.stringify({ applications: [{ experience: 'x_1', event: ref, note: 'loosely related' }] })
|
|
}, options)).resolves.toMatchObject({ applied: 0 })
|
|
})
|
|
|
|
it('protects edits, keeps removed experiences removed and undoes adjustments', async () => {
|
|
const f = await fixture()
|
|
await f.publish([['A', 'decision'], ['B', 'change'], ['C', 'milestone']], 20)
|
|
await extractExperiences(f.store, async () => JSON.stringify({ experiences: [
|
|
{ key: 'new_1', statement: 'Validate first', formed: ['e_1'] }, { key: 'new_2', statement: 'Ship small', formed: ['e_2'] }] }), options)
|
|
const stories = f.db.supervisionStories()
|
|
const [first, second] = f.store.list()
|
|
stories.act({ action: 'experience-edit', experienceId: first!.id, statement: 'Validate the key assumption first', conditions: 'New bets', boundaries: 'Not for fixes' })
|
|
expect(f.store.list()[0]).toMatchObject({ statement: 'Validate the key assumption first', conditions: 'New bets', userEdited: true })
|
|
stories.act({ action: 'experience-merge', experienceId: second!.id, intoId: first!.id })
|
|
expect(f.store.list()).toHaveLength(1)
|
|
expect(f.store.list()[0]!.events.map(event => event.title)).toEqual(['A', 'B'])
|
|
stories.act({ action: 'undo' })
|
|
expect(f.store.list().map(item => item.statement)).toEqual(['Validate the key assumption first', 'Ship small'])
|
|
stories.act({ action: 'experience-remove', experienceId: second!.id })
|
|
// The same statement, or the same evidence under another wording, is not recreated.
|
|
await f.publish([['D', 'decision']], 22)
|
|
await extractExperiences(f.store, async () => JSON.stringify({ experiences: [
|
|
{ key: 'new_1', statement: 'ship small!', formed: ['e_4'] }, { key: 'new_2', statement: 'Keep changes tiny', formed: ['e_2'] }] }), options)
|
|
expect(f.store.list().map(item => item.statement)).toEqual(['Validate the key assumption first'])
|
|
stories.act({ action: 'undo' })
|
|
expect(f.store.list().map(item => item.statement)).toEqual(['Validate the key assumption first', 'Ship small'])
|
|
})
|