Schema 57 rebuilds activity_history_records with order_seq (newer = larger;
a move to the front assigns a new maximum) and stored generated columns
(record_id, occurrence, status, status_group, kind, request_id,
conversation_id, project_id, created_at) with indexes. The order is migrated
from record_order_json exactly; legacy duplicates keep their occurrence and
only the first is shown. record_order_json is no longer written.
ActivityHistoryRepository owns the SQL: page({before, limit <= 500, filter,
projectId, conversationId}) newest first by index, summary() with the counts
and conversation titles/status the Activity page derived from the full list,
reconcile() joined with tasks with a renderer-localized detail line, update()
applied directly in SQL without reading the history, replace() for the
legacy migration, clear(). New activity-history:page/summary/reconcile IPC
with zod validation; page and summary run on the readonly worker.
perf:main measures 24,000 records: startup load 106 ms on Main (14 MB) ->
first page + summary 13.5 ms on the worker (0.6 ms loop block, 114 KB).
195 lines
10 KiB
TypeScript
195 lines
10 KiB
TypeScript
// @vitest-environment node
|
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { DatabaseSync } from 'node:sqlite'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { z } from 'zod'
|
|
import { activityHistorySnapshotSchema, type ActivityHistoryChange, type ActivityRecord } from '../../shared/assistant-contracts'
|
|
import { applyActivityChanges } from '../../shared/activity-history-reference'
|
|
import { AssistantDatabase } from './assistant-database'
|
|
import { parseActivityHistorySnapshot, planActivityHistoryReplace } from './activity-history-plan'
|
|
|
|
const directories: string[] = []
|
|
const opened: AssistantDatabase[] = []
|
|
afterEach(() => {
|
|
for (const database of opened.splice(0)) database.close()
|
|
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true })
|
|
})
|
|
|
|
function open(): { database: AssistantDatabase; raw: DatabaseSync; directory: string } {
|
|
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-activity-plan-'))
|
|
directories.push(directory)
|
|
const database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
|
database.initialize(directory)
|
|
opened.push(database)
|
|
return { database, raw: (database as unknown as { database: DatabaseSync }).database, directory }
|
|
}
|
|
|
|
/** The complete persisted state, independent of the read API. */
|
|
function storedState(raw: DatabaseSync): unknown {
|
|
return {
|
|
header: raw.prepare('SELECT legacy_history_may_be_incomplete FROM activity_history').all(),
|
|
// Sequence values may differ; their order may not.
|
|
rows: raw.prepare('SELECT record_key, record_json FROM activity_history_records ORDER BY order_seq DESC, record_key').all()
|
|
}
|
|
}
|
|
|
|
let counter = 0
|
|
function record(id: string, overrides: Partial<ActivityRecord> = {}): ActivityRecord {
|
|
counter += 1
|
|
return {
|
|
id, conversationId: 'conversation', requestId: `request-${id}`,
|
|
...(counter % 2 === 0 ? { callId: `call-${id}` } : {}),
|
|
scope: counter % 3 === 0 ? { kind: 'project', projectId: 'p', projectName: 'Project' } : { kind: 'global' },
|
|
kind: 'tool', title: `Tool ${id}`, detail: `detail ${counter}`, status: 'running', createdAt: counter,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
/** Reference semantics of a change, applied to a plain newest-first list. */
|
|
const applyReference = (list: ActivityRecord[], change: ActivityHistoryChange): ActivityRecord[] =>
|
|
applyActivityChanges(list, [change])
|
|
|
|
describe('activity history incremental updates', () => {
|
|
it('stores exactly the state a full replace stores, for random change sequences', () => {
|
|
let seed = 11
|
|
const random = (): number => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
|
|
const incremental = open()
|
|
const replaced = open()
|
|
// Legacy history may contain duplicate IDs.
|
|
let list = [record('a'), record('b'), record('a'), record('c'), record('d')]
|
|
let flag = true
|
|
incremental.database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: true })
|
|
for (let round = 0; round < 60; round++) {
|
|
const changes: ActivityHistoryChange[] = Array.from({ length: 1 + Math.floor(random() * 4) }, () => {
|
|
const id = 'abcdefgh'[Math.floor(random() * 8)]!
|
|
const roll = random()
|
|
if (roll < 0.15) return { type: 'remove', id }
|
|
return { type: 'upsert', position: roll < 0.6 ? 'front' : 'in-place',
|
|
record: record(id, { status: random() < 0.5 ? 'completed' : 'running' }) }
|
|
})
|
|
const legacyHistoryMayBeIncomplete = round % 7 === 0 ? round % 2 === 0 : undefined
|
|
for (const change of changes) list = applyReference(list, change)
|
|
flag = legacyHistoryMayBeIncomplete ?? flag
|
|
incremental.database.updateActivityHistory({ changes, ...(legacyHistoryMayBeIncomplete === undefined ? {} : { legacyHistoryMayBeIncomplete }) })
|
|
const expected = { records: list, legacyHistoryMayBeIncomplete: flag }
|
|
replaced.database.replaceActivityHistory(expected)
|
|
expect(incremental.database.getActivityHistory()).toEqual(expected)
|
|
expect(storedState(incremental.raw)).toEqual(storedState(replaced.raw))
|
|
}
|
|
// A fresh connection (no cache) reads the same state.
|
|
incremental.database.close()
|
|
incremental.database.initialize(incremental.directory)
|
|
expect(incremental.database.getActivityHistory().records).toEqual(list)
|
|
})
|
|
|
|
it('validates incremental changes and rolls back a failed batch', () => {
|
|
const { database, raw } = open()
|
|
database.replaceActivityHistory({ records: [record('a')], legacyHistoryMayBeIncomplete: false })
|
|
const before = storedState(raw)
|
|
expect(() => database.updateActivityHistory({ changes: [{ type: 'upsert', position: 'front', record: { ...record('b'), title: '' } }] }))
|
|
.toThrow(z.ZodError)
|
|
raw.exec(`CREATE TRIGGER reject_activity BEFORE INSERT ON activity_history_records
|
|
WHEN json_extract(NEW.record_json, '$.id') = 'z' BEGIN SELECT RAISE(ABORT, 'test failure'); END`)
|
|
expect(() => database.updateActivityHistory({ changes: [
|
|
{ type: 'upsert', position: 'front', record: record('y') },
|
|
{ type: 'upsert', position: 'front', record: record('z') }
|
|
] })).toThrow('test failure')
|
|
expect(storedState(raw)).toEqual(before)
|
|
raw.exec('DROP TRIGGER reject_activity')
|
|
const y = record('y')
|
|
database.updateActivityHistory({ changes: [{ type: 'upsert', position: 'front', record: y }] })
|
|
expect(database.getActivityHistory().records.map(item => item.id)).toEqual(['y', 'a'])
|
|
})
|
|
|
|
it('writes only the touched rows', () => {
|
|
const { database, raw } = open()
|
|
const records = Array.from({ length: 2_000 }, (_, index) => record(String(index)))
|
|
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
|
const changes = (): number => (raw.prepare('SELECT total_changes() AS n').get() as { n: number }).n
|
|
const start = changes()
|
|
const stringify = vi.spyOn(JSON, 'stringify')
|
|
database.updateActivityHistory({ changes: [{ type: 'upsert', position: 'front', record: { ...records[1_500]!, status: 'completed' } }] })
|
|
const calls = stringify.mock.calls.filter(([value]) => !Array.isArray(value)).length
|
|
stringify.mockRestore()
|
|
// One record row; no order list is rewritten.
|
|
expect(changes() - start).toBe(1)
|
|
expect(calls).toBeLessThan(10)
|
|
})
|
|
})
|
|
|
|
describe('activity history replace', () => {
|
|
it('does no writes, transactions or per-record serialization for 5,000 unchanged records', () => {
|
|
const { database, raw } = open()
|
|
const records = Array.from({ length: 5_000 }, (_, index) => record(String(index % 4_000)))
|
|
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: true })
|
|
// The renderer sends a fresh structured clone every time.
|
|
const clone = (): unknown => structuredClone({ records, legacyHistoryMayBeIncomplete: true })
|
|
const total = (): number => (raw.prepare('SELECT total_changes() AS n').get() as { n: number }).n
|
|
const start = total()
|
|
const exec = vi.spyOn(raw, 'exec')
|
|
const stringify = vi.spyOn(JSON, 'stringify')
|
|
const parse = vi.spyOn(activityHistorySnapshotSchema, 'parse')
|
|
database.replaceActivityHistory(clone())
|
|
// Record serialization only; [id, occurrence] keys are tiny.
|
|
const stringifyCalls = stringify.mock.calls.filter(([value]) => !Array.isArray(value)).length
|
|
stringify.mockRestore()
|
|
expect(exec).not.toHaveBeenCalled()
|
|
expect(parse).not.toHaveBeenCalled()
|
|
expect(stringifyCalls).toBeLessThan(10)
|
|
exec.mockRestore()
|
|
parse.mockRestore()
|
|
expect(total()).toBe(start)
|
|
|
|
// One changed record: one row write plus nothing else.
|
|
const changed = structuredClone(records)
|
|
changed[42]!.detail += ' changed'
|
|
database.replaceActivityHistory({ records: changed, legacyHistoryMayBeIncomplete: true })
|
|
expect(total() - start).toBe(1)
|
|
expect(database.getActivityHistory().records).toEqual(changed)
|
|
})
|
|
|
|
it('keeps the schema behavior: same errors, strictness and normalized output', () => {
|
|
const { database } = open()
|
|
const valid = [record('a'), record('b')]
|
|
database.replaceActivityHistory({ records: valid, legacyHistoryMayBeIncomplete: false })
|
|
const cases: unknown[] = [
|
|
{ records: [valid[0], { ...valid[1], extra: 1 }], legacyHistoryMayBeIncomplete: false },
|
|
{ records: [valid[0], { ...valid[1], createdAt: -1 }], legacyHistoryMayBeIncomplete: false },
|
|
{ records: [valid[0], { ...valid[1], scope: { kind: 'global', extra: true } }], legacyHistoryMayBeIncomplete: false },
|
|
{ records: valid, legacyHistoryMayBeIncomplete: false, extra: 1 },
|
|
{ records: valid },
|
|
{ records: 'x', legacyHistoryMayBeIncomplete: false },
|
|
[valid],
|
|
null
|
|
]
|
|
for (const input of cases) {
|
|
const expected = activityHistorySnapshotSchema.safeParse(input)
|
|
expect(expected.success).toBe(false)
|
|
let actual: unknown
|
|
try { database.replaceActivityHistory(input) } catch (error) { actual = error }
|
|
expect(actual).toBeInstanceOf(z.ZodError)
|
|
expect((actual as z.ZodError).issues).toEqual(expected.error!.issues)
|
|
}
|
|
expect(database.getActivityHistory().records).toEqual(valid)
|
|
// Key order and an explicit undefined optional key follow the schema.
|
|
const reordered = Object.fromEntries(Object.entries(valid[0]!).reverse())
|
|
database.replaceActivityHistory({ records: [reordered, { ...valid[1], callId: undefined }], legacyHistoryMayBeIncomplete: false })
|
|
expect(database.getActivityHistory()).toEqual(activityHistorySnapshotSchema.parse({ records: [reordered, { ...valid[1], callId: undefined }], legacyHistoryMayBeIncomplete: false }))
|
|
})
|
|
|
|
it('plans identical results with and without cached records', () => {
|
|
const records = [record('a'), record('b'), record('a')]
|
|
const empty = { entries: new Map() }
|
|
const first = planActivityHistoryReplace(parseActivityHistorySnapshot({ records, legacyHistoryMayBeIncomplete: false }).items, false, empty)
|
|
const reparsed = parseActivityHistorySnapshot(structuredClone({ records, legacyHistoryMayBeIncomplete: false }), first.next)
|
|
const keys = ['["a",0]', '["b",0]', '["a",1]']
|
|
expect(reparsed.items.every((item, index) => item.record === first.next.entries.get(keys[index]!)!.record)).toBe(true)
|
|
const second = planActivityHistoryReplace(reparsed.items, false, first.next)
|
|
expect(second.upserts).toEqual([])
|
|
expect(second.removes).toEqual([])
|
|
expect(second.headerChanged).toBe(false)
|
|
})
|
|
})
|