perf: read conversation lists in the readonly worker and diff activity history saves
conversations:list-summaries and conversations:get run on the readonly query worker. Activity history replace validates and serializes only changed records; a new activity-history:update IPC writes one record. Adds npm run perf:main.
This commit is contained in:
@@ -0,0 +1,251 @@
|
|||||||
|
// Main-process database benchmark (PERF-15). Bundles the production
|
||||||
|
// AssistantDatabase and readonly worker with esbuild, seeds a temporary
|
||||||
|
// assistant database (default 1,000 conversations / 20,000 messages and 5,000
|
||||||
|
// activity records) and measures, for the IPC paths the renderer hits often:
|
||||||
|
// - per-call time
|
||||||
|
// - Main event-loop delay while the calls run back to back
|
||||||
|
// for the previous synchronous path ("sync") and the current path ("after").
|
||||||
|
// conversations:list-summaries sync listConversationSummaries vs worker
|
||||||
|
// conversations:get sync getConversation vs worker
|
||||||
|
// activity-history:replace legacy replace (zod parse + BEGIN IMMEDIATE +
|
||||||
|
// JSON.stringify of every record) vs current
|
||||||
|
// activity-history:update one changed record via the incremental API
|
||||||
|
// Env: GB_MPERF_CONVERSATIONS, GB_MPERF_MESSAGES, GB_MPERF_ACTIVITY,
|
||||||
|
// GB_MPERF_CALLS, GB_MPERF_OUTPUT (JSON), GB_MPERF_NODE=1 (skip Electron).
|
||||||
|
const { buildSync } = require('esbuild')
|
||||||
|
const { mkdtempSync, rmSync, writeFileSync, mkdirSync } = require('node:fs')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const { join, resolve } = require('node:path')
|
||||||
|
const { randomUUID } = require('node:crypto')
|
||||||
|
const { monitorEventLoopDelay, performance } = require('node:perf_hooks')
|
||||||
|
|
||||||
|
const root = resolve(__dirname, '..')
|
||||||
|
|
||||||
|
if (process.env.GB_MPERF_NODE !== '1') {
|
||||||
|
let electron
|
||||||
|
let version
|
||||||
|
try { electron = require('electron'); version = require('electron/package.json').version } catch { electron = undefined }
|
||||||
|
if (typeof electron === 'string' && process.versions.electron !== version) {
|
||||||
|
const { status } = require('node:child_process').spawnSync(electron, [__filename], {
|
||||||
|
stdio: 'inherit', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
||||||
|
})
|
||||||
|
process.exit(status ?? 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationCount = Number(process.env.GB_MPERF_CONVERSATIONS || 1000)
|
||||||
|
const messageCount = Number(process.env.GB_MPERF_MESSAGES || 20000)
|
||||||
|
const activityCount = Number(process.env.GB_MPERF_ACTIVITY || 5000)
|
||||||
|
const calls = Number(process.env.GB_MPERF_CALLS || 15)
|
||||||
|
|
||||||
|
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-main-perf-'))
|
||||||
|
const bundle = join(directory, 'assistant-database.cjs')
|
||||||
|
const workerBundle = join(directory, 'readonly-query-worker.cjs')
|
||||||
|
const schemaBundle = join(directory, 'assistant-contracts.cjs')
|
||||||
|
for (const [entry, outfile] of [
|
||||||
|
['src/main/assistant/assistant-database.ts', bundle],
|
||||||
|
['src/main/readonly-query-worker.ts', workerBundle],
|
||||||
|
['src/shared/assistant-contracts.ts', schemaBundle]
|
||||||
|
]) {
|
||||||
|
buildSync({
|
||||||
|
entryPoints: [join(root, entry)], bundle: true, platform: 'node', format: 'cjs',
|
||||||
|
target: 'node22', outfile, logLevel: 'error', external: ['electron']
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { AssistantDatabase } = require(bundle)
|
||||||
|
const { activityHistorySnapshotSchema } = require(schemaBundle)
|
||||||
|
|
||||||
|
let seed = 42
|
||||||
|
const random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296)
|
||||||
|
const words = 'lighthouse harbor policy engine renderer stream vector index budget latency schedule archive network storage contract invoice release window memory review 发布 审批 流程'.split(' ')
|
||||||
|
const sentence = n => Array.from({ length: n }, () => words[Math.floor(random() * words.length)]).join(' ')
|
||||||
|
const round = value => Math.round(value * 10) / 10
|
||||||
|
const percentile = (values, p) => {
|
||||||
|
const sorted = [...values].sort((a, b) => a - b)
|
||||||
|
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1))]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `body` `calls` times (after one warm-up) and reports the per-call wall
|
||||||
|
* time and the Main event-loop delay histogram (p99/max) over the run.
|
||||||
|
*/
|
||||||
|
async function measure(body) {
|
||||||
|
await body(-1)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 50))
|
||||||
|
// monitorEventLoopDelay is bound to the ~15 ms Windows timer granularity, so
|
||||||
|
// the longest block is measured as the largest gap between setImmediate
|
||||||
|
// ticks (each tick is one event-loop turn).
|
||||||
|
const histogram = monitorEventLoopDelay({ resolution: 1 })
|
||||||
|
histogram.enable()
|
||||||
|
let probing = true
|
||||||
|
let last = performance.now()
|
||||||
|
let maxGap = 0
|
||||||
|
const tick = () => {
|
||||||
|
const now = performance.now()
|
||||||
|
maxGap = Math.max(maxGap, now - last)
|
||||||
|
last = now
|
||||||
|
if (probing) setImmediate(tick)
|
||||||
|
}
|
||||||
|
setImmediate(tick)
|
||||||
|
const samples = []
|
||||||
|
for (let index = 0; index < calls; index += 1) {
|
||||||
|
const started = performance.now()
|
||||||
|
await body(index)
|
||||||
|
samples.push(performance.now() - started)
|
||||||
|
// Yield like the IPC loop does between messages.
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20))
|
||||||
|
probing = false
|
||||||
|
histogram.disable()
|
||||||
|
return {
|
||||||
|
callP50: round(percentile(samples, 50)), callP95: round(percentile(samples, 95)),
|
||||||
|
loopP99: round(histogram.percentile(99) / 1e6), loopMax: round(maxGap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pre-PERF-15 replace implementation, kept as the "before" reference. */
|
||||||
|
function legacyReplace(raw, cache, input) {
|
||||||
|
const snapshot = activityHistorySnapshotSchema.parse(input)
|
||||||
|
raw.exec('BEGIN IMMEDIATE')
|
||||||
|
try {
|
||||||
|
const { data_version: dataVersion } = raw.prepare('PRAGMA data_version').get()
|
||||||
|
let previous = cache.value?.dataVersion === dataVersion ? cache.value.records : undefined
|
||||||
|
if (!previous) {
|
||||||
|
previous = new Map(raw.prepare('SELECT record_key, record_json FROM activity_history_records').all()
|
||||||
|
.map(row => [row.record_key, row.record_json]))
|
||||||
|
}
|
||||||
|
const records = new Map()
|
||||||
|
const occurrences = new Map()
|
||||||
|
const order = []
|
||||||
|
const upsert = raw.prepare(`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
||||||
|
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`)
|
||||||
|
for (const record of snapshot.records) {
|
||||||
|
const occurrence = occurrences.get(record.id) ?? 0
|
||||||
|
occurrences.set(record.id, occurrence + 1)
|
||||||
|
const key = JSON.stringify([record.id, occurrence])
|
||||||
|
const json = JSON.stringify(record)
|
||||||
|
order.push(key)
|
||||||
|
if (previous.get(key) !== json) upsert.run(key, json)
|
||||||
|
records.set(key, json)
|
||||||
|
}
|
||||||
|
const remove = raw.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||||
|
for (const key of previous.keys()) if (!records.has(key)) remove.run(key)
|
||||||
|
const orderJson = JSON.stringify(order)
|
||||||
|
const incomplete = Number(snapshot.legacyHistoryMayBeIncomplete)
|
||||||
|
raw.prepare(`UPDATE activity_history SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||||
|
WHERE singleton = 1 AND (record_order_json != ? OR legacy_history_may_be_incomplete != ?)`)
|
||||||
|
.run(orderJson, incomplete, orderJson, incomplete)
|
||||||
|
raw.exec('COMMIT')
|
||||||
|
cache.value = { records, dataVersion }
|
||||||
|
} catch (error) {
|
||||||
|
raw.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`[mperf] runtime: node ${process.version}${process.versions.electron ? ` (electron ${process.versions.electron})` : ''}`)
|
||||||
|
const workspace = join(directory, 'workspace')
|
||||||
|
mkdirSync(workspace)
|
||||||
|
const path = join(directory, 'assistant.sqlite')
|
||||||
|
const seeding = new AssistantDatabase(path)
|
||||||
|
seeding.initialize(workspace)
|
||||||
|
const seedStarted = performance.now()
|
||||||
|
const projectId = seeding.listProjects()[0].id
|
||||||
|
// Message counts follow a skewed distribution; the first conversation is the largest.
|
||||||
|
const weights = Array.from({ length: conversationCount }, (_, index) => 1 / (1 + index * 0.01))
|
||||||
|
const weightSum = weights.reduce((sum, value) => sum + value, 0)
|
||||||
|
const counts = weights.map(weight => Math.max(1, Math.round(messageCount * weight / weightSum)))
|
||||||
|
const ids = counts.map(() => randomUUID())
|
||||||
|
for (let start = 0; start < conversationCount; start += 50) {
|
||||||
|
seeding.saveLocalConversations(ids.slice(start, start + 50).map((id, offset) => {
|
||||||
|
const index = start + offset
|
||||||
|
return {
|
||||||
|
header: { id, projectId, title: `Conversation ${index} ${sentence(3)}`, updatedAt: 1_700_000_000_000 + index },
|
||||||
|
messages: Array.from({ length: counts[index] }, (_, at) => ({
|
||||||
|
id: randomUUID(), role: at % 2 === 0 ? 'user' : 'assistant', state: 'complete',
|
||||||
|
content: sentence(at % 2 === 0 ? 30 : 120), createdAt: 1_700_000_000_000 + index * 1000 + at
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
const records = Array.from({ length: activityCount }, (_, index) => ({
|
||||||
|
id: randomUUID(), conversationId: ids[index % conversationCount], requestId: randomUUID(),
|
||||||
|
...(index % 2 === 0 ? { callId: `call-${index}` } : {}),
|
||||||
|
scope: index % 3 === 0 ? { kind: 'project', projectId, projectName: 'Perf' } : { kind: 'global' },
|
||||||
|
kind: index % 5 === 0 ? 'request' : 'tool', title: `Tool ${index}`, detail: sentence(40),
|
||||||
|
status: 'completed', createdAt: 1_700_000_000_000 + index
|
||||||
|
}))
|
||||||
|
seeding.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||||
|
const seedSeconds = round((performance.now() - seedStarted) / 1000)
|
||||||
|
seeding.close()
|
||||||
|
|
||||||
|
const database = new AssistantDatabase(path)
|
||||||
|
database.initialize(workspace)
|
||||||
|
const raw = database.database
|
||||||
|
const totalMessages = counts.reduce((sum, value) => sum + value, 0)
|
||||||
|
const largest = ids[0]
|
||||||
|
const typical = ids[Math.floor(conversationCount / 2)]
|
||||||
|
// The renderer retains the current conversation's history.
|
||||||
|
const detailIds = [ids[1]]
|
||||||
|
const results = { conversations: conversationCount, messages: totalMessages, activityRecords: activityCount,
|
||||||
|
largestConversationMessages: counts[0], seedSeconds }
|
||||||
|
|
||||||
|
results.listSummariesSync = await measure(async () => database.listConversationSummaries(detailIds))
|
||||||
|
results.getLargestSync = await measure(async () => database.getConversation(largest))
|
||||||
|
results.getTypicalSync = await measure(async () => database.getConversation(typical))
|
||||||
|
database.enableReadonlyWorker(workerBundle)
|
||||||
|
results.listSummariesWorker = await measure(() => database.listConversationSummariesAsync(detailIds))
|
||||||
|
results.getLargestWorker = await measure(() => database.getConversationAsync(largest))
|
||||||
|
results.getTypicalWorker = await measure(() => database.getConversationAsync(typical))
|
||||||
|
|
||||||
|
// The renderer sends a fresh structured clone of the whole list every save.
|
||||||
|
const unchanged = () => structuredClone({ records, legacyHistoryMayBeIncomplete: false })
|
||||||
|
const oneChanged = index => {
|
||||||
|
const snapshot = unchanged()
|
||||||
|
const record = snapshot.records[(index + 2) % 50]
|
||||||
|
record.detail = `${record.detail} ${index}`
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
|
// Inputs are cloned up front: cloning happens in the renderer / IPC layer.
|
||||||
|
const run = async (makeInput, call) => {
|
||||||
|
const inputs = Array.from({ length: calls + 1 }, (_, index) => makeInput(index - 1))
|
||||||
|
return measure(async index => call(inputs[index + 1]))
|
||||||
|
}
|
||||||
|
const legacyCache = {}
|
||||||
|
results.replaceUnchangedLegacy = await run(unchanged, input => legacyReplace(raw, legacyCache, input))
|
||||||
|
results.replaceOneChangedLegacy = await run(oneChanged, input => legacyReplace(raw, legacyCache, input))
|
||||||
|
// Restore a consistent state for the current implementation's cache.
|
||||||
|
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
|
||||||
|
results.replaceUnchangedAfter = await run(unchanged, input => database.replaceActivityHistory(input))
|
||||||
|
results.replaceOneChangedAfter = await run(oneChanged, input => database.replaceActivityHistory(input))
|
||||||
|
results.updateOneRecord = await run(index => {
|
||||||
|
const record = { ...records[(index + 2) % 50], detail: `incremental ${index}`, status: 'running' }
|
||||||
|
return { changes: [{ type: 'upsert', position: 'front', record }] }
|
||||||
|
}, input => database.updateActivityHistory(input))
|
||||||
|
database.close()
|
||||||
|
|
||||||
|
console.log(`[mperf] seeded ${results.conversations} conversations / ${results.messages} messages (largest ${results.largestConversationMessages}), ${results.activityRecords} activity records in ${seedSeconds} s`)
|
||||||
|
const rows = [
|
||||||
|
['conversations:list-summaries', results.listSummariesSync, results.listSummariesWorker],
|
||||||
|
[`conversations:get (${counts[0]} msgs)`, results.getLargestSync, results.getLargestWorker],
|
||||||
|
[`conversations:get (${counts[Math.floor(conversationCount / 2)]} msgs)`, results.getTypicalSync, results.getTypicalWorker],
|
||||||
|
['activity replace, unchanged', results.replaceUnchangedLegacy, results.replaceUnchangedAfter],
|
||||||
|
['activity replace, 1 changed', results.replaceOneChangedLegacy, results.replaceOneChangedAfter],
|
||||||
|
['activity update, 1 record', results.replaceOneChangedLegacy, results.updateOneRecord]
|
||||||
|
]
|
||||||
|
console.table(rows.map(([name, before, after]) => ({
|
||||||
|
path: name,
|
||||||
|
'before call p50/p95 ms': `${before.callP50} / ${before.callP95}`,
|
||||||
|
'before loop max ms': before.loopMax,
|
||||||
|
'after call p50/p95 ms': `${after.callP50} / ${after.callP95}`,
|
||||||
|
'after loop max ms': after.loopMax
|
||||||
|
})))
|
||||||
|
const output = process.env.GB_MPERF_OUTPUT
|
||||||
|
if (output) writeFileSync(output, JSON.stringify({ kind: 'goodbuddy-main-database-benchmark', node: process.version, results }, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => { process.exitCode = 1; console.error(error) }).finally(() => {
|
||||||
|
rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||||
|
})
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
"perf:app": "node build/run-app-perf.cjs",
|
"perf:app": "node build/run-app-perf.cjs",
|
||||||
"perf:check": "node build/check-perf-thresholds.cjs",
|
"perf:check": "node build/check-perf-thresholds.cjs",
|
||||||
"perf:knowledge": "node build/run-knowledge-perf.cjs",
|
"perf:knowledge": "node build/run-knowledge-perf.cjs",
|
||||||
|
"perf:main": "node build/run-main-perf.cjs",
|
||||||
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
|
"eval:retrieval": "vitest run --config tests/support/knowledge-retrieval-evaluation.ts tests/knowledge-retrieval-metrics.test.ts tests/knowledge-retrieval-evaluation.test.ts",
|
||||||
"build": "npm run typecheck && npm run build:bundle",
|
"build": "npm run typecheck && npm run build:bundle",
|
||||||
"build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs",
|
"build:bundle": "electron-vite build && node build/check-control-plane-package-installer.cjs",
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// @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 { AssistantDatabase } from './assistant-database'
|
||||||
|
import { parseActivityHistorySnapshot, planActivityHistoryReplace, planActivityHistoryUpdate } 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 record_order_json, legacy_history_may_be_incomplete FROM activity_history').all(),
|
||||||
|
rows: raw.prepare('SELECT record_key, record_json FROM activity_history_records ORDER BY 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. */
|
||||||
|
function applyReference(list: ActivityRecord[], change: ActivityHistoryChange): ActivityRecord[] {
|
||||||
|
if (change.type === 'remove') return list.filter(item => item.id !== change.id)
|
||||||
|
const index = list.findIndex(item => item.id === change.record.id)
|
||||||
|
if (index < 0) return [change.record, ...list]
|
||||||
|
if (change.position === 'front') return [change.record, ...list.filter((_, at) => at !== index)]
|
||||||
|
return list.map((item, at) => (at === index ? change.record : item))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 plus the order row.
|
||||||
|
expect(changes() - start).toBe(2)
|
||||||
|
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)
|
||||||
|
expect(reparsed.items.every((item, index) => item.record === first.next.entries.get([...first.next.order][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)
|
||||||
|
expect(() => planActivityHistoryUpdate({ changes: [] }, { entries: new Map() })).toThrow('unreadable')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
activityHistorySnapshotSchema,
|
||||||
|
activityHistoryUpdateSchema,
|
||||||
|
activityRecordSchema,
|
||||||
|
type ActivityRecord
|
||||||
|
} from '../../shared/assistant-contracts'
|
||||||
|
|
||||||
|
// PERF-15: activity history persistence planning. The renderer sends the whole
|
||||||
|
// newest-first list every change; most records are unchanged. These pure
|
||||||
|
// helpers validate and diff against the last committed state without
|
||||||
|
// re-validating or re-serializing unchanged records, and turn incremental
|
||||||
|
// changes into the same stored rows that `replace` would produce.
|
||||||
|
|
||||||
|
/** A stored row; `record` is a validated, privately owned copy when known. */
|
||||||
|
export type ActivityHistoryEntry = { json: string; record?: ActivityRecord }
|
||||||
|
|
||||||
|
export type ActivityHistoryState = {
|
||||||
|
/** Every row in activity_history_records, keyed by record_key. */
|
||||||
|
entries: ReadonlyMap<string, ActivityHistoryEntry>
|
||||||
|
/** Stored order; undefined when unknown (always rewritten). */
|
||||||
|
order?: readonly string[]
|
||||||
|
orderJson?: string
|
||||||
|
incomplete?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityHistoryPlan = {
|
||||||
|
next: ActivityHistoryState & { order: readonly string[]; orderJson: string; incomplete: boolean }
|
||||||
|
upserts: Array<[key: string, json: string]>
|
||||||
|
removes: string[]
|
||||||
|
/** Order or legacy flag changed: the singleton row must be updated. */
|
||||||
|
headerChanged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedItem = { record: ActivityRecord; json?: string; key?: string }
|
||||||
|
|
||||||
|
/** IDs were never unique in the snapshot schema; every occurrence is kept. */
|
||||||
|
export const activityRecordKey = (id: string, occurrence: number): string => JSON.stringify([id, occurrence])
|
||||||
|
|
||||||
|
export const isActivityHistoryPlanEmpty = (plan: ActivityHistoryPlan): boolean =>
|
||||||
|
plan.upserts.length === 0 && plan.removes.length === 0 && !plan.headerChanged
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||||
|
const prototype = Object.getPrototypeOf(value) as unknown
|
||||||
|
return prototype === Object.prototype || prototype === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact structural equality against a validated record (same own keys, same values). */
|
||||||
|
function sameValue(raw: unknown, validated: unknown): boolean {
|
||||||
|
if (raw === validated) return true
|
||||||
|
if (!isPlainObject(validated) || !isPlainObject(raw)) return false
|
||||||
|
const keys = Object.keys(validated)
|
||||||
|
if (Object.keys(raw).length !== keys.length) return false
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!Object.hasOwn(raw, key) || !sameValue(raw[key], validated[key])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOrder(left: readonly string[], right: readonly string[] | undefined): boolean {
|
||||||
|
if (!right || left.length !== right.length) return false
|
||||||
|
for (let index = 0; index < left.length; index++) if (left[index] !== right[index]) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a replace snapshot with the same accept/reject behavior and errors
|
||||||
|
* as `activityHistorySnapshotSchema.parse`, reusing previously validated
|
||||||
|
* records (and their serialized form) that are structurally identical.
|
||||||
|
*/
|
||||||
|
export function parseActivityHistorySnapshot(
|
||||||
|
input: unknown,
|
||||||
|
previous?: ActivityHistoryState
|
||||||
|
): { items: ParsedItem[]; incomplete: boolean } {
|
||||||
|
let raw: { records: readonly unknown[]; legacyHistoryMayBeIncomplete: boolean }
|
||||||
|
if (
|
||||||
|
isPlainObject(input) && Object.keys(input).length === 2 &&
|
||||||
|
Array.isArray(input.records) && typeof input.legacyHistoryMayBeIncomplete === 'boolean'
|
||||||
|
) {
|
||||||
|
raw = input as typeof raw
|
||||||
|
} else {
|
||||||
|
raw = activityHistorySnapshotSchema.parse(input)
|
||||||
|
}
|
||||||
|
const occurrences = new Map<string, number>()
|
||||||
|
const items: ParsedItem[] = new Array(raw.records.length)
|
||||||
|
for (let index = 0; index < raw.records.length; index++) {
|
||||||
|
const candidate = raw.records[index]
|
||||||
|
const id = isPlainObject(candidate) ? candidate.id : undefined
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
const occurrence = occurrences.get(id) ?? 0
|
||||||
|
const key = activityRecordKey(id, occurrence)
|
||||||
|
const entry = previous?.entries.get(key)
|
||||||
|
if (entry?.record && sameValue(candidate, entry.record)) {
|
||||||
|
occurrences.set(id, occurrence + 1)
|
||||||
|
items[index] = { record: entry.record, json: entry.json, key }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parsed = activityRecordSchema.safeParse(candidate)
|
||||||
|
// Throw exactly the error (paths included) the whole-snapshot schema throws.
|
||||||
|
if (!parsed.success) activityHistorySnapshotSchema.parse(input)
|
||||||
|
const record = parsed.success ? parsed.data : activityRecordSchema.parse(candidate)
|
||||||
|
occurrences.set(record.id, (occurrences.get(record.id) ?? 0) + 1)
|
||||||
|
items[index] = { record }
|
||||||
|
}
|
||||||
|
return { items, incomplete: raw.legacyHistoryMayBeIncomplete }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Diffs a full newest-first list against the committed state. */
|
||||||
|
export function planActivityHistoryReplace(
|
||||||
|
items: readonly ParsedItem[],
|
||||||
|
incomplete: boolean,
|
||||||
|
previous: ActivityHistoryState,
|
||||||
|
onProgress?: (processed: number) => void
|
||||||
|
): ActivityHistoryPlan {
|
||||||
|
const entries = new Map<string, ActivityHistoryEntry>()
|
||||||
|
const occurrences = new Map<string, number>()
|
||||||
|
const order: string[] = new Array(items.length)
|
||||||
|
const upserts: Array<[string, string]> = []
|
||||||
|
for (let index = 0; index < items.length; index++) {
|
||||||
|
if (index % 128 === 0) onProgress?.(index)
|
||||||
|
const { record } = items[index]!
|
||||||
|
const occurrence = occurrences.get(record.id) ?? 0
|
||||||
|
occurrences.set(record.id, occurrence + 1)
|
||||||
|
const key = items[index]!.key ?? activityRecordKey(record.id, occurrence)
|
||||||
|
const json = items[index]!.json ?? JSON.stringify(record)
|
||||||
|
order[index] = key
|
||||||
|
if (previous.entries.get(key)?.json !== json) upserts.push([key, json])
|
||||||
|
entries.set(key, { json, record })
|
||||||
|
}
|
||||||
|
const removes: string[] = []
|
||||||
|
for (const key of previous.entries.keys()) if (!entries.has(key)) removes.push(key)
|
||||||
|
return finish(entries, order, incomplete, previous, upserts, removes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(
|
||||||
|
entries: Map<string, ActivityHistoryEntry>,
|
||||||
|
order: string[],
|
||||||
|
incomplete: boolean,
|
||||||
|
previous: ActivityHistoryState,
|
||||||
|
upserts: Array<[string, string]>,
|
||||||
|
removes: string[]
|
||||||
|
): ActivityHistoryPlan {
|
||||||
|
const orderUnchanged = sameOrder(order, previous.order) && previous.orderJson !== undefined
|
||||||
|
const orderJson = orderUnchanged ? previous.orderJson! : JSON.stringify(order)
|
||||||
|
return {
|
||||||
|
next: { entries, order, orderJson, incomplete },
|
||||||
|
upserts,
|
||||||
|
removes,
|
||||||
|
headerChanged: !orderUnchanged || previous.incomplete !== incomplete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies incremental changes (see `activityHistoryChangeSchema`). Only the
|
||||||
|
* changed records are validated and serialized; the result is identical to
|
||||||
|
* `planActivityHistoryReplace` with the resulting list.
|
||||||
|
*/
|
||||||
|
export function planActivityHistoryUpdate(input: unknown, previous: ActivityHistoryState): ActivityHistoryPlan {
|
||||||
|
const update = activityHistoryUpdateSchema.parse(input)
|
||||||
|
if (!previous.order) throw new Error('Activity history order is unreadable; save the full history instead')
|
||||||
|
const entries = new Map(previous.entries)
|
||||||
|
let order = [...previous.order]
|
||||||
|
const touched = new Set<string>()
|
||||||
|
for (const change of update.changes) {
|
||||||
|
if (change.type === 'remove') {
|
||||||
|
// Keys of one ID share the JSON prefix `["id",`.
|
||||||
|
const prefix = `${JSON.stringify([change.id]).slice(0, -1)},`
|
||||||
|
const removed = order.filter(key => key.startsWith(prefix))
|
||||||
|
if (removed.length > 0) order = order.filter(key => !key.startsWith(prefix))
|
||||||
|
for (const key of removed) { entries.delete(key); touched.add(key) }
|
||||||
|
for (let occurrence = 0; ; occurrence++) {
|
||||||
|
const key = activityRecordKey(change.id, occurrence)
|
||||||
|
if (!entries.delete(key)) break
|
||||||
|
touched.add(key)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { record } = change
|
||||||
|
// The first occurrence stays first when moved to the front, so its key is stable.
|
||||||
|
const key = activityRecordKey(record.id, 0)
|
||||||
|
const index = order.indexOf(key)
|
||||||
|
if (index < 0) order.unshift(key)
|
||||||
|
else if (change.position === 'front' && index > 0) {
|
||||||
|
order.splice(index, 1)
|
||||||
|
order.unshift(key)
|
||||||
|
}
|
||||||
|
entries.set(key, { json: JSON.stringify(record), record })
|
||||||
|
touched.add(key)
|
||||||
|
}
|
||||||
|
const upserts: Array<[string, string]> = []
|
||||||
|
const removes: string[] = []
|
||||||
|
for (const key of touched) {
|
||||||
|
const entry = entries.get(key)
|
||||||
|
if (!entry) {
|
||||||
|
if (previous.entries.has(key)) removes.push(key)
|
||||||
|
} else if (previous.entries.get(key)?.json !== entry.json) {
|
||||||
|
upserts.push([key, entry.json])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finish(entries, order, update.legacyHistoryMayBeIncomplete ?? previous.incomplete ?? false,
|
||||||
|
previous, upserts, removes)
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ import { appendConversationQuestionBlock } from '../../shared/conversation-quest
|
|||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||||
|
import {
|
||||||
|
isActivityHistoryPlanEmpty,
|
||||||
|
parseActivityHistorySnapshot,
|
||||||
|
planActivityHistoryReplace,
|
||||||
|
planActivityHistoryUpdate,
|
||||||
|
type ActivityHistoryPlan,
|
||||||
|
type ActivityHistoryState
|
||||||
|
} from './activity-history-plan'
|
||||||
import { statSync } from 'node:fs'
|
import { statSync } from 'node:fs'
|
||||||
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
||||||
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
||||||
@@ -1963,7 +1971,7 @@ export class AssistantDatabase {
|
|||||||
private static readonly executionStatsCacheTtlMs = 30_000
|
private static readonly executionStatsCacheTtlMs = 30_000
|
||||||
private static readonly executionStatsCacheLimit = 8
|
private static readonly executionStatsCacheLimit = 8
|
||||||
private database?: DatabaseSync
|
private database?: DatabaseSync
|
||||||
private activityHistoryCache?: { records: Map<string, string>; dataVersion: number }
|
private activityHistoryCache?: { state: ActivityHistoryState; dataVersion: number }
|
||||||
private executionStatsReader?: ExecutionStatsReader
|
private executionStatsReader?: ExecutionStatsReader
|
||||||
private readonlyReader?: ReadonlyQueryReader
|
private readonlyReader?: ReadonlyQueryReader
|
||||||
private readonlyWorkerPath?: string
|
private readonlyWorkerPath?: string
|
||||||
@@ -3008,6 +3016,25 @@ export class AssistantDatabase {
|
|||||||
return this.readConversationList(new Set(detailIds))
|
return this.readConversationList(new Set(detailIds))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversation summaries on the readonly worker (PERF-15). Every write runs
|
||||||
|
* synchronously on Main, so once no transaction is open, everything written
|
||||||
|
* before this call is committed and visible to the worker. Inside an open
|
||||||
|
* transaction the synchronous path keeps read-your-writes semantics.
|
||||||
|
*/
|
||||||
|
listConversationSummariesAsync(detailIds: string[] = [], signal?: AbortSignal): Promise<import('../../shared/assistant-contracts').ConversationListSnapshot[]> {
|
||||||
|
const database = this.requireDatabase()
|
||||||
|
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||||
|
'listConversationSummaries', [detailIds], signal, () => this.listConversationSummaries(detailIds))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full conversation history on the readonly worker; same rules as listConversationSummariesAsync. */
|
||||||
|
getConversationAsync(conversationId: string, signal?: AbortSignal): Promise<ConversationSnapshot> {
|
||||||
|
const database = this.requireDatabase()
|
||||||
|
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||||
|
'getConversation', [conversationId], signal, () => this.getConversation(conversationId))
|
||||||
|
}
|
||||||
|
|
||||||
private listConversationRows(): ConversationRow[] {
|
private listConversationRows(): ConversationRow[] {
|
||||||
const database = this.requireDatabase()
|
const database = this.requireDatabase()
|
||||||
return database
|
return database
|
||||||
@@ -4969,6 +4996,25 @@ export class AssistantDatabase {
|
|||||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs multi-statement reads against one WAL snapshot. Used by the readonly
|
||||||
|
* worker, where Main may commit between statements; on Main itself the
|
||||||
|
* event loop already serializes reads and writes.
|
||||||
|
*/
|
||||||
|
readSnapshot<T>(read: () => T): T {
|
||||||
|
const database = this.requireDatabase()
|
||||||
|
if (database.isTransaction) return read()
|
||||||
|
database.exec('BEGIN')
|
||||||
|
try {
|
||||||
|
const result = read()
|
||||||
|
database.exec('COMMIT')
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
database.exec('ROLLBACK')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes heavy read-only queries (story graph, conversation search) through a
|
* Routes heavy read-only queries (story graph, conversation search) through a
|
||||||
* read-only worker thread. In-memory databases keep the synchronous path.
|
* read-only worker thread. In-memory databases keep the synchronous path.
|
||||||
@@ -5255,67 +5301,108 @@ export class AssistantDatabase {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceActivityHistory(input: ActivityHistorySnapshot): void {
|
/**
|
||||||
const snapshot = activityHistorySnapshotSchema.parse(input)
|
* Persists the whole newest-first list. Unchanged records are neither
|
||||||
|
* re-validated, re-serialized nor written; a fully unchanged snapshot does
|
||||||
|
* not open a write transaction (PERF-15).
|
||||||
|
*/
|
||||||
|
replaceActivityHistory(input: unknown): void {
|
||||||
const database = this.requireDatabase()
|
const database = this.requireDatabase()
|
||||||
|
// Reuse is safe even from a stale cache: cached records are validated copies.
|
||||||
|
const { items, incomplete } = parseActivityHistorySnapshot(input, this.activityHistoryCache?.state)
|
||||||
|
const committed = this.committedActivityHistoryState(database)
|
||||||
|
const planned = committed && planActivityHistoryReplace(items, incomplete, committed)
|
||||||
|
if (planned && isActivityHistoryPlanEmpty(planned)) return
|
||||||
|
this.writeActivityHistoryPlan(database, (previous) => previous === committed && planned
|
||||||
|
? planned : planActivityHistoryReplace(items, incomplete, previous))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies incremental changes in one transaction (PERF-15). */
|
||||||
|
updateActivityHistory(input: unknown): void {
|
||||||
|
const database = this.requireDatabase()
|
||||||
|
this.writeActivityHistoryPlan(database, (previous) => planActivityHistoryUpdate(input, previous))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cached committed state, when no other connection committed since. */
|
||||||
|
private committedActivityHistoryState(database: DatabaseSync): ActivityHistoryState | undefined {
|
||||||
|
const cache = this.activityHistoryCache
|
||||||
|
if (!cache || database.isTransaction) return undefined
|
||||||
|
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
||||||
|
return cache.dataVersion === dataVersion ? cache.state : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeActivityHistoryPlan(
|
||||||
|
database: DatabaseSync,
|
||||||
|
plan: (previous: ActivityHistoryState) => ActivityHistoryPlan
|
||||||
|
): void {
|
||||||
database.exec('BEGIN IMMEDIATE')
|
database.exec('BEGIN IMMEDIATE')
|
||||||
try {
|
try {
|
||||||
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
const { data_version: dataVersion } = database.prepare('PRAGMA data_version').get() as { data_version: number }
|
||||||
const previous = this.activityHistoryCache?.dataVersion === dataVersion
|
const previous = this.activityHistoryCache?.dataVersion === dataVersion
|
||||||
? this.activityHistoryCache.records : undefined
|
? this.activityHistoryCache.state : this.readActivityHistoryState(database)
|
||||||
const records = this.writeActivityHistory(database, snapshot, undefined, previous)
|
const result = plan(previous)
|
||||||
|
this.applyActivityHistoryPlan(database, result)
|
||||||
database.exec('COMMIT')
|
database.exec('COMMIT')
|
||||||
// Publish only committed records; a failed save must remain retryable.
|
// Publish only committed records; a failed save must remain retryable.
|
||||||
this.activityHistoryCache = { records, dataVersion }
|
this.activityHistoryCache = { state: result.next, dataVersion }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
database.exec('ROLLBACK')
|
database.exec('ROLLBACK')
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private writeActivityHistory(
|
private readActivityHistoryState(database: DatabaseSync): ActivityHistoryState {
|
||||||
database: DatabaseSync,
|
|
||||||
snapshot: ActivityHistorySnapshot,
|
|
||||||
onProgress?: (processed: number) => void,
|
|
||||||
previous?: ReadonlyMap<string, string>
|
|
||||||
): Map<string, string> {
|
|
||||||
if (!previous) {
|
|
||||||
const rows = database.prepare(
|
const rows = database.prepare(
|
||||||
'SELECT record_key, record_json FROM activity_history_records'
|
'SELECT record_key, record_json FROM activity_history_records'
|
||||||
).all() as Array<{ record_key: string; record_json: string }>
|
).all() as Array<{ record_key: string; record_json: string }>
|
||||||
previous = new Map(rows.map((row) => [row.record_key, row.record_json]))
|
const header = database.prepare(
|
||||||
|
'SELECT record_order_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1'
|
||||||
|
).get() as { record_order_json: string; legacy_history_may_be_incomplete: number } | undefined
|
||||||
|
const order = header ? JSON.parse(header.record_order_json) as unknown : undefined
|
||||||
|
return {
|
||||||
|
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json }])),
|
||||||
|
...(Array.isArray(order) && order.every((key) => typeof key === 'string')
|
||||||
|
? { order, orderJson: header!.record_order_json } : {}),
|
||||||
|
...(header ? { incomplete: header.legacy_history_may_be_incomplete === 1 } : {})
|
||||||
}
|
}
|
||||||
const records = new Map<string, string>()
|
}
|
||||||
const occurrences = new Map<string, number>()
|
|
||||||
const order: string[] = []
|
private applyActivityHistoryPlan(database: DatabaseSync, plan: ActivityHistoryPlan): void {
|
||||||
|
if (plan.upserts.length > 0) {
|
||||||
const upsert = database.prepare(
|
const upsert = database.prepare(
|
||||||
`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
`INSERT INTO activity_history_records (record_key, record_json) VALUES (?, ?)
|
||||||
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`
|
ON CONFLICT(record_key) DO UPDATE SET record_json = excluded.record_json`
|
||||||
)
|
)
|
||||||
for (const record of snapshot.records) {
|
for (const [key, json] of plan.upserts) upsert.run(key, json)
|
||||||
if (order.length % 128 === 0) onProgress?.(order.length)
|
|
||||||
// IDs were never unique in the snapshot schema; retain every occurrence.
|
|
||||||
const occurrence = occurrences.get(record.id) ?? 0
|
|
||||||
occurrences.set(record.id, occurrence + 1)
|
|
||||||
const key = JSON.stringify([record.id, occurrence])
|
|
||||||
const json = JSON.stringify(record)
|
|
||||||
order.push(key)
|
|
||||||
if (previous.get(key) !== json) upsert.run(key, json)
|
|
||||||
records.set(key, json)
|
|
||||||
}
|
}
|
||||||
|
if (plan.removes.length > 0) {
|
||||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_key = ?')
|
||||||
for (const key of previous.keys()) {
|
for (const key of plan.removes) remove.run(key)
|
||||||
if (!records.has(key)) remove.run(key)
|
|
||||||
}
|
}
|
||||||
const orderJson = JSON.stringify(order)
|
if (plan.headerChanged) {
|
||||||
const incomplete = Number(snapshot.legacyHistoryMayBeIncomplete)
|
|
||||||
database.prepare(
|
database.prepare(
|
||||||
`UPDATE activity_history
|
`UPDATE activity_history
|
||||||
SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
SET record_order_json = ?, legacy_history_may_be_incomplete = ?
|
||||||
WHERE singleton = 1
|
WHERE singleton = 1`
|
||||||
AND (record_order_json != ? OR legacy_history_may_be_incomplete != ?)`
|
).run(plan.next.orderJson, Number(plan.next.incomplete))
|
||||||
).run(orderJson, incomplete, orderJson, incomplete)
|
}
|
||||||
return records
|
}
|
||||||
|
|
||||||
|
/** Schema-44 migration: writes a parsed legacy snapshot into the new tables. */
|
||||||
|
private writeActivityHistory(
|
||||||
|
database: DatabaseSync,
|
||||||
|
snapshot: ActivityHistorySnapshot,
|
||||||
|
onProgress?: (processed: number) => void
|
||||||
|
): void {
|
||||||
|
const rows = database.prepare(
|
||||||
|
'SELECT record_key, record_json FROM activity_history_records'
|
||||||
|
).all() as Array<{ record_key: string; record_json: string }>
|
||||||
|
// The order column still holds the legacy records; always rewrite it.
|
||||||
|
const previous: ActivityHistoryState = {
|
||||||
|
entries: new Map(rows.map((row) => [row.record_key, { json: row.record_json }]))
|
||||||
|
}
|
||||||
|
this.applyActivityHistoryPlan(database, planActivityHistoryReplace(
|
||||||
|
snapshot.records.map((record) => ({ record })), snapshot.legacyHistoryMayBeIncomplete, previous, onProgress))
|
||||||
}
|
}
|
||||||
|
|
||||||
createTask(input: {
|
createTask(input: {
|
||||||
|
|||||||
@@ -5404,6 +5404,9 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
),
|
),
|
||||||
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
|
listConversations: vi.fn<() => ConversationSnapshot[]>(() => []),
|
||||||
listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []),
|
listConversationSummaries: vi.fn<(ids?: string[]) => ConversationSnapshot[]>(() => []),
|
||||||
|
// The readonly-worker variants delegate to the synchronous mocks.
|
||||||
|
listConversationSummariesAsync: vi.fn(async (ids?: string[]) => assistantDatabase.listConversationSummaries(ids)),
|
||||||
|
getConversationAsync: vi.fn(async (conversationId: string) => assistantDatabase.getConversation(conversationId)),
|
||||||
listRecoverableRemoteTasks: vi.fn<
|
listRecoverableRemoteTasks: vi.fn<
|
||||||
() => Array<{
|
() => Array<{
|
||||||
taskId: string
|
taskId: string
|
||||||
@@ -8221,10 +8224,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
|||||||
harness.recoveryGetHandler?.(event)
|
harness.recoveryGetHandler?.(event)
|
||||||
await vi.waitFor(() => expect(ready.size).toBe(2))
|
await vi.waitFor(() => expect(ready.size).toBe(2))
|
||||||
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
|
await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true))
|
||||||
const summaries = electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
|
const summaries = await electronMocks.handlers.get(ipcChannels.conversationsListSummaries)!(event, { detailIds: [] }) as ConversationSnapshot[]
|
||||||
expect(summaries).toEqual(list())
|
expect(summaries).toEqual(list())
|
||||||
for (const snapshot of summaries) {
|
for (const snapshot of summaries) {
|
||||||
expect(electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot)
|
expect(await electronMocks.handlers.get(ipcChannels.conversationsGet)!(event, snapshot.id)).toEqual(snapshot)
|
||||||
}
|
}
|
||||||
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled()
|
||||||
const update = settingsKind === 'runtime'
|
const update = settingsKind === 'runtime'
|
||||||
|
|||||||
+15
-7
@@ -132,7 +132,6 @@ import { registerMagicNotesIpcHandlers, registerMagicTodosIpcHandlers } from './
|
|||||||
import {
|
import {
|
||||||
assistantIdSchema,
|
assistantIdSchema,
|
||||||
executionStatsInputSchema,
|
executionStatsInputSchema,
|
||||||
activityHistorySnapshotSchema,
|
|
||||||
conversationBranchInputSchema,
|
conversationBranchInputSchema,
|
||||||
conversationSnapshotsSchema,
|
conversationSnapshotsSchema,
|
||||||
conversationListRequestSchema,
|
conversationListRequestSchema,
|
||||||
@@ -6329,16 +6328,19 @@ export function registerIpcHandlers(
|
|||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
return projectConversationRequests(assistantDatabase.listConversations())
|
return projectConversationRequests(assistantDatabase.listConversations())
|
||||||
})
|
})
|
||||||
registerHandler(ipcChannels.conversationsListSummaries, (event, input: unknown) => {
|
// PERF-15: both reads run on the readonly worker. All writes are synchronous
|
||||||
|
// on Main, so a read issued after a write observes it; request projection
|
||||||
|
// uses the in-memory state current when the result arrives.
|
||||||
|
registerHandler(ipcChannels.conversationsListSummaries, async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
const { detailIds } = conversationListRequestSchema.parse(input)
|
const { detailIds } = conversationListRequestSchema.parse(input)
|
||||||
return projectConversationRequests(assistantDatabase.listConversationSummaries([
|
return projectConversationRequests(await assistantDatabase.listConversationSummariesAsync([
|
||||||
...detailIds, ...[...activeRequests.values()].map(request => request.conversationId)
|
...detailIds, ...[...activeRequests.values()].map(request => request.conversationId)
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
registerHandler(ipcChannels.conversationsGet, (event, input: unknown) => {
|
registerHandler(ipcChannels.conversationsGet, async (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
return projectConversationRequests([assistantDatabase.getConversation(assistantIdSchema.parse(input))])[0]
|
return projectConversationRequests([await assistantDatabase.getConversationAsync(assistantIdSchema.parse(input))])[0]
|
||||||
})
|
})
|
||||||
registerHandler(ipcChannels.conversationsSearch, (event, input: unknown) => {
|
registerHandler(ipcChannels.conversationsSearch, (event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
@@ -6830,9 +6832,15 @@ export function registerIpcHandlers(
|
|||||||
ipcChannels.activityHistoryReplace,
|
ipcChannels.activityHistoryReplace,
|
||||||
(event, input: unknown) => {
|
(event, input: unknown) => {
|
||||||
assertTrustedSender(event, window)
|
assertTrustedSender(event, window)
|
||||||
assistantDatabase.replaceActivityHistory(
|
// Validation happens inside, reusing already validated unchanged records.
|
||||||
activityHistorySnapshotSchema.parse(input)
|
assistantDatabase.replaceActivityHistory(input)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
registerHandler(
|
||||||
|
ipcChannels.activityHistoryUpdate,
|
||||||
|
(event, input: unknown) => {
|
||||||
|
assertTrustedSender(event, window)
|
||||||
|
assistantDatabase.updateActivityHistory(input)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,59 @@ describe('assistant readonly worker', () => {
|
|||||||
} finally { database.close() }
|
} finally { database.close() }
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|
||||||
|
it('serves conversation summaries and full history from the worker with the synchronous results', async () => {
|
||||||
|
const root = await mkdtemp(join(directory, 'assistant-'))
|
||||||
|
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||||
|
database.initialize(root)
|
||||||
|
try {
|
||||||
|
const project = database.listProjects()[0]!
|
||||||
|
const message = (content: string, createdAt: number, state: 'complete' | 'streaming' = 'complete', role: 'user' | 'assistant' = 'assistant') =>
|
||||||
|
({ id: randomUUID(), role, state, content, createdAt })
|
||||||
|
const conversations = Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
id: randomUUID(), projectId: index % 2 === 0 ? project.id : undefined, title: `Conversation ${index}`,
|
||||||
|
updatedAt: 100 - index, pinned: index === 7,
|
||||||
|
messages: index === 0 ? [] : Array.from({ length: index }, (_, at) =>
|
||||||
|
message(`m${index}-${at} 跨平台`, index * 10 + at, index === 5 && at === index - 1 ? 'streaming' : 'complete', at === 0 ? 'user' : 'assistant'))
|
||||||
|
}))
|
||||||
|
database.replaceConversations(conversations)
|
||||||
|
const detailSets = [[], [conversations[3]!.id], conversations.map(item => item.id), [randomUUID()]]
|
||||||
|
const expected = detailSets.map(ids => database.listConversationSummaries(ids))
|
||||||
|
const expectedFull = conversations.map(item => database.getConversation(item.id))
|
||||||
|
// Summaries without details carry messageSummary; retained/streaming ones carry messages.
|
||||||
|
expect(expected[0]!.find(item => item.id === conversations[4]!.id)?.messageSummary).toEqual({ count: 4, firstRole: 'user', latestMessageAt: 43 })
|
||||||
|
expect(expected[0]!.find(item => item.id === conversations[5]!.id)?.messages).toHaveLength(5)
|
||||||
|
expect(expected[1]!.find(item => item.id === conversations[3]!.id)).toEqual(expectedFull[3])
|
||||||
|
|
||||||
|
database.enableReadonlyWorker(workerPath)
|
||||||
|
expect(await Promise.all(detailSets.map(ids => database.listConversationSummariesAsync(ids)))).toEqual(expected)
|
||||||
|
expect(await Promise.all(conversations.map(item => database.getConversationAsync(item.id)))).toEqual(expectedFull)
|
||||||
|
expect(database.readonlyWorkerForTest?.pendingCount).toBe(0)
|
||||||
|
// Missing conversations reject with the synchronous error.
|
||||||
|
await expect(database.getConversationAsync(randomUUID())).rejects.toThrow('对话不存在')
|
||||||
|
|
||||||
|
// Read after write: a synchronous write on Main is visible to the next worker read.
|
||||||
|
database.saveLocalConversations([{ header: { id: conversations[1]!.id, title: 'Renamed', updatedAt: 500 },
|
||||||
|
messages: [message('fresh', 999)] }])
|
||||||
|
const afterWrite = await database.listConversationSummariesAsync([])
|
||||||
|
expect(afterWrite).toEqual(database.listConversationSummaries([]))
|
||||||
|
expect(afterWrite[0]!.title).toBe('Renamed')
|
||||||
|
expect(await database.getConversationAsync(conversations[1]!.id)).toEqual(database.getConversation(conversations[1]!.id))
|
||||||
|
|
||||||
|
// Inside an open transaction the synchronous path keeps read-your-writes.
|
||||||
|
const raw = (database as unknown as { database: import('node:sqlite').DatabaseSync }).database
|
||||||
|
raw.exec('BEGIN IMMEDIATE')
|
||||||
|
try {
|
||||||
|
raw.prepare('UPDATE conversations SET title = ? WHERE id = ?').run('Uncommitted', conversations[2]!.id)
|
||||||
|
expect((await database.getConversationAsync(conversations[2]!.id)).title).toBe('Uncommitted')
|
||||||
|
} finally { raw.exec('ROLLBACK') }
|
||||||
|
|
||||||
|
// Worker crash: served synchronously, identical.
|
||||||
|
const inFlight = database.listConversationSummariesAsync([conversations[3]!.id])
|
||||||
|
await database.readonlyWorkerForTest!.terminateWorkerForTest()
|
||||||
|
expect(await inFlight).toEqual(database.listConversationSummaries([conversations[3]!.id]))
|
||||||
|
} finally { database.close() }
|
||||||
|
}, 60_000)
|
||||||
|
|
||||||
it('serves story graph reads from the worker with the synchronous result and errors', async () => {
|
it('serves story graph reads from the worker with the synchronous result and errors', async () => {
|
||||||
const root = await mkdtemp(join(directory, 'assistant-'))
|
const root = await mkdtemp(join(directory, 'assistant-'))
|
||||||
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
const database = new AssistantDatabase(join(root, 'assistant.sqlite'))
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ if (kind === 'knowledge') {
|
|||||||
handlers = {
|
handlers = {
|
||||||
readStoryGraph: ([name, input, projectId], signal) => database.readStoryGraph(
|
readStoryGraph: ([name, input, projectId], signal) => database.readStoryGraph(
|
||||||
name as Parameters<AssistantDatabase['readStoryGraph']>[0], input, projectId as string | undefined, signal),
|
name as Parameters<AssistantDatabase['readStoryGraph']>[0], input, projectId as string | undefined, signal),
|
||||||
searchConversations: ([query]) => database.searchConversations(query as string)
|
searchConversations: ([query]) => database.searchConversations(query as string),
|
||||||
|
// Several statements each: read them from one snapshot.
|
||||||
|
listConversationSummaries: ([detailIds]) => database.readSnapshot(() =>
|
||||||
|
database.listConversationSummaries(detailIds as string[])),
|
||||||
|
getConversation: ([conversationId]) => database.readSnapshot(() =>
|
||||||
|
database.getConversation(conversationId as string))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import type {
|
|||||||
AssistantTask,
|
AssistantTask,
|
||||||
ExecutionStats,
|
ExecutionStats,
|
||||||
ActivityHistorySnapshot,
|
ActivityHistorySnapshot,
|
||||||
|
ActivityHistoryUpdate,
|
||||||
ActivityRecord,
|
ActivityRecord,
|
||||||
TokenUsageSummary,
|
TokenUsageSummary,
|
||||||
ConversationBranchInput,
|
ConversationBranchInput,
|
||||||
@@ -1286,6 +1287,9 @@ const desktopApi: DesktopApi = {
|
|||||||
records,
|
records,
|
||||||
legacyHistoryMayBeIncomplete
|
legacyHistoryMayBeIncomplete
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
update: async (update: ActivityHistoryUpdate) => {
|
||||||
|
await ipcRenderer.invoke(ipcChannels.activityHistoryUpdate, update)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
|
|||||||
@@ -100,10 +100,42 @@ export const activityHistorySnapshotSchema = z
|
|||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental activity history change (PERF-15). Changes apply in order to the
|
||||||
|
* stored, newest-first list; the result is identical to `replace` with the
|
||||||
|
* same final list. A record is identified by its `id` (first occurrence when
|
||||||
|
* legacy history contains duplicate IDs).
|
||||||
|
* - `upsert` + `position: 'front'` inserts the record, or moves the existing
|
||||||
|
* record to the front with the new content (renderer `upsertActivityRecord`).
|
||||||
|
* - `upsert` + `position: 'in-place'` replaces the existing record where it
|
||||||
|
* is; a record that does not exist yet is inserted at the front.
|
||||||
|
* - `remove` deletes every record with the ID.
|
||||||
|
*/
|
||||||
|
export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
|
||||||
|
z.object({
|
||||||
|
type: z.literal('upsert'),
|
||||||
|
record: activityRecordSchema,
|
||||||
|
position: z.enum(['front', 'in-place'])
|
||||||
|
}).strict(),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('remove'),
|
||||||
|
id: z.string().min(1).max(256)
|
||||||
|
}).strict()
|
||||||
|
])
|
||||||
|
|
||||||
|
export const activityHistoryUpdateSchema = z
|
||||||
|
.object({
|
||||||
|
changes: z.array(activityHistoryChangeSchema).max(20_000),
|
||||||
|
legacyHistoryMayBeIncomplete: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
export type ActivityRecord = z.infer<typeof activityRecordSchema>
|
export type ActivityRecord = z.infer<typeof activityRecordSchema>
|
||||||
export type ActivityHistorySnapshot = z.infer<
|
export type ActivityHistorySnapshot = z.infer<
|
||||||
typeof activityHistorySnapshotSchema
|
typeof activityHistorySnapshotSchema
|
||||||
>
|
>
|
||||||
|
export type ActivityHistoryChange = z.infer<typeof activityHistoryChangeSchema>
|
||||||
|
export type ActivityHistoryUpdate = z.infer<typeof activityHistoryUpdateSchema>
|
||||||
|
|
||||||
export const projectExecutionSpaceSchema = z.discriminatedUnion(
|
export const projectExecutionSpaceSchema = z.discriminatedUnion(
|
||||||
'kind',
|
'kind',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
type ExecutionStats,
|
type ExecutionStats,
|
||||||
type ExecutionStatsInput,
|
type ExecutionStatsInput,
|
||||||
type ActivityHistorySnapshot,
|
type ActivityHistorySnapshot,
|
||||||
|
type ActivityHistoryUpdate,
|
||||||
type ActivityRecord,
|
type ActivityRecord,
|
||||||
type TokenUsageSummary,
|
type TokenUsageSummary,
|
||||||
type ConversationSnapshot,
|
type ConversationSnapshot,
|
||||||
@@ -2158,6 +2159,12 @@ export type DesktopApi = {
|
|||||||
records: ActivityRecord[],
|
records: ActivityRecord[],
|
||||||
legacyHistoryMayBeIncomplete: boolean
|
legacyHistoryMayBeIncomplete: boolean
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
|
/**
|
||||||
|
* Incremental save (PERF-15): only the changed records cross IPC and are
|
||||||
|
* written. Optional until renderer mocks adopt it; the preload always
|
||||||
|
* provides it.
|
||||||
|
*/
|
||||||
|
update?: (update: ActivityHistoryUpdate) => Promise<void>
|
||||||
}
|
}
|
||||||
usage: {
|
usage: {
|
||||||
getTokenSummary: () => Promise<TokenUsageSummary>
|
getTokenSummary: () => Promise<TokenUsageSummary>
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export const ipcChannels = {
|
|||||||
tasksSetStatus: 'tasks:set-status',
|
tasksSetStatus: 'tasks:set-status',
|
||||||
activityHistoryGet: 'activity-history:get',
|
activityHistoryGet: 'activity-history:get',
|
||||||
activityHistoryReplace: 'activity-history:replace',
|
activityHistoryReplace: 'activity-history:replace',
|
||||||
|
activityHistoryUpdate: 'activity-history:update',
|
||||||
tokenUsageSummary: 'usage:token-summary',
|
tokenUsageSummary: 'usage:token-summary',
|
||||||
tokenUsageChanged: 'usage:token-changed',
|
tokenUsageChanged: 'usage:token-changed',
|
||||||
artifactsList: 'artifacts:list',
|
artifactsList: 'artifacts:list',
|
||||||
|
|||||||
Reference in New Issue
Block a user