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:
@@ -1,6 +1,9 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { ESLint } from 'eslint'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface ThresholdRow {
|
||||
@@ -21,7 +24,8 @@ interface EvaluateResult {
|
||||
|
||||
interface PerfThresholds {
|
||||
requiredStatus: string
|
||||
scenarios: Record<string, Record<string, { max?: number } | boolean>>
|
||||
maxRendererCpuThrottle: number
|
||||
scenarios: Record<string, Record<string, { max?: number; min?: number } | boolean>>
|
||||
}
|
||||
|
||||
interface PerfCheckerModule {
|
||||
@@ -32,6 +36,169 @@ interface PerfCheckerModule {
|
||||
const require = createRequire(import.meta.url)
|
||||
const checker = require('../build/check-perf-thresholds.cjs') as PerfCheckerModule
|
||||
const thresholds = JSON.parse(readFileSync(checker.defaultThresholdsPath, 'utf8')) as PerfThresholds
|
||||
const root = resolve(dirname(checker.defaultThresholdsPath), '..')
|
||||
const committed = (path: string) => execFileSync('git', ['show', `HEAD:${path}`], { cwd: root, encoding: 'utf8' })
|
||||
|
||||
function assertThresholdRatchet(current: PerfThresholds, baseline: PerfThresholds) {
|
||||
expect(current.requiredStatus).toBe(baseline.requiredStatus)
|
||||
expect(current.maxRendererCpuThrottle).toBeLessThanOrEqual(baseline.maxRendererCpuThrottle)
|
||||
for (const [id, limits] of Object.entries(baseline.scenarios)) {
|
||||
const next = current.scenarios[id]
|
||||
expect(next, `missing scenario ${id}`).toBeDefined()
|
||||
if (!next) throw new Error(`Missing scenario ${id}`)
|
||||
if (limits.optional !== true) expect(next.optional).not.toBe(true)
|
||||
for (const [metric, bound] of Object.entries(limits)) {
|
||||
if (metric === 'optional' || typeof bound === 'boolean') continue
|
||||
const value = next[metric]
|
||||
expect(value, `missing metric ${id}.${metric}`).toBeDefined()
|
||||
if (typeof value !== 'object') throw new Error(`Invalid bound ${id}.${metric}`)
|
||||
if (bound.max !== undefined) {
|
||||
expect(Number.isFinite(value.max)).toBe(true)
|
||||
expect(value.max).toBeLessThanOrEqual(bound.max)
|
||||
}
|
||||
if (bound.min !== undefined) {
|
||||
expect(Number.isFinite(value.min)).toBe(true)
|
||||
expect(value.min).toBeGreaterThanOrEqual(bound.min)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These were execution owners when SA-07 replaced the import-only guard. Older
|
||||
// commits have no context list; subsequent baselines use their committed list.
|
||||
const initialContexts = [
|
||||
'src/agent-daemon/agent-owned-acp-prompt.ts',
|
||||
'src/agent-daemon/daemon.ts',
|
||||
'src/agent-daemon/direct-linux-stdio-process-owner.ts',
|
||||
'src/agent-daemon/index.ts',
|
||||
'src/agent-daemon/runtime-composition.ts',
|
||||
'src/main/desktop-storage-owner.ts',
|
||||
'src/main/desktop-storage-files.ts',
|
||||
'src/main/desktop-storage-runtime-operations.ts',
|
||||
'src/main/desktop-storage-entry.ts',
|
||||
'src/main/desktop-storage-runtime-host-fixture.ts',
|
||||
'src/main/readonly-query-worker.ts',
|
||||
'src/main/assistant-storage-worker.ts',
|
||||
'src/main/agent/private-sqlite-database.ts'
|
||||
]
|
||||
|
||||
describe('SA-07 architecture guards', () => {
|
||||
const eslint = new ESLint({ cwd: root })
|
||||
const lint = async (code: string, filePath = 'src/main/sa07-probe.ts') => {
|
||||
const [result] = await eslint.lintText(code, { filePath: resolve(root, filePath) })
|
||||
expect(result!.fatalErrorCount, code).toBe(0)
|
||||
return result!.messages.filter(message => message.ruleId === 'architecture/sqlite-execution-context')
|
||||
}
|
||||
|
||||
const boundaries = [
|
||||
['node:sqlite', 'DatabaseSync'],
|
||||
['../shared/node/private-sqlite-database', 'openPrivateSqliteDatabase'],
|
||||
['../shared/node/private-sqlite-database', 'PreparedPrivateSqliteDatabaseFile'],
|
||||
['./agent/private-sqlite-database', 'openPrivateSqliteDatabase'],
|
||||
['./assistant/assistant-database', 'AssistantDatabase'],
|
||||
['./knowledge/knowledge-database', 'KnowledgeDatabase'],
|
||||
['./knowledge/external/external-knowledge-store', 'ExternalKnowledgeStore'],
|
||||
['./conversation-attachment-storage', 'ConversationAttachmentStorage'],
|
||||
['./agent/runtime-session-binding-store', 'SqliteRuntimeSessionBindingStore'],
|
||||
['../agent-daemon/agent-model-gateway', 'AgentModelCallLedger'],
|
||||
['../agent-daemon/event-journal', 'EventJournal'],
|
||||
['../agent-daemon/runtime-owner-registry', 'RuntimeOwnerRegistry'],
|
||||
['../agent-daemon/semantic-prompt-store', 'SemanticPromptStore'],
|
||||
['../agent-daemon/index.js', 'AgentModelCallLedger'],
|
||||
['./assistant/assistant-storage-upgrade', 'upgradeAssistantStorage'],
|
||||
['./desktop-storage-owner', 'DesktopStorageOwner'],
|
||||
['./desktop-storage-files', 'openDesktopStorageFiles'],
|
||||
['./desktop-storage-runtime-operations', 'DesktopStorageRuntimeOwner']
|
||||
]
|
||||
|
||||
it.each(boundaries)('restricts value imports from %s in Main, including indirect module access', async (source, name) => {
|
||||
for (const code of [
|
||||
`import { ${name} as value } from '${source}';`,
|
||||
`import * as value from '${source}';`,
|
||||
`import value from '${source}';`,
|
||||
`const value = await import('${source}');`,
|
||||
`const value = await import(\`${source}\`);`,
|
||||
`const value = require('${source}');`,
|
||||
`import value = require('${source}');`,
|
||||
`export { ${name} as value } from '${source}';`,
|
||||
`export * from '${source}';`,
|
||||
`export * as value from '${source}';`
|
||||
]) expect(await lint(code), code).toHaveLength(1)
|
||||
for (const code of [
|
||||
`import type { ${name} } from '${source}';`,
|
||||
`import { type ${name} } from '${source}';`,
|
||||
`import type * as value from '${source}';`,
|
||||
`export type { ${name} } from '${source}';`,
|
||||
`export { type ${name} } from '${source}';`,
|
||||
`export type * from '${source}';`,
|
||||
`type Value = import('${source}').${name};`
|
||||
]) expect(await lint(code), code).toEqual([])
|
||||
})
|
||||
|
||||
it('allows actual owner/worker/test contexts but not similarly named Main files', async () => {
|
||||
const code = "import { AssistantDatabase } from './assistant/assistant-database'"
|
||||
for (const file of [...initialContexts, 'src/main/assistant/assistant-storage-upgrade.test.ts', 'src/agent-daemon/daemon.ts']) {
|
||||
expect(await lint(code, file), file).toEqual([])
|
||||
}
|
||||
for (const file of ['src/main/index.ts', 'src/main/fake-worker.ts', 'src/main/assistant-storage-startup.ts', 'src/renderer/src/probe.ts']) {
|
||||
expect(await lint(code, file), file).toHaveLength(1)
|
||||
}
|
||||
expect(await lint("import { MemoryRuntimeSessionBindingStore } from './agent/runtime-session-binding-store'")).toEqual([])
|
||||
})
|
||||
|
||||
it('removes the paged output sync-fs exception and globally ignores generated temp files', async () => {
|
||||
const [result] = await eslint.lintText("import { readFileSync } from 'node:fs'", {
|
||||
filePath: resolve(root, 'src/main/agent/paged-output-store.ts')
|
||||
})
|
||||
expect(result!.messages.some(message => message.ruleId === 'no-restricted-imports')).toBe(true)
|
||||
expect(await eslint.isPathIgnored(resolve(root, 'temp/generated.test.ts'))).toBe(true)
|
||||
const { default: config } = await import('../vitest.config')
|
||||
expect(config.test?.exclude).toContain('temp/**')
|
||||
expect(config.test?.projects).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ extends: true, test: expect.objectContaining({ name: 'unit' }) }),
|
||||
expect.objectContaining({ extends: true, test: expect.objectContaining({ name: 'integration' }) })
|
||||
]))
|
||||
})
|
||||
|
||||
it('only shrinks allowlists and the App ceiling relative to local committed HEAD', () => {
|
||||
const current = readFileSync(join(root, 'eslint.config.js'), 'utf8')
|
||||
const baseline = committed('eslint.config.js')
|
||||
const list = (source: string, name: string) => {
|
||||
const match = source.match(new RegExp(`const ${name} = \\[([\\s\\S]*?)\\]`))
|
||||
if (!match) return undefined
|
||||
return [...match[1]!.matchAll(/'([^']+)'/g)].map(entry => entry[1]!)
|
||||
}
|
||||
for (const name of ['databaseSyncAllowlist', 'mainSyncFsAllowlist', 'rendererIpcSubscriptionAllowlist', 'sqliteExecutionContexts']) {
|
||||
const before = list(baseline, name) ?? (name === 'sqliteExecutionContexts' ? initialContexts : undefined)
|
||||
const after = list(current, name)
|
||||
expect(before, name).toBeDefined()
|
||||
expect(after, name).toBeDefined()
|
||||
expect(after!.filter(file => !before!.includes(file)), `${name} additions`).toEqual([])
|
||||
}
|
||||
const ceiling = (source: string) => Number(source.match(/const appTsxMaxLines = (\d+)/)?.[1])
|
||||
expect(ceiling(current)).toBeLessThanOrEqual(ceiling(baseline))
|
||||
})
|
||||
|
||||
it('only tightens committed performance ceilings while allowing new checks', () => {
|
||||
assertThresholdRatchet(thresholds, JSON.parse(committed('build/perf-thresholds.json')))
|
||||
const baseline: PerfThresholds = { requiredStatus: 'passed', maxRendererCpuThrottle: 1, scenarios: { sample: { latency: { max: 10 } } } }
|
||||
const tightened = structuredClone(baseline)
|
||||
tightened.scenarios.sample!.latency = { max: 5 }
|
||||
tightened.scenarios.newScenario = { latency: { max: 20 } }
|
||||
assertThresholdRatchet(tightened, baseline)
|
||||
for (const mutate of [
|
||||
(value: PerfThresholds) => { value.scenarios.sample!.latency = { max: 11 } },
|
||||
(value: PerfThresholds) => { delete value.scenarios.sample!.latency },
|
||||
(value: PerfThresholds) => { delete value.scenarios.sample },
|
||||
(value: PerfThresholds) => { value.scenarios.sample!.optional = true },
|
||||
(value: PerfThresholds) => { value.maxRendererCpuThrottle = 4 }
|
||||
]) {
|
||||
const loosened = structuredClone(baseline)
|
||||
mutate(loosened)
|
||||
expect(() => assertThresholdRatchet(loosened, baseline)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A report shaped like tests/support/app-perf-driver.mjs output, with values
|
||||
// close to the 2026-10-02 non-throttled baseline.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { copyFile, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { createServer } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
@@ -12,10 +11,14 @@ import { AssistantDatabase } from '../src/main/assistant/assistant-database'
|
||||
import { SupervisionStoryStore } from '../src/main/assistant/supervision-stories'
|
||||
import { SupervisionExperienceStore } from '../src/main/assistant/supervision-experiences'
|
||||
|
||||
it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
it(process.env.GOODBUDDY_STORY_GRAPH_FOCUS
|
||||
? 'focuses the production 3D story camera through canvas and sidebar selection'
|
||||
: process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
? 'renders the production supervision card at narrow widths in both themes'
|
||||
: 'renders the production supervisor at desktop, narrow, and mobile widths', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-supervisor-'))
|
||||
const temporaryRoot = resolve('temp/story-graph-focus')
|
||||
await mkdir(temporaryRoot, { recursive: true })
|
||||
const directory = await mkdtemp(join(temporaryRoot, 'supervisor-'))
|
||||
// Optional local acceptance run against an online backup. Never initialize/migrate or run models.
|
||||
let portable: string | undefined
|
||||
if (process.env.GOODBUDDY_SUPERVISOR_BACKUP) {
|
||||
@@ -48,6 +51,11 @@ it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
{
|
||||
name: 'supervisor-fixture',
|
||||
configureServer(server) {
|
||||
if (process.env.GOODBUDDY_STORY_GRAPH_FOCUS) server.middlewares.use('/focus-export.json', async (_request, response) => {
|
||||
const source = await readFile('docs/features/story-graph/3d-demo-data.js', 'utf8')
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end(source.slice(source.indexOf('window.STORY_DATA = ') + 'window.STORY_DATA = '.length).trim().replace(/;$/, ''))
|
||||
})
|
||||
if (portable) server.middlewares.use('/portable-review.json', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'application/json')
|
||||
response.end(portable)
|
||||
@@ -73,6 +81,7 @@ it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
await server.listen()
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
TEMP: directory, TMP: directory, TMPDIR: directory,
|
||||
GOODBUDDY_SUPERVISOR_URL:
|
||||
server.resolvedUrls!.local[0] + 'supervisor.html' + (process.env.GOODBUDDY_SUPERVISOR_SIDEBAR ? '?sidebar=1' : ''),
|
||||
GOODBUDDY_SUPERVISOR_DIRECTORY: directory,
|
||||
@@ -89,6 +98,7 @@ it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
driver
|
||||
)
|
||||
await copyFile(resolve('tests/support/supervisor-selection-driver.mjs'), join(directory, 'supervisor-selection-driver.mjs'))
|
||||
await copyFile(resolve('tests/support/story-graph-focus-driver.mjs'), join(directory, 'story-graph-focus-driver.mjs'))
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [driver], {
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
@@ -107,7 +117,8 @@ it(process.env.GOODBUDDY_SUPERVISOR_SIDEBAR
|
||||
child.once('error', reject)
|
||||
})
|
||||
expect(code, output).toBe(0)
|
||||
console.log(output)
|
||||
if (process.env.GOODBUDDY_STORY_GRAPH_FOCUS) process.stdout.write(output)
|
||||
else console.log(output)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { mkdirSync, appendFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import process from 'node:process'
|
||||
|
||||
// Uses the existing full-App driver and production preload. No mock bridge.
|
||||
const DRAIN_BOUND_MS = 180_000
|
||||
// SubagentScheduler default concurrency; offered children above it queue by design.
|
||||
const PRODUCT_SUBAGENT_CAP = 3
|
||||
export async function runConcurrentAcceptance(h) {
|
||||
const { app, win, js, waitFor, settle, clickSelector, typeText, measure, seed, report, writeReport, directory, artifacts, composer, sendButton, stopButton } = h
|
||||
const mode = process.env.GB_PERF_MODE
|
||||
if (!['C01', 'C02', 'C03', 'C04', 'C06', 'C07'].includes(mode)) throw new Error(`${mode}: fixture not implemented; no acceptance result`)
|
||||
const pressure = mode === 'C02' || mode === 'C06'
|
||||
const load = { projects: pressure ? 4 : 3, sessions: pressure ? 8 : 4, subtasks: pressure ? 16 : 8, fragmentIntervalMs: 16, toolUpdateIntervalMs: 1000, toolOutputBytes: 4096 }
|
||||
report.acceptance = { mode, load, status: 'running', cycles: [], gaps: [
|
||||
'Periodic 4 KiB tool updates are not yet implemented; offered text and delegated child requests are measured separately.',
|
||||
'Storage queue wait/execute decomposition, cache/subscription counts and temporary-file accounting are not instrumented.',
|
||||
'C05 storage fault injection remains outside this task.',
|
||||
'C03 review publication requires an existing persisted review result; this run records whether one is available.'
|
||||
] }
|
||||
report.config.longConversationMessages = pressure ? 10_000 : 2_000
|
||||
report.pending = 'concurrent-seed'
|
||||
writeReport()
|
||||
const seeded = await seed()
|
||||
report.seed = { conversations: report.config.seedConversations, messagesPerConversation: report.config.seedMessagesPerConversation, longConversationMessages: report.config.longConversationMessages, seedMs: seeded.seedMs }
|
||||
const projects = []
|
||||
for (let index = 0; index < load.projects; index++) {
|
||||
const rootPath = join(directory, `project-${index}`)
|
||||
mkdirSync(rootPath, { recursive: true })
|
||||
projects.push(await js(`window.goodbuddy.projects.create(${JSON.stringify({ name: `Concurrent ${index}`, description: 'Deterministic architecture acceptance', rootPath, runtimeSelection: { provider: 'model' } })})`))
|
||||
}
|
||||
win.webContents.reload()
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(composer)})`, 60_000)
|
||||
await sleep(1500)
|
||||
await js(`(() => {
|
||||
const state = window.__concurrent = { requests: {}, children: {}, peakChildren: 0, peakParents: 0, events: 0, errors: [] };
|
||||
state.dispose = window.goodbuddy.agent.onEvent(event => {
|
||||
state.events++;
|
||||
const request = state.requests[event.requestId] ||= { events: 0, chars: 0, started: performance.now(), done: false };
|
||||
request.events++;
|
||||
if (event.type === 'text') request.chars += event.delta.length;
|
||||
if (event.type === 'error') state.errors.push({ requestId: event.requestId, message: event.message });
|
||||
if (event.type === 'done' || event.type === 'error') { request.done = true; request.settled = performance.now(); }
|
||||
if (event.type === 'subagent') {
|
||||
const id = event.childTaskId;
|
||||
state.children[id] = { state: event.state, requestId: event.requestId };
|
||||
state.peakChildren = Math.max(state.peakChildren, Object.values(state.children).filter(c => c.state === 'running').length);
|
||||
}
|
||||
state.peakParents = Math.max(state.peakParents, Object.values(state.requests).filter(r => !r.done).length);
|
||||
});
|
||||
return true;
|
||||
})()`)
|
||||
|
||||
// insertText is dropped if focus has not settled yet; confirm it landed.
|
||||
const enterPrompt = async prompt => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await clickSelector(composer, { scroll: false })
|
||||
await win.webContents.insertText(prompt)
|
||||
const landed = await waitFor(`(document.querySelector(${JSON.stringify(composer)})?.value ?? '').includes(${JSON.stringify(prompt)})`, 2_000).then(() => true).catch(() => false)
|
||||
if (landed) return
|
||||
}
|
||||
}
|
||||
|
||||
const measureAvailability = async (name, action) => {
|
||||
const startedAt = performance.now()
|
||||
let value
|
||||
let error
|
||||
try { value = await action() } catch (caught) { error = String(caught?.message ?? caught) }
|
||||
return { name, startedAt, availableAt: error ? null : performance.now(), elapsedMs: error ? null : performance.now() - startedAt, error, value }
|
||||
}
|
||||
|
||||
const runC03 = async () => {
|
||||
const startedAt = performance.now()
|
||||
await sleep(2_000)
|
||||
const library = await js(`window.goodbuddy.knowledge.createLibrary(${JSON.stringify({ name: 'C03 deterministic library', description: 'isolated concurrent acceptance', storageMode: 'reference', graphEnabled: true, graphStrategy: 'rules' })})`)
|
||||
const sourcePath = join(directory, 'c03-knowledge.txt')
|
||||
writeFileSync(sourcePath, 'C03 deterministic knowledge source\n'.repeat(80))
|
||||
const libraryId = String(library.id)
|
||||
const importStarted = performance.now()
|
||||
let importError
|
||||
try {
|
||||
await js(`window.goodbuddy.knowledge.importPaths(${JSON.stringify(libraryId)}, [${JSON.stringify(sourcePath)}], 'rules')`)
|
||||
} catch (error) { importError = String(error?.message ?? error) }
|
||||
const graphSource = await js('window.goodbuddy.supervision.overview({})')
|
||||
const resultId = Array.isArray(graphSource) ? graphSource[0]?.id : undefined
|
||||
const batches = []
|
||||
for (let batch = 0; batch < 100; batch += 1) {
|
||||
const conversationId = projects[batch % projects.length].id
|
||||
const work = [
|
||||
js(`window.goodbuddy.conversations.listSummaries()`),
|
||||
js(`window.goodbuddy.knowledge.search([${JSON.stringify(libraryId)}], ${JSON.stringify(`c03-${batch}`)})`).catch(() => []),
|
||||
js(`window.goodbuddy.supervision.graph(${JSON.stringify(resultId ? { resultId } : {})})`).catch(error => ({ error: String(error?.message ?? error) }))
|
||||
]
|
||||
batches.push({ batch, conversationId, startedAt: performance.now(), results: await Promise.all(work) })
|
||||
}
|
||||
report.acceptance.c03 = {
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
reviewResultAvailable: Boolean(resultId),
|
||||
reviewPublishBatches: 0,
|
||||
knowledgeImport: { attempted: true, elapsedMs: performance.now() - importStarted, error: importError },
|
||||
overlappingBatches: batches.length,
|
||||
graphReads: batches.filter(item => !item.results[2]?.error).length,
|
||||
historyReads: batches.length,
|
||||
searchCalls: batches.length,
|
||||
overlapWindowMs: batches.length ? performance.now() - batches[0].startedAt : 0,
|
||||
status: resultId && !importError ? 'measured-with-gaps' : 'not-fulfilled'
|
||||
}
|
||||
writeReport()
|
||||
}
|
||||
|
||||
const runC04 = async () => {
|
||||
const startedAt = performance.now()
|
||||
const faultConversation = await js(`window.goodbuddy.conversations.listSummaries().then(items => items[0]?.id)`)
|
||||
if (!faultConversation) throw new Error('C04 requires a conversation')
|
||||
await enterPrompt('gb-fault:hang isolate this request')
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(sendButton)}) && !document.querySelector(${JSON.stringify(sendButton)}).disabled`, 15_000, 'C04 fault send')
|
||||
await clickSelector(sendButton, { scroll: false })
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(stopButton)})`, 15_000, 'C04 fault started')
|
||||
await waitFor('Object.keys(window.__concurrent.requests).length > 0', 15_000, 'C04 runtime admitted')
|
||||
const faultRequestId = await js('Object.keys(window.__concurrent.requests).at(-1)')
|
||||
const faultStartedAt = performance.now()
|
||||
await clickSelector('button.new-chat', { scroll: false })
|
||||
await enterPrompt('gb-concurrent:parent:c04-health execute one healthy task')
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(sendButton)}) && !document.querySelector(${JSON.stringify(sendButton)}).disabled`, 15_000, 'C04 peer send')
|
||||
await clickSelector(sendButton, { scroll: false })
|
||||
const peerStartedAt = performance.now()
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(stopButton)})`, 15_000, 'C04 peer started')
|
||||
await waitFor('Object.keys(window.__concurrent.requests).length >= 2', 15_000, 'C04 peer admitted')
|
||||
const peerRequestId = await js('Object.keys(window.__concurrent.requests).at(-1)')
|
||||
await js(`window.goodbuddy.agent.cancel(${JSON.stringify(faultRequestId)})`)
|
||||
const cancelledAt = performance.now()
|
||||
await waitFor(`window.__concurrent.requests[${JSON.stringify(faultRequestId)}]?.done === true`, 30_000, 'C04 fault cancelled')
|
||||
await waitFor(`!document.querySelector(${JSON.stringify(stopButton)})`, 60_000, 'C04 peer complete')
|
||||
const [status, summaries, state] = await Promise.all([
|
||||
js('window.goodbuddy.agent.getStatus()'),
|
||||
js('window.goodbuddy.conversations.listSummaries()'),
|
||||
js('({ errors: window.__concurrent.errors, requests: window.__concurrent.requests })')
|
||||
])
|
||||
const faultState = { id: faultRequestId, request: state.requests[faultRequestId] }
|
||||
const peerState = state.requests[peerRequestId]
|
||||
report.acceptance.c04 = {
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
faultStartedMs: faultStartedAt - startedAt,
|
||||
cancellationObservedMs: cancelledAt - faultStartedAt,
|
||||
healthyPeerCompletedMs: performance.now() - peerStartedAt,
|
||||
faultRequestId,
|
||||
peerRequestId,
|
||||
peerRequestDone: peerState?.done === true,
|
||||
peerErrorCount: state.errors.filter(error => error.requestId === peerRequestId).length,
|
||||
healthyPeerUnaffected: peerState?.done === true && !state.errors.some(error => error.requestId === peerRequestId),
|
||||
faultRequestCancelled: faultState.request?.done === true,
|
||||
faultRequestSettled: faultState.request?.done === true,
|
||||
queryResponsive: Array.isArray(summaries),
|
||||
statusResponsive: status?.available === true,
|
||||
cleanupCompleted: faultState.request?.done === true,
|
||||
observedRequestCount: state.requests ? Object.keys(state.requests).length : 0,
|
||||
status: faultState.request?.done === true &&
|
||||
Array.isArray(summaries) &&
|
||||
status?.available === true &&
|
||||
state.requests && Object.keys(state.requests).length >= 2 && peerState?.done === true
|
||||
? 'measured'
|
||||
: 'not-fulfilled'
|
||||
}
|
||||
writeReport()
|
||||
}
|
||||
|
||||
const runC07 = async () => {
|
||||
const coldStartedAt = performance.now()
|
||||
const cold = {
|
||||
input: await measureAvailability('input', () => waitFor(`!!document.querySelector(${JSON.stringify(composer)})`)),
|
||||
history: await measureAvailability('history', () => js('window.goodbuddy.conversations.listSummaries()')),
|
||||
supervisor: await measureAvailability('supervisor-overview', () => js('window.goodbuddy.supervision.overview({})')),
|
||||
graph: await measureAvailability('graph', () => js('window.goodbuddy.supervision.graph({})')),
|
||||
knowledge: await measureAvailability('knowledge-snapshot', () => js('window.goodbuddy.knowledge.getSnapshot()'))
|
||||
}
|
||||
win.webContents.reload()
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(composer)})`, 60_000, 'C07 warm composer')
|
||||
const warm = {
|
||||
input: await measureAvailability('input', () => waitFor(`!!document.querySelector(${JSON.stringify(composer)})`)),
|
||||
history: await measureAvailability('history', () => js('window.goodbuddy.conversations.listSummaries()')),
|
||||
supervisor: await measureAvailability('supervisor-overview', () => js('window.goodbuddy.supervision.overview({})')),
|
||||
graph: await measureAvailability('graph', () => js('window.goodbuddy.supervision.graph({})')),
|
||||
knowledge: await measureAvailability('knowledge-snapshot', () => js('window.goodbuddy.knowledge.getSnapshot()'))
|
||||
}
|
||||
report.acceptance.c07 = { cold: { elapsedMs: performance.now() - coldStartedAt, stages: cold }, warm: { stages: warm }, status: 'measured-with-gaps', sourceBody: 'not available without a persisted supervision source' }
|
||||
writeReport()
|
||||
}
|
||||
|
||||
if (mode === 'C03') await runC03()
|
||||
if (mode === 'C04') await runC04()
|
||||
if (mode === 'C07') await runC07()
|
||||
if (['C03', 'C04', 'C07'].includes(mode)) {
|
||||
report.acceptance.status = mode === 'C04'
|
||||
? report.acceptance.c04?.status ?? 'not-fulfilled'
|
||||
: 'not-fulfilled'
|
||||
// C04 is a correctness scenario: cancellation settles and the healthy peer finishes.
|
||||
report.status = mode === 'C04' && report.acceptance.c04?.healthyPeerUnaffected && report.acceptance.status === 'measured' ? 'passed' : report.acceptance.status
|
||||
report.pending = null
|
||||
await js('true')
|
||||
await h.screenshot(`concurrent-${mode.toLowerCase()}-final`)
|
||||
return
|
||||
}
|
||||
const selectProject = async index => {
|
||||
await clickSelector('.project-switcher__trigger', { scroll: false })
|
||||
await waitFor(`!![...document.querySelectorAll('[role=menuitemradio]')].find(e => e.textContent.includes('Concurrent ${index}'))`)
|
||||
await js(`(() => { const e = [...document.querySelectorAll('[role=menuitemradio]')].find(e => e.textContent.includes('Concurrent ${index}')); e.dataset.perfTarget = 'true'; return true; })()`)
|
||||
await clickSelector('[data-perf-target=true]')
|
||||
await waitFor(`document.querySelector('.project-switcher__trigger')?.textContent.includes('Concurrent ${index}')`)
|
||||
await settle()
|
||||
}
|
||||
const startCycle = async cycle => {
|
||||
for (let index = 0; index < load.sessions; index++) {
|
||||
await selectProject(index % projects.length)
|
||||
await clickSelector('button.new-chat', { scroll: false })
|
||||
await enterPrompt(`gb-concurrent:parent:c${cycle}-s${index} execute two delegated tasks`)
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(sendButton)}) && !document.querySelector(${JSON.stringify(sendButton)}).disabled`)
|
||||
await clickSelector(sendButton, { scroll: false })
|
||||
await sleep(50)
|
||||
}
|
||||
}
|
||||
const started = performance.now()
|
||||
// C06: 5 minutes of repeated create/complete cycles; long enough to expose
|
||||
// growth trends without a fixed 30-minute requirement.
|
||||
const requiredMs = mode === 'C06' ? 300_000 : 150_000
|
||||
report.acceptance.startedAt = new Date().toISOString()
|
||||
report.acceptance.requiredMs = requiredMs
|
||||
let cycle = 0
|
||||
do {
|
||||
await startCycle(cycle)
|
||||
if (cycle === 0) {
|
||||
report.pending = 'concurrent-warmup-30s'
|
||||
writeReport()
|
||||
await sleep(30_000)
|
||||
}
|
||||
const result = await measure(`${mode}-cycle-${cycle}`, 'Native typing while production parent and child requests execute', async () => {
|
||||
const until = performance.now() + (mode === 'C06' ? 30_000 : 30_000)
|
||||
let samples = 0
|
||||
while (performance.now() < until) {
|
||||
await clickSelector(composer, { scroll: false })
|
||||
await typeText('abcdefghijklmnopqrstuvwxyz')
|
||||
samples += 26
|
||||
const state = await js(`(() => { const s = window.__concurrent; return { requests: s.requests, children: s.children, peakChildren: s.peakChildren, peakParents: s.peakParents, events: s.events, errors: s.errors }; })()`)
|
||||
appendFileSync(join(artifacts, 'heartbeat.jsonl'), JSON.stringify({ at: new Date().toISOString(), elapsedMs: performance.now() - started, cycle, samples, state, pids: app.getAppMetrics() }) + '\n')
|
||||
if (Object.values(state.requests).every(request => request.done) && performance.now() < until - 10_000) {
|
||||
await startCycle(++cycle)
|
||||
}
|
||||
}
|
||||
return { fields: { nativeKeySamplesOffered: samples } }
|
||||
})
|
||||
const drainStarted = performance.now()
|
||||
// Children run behind the product subagent cap (3), so 16 delegated 12 s children
|
||||
// need over a minute. The bound is identical for baseline and candidate; the
|
||||
// measured drainMs is the throughput comparison.
|
||||
const drained = await waitFor('Object.values(window.__concurrent.requests).every(r => r.done)', DRAIN_BOUND_MS, 'production request drain').then(() => true).catch(() => false)
|
||||
const drainMs = performance.now() - drainStarted
|
||||
const state = await js(`(() => { const { requests, children, peakChildren, peakParents, events, errors } = window.__concurrent; return { requests, children, peakChildren, peakParents, events, errors }; })()`)
|
||||
const persisted = await js('window.goodbuddy.conversations.listSummaries().then(items => items.length)')
|
||||
report.acceptance.cycles.push({ cycle, drained, drainMs, state, persistedSummaries: persisted, heapMB: await h.retainedHeapMB(), metrics: result.id })
|
||||
report.acceptance.elapsedMs = performance.now() - started
|
||||
writeReport()
|
||||
cycle++
|
||||
} while (performance.now() - started < requiredMs)
|
||||
report.acceptance.fullDurationCompleted = performance.now() - started >= requiredMs
|
||||
report.acceptance.elapsedMs = performance.now() - started
|
||||
const state = await js('({peakChildren: window.__concurrent.peakChildren, peakParents: window.__concurrent.peakParents})')
|
||||
report.acceptance.achieved = state
|
||||
report.acceptance.budgets = report.scenarios.map(s => ({ id: s.id, input: s.latency?.count >= 100 && s.latency.p95 <= 16, frame: s.renderer.frames.p95 <= 17, longTasks: s.renderer.longTasks.count === 0, mainP99: s.main.eventLoopDelay.p99 <= 30, mainSingleBlock: 'not measured; monitor delay is not single-block duration' }))
|
||||
// Release gate: correctness only. Budgets above are reported as target gaps;
|
||||
// regressions are judged by alternating baseline/candidate runs, not here.
|
||||
const cycles = report.acceptance.cycles
|
||||
const errors = cycles.flatMap(item => item.state.errors ?? [])
|
||||
report.acceptance.correctness = {
|
||||
allCyclesDrained: cycles.length > 0 && cycles.every(item => item.drained),
|
||||
errors: errors.length,
|
||||
peakParents: state.peakParents, peakChildren: state.peakChildren,
|
||||
childCap: Math.min(load.subtasks, PRODUCT_SUBAGENT_CAP),
|
||||
drainMs: cycles.map(item => Math.round(item.drainMs)),
|
||||
reachedLoad: state.peakParents >= load.sessions && state.peakChildren >= Math.min(load.subtasks, PRODUCT_SUBAGENT_CAP),
|
||||
fullDuration: report.acceptance.fullDurationCompleted
|
||||
}
|
||||
const gate = report.acceptance.correctness
|
||||
report.acceptance.status = gate.allCyclesDrained && gate.errors === 0 && gate.reachedLoad && gate.fullDuration ? 'correctness-passed' : 'not-fulfilled'
|
||||
report.status = report.acceptance.status === 'correctness-passed' ? 'passed' : 'not-fulfilled'
|
||||
report.pending = null
|
||||
await js('window.__concurrent.dispose(); true')
|
||||
await h.screenshot('concurrent-final')
|
||||
}
|
||||
@@ -264,6 +264,7 @@ async function measure(id, description, body) {
|
||||
}
|
||||
},
|
||||
processes: {
|
||||
pids: app.getAppMetrics().map(metric => ({ pid: metric.pid, type: metric.type, cpu: metric.cpu.percentCPUUsage, workingSetMB: metric.memory.workingSetSize / 1024 })),
|
||||
cpu: Object.fromEntries(Object.entries(processes).map(([type, value]) => [type, value.cpu])),
|
||||
workingSetMB: Object.fromEntries(Object.entries(processes).map(([type, value]) => [type, Math.round(value.workingSetMB)]))
|
||||
},
|
||||
@@ -511,6 +512,15 @@ try {
|
||||
await applyCpuThrottle()
|
||||
await sleep(1_500)
|
||||
|
||||
if (process.env.GB_PERF_MODE?.startsWith('C')) {
|
||||
const { runConcurrentAcceptance } = await import('./app-perf-concurrent.mjs')
|
||||
await runConcurrentAcceptance({ app, win, js, waitFor, settle, clickSelector, typeText, measure, seed, report, writeReport, screenshot, directory, artifacts, composer, sendButton, stopButton, retainedHeapMB })
|
||||
clearTimeout(deadline)
|
||||
writeReport()
|
||||
app.exit(report.status === 'passed' ? 0 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
const idle = await measure('idle', 'Fresh profile, no interaction for 3 s', async () => { await sleep(3_000) })
|
||||
frameBaseline = idle.renderer.frames.p50 ?? 16.7
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AssistantDatabase } from '../../src/main/assistant/assistant-database'
|
||||
import { createSupervisionDomainPorts } from '../../src/main/assistant/supervision-domain-ports'
|
||||
import type { DesktopStorageClient } from '../../src/main/desktop-storage-client'
|
||||
import { storageDataBytes } from '../../src/main/desktop-storage-contracts'
|
||||
import type { ReadonlyQueryReader } from '../../src/main/readonly-query-reader'
|
||||
import type { ReviewState } from '../../src/main/assistant/supervision-review-store'
|
||||
|
||||
/** Exercise the real caller facades and repository commits across an asynchronous data boundary. */
|
||||
export function asyncSupervisionStorage(database: AssistantDatabase, before?: (domain: string, method: string, args: unknown[]) => Promise<void>, reader?: ReadonlyQueryReader) {
|
||||
const call = async (domain: string, method: string, args: unknown[], options?: { signal?: AbortSignal }) => {
|
||||
storageDataBytes(args)
|
||||
const data = structuredClone(args)
|
||||
if (before) await before(domain, method, data)
|
||||
options?.signal?.throwIfAborted()
|
||||
if (domain === 'assistant' && method === 'supervisionContext') {
|
||||
if (reader) return reader.call('reviewContext', data, options?.signal)
|
||||
const { scope } = data[0] as ReviewState['request']
|
||||
return database.readSnapshot(() => ({ summary: database.reviewSummary(scope, 'supervisor'), background: database.reviewBackground(scope) }))
|
||||
}
|
||||
if (domain === 'assistant' && method === 'listSupervisionCandidates' && reader) return reader.call('reviewCandidates', data, options?.signal)
|
||||
if (domain === 'review' && reader && (method === 'initialize' || method === 'resume')) {
|
||||
const store = database.supervisionReviewStore()
|
||||
const runId = String(data[0])
|
||||
return method === 'initialize'
|
||||
? store.initializeWithReader(runId, data[1] as ReviewState, reader.reviewManifest(runId), options?.signal)
|
||||
: store.resumeWithReader(runId, reader.reviewManifest(runId), options?.signal)
|
||||
}
|
||||
const target = domain === 'assistant' ? database : domain === 'review' ? database.supervisionReviewStore(options?.signal)
|
||||
: domain === 'stories' ? database.supervisionStories() : domain === 'experiences' ? database.supervisionExperiences()
|
||||
: database.supervisionSuggestions()
|
||||
const operation = (target as unknown as Record<string, (...input: unknown[]) => unknown>)[method]!
|
||||
return structuredClone(await operation.apply(target, data))
|
||||
}
|
||||
return createSupervisionDomainPorts({ call: call as DesktopStorageClient['call'] })
|
||||
}
|
||||
@@ -94,7 +94,7 @@ void app.whenReady().then(async () => {
|
||||
const result = await supervisor.run(request, run.id)
|
||||
return { status: result.status ?? 'completed', runId: result.runId }
|
||||
} })
|
||||
const config = heartbeat.create({ name: 'Isolated live test', scope: request.scope, timezone: 'UTC',
|
||||
const config = await heartbeat.create({ name: 'Isolated live test', scope: request.scope, timezone: 'UTC',
|
||||
recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 }, now)
|
||||
let tick = 0
|
||||
const due = async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { z } from 'zod'
|
||||
import { embeddingStorageProvider } from '../../src/main/knowledge/embedding-provider-key'
|
||||
import { KnowledgeService } from '../../src/main/knowledge/knowledge-service'
|
||||
import { TestKnowledgeService as KnowledgeService } from './knowledge-test-service'
|
||||
import { knowledgeRetrievalTerms } from '../../src/main/knowledge/retrieval-text'
|
||||
import { isPathInside } from '../../src/main/workspace-file-access'
|
||||
import type { EmbeddingProvider } from '../../src/main/knowledge/types'
|
||||
@@ -488,22 +488,22 @@ async function createSeededService(
|
||||
embeddingProvider: provider
|
||||
})
|
||||
await service.initialize()
|
||||
const library = service.createLibrary({
|
||||
const library = (await service.createLibrary({
|
||||
id: 'library-retrieval-eval',
|
||||
name: 'Synthetic retrieval evaluation',
|
||||
storageMode: 'reference',
|
||||
graphEnabled: false
|
||||
})
|
||||
const source = service.database.upsertSource({
|
||||
}))
|
||||
const source = (await service.database.upsertSource({
|
||||
id: 'source-retrieval-eval',
|
||||
knowledgeBaseId: library.id,
|
||||
type: 'file',
|
||||
location: 'fixture://synthetic-bilingual-v1',
|
||||
displayName: 'Synthetic fixture',
|
||||
status: 'ready'
|
||||
})
|
||||
}))
|
||||
for (const document of fixture.documents) {
|
||||
service.database.upsertDocument(
|
||||
await service.database.upsertDocument(
|
||||
{
|
||||
id: document.id,
|
||||
knowledgeBaseId: library.id,
|
||||
@@ -520,7 +520,7 @@ async function createSeededService(
|
||||
role: 'standalone' as const
|
||||
}))
|
||||
)
|
||||
service.database.replaceDocumentEmbeddings(
|
||||
await service.database.replaceDocumentEmbeddings(
|
||||
document.id,
|
||||
embeddingStorageProvider(provider),
|
||||
provider.model,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { KnowledgeService, type KnowledgeServiceOptions } from '../../src/main/knowledge/knowledge-service'
|
||||
import { KnowledgeDatabase } from '../../src/main/knowledge/knowledge-database'
|
||||
import { createKnowledgeStoragePort } from '../../src/main/knowledge/knowledge-storage-port'
|
||||
import { publishStorageDocument } from '../../src/main/desktop-storage-publication'
|
||||
import { replaceDocumentGraph } from '../../src/main/desktop-storage-knowledge-operations'
|
||||
import type { DesktopStorageClient } from '../../src/main/desktop-storage-client'
|
||||
|
||||
/** Tests own their connection; production composition injects the storage client. */
|
||||
export class TestKnowledgeService extends KnowledgeService {
|
||||
private readonly owner: KnowledgeDatabase
|
||||
|
||||
constructor(options: Omit<KnowledgeServiceOptions, 'database'> & { databasePath: string }) {
|
||||
const owner = new KnowledgeDatabase(options.databasePath)
|
||||
owner.initialize()
|
||||
const call = async (domain: string, method: string, args: unknown[]) => {
|
||||
if (method === 'publishDocument') return publishStorageDocument(owner, ...args as Parameters<typeof publishStorageDocument> extends [unknown, ...infer A] ? A : never)
|
||||
if (method === 'replaceDocumentGraph') return replaceDocumentGraph(owner, ...args as Parameters<typeof replaceDocumentGraph> extends [unknown, ...infer A] ? A : never)
|
||||
const target = domain === 'external' ? owner.externalStore : owner
|
||||
return (target as unknown as Record<string, (...args: unknown[]) => unknown>)[method]!.apply(target, args)
|
||||
}
|
||||
const database = createKnowledgeStoragePort({ call: call as DesktopStorageClient['call'] })
|
||||
super({ ...options, database })
|
||||
this.owner = owner
|
||||
}
|
||||
|
||||
override async dispose(): Promise<void> {
|
||||
try { await super.dispose() } finally { this.owner.close() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mkdir, mkdtemp, open, rm, stat, type FileHandle } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type { PagedOutputBackingStore } from '../../src/main/agent/paged-output-store'
|
||||
|
||||
export const outputFixtureRoot = resolve('temp/goodbuddy-output-upgrade')
|
||||
|
||||
// Real asynchronous file IO for contract tests, not a production storage owner.
|
||||
export class FileOutputBacking implements PagedOutputBackingStore {
|
||||
private directory?: Promise<string>
|
||||
private readonly entries = new Map<string, { owner: string; path: string; file?: FileHandle }>()
|
||||
writes = 0
|
||||
maximumWriteBytes = 0
|
||||
|
||||
async create(owner: string, handle: string): Promise<void> {
|
||||
this.directory ??= mkdir(outputFixtureRoot, { recursive: true }).then(() =>
|
||||
mkdtemp(join(outputFixtureRoot, 'backing-')))
|
||||
const path = join(await this.directory, handle.replace(':', '-'))
|
||||
const file = await open(path, 'wx+')
|
||||
this.entries.set(handle, { owner, path, file })
|
||||
}
|
||||
|
||||
private entry(owner: string, handle: string) {
|
||||
const entry = this.entries.get(handle)
|
||||
if (!entry || entry.owner !== owner) throw new Error('Unknown output owner')
|
||||
return entry
|
||||
}
|
||||
|
||||
async append(owner: string, handle: string, bytes: Uint8Array): Promise<void> {
|
||||
const { file } = this.entry(owner, handle)
|
||||
if (!file) throw new Error('Output already finished')
|
||||
this.writes += 1
|
||||
this.maximumWriteBytes = Math.max(this.maximumWriteBytes, bytes.byteLength)
|
||||
for (let offset = 0; offset < bytes.byteLength;) {
|
||||
const { bytesWritten } = await file.write(bytes, offset, bytes.byteLength - offset)
|
||||
if (!bytesWritten) throw new Error('Output write made no progress')
|
||||
offset += bytesWritten
|
||||
}
|
||||
}
|
||||
|
||||
async finish(owner: string, handle: string, totalBytes: number): Promise<void> {
|
||||
const entry = this.entry(owner, handle)
|
||||
await entry.file?.close()
|
||||
entry.file = undefined
|
||||
if ((await stat(entry.path)).size !== totalBytes) throw new Error('Output size mismatch')
|
||||
}
|
||||
|
||||
async read(owner: string, handle: string, cursor: number, length: number): Promise<Uint8Array> {
|
||||
const file = await open(this.entry(owner, handle).path, 'r')
|
||||
try {
|
||||
const bytes = Buffer.alloc(length)
|
||||
let offset = 0
|
||||
while (offset < length) {
|
||||
const { bytesRead } = await file.read(bytes, offset, length - offset, cursor + offset)
|
||||
if (!bytesRead) break
|
||||
offset += bytesRead
|
||||
}
|
||||
return bytes.subarray(0, offset)
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
}
|
||||
|
||||
async release(owner: string, handle: string): Promise<void> {
|
||||
if (!this.entries.has(handle)) return
|
||||
const entry = this.entry(owner, handle)
|
||||
await entry.file?.close()
|
||||
entry.file = undefined
|
||||
await rm(entry.path, { force: true })
|
||||
this.entries.delete(handle)
|
||||
}
|
||||
|
||||
get count(): number { return this.entries.size }
|
||||
get createdDirectory(): boolean { return this.directory !== undefined }
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
for (const [handle, entry] of this.entries) await this.release(entry.owner, handle)
|
||||
if (this.directory) await rm(await this.directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,335 @@
|
||||
// Run with Node 24+: node tests/support/portable-storage-upgrade.mjs --launch
|
||||
// --resume reuses an interrupted run's backup; --report prints aggregate evidence.
|
||||
import assert from 'node:assert/strict'
|
||||
import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { setTimeout, clearTimeout, setInterval, clearInterval } from 'node:timers'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, openSync, closeSync } from 'node:fs'
|
||||
import { cp, mkdir, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { DatabaseSync, backup } from 'node:sqlite'
|
||||
import { spawn, execFileSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { cpus, totalmem, platform, release } from 'node:os'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const source = join(root, 'dist/GoodBuddy-windows-x64/data')
|
||||
const temporary = join(root, 'temp/goodbuddy-storage-upgrade')
|
||||
const run = join(temporary, process.argv.includes('--fresh') ? 'fresh-run' : 'run')
|
||||
const resume = process.argv.includes('--resume')
|
||||
if (process.argv.includes('--report')) {
|
||||
const result = JSON.parse(await readFile(join(temporary, 'summary.json'), 'utf8'))
|
||||
const canonicalContentUnchanged = result.comparison && Object.values(result.comparison).every(value => value.changed.length === 0)
|
||||
const databases = Object.fromEntries(Object.entries(result.metadata ?? {}).map(([name, metadata]) => [name, {
|
||||
sourceBytes: result.sourceBefore[name]?.bytes, sourceWalBytes: result.sourceBefore[name + '-wal']?.bytes,
|
||||
backupBytes: result.backup?.[name]?.file.bytes, workBytes: result.workFiles?.[name]?.bytes,
|
||||
versionBefore: metadata.version, versionAfter: result.after?.[name]?.version,
|
||||
freePagesBefore: metadata.freePages, freePagesAfter: result.after?.[name]?.freePages,
|
||||
tables: Object.fromEntries(Object.entries(metadata.tables).map(([table, entry]) => [table, entry.count])),
|
||||
snapshotBeforeMs: result.backup?.[name]?.snapshot.durationMs, snapshotAfterMs: result.after?.[name]?.durationMs,
|
||||
comparison: result.comparison?.[name], integrity: result.finalIntegrity?.[name]
|
||||
}]))
|
||||
console.log(JSON.stringify({ startedAt: result.startedAt, environment: result.environment,
|
||||
status: result.status, canonicalContentUnchanged, failure: result.failure, sourceUnchanged: result.sourceUnchanged, copiesRemoved: result.copiesRemoved,
|
||||
durationMs: result.durationMs, databases, initialization: result.initialization, exercise: result.exercise,
|
||||
attachments: result.attachments, notes: result.notesBefore, notesUnchanged: result.notesUnchanged }, null, 2))
|
||||
process.exit(0)
|
||||
}
|
||||
if (process.argv.includes('--launch')) {
|
||||
await mkdir(temporary, { recursive: true })
|
||||
const log = openSync(join(temporary, 'execution.log'), 'w')
|
||||
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--supervise', ...process.argv.slice(2).filter(arg => arg !== '--launch')], { detached: true, stdio: ['ignore', log, log] })
|
||||
closeSync(log)
|
||||
child.unref()
|
||||
console.log(`Launched verification supervisor ${child.pid}`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (process.argv.includes('--supervise')) {
|
||||
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...process.argv.slice(2).filter(arg => arg !== '--supervise')], { stdio: 'inherit' })
|
||||
const timer = setTimeout(() => {
|
||||
console.log('Overall verification deadline exceeded')
|
||||
if (process.platform === 'win32') execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { timeout: 10_000, stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, 900_000)
|
||||
child.once('error', () => { clearTimeout(timer); process.exit(1) })
|
||||
child.once('exit', code => { clearTimeout(timer); process.exit(code ?? 1) })
|
||||
await new Promise(() => {})
|
||||
}
|
||||
if (process.argv.includes('--cleanup-interrupted')) {
|
||||
console.log('Removing interrupted validation copies')
|
||||
for (const name of ['assistant.sqlite', 'assistant.sqlite-wal', 'assistant.sqlite-shm']) {
|
||||
try { await unlink(join(run, 'work', name)); console.log(`Removed ${name}`) }
|
||||
catch (error) { console.log(`Cleanup ${name}: ${error.code}`) }
|
||||
}
|
||||
await rm(run, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
|
||||
console.log('Interrupted validation copies removed')
|
||||
process.exit(0)
|
||||
}
|
||||
const names = ['assistant.sqlite', 'knowledge.sqlite', 'conversation-attachments.sqlite', 'remote-runtime-bindings.sqlite']
|
||||
const quote = value => `"${value.replaceAll('"', '""')}"`
|
||||
const hash = value => createHash('sha256').update(value).digest('hex')
|
||||
const canonical = value => JSON.stringify(value, (_key, item) => typeof item === 'bigint' ? String(item) : item instanceof Uint8Array ? Buffer.from(item).toString('base64') : item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a.localeCompare(b))) : item)
|
||||
async function fingerprint(path) {
|
||||
const info = await stat(path).catch(error => { if (error.code !== 'ENOENT') throw error })
|
||||
if (!info) return null
|
||||
const digest = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) digest.update(chunk)
|
||||
return { bytes: info.size, mtimeMs: info.mtimeMs, sha256: digest.digest('hex') }
|
||||
}
|
||||
async function sourceFingerprint() {
|
||||
return Object.fromEntries(await Promise.all(names.flatMap(name => ['', '-wal', '-shm'].map(async suffix => [name + suffix, await fingerprint(join(source, name + suffix))]))))
|
||||
}
|
||||
async function contentFingerprint(directory) {
|
||||
const entries = []
|
||||
async function visit(path, relative) {
|
||||
for (const entry of await readdir(path, { withFileTypes: true })) {
|
||||
assert.ok(!entry.isSymbolicLink(), 'file-backed content must be isolated')
|
||||
const name = join(relative, entry.name)
|
||||
if (entry.isDirectory()) await visit(join(path, entry.name), name)
|
||||
else {
|
||||
const file = await fingerprint(join(path, entry.name))
|
||||
entries.push({ name, bytes: file.bytes, sha256: file.sha256 })
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(directory, '')
|
||||
return { files: entries.length, bytes: entries.reduce((sum, file) => sum + file.bytes, 0), sha256: hash(canonical(entries.sort((a, b) => a.name.localeCompare(b.name)))) }
|
||||
}
|
||||
function inspect(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true })
|
||||
try {
|
||||
db.exec('BEGIN')
|
||||
const tables = {}
|
||||
for (const { name } of db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${quote(name)})`).all().map(column => column.name)
|
||||
tables[name] = { count: db.prepare(`SELECT COUNT(*) AS n FROM ${quote(name)}`).get().n, columns }
|
||||
}
|
||||
return { version: db.prepare('PRAGMA user_version').get().user_version,
|
||||
pageCount: db.prepare('PRAGMA page_count').get().page_count, pageSize: db.prepare('PRAGMA page_size').get().page_size,
|
||||
freePages: db.prepare('PRAGMA freelist_count').get().freelist_count, tables }
|
||||
} finally { db.close() }
|
||||
}
|
||||
|
||||
// Hash every existing table/column, including IDs, order columns, provenance and references.
|
||||
// Sorting row digests makes physical SQLite row order irrelevant without retaining bodies.
|
||||
function snapshot(path, restoreSubagentPayload) {
|
||||
const started = performance.now()
|
||||
const blockDigests = new WeakMap()
|
||||
const progressDigests = new WeakMap()
|
||||
const db = new DatabaseSync(path, { readOnly: true })
|
||||
try {
|
||||
db.exec('BEGIN')
|
||||
assert.equal(db.prepare('PRAGMA integrity_check').get().integrity_check, 'ok', 'integrity check failed')
|
||||
const metadata = inspect(path)
|
||||
const tables = {}
|
||||
for (const [name, table] of Object.entries(metadata.tables)) {
|
||||
console.log(`Hashing ${name}: ${table.count} rows`)
|
||||
const rows = []
|
||||
let previousTask, blocks = new Map()
|
||||
const order = name === 'task_events' ? ' ORDER BY task_id, id' : ''
|
||||
for (const row of db.prepare(`SELECT * FROM ${quote(name)}${order}`).iterate()) {
|
||||
if (name === 'task_events' && row.kind === 'subagent') {
|
||||
if (previousTask !== row.task_id) { blocks = new Map(); previousTask = row.task_id }
|
||||
const restored = restoreSubagentPayload(JSON.parse(row.payload_json), blocks)
|
||||
if (Array.isArray(restored?.progress)) {
|
||||
// Production restoration shares unchanged immutable blocks. Hash their
|
||||
// canonical content once, preserving the full ordered progress digest.
|
||||
let progressDigest = progressDigests.get(restored.progress)
|
||||
if (!progressDigest) {
|
||||
progressDigest = hash(restored.progress.map(block => {
|
||||
let digest = blockDigests.get(block)
|
||||
if (!digest) { digest = hash(canonical(block)); blockDigests.set(block, digest) }
|
||||
return digest
|
||||
}).join('\n'))
|
||||
progressDigests.set(restored.progress, progressDigest)
|
||||
}
|
||||
row.payload_json = canonical({ ...restored, progress: { count: restored.progress.length, sha256: progressDigest } })
|
||||
} else row.payload_json = canonical(restored)
|
||||
}
|
||||
rows.push(hash(canonical(row)))
|
||||
if (rows.length % 100_000 === 0) {
|
||||
console.log(`Hashed ${name}: ${rows.length}/${table.count}`)
|
||||
assert.ok(performance.now() - started < 600_000, 'snapshot deadline exceeded')
|
||||
}
|
||||
}
|
||||
tables[name] = { count: table.count, sha256: hash(rows.sort().join('\n')) }
|
||||
}
|
||||
const violations = db.prepare('PRAGMA foreign_key_check').all()
|
||||
return { ...metadata, tables, durationMs: performance.now() - started, integrity: 'ok', foreignKeyViolations: violations.length,
|
||||
foreignKeyDigest: hash(canonical(violations)) }
|
||||
} finally { db.close() }
|
||||
}
|
||||
|
||||
if (process.argv.includes('--verify-source')) {
|
||||
const result = JSON.parse(await readFile(join(temporary, 'summary.json'), 'utf8'))
|
||||
assert.equal(canonical(await sourceFingerprint()), canonical(result.sourceBefore), 'original database files changed')
|
||||
if (result.notesBefore) assert.deepEqual(await contentFingerprint(join(source, 'notes')), result.notesBefore, 'original notes changed')
|
||||
assert.ok(Object.values(result.comparison).every(value => value.changed.length === 0))
|
||||
console.log('Original DB/WAL/SHM fingerprints and notes still match; saved canonical comparisons have no changed tables')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const summary = { startedAt: new Date().toISOString(), externalModelCalls: 0,
|
||||
environment: { platform: platform(), release: release(), cpu: cpus()[0]?.model, memoryBytes: totalmem(), node: process.version,
|
||||
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 10_000 }).trim(), dirtyWorktree: true },
|
||||
caveat: 'Per-database read-only SQLite online backup includes committed WAL. Sequential databases/files are not a cross-database transaction. Live application writes may change fingerprints; no source checkpoint or initialization is performed.' }
|
||||
let before
|
||||
let ownsRun = false
|
||||
try {
|
||||
console.log('Fingerprinting original database files')
|
||||
await mkdir(temporary, { recursive: true })
|
||||
before = await sourceFingerprint()
|
||||
summary.sourceBefore = before
|
||||
await writeFile(join(temporary, 'source-before.json'), JSON.stringify(before, null, 2))
|
||||
console.log('Inspecting database metadata')
|
||||
if (resume) summary.metadata = Object.fromEntries(names.map(name => [name, inspect(join(run, 'backup', name))]))
|
||||
if (process.argv.includes('--inspect')) {
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
} else {
|
||||
// Never remove a previous run implicitly: it may belong to another validator.
|
||||
if (!resume) await mkdir(run)
|
||||
ownsRun = true
|
||||
if (!resume) await mkdir(join(run, 'backup'))
|
||||
if (resume) await rm(join(run, 'work'), { recursive: true, force: true })
|
||||
await mkdir(join(run, 'work'))
|
||||
for (const name of names) {
|
||||
console.log(`${resume ? 'Reusing backup' : 'Backing up'} ${name}`)
|
||||
if (!resume) {
|
||||
// Capture main + WAL without letting SQLite touch the original SHM. Require
|
||||
// stable source fingerprints across capture before using the online backup.
|
||||
await mkdir(join(run, 'capture'), { recursive: true })
|
||||
await cp(join(source, name), join(run, 'capture', name))
|
||||
if (before[name + '-wal']) await cp(join(source, name + '-wal'), join(run, 'capture', name + '-wal'))
|
||||
assert.deepEqual(await fingerprint(join(source, name)), before[name], 'source changed during capture')
|
||||
assert.deepEqual(await fingerprint(join(source, name + '-wal')), before[name + '-wal'], 'WAL changed during capture')
|
||||
const db = new DatabaseSync(join(run, 'capture', name), { readOnly: true })
|
||||
try { await backup(db, join(run, 'backup', name)) } finally { db.close() }
|
||||
}
|
||||
await cp(join(run, 'backup', name), join(run, 'work', name))
|
||||
}
|
||||
summary.metadata = Object.fromEntries(names.map(name => [name, inspect(join(run, 'backup', name))]))
|
||||
assert.ok(summary.metadata['assistant.sqlite'].tables.messages.count > 0, 'real messages required')
|
||||
// Notes use relative file-backed content. No settings/credential files are copied.
|
||||
if (await stat(join(source, 'notes')).catch(() => undefined)) {
|
||||
summary.notesBefore = await contentFingerprint(join(source, 'notes'))
|
||||
await cp(join(source, 'notes'), join(run, 'work/notes'), { recursive: true })
|
||||
assert.deepEqual(await contentFingerprint(join(run, 'work/notes')), summary.notesBefore, 'note copy differs')
|
||||
}
|
||||
const assets = new DatabaseSync(join(run, 'backup/conversation-attachments.sqlite'), { readOnly: true })
|
||||
summary.attachments = { referenced: 0, copied: 0, missing: 0, outsideRoot: 0, verifiedBytes: 0 }
|
||||
const attachmentFiles = []
|
||||
try {
|
||||
for (const row of assets.prepare('SELECT path FROM attachments').iterate()) {
|
||||
summary.attachments.referenced++
|
||||
const relative = row.path.replaceAll('\\', '/')
|
||||
const from = resolve(source, relative)
|
||||
if (!from.startsWith(source + '/'.replace('/', process.platform === 'win32' ? '\\' : '/'))) { summary.attachments.outsideRoot++; continue }
|
||||
const info = await stat(from).catch(() => undefined)
|
||||
if (!info) { summary.attachments.missing++; continue }
|
||||
await cp(from, join(run, 'work', relative), { recursive: true })
|
||||
const directory = info.isDirectory()
|
||||
const file = await (directory ? contentFingerprint(from) : fingerprint(from))
|
||||
assert.equal((await (directory ? contentFingerprint(join(run, 'work', relative)) : fingerprint(join(run, 'work', relative)))).sha256, file.sha256, 'attachment copy differs')
|
||||
attachmentFiles.push({ relative, file, directory })
|
||||
summary.attachments.verifiedBytes += file.bytes
|
||||
summary.attachments.copied++
|
||||
}
|
||||
} finally { assets.close() }
|
||||
for (const [entry, output, format] of [
|
||||
['src/main/desktop-storage-entry.ts', 'desktop-storage-entry.mjs', 'esm'],
|
||||
['src/main/readonly-query-worker.ts', 'readonly-query-worker.cjs', 'cjs'],
|
||||
['src/main/assistant-storage-worker.ts', 'assistant-storage-worker.cjs', 'cjs'],
|
||||
['src/main/assistant/subagent-progress-storage.ts', 'canonical.mjs', 'esm'],
|
||||
['tests/support/portable-storage-upgrade-fixture.ts', 'main.mjs', 'esm']
|
||||
]) await build({ entryPoints: [join(root, entry)], outfile: join(run, output), bundle: true, platform: 'node', format,
|
||||
external: ['electron', '@napi-rs/canvas'], logLevel: 'silent' })
|
||||
const { restoreSubagentPayload } = await import(pathToFileURL(join(run, 'canonical.mjs')).href)
|
||||
summary.backup = Object.fromEntries(await Promise.all(names.map(async name => [name, { file: await fingerprint(join(run, 'backup', name)), snapshot: snapshot(join(run, 'backup', name), restoreSubagentPayload) }])))
|
||||
await writeFile(join(temporary, 'summary.json'), JSON.stringify(summary, null, 2))
|
||||
const env = { ...process.env, GB_PORTABLE_UPGRADE_ROOT: run, TEMP: run, TMP: run, TMPDIR: run }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
async function fixture(phase) {
|
||||
console.log(`Production fixture: ${phase}`)
|
||||
await rm(join(run, 'fixture-summary.json'), { force: true })
|
||||
const child = spawn(createRequire(import.meta.url)('electron'), [join(run, 'main.mjs')], { env: { ...env, GB_PORTABLE_UPGRADE_PHASE: phase }, stdio: 'ignore' })
|
||||
let timedOut = false
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
if (process.platform === 'win32') execFileSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { timeout: 10_000, stdio: 'ignore' })
|
||||
else child.kill('SIGKILL')
|
||||
}, 180_000)
|
||||
const progress = setInterval(() => {
|
||||
void readFile(join(run, 'fixture-summary.json'), 'utf8').then(text => console.log(`Fixture stage: ${JSON.parse(text).stage}`)).catch(() => {})
|
||||
}, 10_000)
|
||||
const code = await new Promise((resolveExit, reject) => { child.once('exit', resolveExit); child.once('error', reject) }).finally(() => { clearTimeout(timer); clearInterval(progress) })
|
||||
const result = JSON.parse(await readFile(join(run, 'fixture-summary.json'), 'utf8'))
|
||||
summary.fixture = result
|
||||
assert.equal(timedOut, false, 'production fixture deadline exceeded')
|
||||
assert.equal(code, 0, 'production fixture failed; see sanitized stage')
|
||||
return result
|
||||
}
|
||||
summary.initialization = await fixture('initialize')
|
||||
summary.after = Object.fromEntries(names.map(name => [name, snapshot(join(run, 'work', name), restoreSubagentPayload)]))
|
||||
summary.comparison = {}
|
||||
for (const name of names) {
|
||||
const original = summary.backup[name].snapshot
|
||||
const current = summary.after[name]
|
||||
summary.comparison[name] = { unchanged: [], changed: [], added: Object.keys(current.tables).filter(table => !original.tables[table]) }
|
||||
for (const [table, value] of Object.entries(original.tables)) {
|
||||
summary.comparison[name][canonical(value) === canonical(current.tables[table]) ? 'unchanged' : 'changed'].push(table)
|
||||
}
|
||||
assert.equal((await fingerprint(join(run, 'backup', name))).sha256, summary.backup[name].file.sha256, 'backup changed')
|
||||
assert.equal(current.foreignKeyDigest, original.foreignKeyDigest, 'foreign key violations changed')
|
||||
assert.equal(summary.comparison[name].changed.length, 0, 'existing content changed; review startup recovery before accepting')
|
||||
}
|
||||
summary.exercise = await fixture('exercise')
|
||||
summary.finalIntegrity = {}
|
||||
for (const name of names) {
|
||||
const db = new DatabaseSync(join(run, 'work', name), { readOnly: true })
|
||||
try {
|
||||
const integrity = db.prepare('PRAGMA integrity_check').get().integrity_check
|
||||
const violations = db.prepare('PRAGMA foreign_key_check').all()
|
||||
assert.equal(integrity, 'ok')
|
||||
assert.equal(hash(canonical(violations)), summary.backup[name].snapshot.foreignKeyDigest)
|
||||
summary.finalIntegrity[name] = { integrity, foreignKeyViolations: violations.length }
|
||||
} finally { db.close() }
|
||||
}
|
||||
for (const { relative, file, directory } of attachmentFiles) {
|
||||
const read = directory ? contentFingerprint : fingerprint
|
||||
assert.deepEqual(await read(join(source, relative)), file, 'original attachment changed')
|
||||
assert.equal((await read(join(run, 'work', relative)))?.sha256, file.sha256, 'work attachment changed')
|
||||
}
|
||||
summary.attachments.unchangedAfter = true
|
||||
if (summary.notesBefore) {
|
||||
assert.deepEqual(await contentFingerprint(join(source, 'notes')), summary.notesBefore, 'original notes changed')
|
||||
assert.deepEqual(await contentFingerprint(join(run, 'work/notes')), summary.notesBefore, 'work notes changed')
|
||||
summary.notesUnchanged = true
|
||||
}
|
||||
summary.workFiles = Object.fromEntries(await Promise.all(names.map(async name => [name, await fingerprint(join(run, 'work', name))])))
|
||||
summary.status = 'passed'
|
||||
}
|
||||
} catch (error) {
|
||||
summary.status = 'failed'
|
||||
// Error messages, stacks and assertions can contain private row data or paths.
|
||||
summary.failure = { name: error?.name, code: error?.code, stage: summary.fixture?.stage ?? (summary.backup ? 'validation' : 'backup/setup') }
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (before) {
|
||||
summary.sourceAfter = await sourceFingerprint()
|
||||
summary.sourceUnchanged = canonical(before) === canonical(summary.sourceAfter)
|
||||
if (!summary.sourceUnchanged) { summary.status = 'failed: source fingerprints changed'; process.exitCode = 1 }
|
||||
}
|
||||
if (!process.argv.includes('--inspect')) {
|
||||
if (ownsRun) await rm(run, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })
|
||||
summary.copiesRemoved = ownsRun
|
||||
summary.durationMs = Date.now() - Date.parse(summary.startedAt)
|
||||
summary.reusedBackup = resume
|
||||
await writeFile(join(temporary, 'summary.json'), JSON.stringify(summary, null, 2))
|
||||
console.log(JSON.stringify({ status: summary.status, failure: summary.failure, sourceUnchanged: summary.sourceUnchanged, copiesRemoved: summary.copiesRemoved }))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import console from 'node:console'
|
||||
|
||||
export async function validateStoryGraphFocus(win, js, wait, url) {
|
||||
await win.loadURL(url + '?spiral=1&recap=1&focus=1')
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
await js('document.querySelector("#supervisor-tab-graph").click()')
|
||||
await wait('[...document.querySelectorAll("button")].some(b => b.textContent === "时间螺旋")')
|
||||
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
|
||||
await wait('window.focusFrames?.length > 0')
|
||||
win.show(); win.focus(); win.webContents.focus()
|
||||
await wait('document.hasFocus()')
|
||||
const canvas = 'document.querySelector(".story-graph-3d__viewport canvas[tabindex]")'
|
||||
const frame = () => js('window.focusFrames.at(-1)')
|
||||
const sample = () => js(`new Promise(resolve => {
|
||||
const start = performance.now(); const samples = [];
|
||||
function tick(now) { samples.push({...window.focusFrames.at(-1)}); if (now-start < 650) requestAnimationFrame(tick); else resolve(samples); }
|
||||
requestAnimationFrame(tick);
|
||||
})`)
|
||||
const changed = (a, b) => Math.hypot(...a.position.map((v, i) => v - b.position[i]))
|
||||
const stable = samples => {
|
||||
const tail = samples.filter(s => s.at === samples.at(-1).at)
|
||||
assert(tail.length > 2, 'Animation finishes and stops requesting frames')
|
||||
}
|
||||
const pick = async name => {
|
||||
await js('document.querySelector(".story-graph-3d__picker").click()')
|
||||
await wait('!!document.querySelector(".story-graph-3d__menu [role=menuitem]")')
|
||||
await js(`[...document.querySelectorAll('.story-graph-3d__menu [role=menuitem]')].find(b => b.textContent.includes(${JSON.stringify(name)})).click()`)
|
||||
}
|
||||
const clickTarget = async id => {
|
||||
await js(`${canvas}.scrollIntoView()`)
|
||||
await js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
const at = await js(`(() => { const r=${canvas}.getBoundingClientRect(); const p=window.focusTargets.find(t=>t.id===${JSON.stringify(id)});
|
||||
return {x:Math.round(r.left+(p.x+1)*r.width/2),y:Math.round(r.top+(1-p.y)*r.height/2)} })()`)
|
||||
assert(await js(`document.elementFromPoint(${at.x},${at.y}) === ${canvas}`), 'Native target is on the visible canvas')
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', ...at })
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...at })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...at })
|
||||
}
|
||||
const list = async kind => {
|
||||
await js(`(() => { const s=document.querySelector('.supervisor-workspace__category');
|
||||
if(s) { s.value='${kind}'; s.dispatchEvent(new Event('change',{bubbles:true})); }
|
||||
else document.querySelector('[id$="-list-tab-${kind}"]').click(); })()`)
|
||||
}
|
||||
const reports = []
|
||||
const before = await frame()
|
||||
await pick('Fixture project')
|
||||
const flight = await sample()
|
||||
assert(changed(before, flight.at(-1)) > 100, 'Hierarchy selection moves the actual Three camera')
|
||||
assert(new Set(flight.map(s => s.at)).size > 4, 'Focus is animated over multiple rendered frames')
|
||||
stable(flight)
|
||||
for (let i = 1; i < flight.length; i++) {
|
||||
const delta = Math.atan2(Math.sin(flight[i].yaw-flight[i-1].yaw), Math.cos(flight[i].yaw-flight[i-1].yaw))
|
||||
assert(Math.abs(delta) < 0.8, 'No discontinuous camera turn')
|
||||
}
|
||||
reports.push({ hierarchyDistance: changed(before, flight.at(-1)), renderedFrames: new Set(flight.map(s => s.at)).size })
|
||||
await js(`for(let i=0;i<55;i++) ${canvas}.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`)
|
||||
const turned = await frame()
|
||||
await pick('Fixture story')
|
||||
const shortFlight = [turned, ...await sample()]
|
||||
const rotation = shortFlight.slice(1).reduce((sum, f, i) => sum + Math.abs(Math.atan2(Math.sin(f.yaw-shortFlight[i].yaw), Math.cos(f.yaw-shortFlight[i].yaw))), 0)
|
||||
assert(rotation <= Math.PI + 1e-6, 'Focus after a full manual revolution takes the shortest angular path')
|
||||
reports.push({ shortestRotation: rotation })
|
||||
await list('story')
|
||||
await wait('!!document.querySelector(".supervisor-workspace__story-group button")')
|
||||
await js('[...document.querySelectorAll(".supervisor-workspace__story-group button")].find(b => b.textContent.includes("Secondary story")).click()')
|
||||
await wait('document.querySelector(".story-graph-3d__breadcrumb strong")?.textContent === "Secondary story"')
|
||||
stable(await sample())
|
||||
await js(`${canvas}.dispatchEvent(new KeyboardEvent('keydown',{key:'2',bubbles:true}))`)
|
||||
await sample()
|
||||
await clickTarget('feature')
|
||||
await wait('document.querySelector(".story-graph-3d__breadcrumb strong")?.textContent === "Fixture story"')
|
||||
stable(await sample())
|
||||
assert(await js('[...document.querySelectorAll(".supervisor-workspace__story-group button")].find(b=>b.textContent.includes("Fixture story")).getAttribute("aria-pressed") === "true"'))
|
||||
await list('experience')
|
||||
await wait('!!document.querySelector("[id$=list-panel-experience] button")')
|
||||
await js('document.querySelector("[id$=list-panel-experience] button").click()')
|
||||
stable(await sample())
|
||||
let target = await js('window.focusTargets.find(t => t.id === "focus-experience")')
|
||||
assert(target && Math.abs(target.x) < 0.01 && Math.abs(target.y) < 0.01, 'External experience selection centers its actual diamond')
|
||||
await js(`${canvas}.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`)
|
||||
await clickTarget('focus-experience')
|
||||
stable(await sample())
|
||||
target = await js('window.focusTargets.find(t => t.id === "focus-experience")')
|
||||
assert(Math.abs(target.x) < 0.01 && Math.abs(target.y) < 0.01, 'Native diamond pick refocuses the experience')
|
||||
// Root has no within-project experience link. Selecting it must expose the project level.
|
||||
await js('document.querySelector(".story-graph-3d__breadcrumb button").click()')
|
||||
await sample()
|
||||
await list('event')
|
||||
await wait('!!document.querySelector(".supervisor-workspace__list-panel button")')
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel button").click()')
|
||||
stable(await sample())
|
||||
await list('experience')
|
||||
await js('document.querySelector("[id$=list-panel-experience] button").click()')
|
||||
stable(await sample())
|
||||
assert.equal(await js('document.querySelector(".story-graph-3d__breadcrumb strong").textContent'), 'Fixture project')
|
||||
for (const width of [1440, 390]) {
|
||||
win.setContentSize(width, 1000)
|
||||
await wait(`innerWidth === ${width}`)
|
||||
await list('event')
|
||||
await js('document.querySelectorAll(".supervisor-workspace__list-panel button")[1].click()')
|
||||
stable(await sample())
|
||||
target = await js('window.focusTargets.find(t => t.id === "event-1")')
|
||||
assert(target && Math.abs(target.x) < 0.01 && Math.abs(target.y) < 0.01, 'External event centers at both viewport widths')
|
||||
reports.push({ width, eventProjection: target })
|
||||
await js('document.querySelector(".supervisor-workspace__list-panel button").click()')
|
||||
await sample()
|
||||
}
|
||||
win.setContentSize(1440, 1000)
|
||||
await wait('innerWidth === 1440')
|
||||
await js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
||||
// A native canvas click must select the dot through its translucent stave and refocus it.
|
||||
await js(`${canvas}.focus(); ${canvas}.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`)
|
||||
const dot = await js(`(() => { const c=${canvas}; c.scrollIntoView(); const r=c.getBoundingClientRect();
|
||||
const p=window.focusTargets.find(t=>t.id==='event-0'); return {x:Math.round(r.left+(p.x+1)*r.width/2),y:Math.round(r.top+(1-p.y)*r.height/2)} })()`)
|
||||
const hitElement = await js(`document.elementFromPoint(${dot.x},${dot.y})?.outerHTML.slice(0,300)`)
|
||||
assert(await js(`document.elementFromPoint(${dot.x},${dot.y}) === ${canvas}`), `Canvas hit point: ${JSON.stringify({ dot, hitElement })}`)
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', ...dot })
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...dot })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, ...dot })
|
||||
await sample()
|
||||
target = await js('window.focusTargets.find(t=>t.id==="event-0")')
|
||||
assert(Math.abs(target.x) < 0.01 && Math.abs(target.y) < 0.01, `Native canvas dot click recenters the selected event: ${JSON.stringify({target, dot, hitElement, current:await frame()})}`)
|
||||
assert(await js('document.querySelector(".supervisor-workspace__list-panel button").getAttribute("aria-pressed") === "true"'))
|
||||
for (const input of ['keyboard', 'wheel', 'drag']) {
|
||||
await pick('Secondary story')
|
||||
await wait('window.focusFrames.at(-1).zoom > 0')
|
||||
await js(`new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))`)
|
||||
const pre = await frame()
|
||||
if (input === 'keyboard') {
|
||||
await js(`${canvas}.focus()`)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Left' })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Left' })
|
||||
} else {
|
||||
const at = await js(`(() => { const c=${canvas}; c.scrollIntoView(); const r=c.getBoundingClientRect(); return {x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)} })()`)
|
||||
if (input === 'wheel') win.webContents.sendInputEvent({ type: 'mouseWheel', ...at, deltaY: 70, deltaX: 0 })
|
||||
else {
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', button: 'left', clickCount: 1, ...at })
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', button: 'left', modifiers: ['leftButtonDown'], x: at.x+30, y: at.y+10 })
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', button: 'left', clickCount: 1, x: at.x+30, y: at.y+10 })
|
||||
}
|
||||
}
|
||||
await wait(`window.focusFrames.at(-1).at > ${pre.at}`)
|
||||
const interrupted = await frame(), samples = await sample()
|
||||
assert.deepEqual(samples.at(-1).position, interrupted.position, `${input} cancels the in-flight focus`)
|
||||
assert.equal(samples.at(-1).zoom, interrupted.zoom)
|
||||
reports.push({ interruption: input, position: interrupted.position, zoom: interrupted.zoom })
|
||||
}
|
||||
const manual = await frame()
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await js('document.querySelector(".supervisor-workspace__review-context > .secondary-button").click()')
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
await sample()
|
||||
assert.deepEqual((await frame()).position, manual.position, 'Equal-content refresh does not refocus')
|
||||
}
|
||||
win.webContents.debugger.attach('1.3')
|
||||
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] })
|
||||
await wait('matchMedia("(prefers-reduced-motion: reduce)").matches')
|
||||
await js('window.focusFrames = []')
|
||||
await pick('Fixture story')
|
||||
await sample()
|
||||
const reduced = await js('window.focusFrames')
|
||||
assert(new Set(reduced.map(s => JSON.stringify(s.position))).size <= 2, 'Reduced motion applies a single camera jump')
|
||||
win.webContents.debugger.detach()
|
||||
await pick('Secondary story')
|
||||
await wait('window.focusRafs.size > 0')
|
||||
await js('[...document.querySelectorAll(".supervisor-workspace__graph-mode button")].find(b=>b.getAttribute("aria-pressed")==="false").click()')
|
||||
await wait('!document.querySelector(".story-graph-3d__gl")')
|
||||
assert.equal(await js('window.focusRafs.size'), 0, 'Unmount cancels the owned camera RAF')
|
||||
await win.loadURL(url + '?spiral=1&recap=1&focus=1&real-export=1')
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
await js('document.querySelector("#supervisor-tab-graph").click()')
|
||||
await wait('[...document.querySelectorAll("button")].some(b => b.textContent === "时间螺旋")')
|
||||
await js('[...document.querySelectorAll("button")].find(b => b.textContent === "时间螺旋").click()')
|
||||
await wait('window.focusFrames?.length > 0')
|
||||
await sample()
|
||||
const realBefore = await frame()
|
||||
await list('story')
|
||||
await wait('!!document.querySelector(".supervisor-workspace__story-group button")')
|
||||
await js('document.querySelector(".supervisor-workspace__story-group button").click()')
|
||||
const realFlight = await sample()
|
||||
assert(changed(realBefore, realFlight.at(-1)) > 5, 'Checked-in real export selection moves the production camera')
|
||||
stable(realFlight)
|
||||
await js(`${canvas}.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`)
|
||||
const realManual = await frame()
|
||||
await js('document.querySelector(".supervisor-workspace__review-context > .secondary-button").click()')
|
||||
await wait('document.querySelector(".supervisor-workspace")?.getAttribute("aria-busy") === "false"')
|
||||
await sample()
|
||||
assert.deepEqual((await frame()).position, realManual.position)
|
||||
const realExport = { ...await js('JSON.parse(document.documentElement.dataset.exportCounts)'), cameraDistance: changed(realBefore, realFlight.at(-1)) }
|
||||
console.log(JSON.stringify({ storyGraphFocus: 'passed', reports, refreshes: 4, reducedMotionFrames: reduced.length, canvasClick: true, unmountPendingRafs: 0, realExport, modelCalls: 0 }))
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import { DesktopStorageClient } from '../../src/main/desktop-storage-client'
|
||||
import { createSupervisionDomainPorts } from '../../src/main/assistant/supervision-domain-ports'
|
||||
import { createProductionSupervisorService, createProductionSuggestionPhraser } from '../../src/main/assistant/supervision-production'
|
||||
import { SupervisionModelPool } from '../../src/main/assistant/supervision-model-pool'
|
||||
import { HeartbeatService } from '../../src/main/assistant/heartbeat-service'
|
||||
import { deriveSuggestions } from '../../src/main/assistant/supervision-suggester'
|
||||
import type { AgentRuntime } from '../../src/main/agent/runtime'
|
||||
import type { SupervisionEvidence, SupervisionRunRequest } from '../../src/shared/supervision-contracts'
|
||||
|
||||
const root = process.env.GB_SUPERVISION_STORAGE_ROOT!
|
||||
app.setPath('userData', join(root, 'electron'))
|
||||
async function run(): Promise<void> {
|
||||
await app.whenReady()
|
||||
const storage = new DesktopStorageClient({ assistantPath: join(root, 'assistant.sqlite'), knowledgePath: join(root, 'knowledge.sqlite'),
|
||||
defaultRootPath: root, userDataPath: root, entryPath: join(root, 'desktop-storage-entry.mjs'),
|
||||
readerWorkerPath: join(root, 'readonly-query-worker.cjs'), upgradeWorkerPath: join(root, 'assistant-storage-worker.cjs') })
|
||||
const pool = new SupervisionModelPool()
|
||||
try {
|
||||
await storage.ready
|
||||
const ports = createSupervisionDomainPorts(storage)
|
||||
const project = (await storage.call('assistant', 'listProjects', []))[0]!
|
||||
const now = Date.now()
|
||||
const seed = async (content: string) => storage.call('assistant', 'saveLocalConversations', [[{
|
||||
header: { id: randomUUID(), projectId: project.id, title: 'Review fixture', updatedAt: now },
|
||||
messages: [{ id: randomUUID(), role: 'user', content, state: 'complete', createdAt: now }]
|
||||
}]])
|
||||
await seed('A'.repeat(1500))
|
||||
const request: SupervisionRunRequest = { trigger: 'heartbeat', scope: { kind: 'global' },
|
||||
timeRange: { from: new Date(now - 1000).toISOString(), to: new Date(now + 10000).toISOString() } }
|
||||
const phases: string[] = []
|
||||
let block = false
|
||||
let entered!: () => void
|
||||
const modelEntered = new Promise<void>(resolve => { entered = resolve })
|
||||
const runtime: AgentRuntime = { runtimeId: 'model', capability: 'chat', requiresToolApproval: false, supportsToolExecution: false,
|
||||
getStatus: async () => ({ id: 'model', label: 'Fixture', available: true, detail: 'Fixture ready', supportsToolExecution: false }),
|
||||
dispose: async () => {},
|
||||
run: async function* (input, signal) {
|
||||
if (block) {
|
||||
entered()
|
||||
await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
|
||||
yield { type: 'error', requestId: input.requestId, status: 'cancelled', message: 'Cancelled fixture' }
|
||||
return
|
||||
}
|
||||
let output: unknown
|
||||
const empty = { summary: 'Review', changeDigest: '', openItems: ['Choose the next milestone'], events: [], entities: [], entityChanges: [], relations: [] }
|
||||
if (input.prompt.includes('You organise the GoodBuddy')) {
|
||||
phases.push('stories')
|
||||
output = { stories: [{ key: 'new_1', level: 'feature', name: 'Delivery' }],
|
||||
assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_1' }] }
|
||||
} else if (input.prompt.includes('You distil reusable experience')) {
|
||||
phases.push('experiences')
|
||||
output = { experiences: [{ key: 'new_1', statement: 'Check the assumption before publishing', formed: ['e_1'] }] }
|
||||
} else if (input.prompt.includes('You write short, concrete suggestions')) {
|
||||
phases.push('suggestions')
|
||||
const candidates = JSON.parse(input.prompt.split('CANDIDATES:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!)
|
||||
output = { suggestions: candidates }
|
||||
} else if (input.prompt.includes('These inputs are navigation summaries')) {
|
||||
phases.push('navigation')
|
||||
output = empty
|
||||
} else {
|
||||
phases.push('leaf')
|
||||
const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as SupervisionEvidence[]
|
||||
output = { ...empty, events: [{ title: 'Decide delivery approach', description: 'Validate the assumption first', eventType: 'decision',
|
||||
occurredAt: new Date(now).toISOString(), entityIds: [], sourceReferenceIds: [evidence[0]!.id] }] }
|
||||
}
|
||||
yield { type: 'text', requestId: input.requestId, delta: JSON.stringify(output) }
|
||||
yield { type: 'model-usage', requestId: input.requestId, callId: 'call', runtime: 'model', provider: 'fixture', model: 'fixture',
|
||||
inputTokens: 4, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
yield { type: 'done', requestId: input.requestId }
|
||||
} }
|
||||
const settings = async () => ({ supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10,
|
||||
executionSeconds: 300, experienceMinEvents: 2 } })
|
||||
const supervisor = createProductionSupervisorService(ports.supervision, settings, async () => runtime, pool)
|
||||
const phrase = createProductionSuggestionPhraser(ports.supervision, settings, async () => runtime, pool)
|
||||
const heartbeat = new HeartbeatService(ports.heartbeat, {
|
||||
review: async ({ run }) => {
|
||||
const result = await supervisor.run(request, run.id)
|
||||
return { status: result.status ?? 'completed', runId: result.runId }
|
||||
},
|
||||
suggest: async ({ run, supervisionRunId }) => deriveSuggestions(ports.suggestions, phrase, { heartbeatRunId: run.id, supervisionRunId })
|
||||
})
|
||||
const config = await heartbeat.create({ name: 'Fixture', scope: request.scope, timezone: 'UTC', recurrence: { type: 'daily', localTime: '09:00' },
|
||||
enabled: true, lookbackHours: 24, retentionDays: 30 })
|
||||
const first = await heartbeat.runNow({ id: config.id, idempotencyKey: 'first' })
|
||||
assert.equal(first.status, 'completed')
|
||||
assert.deepEqual(phases, ['leaf', 'leaf', 'navigation', 'stories', 'experiences', 'suggestions'])
|
||||
assert.equal((await ports.stories.list(request.scope)).length, 1)
|
||||
assert.equal((await ports.experiences.list()).length, 1)
|
||||
assert.ok(await ports.suggestions.countForHeartbeat(first.id) > 0)
|
||||
assert.equal((await storage.call('assistant', 'getTokenUsageSummary', [])).systemTotals?.callCount, 6)
|
||||
assert.equal((await heartbeat.runNow({ id: config.id, idempotencyKey: 'quiet' })).status, 'no_change')
|
||||
assert.equal(phases.length, 6)
|
||||
await seed('A new decision')
|
||||
block = true
|
||||
const pending = supervisor.run(request)
|
||||
await modelEntered
|
||||
const runId = supervisor.execution().runId!
|
||||
await supervisor.cancel(runId)
|
||||
assert.equal((await pending).status, 'cancelled')
|
||||
assert.equal(supervisor.execution().active, false)
|
||||
assert.equal((await storage.call('assistant', 'listSupervisionActivity', [])).find(row => row.id === runId)!.status, 'cancelled')
|
||||
assert.equal(storage.pendingCount, 0)
|
||||
console.log('supervision-storage: passed')
|
||||
} finally { pool.dispose(); await storage.close() }
|
||||
}
|
||||
void run().then(() => app.exit(0), error => { console.error(error); app.exit(1) })
|
||||
@@ -6,6 +6,7 @@ import process from 'node:process'
|
||||
import console from 'node:console'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
import { validateSupervisorSelection } from './supervisor-selection-driver.mjs'
|
||||
import { validateStoryGraphFocus } from './story-graph-focus-driver.mjs'
|
||||
|
||||
const directory = process.env.GOODBUDDY_SUPERVISOR_DIRECTORY
|
||||
const artifacts = process.env.GOODBUDDY_SUPERVISOR_ARTIFACTS || directory
|
||||
@@ -64,6 +65,11 @@ app
|
||||
await settle()
|
||||
}
|
||||
const reports = []
|
||||
if (process.env.GOODBUDDY_STORY_GRAPH_FOCUS) {
|
||||
await validateStoryGraphFocus(win, js, wait, process.env.GOODBUDDY_SUPERVISOR_URL)
|
||||
assert.deepEqual(errors, [])
|
||||
win.destroy(); app.quit(); return
|
||||
}
|
||||
if (process.env.GOODBUDDY_SUPERVISOR_WINDOWING) {
|
||||
await validateSupervisorSelection(win, js, wait, process.env.GOODBUDDY_SUPERVISOR_URL)
|
||||
assert.deepEqual(errors, [])
|
||||
|
||||
@@ -14,6 +14,18 @@ import { installBundledUiFonts } from '../../src/renderer/src/fonts'
|
||||
installBundledUiFonts()
|
||||
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (params.has('focus')) {
|
||||
const request = window.requestAnimationFrame.bind(window), cancel = window.cancelAnimationFrame.bind(window)
|
||||
const pending = new Set<number>()
|
||||
Reflect.set(window, 'focusRafs', pending)
|
||||
window.requestAnimationFrame = callback => {
|
||||
const tracked = new Error().stack?.includes('StoryGraph3D.tsx')
|
||||
const id = request(now => { pending.delete(id); callback(now) })
|
||||
if (tracked) pending.add(id)
|
||||
return id
|
||||
}
|
||||
window.cancelAnimationFrame = id => { pending.delete(id); cancel(id) }
|
||||
}
|
||||
if (params.has('spiral')) {
|
||||
const THREE = await import('three')
|
||||
const add = THREE.Group.prototype.add
|
||||
@@ -60,6 +72,30 @@ if (params.has('spiral')) {
|
||||
}
|
||||
THREE.Scene.prototype.onAfterRender = function (_renderer, scene, camera) {
|
||||
document.documentElement.dataset.camera = JSON.stringify(camera.position.toArray())
|
||||
if (params.has('focus')) {
|
||||
const frames = Reflect.get(window, 'focusFrames') ?? []
|
||||
const direction = camera.getWorldDirection(new THREE.Vector3())
|
||||
const snapshot = { at: performance.now(), position: camera.position.toArray(), yaw: Math.atan2(direction.x, -direction.z), zoom: (camera as import('three').PerspectiveCamera).zoom }
|
||||
frames.push(snapshot)
|
||||
if (frames.length > 240) frames.shift()
|
||||
Reflect.set(window, 'focusFrames', frames)
|
||||
const targets: unknown[] = []
|
||||
scene.traverse(object => {
|
||||
if (object.userData.node && !object.userData.cluster) {
|
||||
const positions = (object as import('three').Mesh).geometry.getAttribute('position')
|
||||
const mid = Math.floor(positions.count / 4) * 2
|
||||
const p = new THREE.Vector3().fromBufferAttribute(positions, mid)
|
||||
.lerp(new THREE.Vector3().fromBufferAttribute(positions, mid + 1), 0.37).project(camera)
|
||||
targets.push({ id: object.userData.node.id, kind: 'story', x: p.x, y: p.y })
|
||||
}
|
||||
if (!object.userData.cluster && !object.userData.link) return
|
||||
const p = object.position.clone().project(camera)
|
||||
targets.push({ id: object.userData.link?.id ?? object.userData.cluster.events[0].id,
|
||||
kind: object.userData.link ? 'experience' : 'event', x: p.x, y: p.y })
|
||||
})
|
||||
Reflect.set(window, 'focusTargets', targets)
|
||||
return
|
||||
}
|
||||
const point = new THREE.Vector3(), bounds = { left: 1, right: -1, top: -1, bottom: 1 }
|
||||
scene.traverse(object => {
|
||||
const positions = (object as import('three').Mesh).geometry?.getAttribute('position')
|
||||
@@ -221,7 +257,10 @@ Object.defineProperty(window, 'goodbuddy', {
|
||||
events: [...graph.events, { id: 'outside-review', title: 'Outside review', occurred_at: '2026-08-01T00:00:00Z' }].filter((_event, index) => index % 2 === storyIndex).map(event => ({
|
||||
id: event.id, title: event.title, startedAt: event.occurred_at, endedAt: event.occurred_at, projectId: 'p', primary: true, userSet: false
|
||||
}))
|
||||
})), experiences: [], unassigned: 0, canUndo: false }) } : {}),
|
||||
})), experiences: params.has('focus') ? [{ id: 'focus-experience', statement: 'Fixture experience', conditions: '', boundaries: '', userEdited: false,
|
||||
events: [0, 1].map(index => ({ id: `event-${index}`, role: index ? 'applied' : 'formed', note: '', title: graph.events[index]!.title,
|
||||
projectId: 'p', at: graph.events[index]!.occurred_at, storyId: index ? 'secondary' : 'feature', storyName: index ? 'Secondary story' : 'Fixture story' }))
|
||||
}] : [], unassigned: 0, canUndo: false }) } : {}),
|
||||
continueContext: async () => ({ prompt: '模拟讨论上下文:本周已核对交付清单,负责人已确认。\n\n原始依据:模拟会议记录。外部评审时间仍待确认,下一步需要核对验收条件。' }),
|
||||
continue: async () => { throw new Error('Preview fixture must not send a message') },
|
||||
execution: async () => ({ active: params.has('activity'), ...(params.has('activity') ? { runId: 'activity-1' } : {}) }),
|
||||
@@ -307,6 +346,27 @@ Object.defineProperty(window, 'goodbuddy', {
|
||||
}
|
||||
})
|
||||
let portableProjects: AssistantProject[] = []
|
||||
if (params.has('real-export')) {
|
||||
// Use the checked-in read-only export. Its proposed feature grouping is not user-confirmed.
|
||||
const data: { range: string[]; projects: Array<{ name: string; features: Array<{ id: string; name: string; events: Array<{ id: string; at: string; title: string; text: string }> }> }> } = await (await fetch('/focus-export.json')).json()
|
||||
const savedEvents = new Map<string, SupervisionGraphView['events'][number]>()
|
||||
const exportedStories = data.projects.flatMap((project, index) => project.features.map(feature => ({
|
||||
id: feature.id, projectId: `export-${index}`, projectName: project.name, parentId: null, level: 'feature' as const, name: feature.name,
|
||||
description: '', state: 'active' as const, stateEventId: null, userEdited: false, startedAt: null, endedAt: null,
|
||||
events: feature.events.filter(event => {
|
||||
if (savedEvents.has(event.id)) return false
|
||||
savedEvents.set(event.id, { id: event.id, title: event.title || event.id, description: event.text, occurred_at: event.at, project_id: `export-${index}` })
|
||||
return true
|
||||
}).map(event => ({ id: event.id, title: event.title || event.id, startedAt: event.at, endedAt: event.at, projectId: `export-${index}`, primary: true, userSet: false }))
|
||||
})))
|
||||
const api = window.goodbuddy.supervision, overview = api.overview
|
||||
Object.assign(api, {
|
||||
overview: async () => (await overview()).map(result => ({ ...result, timeRange: { from: `${data.range[0]}T00:00:00Z`, to: `${data.range[1]}T00:00:00Z` } })),
|
||||
graph: async () => ({ ...graph, events: [...savedEvents.values()], attention: [], eventEntities: [], eventSources: [] }),
|
||||
stories: async () => ({ stories: exportedStories, experiences: [], unassigned: 0, canUndo: false })
|
||||
})
|
||||
document.documentElement.dataset.exportCounts = JSON.stringify({ projects: data.projects.length, stories: exportedStories.length, events: savedEvents.size })
|
||||
}
|
||||
if (params.has('portable')) {
|
||||
const data = await (await fetch('/portable-review.json')).json()
|
||||
portableProjects = data.projects
|
||||
|
||||
Reference in New Issue
Block a user