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:
mesalogo
2026-10-04 00:03:33 +08:00
parent 0e9c9d4927
commit bf9850a6a9
16 changed files with 1294 additions and 493 deletions
@@ -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 }
}
/**
+3 -2
View File
@@ -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. */
+2 -7
View File
@@ -6869,7 +6869,7 @@ export function registerIpcHandlers(
ipcChannels.activityHistoryUpdate,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.updateActivityHistory(input)
return assistantDatabase.updateActivityHistory(input)
}
)
registerHandler(ipcChannels.activityHistoryClear, (event) => {
@@ -6886,12 +6886,7 @@ export function registerIpcHandlers(
})
registerHandler(ipcChannels.activityHistoryReconcile, (event, input: unknown) => {
assertTrustedSender(event, window)
const request = activityHistoryReconcileRequestSchema.parse(input)
// Requests still running in Main are active too, whichever window started them.
return assistantDatabase.reconcileActivityHistory({
...request,
activeRequestIds: [...new Set([...request.activeRequestIds, ...activeRequests.keys()])]
})
return assistantDatabase.reconcileActivityHistory(activityHistoryReconcileRequestSchema.parse(input))
})
registerHandler(ipcChannels.tokenUsageSummary, (event) => {