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) => {
+3 -3
View File
@@ -74,6 +74,7 @@ import type {
ActivityHistorySummary,
ActivityHistorySummaryRequest,
ActivityHistoryUpdate,
ActivityHistoryUpdateResult,
ActivityRecord,
TokenUsageSummary,
ConversationBranchInput,
@@ -1296,9 +1297,8 @@ const desktopApi: DesktopApi = {
legacyHistoryMayBeIncomplete
})
},
update: async (update: ActivityHistoryUpdate) => {
await ipcRenderer.invoke(ipcChannels.activityHistoryUpdate, update)
},
update: (update: ActivityHistoryUpdate) =>
ipcRenderer.invoke(ipcChannels.activityHistoryUpdate, update) as Promise<ActivityHistoryUpdateResult>,
clear: async () => {
await ipcRenderer.invoke(ipcChannels.activityHistoryClear)
},
+93
View File
@@ -991,3 +991,96 @@ describe('ActivityPanel', () => {
expect(screen.queryByRole('button', { name: '刷新' })).not.toBeInTheDocument()
})
})
describe('ActivityPanel paged by Main', () => {
function makeRandom(seed: number): () => number {
let state = seed
return () => (state = (state * 1664525 + 1013904223) >>> 0) / 4294967296
}
function randomList(random: () => number, length: number): ActivityRecord[] {
const pick = <T,>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
const list: ActivityRecord[] = []
for (let index = 0; index < length; index++) {
const duplicate = list.length > 0 && random() < 0.08
list.push({
id: duplicate ? pick(list).id : `random-${index}`,
conversationId: `conversation-${Math.floor(random() * 5)}`,
requestId: `request-${Math.floor(random() * 8)}`,
scope: random() < 0.5 ? { kind: 'project', projectId: 'p1', projectName: '项目一' } : { kind: 'global' },
kind: pick(['request', 'tool', 'approval', 'subagent', 'result'] as const),
title: `标题 ${index}`,
detail: `详情 ${index}`,
status: pick(['pending', 'running', 'completed', 'failed', 'denied', 'cancelled', 'interrupted'] as const),
createdAt: Date.UTC(2026, 0, 1) + Math.floor(random() * 30) * 1000
})
}
return list
}
const filterButtons = (container: HTMLElement): HTMLButtonElement[] =>
[...container.querySelectorAll<HTMLButtonElement>('.activity-panel__toolbar [role="group"] button')]
/** The visible text and status classes of the page; what the user can compare. */
function snapshot(container: HTMLElement): string {
return [...container.querySelectorAll('.activity-panel__toolbar, .activity-project, .activity-panel__load-more')]
.map((element) => `${element.textContent ?? ''}|${[...element.querySelectorAll('.status-badge')].map((badge) => badge.className).join(',')}`)
.join('\n')
}
it('shows the same counts, groups, titles, status and clear count as the full-list computation', async () => {
const { firstOccurrences, referenceActivitySummary } = await import('../../shared/activity-history-reference')
for (const seed of [2, 13, 77, 404]) {
const random = makeRandom(seed)
const stored = randomList(random, 120 + Math.floor(random() * 500))
// The former page got the whole shown list (first occurrences).
const full = firstOccurrences(stored)
const props = { tokenUsage: makeTokenUsage(), onClear: vi.fn(), onOpenConversation: vi.fn() }
for (const filter of ['all', 'active', 'failed'] as const) {
const before = render(<ActivityPanel {...props} records={full} />)
if (filter !== 'all') {
fireEvent.click(filterButtons(before.container)[filter === 'active' ? 1 : 2]!)
}
const expected = snapshot(before.container)
before.unmount()
// Now: only a loaded prefix (as Main pages it) plus Main's summary.
const matching = full.filter((record) =>
filter === 'all' ? true : filter === 'active'
? record.status === 'pending' || record.status === 'running'
: ['failed', 'denied', 'cancelled', 'interrupted'].includes(record.status))
const loaded = matching.slice(0, 500)
const summary = referenceActivitySummary(stored, [...new Set(loaded.map((record) => record.conversationId))])
const after = render(
<ActivityPanel
{...props}
paging={{ filter, summary, hasMore: loaded.length < matching.length, onFilterChange: vi.fn(), onLoadMore: vi.fn() }}
records={loaded}
/>
)
expect(snapshot(after.container)).toBe(expected)
after.unmount()
}
}
})
it('loads the next page behind "load more" and switches filters through the callbacks', () => {
const records = Array.from({ length: 500 }, (_, index) => makeRecord(index))
const onLoadMore = vi.fn()
const onFilterChange = vi.fn()
const summary = { counts: { all: 1_200, active: 0, failed: 0 }, conversations: {}, legacyHistoryMayBeIncomplete: false }
const { container } = render(
<ActivityPanel
onClear={vi.fn()}
onOpenConversation={vi.fn()}
paging={{ filter: 'all', summary, hasMore: true, onFilterChange, onLoadMore }}
records={records}
tokenUsage={makeTokenUsage()}
/>
)
expect(container.querySelectorAll('.activity-list__item')).toHaveLength(500)
const loadMore = container.querySelector<HTMLButtonElement>('.activity-panel__load-more')!
expect(loadMore.textContent).toContain('700')
fireEvent.click(loadMore)
expect(onLoadMore).toHaveBeenCalledOnce()
fireEvent.click(filterButtons(container)[2]!)
expect(onFilterChange).toHaveBeenCalledWith('failed')
expect(container.textContent).toContain('1,200')
})
})
+116 -33
View File
@@ -1,8 +1,9 @@
import { Activity, ChevronRight, RefreshCw, Trash2 } from 'lucide-react'
import { memo, useMemo, useState } from 'react'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import type {
ActivityHistorySummary,
AssistantProject,
TokenUsageSummary
} from '../../shared/assistant-contracts'
@@ -41,7 +42,23 @@ const activityRenderBatchSize = 500
export type ActivityPanelProps = {
projects?: readonly AssistantProject[]
/**
* The records to show, newest first. With `paging` they are the loaded
* pages of `paging.filter`; without, the whole history.
*/
records: readonly ActivityRecord[]
/**
* Paged history (PERF-15): counts, titles and conversation status come
* from `summary`, computed by Main over the whole history; "load more"
* and filter changes go through the callbacks.
*/
paging?: {
filter: ActivityFilter
summary?: ActivityHistorySummary
hasMore: boolean
onFilterChange: (filter: ActivityFilter) => void
onLoadMore: () => void
}
tokenUsage: TokenUsageSummary
onClear: () => void
onOpenConversation: (conversationId: string) => void
@@ -187,11 +204,15 @@ function getConversationTitles(
return conversationTitles
}
function groupActivityRecordsByProject(
records: readonly ActivityRecord[],
type ConversationFacts = {
titles: ReadonlyMap<string, string>
status: (conversationId: string, shown: readonly ActivityRecord[]) => ActivityRecord['status']
}
/** Titles and status from the whole list (unpaged). */
function conversationFactsFromRecords(
allRecords: readonly ActivityRecord[]
): ProjectActivityGroup[] {
const conversationTitles = getConversationTitles(allRecords)
): ConversationFacts {
const allConversationRecords = new Map<string, ActivityRecord[]>()
for (const record of allRecords) {
const conversationRecords =
@@ -199,6 +220,35 @@ function groupActivityRecordsByProject(
conversationRecords.push(record)
allConversationRecords.set(record.conversationId, conversationRecords)
}
return {
titles: getConversationTitles(allRecords),
status: (conversationId, shown) =>
conversationStatus(allConversationRecords.get(conversationId) ?? shown)
}
}
/** Titles and status Main computed over the whole history; loaded records fill gaps. */
function conversationFactsFromSummary(
summary: ActivityHistorySummary,
loaded: readonly ActivityRecord[]
): ConversationFacts {
const fallback = conversationFactsFromRecords(loaded)
const titles = new Map(fallback.titles)
for (const [conversationId, facts] of Object.entries(summary.conversations)) {
if (facts.title !== undefined) titles.set(conversationId, facts.title)
}
return {
titles,
status: (conversationId, shown) =>
summary.conversations[conversationId]?.status ?? fallback.status(conversationId, shown)
}
}
function groupActivityRecordsByProject(
records: readonly ActivityRecord[],
facts: ConversationFacts
): ProjectActivityGroup[] {
const conversationTitles = facts.titles
const projectGroups = new Map<
string,
{
@@ -231,9 +281,7 @@ function groupActivityRecordsByProject(
conversationTitles.get(conversationId) ?? items[0]!.title,
records: items,
latestAt: Math.max(...items.map((record) => record.createdAt)),
status: conversationStatus(
allConversationRecords.get(conversationId) ?? items
)
status: facts.status(conversationId, items)
})
)
return {
@@ -254,13 +302,23 @@ export const ActivityPanel = memo(function ActivityPanel({
tokenUsage,
onClear,
onOpenConversation,
onRefresh
onRefresh,
paging
}: ActivityPanelProps): React.JSX.Element {
const { t, i18n } = useTranslation('activity')
const { t: tWorkspace } = useTranslation('workspace')
const [activeView, setActiveView] =
useState<ActivityView>('tasks')
const [filter, setFilter] = useState<ActivityFilter>('all')
const [localFilter, setLocalFilter] = useState<ActivityFilter>('all')
const filter = paging?.filter ?? localFilter
const onFilterChange = paging?.onFilterChange
const setFilter = useCallback(
(next: ActivityFilter): void => {
if (onFilterChange) onFilterChange(next)
else setLocalFilter(next)
},
[onFilterChange]
)
const [tokenGroup, setTokenGroup] =
useState<Exclude<TokenUsageGroup, 'source'>>('project')
const [systemTokenGroup, setSystemTokenGroup] =
@@ -414,6 +472,7 @@ export const ActivityPanel = memo(function ActivityPanel({
}),
[projectDisplayNames, tokenUsage]
)
const summary = paging?.summary
const matchingRecords = useMemo(
() => displayRecords.filter((record) => matchesFilter(record, filter)),
[displayRecords, filter]
@@ -422,9 +481,16 @@ export const ActivityPanel = memo(function ActivityPanel({
() => matchingRecords.slice(0, visibleRecordCount),
[matchingRecords, visibleRecordCount]
)
const conversationFacts = useMemo(
() =>
summary
? conversationFactsFromSummary(summary, displayRecords)
: conversationFactsFromRecords(displayRecords),
[displayRecords, summary]
)
const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
[displayRecords, filteredRecords]
() => groupActivityRecordsByProject(filteredRecords, conversationFacts),
[conversationFacts, filteredRecords]
)
const timelineBounds = useMemo(() => {
const timestamps = filteredRecords.map((record) => record.createdAt)
@@ -450,12 +516,23 @@ export const ActivityPanel = memo(function ActivityPanel({
const selectedTimelineRecord = filteredRecords.find(
(record) => record.id === selectedTimelineRecordId
)
const conversationTitles = useMemo(
() => getConversationTitles(displayRecords),
[displayRecords]
)
const activeCount = displayRecords.filter(isActive).length
const failedCount = displayRecords.filter(isFailed).length
const conversationTitles = conversationFacts.titles
// Before the first summary arrives, the loaded records are the best estimate.
const totalCount = summary?.counts.all ?? displayRecords.length
const activeCount =
summary?.counts.active ?? displayRecords.filter(isActive).length
const failedCount =
summary?.counts.failed ?? displayRecords.filter(isFailed).length
const matchingTotal =
filter === 'all' ? totalCount : filter === 'active' ? activeCount : failedCount
// Records of this filter that exist but are not shown yet.
const remainingCount = paging
? Math.max(
matchingTotal - filteredRecords.length,
matchingRecords.length - filteredRecords.length,
paging.hasMore ? 1 : 0
)
: matchingRecords.length - filteredRecords.length
const filters: ReadonlyArray<{
value: ActivityFilter
label: string
@@ -463,8 +540,8 @@ export const ActivityPanel = memo(function ActivityPanel({
{
value: 'all',
label: t('filters.all', {
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
count: totalCount,
formattedCount: formatCount(totalCount)
})
},
{
@@ -691,19 +768,19 @@ export const ActivityPanel = memo(function ActivityPanel({
/>
<DestructiveConfirmActions
confirmAriaLabel={t('clear.confirmAriaLabel', {
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
count: totalCount,
formattedCount: formatCount(totalCount)
})}
confirmLabel={t('clear.confirmLabel', {
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
count: totalCount,
formattedCount: formatCount(totalCount)
})}
confirming={confirmingClear}
disabled={!confirmingClear && displayRecords.length === 0}
disabled={!confirmingClear && totalCount === 0 && displayRecords.length === 0}
icon={<Trash2 aria-hidden="true" size={15} />}
message={t('clear.message', {
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
count: totalCount,
formattedCount: formatCount(totalCount)
})}
onCancel={() => setConfirmingClear(false)}
onConfirm={() => {
@@ -739,20 +816,26 @@ export const ActivityPanel = memo(function ActivityPanel({
/>
)
const loadMoreRecords =
filteredRecords.length < matchingRecords.length ? (
remainingCount > 0 ? (
<button
className="secondary-button activity-panel__load-more"
onClick={() =>
onClick={() => {
// Show the next loaded batch at once; load another page behind it.
setVisibleRecordCount(
(current) => current + activityRenderBatchSize
)
}
if (
paging?.hasMore &&
matchingRecords.length <
visibleRecordCount + 2 * activityRenderBatchSize
) {
paging.onLoadMore()
}
}}
type="button"
>
{t('records.loadMore', {
remaining: formatCount(
matchingRecords.length - filteredRecords.length
)
remaining: formatCount(remainingCount)
})}
</button>
) : null
+58 -10
View File
@@ -14222,20 +14222,68 @@ describe("App", () => {
expect(await screen.findByLabelText("项目:默认项目")).toHaveClass("scope-badge");
await waitFor(() => {
expect(api.activityHistory.replace).toHaveBeenCalledWith(
[
expect.objectContaining({
id: "legacy-activity",
detail: "x".repeat(4_000),
}),
],
true,
);
expect(savedActivity).toEqual([
expect.objectContaining({
id: "legacy-activity",
detail: "x".repeat(4_000),
}),
]);
expect(api.activityHistory.update).toHaveBeenCalledWith({
changes: [],
legacyHistoryMayBeIncomplete: true,
});
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull();
});
// The legacy migration no longer writes the whole list.
expect(api.activityHistory.replace).not.toHaveBeenCalled();
});
it("keeps at most one page of activity after startup and trims when the page is left", async () => {
savedActivity = Array.from({ length: 1_200 }, (_, index) => ({
id: `stored-${index}`,
conversationId: `stored-conversation-${index % 7}`,
requestId: `stored-request-${index}`,
scope: { kind: "global" as const },
kind: index % 3 === 0 ? ("request" as const) : ("tool" as const),
title: `存量记录 ${index}`,
detail: "detail",
status: index % 5 === 0 ? ("failed" as const) : ("completed" as const),
createdAt: 1_200 - index,
}));
render(<App />);
// Startup: Main reconciles, then one page of at most 200 records.
await waitFor(() => expect(api.activityHistory.page).toHaveBeenCalledWith({ limit: 200 }));
expect(api.activityHistory.get).not.toHaveBeenCalled();
expect(api.activityHistory.reconcile).toHaveBeenCalledOnce();
expect(vi.mocked(api.activityHistory.reconcile).mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(api.activityHistory.page).mock.invocationCallOrder[0]!);
expect(api.activityHistory.page).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
// The page loads a full visible batch (500, as before paging) and counts the whole history.
expect(await screen.findByRole("button", { name: "全部 1,200" })).toBeInTheDocument();
expect(api.activityHistory.page).toHaveBeenLastCalledWith({ limit: 500 });
const records = (): number =>
[...document.querySelectorAll(".activity-group small")]
.reduce((total, element) => total + Number(/\d[\d,]*/u.exec(element.textContent ?? "")![0]!.replace(",", "")), 0);
await waitFor(() => expect(records()).toBe(500));
expect(screen.getByRole("button", { name: "继续显示(剩余 700 条)" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "清空记录" }));
expect(screen.getByText("永久清空 1,200 条活动记录?此操作不可撤销。")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "确认清空 1,200 条活动记录" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "取消" }));
fireEvent.click(screen.getByRole("button", { name: "继续显示(剩余 700 条)" }));
await waitFor(() => expect(records()).toBe(1_000));
await waitFor(() => expect(api.activityHistory.page).toHaveBeenLastCalledWith({ before: expect.any(Number), limit: 500 }));
// Leaving the page keeps one visible batch; coming back shows the same first rows.
fireEvent.click(screen.getByRole("button", { name: "对话" }));
fireEvent.click(screen.getByRole("button", { name: "运行记录" }));
expect(await screen.findByRole("button", { name: "全部 1,200" })).toBeInTheDocument();
expect(records()).toBe(500);
}, 30_000);
it("marks the current primary navigation page and hides decorative icons", async () => {
render(<App />);
+27 -13
View File
@@ -98,6 +98,7 @@ import {
runtimeProviderLabel,
} from "./runtime-selection";
import type {
ActivityHistoryFilter,
AssistantProject,
AssistantArtifact,
AssistantMemory,
@@ -202,7 +203,7 @@ import {
loadLegacyActivityHistory,
type ActivityRecord,
} from "./activity-store";
import { useActivityPanelRecords } from "./activity-selectors";
import { useActivityPanel } from "./activity-selectors";
import { useActivityHistorySync } from "./activity-sync";
import {
KnowledgeCitationDialog,
@@ -1769,9 +1770,8 @@ function App(): React.JSX.Element {
const citationRequestRef = useRef(0);
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
const [legacyActivityHistory] = useState(loadLegacyActivityHistory);
const [activityStore] = useState(() => createActivityStore(
legacyActivityHistory.records, legacyActivityHistory.historyMayBeIncomplete));
const activityPanelRecords = useActivityPanelRecords(activityStore);
const [activityStore] = useState(() => createActivityStore(legacyActivityHistory.records));
const activityPanel = useActivityPanel(activityStore);
const activeRuns = useRef(new Map<string, ActiveRun>());
const preparingConversations = useRef(new Set<string>());
const [activeConversationIds, setActiveConversationIds] = useState<
@@ -3267,7 +3267,8 @@ function App(): React.JSX.Element {
return stop;
}, [conversationStoreReady, conversationPersistence]);
const persistActivityHistory = useActivityHistorySync(activityStore, legacyActivityHistory, {
const activitySync = useActivityHistorySync(activityStore, legacyActivityHistory, {
activeRequestIds: () => new Set(activeRuns.current.keys()),
onReadFailed: () => notify({
tone: "error", message: tRef.current("notices.activityHistoryReadFailed"), dedupeKey: "activity-history-read",
}),
@@ -3278,9 +3279,10 @@ function App(): React.JSX.Element {
}),
// The loaded history appears at once when the Activity page is open.
onLoaded: () => {
if (viewRef.current === "activity") activityStore.refreshPanel();
if (viewRef.current === "activity") void activitySync.show();
},
});
const persistActivityHistory = activitySync.flush;
useEffect(
() =>
@@ -3457,9 +3459,11 @@ function App(): React.JSX.Element {
};
}, []);
const refreshActivityPanelRecords = activityStore.refreshPanel;
const { show: showActivity, hide: hideActivity } = activitySync;
const refreshActivityPanelRecords = useCallback((): void => void showActivity(), [showActivity]);
// Layout effect so entering the page never paints a stale snapshot.
// Layout effect so entering the page never paints a stale snapshot; leaving
// it keeps only the first page loaded.
useLayoutEffect(() => {
if (view !== "activity") {
return;
@@ -3469,8 +3473,11 @@ function App(): React.JSX.Element {
refreshActivityPanelRecords,
activityPanelRefreshIntervalMs,
);
return () => window.clearInterval(interval);
}, [refreshActivityPanelRecords, view]);
return () => {
window.clearInterval(interval);
hideActivity();
};
}, [hideActivity, refreshActivityPanelRecords, view]);
const resumeProjectConversationQueues = useCallback(
(projectId: string): void => {
@@ -4042,7 +4049,7 @@ function App(): React.JSX.Element {
}, [refreshTokenUsage, view]);
const manualRefreshActivity = useCallback(async (): Promise<void> => {
refreshActivityPanelRecords();
await showActivity();
try {
await refreshTokenUsage();
} catch {
@@ -4051,7 +4058,13 @@ function App(): React.JSX.Element {
message: tRef.current("notices.tokenUsageReadFailed"),
});
}
}, [notify, refreshActivityPanelRecords, refreshTokenUsage]);
}, [notify, showActivity, refreshTokenUsage]);
const { loadMore: loadMoreActivity, setFilter: setActivityFilter } = activitySync;
const activityPaging = useMemo(() => ({
filter: activityPanel.page.filter, summary: activityPanel.summary, hasMore: activityPanel.page.hasMore,
onFilterChange: (filter: ActivityHistoryFilter) => void setActivityFilter(filter),
onLoadMore: () => void loadMoreActivity(),
}), [activityPanel, loadMoreActivity, setActivityFilter]);
useInitialArtifactSync(taskStore, () =>
notify({
@@ -7564,7 +7577,8 @@ function App(): React.JSX.Element {
onOpenConversation={openActivityConversation}
onRefresh={manualRefreshActivity}
projects={projects}
records={activityPanelRecords}
paging={activityPaging}
records={activityPanel.page.records}
tokenUsage={tokenUsage}
/>
</Suspense>
+22 -8
View File
@@ -1,12 +1,26 @@
import { useSyncExternalStore } from 'react'
import type { ActivityRecord, ActivityStore } from './activity-store'
import { useCallback, useRef, useSyncExternalStore } from 'react'
import type { ActivityHistorySummary } from '../../shared/assistant-contracts'
import type { ActivityPageState, ActivityStore } from './activity-store'
export type ActivityPanelView = {
page: ActivityPageState
summary?: ActivityHistorySummary
}
/**
* The records the Activity page shows: a snapshot refreshed on entry, by the
* page timer and the refresh button, so streaming runs do not re-render it.
* What the Activity page shows: the snapshot of the loaded pages (refreshed
* on entry, by the page timer and the refresh button, so streaming runs do
* not re-render it) and Main's summary. The same object while both are
* unchanged.
*/
export function useActivityPanelRecords(
store: ActivityStore
): readonly ActivityRecord[] {
return useSyncExternalStore(store.subscribePanel, store.getPanelRecords)
export function useActivityPanel(store: ActivityStore): ActivityPanelView {
const cache = useRef<ActivityPanelView | undefined>(undefined)
const getSnapshot = useCallback((): ActivityPanelView => {
const page = store.getPanel()
const summary = store.getSummary()
const cached = cache.current
if (cached && cached.page === page && cached.summary === summary) return cached
return (cache.current = { page, ...(summary ? { summary } : {}) })
}, [store])
return useSyncExternalStore(store.subscribePanel, getSnapshot)
}
+275 -171
View File
@@ -3,9 +3,14 @@ import i18n from './i18n'
import {
activityRecordSchema,
type ActivityHistoryChange,
type ActivityHistoryFilter,
type ActivityHistoryPage,
type ActivityHistoryReconcileRequest,
type ActivityHistorySummary,
type ActivityRecord as SharedActivityRecord,
type AssistantTask
} from '../../shared/assistant-contracts'
import { applyActivityChanges } from '../../shared/activity-history-reference'
export const ACTIVITY_STORAGE_KEY = 'goodbuddy.activity-records.v1'
@@ -225,36 +230,71 @@ export function clearLegacyActivityHistory(
}
/**
* What still has to reach Main, in order: incremental changes, or a marker
* that the whole history must be cleared or written in full first.
* What still has to reach Main, in order: incremental changes, a reconcile
* against the stored tasks, the legacy-incomplete flag, or clearing it all.
*/
export type ActivityOutboxEntry =
| ActivityHistoryChange
| {
type: 'reconcile'
request: ActivityHistoryReconcileRequest
/** Kept to apply the same reconcile to pages read before Main ran it. */
tasks: readonly AssistantTask[]
}
| { type: 'legacy-flag'; value: boolean }
| { type: 'clear' }
| { type: 'replace' }
type Listener = () => void
export type ActivityStore = ReturnType<typeof createActivityStore>
/** The loaded records of one filter. */
export type ActivityPageState = {
filter: ActivityHistoryFilter
/** Newest first: a prefix of Main's list for `filter`, plus this session's unsaved records. */
records: readonly ActivityRecord[]
/** `before` of the next page; undefined when everything is loaded or not known (trimmed). */
nextBefore?: number
/** Whether more records may exist than are loaded. */
hasMore: boolean
}
export const ACTIVITY_FIRST_PAGE_SIZE = 200
export const ACTIVITY_PAGE_SIZE = 500
export function matchesActivityFilter(record: ActivityRecord, filter: ActivityHistoryFilter): boolean {
if (filter === 'active') return record.status === 'pending' || record.status === 'running'
if (filter === 'failed') {
return record.status === 'failed' || record.status === 'denied' ||
record.status === 'cancelled' || record.status === 'interrupted'
}
return true
}
export const isActivityChange = (entry: ActivityOutboxEntry): entry is ActivityHistoryChange =>
entry.type !== 'reconcile' && entry.type !== 'clear' && entry.type !== 'legacy-flag'
/**
* Activity history, kept outside App state (P2/P4). Every domain action
* updates the newest-first list and appends the matching contract changes to
* the outbox, which activity-sync.ts sends to Main; the list is never written
* back as a whole except for the legacy migration fallback.
* Activity history, kept outside App state (P2/P4). Main owns the history;
* the renderer keeps only the pages it loaded. Every domain action applies
* its contract change to the loaded records (`applyActivityChanges`, the
* same semantics Main applies) and appends it to the outbox, which
* activity-sync.ts sends to Main. Main matches changes by content, so
* records that are not loaded are updated too.
*
* The Activity page shows a snapshot (`getPanelRecords`) instead of the live
* list so streaming runs do not re-render it; `refreshPanel` updates it.
* The Activity page shows a snapshot (`getPanel`) instead of the live list so
* streaming runs do not re-render it; `refreshPanel` updates it.
*/
export function createActivityStore(
initial: readonly ActivityRecord[] = [],
initialLegacyIncomplete = false
) {
let records: readonly ActivityRecord[] = mergeActivityRecords(initial, [])
let panelRecords = records
export function createActivityStore(initial: readonly ActivityRecord[] = []) {
let page: ActivityPageState = { filter: 'all', records: mergeActivityRecords(initial, []), hasMore: true }
let panel = page
let summary: ActivityHistorySummary | undefined
let ready = false
let legacyIncomplete = initialLegacyIncomplete
/** Bumped whenever the loaded pages are replaced; stale page responses are dropped. */
let generation = 0
let outbox: ActivityOutboxEntry[] = []
/** Taken by the sync and not acknowledged yet. */
let sending: ActivityOutboxEntry[] = []
const listeners = new Set<Listener>()
const panelListeners = new Set<Listener>()
const outboxListeners = new Set<Listener>()
@@ -267,187 +307,202 @@ export function createActivityStore(
set.delete(listener)
}
}
const setPage = (next: ActivityPageState): void => {
page = next
notify(listeners)
}
const enqueue = (entry: ActivityOutboxEntry): void => {
const last = outbox.at(-1)
// Consecutive upserts of one record collapse: front then in-place is a
// front upsert with the newer content, and so on.
if (
entry.type === 'upsert' &&
last?.type === 'upsert' &&
last.record.id === entry.record.id
) {
outbox[outbox.length - 1] = {
type: 'upsert',
record: entry.record,
position:
last.position === 'front' || entry.position === 'front'
? 'front'
: 'in-place'
}
return
}
outbox.push(entry)
}
const commit = (
next: readonly ActivityRecord[],
changes: readonly ActivityOutboxEntry[]
): void => {
if (next === records && changes.length === 0) return
const wasEmpty = outbox.length === 0
records = next
for (const change of changes) enqueue(change)
notify(listeners)
if (wasEmpty && outbox.length > 0) notify(outboxListeners)
// Only the latest reconcile matters: it reads the stored tasks when it runs.
if (entry.type === 'reconcile' && outbox.at(-1)?.type === 'reconcile') outbox[outbox.length - 1] = entry
else outbox.push(entry)
if (wasEmpty) notify(outboxListeners)
}
/** Maps records in place; each changed one becomes an in-place upsert. */
const mapInPlace = (
update: (record: ActivityRecord) => ActivityRecord
): void => {
const changes: ActivityOutboxEntry[] = []
const next = records.map((record) => {
const updated = update(record)
if (updated !== record) {
changes.push({ type: 'upsert', record: updated, position: 'in-place' })
const apply = (change: ActivityHistoryChange): void => {
const records = applyActivityChanges(page.records, [change])
enqueue(change)
if (!sameRecords(records, page.records)) setPage({ ...page, records })
}
/** What Main does not have yet: the changes after the last queued clear (and whether there is one). */
const queuedChanges = (): { cleared: boolean; entries: ActivityOutboxEntry[] } => {
const queued = [...sending, ...outbox]
const cleared = queued.findLastIndex((entry) => entry.type === 'clear')
return { cleared: cleared >= 0, entries: queued.slice(cleared + 1) }
}
const replay = (
records: readonly ActivityRecord[],
entries: readonly ActivityOutboxEntry[],
inserts: boolean
): ActivityRecord[] => {
let result = [...records]
for (const entry of entries) {
if (entry.type === 'reconcile') {
result = reconcileActivityRecords(result, entry.tasks, new Set(entry.request.activeRequestIds))
} else if (isActivityChange(entry) && (inserts || (entry.type !== 'upsert' && entry.type !== 'upsert-call'))) {
result = applyActivityChanges(result, [entry])
}
return updated
})
if (changes.length > 0) commit(next, changes)
}
return result
}
const removeWhere = (match: (record: ActivityRecord) => boolean): void => {
const removed = records.filter(match)
if (removed.length === 0) return
commit(
records.filter((record) => !match(record)),
[...new Set(removed.map((record) => record.id))].map((id) => ({
type: 'remove' as const,
id
}))
)
/** A first page from Main with the queued changes applied, as Main will store it. */
const firstPageRecords = (loaded: readonly ActivityRecord[], filter: ActivityHistoryFilter): ActivityRecord[] => {
const { cleared, entries } = queuedChanges()
return replay(cleared ? [] : loaded, entries, true).filter((record) => matchesActivityFilter(record, filter))
}
/** An older page: queued upserts already put their records in front of the loaded ones. */
const olderPageRecords = (loaded: readonly ActivityRecord[]): ActivityRecord[] => {
const { cleared, entries } = queuedChanges()
if (cleared) return []
const moved = new Set<string>()
for (const entry of entries) {
if (entry.type === 'upsert') moved.add(entry.record.id)
if (entry.type !== 'upsert-call') continue
// Inserted here because the call's record was not loaded: Main moves
// the first record of the call to the front instead.
const key = callKey(entry.record)
const local = page.records.find((record) => record.callId !== undefined && callKey(record) === key)
if (local?.id !== entry.record.id) continue
const match = loaded.find((record) => !moved.has(record.id) && record.callId !== undefined && callKey(record) === key)
if (match) moved.add(match.id)
}
return replay(loaded.filter((record) => !moved.has(record.id)), entries, false)
.filter((record) => matchesActivityFilter(record, page.filter))
}
const clearQueued = (): boolean => queuedChanges().cleared
const pageEnd = (loaded: ActivityHistoryPage): Pick<ActivityPageState, 'hasMore' | 'nextBefore'> =>
loaded.nextBefore === undefined || clearQueued()
? { hasMore: false }
: { hasMore: true, nextBefore: loaded.nextBefore }
const store = {
const emptySummary = (): ActivityHistorySummary => ({
counts: { all: 0, active: 0, failed: 0 }, conversations: {}, legacyHistoryMayBeIncomplete: false
})
return {
/** The loaded records, newest first. */
getRecords(): readonly ActivityRecord[] {
return records
return page.records
},
getPage(): ActivityPageState {
return page
},
getGeneration(): number {
return generation
},
subscribe: subscribe(listeners),
getPanelRecords(): readonly ActivityRecord[] {
return panelRecords
/** What the Activity page shows (a snapshot). */
getPanel(): ActivityPageState {
return panel
},
/** Counts and conversation titles/status over the whole history (Main). */
getSummary(): ActivityHistorySummary | undefined {
return summary
},
subscribePanel: subscribe(panelListeners),
/** Shows the current list on the Activity page. */
/** Shows the current records on the Activity page. */
refreshPanel(): void {
if (panelRecords === records) return
panelRecords = records
if (panel === page) return
panel = page
notify(panelListeners)
},
setSummary(next: ActivityHistorySummary): void {
summary = next
notify(panelListeners)
},
/** Whether the stored history was loaded; changes are sent only after. */
isReady(): boolean {
return ready
},
/** Whether the migrated legacy history may have lost records. */
isLegacyIncomplete(): boolean {
return legacyIncomplete
markReady(): void {
ready = true
if (outbox.length > 0) notify(outboxListeners)
},
/** Adds a record, or moves a tool/subagent record of the same call to the front. */
record(incoming: ActivityRecord): void {
const next = upsertActivityRecord(records, incoming)
if (next === records) return
commit(next, [{ type: 'upsert', record: next[0]!, position: 'front' }])
apply((incoming.kind === 'tool' || incoming.kind === 'subagent') && incoming.callId
? { type: 'upsert-call', record: incoming }
: { type: 'upsert', record: incoming, position: 'front' })
},
/** Updates the request record(s) of a run where they are. */
updateRequest(
requestId: string,
status: ActivityRecord['status'],
detail?: string
): void {
mapInPlace((record) =>
record.requestId === requestId && record.kind === 'request'
? { ...record, status, detail: detail ?? record.detail }
: record
)
updateRequest(requestId: string, status: ActivityRecord['status'], detail?: string): void {
apply({ type: 'update-request', requestId, status, ...(detail === undefined ? {} : { detail }) })
},
/** Resolves the newest pending approval of a conversation. */
updateApproval(
conversationId: string,
status: ActivityRecord['status'],
detailLine: string
): void {
let updated = false
mapInPlace((record) => {
if (
updated ||
record.conversationId !== conversationId ||
record.kind !== 'approval' ||
record.status !== 'pending'
) {
return record
}
updated = true
return { ...record, status, detail: `${record.detail}\n${detailLine}` }
})
updateApproval(conversationId: string, status: ActivityRecord['status'], detailLine: string): void {
apply({ type: 'resolve-approval', conversationId, status, detailLine })
},
/** Drops every record of a request that never started. */
removeByRequest(requestId: string): void {
removeWhere((record) => record.requestId === requestId)
apply({ type: 'remove-request', requestId })
},
/** Drops the provisional tool record a subagent replaces. */
removeToolByCallId(requestId: string, callId: string): void {
removeWhere(
(record) =>
record.requestId === requestId &&
record.kind === 'tool' &&
record.callId === callId
)
apply({ type: 'remove-call', requestId, callId })
},
/** Ends the unfinished non-request records of a finished run. */
settleRequest(
requestId: string,
status: ActivityRecord['status'],
detailLine: string
): void {
mapInPlace((record) =>
record.requestId === requestId &&
record.kind !== 'request' &&
(record.status === 'pending' || record.status === 'running')
? { ...record, status, detail: `${record.detail}\n${detailLine}` }
: record
)
},
/** Ends records left running by a run that is no longer active. */
reconcile(
tasks: readonly AssistantTask[],
activeRequestIds: ReadonlySet<string>
): void {
const next = reconcileActivityRecords(records, tasks, activeRequestIds)
const changes: ActivityOutboxEntry[] = []
next.forEach((record, index) => {
if (record !== records[index]) {
changes.push({ type: 'upsert', record, position: 'in-place' })
}
})
if (changes.length > 0) commit(next, changes)
settleRequest(requestId: string, status: ActivityRecord['status'], detailLine: string): void {
apply({ type: 'settle-request', requestId, status, detailLine })
},
/**
* Adds the stored history behind the records of this session. Stored
* duplicates of an ID are dropped, as the list always showed only the
* first one.
* Ends records left running by requests that are no longer active: the
* loaded ones here, all of them in Main (joined with the stored tasks).
*/
mergeLoaded(
loaded: readonly ActivityRecord[],
loadedLegacyIncomplete: boolean
): void {
const next = mergeActivityRecords(records, loaded)
const hasDuplicates =
new Set(loaded.map((record) => record.id)).size < loaded.length
ready = true
legacyIncomplete = legacyIncomplete || loadedLegacyIncomplete
commit(next, hasDuplicates ? [{ type: 'remove-duplicates' }] : [])
if (outbox.length > 0) notify(outboxListeners)
reconcile(tasks: readonly AssistantTask[], activeRequestIds: ReadonlySet<string>): void {
const interruptedDetail = i18n.t('records.interruptedOnRestart', { ns: 'activity' })
const records = reconcileActivityRecords(page.records, tasks, activeRequestIds)
enqueue({ type: 'reconcile', request: { activeRequestIds: [...activeRequestIds], interruptedDetail }, tasks })
if (!sameRecords(records, page.records)) setPage({ ...page, records })
},
/**
* Replaces the loaded records with a first page from Main; this
* session's unsaved records stay in front. `generation` is the value
* when the request started: a response superseded meanwhile is dropped.
*/
setFirstPage(filter: ActivityHistoryFilter, loaded: ActivityHistoryPage, requestGeneration: number): boolean {
if (requestGeneration !== generation) return false
generation++
setPage({
filter,
records: firstPageRecords(loaded.records, filter),
...pageEnd(loaded)
})
return true
},
/** Appends the page that starts at the current `nextBefore`. */
appendPage(loaded: ActivityHistoryPage, requestGeneration: number, before: number): boolean {
if (requestGeneration !== generation || page.nextBefore !== before) return false
generation++
setPage({
filter: page.filter,
records: mergeActivityRecords(page.records, olderPageRecords(loaded.records)),
...pageEnd(loaded)
})
return true
},
/**
* Shows the loaded records of another filter at once; the sync then
* loads its first page. They are a prefix of that filter's list.
*/
showFilter(filter: ActivityHistoryFilter): void {
if (filter === page.filter) return
generation++
setPage({ filter, records: page.records.filter((record) => matchesActivityFilter(record, filter)), hasMore: true })
panel = page
notify(panelListeners)
},
/** Keeps at most the first page (leaving the Activity page); the next entry reloads. */
trim(size = ACTIVITY_PAGE_SIZE): void {
if (page.records.length <= size) return
generation++
setPage({ filter: page.filter, records: page.records.slice(0, size), hasMore: true })
// The hidden page lets go of the rest too; entering shows the same first rows.
panel = page
notify(panelListeners)
},
/**
* Clears the history here and, through the sync, in Main. Queued changes
@@ -455,27 +510,40 @@ export function createActivityStore(
* loaded only this session's records go, as before.
*/
clear(): void {
records = []
panelRecords = []
legacyIncomplete = false
generation++
page = { filter: page.filter, records: [], hasMore: false }
panel = page
summary = summary && emptySummary()
outbox = ready ? [{ type: 'clear' }] : []
sending = []
notify(listeners)
notify(panelListeners)
notify(outboxListeners)
},
/** Main already deleted everything (clear local data): nothing to send. */
reset(): void {
records = []
panelRecords = []
legacyIncomplete = false
generation++
page = { filter: 'all', records: [], hasMore: false }
panel = page
summary = summary && emptySummary()
outbox = []
notify(listeners)
notify(panelListeners)
},
/** The next save writes the whole list (Main's copy is not a known base). */
requireReplace(): void {
outbox = [{ type: 'replace' }]
notify(outboxListeners)
/**
* Queues the migration of the legacy localStorage history in front of
* this session's changes: the stored list becomes
* mergeActivityRecords(legacy, stored), as the former full replace did.
*/
queueLegacyMigration(legacy: readonly ActivityRecord[], mayBeIncomplete: boolean): void {
const changes: ActivityOutboxEntry[] = [...legacy].reverse().map((record) =>
({ type: 'upsert', record, position: 'front' }))
changes.push({ type: 'remove-duplicates' })
// The stored flag stays set when it was; the legacy history can only add to it.
if (mayBeIncomplete) changes.push({ type: 'legacy-flag', value: true })
const wasEmpty = outbox.length === 0
outbox = [...changes, ...outbox]
if (wasEmpty) notify(outboxListeners)
},
getOutbox(): readonly ActivityOutboxEntry[] {
@@ -484,13 +552,49 @@ export function createActivityStore(
subscribeOutbox: subscribe(outboxListeners),
/** Removes and returns the first `count` outbox entries. */
takeOutbox(count: number): ActivityOutboxEntry[] {
return outbox.splice(0, count)
sending = outbox.splice(0, count)
return sending
},
/** Puts entries that failed to save back in front, in order. */
/**
* The taken entries are saved. calls are Main's stored records of the
* sent upsert-call changes: a call whose earlier record was not loaded
* keeps that record's id and createdAt, so the loaded copy takes them.
*/
acknowledge(calls: readonly ActivityRecord[] = []): void {
const sent = sending.flatMap((entry) => (entry.type === 'upsert-call' ? [entry.record.id] : []))
sending = []
// The renderer's id of each sent call record -> the id Main keeps.
const renamed = new Map<string, ActivityRecord>()
sent.forEach((id, index) => {
const stored = calls[index]
if (stored && stored.id !== id) renamed.set(id, stored)
})
if (renamed.size === 0) return
const records = page.records.map((record) => {
const stored = renamed.get(record.id)
return stored ? { ...record, id: stored.id, createdAt: stored.createdAt, scope: stored.scope } : record
})
// Later queued changes refer to the renderer's id only through content, never by id.
setPage({ ...page, records: mergeActivityRecords(records, []) })
}, /** Puts entries that failed to save back in front, in order. */
requeue(entries: readonly ActivityOutboxEntry[]): void {
if (outbox[0]?.type === 'clear' || outbox[0]?.type === 'replace') return
sending = []
if (outbox[0]?.type === 'clear') return
outbox = [...entries, ...outbox]
}
}
return store
}
const callKey = (record: ActivityRecord): string =>
JSON.stringify([record.kind, record.requestId, record.callId])
const recordKeys: readonly (keyof ActivityRecord)[] = [
'id', 'conversationId', 'requestId', 'callId', 'kind', 'title', 'detail', 'status', 'createdAt'
]
function sameRecords(left: readonly ActivityRecord[], right: readonly ActivityRecord[]): boolean {
return left.length === right.length && left.every((record, index) => {
const other = right[index]!
return record === other || (recordKeys.every((key) => record[key] === other[key]) &&
JSON.stringify(record.scope) === JSON.stringify(other.scope))
})
}
+259 -135
View File
@@ -4,14 +4,28 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type {
ActivityHistoryPage,
ActivityHistorySnapshot,
ActivityHistorySummary,
ActivityHistoryUpdate,
ActivityRecord,
AssistantTask
} from '../../shared/assistant-contracts'
import { createActivityStore, type ActivityStore } from './activity-store'
import { firstOccurrences } from '../../shared/activity-history-reference'
import i18n from './i18n'
import {
ACTIVITY_FIRST_PAGE_SIZE,
createActivityStore,
mergeActivityRecords,
reconcileActivityRecords,
upsertActivityRecord,
type ActivityStore
} from './activity-store'
import {
loadActivityHistory,
loadFirstActivityPage,
loadNextActivityPage,
refreshActivitySummary,
startActivitySync,
type ActivityHistoryApi
} from './activity-sync'
@@ -22,8 +36,13 @@ type AssistantDatabase = {
close: () => void
getActivityHistory: () => ActivityHistorySnapshot
replaceActivityHistory: (input: unknown) => void
updateActivityHistory: (input: unknown) => void
updateActivityHistory: (input: unknown) => { calls: ActivityRecord[] }
clearActivityHistory: () => void
getActivityHistoryPage: (input: unknown) => ActivityHistoryPage
getActivityHistorySummary: (input: unknown) => ActivityHistorySummary
reconcileActivityHistory: (input: unknown) => number
createTask: (input: { id: string; title: string; instructions: string; workMode: 'ask' }) => AssistantTask
updateTaskStatus: (taskId: string, status: AssistantTask['status']) => void
}
let AssistantDatabase: new (path: string) => AssistantDatabase
beforeAll(async () => {
@@ -52,40 +71,44 @@ function openDatabase(): AssistantDatabase {
return database
}
/** IPC double over a real database; `fail()` decides whether a call throws first. */
function databaseApi(
database: AssistantDatabase,
fail: () => boolean = () => false
): ActivityHistoryApi & { updates: ActivityHistoryUpdate[] } {
type TestApi = ActivityHistoryApi & { updates: ActivityHistoryUpdate[]; calls: string[] }
/** IPC double over a real database (structured clones, like IPC); `fail()` makes a save throw. */
function databaseApi(database: AssistantDatabase, fail: () => boolean = () => false): TestApi {
const updates: ActivityHistoryUpdate[] = []
const guard = (): void => {
const calls: string[] = []
const guard = (name: string): void => {
calls.push(name)
if (fail()) throw new Error('injected save failure')
}
return {
updates,
get: async () => database.getActivityHistory(),
replace: async (records, legacyHistoryMayBeIncomplete) => {
guard()
database.replaceActivityHistory(
structuredClone({ records, legacyHistoryMayBeIncomplete })
)
},
calls,
update: async (update) => {
guard()
guard('update')
updates.push(update)
database.updateActivityHistory(structuredClone(update))
return structuredClone(database.updateActivityHistory(structuredClone(update)))
},
clear: async () => {
guard()
guard('clear')
database.clearActivityHistory()
},
reconcile: async (request) => {
guard('reconcile')
return database.reconcileActivityHistory(structuredClone(request))
},
page: async (request) => {
calls.push('page')
return database.getActivityHistoryPage(structuredClone(request))
},
summary: async (request) => {
calls.push('summary')
return database.getActivityHistorySummary(structuredClone(request))
}
}
}
const manualTimers = {
setTimeout: () => 1,
clearTimeout: () => undefined
}
const manualTimers = { setTimeout: () => 1, clearTimeout: () => undefined }
function makeRandom(seed: number): () => number {
let state = seed
@@ -109,140 +132,240 @@ function record(overrides: Partial<ActivityRecord> = {}): ActivityRecord {
}
}
function task(id: string, status: AssistantTask['status']): AssistantTask {
async function startup(
store: ActivityStore,
api: TestApi,
sync: { flush: () => Promise<void> },
activeRequestIds: ReadonlySet<string> = new Set(),
legacy: ActivityRecord[] = [],
legacyIncomplete = false
): Promise<void> {
await loadActivityHistory(store, api, { records: legacy, historyMayBeIncomplete: legacyIncomplete }, {
flush: sync.flush,
activeRequestIds: () => activeRequestIds,
onReadFailed: () => undefined,
isActive: () => true
})
}
/**
* The former App behavior over the whole list (activity state = every
* record), used as the oracle the paged store plus Main must match.
*/
function oracle(initial: readonly ActivityRecord[]) {
let list = firstOccurrences(initial)
return {
id,
title: id,
instructions: id,
workMode: 'ask',
status,
createdAt: new Date(0).toISOString()
} as AssistantTask
get: () => list,
record: (incoming: ActivityRecord) => { list = upsertActivityRecord(list, incoming) },
updateRequest: (requestId: string, status: ActivityRecord['status'], detail?: string) => {
list = list.map((item) => item.requestId === requestId && item.kind === 'request'
? { ...item, status, detail: detail ?? item.detail } : item)
},
updateApproval: (conversationId: string, status: ActivityRecord['status'], line: string) => {
let updated = false
list = list.map((item) => {
if (updated || item.conversationId !== conversationId || item.kind !== 'approval' || item.status !== 'pending') return item
updated = true
return { ...item, status, detail: `${item.detail}\n${line}` }
})
},
removeByRequest: (requestId: string) => { list = list.filter((item) => item.requestId !== requestId) },
removeToolByCallId: (requestId: string, callId: string) => {
list = list.filter((item) => !(item.requestId === requestId && item.kind === 'tool' && item.callId === callId))
},
settleRequest: (requestId: string, status: ActivityRecord['status'], line: string) => {
list = list.map((item) => item.requestId === requestId && item.kind !== 'request' &&
(item.status === 'pending' || item.status === 'running')
? { ...item, status, detail: `${item.detail}\n${line}` } : item)
},
reconcile: (tasks: readonly AssistantTask[], active: ReadonlySet<string>) => {
list = reconcileActivityRecords(list, tasks, active)
},
clear: () => { list = [] },
prepend: (records: readonly ActivityRecord[]) => { list = mergeActivityRecords(records, list) }
}
}
async function loadedStore(api: ActivityHistoryApi): Promise<ActivityStore> {
const store = createActivityStore()
await loadActivityHistory(
store,
api,
{ records: [], historyMayBeIncomplete: false },
{ onReadFailed: () => undefined, onSaveFailed: () => undefined, isActive: () => true }
)
return store
}
describe('activity history sync', () => {
it('stores what a full replace of the final list stores, for random actions, flushes and failures', async () => {
for (const seed of [3, 17, 2026]) {
describe('activity history sync (paged)', () => {
it('ends in the state the former full-list code saved, for random actions, pages, trims, flushes and failures', async () => {
for (const seed of [3, 17, 2026, 41, 7777, 90210]) {
const random = makeRandom(seed)
const pick = <T,>(items: readonly T[]): T =>
items[Math.floor(random() * items.length)]!
const incremental = openDatabase()
// Legacy history with duplicate IDs, stored by the former full replace.
const first = record({ id: 'dup' })
const legacy = [
first,
record({ status: 'completed' }),
{ ...first, detail: 'older duplicate' },
record({ kind: 'request', status: 'running', requestId: 'request-1' }),
record({ kind: 'approval', status: 'pending', conversationId: 'conversation-1' })
]
incremental.replaceActivityHistory({
records: legacy,
legacyHistoryMayBeIncomplete: seed % 2 === 0
})
const pick = <T,>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
const database = openDatabase()
// Stored history with legacy duplicate IDs; the first occurrence is the shown one.
const stored = Array.from({ length: 260 }, (_, index) => record({
kind: pick(['tool', 'subagent', 'request', 'result', 'approval'] as const),
status: pick(['pending', 'running', 'completed', 'failed'] as const),
requestId: `request-${index % 7}`,
conversationId: `conversation-${index % 4}`,
...(index % 3 === 0 ? { callId: pick(['a', 'b', 'c']) } : {})
}))
stored.splice(40, 0, { ...stored[3]!, detail: 'hidden duplicate' })
stored.splice(250, 0, { ...stored[200]!, detail: 'hidden duplicate' })
database.replaceActivityHistory({ records: stored, legacyHistoryMayBeIncomplete: false })
const tasks: AssistantTask[] = []
for (const [index, status] of (['completed', 'failed', 'running', 'cancelled'] as const).entries()) {
const created = database.createTask({ id: `request-${index}`, title: 't', instructions: 'i', workMode: 'ask' })
database.updateTaskStatus(created.id, status)
tasks.push({ ...created, status })
}
const expected = oracle(stored)
let failing = false
let expectedFlag = seed % 2 === 0
const api = databaseApi(incremental, () => failing && random() < 0.5)
const store = await loadedStore(api)
const sync = startActivitySync(store, api, {
onSaveFailed: () => undefined,
timers: manualTimers
})
const api = databaseApi(database, () => failing && random() < 0.5)
const store = createActivityStore()
const sync = startActivitySync(store, api, { onSaveFailed: () => undefined, timers: manualTimers })
const active = new Set(['request-5'])
await startup(store, api, sync, active)
expected.reconcile(tasks, active)
expect(store.getRecords().length).toBeLessThanOrEqual(ACTIVITY_FIRST_PAGE_SIZE)
const callIds = ['a', 'b', 'c', 'd']
for (let step = 0; step < 400; step++) {
for (let step = 0; step < 500; step++) {
const roll = random()
const requestId = `request-${Math.floor(random() * 5)}`
if (roll < 0.3) {
const requestId = `request-${Math.floor(random() * 8)}`
const conversationId = `conversation-${Math.floor(random() * 4)}`
if (roll < 0.25) {
const kind = pick(['tool', 'subagent', 'request', 'result', 'approval'] as const)
store.record(record({
kind,
requestId,
const incoming = record({
kind, requestId, conversationId,
...(kind === 'tool' || kind === 'subagent' ? { callId: pick(callIds) } : {}),
status: pick(['pending', 'running', 'completed', 'failed'] as const)
}))
} else if (roll < 0.4) {
store.updateRequest(requestId, pick(['completed', 'failed'] as const), random() < 0.5 ? 'done' : undefined)
} else if (roll < 0.47) {
store.updateApproval(`conversation-${Math.floor(random() * 3)}`, 'denied', 'decision')
} else if (roll < 0.52) {
})
store.record(incoming)
expected.record(incoming)
} else if (roll < 0.35) {
const status = pick(['completed', 'failed'] as const)
const detail = random() < 0.5 ? 'done' : undefined
store.updateRequest(requestId, status, detail)
expected.updateRequest(requestId, status, detail)
} else if (roll < 0.42) {
store.updateApproval(conversationId, 'denied', 'decision')
expected.updateApproval(conversationId, 'denied', 'decision')
} else if (roll < 0.46) {
store.removeByRequest(requestId)
} else if (roll < 0.58) {
store.removeToolByCallId(requestId, pick(callIds))
} else if (roll < 0.65) {
expected.removeByRequest(requestId)
} else if (roll < 0.51) {
const callId = pick(callIds)
store.removeToolByCallId(requestId, callId)
expected.removeToolByCallId(requestId, callId)
} else if (roll < 0.57) {
store.settleRequest(requestId, 'interrupted', 'incomplete')
} else if (roll < 0.72) {
store.reconcile(
[task('request-0', 'completed'), task('request-2', 'failed'), task('request-3', 'running')],
new Set(random() < 0.5 ? ['request-1'] : [])
)
} else if (roll < 0.74) {
expected.settleRequest(requestId, 'interrupted', 'incomplete')
} else if (roll < 0.62) {
const running = new Set(random() < 0.5 ? ['request-1'] : [])
store.reconcile(tasks, running)
expected.reconcile(tasks, running)
} else if (roll < 0.635) {
store.clear()
expectedFlag = false
} else if (roll < 0.8) {
expected.clear()
} else if (roll < 0.68) {
failing = random() < 0.4
} else if (roll < 0.73) {
await loadNextActivityPage(store, api)
} else if (roll < 0.76) {
store.trim(Math.floor(random() * 60))
} else if (roll < 0.79) {
await loadFirstActivityPage(store, api, 1 + Math.floor(random() * 80))
} else {
await sync.flush()
}
// Loaded records are always the newest records of the full list.
if (store.getOutbox().length === 0) {
const loaded = store.getRecords()
expect(loaded).toEqual(expected.get().slice(0, loaded.length))
}
}
failing = false
await sync.flush()
expect(store.getOutbox()).toEqual([])
const shown = firstOccurrences(database.getActivityHistory().records)
expect(shown).toEqual(expected.get())
const replaced = openDatabase()
replaced.replaceActivityHistory({
records: [...store.getRecords()],
legacyHistoryMayBeIncomplete: expectedFlag
})
expect(incremental.getActivityHistory()).toEqual(replaced.getActivityHistory())
expect(incremental.getActivityHistory().records).toEqual(store.getRecords())
replaced.replaceActivityHistory({ records: expected.get(), legacyHistoryMayBeIncomplete: false })
expect(shown).toEqual(replaced.getActivityHistory().records)
sync.stop()
}
})
}, 60_000)
it('sends only the changed records', async () => {
it('keeps at most one page after startup and sends only changed records, including unloaded ones', async () => {
const database = openDatabase()
const records = Array.from({ length: 5_000 }, () => record({ status: 'completed' }))
const records = Array.from({ length: 5_000 }, (_, index) => record({ status: 'completed', kind: index % 5 === 0 ? 'request' : 'tool', requestId: `request-${index}` }))
database.replaceActivityHistory({ records, legacyHistoryMayBeIncomplete: false })
const api = databaseApi(database)
const store = await loadedStore(api)
const store = createActivityStore()
const sync = startActivitySync(store, api, { onSaveFailed: () => undefined, timers: manualTimers })
await startup(store, api, sync)
expect(store.getRecords()).toHaveLength(ACTIVITY_FIRST_PAGE_SIZE)
expect(api.calls).toEqual(['reconcile', 'page'])
const tool = record({ callId: 'call-1', requestId: 'request-x' })
store.record(tool)
store.record({ ...tool, id: 'ignored', detail: 'progress', status: 'completed' })
store.updateRequest(records[4_000]!.requestId, 'failed')
// records[4_000] is far outside the loaded page.
store.updateRequest(records[4_000]!.requestId, 'failed', 'late failure')
await sync.flush()
// Two consecutive upserts of the tool collapse into one.
expect(api.updates).toHaveLength(1)
const changes = api.updates[0]!.changes
const requestRecords = records.filter(
(item) => item.requestId === records[4_000]!.requestId && item.kind === 'request'
)
expect(changes).toHaveLength(1 + requestRecords.length)
expect(JSON.stringify(api.updates[0]).length).toBeLessThan(1_000)
expect(database.getActivityHistory().records[0]).toMatchObject({
id: tool.id,
detail: 'progress'
})
expect(api.updates[0]!.changes).toHaveLength(3)
expect(JSON.stringify(api.updates[0]).length).toBeLessThan(1_500)
const after = database.getActivityHistory().records
expect(after[0]).toMatchObject({ id: tool.id, detail: 'progress', status: 'completed' })
expect(after.find((item) => item.id === records[4_000]!.id)).toMatchObject({ status: 'failed', detail: 'late failure' })
expect(store.getRecords().length).toBeLessThanOrEqual(ACTIVITY_FIRST_PAGE_SIZE + 1)
sync.stop()
})
it('shows a tool call once when its earlier record arrives on a later page while the update is queued', async () => {
const database = openDatabase()
const old = record({ kind: 'tool', callId: 'call-z', requestId: 'request-z', status: 'running' })
const newer = Array.from({ length: 4 }, () => record({ status: 'completed' }))
database.replaceActivityHistory({ records: [...newer, old], legacyHistoryMayBeIncomplete: false })
const api = databaseApi(database)
const store = createActivityStore()
const sync = startActivitySync(store, api, { onSaveFailed: () => undefined, timers: manualTimers })
await startup(store, api, sync)
await loadFirstActivityPage(store, api, 2)
const incoming = record({ kind: 'tool', callId: 'call-z', requestId: 'request-z', status: 'completed', detail: 'done' })
store.record(incoming)
await loadNextActivityPage(store, api)
const expected = upsertActivityRecord([...newer, old], incoming)
// Until saved, the call carries this session's id; content and order already match.
const withoutIds = (list: readonly ActivityRecord[]): unknown[] =>
list.map((item) => ({ ...item, id: undefined, createdAt: undefined }))
expect(store.getRecords()).toHaveLength(5)
expect(withoutIds(store.getRecords())).toEqual(withoutIds(expected))
await sync.flush()
expect(store.getRecords()).toEqual(expected.slice(0, store.getRecords().length))
expect(database.getActivityHistory().records).toEqual(expected)
sync.stop()
})
it('marks records left running by the previous session interrupted, with the same localized line', async () => {
const database = openDatabase()
const leftover = record({ status: 'running', requestId: 'gone', detail: 'reading' })
const done = record({ status: 'running', requestId: 'finished', kind: 'request', detail: 'started' })
const live = record({ status: 'running', requestId: 'live' })
database.replaceActivityHistory({ records: [leftover, done, live], legacyHistoryMayBeIncomplete: false })
const task = database.createTask({ id: 'finished', title: 't', instructions: 'i', workMode: 'ask' })
database.updateTaskStatus(task.id, 'completed')
const api = databaseApi(database)
const store = createActivityStore()
const sync = startActivitySync(store, api, { onSaveFailed: () => undefined, timers: manualTimers })
await startup(store, api, sync, new Set(['live']))
const line = i18n.t('records.interruptedOnRestart', { ns: 'activity' })
expect(line.length).toBeGreaterThan(0)
const expected = reconcileActivityRecords([leftover, done, live], [{ ...task, status: 'completed' }], new Set(['live']))
expect(expected[0]!.detail).toBe(`reading\n${line}`)
expect(database.getActivityHistory().records).toEqual(expected)
expect(store.getRecords()).toEqual(expected)
sync.stop()
})
it('keeps failed changes queued and retries them in order with backoff', async () => {
const database = openDatabase()
let fail = true
let fail = false
const api = databaseApi(database, () => fail)
const store = await loadedStore(api)
const store = createActivityStore()
const delays: number[] = []
const callbacks: Array<() => void> = []
const onSaveFailed = vi.fn()
@@ -257,6 +380,8 @@ describe('activity history sync', () => {
clearTimeout: () => undefined
}
})
await startup(store, api, sync)
fail = true
const a = record()
const b = record()
store.record(a)
@@ -276,45 +401,44 @@ describe('activity history sync', () => {
sync.stop()
})
it('clears the database in one call and drops queued changes', async () => {
it('clear empties the database, the loaded pages and the summary', async () => {
const database = openDatabase()
database.replaceActivityHistory({ records: [record(), record()], legacyHistoryMayBeIncomplete: true })
const api = databaseApi(database)
const update = vi.spyOn(api, 'update')
const store = await loadedStore(api)
const store = createActivityStore()
const sync = startActivitySync(store, api, { onSaveFailed: () => undefined, timers: manualTimers })
await startup(store, api, sync)
store.refreshPanel()
await refreshActivitySummary(store, api)
expect(store.getSummary()?.counts.all).toBe(2)
store.record(record())
store.clear()
await sync.flush()
expect(update).not.toHaveBeenCalled()
expect(api.updates).toEqual([])
expect(database.getActivityHistory()).toEqual({ records: [], legacyHistoryMayBeIncomplete: false })
expect(store.getRecords()).toEqual([])
expect(store.getPanel().records).toEqual([])
expect(store.getSummary()?.counts).toEqual({ all: 0, active: 0, failed: 0 })
sync.stop()
})
it('migrates legacy records with one replace and retries it in full after a failure', async () => {
it('migrates legacy localStorage records in front of the stored ones, retrying after a failure', async () => {
const database = openDatabase()
const stored = record({ status: 'completed' })
database.replaceActivityHistory({ records: [stored], legacyHistoryMayBeIncomplete: false })
const both = record({ status: 'completed' })
database.replaceActivityHistory({ records: [stored, both], legacyHistoryMayBeIncomplete: false })
let fail = true
const api = databaseApi(database, () => fail)
const legacyRecord = record({ status: 'completed' })
const store = createActivityStore([legacyRecord], true)
await loadActivityHistory(
store,
api,
{ records: [legacyRecord], historyMayBeIncomplete: true },
{ onReadFailed: () => undefined, onSaveFailed: () => undefined, isActive: () => true }
)
expect(store.getRecords()).toEqual([legacyRecord, stored])
expect(store.getOutbox()).toEqual([{ type: 'replace' }])
fail = false
const store = createActivityStore([legacyRecord, both])
const sync = startActivitySync(store, api, { onSaveFailed: () => undefined, timers: manualTimers })
await startup(store, api, sync, new Set(), [legacyRecord, both], true)
fail = false
await sync.flush()
expect(database.getActivityHistory()).toEqual({
records: [legacyRecord, stored],
legacyHistoryMayBeIncomplete: true
})
await loadFirstActivityPage(store, api)
const expected = mergeActivityRecords([legacyRecord, both], [stored, both])
expect(database.getActivityHistory()).toEqual({ records: expected, legacyHistoryMayBeIncomplete: true })
expect(store.getRecords()).toEqual(expected)
sync.stop()
})
})
+186 -77
View File
@@ -1,34 +1,47 @@
import { useCallback, useEffect, useRef } from 'react'
import type {
ActivityHistoryChange,
ActivityHistorySnapshot,
ActivityHistoryFilter,
ActivityHistoryPage,
ActivityHistoryPageRequest,
ActivityHistoryReconcileRequest,
ActivityHistorySummary,
ActivityHistorySummaryRequest,
ActivityHistoryUpdate,
ActivityRecord
ActivityHistoryUpdateResult
} from '../../shared/assistant-contracts'
import i18n from './i18n'
import {
ACTIVITY_FIRST_PAGE_SIZE,
ACTIVITY_PAGE_SIZE,
clearLegacyActivityHistory,
mergeActivityRecords,
isActivityChange,
type ActivityOutboxEntry,
type ActivityStore,
type LegacyActivityHistory
} from './activity-store'
/**
* Activity history IPC with Main (P4): the first load with the legacy
* localStorage migration, and the incremental save queue. Changes collect in
* the store's outbox and leave 250 ms after the first one, at most 20,000 per
* `update` call. A failed save keeps its changes queued, in order, and is
* retried with backoff; quitting and unmounting flush what is left.
* Activity history IPC with Main (P4): startup (legacy migration, reconcile,
* first page and summary), pages on demand, and the incremental save queue.
* Changes collect in the store's outbox and leave 250 ms after the first
* one, at most 20,000 per `update` call. A failed save keeps its changes
* queued, in order, and is retried with backoff; quitting and unmounting
* flush what is left.
*/
export type ActivityHistoryApi = {
get: () => Promise<ActivityHistorySnapshot>
replace: (records: ActivityRecord[], legacyHistoryMayBeIncomplete: boolean) => Promise<void>
update: (update: ActivityHistoryUpdate) => Promise<void>
update: (update: ActivityHistoryUpdate) => Promise<ActivityHistoryUpdateResult | void>
clear: () => Promise<void>
page: (request: ActivityHistoryPageRequest) => Promise<ActivityHistoryPage>
summary: (request: ActivityHistorySummaryRequest) => Promise<ActivityHistorySummary>
reconcile: (request: ActivityHistoryReconcileRequest) => Promise<number>
}
export const ACTIVITY_SAVE_DELAY_MS = 250
export const ACTIVITY_MAX_CHANGES_PER_UPDATE = 20_000
/** The Activity page shows this many records before "load more", as before paging. */
export const ACTIVITY_VISIBLE_BATCH = 500
const MAX_SUMMARY_CONVERSATIONS = 5_000
const RETRY_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000]
export type ActivitySync = {
@@ -51,8 +64,8 @@ const saving = new WeakMap<ActivityStore, Promise<void>>()
export function startActivitySync(
store: ActivityStore,
api: ActivityHistoryApi,
options: { onSaveFailed: () => void; timers?: Timers; delayMs?: number }
api: Pick<ActivityHistoryApi, 'update' | 'clear' | 'reconcile'>,
options: { onSaveFailed: () => void; onDrained?: () => void; timers?: Timers; delayMs?: number }
): ActivitySync {
const timers = options.timers ?? defaultTimers
const delayMs = options.delayMs ?? ACTIVITY_SAVE_DELAY_MS
@@ -72,33 +85,33 @@ export function startActivitySync(
const sendNext = async (): Promise<boolean> => {
const first = store.getOutbox()[0]
if (!first) return true
let batch: ActivityOutboxEntry[]
let batch: ActivityOutboxEntry[] = []
try {
if (first.type === 'clear') {
batch = store.takeOutbox(1)
await api.clear()
} else if (first.type === 'replace') {
// The full list already contains everything queued after the marker.
batch = store.takeOutbox(store.getOutbox().length)
await api.replace([...store.getRecords()], store.isLegacyIncomplete())
} else if (first.type === 'reconcile') {
batch = store.takeOutbox(1)
await api.reconcile(first.request)
} else if (first.type === 'legacy-flag') {
batch = store.takeOutbox(1)
await api.update({ changes: [], legacyHistoryMayBeIncomplete: first.value })
} else {
const outbox = store.getOutbox()
let count = 0
while (
count < outbox.length &&
count < ACTIVITY_MAX_CHANGES_PER_UPDATE &&
outbox[count]!.type !== 'clear' &&
outbox[count]!.type !== 'replace'
) {
while (count < outbox.length && count < ACTIVITY_MAX_CHANGES_PER_UPDATE && isActivityChange(outbox[count]!)) {
count++
}
batch = store.takeOutbox(count)
await api.update({ changes: batch as ActivityHistoryChange[] })
const result = await api.update({ changes: batch as ActivityHistoryChange[] })
store.acknowledge(result ? result.calls : [])
return true
}
} catch {
store.requeue(batch!)
store.requeue(batch)
return false
}
store.acknowledge()
return true
}
@@ -112,6 +125,7 @@ export function startActivitySync(
}
failures = 0
}
options.onDrained?.()
}
const flush = async (): Promise<void> => {
@@ -128,9 +142,7 @@ export function startActivitySync(
}
const unsubscribe = store.subscribeOutbox(() => {
if (store.isReady() && store.getOutbox().length > 0 && failures === 0) {
schedule(delayMs)
}
if (store.isReady() && store.getOutbox().length > 0 && failures === 0) schedule(delayMs)
})
if (store.isReady() && store.getOutbox().length > 0) schedule(delayMs)
@@ -145,33 +157,132 @@ export function startActivitySync(
}
}
/** Loads the first page of the store's filter (replacing the loaded pages). */
export async function loadFirstActivityPage(
store: ActivityStore,
api: Pick<ActivityHistoryApi, 'page'>,
limit: number = ACTIVITY_FIRST_PAGE_SIZE,
filter: ActivityHistoryFilter = store.getPage().filter
): Promise<void> {
const generation = store.getGeneration()
const page = await api.page({ limit, ...(filter === 'all' ? {} : { filter }) })
store.setFirstPage(filter, page, generation)
}
/** Loads the page after the loaded ones, when there is one. */
export async function loadNextActivityPage(
store: ActivityStore,
api: Pick<ActivityHistoryApi, 'page'>
): Promise<void> {
const { filter, nextBefore, hasMore } = store.getPage()
if (!hasMore) return
if (nextBefore === undefined) {
// Trimmed: reload the head, then continue from its end as if never trimmed.
await loadFirstActivityPage(store, api, ACTIVITY_PAGE_SIZE)
if (store.getPage().nextBefore !== undefined) await loadNextActivityPage(store, api)
return
}
const generation = store.getGeneration()
const page = await api.page({ before: nextBefore, limit: ACTIVITY_PAGE_SIZE, ...(filter === 'all' ? {} : { filter }) })
store.appendPage(page, generation, nextBefore)
}
/** Reloads the summary for the conversations the page shows. */
export async function refreshActivitySummary(
store: ActivityStore,
api: Pick<ActivityHistoryApi, 'summary'>
): Promise<void> {
const conversationIds = [...new Set(store.getPanel().records.map((record) => record.conversationId))]
.slice(0, MAX_SUMMARY_CONVERSATIONS)
store.setSummary(await api.summary({ conversationIds }))
}
/**
* Startup: queue the legacy migration, let saves start and send what is
* queued, let Main end records left running by the previous session, then
* load the first page.
*/
export async function loadActivityHistory(
store: ActivityStore,
api: Pick<ActivityHistoryApi, 'page' | 'reconcile'>,
legacy: LegacyActivityHistory,
handlers: {
flush: () => Promise<void>
activeRequestIds: () => ReadonlySet<string>
onReadFailed: () => void
isActive: () => boolean
}
): Promise<void> {
if (legacy.records.length > 0 || legacy.historyMayBeIncomplete) {
store.queueLegacyMigration(legacy.records, legacy.historyMayBeIncomplete)
}
store.markReady()
await handlers.flush()
if (!handlers.isActive()) return
try {
await api.reconcile({
activeRequestIds: [...handlers.activeRequestIds()],
interruptedDetail: i18n.t('records.interruptedOnRestart', { ns: 'activity' })
})
} catch {
// The next task refresh reconciles again.
}
if (!handlers.isActive()) return
try {
await loadFirstActivityPage(store, api)
} catch {
if (handlers.isActive()) handlers.onReadFailed()
}
}
/**
* Loads the stored history on mount, then saves changes incrementally. The
* returned `flush` (stable) sends what is queued, for the quit handshake; the
* queue is also flushed on unmount.
* returned actions are stable: `flush` (quit handshake), `show` (the
* Activity page opens or its timer/refresh runs: flush, load at least one
* visible batch, refresh the snapshot and summary), `hide`, `loadMore` and
* `setFilter`.
*/
export function useActivityHistorySync(
store: ActivityStore,
legacy: LegacyActivityHistory,
notices: { onReadFailed: () => void; onSaveFailed: () => void; onLoaded?: () => void }
): () => Promise<void> {
const noticesRef = useRef(notices)
options: {
activeRequestIds: () => ReadonlySet<string>
onReadFailed: () => void
onSaveFailed: () => void
onLoaded?: () => void
}
): {
flush: () => Promise<void>
show: () => Promise<void>
hide: () => void
loadMore: () => Promise<void>
setFilter: (filter: ActivityHistoryFilter) => Promise<void>
} {
const optionsRef = useRef(options)
useEffect(() => {
noticesRef.current = notices
optionsRef.current = options
})
const syncRef = useRef<ActivitySync | undefined>(undefined)
useEffect(() => {
let active = true
let migrating = legacy.records.length > 0 || legacy.historyMayBeIncomplete
const api = window.goodbuddy.activityHistory
const onSaveFailed = (): void => noticesRef.current.onSaveFailed()
const sync = startActivitySync(store, api, { onSaveFailed })
const sync = startActivitySync(store, api, {
onSaveFailed: () => optionsRef.current.onSaveFailed(),
onDrained: () => {
// The legacy records are in Main once the queue in front of them drained.
if (migrating) clearLegacyActivityHistory()
migrating = false
}
})
syncRef.current = sync
void loadActivityHistory(store, api, legacy, {
onReadFailed: () => noticesRef.current.onReadFailed(),
onSaveFailed,
flush: sync.flush,
activeRequestIds: () => optionsRef.current.activeRequestIds(),
onReadFailed: () => optionsRef.current.onReadFailed(),
isActive: () => active
}).then(() => {
if (active && store.isReady()) noticesRef.current.onLoaded?.()
if (active) optionsRef.current.onLoaded?.()
})
return () => {
active = false
@@ -180,46 +291,44 @@ export function useActivityHistorySync(
void sync.flush()
}
}, [legacy, store])
return useCallback(async () => {
const flush = useCallback(async () => {
await syncRef.current?.flush()
}, [])
}
/**
* Loads the stored history once. A legacy localStorage history is written
* together with it as a full `replace` and then removed; the store is ready
* (saves start) only after the load succeeded.
*/
export async function loadActivityHistory(
store: ActivityStore,
api: ActivityHistoryApi,
legacy: LegacyActivityHistory,
handlers: { onReadFailed: () => void; onSaveFailed: () => void; isActive: () => boolean }
): Promise<void> {
let snapshot: ActivityHistorySnapshot
try {
snapshot = await api.get()
} catch {
if (handlers.isActive()) handlers.onReadFailed()
return
}
if (!handlers.isActive()) return
let replaceFailed = false
if (legacy.records.length > 0 || legacy.historyMayBeIncomplete) {
// Everything queued so far is part of the list sent here.
store.takeOutbox(store.getOutbox().length)
const api = (): ActivityHistoryApi => window.goodbuddy.activityHistory
const show = useCallback(async () => {
store.refreshPanel()
await flush()
try {
await api.replace(
mergeActivityRecords(store.getRecords(), snapshot.records),
legacy.historyMayBeIncomplete || snapshot.legacyHistoryMayBeIncomplete
)
clearLegacyActivityHistory()
if (store.getRecords().length < ACTIVITY_VISIBLE_BATCH && store.getPage().hasMore) {
await loadFirstActivityPage(store, api(), ACTIVITY_VISIBLE_BATCH)
store.refreshPanel()
}
await refreshActivitySummary(store, api())
} catch {
replaceFailed = true
handlers.onSaveFailed()
optionsRef.current.onReadFailed()
}
if (!handlers.isActive()) return
}
store.mergeLoaded(snapshot.records, snapshot.legacyHistoryMayBeIncomplete)
if (replaceFailed) store.requireReplace()
}, [flush, store])
const hide = useCallback(() => store.trim(), [store])
const loadMore = useCallback(async () => {
try {
await flush()
await loadNextActivityPage(store, api())
store.refreshPanel()
await refreshActivitySummary(store, api())
} catch {
optionsRef.current.onReadFailed()
}
}, [flush, store])
const setFilter = useCallback(async (filter: ActivityHistoryFilter) => {
store.showFilter(filter)
try {
await flush()
await loadFirstActivityPage(store, api(), ACTIVITY_VISIBLE_BATCH, filter)
store.refreshPanel()
await refreshActivitySummary(store, api())
} catch {
optionsRef.current.onReadFailed()
}
}, [flush, store])
return { flush, show, hide, loadMore, setFilter }
}
+71 -14
View File
@@ -120,21 +120,78 @@ export function applyActivityChanges(
changes: readonly ActivityHistoryChange[]
): ActivityRecord[] {
let next = [...list]
/** Indexes of shown records (first occurrences) that match. */
const shown = (match: (record: ActivityRecord) => boolean): number[] => {
const seen = new Set<string>()
const indexes: number[] = []
next.forEach((record, index) => {
if (!seen.has(record.id) && match(record)) indexes.push(index)
seen.add(record.id)
})
return indexes
}
const updateAt = (indexes: readonly number[], update: (record: ActivityRecord) => ActivityRecord): void => {
const set = new Set(indexes)
next = next.map((record, index) => (set.has(index) ? update(record) : record))
}
const removeIds = (ids: ReadonlySet<string>): void => {
next = next.filter((record) => !ids.has(record.id))
}
for (const change of changes) {
if (change.type === 'remove') {
next = next.filter((item) => item.id !== change.id)
continue
switch (change.type) {
case 'remove':
removeIds(new Set([change.id]))
break
case 'remove-duplicates':
next = firstOccurrences(next)
break
case 'upsert': {
const index = next.findIndex((item) => item.id === change.record.id)
if (index < 0) next = [change.record, ...next]
else if (change.position === 'front') next = [change.record, ...next.filter((_, at) => at !== index)]
else next = next.map((item, at) => (at === index ? change.record : item))
break
}
case 'upsert-call': {
const incoming = change.record
const [index] = shown((record) => record.kind === incoming.kind &&
record.requestId === incoming.requestId && record.callId === incoming.callId)
if (index === undefined) {
next = [incoming, ...next]
break
}
const existing = next[index]!
const unchanged = existing.conversationId === incoming.conversationId && existing.title === incoming.title &&
existing.detail === incoming.detail && existing.status === incoming.status
if (unchanged && index === 0) break
next = [
unchanged ? existing : { ...incoming, id: existing.id, createdAt: existing.createdAt, scope: existing.scope },
...next.filter((_, at) => at !== index)
]
break
}
case 'update-request':
updateAt(shown((record) => record.requestId === change.requestId && record.kind === 'request'),
(record) => ({ ...record, status: change.status, detail: change.detail ?? record.detail }))
break
case 'resolve-approval':
updateAt(shown((record) => record.conversationId === change.conversationId &&
record.kind === 'approval' && record.status === 'pending').slice(0, 1),
(record) => ({ ...record, status: change.status, detail: `${record.detail}\n${change.detailLine}` }))
break
case 'settle-request':
updateAt(shown((record) => record.requestId === change.requestId && record.kind !== 'request' &&
isActive(record)),
(record) => ({ ...record, status: change.status, detail: `${record.detail}\n${change.detailLine}` }))
break
case 'remove-request':
removeIds(new Set(shown((record) => record.requestId === change.requestId).map((index) => next[index]!.id)))
break
case 'remove-call':
removeIds(new Set(shown((record) => record.requestId === change.requestId && record.kind === 'tool' &&
record.callId === change.callId).map((index) => next[index]!.id)))
break
}
if (change.type === 'remove-duplicates') {
const seen = new Set<string>()
next = next.filter((item) => !seen.has(item.id) && Boolean(seen.add(item.id)))
continue
}
const index = next.findIndex((item) => item.id === change.record.id)
if (index < 0) next = [change.record, ...next]
else if (change.position === 'front') {
next = [change.record, ...next.filter((_, at) => at !== index)]
} else next = next.map((item, at) => (at === index ? change.record : item))
}
return next
}
}
+56 -4
View File
@@ -110,9 +110,25 @@ export const activityHistorySnapshotSchema = z
* - `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.
* - `remove-duplicates` deletes every record but the first of each ID; the
* renderer shows only the first (see `mergeActivityRecords`).
* The other changes match shown records (first occurrences) by content, so
* the renderer does not need to have them loaded:
* - `upsert-call`: a tool/subagent record with a callId. The first record
* of the same kind, request and call is moved to the front with the new
* content but its own id, createdAt and scope (nothing happens when it is
* unchanged and already first); without one the record is inserted at the
* front (renderer `upsertActivityRecord`).
* - `update-request`: every request record of the request gets the status
* (and the detail, when given), in place.
* - `resolve-approval`: the first pending approval of the conversation gets
* the status and `detailLine` appended on a new line, in place.
* - `settle-request`: every pending/running non-request record of the
* request gets the status and `detailLine` appended, in place.
* - `remove-request`: deletes every record of the request.
* - `remove-call`: deletes the tool records of the request with the callId.
*/
const activityIdSchema = z.string().min(1).max(256)
const activityStatusSchema = activityRecordSchema.shape.status
const activityDetailLineSchema = z.string().max(1_000_000)
export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('upsert'),
@@ -121,9 +137,43 @@ export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
}).strict(),
z.object({
type: z.literal('remove'),
id: z.string().min(1).max(256)
id: activityIdSchema
}).strict(),
z.object({ type: z.literal('remove-duplicates') }).strict()
z.object({ type: z.literal('remove-duplicates') }).strict(),
z.object({
type: z.literal('upsert-call'),
record: activityRecordSchema.refine(
(record) => (record.kind === 'tool' || record.kind === 'subagent') && record.callId !== undefined,
'upsert-call needs a tool or subagent record with a callId'
)
}).strict(),
z.object({
type: z.literal('update-request'),
requestId: activityIdSchema,
status: activityStatusSchema,
detail: z.string().optional()
}).strict(),
z.object({
type: z.literal('resolve-approval'),
conversationId: activityIdSchema,
status: activityStatusSchema,
detailLine: activityDetailLineSchema
}).strict(),
z.object({
type: z.literal('settle-request'),
requestId: activityIdSchema,
status: activityStatusSchema,
detailLine: activityDetailLineSchema
}).strict(),
z.object({
type: z.literal('remove-request'),
requestId: activityIdSchema
}).strict(),
z.object({
type: z.literal('remove-call'),
requestId: activityIdSchema,
callId: activityIdSchema
}).strict()
])
export const activityHistoryUpdateSchema = z
@@ -189,6 +239,8 @@ export type ActivityHistorySnapshot = z.infer<
>
export type ActivityHistoryChange = z.infer<typeof activityHistoryChangeSchema>
export type ActivityHistoryUpdate = z.infer<typeof activityHistoryUpdateSchema>
/** The stored record of each upsert-call change, in order. */
export type ActivityHistoryUpdateResult = { calls: ActivityRecord[] }
export const projectExecutionSpaceSchema = z.discriminatedUnion(
'kind',
+2 -1
View File
@@ -51,6 +51,7 @@ import {
type ActivityHistorySummary,
type ActivityHistorySummaryRequest,
type ActivityHistoryUpdate,
type ActivityHistoryUpdateResult,
type ActivityRecord,
type TokenUsageSummary,
type ConversationSnapshot,
@@ -2169,7 +2170,7 @@ export type DesktopApi = {
* Incremental save (PERF-15): only the changed records cross IPC and are
* written. `replace` remains for the legacy localStorage migration.
*/
update: (update: ActivityHistoryUpdate) => Promise<void>
update: (update: ActivityHistoryUpdate) => Promise<ActivityHistoryUpdateResult | void>
/** Deletes the whole history in one transaction. */
clear: () => Promise<void>
/** One page of shown records, newest first (see activityHistoryPageRequestSchema). */