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.
123 lines
8.9 KiB
TypeScript
123 lines
8.9 KiB
TypeScript
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 }))
|
|
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' })
|
|
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;
|
|
DROP TABLE activity_history_records;
|
|
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
|
|
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
|
|
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
|
|
DROP VIEW supervision_review_current;
|
|
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
|
|
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
|
|
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
|
|
ALTER TABLE magic_note_entries DROP COLUMN source_json;
|
|
ALTER TABLE projects ADD COLUMN default_work_mode TEXT NOT NULL DEFAULT 'ask';
|
|
ALTER TABLE conversations ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'ask';
|
|
ALTER TABLE tasks ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'execute';
|
|
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 }>).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' })
|
|
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] }
|
|
// Cross-project off: project B is never offered project A's entities, so it cannot claim them.
|
|
other.candidates = db.listSupervisionCandidates(other.request, { projectId: projects[1]!.id, crossProject: false })
|
|
expect(other.candidates).toEqual([])
|
|
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 }) }
|
|
})
|