fix: reduce supervisor stalls and preserve task delivery

Load Supervisor graphs only when opened, preserve source details on demand, reuse indexed story digests and window selection lists without losing keyboard or focus behavior. Route heavy UI reads through the existing worker, batch scoped queries and construct each publication snapshot once.

Keep remote cancellation responsive during model-route cleanup, remove late routes and retain reported usage after response-cap cancellation. Preserve Telegram pending replies through polling conflicts, show delivery errors and honor the final rate-limit cooldown.

Schema 62 adds lookup indexes without rewriting user content; older clients cannot open upgraded data. Large review publication still blocks Main and remains documented follow-up work. Record the future storage-service direction without performing the architecture migration.

Validation: exact staged code passed isolated full tests (6212 passed, 87 skipped), typecheck and lint. Three-round source performance comparisons, SQLite migration equivalence, native Electron interaction checks and current-source Linux x64 Host validation completed; eight external text-model requests.

Release note: Supervisor opens reviews without loading the entire graph, keeps long selection lists responsive and preserves details and keyboard navigation. Remote cancellation and Telegram reply recovery are more reliable; remote fixes require an Agent build containing this change.
This commit is contained in:
mesalogo
2026-10-05 14:18:33 +08:00
parent e50ca6d149
commit b90ff50f2a
59 changed files with 2108 additions and 265 deletions
+5 -1
View File
@@ -18,6 +18,7 @@ export class AgentAcpConnection {
private closed = false
private closeInput?: () => void
private bridgeOrigin?: string
private readonly routeLifetime = new AbortController()
constructor(
process: RuntimeAcpProcessOwner,
@@ -43,6 +44,7 @@ export class AgentAcpConnection {
this.unsubscribeExit = process.subscribeExit?.(() => {
if (this.closed) return
const error = new Error('ACP Runtime process exited')
this.routeLifetime.abort(error)
controller?.error(error)
controller = undefined
rejectExit(error)
@@ -89,17 +91,19 @@ export class AgentAcpConnection {
socketPath?: string,
imageToolName?: string
): Promise<void> {
this.routeLifetime.signal.throwIfAborted()
if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready')
const response = await fetch(`${this.bridgeOrigin}/session`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, imageToolName } : { release: true }) }),
signal: AbortSignal.timeout(10_000)
signal: AbortSignal.any([this.routeLifetime.signal, AbortSignal.timeout(10_000)])
})
if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`)
}
dispose(): void {
if (this.closed) return
this.closed = true
this.routeLifetime.abort(new Error('ACP transport is closed'))
this.sessions.clear()
this.unsubscribeOutput()
this.unsubscribeExit()
@@ -12,6 +12,7 @@ import type {
RuntimeAcpProcessOwner
} from './runtime-acp-backend'
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
import { AgentAcpConnection } from './agent-acp-connection'
import { SemanticPromptStore } from './semantic-prompt-store'
const temporary: string[] = []
@@ -23,6 +24,35 @@ afterEach(() => {
})
describe('AgentOwnedAcpPrompt', () => {
it.each(['dispose', 'exit'])('aborts pending route HTTP requests on transport %s', async action => {
const process = new MemoryProcess()
let client!: Client
const transport = new AgentAcpConnection(process, undefined, factory => {
client = factory()
return {} as ClientSideConnection
})
const signals: AbortSignal[] = []
const fetcher = vi.fn<typeof fetch>(async (_input, init) => new Promise((_resolve, reject) => {
const signal = init!.signal!
signals.push(signal)
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
}))
vi.stubGlobal('fetch', fetcher)
try {
await client.extNotification!('goodbuddy/modelBridgeReady', { origin: `http://127.0.0.1:1234/${'a'.repeat(43)}` })
const install = transport.setModelRoute('session-1', 'operation-1', '/broker').catch(error => error)
const release = transport.setModelRoute('session-2', 'operation-2').catch(error => error)
expect(signals.every(signal => !signal.aborted)).toBe(true)
if (action === 'dispose') transport.dispose()
else await process.emitExit()
expect(signals.every(signal => signal.aborted)).toBe(true)
expect(await install).toBeInstanceOf(Error)
expect(await release).toBeInstanceOf(Error)
await expect(transport.setModelRoute('session-1', 'operation-1')).rejects.toThrow()
expect(fetcher).toHaveBeenCalledTimes(2)
} finally { transport.dispose(); vi.unstubAllGlobals() }
})
it('coalesces identical starts and rejects conflicting starts while initialization is pending', async () => {
const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-start-'))
temporary.push(root)
@@ -88,7 +118,7 @@ describe('AgentOwnedAcpPrompt', () => {
bindingId: 'binding-1', operationId, requestId: operationId,
prompt: [{ type: 'text', text: 'Continue' }]
})
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId)
expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, expect.any(AbortSignal))
if (index !== 2) {
expect(index === 0 ? newSession : resumeSession).toHaveBeenLastCalledWith(expect.objectContaining({ mcpServers: mcpServers() }))
}
+2 -2
View File
@@ -32,7 +32,7 @@ export type AgentOwnedAcpPromptOptions = {
expectedModel?: string
process: RuntimeAcpProcessOwner
transport?: AgentAcpConnection
prepareSession?: (sessionId: string, operationId: string) => Promise<void>
prepareSession?: (sessionId: string, operationId: string, signal: AbortSignal) => Promise<void>
transcript: SemanticPromptStore
completePrompt: (
operationId: string,
@@ -237,7 +237,7 @@ export class AgentOwnedAcpPrompt {
}))
}
this.#hasMcpServers = mcpServers.length > 0
await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId)))
await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId, signal)))
signal.throwIfAborted()
this.#active = {
operationId: request.operationId
@@ -518,6 +518,92 @@ function harness(input: {
}
describe('RuntimeAcpBackend', () => {
it.each(['cancel', 'deadline'])('does not hold peer control while releasing a startup route after %s', async reason => {
const fixture = await ownedHarness(true)
const releaseRoute = deferred()
let finishModel: (() => Promise<void>) | undefined
let releasing = false
try {
await fixture.start()
const process = fixture.processes[0]!
const originalWrite = process.onWrite!
process.onWrite = payload => {
const request = JSON.parse(Buffer.from(payload).toString())
const reply = (result: unknown) => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`)
if (request.method === 'session/new') void reply({ sessionId: 'starting-session' })
else if (request.method === 'session/set_config_option' && request.params.sessionId === 'starting-session') {
finishModel = () => reply({ configOptions: [{ id: 'model', name: 'Model', type: 'select', currentValue: request.params.value, options: [] }] })
} else originalWrite(payload)
}
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
vi.useFakeTimers()
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
operationId: 'starting-request', requestId: 'starting-request',
deadlineAt: reason === 'deadline' ? new Date(1100).toISOString() : UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }).catch(error => error)
await vi.advanceTimersByTimeAsync(1)
expect(finishModel).toBeDefined()
vi.mocked(AgentAcpConnection.prototype.setModelRoute).mockImplementation(async (_session, _operation, socket) => {
if (!socket) { releasing = true; await releaseRoute.promise }
})
let cancelled = reason === 'deadline', peer = false
const cancellation = reason === 'cancel' ? invoke(fixture, 'runtime/escalateCancellation', {
bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'starting-session', reason: 'requested'
}).then(() => { cancelled = true }) : undefined
await vi.advanceTimersByTimeAsync(200)
expect(releasing).toBe(true)
const query = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peer = true })
await vi.advanceTimersByTimeAsync(200)
const observed = { cancelled, peer }
releaseRoute.resolve()
await finishModel!()
finishModel = undefined
await Promise.all([starting, cancellation, query])
expect(observed).toEqual({ cancelled: true, peer: true })
expect(process.stops).toHaveLength(0)
expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running')
expect(fixture.page().events.filter(event => event.kind === 'prompt-terminal')).toHaveLength(0)
} finally {
releaseRoute.resolve(); await finishModel?.(); await fixture.prompts[0]?.()
vi.useRealTimers(); await fixture.cleanup()
}
})
it('removes a late startup route after cancellation without revoking its peer route', async () => {
const fixture = await ownedHarness(true)
const install = deferred()
const entered = deferred()
const routes = new Map<string, string>([['session-1', 'request-1']])
try {
await fixture.start()
const process = fixture.processes[0]!
const originalWrite = process.onWrite!
process.onWrite = payload => {
const request = JSON.parse(Buffer.from(payload).toString())
if (request.method === 'session/new') void process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'starting-session' } })}\n`)
else originalWrite(payload)
}
vi.mocked(AgentAcpConnection.prototype.setModelRoute).mockImplementation(async (session, operation, socket) => {
if (socket) { entered.resolve(); await install.promise; routes.set(session, operation) }
else if (routes.get(session) === operation) routes.delete(session)
})
const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string }
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch,
operationId: 'starting-request', requestId: 'starting-request', deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE }))
const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }).catch(error => error)
await entered.promise
await invoke(fixture, 'runtime/escalateCancellation', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'starting-session', reason: 'requested' })
expect(await starting).toBeInstanceOf(Error)
install.resolve()
await new Promise(resolve => setImmediate(resolve))
expect([...routes]).toEqual([['session-1', 'request-1']])
expect(process.stops).toHaveLength(0)
const page = fixture.transcript.page({ bindingId: 'starting', operationId: 'starting-request', controllerId: fixture.context.controller.controllerId, afterSequence: '0', limit: 10 })
expect(page.state).toBe('cancelled')
expect(page.events.filter(event => event.kind === 'prompt-terminal')).toHaveLength(1)
} finally { install.resolve(); await fixture.prompts[0]?.(); await fixture.cleanup() }
})
it.each(['watchdog', 'dispose'])('interrupts stalled native initialization through %s', async action => {
vi.useFakeTimers()
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
+14 -6
View File
@@ -1502,11 +1502,17 @@ export class RuntimeAcpBackend {
process: binding.process,
...(binding.sharedProcess ? {
transport: binding.sharedProcess.transport,
prepareSession: async (sessionId: string, operationId: string) => {
await binding.sharedProcess!.transport.setModelRoute(
sessionId, operationId, binding.modelBridgeBroker!.socketPath,
binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined
)
prepareSession: async (sessionId: string, operationId: string, signal: AbortSignal) => {
const transport = binding.sharedProcess!.transport
try {
await transport.setModelRoute(
sessionId, operationId, binding.modelBridgeBroker!.socketPath,
binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined
)
} finally {
// Cancellation can release the route before this installation finishes.
if (signal.aborted) await transport.setModelRoute(sessionId, operationId).catch(() => undefined)
}
}
} : {}),
transcript: this.#options.semanticPrompts,
@@ -1897,7 +1903,9 @@ export class RuntimeAcpBackend {
binding.modelBridgeClient = undefined
let brokerError: unknown
if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) {
await binding.sharedProcess.transport.setModelRoute(
// Broker shutdown below revokes dispatch authority. Native route cleanup
// is bounded by the transport and must not hold the global control queue.
void binding.sharedProcess.transport.setModelRoute(
binding.ownedAcp.sessionId, binding.activeOperationId
).catch(() => undefined)
}
@@ -320,12 +320,13 @@ it('persists supervision knowledge locators and keeps memory sources distinct',
}
database.saveSupervisionResult(result)
const graph = database.getSupervisionGraph()
expect(graph.sources).toEqual(expect.arrayContaining([
expect.objectContaining({ source_type: 'knowledge', locator_json: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }),
expect.objectContaining({ source_type: 'memory', locator_json: null })
const sources = (graph.sources as Array<{ id: string }>).map(source => database.getSupervisionSource(source.id)!)
expect(sources).toEqual(expect.arrayContaining([
expect.objectContaining({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }),
expect.objectContaining({ sourceType: 'memory', locatorJson: null })
]))
const source = (graph.sources as Array<{ id: string; source_type: string }>).find((item) => item.source_type === 'knowledge')!
expect(database.getSupervisionSource(source.id)).toMatchObject({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) })
const source = sources.find(item => item.sourceType === 'knowledge')!
expect(source).toMatchObject({ content: result.evidence[0]!.content, sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) })
} finally {
database.close()
}
@@ -443,7 +444,8 @@ it.each(['same-project', 'different-project'] as const)('isolates supervision mo
expect(graph.events).toHaveLength(1)
expect(graph.entities).toHaveLength(2)
expect(graph.sources).toHaveLength(1)
expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId, locator_json: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) })]))
expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId })]))
expect(database.getSupervisionSource(sourceId)).toMatchObject({ locatorJson: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) })
}
expect(inspected.prepare('PRAGMA foreign_key_check').all()).toEqual([])
} finally { inspected.close() }
@@ -2936,7 +2938,7 @@ describe('AssistantDatabase', () => {
migrated.upgradeStorage(progress, () => false, { migrateNotes: false, reclaimSpace: false })
expect(progress).not.toHaveBeenCalled()
const check = new DatabaseSync(path)
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(61)
expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(tables.map(table => check.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())).toEqual(rows)
expect(check.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()).toEqual(
schema.map(row => ['projects', 'conversations'].includes(row.name as string) && row.type === 'table'
+55 -17
View File
@@ -136,7 +136,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 61
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62
function withoutHistoricalWorkMode(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
@@ -9144,6 +9144,7 @@ export class AssistantDatabase {
resultId, runId, result.output.summary, result.output.changeDigest,
JSON.stringify(result.output.openItems), JSON.stringify(result.coverage ?? { evidence: result.evidence.length }), timestamp
)
const graph: { entities: Record<string, unknown>[]; relations: Record<string, unknown>[] } = { entities: [], relations: [] }
const saveFacts = (result: StoredSupervisionResult) => {
// Model IDs are local to one batch, never persistent cross-run identities.
const sourceIds = new Map(result.evidence.map((source) => [source.id, randomUUID()]))
@@ -9222,15 +9223,8 @@ export class AssistantDatabase {
story.id, persistentId(entityIds, relation.fromEntityId), persistentId(entityIds, relation.toEntityId), relation.relationType),
source_reference_ids_json: sourceReferences(relation.sourceReferenceIds)
}))
database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object(
'entities', json((SELECT json_group_array(json(value)) FROM (
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities')
UNION ALL SELECT value FROM json_each(?)
))),
'relations', json((SELECT json_group_array(json(value)) FROM (
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations')
UNION ALL SELECT value FROM json_each(?)
)))) WHERE id = ?`).run(JSON.stringify(entities), JSON.stringify(relations), resultId)
for (const entity of entities) graph.entities.push(entity)
for (const relation of relations) graph.relations.push(relation)
}
if (result.coverage && result.runId) {
for (let offset = 0;; offset += 10) {
@@ -9242,12 +9236,15 @@ export class AssistantDatabase {
// each leaf's full description and references remain independently readable.
database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object(
'entities', json((SELECT json_group_array(json(value)) FROM (
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities') GROUP BY json_extract(value, '$.id')))),
SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id')))),
'relations', json((SELECT json_group_array(json(value)) FROM (
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations') GROUP BY json_extract(value, '$.id')))))
WHERE id = ?`).run(resultId)
SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id')))))
WHERE id = ?`).run(JSON.stringify(graph.entities), JSON.stringify(graph.relations), resultId)
this.supervisionReviewStore().commitCheckpoints(result.runId, result.request)
} else saveFacts(result)
} else {
saveFacts(result)
database.prepare('UPDATE supervision_results SET graph_snapshot_json = ? WHERE id = ?').run(JSON.stringify(graph), resultId)
}
placeTimelineEvents(database, resultId)
supersedeReextractedEvents(database, resultId)
if (result.batch) this.saveReviewCheckpoints(result.batch)
@@ -9273,6 +9270,36 @@ export class AssistantDatabase {
return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined
}
async listSupervisionResultsAsync(limit = 20, target?: SupervisionTarget, resultId?: string): Promise<ReturnType<AssistantDatabase['listSupervisionResults']>> {
this.requireDatabase()
const reader = this.readonlyQueryReader()
if (reader) return reader.call('supervisionOverview', [limit, target, resultId])
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
return this.listSupervisionResults(limit, target, resultId)
}
async getSupervisionGraphAsync(input: SupervisionGraphRequest = {}): Promise<Record<string, unknown>> {
this.requireDatabase()
const reader = this.readonlyQueryReader()
if (reader) return reader.call('supervisionGraph', [input])
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
return this.getSupervisionGraph(input)
}
getSupervisionStories(scope: SupervisionRunRequest['scope']) {
const stories = this.supervisionStories()
return { stories: stories.list(scope), experiences: this.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined),
unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() }
}
async getSupervisionStoriesAsync(scope: SupervisionRunRequest['scope']): Promise<ReturnType<AssistantDatabase['getSupervisionStories']>> {
this.requireDatabase()
const reader = this.readonlyQueryReader()
if (reader) return reader.call('supervisionStories', [scope])
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
return this.getSupervisionStories(scope)
}
listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) {
const owner = target?.type === 'conversation' ? this.getConversation(target.conversationId)
: target?.type === 'task' ? this.getTask(target.taskId) : undefined
@@ -9325,7 +9352,7 @@ export class AssistantDatabase {
attention: supervisionAttention(database, scope.kind === 'projects' ? scope.projectIds : undefined,
new Date(range.from).toISOString(), new Date(range.to).toISOString()),
events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId),
sources: database.prepare('SELECT * FROM supervision_sources WHERE result_id = ?').all(input.resultId),
sources: database.prepare('SELECT id, title, occurred_at FROM supervision_sources WHERE result_id = ? ORDER BY rowid').all(input.resultId),
eventEntities: database.prepare(`SELECT ee.event_id, ee.entity_id FROM supervision_event_entities ee JOIN supervision_events e ON e.id = ee.event_id WHERE e.result_id = ?`).all(input.resultId),
eventSources: database.prepare(`SELECT es.event_id, es.source_id FROM supervision_event_sources es JOIN supervision_events e ON e.id = es.event_id WHERE e.result_id = ?`).all(input.resultId)
}
@@ -9352,9 +9379,9 @@ export class AssistantDatabase {
GROUP BY r.from_entity_id, r.to_entity_id, r.relation_type`).all(id),
eventEntities: database.prepare('SELECT event_id, entity_id FROM supervision_event_entities WHERE event_id IN (SELECT id FROM temp.story_events)').all(),
eventSources: database.prepare('SELECT event_id, source_id FROM supervision_event_sources WHERE event_id IN (SELECT id FROM temp.story_events)').all(),
sources: database.prepare(`SELECT * FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources
sources: database.prepare(`SELECT id, title, occurred_at FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources
WHERE event_id IN (SELECT id FROM temp.story_events)) OR result_id IN (SELECT r.id FROM supervision_results r
JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?)`).all(String((story as { scope_json: string }).scope_json)),
JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?) ORDER BY rowid`).all(String((story as { scope_json: string }).scope_json)),
attention: this.storyAttention(scope)
}
} finally { database.exec('DROP TABLE IF EXISTS temp.story_events') }
@@ -12334,6 +12361,17 @@ export class AssistantDatabase {
database.enableDefensive(true)
}
}
if (version.user_version < 62) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(`
CREATE INDEX IF NOT EXISTS supervision_sources_result ON supervision_sources(result_id, occurred_at, source_type, id);
CREATE INDEX IF NOT EXISTS supervision_results_created ON supervision_results(created_at);
PRAGMA user_version = 62;
COMMIT;
`)
} catch (error) { database.exec('ROLLBACK'); throw error }
}
}
supervisionExperiences(): SupervisionExperienceStore {
+24 -12
View File
@@ -196,20 +196,32 @@ export class SupervisionExperienceStore {
}
list(projectIds?: string[]): SupervisionExperience[] {
const rows = this.db.prepare("SELECT * FROM supervision_experiences WHERE status = 'current' ORDER BY created_at, rowid").all()
const links = this.db.prepare(`SELECT x.event_id, x.role, x.note, e.title, e.project_id, COALESCE(e.started_at, e.occurred_at) AS at,
(SELECT s.id FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE es.event_id = e.id AND es.is_primary = 1) AS story_id,
(SELECT s.name FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE es.event_id = e.id AND es.is_primary = 1) AS story_name
const rows = this.db.prepare(`SELECT * FROM supervision_experiences WHERE status = 'current'
AND id IN (SELECT x.experience_id FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id
WHERE x.role = 'formed' AND e.superseded_by IS NULL)
AND (? IS NULL OR id IN (SELECT x.experience_id FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id
WHERE e.superseded_by IS NULL AND e.project_id != '' AND e.project_id IN (SELECT value FROM json_each(?))))
ORDER BY created_at, rowid`).all(projectIds ? 1 : null, JSON.stringify(projectIds ?? []))
if (!rows.length) return []
// Scope selects experiences, not their links: cross-project context remains complete.
const links = this.db.prepare(`SELECT x.experience_id, x.event_id, x.role, x.note, e.title, e.project_id,
COALESCE(e.started_at, e.occurred_at) AS at, s.id AS story_id, s.name AS story_name
FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id
WHERE x.experience_id = ? AND e.superseded_by IS NULL ORDER BY at, e.id`)
return rows.map(row => {
const events = links.all(String(row.id)).map(link => ({ id: String(link.event_id), role: link.role as 'formed' | 'applied', note: String(link.note),
LEFT JOIN supervision_event_stories es ON es.event_id = e.id AND es.is_primary = 1
LEFT JOIN supervision_stories s ON s.id = es.story_id
WHERE x.experience_id IN (SELECT value FROM json_each(?)) AND e.superseded_by IS NULL
ORDER BY x.experience_id, at, e.id, x.role`).all(JSON.stringify(rows.map(row => row.id)))
const byExperience = new Map<string, SupervisionExperience['events']>()
for (const link of links) {
const id = String(link.experience_id)
const events = byExperience.get(id) ?? []
events.push({ id: String(link.event_id), role: link.role as 'formed' | 'applied', note: String(link.note),
title: String(link.title), projectId: link.project_id ? String(link.project_id) : null, at: String(link.at),
storyId: link.story_id ? String(link.story_id) : null, storyName: link.story_name ? String(link.story_name) : null }))
return { id: String(row.id), statement: String(row.statement), conditions: String(row.conditions), boundaries: String(row.boundaries),
userEdited: row.user_edited === 1, events }
// Experiences are global; a project view keeps those formed or applied in its projects.
}).filter(item => item.events.some(event => event.role === 'formed') && (!projectIds || item.events.some(event => event.projectId && projectIds.includes(event.projectId))))
storyId: link.story_id ? String(link.story_id) : null, storyName: link.story_name ? String(link.story_name) : null })
byExperience.set(id, events)
}
return rows.map(row => ({ id: String(row.id), statement: String(row.statement), conditions: String(row.conditions), boundaries: String(row.boundaries),
userEdited: row.user_edited === 1, events: byExperience.get(String(row.id)) ?? [] }))
}
/** Snapshot of the experiences and links an adjustment changes, for undo. */
@@ -81,7 +81,7 @@ it('supervision preserves result history and protected identities within scope t
expect(read(latest.id).entities.find((entity) => entity.id === confirmedEntity.id)).toMatchObject({ ...confirmedEntity, confirmation_state: 'confirmed' })
expect(read(latest.id).relations[0]).toMatchObject({ id: relationId, reason: 'Original reason', confirmation_state: 'confirmed' })
const storedGraph = db.getSupervisionGraph({ resultId: latest.id })
const currentSource = (storedGraph.sources as Array<{ id: string; source_type: string }>).find((source) => source.source_type === 'conversation')!
const currentSource = (storedGraph.sources as Array<{ id: string }>).find((source) => db.getSupervisionSource(source.id)!.sourceType === 'conversation')!
expect((storedGraph.entities as Array<{ source_reference_ids_json: string }>).every((entity) => entity.source_reference_ids_json === JSON.stringify([currentSource.id]))).toBe(true)
expect(read(a.id)).toEqual(original)
db.applySupervisionRelationAction({ resultId: latest.id, relationId, action: 'revoke' })
@@ -229,3 +229,94 @@ it('retries an actual interrupted direct-model stream and retains its reported u
expect(f.release).toHaveBeenCalledTimes(2)
expect(f.dispose).toHaveBeenCalledOnce()
})
it.each([
['openai-chat-completions', false], ['anthropic-messages', false],
['openai-chat-completions', true], ['anthropic-messages', true]
] as const)(
'retains reported usage when %s output exceeds capacity without retrying (buffered=%s)', async (protocol, buffered) => {
const text = 'x'.repeat(1024 * 1024 + 1)
const events = protocol === 'anthropic-messages'
? [{ type: 'message_start', message: { id: 'call', model: 'fake', usage: { input_tokens: 12, output_tokens: 3 } } },
{ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }]
: [{ choices: [{ delta: { content: text } }], usage: { prompt_tokens: 12, completion_tokens: 3 } }]
let body!: ReadableStreamDefaultController<Uint8Array>
const stream = new ReadableStream<Uint8Array>({ start(controller) { body = controller } })
body.enqueue(new TextEncoder().encode(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')))
if (buffered) {
body.enqueue(new TextEncoder().encode(protocol === 'anthropic-messages' ? 'data: {"type":"message_stop"}\n\n' : 'data: [DONE]\n\n'))
body.close()
}
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
if (!buffered) init!.signal!.addEventListener('abort', () => body.error(init!.signal!.reason), { once: true })
return new Response(stream)
})
const runtime = new ModelAgentRuntime({ baseUrl: 'https://model.invalid', model: 'fake', authentication: 'none',
protocol, toolProvider: noModelTools, fetcher })
const f = fixture(runtime.run.bind(runtime))
f.release.mockImplementation(runtime.releaseConversation.bind(runtime))
f.dispose.mockImplementation(runtime.dispose.bind(runtime))
await expect(f.start()).rejects.toThrow('1024 KiB')
expect(fetcher).toHaveBeenCalledOnce()
expect(stream.locked).toBe(false)
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 1, input: 12, output: 3 })
expect(f.release).toHaveBeenCalledOnce()
expect(f.dispose).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('drains usage only after overflow and preserves the cap error over later errors and timeout', async () => {
let reason: unknown
const finalized = vi.fn()
const f = fixture(async function* (input, signal) {
try {
yield { type: 'text', requestId: input.requestId, delta: 'x'.repeat(1024 * 1024 + 1) }
reason = signal.reason
expect(signal.aborted).toBe(true)
await new Promise(resolve => setTimeout(resolve, 200))
yield { type: 'text', requestId: input.requestId, delta: 'discard trailing text' }
yield { type: 'model-usage', requestId: input.requestId, callId: 'call', runtime: 'model', provider: 'openai',
model: 'fake', inputTokens: 12, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 }
yield { type: 'error', requestId: input.requestId, status: 'failed', message: 'terminated' }
yield { type: 'done', requestId: input.requestId }
} finally { finalized() }
}, 0.1)
const pending = f.start().catch(error => error)
await vi.advanceTimersByTimeAsync(250)
expect(await pending).toBe(reason)
expect(reason).toBeInstanceOf(Error)
expect((reason as Error).message).toContain('1024 KiB')
expect(f.runtime.run).toHaveBeenCalledOnce()
expect(finalized).toHaveBeenCalledOnce()
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 1, input: 12, output: 3 })
expect(f.release).toHaveBeenCalledOnce()
expect(f.dispose).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('bounds overflow accounting drain and ignores events arriving after cleanup', async () => {
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const finalized = vi.fn()
const f = fixture(async function* (input) {
try {
yield { type: 'text', requestId: input.requestId, delta: 'x'.repeat(1024 * 1024 + 1) }
await gate
yield { type: 'model-usage', requestId: input.requestId, callId: 'late', runtime: 'model', provider: 'openai',
model: 'fake', inputTokens: 12, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 }
} finally { finalized() }
})
const pending = f.start().catch(error => error)
try {
await vi.advanceTimersByTimeAsync(999)
expect(f.release).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(await pending).toMatchObject({ message: expect.stringContaining('1024 KiB') })
expect(f.release).toHaveBeenCalledOnce()
expect(f.dispose).toHaveBeenCalledOnce()
await expect(f.pool.run(async () => 'released')).resolves.toBe('released')
} finally { release(); await vi.advanceTimersByTimeAsync(1) }
expect(finalized).toHaveBeenCalledOnce()
expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 0 })
expect(vi.getTimerCount()).toBe(0)
})
+28 -10
View File
@@ -73,22 +73,37 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
}, timeoutSeconds * 1000)
const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024
let output = '', completed = false
let overflowError: Error | undefined
let drainTimeout: ReturnType<typeof setTimeout> | undefined
let drainDeadline: Promise<never> | undefined
let events: ReturnType<AgentRuntime['run']> | undefined
const requestId = randomUUID()
const conversationId = `supervision:${requestId}`
try {
// Each attempt has its own hidden task so reported usage survives retries.
database.createTask({ id: requestId, conversationId, title: request.title, instructions: request.instructions,
origin: 'assistant', visible: false })
for await (const event of runtime.run({ requestId, conversationId, prompt: request.prompt },
modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' })) {
if (event.type === 'text') {
output += event.delta
if (Buffer.byteLength(output) > responseKiB * 1024) {
controller.abort(new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`))
modelSignal.throwIfAborted()
}
}
events = runtime.run({ requestId, conversationId, prompt: request.prompt },
modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' })
while (true) {
const next = events.next()
const step = await (drainDeadline ? Promise.race([next, drainDeadline]) : next)
if (step.done) break
const event = step.value
if (event.type === 'model-usage') persistUsage(event)
if (overflowError) continue
if (event.type === 'text') {
if (Buffer.byteLength(output + event.delta) > responseKiB * 1024) {
overflowError = new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`)
output = ''
controller.abort(overflowError)
// Let the aborted stream emit usage, but never retain further text
// or let an unresponsive iterator hold the supervisor pool forever.
drainDeadline = new Promise<never>((_resolve, reject) => {
drainTimeout = setTimeout(() => reject(overflowError), 1_000)
})
} else output += event.delta
}
if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用')
if (event.type === 'error') {
const error = new Error(event.message)
@@ -102,7 +117,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
database.updateTaskStatus(requestId, 'completed')
return output
} catch (error) {
const failure = timeoutError ?? (modelSignal.aborted ? modelSignal.reason : error)
const failure = overflowError ?? timeoutError ?? (modelSignal.aborted ? modelSignal.reason : error)
try {
database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed',
failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败')
@@ -110,7 +125,10 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r
if (attempt > 0 || modelSignal.aborted || !isTransientSupervisionError(failure)) throw failure
} finally {
clearTimeout(timeout)
clearTimeout(drainTimeout)
controller.abort()
const closing = events?.return().catch(() => undefined)
if (!overflowError) await closing
await runtime.releaseConversation?.(conversationId)
}
signal.throwIfAborted()
+14 -4
View File
@@ -389,12 +389,22 @@ export class SupervisionStoryStore {
AND EXISTS (SELECT 1 FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id
WHERE es.story_id = s.id AND e.project_id IN (SELECT value FROM json_each(?)))))
ORDER BY s.level, s.name`).all(projects, projects, projects ?? '[]')
const events = this.db.prepare(`SELECT es.event_id, es.is_primary, es.user_set, e.title, e.project_id,
if (!rows.length) return []
const events = this.db.prepare(`SELECT es.story_id, es.event_id, es.is_primary, es.user_set, e.title, e.project_id,
COALESCE(e.started_at, e.occurred_at) AS started_at, COALESCE(e.ended_at, e.occurred_at) AS ended_at
FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id WHERE es.story_id = ? AND e.superseded_by IS NULL ORDER BY started_at, e.id`)
FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id
WHERE es.story_id IN (SELECT value FROM json_each(?)) AND e.superseded_by IS NULL
ORDER BY es.story_id, started_at, e.id`).all(JSON.stringify(rows.map(row => row.id)))
const byStory = new Map<string, SupervisionStory['events']>()
for (const e of events) {
const id = String(e.story_id)
const linked = byStory.get(id) ?? []
linked.push({ id: String(e.event_id), title: String(e.title), projectId: e.project_id ? String(e.project_id) : null,
startedAt: String(e.started_at), endedAt: String(e.ended_at), primary: e.is_primary === 1, userSet: e.user_set === 1 })
byStory.set(id, linked)
}
return rows.map(row => {
const linked = events.all(String(row.id)).map(e => ({ id: String(e.event_id), title: String(e.title), projectId: e.project_id ? String(e.project_id) : null,
startedAt: String(e.started_at), endedAt: String(e.ended_at), primary: e.is_primary === 1, userSet: e.user_set === 1 }))
const linked = byStory.get(String(row.id)) ?? []
// A cross story's span comes from the events it links.
const spanning = row.level === 'cross' ? linked : linked.filter(e => e.primary)
return { id: String(row.id), projectId: row.project_id ? String(row.project_id) : null, projectName: row.project_name ? String(row.project_name) : null,
@@ -0,0 +1,232 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { build } from 'esbuild'
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database'
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from './assistant-storage-upgrade'
import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts'
import type { SupervisionSummaryOutput } from '../../shared/supervision-contracts'
let directory: string, workerPath: string
const cleanups: Array<() => void> = []
beforeAll(async () => {
directory = await mkdtemp(join(tmpdir(), 'supervision-ui-reads-'))
workerPath = join(directory, 'worker.cjs')
await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: workerPath,
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
}, 60_000)
afterEach(() => { vi.restoreAllMocks(); for (const close of cleanups.splice(0)) close() })
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10 }) })
const at = '2026-10-01T12:00:00.000Z'
const request = { trigger: 'manual' as const, scope: { kind: 'global' as const }, timeRange: { from: at, to: at } }
const empty: SupervisionSummaryOutput = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }
function fixture() {
const path = join(directory, `${randomUUID()}.sqlite`)
const db = new AssistantDatabase(path)
db.initialize(directory)
const sql = (db as unknown as { database: DatabaseSync }).database
cleanups.push(() => db.close())
const project = db.listProjects()[0]!
const other = db.createProject({ name: 'Other', description: '', rootPath: directory })
db.saveSupervisionResult({ request,
evidence: Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, sourceType: 'conversation' as const, sourceId: 'conversation',
title: `Source ${i}`, content: `Full source ${i} ${'body'.repeat(2000)}`, occurredAt: at,
locator: { projectId: i < 3 ? project.id : other.id } })),
output: { ...empty, events: Array.from({ length: 6 }, (_, i) => ({ title: `Event ${i}`, description: 'Description', occurredAt: at,
eventType: 'decision' as const, sourceReferenceIds: [`s${i}`], entityIds: [] })) } })
const result = db.listSupervisionResults()[0]!
const events = sql.prepare('SELECT id, project_id FROM supervision_events ORDER BY title').all()
for (const [i, event] of events.entries()) {
sql.prepare(`INSERT INTO supervision_stories (id, project_id, level, name, created_at, updated_at)
VALUES (?, ?, 'feature', ?, ?, ?)`).run(`story${i}`, String(event.project_id), `Story ${i}`, at, at)
sql.prepare('INSERT INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, 1)').run(String(event.id), `story${i}`)
sql.prepare('INSERT INTO supervision_experiences (id, statement, created_at, updated_at) VALUES (?, ?, ?, ?)').run(`experience${i}`, `Experience ${i}`, at, at)
sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES (?, ?, 'formed')").run(`experience${i}`, String(event.id))
}
// A cross-project experience must retain all links, not only the selected project's links.
sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES ('experience0', ?, 'applied')").run(String(events[3]!.id))
return { db, sql, path, project, other, result, events }
}
it('returns source headers only in both graph paths while retaining full source detail and source order', () => {
const f = fixture()
const expected = f.sql.prepare('SELECT id, title, occurred_at FROM supervision_sources ORDER BY rowid').all()
for (const input of [{ resultId: f.result.id }, { storyLineId: f.result.storyLineId! }]) {
expect(f.db.getSupervisionGraph(input).sources).toEqual(expected)
}
for (const source of expected) expect(f.db.getSupervisionSource(String(source.id))?.content).toBe(`Full source ${String(source.title).slice(-1)} ${'body'.repeat(2000)}`)
})
it('reads story memberships and scoped experiences in a constant number of SQL statements', () => {
const f = fixture()
let queries = 0
const prepare = f.sql.prepare.bind(f.sql)
vi.spyOn(f.sql, 'prepare').mockImplementation(sql => {
const statement = prepare(sql)
const all = statement.all.bind(statement)
vi.spyOn(statement, 'all').mockImplementation((...args) => { queries++; return all(...args) })
return statement
})
const stories = f.db.supervisionStories().list({ kind: 'global' })
expect(stories.map(story => [story.name, story.events.map(event => event.title)])).toEqual(
Array.from({ length: 6 }, (_, i) => [`Story ${i}`, [`Event ${i}`]]))
expect(queries).toBe(2)
queries = 0
const experiences = f.db.supervisionExperiences().list([f.other.id])
expect(experiences.map(item => item.statement)).toEqual(['Experience 0', 'Experience 3', 'Experience 4', 'Experience 5'])
expect(experiences[0]!.events.map(event => [event.title, event.role, event.storyName])).toEqual(
[0, 3].sort((a, b) => String(f.events[a]!.id).localeCompare(String(f.events[b]!.id))).map(i => [`Event ${i}`, i ? 'applied' : 'formed', `Story ${i}`]))
expect(queries).toBe(2)
queries = 0
expect(f.db.supervisionExperiences().list(['unrelated'])).toEqual([])
expect(queries).toBe(1)
})
it('serves UI reads on the real worker and observes writes committed before the next read', async () => {
const f = fixture()
const overview = f.db.listSupervisionResults()
const graph = f.db.getSupervisionGraph({ resultId: f.result.id })
const stories = { stories: f.db.supervisionStories().list(request.scope), experiences: f.db.supervisionExperiences().list(),
unassigned: f.db.supervisionStories().unassignedCount(request.scope), canUndo: false }
f.db.enableReadonlyWorker(workerPath)
const syncOverview = vi.spyOn(f.db, 'listSupervisionResults').mockImplementation(() => { throw new Error('Main overview') })
const syncGraph = vi.spyOn(f.db, 'getSupervisionGraph').mockImplementation(() => { throw new Error('Main graph') })
const syncStories = vi.spyOn(f.db, 'supervisionStories').mockImplementation(() => { throw new Error('Main stories') })
expect(await f.db.listSupervisionResultsAsync()).toEqual(overview)
expect(await f.db.getSupervisionGraphAsync({ resultId: f.result.id })).toEqual(graph)
expect(await f.db.getSupervisionStoriesAsync(request.scope)).toEqual(stories)
f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('Committed', f.result.id)
expect((await f.db.listSupervisionResultsAsync())[0]!.summary).toBe('Committed')
await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id, storyLineId: 'wrong' })).rejects.toThrow('不匹配')
await f.db.readonlyWorkerForTest!.terminateWorkerForTest()
await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow('backing off')
await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id })).rejects.toThrow('backing off')
await expect(f.db.getSupervisionStoriesAsync(request.scope)).rejects.toThrow('backing off')
expect(syncOverview).not.toHaveBeenCalled()
expect(syncGraph).not.toHaveBeenCalled()
expect(syncStories).not.toHaveBeenCalled()
})
it('rejects missing production worker configuration instead of performing a synchronous UI read', async () => {
const f = fixture()
await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow('worker is not configured')
await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id })).rejects.toThrow('worker is not configured')
await expect(f.db.getSupervisionStoriesAsync(request.scope)).rejects.toThrow('worker is not configured')
f.db.enableReadonlyWorker(join(directory, 'missing.cjs'))
await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow()
})
it('keeps a multi-statement UI read on one WAL snapshot while another connection commits', () => {
const f = fixture()
const reader = new AssistantDatabase(f.path)
reader.openReadOnly()
cleanups.push(() => reader.close())
reader.readSnapshot(() => {
expect(reader.listSupervisionResults()[0]!.summary).toBe('Review')
f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('New summary', f.result.id)
f.sql.prepare("UPDATE supervision_stories SET name = 'Renamed' WHERE id = 'story0'").run()
expect(reader.listSupervisionResults()[0]!.summary).toBe('Review')
expect(reader.getSupervisionStories(request.scope).stories[0]!.name).toBe('Story 0')
})
expect(reader.listSupervisionResults()[0]!.summary).toBe('New summary')
expect(reader.getSupervisionStories(request.scope).stories.some(story => story.name === 'Renamed')).toBe(true)
})
it('upgrades schema 61 indexes without changing rows, rowids, source order or reclaiming free pages', () => {
const f = fixture()
const tables = f.sql.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all()
const contents = () => tables.map(row => [row.name, f.sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])
const before = contents()
const sources = f.db.getSupervisionGraph({ resultId: f.result.id }).sources
f.sql.exec(`DROP INDEX IF EXISTS supervision_sources_result;
DROP INDEX IF EXISTS supervision_results_created;
CREATE TABLE disposable (body BLOB); INSERT INTO disposable VALUES (zeroblob(1048576)); DROP TABLE disposable;
PRAGMA user_version = 61`)
expect(f.sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
f.db.close()
expect(getPendingAssistantStorageUpgrade(f.path)).toEqual({ migrateNotes: false, reclaimSpace: false })
const progress = vi.fn()
upgradeAssistantStorage(f.path, progress)
const reopened = new AssistantDatabase(f.path)
reopened.openReadOnly()
cleanups.push(() => reopened.close())
const sql = (reopened as unknown as { database: DatabaseSync }).database
expect(tables.map(row => [row.name, sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])).toEqual(before)
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(sql.prepare("SELECT name FROM sqlite_master WHERE name IN ('supervision_sources_result', 'supervision_results_created') ORDER BY name").all())
.toEqual([{ name: 'supervision_results_created' }, { name: 'supervision_sources_result' }])
expect(reopened.getSupervisionGraph({ resultId: f.result.id }).sources).toEqual(sources)
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
expect(sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
expect(progress.mock.calls.some(([value]) => value.stage === 'compacting' || value.stage === 'converting')).toBe(false)
expect(getPendingAssistantStorageUpgrade(f.path)).toBeUndefined()
upgradeAssistantStorage(f.path, progress)
})
it('publishes one final snapshot for multiple leaves and rolls back results and checkpoints together', () => {
const f = fixture()
const runId = f.db.startSupervisionRun(request)
const store = f.db.supervisionReviewStore()
f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) },
messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }])
store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' })
for (let i = 0; i < 2; i++) {
const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)!
store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at,
locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }],
{ ...empty, entities: [{ id: 'entity', label: `Entity ${i}`, description: 'Original', sourceReferenceIds: ['source'] }] })
}
f.sql.exec(`CREATE TRIGGER snapshot_once BEFORE UPDATE OF graph_snapshot_json ON supervision_results
WHEN OLD.graph_snapshot_json != '{"entities":[],"relations":[]}' BEGIN SELECT RAISE(ABORT, 'Repeated graph snapshot'); END;
CREATE TRIGGER fail_checkpoints BEFORE INSERT ON review_checkpoints BEGIN SELECT RAISE(ABORT, 'Checkpoint failed'); END`)
const publication = { request, runId, coverage: store.progress(runId), evidence: [], output: empty }
expect(() => f.db.saveSupervisionResult(publication)).toThrow('Checkpoint failed')
expect(f.db.listSupervisionResults()).toHaveLength(1)
expect(f.sql.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)!.status).toBe('running')
expect(f.sql.prepare('SELECT * FROM review_checkpoints').all()).toEqual([])
f.sql.exec('DROP TRIGGER fail_checkpoints')
f.db.saveSupervisionResult(publication)
const latest = f.db.listSupervisionResults()[0]!
const graph = f.db.getSupervisionGraph({ resultId: latest.id })
expect(graph.entities).toHaveLength(2)
expect(graph.sources).toHaveLength(2)
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(2)
expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([])
})
it('retains first-leaf snapshot values and source references when persisted identities recur across leaves', () => {
const f = fixture()
f.db.saveSupervisionResult({ request, evidence: [], output: { ...empty,
entities: ['A', 'B'].map(id => ({ id, label: id, description: 'Original', sourceReferenceIds: [] })),
relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: 'Original', sourceReferenceIds: [] }] } })
const candidates = f.db.listSupervisionCandidates(request)
const a = candidates.find(item => item.label === 'A')!, b = candidates.find(item => item.label === 'B')!
f.db.applySupervisionEntityAction({ entityId: a.id, action: 'confirm' })
const runId = f.db.startSupervisionRun(request)
const store = f.db.supervisionReviewStore()
f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) },
messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }])
store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' })
for (let i = 0; i < 2; i++) {
const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)!
store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at,
locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }], { ...empty,
entities: [a, b].map(item => ({ id: item.label, persistedId: item.id, label: item.label, description: `Leaf ${i}`, sourceReferenceIds: ['source'] })),
relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: `Leaf ${i}`, sourceReferenceIds: ['source'] }] })
}
f.db.saveSupervisionResult({ request, runId, candidates, coverage: store.progress(runId), evidence: [], output: empty })
const result = f.db.listSupervisionResults()[0]!
const graph = f.db.getSupervisionGraph({ resultId: result.id })
const sourceId = (graph.sources as Array<{ id: string; title: string }>).find(source => source.title === 'Leaf 0')!.id
expect(graph.entities).toEqual([a, b].sort((x, y) => x.id < y.id ? -1 : 1).map(item => expect.objectContaining({ id: item.id,
description: item.id === a.id ? 'Original' : 'Leaf 0', confirmation_state: item.id === a.id ? 'confirmed' : 'automatic',
source_reference_ids_json: JSON.stringify([sourceId]) })))
expect(graph.relations).toEqual([expect.objectContaining({ reason: 'Leaf 0', source_reference_ids_json: JSON.stringify([sourceId]) })])
expect(f.sql.prepare('SELECT description FROM supervision_entities WHERE id = ?').get(b.id)!.description).toBe('Leaf 1')
})
@@ -203,6 +203,12 @@ describe('ChannelManager', () => {
})
status({ state: 'running' })
deliveryFailure(new Error('send failed'))
expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running', lastError: 'Telegram sending failed: send failed' })
status({ state: 'running' })
expect((await manager.snapshot()).telegram.status.lastError).toBe('Telegram sending failed: send failed')
deliveryFailure(new Error('status-test-secret token=synthetic-secret failed'))
expect(JSON.stringify((await manager.snapshot()).telegram.status)).not.toMatch(/status-test-secret|synthetic-secret/)
deliverySuccess()
expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running' })
await manager.stopAll()
})
+16 -4
View File
@@ -488,12 +488,16 @@ export class ChannelManager {
settings: ResolvedChannelSettings,
generation?: symbol
): Promise<{ service: ManagedChannelService; driver: TestableChannelDriver }> {
let connectionStatus: ChannelRuntimeStatus = { state: 'starting' }
let deliveryError: string | undefined
const onStatus = (status: ChannelRuntimeStatus): void => {
if (generation === undefined || this.serviceGenerations.get(settings.channel) !== generation) return
connectionStatus = status
const lastError = status.lastError ?? deliveryError
this.setStatus(settings.channel, {
state: status.state,
...(status.lastError ? {
lastError: redactManagerError(status.lastError, [
...(lastError ? {
lastError: redactManagerError(lastError, [
settings.channel === 'weixin' ? settings.token : settings.secret
])
} : {})
@@ -506,7 +510,11 @@ export class ChannelManager {
dedupStore: this.dedupStore,
outbox: this.outbox,
onDeliveryFailure: (error) => {
if (settings.channel === 'telegram') return
if (settings.channel === 'telegram') {
deliveryError = `Telegram sending failed: ${redactManagerError(error, [settings.secret])}`
onStatus(connectionStatus)
return
}
onStatus({
state: 'error',
lastError: redactManagerError(error, [
@@ -517,7 +525,11 @@ export class ChannelManager {
})
},
onDeliverySuccess: () => {
if (settings.channel === 'telegram') return
if (settings.channel === 'telegram') {
deliveryError = undefined
onStatus(connectionStatus)
return
}
onStatus({ state: 'running' })
}
})
@@ -318,6 +318,18 @@ describe('TelegramChannelDriver', () => {
expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'stopped' })
})
it('rechecks a transient webhook failure before polling after identifying the Bot', async () => {
const { driver, fetch, calls, onStatus } = setup()
fetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true }))
.mockResolvedValueOnce(error(503))
await driver.start(vi.fn())
await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('503') })))
expect(calls.some(call => call.method === 'getUpdates')).toBe(false)
await vi.waitFor(() => expect(calls.some(call => call.method === 'getUpdates')).toBe(true), { timeout: 2500 })
expect(fetch.mock.calls.map(([input]) => String(input).split('/').at(-1)))
.toEqual(['getMe', 'getWebhookInfo', 'getWebhookInfo', 'getUpdates'])
})
it('caller cancellation releases an identity waiter without cancelling startup', async () => {
const { driver, fetch, onStatus } = setup()
let validationSignal: AbortSignal | undefined
+20 -5
View File
@@ -95,7 +95,11 @@ export class TelegramChannelDriver implements ChannelDriver {
}
testConnection(): Promise<BotIdentity> {
return this.track(this.validate(this.lifecycle.signal))
return this.track((async () => {
const identity = await this.identify(this.lifecycle.signal)
await this.checkWebhook(this.lifecycle.signal)
return identity
})())
}
updateAllowedSenderIds(ids: readonly string[]): void {
@@ -184,23 +188,32 @@ export class TelegramChannelDriver implements ChannelDriver {
}
}
private async validate(signal: AbortSignal): Promise<BotIdentity> {
private async identify(signal: AbortSignal): Promise<BotIdentity> {
const me = await this.request<{ id: number; is_bot: boolean; username?: string }>('getMe', {}, signal)
if (!positiveId(me?.id) || me.is_bot !== true) throw new Error('Telegram returned an invalid Bot identity.')
return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) }
}
private async checkWebhook(signal: AbortSignal): Promise<void> {
const webhook = await this.request<{ url: string }>('getWebhookInfo', {}, signal)
if (typeof webhook?.url !== 'string') throw new Error('Telegram returned invalid webhook information.')
if (webhook.url) throw new TelegramError(409)
return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) }
}
private async poll(handler: ChannelInboundHandler, signal: AbortSignal): Promise<void> {
let failures = 0
let pollingReady = false
while (!signal.aborted) {
const started = Date.now()
try {
if (!this.identity) {
this.identity = await this.validate(signal)
// Outgoing replies need the Bot identity, not permission to long-poll.
this.identity = await this.identify(signal)
checkSignal(signal)
}
if (!pollingReady) {
await this.checkWebhook(signal)
pollingReady = true
for (const notify of this.readinessWaiters) notify()
failures = 0
this.status({ state: 'running' })
@@ -317,8 +330,10 @@ export class TelegramChannelDriver implements ChannelDriver {
} finally {
clearTimeout(timer)
}
if (failure.code !== 429 || !retryRateLimit || attempt >= 3) throw failure
if (failure.code !== 429 || !retryRateLimit) throw failure
// The outbox may retry as soon as this call rejects, including the final attempt.
await wait(failure.retryAfter * 1000, signal)
if (attempt >= 3) throw failure
}
}
+155
View File
@@ -0,0 +1,155 @@
// @vitest-environment node
import { createServer } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { describe, expect, it, vi } from 'vitest'
import { AssistantDatabase } from '../assistant/assistant-database'
import { ChannelManager } from './channel-manager'
import { ChannelSettingsStore } from './channel-settings-store'
import { SqliteChannelOutbox } from './sqlite-channel-state'
import { TelegramChannelDriver } from './telegram-channel-driver'
import type { ChannelRuntimeStatusChange } from '../../shared/channel-settings-contracts'
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-delivery-'))
const database = new AssistantDatabase(':memory:')
database.initialize(root)
const outbox = new SqliteChannelOutbox(database)
const settings = new ChannelSettingsStore(join(root, 'channels.json'), {
isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString()
}, {})
const secret = 'synthetic-telegram-delivery-token'
await settings.apply({ telegram: { enabled: true, secret: { action: 'replace', value: secret },
allowedSenderIds: ['7'], allowGroupMessages: false } })
const state = { webhook: '', pollingCode: 0, polls: 0,
updates: [] as object[], sends: [] as { text: string; time: number }[],
sendErrors: [] as { code: number; retryAfter?: number }[] }
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += String(chunk)
const method = request.url!.slice(1)
let result: unknown
let failure: { code: number; retryAfter?: number } | undefined
if (method === 'getMe') result = { id: 42, is_bot: true }
else if (method === 'getWebhookInfo') result = { url: state.webhook }
else if (method === 'getUpdates') {
state.polls++
if (state.pollingCode) failure = { code: state.pollingCode }
else result = state.updates.splice(0)
} else if (method === 'sendMessage') {
state.sends.push({ text: JSON.parse(body).text, time: performance.now() })
failure = state.sendErrors.shift()
result = { message_id: state.sends.length }
} else if (method === 'sendChatAction') result = true
else throw new Error('Unexpected fixture method')
response.setHeader('content-type', 'application/json')
response.statusCode = failure?.code ?? 200
response.end(JSON.stringify(failure
? { ok: false, error_code: failure.code, parameters: { retry_after: failure.retryAfter },
description: `token=${secret} https://api.telegram.org/bot${secret}/sendMessage` }
: { ok: true, result }))
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const execute = vi.fn(async () => ({ status: 'completed', output: 'synthetic result' }))
const changes: ChannelRuntimeStatusChange[] = []
const manager = new ChannelManager(settings, execute, {
outbox, onStatusChanged: change => changes.push(change),
createDriver: (resolved, onStatus) => new TelegramChannelDriver({
secret: resolved.channel === 'telegram' ? resolved.secret! : '',
allowedSenderIds: resolved.allowedSenderIds, onStatus,
fetch: (input, init) => fetch(`http://127.0.0.1:${address.port}/${String(input).split('/').at(-1)}`, init)
})
})
return { state, outbox, manager, execute, changes, secret,
enqueue: (eventId: string, bot = '42') => outbox.enqueue({ channel: 'telegram', eventId,
conversationId: `${bot}:7`, recipientId: '7', status: 'completed', output: eventId }),
inbound: (id: number) => state.updates.push({ update_id: id, message: { message_id: id,
date: 1234, from: { id: 7, is_bot: false }, chat: { id: 7, type: 'private' }, text: 'request' } }),
async close() {
await manager.stopAll()
server.closeAllConnections()
await new Promise<void>(resolve => server.close(() => resolve()))
database.close()
await rm(root, { recursive: true, force: true })
}
}
}
describe('Telegram delivery through loopback HTTP and SQLite', () => {
it('delivers identity-matched pending replies during a startup webhook conflict without sending another Bot results', async () => {
const f = await fixture()
try {
f.state.webhook = 'https://example.invalid/webhook'
f.enqueue('same-bot')
f.enqueue('other-bot', '41')
await f.manager.initialize()
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toMatchObject({
state: 'error', lastError: expect.stringContaining('polling conflict')
}))
await vi.waitFor(() => expect(f.outbox.listUndelivered().every(entry => entry.state === 'terminal')).toBe(true))
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
expect(f.outbox.listUndelivered().map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
expect(f.state.polls).toBe(0)
f.state.webhook = ''
await f.manager.reload('telegram')
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status.state).toBe('running'))
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
expect(f.execute).not.toHaveBeenCalled()
} finally { await f.close() }
})
it('surfaces sanitized sending failures without changing polling state or clearing them on healthy polls', async () => {
const f = await fixture()
try {
f.state.sendErrors.push({ code: 403 })
f.inbound(1)
await f.manager.initialize()
await vi.waitFor(() => expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
await vi.waitFor(() => expect(f.state.polls).toBeGreaterThanOrEqual(2))
expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running',
lastError: expect.stringMatching(/sending failed.*forbidden/i) })
expect(JSON.stringify(f.changes)).not.toContain(f.secret)
expect(JSON.stringify(f.changes)).not.toContain('api.telegram.org')
f.inbound(2)
await vi.waitFor(() => expect(f.state.sends).toHaveLength(2))
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running' }))
expect(f.execute).toHaveBeenCalledTimes(2)
} finally { await f.close() }
})
it('honors the latest retry_after when an exhausted driver attempt passes to the outbox', async () => {
const f = await fixture()
try {
f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 },
{ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 3 })
f.inbound(1)
await f.manager.initialize()
await vi.waitFor(() => expect(f.state.sends).toHaveLength(5), { timeout: 10_000 })
expect(f.state.sends[4]!.time - f.state.sends[3]!.time).toBeGreaterThanOrEqual(2995)
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
expect(f.state.sends.every(send => send.text === 'synthetic result')).toBe(true)
expect(f.execute).toHaveBeenCalledOnce()
} finally { await f.close() }
}, 15_000)
it('cancels the final rate-limit wait without consuming or duplicating the pending reply', async () => {
const f = await fixture()
try {
f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 },
{ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 86400 })
f.enqueue('cancelled-wait')
await f.manager.initialize()
await vi.waitFor(() => expect(f.state.sends).toHaveLength(4), { timeout: 5000 })
await f.manager.stopAll()
expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'pending', attempts: 0 })
expect(f.state.sends).toHaveLength(4)
await f.manager.initialize()
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
expect(f.state.sends).toHaveLength(5)
expect(f.execute).not.toHaveBeenCalled()
} finally { await f.close() }
}, 10_000)
})
+55 -5
View File
@@ -13,6 +13,7 @@ import {
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { execFileSync } from 'node:child_process'
import { ipcChannels } from '../shared/ipc-channels'
import type {
AssistantProject,
@@ -6456,6 +6457,50 @@ describe('registerIpcHandlers agent terminal state', () => {
} finally { await harness.dispose(); database.close() }
})
it('routes Supervisor overview, graph and stories IPC through the real readonly worker without Main fallback', async () => {
const directory = await mkdtemp(join(tmpdir(), 'supervision-ipc-worker-'))
const worker = join(directory, 'worker.cjs')
execFileSync(process.execPath, ['-e', `require('esbuild').buildSync(${JSON.stringify({
entryPoints: [join(process.cwd(), 'src/main/readonly-query-worker.ts')], outfile: worker,
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent'
})})`], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } })
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
database.initialize(directory)
const at = '2026-10-01T12:00:00.000Z'
database.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: at, to: at } },
evidence: [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: 'Source', content: 'Full body', occurredAt: at }],
output: { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } })
const overview = database.listSupervisionResults()
const resultId = overview[0]!.id
const sourceId = overview[0]!.sourceId!
const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined,
false, undefined, undefined, undefined, undefined, database)
try {
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true })
const event = trustedEvent(harness.webContents)
const requests = [[ipcChannels.supervisionOverview, {}], [ipcChannels.supervisionGraph, { resultId }],
[ipcChannels.supervisionStories, { scope: { kind: 'global' } }]] as const
for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('worker is not configured')
database.enableReadonlyWorker(worker)
const reads = [vi.spyOn(database, 'listSupervisionResults'), vi.spyOn(database, 'getSupervisionGraph'), vi.spyOn(database, 'supervisionStories')]
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(event, {})).resolves.toEqual(overview)
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(event, { resultId })).resolves.toMatchObject({
sources: [{ id: sourceId, title: 'Source', occurred_at: at }] })
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
stories: [], experiences: [], unassigned: 0, canUndo: false })
expect(electronMocks.handlers.get(ipcChannels.supervisionSource)!(event, { sourceId })).toMatchObject({ content: 'Full body' })
await database.readonlyWorkerForTest!.terminateWorkerForTest()
for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('backing off')
for (const read of reads) expect(read).not.toHaveBeenCalled()
harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false })
await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({
stories: [], experiences: [], unassigned: 0, canUndo: false })
} finally {
await harness.dispose(); database.close()
await rm(directory, { recursive: true, force: true, maxRetries: 10 })
}
}, 60_000)
it('scopes supervision continuation context to source-linked graph evidence', async () => {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
@@ -6650,12 +6695,12 @@ describe('registerIpcHandlers agent terminal state', () => {
await expect(activity(trustedEvent(harness.webContents), { configId: 'unrelated-plan' })).resolves.toEqual([])
await expect(activity(trustedEvent(harness.webContents), { configId: '' })).rejects.toThrow()
await expect(activity({ sender: {} }, {})).rejects.toThrow()
expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).toEqual(overview)
await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).resolves.toEqual(overview)
expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId)
const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] }
expect(graph.sources).toHaveLength(evidence.length)
expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }])
expect(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配')
await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).rejects.toThrow('不匹配')
expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配')
const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to))
expect(legacy.tasks.some(task => task.id === oldTask.id)).toBe(false)
@@ -6678,14 +6723,19 @@ describe('registerIpcHandlers agent terminal state', () => {
database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() },
messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }])
let first = true
let providerFailures = ending === 'provider' ? 2 : 0
const releaseConversation = vi.fn(async () => undefined)
if (ending === 'cleanup') releaseConversation.mockRejectedValueOnce(new Error('Release failed'))
const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) {
yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) }
if (providerFailures > 0) {
providerFailures--
first = false
throw new Error('Provider HTTP 429: retry later')
}
if (first) {
first = false
if (ending === 'incomplete') return
if (ending === 'provider') throw new Error('Provider HTTP 429: retry later')
if (ending !== 'cleanup') await new Promise<void>(resolve => signal.addEventListener('abort', () => resolve(), { once: true }))
if (ending === 'throw') throw new DOMException('This operation was aborted', 'AbortError')
}
@@ -6708,10 +6758,10 @@ describe('registerIpcHandlers agent terminal state', () => {
await rejected
expect(database.listSupervisionResults()).toEqual([])
expect(database.listSupervisionActivity()).toEqual([expect.objectContaining({ status: 'failed', error })])
expect(releaseConversation).toHaveBeenCalledOnce()
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 2 : 1)
await invoke()
expect(database.listSupervisionResults()).toHaveLength(1)
expect(releaseConversation).toHaveBeenCalledTimes(2)
expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 3 : 2)
} finally { await harness.dispose(); database.close(); vi.useRealTimers() }
})
+3 -4
View File
@@ -7071,7 +7071,7 @@ export function registerIpcHandlers(
registerHandler(ipcChannels.supervisionOverview, (event, input: unknown) => {
assertTrustedSender(event, window)
const request = supervisionOverviewRequestSchema.parse(input ?? {})
return assistantDatabase.listSupervisionResults(20, request.target, request.resultId)
return assistantDatabase.listSupervisionResultsAsync(20, request.target, request.resultId)
})
registerHandler(ipcChannels.supervisionRun, async (event, input: unknown) => {
assertTrustedSender(event, window)
@@ -7083,7 +7083,7 @@ export function registerIpcHandlers(
})
registerHandler(ipcChannels.supervisionGraph, (event, input: unknown) => {
assertTrustedSender(event, window)
return assistantDatabase.getSupervisionGraph(supervisionGraphRequestSchema.parse(input ?? {}))
return assistantDatabase.getSupervisionGraphAsync(supervisionGraphRequestSchema.parse(input ?? {}))
})
registerHandler(ipcChannels.supervisionSource, (event, input: unknown) => {
assertTrustedSender(event, window)
@@ -7216,8 +7216,7 @@ export function registerIpcHandlers(
assertTrustedSender(event, window)
const { scope } = supervisionStoryListSchema.parse(input)
if ((await applicationSettingsStore?.get())?.heartbeatEnabled !== true) return { stories: [], experiences: [], unassigned: 0, canUndo: false }
const stories = assistantDatabase.supervisionStories()
return { stories: stories.list(scope), experiences: assistantDatabase.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined), unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() }
return assistantDatabase.getSupervisionStoriesAsync(scope)
})
registerHandler(ipcChannels.supervisionStoryAction, async (event, input: unknown) => {
assertTrustedSender(event, window)
+4
View File
@@ -66,6 +66,10 @@ if (kind === 'checkpoint') {
handlers = {
readStoryGraph: ([name, input, projectId], signal) => database.readStoryGraph(
name as Parameters<AssistantDatabase['readStoryGraph']>[0], input, projectId as string | undefined, signal),
supervisionOverview: ([limit, target, resultId]) => database.readSnapshot(() => database.listSupervisionResults(
limit as number, target as Parameters<AssistantDatabase['listSupervisionResults']>[1], resultId as string | undefined)),
supervisionGraph: ([input]) => database.readSnapshot(() => database.getSupervisionGraph(input as Parameters<AssistantDatabase['getSupervisionGraph']>[0])),
supervisionStories: ([scope]) => database.readSnapshot(() => database.getSupervisionStories(scope as ReviewState['request']['scope'])),
searchConversations: ([query]) => database.searchConversations(query as string),
// Several statements each: read them from one snapshot.
listConversationSummaries: ([detailIds]) => database.readSnapshot(() =>
+19
View File
@@ -14110,6 +14110,25 @@ describe("App", () => {
expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument();
});
it("does not reload artifacts on first or warm Supervisor entry", async () => {
render(<App />);
await waitFor(() => {
expect(api.artifacts.list).toHaveBeenCalled();
expect(api.heartbeats.history).toHaveBeenCalled();
});
const artifactReads = vi.mocked(api.artifacts.list).mock.calls.length;
const historyReads = vi.mocked(api.heartbeats.history).mock.calls.length;
fireEvent.click(await screen.findByRole("button", { name: "监督者" }));
await screen.findByRole("heading", { name: "监督者" });
await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 1));
expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads);
const navigation = within(screen.getByRole("navigation", { name: "主导航" }));
fireEvent.click(navigation.getByRole("button", { name: "对话" }));
fireEvent.click(navigation.getByRole("button", { name: "监督者" }));
await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 2));
expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads);
});
it("counts only pending supervisor suggestions in the navigation badge", async () => {
vi.mocked(api.supervision.suggestions).mockResolvedValue([
{ id: "00000000-0000-4000-8000-000000000901", resultId: null, heartbeatRunId: null, scope: { kind: "global" },
+2 -3
View File
@@ -171,7 +171,6 @@ import {
useTaskStatusRows,
} from "./task-selectors";
import {
refreshArtifacts,
refreshTasks,
useArtifactHydration,
useInitialArtifactSync,
@@ -3878,8 +3877,8 @@ function App(): React.JSX.Element {
}, [loadHeartbeats, projects.length]);
const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
await Promise.all([refreshArtifacts(taskStore), refreshHeartbeats()]);
}, [refreshHeartbeats, taskStore]);
await refreshHeartbeats();
}, [refreshHeartbeats]);
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
setHeartbeatLoading(true);
+9 -4
View File
@@ -152,7 +152,9 @@ function createProps(
it('graph navigation reopens the graph tab on repeated requests and preserves normal tab changes', async () => {
const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }))
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never
const result = { id: 'A', storyLineId: 'story-A', sourceId: null, summary: 'Saved review', changeDigest: '',
createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, openItems: [], timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } }
window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [result], graph } } as never
const props = createProps({ graphNavigation: { resultId: 'A' } })
const view = renderComponent(<HeartbeatCenter {...props} />)
const tab = screen.getByRole('tab', { name: '故事线图谱' })
@@ -236,7 +238,7 @@ describe('HeartbeatCenter', () => {
expect(within(panel).getByRole('button', { name: t('center.actions.refresh') })).toBeVisible()
}
if (key === 'supervisor.graph') {
const graphNavigation = within(panel).getByRole('tablist', { name: t('supervisor.selection') })
const graphNavigation = await within(panel).findByRole('tablist', { name: t('supervisor.selection') })
expect(screen.getAllByRole('tablist')).toEqual([navigation, graphNavigation])
expect(within(panel).getAllByRole('tablist')).toEqual([graphNavigation])
const graphTabs = ['event', 'entity', 'relation'].map((kind) =>
@@ -292,11 +294,14 @@ describe('HeartbeatCenter', () => {
expect(await screen.findByText('Older review body')).toBeVisible()
expect(screen.getByRole('tab', { name: '工作回顾' })).toHaveAttribute('aria-selected', 'true')
expect(overview).toHaveBeenCalledWith({ resultId: 'old' })
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
expect(graph).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('tab', { name: '活动记录' }))
fireEvent.click(await screen.findByRole('button', { name: '在图谱中查看' }))
expect(screen.getByRole('tab', { name: '故事线图谱' })).toHaveAttribute('aria-selected', 'true')
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
await waitFor(() => {
expect(graph).toHaveBeenCalledOnce()
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
})
})
it.each(['zh-CN', 'en-US'])('edits the supervision timeout and concurrency and preserves failed edits (%s)', async (language) => {
@@ -0,0 +1,73 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { SupervisionStoryView } from '../../shared/supervision-story-contracts'
import { useSupervisionStories } from './SupervisionStories'
const scopeA = { kind: 'projects' as const, projectIds: ['11111111-1111-4111-8111-111111111111'] }
const scopeB = { kind: 'projects' as const, projectIds: ['22222222-2222-4222-8222-222222222222'] }
const rename = { action: 'rename' as const, storyId: '11111111-1111-4111-8111-111111111111', name: 'Renamed story' }
const snapshot = (name: string): SupervisionStoryView => ({ stories: [{ id: rename.storyId, name, projectId: null,
projectName: null, parentId: null, level: 'feature', description: '', state: 'active', stateEventId: null,
userEdited: false, events: [], startedAt: null, endedAt: null }], experiences: [], unassigned: 0, canUndo: false })
afterEach(() => { cleanup(); vi.restoreAllMocks(); window.goodbuddy = {} as never })
it.each(['scope', 'refresh revision'] as const)('does not let a retained mutation reload invalidate the new %s read', async change => {
const initial = snapshot('Initial'), current = snapshot('Current'), stale = snapshot('Stale mutation reload')
let finishMutation!: () => void
let finishCurrent!: (value: SupervisionStoryView) => void
const storyAction = vi.fn(() => new Promise<void>(resolve => { finishMutation = resolve }))
const stories = vi.fn().mockResolvedValueOnce(initial)
.mockImplementationOnce(() => new Promise<SupervisionStoryView>(resolve => { finishCurrent = resolve }))
.mockResolvedValue(stale)
window.goodbuddy = { supervision: { stories, storyAction } } as never
const revision = {}
const view = renderHook(({ scope, revision }) => useSupervisionStories(scope, true, revision),
{ initialProps: { scope: scopeA, revision } })
await waitFor(() => expect(view.result.current.view).toBe(initial))
let mutation!: Promise<boolean>
act(() => { mutation = view.result.current.act(rename) })
view.rerender({ scope: change === 'scope' ? scopeB : scopeA, revision: change === 'scope' ? revision : {} })
await waitFor(() => expect(stories).toHaveBeenCalledTimes(2))
// The old action finishes while the replacement read is still pending.
await act(async () => { finishMutation(); await mutation })
await act(async () => finishCurrent(current))
await waitFor(() => expect(view.result.current.view).toBe(current))
expect(stories).toHaveBeenCalledTimes(2)
expect(view.result.current.error).toBeUndefined()
expect(view.result.current.pending).toBe(false)
})
it('rejects a retained reload before sending a request after same-scope refresh or unmount', async () => {
const current = snapshot('Current')
const stories = vi.fn().mockResolvedValue(current)
window.goodbuddy = { supervision: { stories } } as never
const view = renderHook(({ revision }) => useSupervisionStories(scopeA, true, revision), { initialProps: { revision: 1 } })
await waitFor(() => expect(view.result.current.view).toBe(current))
const obsoleteReload = view.result.current.reload
view.rerender({ revision: 2 })
await waitFor(() => expect(stories).toHaveBeenCalledTimes(2))
await act(async () => obsoleteReload())
expect(stories).toHaveBeenCalledTimes(2)
const unmountedReload = view.result.current.reload
view.unmount()
await unmountedReload()
expect(stories).toHaveBeenCalledTimes(2)
})
it('still reloads an owned mutation and reuses its refreshed snapshot on tab return', async () => {
const initial = snapshot('Initial'), updated = snapshot('Renamed story')
const stories = vi.fn().mockResolvedValueOnce(initial).mockResolvedValue(updated)
const storyAction = vi.fn().mockResolvedValue(undefined)
window.goodbuddy = { supervision: { stories, storyAction } } as never
const view = renderHook(({ active }) => useSupervisionStories(scopeA, active, 1), { initialProps: { active: true } })
await waitFor(() => expect(view.result.current.view).toBe(initial))
await act(async () => { expect(await view.result.current.act(rename)).toBe(true) })
expect(view.result.current.view).toBe(updated)
expect(stories).toHaveBeenCalledTimes(2)
expect(storyAction).toHaveBeenCalledWith(rename)
view.rerender({ active: false })
view.rerender({ active: true })
expect(view.result.current.view).toBe(updated)
expect(stories).toHaveBeenCalledTimes(2)
})
+22 -10
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SupervisionRunRequest } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts'
@@ -12,20 +12,32 @@ export function useSupervisionStories(scope: SupervisionRunRequest['scope'] | un
const [view, setView] = useState<{ key: string; data: SupervisionStoryView }>({ key: '', data: empty })
const [error, setError] = useState<string>()
const [pending, setPending] = useState(false)
const latestKey = useRef(key)
useEffect(() => { latestKey.current = key }, [key])
const latest = useRef<{ key: string; revision: unknown } | undefined>(undefined)
const loaded = useRef<{ key: string; revision: unknown } | undefined>(undefined)
const generation = useRef(0)
useLayoutEffect(() => {
latest.current = { key, revision }
return () => { latest.current = undefined }
}, [key, revision])
const load = useCallback(async () => {
if (!api?.stories || !key) return
const owner = latest.current
// A completed mutation may still hold the previous scope/refresh callback.
if (!api?.stories || !key || !owner || owner.key !== key || owner.revision !== revision) return
const request = ++generation.current
try {
const data = await api.stories({ scope: JSON.parse(key) as SupervisionRunRequest['scope'] })
if (latestKey.current === key) { setView({ key, data }); setError(undefined) }
} catch (reason) { if (latestKey.current === key) setError(reason instanceof Error ? reason.message : String(reason)) }
}, [api, key])
if (latest.current === owner && request === generation.current) {
loaded.current = { key, revision }
setView({ key, data }); setError(undefined)
}
} catch (reason) { if (latest.current === owner && request === generation.current) setError(reason instanceof Error ? reason.message : String(reason)) }
}, [api, key, revision])
useEffect(() => {
if (!active) return
if (!active || (loaded.current?.key === key && loaded.current.revision === revision)) return
const task = window.setTimeout(() => void load(), 0)
return () => window.clearTimeout(task)
}, [active, load, revision])
const invalidate = () => { generation.current++ }
return () => { window.clearTimeout(task); invalidate() }
}, [active, key, load, revision])
const act = async (action: SupervisionStoryAction) => {
if (!api?.storyAction || pending) return false
setPending(true)
+3 -6
View File
@@ -1,7 +1,6 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SupervisionStoryView } from '../../shared/supervision-story-contracts'
import { storyDigest, type StoryDigestEntry } from './supervision-story-digest'
import type { StoryDigest, StoryDigestEntry } from './supervision-story-digest'
type Focus = { kind: 'story' | 'experience'; id: string }
const shown = 6
@@ -11,15 +10,13 @@ const shown = 6
* started, finished or stayed quiet, and which experiences formed or were used. Reads stored
* stories only; each item opens the story or experience in the graph.
*/
export function SupervisionStoryDigest({ view, range, date, onOpen }: {
view: SupervisionStoryView
range: { from: string; to: string }
export function SupervisionStoryDigest({ digest, date, onOpen }: {
digest: StoryDigest
date: (value: string) => string
onOpen: (focus: Focus) => void
}): React.JSX.Element {
const { t } = useTranslation('heartbeat')
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
const digest = storyDigest(view, range)
const total = digest.advanced.length + digest.started.length + digest.concluded.length + digest.experiences.length
const limit = <T,>(key: string, items: T[]) => expanded[key] ? items : items.slice(0, shown)
const more = (key: string, count: number) => count > shown && !expanded[key]
@@ -0,0 +1,82 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { SupervisionGraphView } from '../../shared/supervision-contracts'
import { useListWindow } from './use-list-window'
type Kind = 'event' | 'entity' | 'relation'
export function SupervisorSelectionList({ kind, events, entities, relations, entityMap, selectedId, onSelect, date, shortDate, entityTone, id, labelledBy }: {
kind: Kind
events: SupervisionGraphView['events']
entities: SupervisionGraphView['entities']
relations: SupervisionGraphView['relations']
entityMap: Map<string, SupervisionGraphView['entities'][number]>
selectedId?: string
onSelect: (selection: { kind: Kind; id: string }) => void
date: (value: string) => string
shortDate: (value: string) => string
entityTone: (id: string) => number
id: string
labelledBy: string
}) {
const { t } = useTranslation('heartbeat')
const ref = useRef<HTMLDivElement>(null)
const [keyboardId, setKeyboardId] = useState<string>()
const records = kind === 'event' ? events : kind === 'entity' ? entities : relations
const ids = useMemo(() => records.map(record => record.id), [records])
// Native Tab entry from either side must reach the logical endpoints, even when scrolled away.
const list = useListWindow({ ids, scope: kind, scrollRef: ref, keepIds: [ids[0], ids.at(-1), selectedId, keyboardId], enabled: true, estimatedRowHeight: kind === 'event' ? 64 : 44 })
const { onScroll } = list
useLayoutEffect(() => {
const panel = ref.current
if (!panel || !selectedId) return
const row = Array.from(panel.querySelectorAll<HTMLElement>('[data-list-window-row]')).find(item => item.dataset.listWindowRow === selectedId)
if (!row) return
const bounds = panel.getBoundingClientRect(), rect = row.getBoundingClientRect()
if (rect.top < bounds.top) panel.scrollTop += rect.top - bounds.top
else if (rect.bottom > bounds.bottom) panel.scrollTop += rect.bottom - bounds.bottom
onScroll()
}, [selectedId, onScroll])
return <div ref={ref} className="supervisor-workspace__list-panel supervisor-workspace__selection-list"
role="tabpanel" id={id} aria-labelledby={labelledBy} tabIndex={0}
onScroll={list.onScroll} onFocus={list.onFocus} onBlur={list.onBlur}
onKeyDown={event => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Tab'].includes(event.key) || !ids.length) return
const row = (event.target as HTMLElement).closest<HTMLElement>('[data-list-window-row]')
const index = row ? ids.indexOf(row.dataset.listWindowRow!) : -1
const next = event.key === 'Home' ? 0 : event.key === 'End' ? ids.length - 1
: index + (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1)
if (event.key === 'Tab' && (index < 0 || next < 0 || next >= ids.length)) return
event.preventDefault()
event.stopPropagation()
const nextId = ids[Math.max(0, Math.min(ids.length - 1, next))]!
// Materialize the logical next row before native focus, including across spacers.
flushSync(() => setKeyboardId(nextId))
const target = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-list-window-row]') ?? []).find(item => item.dataset.listWindowRow === nextId)
target?.querySelector('button')?.focus()
target?.scrollIntoView?.({ block: 'nearest' })
list.onScroll()
}}>
{!ids.length && <p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>}
{list.segments.map(segment => {
if (segment.kind === 'spacer') return <div key={segment.key} data-list-window-spacer aria-hidden="true" style={{ height: segment.height, flex: '0 0 auto' }} />
const index = segment.index, rowId = ids[index]!
const event = kind === 'event' ? events[index] : undefined
const entity = kind === 'entity' ? entities[index] : undefined
const relation = kind === 'relation' ? relations[index] : undefined
return <div key={rowId} data-list-window-row={rowId} ref={list.rowRef(rowId)} className="supervisor-workspace__list-row">
<button type="button" aria-pressed={selectedId === rowId}
aria-label={event ? `${index + 1}. ${event.title} · ${date(event.occurred_at)}` : undefined}
data-tone={entity ? entityTone(rowId) : undefined} onClick={() => onSelect({ kind, id: rowId })}>
{event ? <><span className="supervisor-workspace__event-index">{index + 1}.</span>
<span className="supervisor-workspace__list-copy">{event.title}<small title={date(event.occurred_at)}>{shortDate(event.occurred_at)}</small></span></>
: entity ? <><i className="supervisor-workspace__entity-dot" aria-hidden="true" /><span className="supervisor-workspace__list-copy">{entity.canonical_label}</span></>
: relation ? <>{entityMap.get(relation.from_entity_id)?.canonical_label} → {entityMap.get(relation.to_entity_id)?.canonical_label} · {t(`supervisor.relationTypes.${relation.relation_type}`, { defaultValue: relation.relation_type })}</> : null}
</button>
</div>
})}
</div>
}
+157 -10
View File
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { SupervisorWorkspace } from './SupervisorWorkspace'
import { changeUiLocale, i18nResources } from './i18n'
import * as digestModule from './supervision-story-digest'
const result = { id: 'result-1', storyLineId: 'story', sourceId: null, summary: 'Recap', changeDigest: '', createdAt: '2026-09-22T00:00:00.000Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-20T00:00:00.000Z', to: '2026-09-22T00:00:00.000Z' }, openItems: [] }
const render = (ui: React.ReactNode) => {
@@ -15,6 +16,143 @@ const selectHistory = (id: string) => {
}
describe('SupervisorWorkspace', () => {
it('loads recap independently of graph and reuses the graph across recap switches until refreshed', async () => {
const data = { storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }
const graph = vi.fn().mockResolvedValue(data)
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
const view = render(<SupervisorWorkspace />)
await screen.findByText('Recap')
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
expect(graph).not.toHaveBeenCalled()
view.rerender(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('group', { name: '故事线图谱' })
expect(graph).toHaveBeenCalledOnce()
view.rerender(<SupervisorWorkspace />)
view.rerender(<SupervisorWorkspace tab="graph" />)
expect(screen.getByRole('group', { name: '故事线图谱' })).toBeVisible()
expect(graph).toHaveBeenCalledOnce()
view.rerender(<SupervisorWorkspace />)
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
expect(graph).toHaveBeenCalledOnce()
view.rerender(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
})
it('computes one digest per story snapshot and keeps it on warm switches and unrelated edits', async () => {
const snapshot = { stories: [{ id: 's', name: 'Story', projectId: null, projectName: null, parentId: null, level: 'feature',
description: '', state: 'active', stateEventId: null, userEdited: false, startedAt: result.createdAt, endedAt: null,
events: [{ id: 'e', title: 'Evidence', projectId: null, startedAt: result.createdAt, endedAt: result.createdAt, primary: true, userSet: false }] }],
experiences: [], unassigned: 0, canUndo: false }
const stories = vi.fn().mockResolvedValue(snapshot)
const digest = vi.spyOn(digestModule, 'storyDigest')
window.goodbuddy = { supervision: { overview: async () => [result], stories,
graph: async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
const view = render(<SupervisorWorkspace />)
await screen.findByRole('article', { name: '按故事查看本次回顾' })
expect(digest.mock.calls.filter(([data]) => data === snapshot)).toHaveLength(1)
digest.mockClear()
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
view.rerender(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace />)
expect(stories).toHaveBeenCalledOnce()
expect(digest).not.toHaveBeenCalled()
digest.mockRestore()
})
it('bounds graph list rows while keeping the last record and keyboard traversal reachable', async () => {
const events = Array.from({ length: 600 }, (_, i) => ({ id: `e${i}`, title: `Event ${i}`, description: `Detail ${i}`, occurred_at: result.createdAt }))
window.goodbuddy = { supervision: { overview: async () => [result],
graph: async () => ({ storyLine: null, events, entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }) } } as never
render(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('group', { name: '故事线图谱' })
const panel = screen.getByRole('tabpanel')
expect(within(panel).getAllByRole('button').length).toBeLessThanOrEqual(122)
fireEvent.keyDown(panel, { key: 'End' })
expect(within(panel).getByRole('button', { name: /600\. Event 599/ })).toHaveFocus()
fireEvent.click(document.activeElement!)
expect(screen.getByText('Detail 599')).toBeVisible()
fireEvent.keyDown(panel, { key: 'Home' })
expect(within(panel).getByRole('button', { name: /1\. Event 0 / })).toHaveFocus()
for (let i = 0; i < 125; i++) fireEvent.keyDown(document.activeElement!, { key: 'Tab' })
expect(within(panel).getByRole('button', { name: /126\. Event 125/ })).toHaveFocus()
expect(within(panel).getAllByRole('button').length).toBeLessThanOrEqual(123)
})
it('leaves a pending or failed graph without blocking recap and retries only on graph demand', async () => {
let finish!: (value: unknown) => void
const graphData = (title: string) => ({ storyLine: null, events: [{ id: 'e', title, description: '', occurred_at: result.createdAt }],
entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })
const graph = vi.fn().mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
.mockRejectedValueOnce(new Error('graph failed')).mockResolvedValue(graphData('Current event'))
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
const view = render(<SupervisorWorkspace />)
await screen.findByText('Recap')
view.rerender(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(graph).toHaveBeenCalledOnce())
view.rerender(<SupervisorWorkspace />)
expect(screen.getByLabelText('历史结果')).toBeEnabled()
await act(async () => finish(graphData('Obsolete event')))
view.rerender(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('alert')
expect(screen.queryByText('Obsolete event')).not.toBeInTheDocument()
view.rerender(<SupervisorWorkspace />)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(screen.getByText('Recap')).toBeVisible()
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
expect(graph).toHaveBeenCalledTimes(2)
view.rerender(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('group', { name: '故事线图谱' })
expect(graph).toHaveBeenCalledTimes(3)
expect(screen.queryByText('Obsolete event')).not.toBeInTheDocument()
})
it('keeps missing historical recap navigation retryable without falling back to the latest result', async () => {
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId ? [] : [result])
const graph = vi.fn()
window.goodbuddy = { supervision: { overview, graph } } as never
render(<SupervisorWorkspace graphNavigation={{ resultId: 'deleted' }} />)
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
expect(screen.queryByText('Recap')).not.toBeInTheDocument()
expect(graph).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(overview.mock.calls.filter(([input]) => input?.resultId === 'deleted')).toHaveLength(2))
expect(graph).not.toHaveBeenCalled()
})
it('invalidates a cached graph after entity edits and keeps source bodies demand-loaded', async () => {
let confirmed = false
const graph = vi.fn(async () => ({ storyLine: null,
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: confirmed ? 'confirmed' : 'automatic' }],
relations: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
eventEntities: [{ event_id: 'event', entity_id: 'entity' }], eventSources: [{ event_id: 'event', source_id: 'source' }] }))
const entityAction = vi.fn(async () => { confirmed = true })
const source = vi.fn().mockResolvedValue({ title: 'Source', content: 'Full saved body', occurredAt: result.createdAt })
window.goodbuddy = { supervision: { overview: async () => [result], graph, source, entityAction } } as never
const view = render(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('group', { name: '故事线图谱' })
expect(source).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('tab', { name: /实体/ }))
fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: 'Entity' }))
fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm }))
await waitFor(() => {
expect(graph).toHaveBeenCalledTimes(2)
expect(screen.getByLabelText('历史结果')).toBeEnabled()
})
expect(entityAction).toHaveBeenCalledWith({ entityId: 'entity', resultId: result.id, action: 'confirm' })
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
expect(await screen.findByText('Full saved body')).toBeVisible()
view.rerender(<SupervisorWorkspace />)
view.rerender(<SupervisorWorkspace tab="graph" />)
expect(graph).toHaveBeenCalledTimes(2)
expect(screen.getByText('Full saved body')).toBeVisible()
expect(source).toHaveBeenCalledOnce()
})
it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => {
let finishReview!: () => void
const run = vi.fn(() => new Promise<void>(resolve => { finishReview = resolve }))
@@ -339,7 +477,7 @@ describe('SupervisorWorkspace', () => {
expect(screen.getByLabelText('时间范围')).toHaveValue('30')
expect(screen.getByLabelText('关注范围')).toHaveValue(project.id)
expect(recap.textContent).toBe(frozenText)
expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' })
expect(graph).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
expect(recap.textContent).toBe(frozenText)
@@ -421,7 +559,7 @@ describe('SupervisorWorkspace', () => {
let resolveSource!: (value: unknown) => void
const source = vi.fn(() => new Promise(resolve => { resolveSource = resolve }))
const graph = vi.fn(async () => ({ storyLine: null, events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }], entities: [], relations: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }], eventEntities: [], eventSources: [{ event_id: 'event', source_id: 'source' }] }))
window.goodbuddy = { supervision: { overview: async () => [result], graph, source } } as never
window.goodbuddy = { supervision: { overview: async () => [{ ...result, id: 'A' }, { ...result, id: 'B' }], graph, source } } as never
const view = render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'A' }} />)
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
@@ -450,6 +588,9 @@ describe('SupervisorWorkspace', () => {
view.rerender(<SupervisorWorkspace graphNavigation={next} />)
expect(screen.getByLabelText('历史结果')).toHaveValue('B')
selectHistory('A')
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
expect(graph).toHaveBeenCalledTimes(2)
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={next} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story' }))
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'B' }} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story' }))
@@ -458,14 +599,16 @@ describe('SupervisorWorkspace', () => {
it('graph navigation requests results outside overview and keeps missing results retryable without latest fallback', async () => {
const graph = vi.fn().mockRejectedValue(new Error('Missing requested result'))
window.goodbuddy = { supervision: { overview: async () => [result], graph } } as never
const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId ? [] : [result])
window.goodbuddy = { supervision: { overview, graph } } as never
render(<SupervisorWorkspace tab="graph" graphNavigation={{ resultId: 'older' }} />)
expect(await screen.findByRole('alert')).toHaveTextContent(i18nResources['zh-CN'].heartbeat.supervisor.loadFailed)
expect(screen.queryByText('Missing requested result')).not.toBeInTheDocument()
expect(graph).toHaveBeenCalledWith({ resultId: 'older', storyLineId: undefined })
expect(graph).not.toHaveBeenCalled()
expect(overview).toHaveBeenCalledWith({ resultId: 'older' })
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(graph).toHaveBeenCalledTimes(2))
expect(graph).toHaveBeenLastCalledWith({ resultId: 'older', storyLineId: undefined })
await waitFor(() => expect(overview.mock.calls.filter(([input]) => input?.resultId === 'older')).toHaveLength(2))
expect(graph).not.toHaveBeenCalled()
expect(screen.queryByText('Recap')).not.toBeInTheDocument()
})
it('opens the selected result graph and ignores a late response from another project', async () => {
@@ -473,15 +616,18 @@ describe('SupervisorWorkspace', () => {
const b = { ...result, id: 'B', storyLineId: 'story-B', summary: 'B recap' }
const graphFor = (title: string) => ({ storyLine: null, events: [{ id: title, title, description: title, occurred_at: result.createdAt }], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })
let resolveA!: (value: unknown) => void
const graph = vi.fn().mockResolvedValueOnce(graphFor('B event')).mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })).mockResolvedValueOnce(graphFor('B selected'))
const graph = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { resolveA = resolve })).mockResolvedValueOnce(graphFor('B selected'))
window.goodbuddy = { supervision: { overview: async () => [b, a], graph } } as never
const onTabChange = vi.fn()
const view = render(<SupervisorWorkspace onTabChange={onTabChange} />)
await screen.findByText('B recap')
expect(graph).not.toHaveBeenCalled()
selectHistory('A')
await waitFor(() => expect(screen.getByLabelText('历史结果')).toBeEnabled())
view.rerender(<SupervisorWorkspace tab="graph" onTabChange={onTabChange} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'A', storyLineId: 'story-A' }))
const navigation = { resultId: 'B' }
view.rerender(<SupervisorWorkspace graphNavigation={navigation} onTabChange={onTabChange} />)
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
await waitFor(() => expect(graph).toHaveBeenLastCalledWith({ resultId: 'B', storyLineId: 'story-B' }))
await act(async () => resolveA(graphFor('Wrong A event')))
view.rerender(<SupervisorWorkspace tab="graph" graphNavigation={navigation} onTabChange={onTabChange} />)
@@ -495,7 +641,7 @@ describe('SupervisorWorkspace', () => {
expect(tokens, token).toContain(`${token}:`)
}
})
afterEach(async () => { cleanup(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
afterEach(async () => { cleanup(); vi.restoreAllMocks(); vi.useRealTimers(); window.goodbuddy = {} as never; await changeUiLocale('zh-CN') })
it('shows unavailable immediately and does not call supervision APIs when the bridge is absent', () => {
window.goodbuddy = {} as never
@@ -849,7 +995,8 @@ describe('SupervisorWorkspace', () => {
expect(circles).toHaveLength(8)
expect(new Set(circles.map(circle => `${circle.getAttribute('cx')},${circle.getAttribute('cy')}`)).size).toBe(8)
const list = screen.getByRole('complementary', { name: '图谱选择' })
expect(within(list).getAllByRole('button')).toHaveLength(80)
expect(within(list).getAllByRole('button').length).toBeLessThan(80)
fireEvent.keyDown(within(list).getByRole('tabpanel'), { key: 'End' })
fireEvent.click(within(list).getByRole('button', { name: /80\. 事件 79/ }))
expect(within(graph).getByRole('button', { name: '事件 79' })).toHaveAttribute('aria-pressed', 'true')
expect(within(screen.getByRole('complementary', { name: '详情与来源' })).getByText('详情 79')).toBeInTheDocument()
+66 -109
View File
@@ -10,6 +10,7 @@ import { storyDigest } from './supervision-story-digest'
import { SupervisionDiscussion } from './SupervisionDiscussion'
import { SupervisionStoryDigest } from './SupervisionStoryDigest'
import { useFillHeight } from './use-fill-height'
import { SupervisorSelectionList } from './SupervisorSelectionList'
import { SupervisionEventStory, SupervisionExperienceDetail, SupervisionExperienceList, SupervisionStoryDetail, SupervisionStoryList, useSupervisionStories } from './SupervisionStories'
import type { AssistantProject } from '../../shared/assistant-contracts'
import { heartbeatScopeSchema } from '../../shared/assistant-contracts'
@@ -81,8 +82,10 @@ export function SupervisorWorkspace({
const api = window.goodbuddy?.supervision
const [results, setResults] = useState<SupervisionResultView[]>([])
const [graph, setGraph] = useState(emptyGraph)
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState<string>()
const [overviewLoading, setOverviewLoading] = useState(true)
const [overviewError, setOverviewError] = useState<string>()
const [graphResultId, setGraphResultId] = useState<string>()
const [graphError, setGraphError] = useState<string>()
const [pending, setPending] = useState<string>()
const [selection, setSelection] = useState<Selection | undefined>(graphNavigation?.focus)
const [listTab, setListTab] = useState<Selection['kind']>(graphNavigation?.focus?.kind ?? 'event')
@@ -122,26 +125,29 @@ export function SupervisorWorkspace({
setAppliedNavigation(graphNavigation)
setResultId(graphNavigation?.resultId)
setGraph(emptyGraph)
setLoading(true)
setGraphResultId(undefined)
setGraphError(undefined)
setOverviewLoading(true)
setSource(undefined)
setSelection(graphNavigation?.focus)
setDetailOpen(Boolean(graphNavigation?.focus))
if (graphNavigation?.focus) setListTab(graphNavigation.focus.kind)
setPending(undefined)
setLoadError(undefined)
setOverviewError(undefined)
}
const selectedResult = useRef<string | undefined>(undefined)
const requestedNavigation = useRef<SupervisionGraphNavigation | undefined>(undefined)
const loadGeneration = useRef(0)
const [confirmRemoval, setConfirmRemoval] = useState(false)
const [revision, setRevision] = useState<string>()
const date = (value: string) =>
new Date(value).toLocaleString(i18n.resolvedLanguage)
const shortDate = (value: string) =>
new Date(value).toLocaleDateString(i18n.resolvedLanguage, {
month: '2-digit',
day: '2-digit'
})
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.resolvedLanguage, {
year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric'
}), [i18n.resolvedLanguage])
const shortDateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.resolvedLanguage, {
month: '2-digit', day: '2-digit'
}), [i18n.resolvedLanguage])
const date = (value: string) => dateFormatter.format(new Date(value))
const shortDate = (value: string) => shortDateFormatter.format(new Date(value))
const scopeText = (scope: SupervisionRunRequest['scope']) =>
scope.kind === 'global'
? t('center.scope.global')
@@ -160,13 +166,15 @@ export function SupervisorWorkspace({
const generation = ++loadGeneration.current
setPending(undefined)
selectedResult.current = requestedId
setLoading(true)
setOverviewLoading(true)
setGraph(emptyGraph)
setGraphResultId(undefined)
setGraphError(undefined)
setResultId(requestedId)
setSource(undefined)
setConfirmRemoval(false)
setRevision(undefined)
setLoadError(undefined)
setOverviewError(undefined)
try {
const overview = (await api.overview()).map((item) => supervisionResultViewSchema.parse(item))
if (generation !== loadGeneration.current) return
@@ -177,35 +185,15 @@ export function SupervisorWorkspace({
}
const selected = requestedId ? overview.find((item) => item.id === requestedId) : overview[0]
setResults(overview)
if (requestedId && !selected) throw new Error('Requested supervision result is unavailable')
const nextId = requestedId ?? selected?.id
setResultId(nextId)
selectedResult.current = nextId
const nextGraph = nextId ? await api.graph({ resultId: nextId, storyLineId: selected?.storyLineId }) : emptyGraph
if (generation !== loadGeneration.current) return
const parsedGraph = supervisionGraphViewSchema.parse(nextGraph)
if (parsedGraph.storyLine)
heartbeatScopeSchema.parse(JSON.parse(parsedGraph.storyLine.scope_json))
setGraph(parsedGraph)
setSelection((current) => {
const records =
current?.kind === 'event'
? parsedGraph.events
: current?.kind === 'entity'
? parsedGraph.entities
: parsedGraph.relations
if (current?.kind === 'story' || current?.kind === 'experience') return current
if (current && records.some((item) => item.id === current.id))
return current
const latestEvent = [...parsedGraph.events].sort(
(a, b) => Date.parse(b.occurred_at) - Date.parse(a.occurred_at)
)[0]
return latestEvent ? { kind: 'event', id: latestEvent.id } : undefined
})
} catch {
if (generation !== loadGeneration.current) return
setLoadError(t('supervisor.loadFailed'))
setOverviewError(t('supervisor.loadFailed'))
} finally {
if (generation === loadGeneration.current) setLoading(false)
if (generation === loadGeneration.current) setOverviewLoading(false)
}
}, [api, t])
useEffect(() => {
@@ -219,6 +207,36 @@ export function SupervisorWorkspace({
return () => { window.clearTimeout(task); invalidate() }
}, [refresh, graphNavigation])
const latest = resultId ? results.find((item) => item.id === resultId) : results[0]
const storyLineId = latest?.storyLineId
// The selected result is the only graph cache. Refresh/navigation invalidate it;
// switching tabs keeps it, and an obsolete in-flight read cannot refill it.
useEffect(() => {
if (!api || tab !== 'graph' || overviewLoading || overviewError || !resultId || graphResultId === resultId || graphError) return
let active = true
const generation = loadGeneration.current
void api.graph({ resultId, storyLineId }).then(value => {
if (!active || generation !== loadGeneration.current) return
const parsed = supervisionGraphViewSchema.parse(value)
if (parsed.storyLine) heartbeatScopeSchema.parse(JSON.parse(parsed.storyLine.scope_json))
setGraph(parsed)
setGraphResultId(resultId)
setSelection(current => {
if (current?.kind === 'story' || current?.kind === 'experience') return current
const records = current?.kind === 'event' ? parsed.events : current?.kind === 'entity' ? parsed.entities : parsed.relations
if (current && records.some(item => item.id === current.id)) return current
const newest = parsed.events.reduce<SupervisionGraphView['events'][number] | undefined>((found, event) =>
!found || Date.parse(event.occurred_at) > Date.parse(found.occurred_at) ? event : found, undefined)
return newest ? { kind: 'event', id: newest.id } : undefined
})
}).catch(() => {
if (active && generation === loadGeneration.current) setGraphError(t('supervisor.loadFailed'))
})
return () => { active = false }
}, [api, tab, overviewLoading, overviewError, resultId, storyLineId, graphResultId, graphError, t])
const loadError = overviewError ?? (tab === 'graph' ? graphError : undefined)
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
if (period === 'range') {
const from = new Date(`${startDate}T00:00:00`)
@@ -510,11 +528,10 @@ export function SupervisorWorkspace({
if (generation === loadGeneration.current) setPending(undefined)
}
}
const latest = resultId ? results.find((item) => item.id === resultId) : results[0]
const graphScope = graph.storyLine
const graphScope = useMemo(() => graph.storyLine
? (JSON.parse(graph.storyLine.scope_json) as SupervisionRunRequest['scope'])
: undefined
const storyState = useSupervisionStories(graphScope, tab === 'graph', graph)
: undefined, [graph.storyLine])
const storyState = useSupervisionStories(latest?.scope ?? graphScope, tab === 'graph' || tab === 'overview', results)
const projectNames = useMemo(() => new Map(projects.map(project => [project.id, project.name])), [projects])
const graphLayoutRef = useRef<HTMLDivElement>(null)
// Flat labels need a taller minimum; the spiral can use short windows without that constraint.
@@ -523,8 +540,8 @@ export function SupervisorWorkspace({
// Without a graph, the empty state takes the visible height so its message sits in the middle of the page.
useFillHeight(graphEmptyRef, tab === 'graph' && !loading && !loadError && graph.events.length === 0, 24, 320)
// The work review reads the selected result's own scope, so it matches the period shown.
const recapStories = useSupervisionStories(latest?.scope, tab === 'overview', latest?.id)
const digest = latest && recapStories.available ? storyDigest(recapStories.view, latest.timeRange) : undefined
const digest = useMemo(() => latest && storyState.available ? storyDigest(storyState.view, latest.timeRange) : undefined,
[latest, storyState.available, storyState.view])
const hasDigest = digest && [digest.advanced, digest.started, digest.concluded, digest.experiences, digest.quiet].some(items => items.length > 0)
const selectedStory = selection?.kind === 'story' ? storyState.view.stories.find((story) => story.id === selection.id) : undefined
const selectedExperience = selection?.kind === 'experience' ? storyState.view.experiences.find((item) => item.id === selection.id) : undefined
@@ -713,7 +730,7 @@ export function SupervisorWorkspace({
</ul>
</>
)}
{hasDigest && <SupervisionStoryDigest view={recapStories.view} range={latest.timeRange} date={date}
{hasDigest && <SupervisionStoryDigest digest={digest} date={date}
onOpen={(focus) => { select(focus); onTabChange?.('graph') }} />}
</aside>}
</article>
@@ -774,7 +791,11 @@ export function SupervisorWorkspace({
{ id: 'experience' as const, label: `${t('supervisor.listTabs.experience')} ${storyState.view.experiences.length}` }] : [])
]}
/>
<div
{listTab === 'event' || listTab === 'entity' || listTab === 'relation' ? <SupervisorSelectionList
key={`${resultId}:${listTab}`} kind={listTab} events={layout.events} entities={layout.entities} relations={graph.relations}
entityMap={layout.entityMap} selectedId={selection?.kind === listTab ? selection.id : undefined}
onSelect={select} date={date} shortDate={shortDate} entityTone={entityTone}
id={`${graphId}-list-panel-${listTab}`} labelledBy={`${graphId}-list-tab-${listTab}`} /> : <div
key={listTab}
className="supervisor-workspace__list-panel"
role="tabpanel"
@@ -782,10 +803,6 @@ export function SupervisorWorkspace({
aria-labelledby={`${graphId}-list-tab-${listTab}`}
tabIndex={0}
>
{((listTab === 'entity' && !layout.entities.length) ||
(listTab === 'relation' && !graph.relations.length)) && (
<p className="supervisor-workspace__muted">{t('supervisor.listEmpty')}</p>
)}
{listTab === 'story' && <>
{storyState.error && <p role="alert">{storyState.error}</p>}
{!storyState.view.stories.length && <p className="supervisor-workspace__muted">{t('supervisor.stories.empty')}</p>}
@@ -800,67 +817,7 @@ export function SupervisorWorkspace({
<SupervisionExperienceList experiences={storyState.view.experiences} selectedId={selectedExperience?.id}
onSelect={(id) => select({ kind: 'experience', id })} />
</>}
{listTab === 'event' && layout.events.map((event, index) => (
<button
key={event.id}
aria-label={`${index + 1}. ${event.title} · ${date(event.occurred_at)}`}
aria-pressed={selection?.id === event.id}
onClick={() => select({ kind: 'event', id: event.id })}
>
<span className="supervisor-workspace__event-index">
{index + 1}.
</span>
<span className="supervisor-workspace__list-copy">
{event.title}
<small title={date(event.occurred_at)}>
{shortDate(event.occurred_at)}
</small>
</span>
</button>
))}
{listTab === 'entity' && layout.entities.map((entity) => (
<button
key={entity.id}
data-tone={entityTone(entity.id)}
aria-pressed={selection?.id === entity.id}
onClick={() =>
select({ kind: 'entity', id: entity.id })
}
>
<i
className="supervisor-workspace__entity-dot"
aria-hidden="true"
/>
<span className="supervisor-workspace__list-copy">
{entity.canonical_label}
</span>
</button>
))}
{listTab === 'relation' && graph.relations.map((relation) => (
<button
key={relation.id}
aria-pressed={selection?.id === relation.id}
onClick={() =>
select({ kind: 'relation', id: relation.id })
}
>
{
layout.entityMap.get(relation.from_entity_id)
?.canonical_label
}{' '}
→{' '}
{
layout.entityMap.get(relation.to_entity_id)
?.canonical_label
}{' '}
·{' '}
{t(
`supervisor.relationTypes.${relation.relation_type}`,
{ defaultValue: relation.relation_type }
)}
</button>
))}
</div>
</div>}
<p className="supervisor-workspace__muted">
{t('supervisor.legend')}
</p>
@@ -39,3 +39,17 @@ it('lists experiences formed in the period, or applied in it when formed earlier
expect(storyDigest(view, range).experiences.map(entry => [entry.experience.id, entry.role, entry.events.map(item => item.id)]))
.toEqual([['new', 'formed', ['a']], ['reused', 'applied', ['c']]])
})
it('preserves inclusive boundaries, tie ordering, orphan threads and direct-child aggregation', () => {
const view: SupervisionStoryView = { unassigned: 0, canUndo: false, experiences: [], stories: [
story('feature', [event('b', range.from), event('excluded', range.from, false)]),
story('child', [event('a', range.from), event('z', range.to)], { level: 'thread', parentId: 'feature' }),
story('grandchild', [event('deep', range.from)], { level: 'thread', parentId: 'child' }),
story('orphan', [event('orphan-event', range.to)], { level: 'thread', parentId: 'missing' }),
story('cross', [event('linked', range.from, false)], { level: 'cross' })
] }
const digest = storyDigest(view, range)
expect(digest.started.map(entry => [entry.story.id, entry.events.map(item => item.id), entry.latest.id]))
.toEqual([['feature', ['a', 'b', 'z'], 'z'], ['orphan', ['orphan-event'], 'orphan-event'], ['cross', ['linked'], 'linked']])
expect(view.stories[0]!.events.map(item => item.id)).toEqual(['b', 'excluded'])
})
+10 -3
View File
@@ -28,10 +28,17 @@ export function storyDigest(view: SupervisionStoryView, range: { from: string; t
const inRange = (value: string) => { const at = Date.parse(value); return at >= from && at <= to }
const digest: StoryDigest = { advanced: [], started: [], concluded: [], quiet: [], experiences: [], unassigned: view.unassigned }
// Features show their threads' work too; threads are listed on their own only when the feature is absent.
const children = (story: SupervisionStory) => view.stories.filter(child => child.parentId === story.id)
const ids = new Set(view.stories.map(story => story.id))
const children = new Map<string, SupervisionStory[]>()
for (const story of view.stories) {
if (story.level === 'thread' && view.stories.some(parent => parent.id === story.parentId)) continue
const events = [...own(story), ...children(story).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id))
if (story.parentId === null) continue
const siblings = children.get(story.parentId)
if (siblings) siblings.push(story)
else children.set(story.parentId, [story])
}
for (const story of view.stories) {
if (story.level === 'thread' && story.parentId !== null && ids.has(story.parentId)) continue
const events = [...own(story), ...(children.get(story.id) ?? []).flatMap(own)].sort((a, b) => a.startedAt.localeCompare(b.startedAt) || a.id.localeCompare(b.id))
if (!events.length) continue
const during = events.filter(event => inRange(event.startedAt))
const before = events.filter(event => Date.parse(event.startedAt) < from)
+14
View File
@@ -773,6 +773,7 @@
padding-inline: var(--space-1);
}
.supervisor-workspace__list-panel > button,
.supervisor-workspace__list-row > button,
.supervisor-workspace__story-group button {
flex: 0 0 auto;
display: flex;
@@ -810,10 +811,12 @@
border-radius: 50%;
}
.supervisor-workspace__list-panel > button:hover,
.supervisor-workspace__list-row > button:hover,
.supervisor-workspace__story-group button:hover {
background: var(--surface-raised);
}
.supervisor-workspace__list-panel > button[aria-pressed='true'],
.supervisor-workspace__list-row > button[aria-pressed='true'],
.supervisor-workspace__story-group button[aria-pressed='true'] {
border-color: var(--accent);
background: var(--accent-subtle);
@@ -976,6 +979,17 @@
min-width: 0;
width: 100%;
}
.supervisor-workspace__graph-list > .supervisor-workspace__selection-list {
gap: 0;
overflow-anchor: none;
}
.supervisor-workspace__list-row {
flex: 0 0 auto;
padding-bottom: var(--space-1);
}
.supervisor-workspace__list-row > button {
width: 100%;
}
.supervisor-activity > .supervisor-workspace__action-bar {
justify-content: flex-start;
align-items: flex-end;
+39
View File
@@ -0,0 +1,39 @@
import { useRef } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { installFakeListLayout } from './list-window-test-layout'
import { useListWindow } from './use-list-window'
const ids = Array.from({ length: 600 }, (_, index) => `row-${index}`)
function List() {
const ref = useRef<HTMLDivElement>(null)
const list = useListWindow({ ids, scope: 'test', scrollRef: ref, enabled: true, keepIds: ['row-599'], estimatedRowHeight: 44 })
return <div ref={ref} data-scroll-list onScroll={list.onScroll} onFocus={list.onFocus} onBlur={list.onBlur}>
{list.segments.map(segment => segment.kind === 'spacer'
? <div key={segment.key} data-list-window-spacer style={{ height: segment.height }} />
: <div key={ids[segment.index]} data-list-window-row={ids[segment.index]} ref={list.rowRef(ids[segment.index]!)}><button>{ids[segment.index]}</button></div>)}
</div>
}
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('keeps visibly focused rows in view when rewindowing replaces measured DOM with estimated spacers', () => {
const layout = installFakeListLayout({ isContainer: element => element.hasAttribute('data-scroll-list'),
rowAttribute: 'data-list-window-row', rowHeight: () => 38, viewport: 528 })
try {
const view = render(<List />)
const panel = view.container.querySelector<HTMLElement>('[data-scroll-list]')!
const row = panel.querySelector<HTMLElement>('[data-list-window-row="row-599"]')!
const button = row.querySelector('button')!
act(() => { button.focus(); panel.scrollTop = panel.scrollHeight; fireEvent.scroll(panel) })
expect(button).toHaveFocus()
expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(528)
layout.measure()
expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(528)
// Explicit scrolling away keeps focus mounted, but must not pull the viewport back.
layout.scroll(panel, 0)
expect(button).toHaveFocus()
expect(panel.scrollTop).toBe(0)
expect(row.getBoundingClientRect().top).toBeGreaterThan(528)
} finally { layout.restore() }
})
+35 -3
View File
@@ -184,6 +184,7 @@ export function useListWindow({
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
const observerRef = useRef<ResizeObserver | undefined>(undefined)
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
const visibleFocusRef = useRef<HTMLElement | undefined>(undefined)
// List-relative scroll position seen at the last scroll event or commit, and
// the ids and offsets of the last committed windowed layout. Together they
// locate the reader's row when ids change, without reading layout per scroll.
@@ -262,9 +263,23 @@ export function useListWindow({
return { top, viewport: container ? container.clientHeight : window.innerHeight }
}, [])
const captureVisibleFocus = useCallback((current: Geometry): void => {
visibleFocusRef.current = undefined
if (current.list.contains(document.activeElement)) {
const focused = document.activeElement?.closest<HTMLElement>(rowSelector)
if (focused) {
const rect = focused.getBoundingClientRect()
const top = viewportTop(current.container)
const height = current.container?.clientHeight ?? window.innerHeight
if (rect.top >= top - 1 && rect.bottom <= top + height + 1) visibleFocusRef.current = focused
}
}
}, [rowSelector])
/** First mounted row reaching into the viewport, relative to the viewport top. */
const captureScrollAnchor = useCallback((): void => {
const current = geometry()
if (current) captureVisibleFocus(current)
if (!current || position(current).top <= 0) {
scrollAnchorRef.current = undefined
return
@@ -278,7 +293,7 @@ export function useListWindow({
}
}
scrollAnchorRef.current = undefined
}, [geometry, position, rowAttribute, rowSelector])
}, [captureVisibleFocus, geometry, position, rowAttribute, rowSelector])
const activeRef = useRef(active)
useLayoutEffect(() => {
@@ -295,8 +310,11 @@ export function useListWindow({
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
// mounted rows, so a steady scroll remounts rows every screen or so.
if (!coversViewport(layoutNow, top)) setAnchorTop(top)
}, [geometry, heights, position])
if (!coversViewport(layoutNow, top)) {
captureVisibleFocus(current)
setAnchorTop(top)
}
}, [captureVisibleFocus, geometry, heights, position])
// Row and viewport measurement.
useLayoutEffect(() => {
@@ -391,6 +409,20 @@ export function useListWindow({
else window.scrollBy(0, delta)
}
}
// Height estimates can move a just-focused row past the viewport edge after
// keyboard navigation. Only follow focus that was visible before remeasurement.
const focused = visibleFocusRef.current
visibleFocusRef.current = undefined
if (windowed && focused?.isConnected && focused.contains(document.activeElement)) {
const rect = focused.getBoundingClientRect()
const top = viewportTop(container)
const height = container?.clientHeight ?? window.innerHeight
const delta = rect.bottom > top + height ? rect.bottom - top - height : Math.min(0, rect.top - top)
if (Math.abs(delta) >= 0.5) {
if (container) container.scrollTop += delta
else window.scrollBy(0, delta)
}
}
const layoutNow = layoutRef.current
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
const { top } = position(current)