feat: add supervisor workspace and traceable story graph

Extend the existing heartbeat workspace with scoped work reviews, result-specific story graphs, source inspection, and pinned conversation or task feedback. Connect confirmed continuation and local knowledge entity previews through the desktop APIs, and preserve result history and user corrections in schema 43.

Include focused regression tests, an isolated visual fixture, and implementation evidence. Full event-by-event replay, Experiment targets, no-change handling, and cancellation remain incomplete; this commit does not claim complete feature acceptance. Existing automatic plans remain active when the application entry is disabled.

Release note: Review work progress in the Supervisor workspace, trace graph events to saved sources, and open a matching review from pinned conversation or task feedback.
This commit is contained in:
mesalogo
2026-09-23 13:49:13 +08:00
parent 785b1ee468
commit 7d487f9326
49 changed files with 5980 additions and 310 deletions
@@ -0,0 +1,106 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { expect, it } from 'vitest'
import { AssistantDatabase } from './assistant-database'
import { SupervisorService, type StoredSupervisionResult } from './supervisor-service'
import { supervisionGraphViewSchema } from '../../shared/supervision-contracts'
it('supervision preserves result history and protected identities within scope through schema 42 upgrade', async () => {
const directory = await mkdtemp(join(tmpdir(), 'supervision-history-'))
const path = join(directory, 'assistant.sqlite')
const db = new AssistantDatabase(path)
db.initialize(process.cwd())
try {
const projects = ['A', 'B'].map((name) => db.createProject({ name, description: name, rootPath: directory, defaultWorkMode: 'ask' }))
db.replaceConversations(projects.map((project) => ({ id: project.name, projectId: project.id, title: project.name, updatedAt: Date.now(), messages: [] })))
const taskId = randomUUID()
db.createTask({ id: taskId, projectId: projects[0]!.id, conversationId: 'A', title: 'Task A', instructions: 'Review', workMode: 'ask' })
const first: StoredSupervisionResult = {
request: { trigger: 'manual', scope: { kind: 'projects', projectIds: [projects[0]!.id] }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } },
evidence: [{ id: 'source', sourceType: 'conversation', sourceId: 'A', title: 'A source', content: 'A content', occurredAt: '2026-09-20T00:00:00Z' },
{ id: 'task', sourceType: 'task', sourceId: taskId, title: 'A task', content: 'Task content', occurredAt: '2026-09-20T00:00:00Z' }],
output: { summary: 'A first', changeDigest: '', openItems: [],
entities: ['one', 'two'].map((id) => ({ id, label: id, description: 'Original', sourceReferenceIds: ['source'] })),
events: [{ title: 'A event', description: '', eventType: 'discussion', occurredAt: '2026-09-20T00:00:00Z', entityIds: ['one', 'two'], sourceReferenceIds: ['source'] }],
entityChanges: [], relations: [{ fromEntityId: 'one', toEntityId: 'two', relationType: 'related', reason: 'Original reason', sourceReferenceIds: [] }] }
}
db.saveSupervisionResult(first)
const a = db.listSupervisionResults()[0]!
const read = (id: string) => {
const graph = supervisionGraphViewSchema.parse(db.getSupervisionGraph({ resultId: id }))
graph.entities.sort((a, b) => a.id.localeCompare(b.id))
return graph
}
const original = read(a.id)
db.close()
const legacy = new DatabaseSync(path)
legacy.exec('ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json; PRAGMA user_version = 42;')
legacy.close()
db.initialize(process.cwd())
expect(read(a.id)).toEqual(original)
const second = structuredClone(first)
second.output.summary = 'A second'
second.output.entities.forEach((entity) => { entity.description = 'Updated'; entity.label += ' updated' })
const service = new SupervisorService({ collect: async () => second.evidence }, {
summarize: async ({ candidates }) => ({ ...second.output, entities: second.output.entities.map((entity, index) => ({ ...entity, persistedId: candidates.find((candidate) => candidate.label === first.output.entities[index]!.label)!.id })) })
}, { candidates: async (request) => db.listSupervisionCandidates(request), save: async (result) => db.saveSupervisionResult(result) })
await service.run(second.request)
const a2 = db.listSupervisionResults()[0]!
expect(read(a2.id).entities.map((entity) => entity.id).sort()).toEqual(original.entities.map((entity) => entity.id).sort())
expect(read(a2.id).entities.every((entity) => entity.description === 'Updated')).toBe(true)
expect(read(a.id)).toEqual(original)
const entityId = original.entities[0]!.id
const relationId = original.relations[0]!.id
db.applySupervisionEntityAction({ resultId: a2.id, entityId, action: 'revise', label: 'Human label', description: 'Human description' })
const confirmedEntity = read(a2.id).entities.find((entity) => entity.id !== entityId)!
db.applySupervisionEntityAction({ resultId: a2.id, entityId: confirmedEntity.id, action: 'confirm' })
db.applySupervisionRelationAction({ resultId: a2.id, relationId, action: 'confirm' })
const third = structuredClone(second)
third.candidates = db.listSupervisionCandidates(third.request)
third.output.entities = third.output.entities.map((entity, index) => ({ ...entity, persistedId: original.entities.find((item) => item.canonical_label === first.output.entities[index]!.label)!.id, label: 'Model overwrite', description: 'Model overwrite' }))
third.output.relations[0]!.reason = 'Model overwrite'
db.saveSupervisionResult(third)
const latest = db.listSupervisionResults()[0]!
expect(read(latest.id).entities.find((entity) => entity.id === entityId)).toMatchObject({ canonical_label: 'Human label', description: 'Human description', confirmation_state: 'revised' })
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')!
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' })
db.saveSupervisionResult(third)
expect(read(db.listSupervisionResults()[0]!.id).relations).toEqual([])
expect(read(a.id).relations).toEqual(original.relations)
const other = structuredClone(third)
other.request.scope = { kind: 'projects', projectIds: [projects[1]!.id] }
expect(() => db.saveSupervisionResult(other)).toThrow('本次范围')
other.output.entities.forEach((entity) => { delete entity.persistedId })
other.evidence = [{ ...first.evidence[0]!, sourceId: 'B', title: 'B source' }]
other.output.summary = 'B latest'
db.saveSupervisionResult(other)
const b = db.listSupervisionResults()[0]!
expect(() => db.getSupervisionGraph({ resultId: a.id, storyLineId: b.storyLineId })).toThrow('不匹配')
expect(read(b.id).entities.some((entity) => original.entities.some((old) => old.id === entity.id))).toBe(false)
expect(read(a.id)).toEqual(original)
expect(() => db.applySupervisionEntityAction({ resultId: a.id, entityId: read(b.id).entities[0]!.id, action: 'confirm' })).toThrow('不匹配')
// Historical sources can still point to a conversation whose project has changed.
const foreignScope = structuredClone(first)
foreignScope.request.scope = other.request.scope
db.saveSupervisionResult(foreignScope)
const forA = db.listSupervisionResults(20, { type: 'conversation', conversationId: 'A' })
expect(forA.every((result) => result.storyLineId === a.storyLineId)).toBe(true)
expect(forA.length).toBe(4)
expect(db.listSupervisionResults(20, { type: 'conversation', conversationId: 'B' }).map((result) => result.id)).toEqual([b.id])
const forTask = db.listSupervisionResults(20, { type: 'task', taskId })
expect(forTask).toHaveLength(4)
expect(db.getSupervisionSource(forTask[0]!.sourceId!)?.sourceId).toBe(taskId)
db.close()
db.initialize(process.cwd())
expect(read(a.id)).toEqual(original)
} finally { db.close(); await rm(directory, { recursive: true, force: true }) }
})