perf(activity): keep only loaded activity pages in the renderer, driven by Main's summary
The renderer no longer holds the whole history. Startup sends queued changes, lets Main reconcile leftover pending/running records against the stored tasks (same localized interruptedOnRestart line), then loads the first page (200). The Activity page loads a full visible batch (500, as before paging); "load more" shows the next batch and loads the next page behind it; a filter switch refetches that filter's first page; leaving the page trims to one batch. Counts, project/conversation groups, titles, conversation status and the clear count come from activityHistory.summary (refreshed on entry, by the page timer, the refresh button and after load more / filter changes). Records that are not loaded are still updated incrementally: the update contract gains content-matched changes (upsert-call, update-request, resolve-approval, settle-request, remove-request, remove-call) that Main applies in SQL to shown rows; the store applies the same semantics to its pages through the shared reference. update returns Main's stored call records so a call whose earlier record was not loaded keeps one id. Task refresh reconciles via Main SQL. Legacy localStorage records migrate as incremental changes; the renderer no longer calls replace. Tests: randomized sequences of every action, page loads, trims, flushes and injected failures against the former full-list code on a real database; first page <= 200 after startup; flush payload depends only on changed records (including unloaded ones); ActivityPanel output with pages + summary equals the full-list rendering for randomized datasets with duplicates; restart interrupts leftover records with the same detail; clear empties the database, pages and summary; SQL change semantics equal the reference.
This commit is contained in:
@@ -5,8 +5,9 @@ import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { build } from 'esbuild'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { ActivityRecord, AssistantTask } from '../../shared/assistant-contracts'
|
||||
import type { ActivityHistoryChange, ActivityRecord, AssistantTask } from '../../shared/assistant-contracts'
|
||||
import {
|
||||
applyActivityChanges,
|
||||
firstOccurrences,
|
||||
referenceActivityPage,
|
||||
referenceActivityReconcile,
|
||||
@@ -89,6 +90,34 @@ describe('ActivityHistoryRepository', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies content-matched changes in SQL exactly like the reference, including legacy duplicates', () => {
|
||||
for (const seed of [4, 8, 15]) {
|
||||
const random = makeRandom(seed)
|
||||
const pick = <T,>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
|
||||
const { database } = open()
|
||||
let list = randomList(random, 150).map((item, index) =>
|
||||
item.kind === 'tool' || item.kind === 'subagent' ? { ...item, callId: `call-${index % 4}` } : item)
|
||||
database.replaceActivityHistory({ records: list, legacyHistoryMayBeIncomplete: false })
|
||||
list = firstOccurrences(list)
|
||||
for (let round = 0; round < 80; round++) {
|
||||
const requestId = `request-${Math.floor(random() * 10)}`
|
||||
const status = pick(statuses)
|
||||
const changes: ActivityHistoryChange[] = [pick([
|
||||
(): ActivityHistoryChange => ({ type: 'upsert-call', record: { ...randomList(random, 1)[0]!, id: `new-${round}`,
|
||||
kind: pick(['tool', 'subagent'] as const), requestId, callId: `call-${Math.floor(random() * 4)}` } }),
|
||||
(): ActivityHistoryChange => ({ type: 'update-request', requestId, status, ...(random() < 0.5 ? { detail: 'd' } : {}) }),
|
||||
(): ActivityHistoryChange => ({ type: 'resolve-approval', conversationId: `conversation-${Math.floor(random() * 6)}`, status, detailLine: 'x' }),
|
||||
(): ActivityHistoryChange => ({ type: 'settle-request', requestId, status, detailLine: 'y' }),
|
||||
(): ActivityHistoryChange => ({ type: 'remove-request', requestId }),
|
||||
(): ActivityHistoryChange => ({ type: 'remove-call', requestId, callId: `call-${Math.floor(random() * 4)}` })
|
||||
])()]
|
||||
database.updateActivityHistory({ changes })
|
||||
list = applyActivityChanges(list, changes)
|
||||
expect(firstOccurrences(database.getActivityHistory().records)).toEqual(list)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('reconciles with the stored tasks like the former renderer code', () => {
|
||||
const { database } = open()
|
||||
const random = makeRandom(5)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ActivityHistoryPage,
|
||||
type ActivityHistorySnapshot,
|
||||
type ActivityHistorySummary,
|
||||
type ActivityHistoryUpdateResult,
|
||||
type ActivityRecord
|
||||
} from '../../shared/assistant-contracts'
|
||||
import {
|
||||
@@ -275,9 +276,15 @@ export class ActivityHistoryRepository {
|
||||
})
|
||||
}
|
||||
|
||||
/** Applies incremental changes in one transaction, without reading the history. */
|
||||
update(input: unknown): void {
|
||||
/**
|
||||
* Applies incremental changes in one transaction, without reading the
|
||||
* history. Returns the stored record of every upsert-call, which keeps
|
||||
* the id and createdAt of an earlier record of the call the renderer may
|
||||
* not have loaded.
|
||||
*/
|
||||
update(input: unknown): ActivityHistoryUpdateResult {
|
||||
const update = activityHistoryUpdateSchema.parse(input)
|
||||
const calls: ActivityRecord[] = []
|
||||
this.write(() => {
|
||||
const database = this.database
|
||||
const nextSeq = database.prepare(
|
||||
@@ -294,25 +301,95 @@ export class ActivityHistoryRepository {
|
||||
'UPDATE activity_history_records SET record_json = ? WHERE record_key = ? AND record_json != ?'
|
||||
)
|
||||
const remove = database.prepare('DELETE FROM activity_history_records WHERE record_id = ?')
|
||||
const seq = (): number => (nextSeq.get() as { seq: number }).seq
|
||||
// Shown rows (first occurrences) a content-matched change applies to, newest first.
|
||||
type Row = { record_key: string; order_seq: number; record: ActivityRecord }
|
||||
const select = (where: string, limit: number, ...args: SqlValue[]): Row[] =>
|
||||
(database.prepare(
|
||||
`SELECT record_key, order_seq, record_json FROM activity_history_records
|
||||
WHERE occurrence = 0 AND ${where} ORDER BY order_seq DESC LIMIT ${limit}`
|
||||
).all(...args) as Array<{ record_key: string; order_seq: number; record_json: string }>)
|
||||
.map((row) => ({ record_key: row.record_key, order_seq: row.order_seq, record: parseRow(row.record_json) }))
|
||||
const frontSeq = database.prepare(
|
||||
'SELECT MAX(order_seq) AS seq FROM activity_history_records WHERE occurrence = 0'
|
||||
)
|
||||
const rewrite = (key: string, record: ActivityRecord): void => {
|
||||
const json = JSON.stringify(activityRecordSchema.parse(record))
|
||||
replaceInPlace.run(json, key, json)
|
||||
}
|
||||
for (const change of update.changes) {
|
||||
if (change.type === 'remove-duplicates') {
|
||||
database.exec('DELETE FROM activity_history_records WHERE occurrence != 0')
|
||||
} else if (change.type === 'remove') {
|
||||
remove.run(change.id)
|
||||
} else {
|
||||
// The first occurrence stays first when moved to the front, so its key is stable.
|
||||
const key = activityRecordKey(change.record.id, 0)
|
||||
const json = JSON.stringify(change.record)
|
||||
const seq = (): number => (nextSeq.get() as { seq: number }).seq
|
||||
if (!exists.get(key)) insert.run(key, json, seq())
|
||||
else if (change.position === 'front') moveToFront.run(json, seq(), key)
|
||||
else replaceInPlace.run(json, key, json)
|
||||
switch (change.type) {
|
||||
case 'remove-duplicates':
|
||||
database.exec('DELETE FROM activity_history_records WHERE occurrence != 0')
|
||||
break
|
||||
case 'remove':
|
||||
remove.run(change.id)
|
||||
break
|
||||
case 'upsert': {
|
||||
// The first occurrence stays first when moved to the front, so its key is stable.
|
||||
const key = activityRecordKey(change.record.id, 0)
|
||||
const json = JSON.stringify(change.record)
|
||||
if (!exists.get(key)) insert.run(key, json, seq())
|
||||
else if (change.position === 'front') moveToFront.run(json, seq(), key)
|
||||
else replaceInPlace.run(json, key, json)
|
||||
break
|
||||
}
|
||||
case 'upsert-call': {
|
||||
const incoming = change.record
|
||||
const [existing] = select(
|
||||
"request_id = ? AND kind = ? AND json_extract(record_json, '$.callId') = ?", 1,
|
||||
incoming.requestId, incoming.kind, incoming.callId!
|
||||
)
|
||||
if (!existing) {
|
||||
insert.run(activityRecordKey(incoming.id, 0), JSON.stringify(incoming), seq())
|
||||
calls.push(incoming)
|
||||
break
|
||||
}
|
||||
const before = existing.record
|
||||
const unchanged = before.conversationId === incoming.conversationId && before.title === incoming.title &&
|
||||
before.detail === incoming.detail && before.status === incoming.status
|
||||
const front = existing.order_seq === (frontSeq.get() as { seq: number }).seq
|
||||
const next = unchanged ? before : { ...incoming, id: before.id, createdAt: before.createdAt, scope: before.scope }
|
||||
calls.push(next)
|
||||
if (unchanged && front) break
|
||||
moveToFront.run(JSON.stringify(activityRecordSchema.parse(next)), seq(), existing.record_key)
|
||||
break
|
||||
}
|
||||
case 'update-request':
|
||||
for (const row of select("request_id = ? AND kind = 'request'", -1, change.requestId)) {
|
||||
rewrite(row.record_key, { ...row.record, status: change.status, detail: change.detail ?? row.record.detail })
|
||||
}
|
||||
break
|
||||
case 'resolve-approval': {
|
||||
const [row] = select("conversation_id = ? AND kind = 'approval' AND status = 'pending'", 1,
|
||||
change.conversationId)
|
||||
if (row) {
|
||||
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'settle-request':
|
||||
for (const row of select("request_id = ? AND kind != 'request' AND status_group = 'active'", -1,
|
||||
change.requestId)) {
|
||||
rewrite(row.record_key, { ...row.record, status: change.status, detail: `${row.record.detail}\n${change.detailLine}` })
|
||||
}
|
||||
break
|
||||
case 'remove-request':
|
||||
for (const row of select('request_id = ?', -1, change.requestId)) remove.run(row.record.id)
|
||||
break
|
||||
case 'remove-call':
|
||||
for (const row of select(
|
||||
"request_id = ? AND kind = 'tool' AND json_extract(record_json, '$.callId') = ?", -1,
|
||||
change.requestId, change.callId
|
||||
)) remove.run(row.record.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (update.legacyHistoryMayBeIncomplete !== undefined) {
|
||||
this.writeIncomplete(update.legacyHistoryMayBeIncomplete)
|
||||
}
|
||||
})
|
||||
return { calls }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
ActivityHistoryPage,
|
||||
ActivityHistorySnapshot,
|
||||
ActivityHistorySummary,
|
||||
ActivityHistoryUpdateResult,
|
||||
ConversationQueueItem,
|
||||
ConversationAnsweredQuestion,
|
||||
ConversationBranchInput,
|
||||
@@ -5310,8 +5311,8 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
/** Applies incremental changes in one transaction (PERF-15). */
|
||||
updateActivityHistory(input: unknown): void {
|
||||
this.activityHistory().update(input)
|
||||
updateActivityHistory(input: unknown): ActivityHistoryUpdateResult {
|
||||
return this.activityHistory().update(input)
|
||||
}
|
||||
|
||||
/** Deletes every activity record in one transaction. */
|
||||
|
||||
Reference in New Issue
Block a user