perf(activity): page, summarize and reconcile activity history in Main by index
Schema 57 rebuilds activity_history_records with order_seq (newer = larger;
a move to the front assigns a new maximum) and stored generated columns
(record_id, occurrence, status, status_group, kind, request_id,
conversation_id, project_id, created_at) with indexes. The order is migrated
from record_order_json exactly; legacy duplicates keep their occurrence and
only the first is shown. record_order_json is no longer written.
ActivityHistoryRepository owns the SQL: page({before, limit <= 500, filter,
projectId, conversationId}) newest first by index, summary() with the counts
and conversation titles/status the Activity page derived from the full list,
reconcile() joined with tasks with a renderer-localized detail line, update()
applied directly in SQL without reading the history, replace() for the
legacy migration, clear(). New activity-history:page/summary/reconcile IPC
with zod validation; page and summary run on the readonly worker.
perf:main measures 24,000 records: startup load 106 ms on Main (14 MB) ->
first page + summary 13.5 ms on the worker (0.6 ms loop block, 114 KB).
This commit is contained in:
@@ -1,8 +1,116 @@
|
||||
import type {
|
||||
ActivityHistoryChange,
|
||||
ActivityRecord
|
||||
ActivityHistoryPage,
|
||||
ActivityHistoryPageRequest,
|
||||
ActivityHistorySummary,
|
||||
ActivityRecord,
|
||||
AssistantTask
|
||||
} from './assistant-contracts'
|
||||
|
||||
const isActive = (record: ActivityRecord): boolean =>
|
||||
record.status === 'pending' || record.status === 'running'
|
||||
const isFailed = (record: ActivityRecord): boolean =>
|
||||
record.status === 'failed' || record.status === 'denied' ||
|
||||
record.status === 'cancelled' || record.status === 'interrupted'
|
||||
|
||||
/** The records a list shows: the first occurrence of each ID. */
|
||||
export function firstOccurrences(list: readonly ActivityRecord[]): ActivityRecord[] {
|
||||
const seen = new Set<string>()
|
||||
return list.filter((item) => !seen.has(item.id) && Boolean(seen.add(item.id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference page over a plain newest-first list; a record's sequence is its
|
||||
* distance from the end of the list (newer = larger), like `order_seq`.
|
||||
*/
|
||||
export function referenceActivityPage(
|
||||
list: readonly ActivityRecord[],
|
||||
request: ActivityHistoryPageRequest
|
||||
): ActivityHistoryPage {
|
||||
const seen = new Set<string>()
|
||||
const matching: Array<{ record: ActivityRecord; seq: number }> = []
|
||||
list.forEach((record, index) => {
|
||||
if (seen.has(record.id)) return
|
||||
seen.add(record.id)
|
||||
const seq = list.length - index
|
||||
if (request.before !== undefined && seq >= request.before) return
|
||||
if (request.filter === 'active' && !isActive(record)) return
|
||||
if (request.filter === 'failed' && !isFailed(record)) return
|
||||
if (request.projectId && (record.scope.kind !== 'project' || record.scope.projectId !== request.projectId)) return
|
||||
if (request.conversationId && record.conversationId !== request.conversationId) return
|
||||
matching.push({ record, seq })
|
||||
})
|
||||
const shown = matching.slice(0, request.limit)
|
||||
return {
|
||||
records: shown.map((item) => item.record),
|
||||
...(matching.length > request.limit ? { nextBefore: shown.at(-1)!.seq } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The former ActivityPanel computation over the whole list: counts, the
|
||||
* first request title and the conversation status.
|
||||
*/
|
||||
export function referenceActivitySummary(
|
||||
list: readonly ActivityRecord[],
|
||||
conversationIds: readonly string[],
|
||||
legacyHistoryMayBeIncomplete = false
|
||||
): ActivityHistorySummary {
|
||||
const records = firstOccurrences(list)
|
||||
const latest = (candidates: readonly ActivityRecord[]): ActivityRecord | undefined =>
|
||||
candidates.reduce<ActivityRecord | undefined>(
|
||||
(best, record) => (!best || record.createdAt > best.createdAt ? record : best), undefined)
|
||||
const conversations: ActivityHistorySummary['conversations'] = {}
|
||||
for (const conversationId of conversationIds) {
|
||||
const own = records.filter((record) => record.conversationId === conversationId)
|
||||
const title = own.find((record) => record.kind === 'request')?.title
|
||||
const latestRequest = latest(own.filter((record) => record.kind === 'request'))
|
||||
const status = latestRequest
|
||||
? (latest(own.filter((record) => record.kind === 'result' && record.requestId === latestRequest.requestId))
|
||||
?.status ?? latestRequest.status)
|
||||
: (latest(own.filter((record) => record.kind === 'result'))?.status ?? latest(own)?.status ?? 'completed')
|
||||
conversations[conversationId] = { ...(title === undefined ? {} : { title }), status }
|
||||
}
|
||||
return {
|
||||
counts: {
|
||||
all: records.length,
|
||||
active: records.filter(isActive).length,
|
||||
failed: records.filter(isFailed).length
|
||||
},
|
||||
conversations,
|
||||
legacyHistoryMayBeIncomplete
|
||||
}
|
||||
}
|
||||
|
||||
/** Reference of `reconcile` (the former renderer reconcileActivityRecords) on shown records. */
|
||||
export function referenceActivityReconcile(
|
||||
list: readonly ActivityRecord[],
|
||||
tasks: readonly Pick<AssistantTask, 'id' | 'status'>[],
|
||||
activeRequestIds: ReadonlySet<string>,
|
||||
interruptedDetail: string
|
||||
): ActivityRecord[] {
|
||||
const tasksById = new Map(tasks.map((task) => [task.id, task]))
|
||||
const seen = new Set<string>()
|
||||
return list.map((record) => {
|
||||
const first = !seen.has(record.id)
|
||||
seen.add(record.id)
|
||||
if (!first || activeRequestIds.has(record.requestId) || !isActive(record)) return record
|
||||
const task = tasksById.get(record.requestId)
|
||||
const terminal = !task
|
||||
? 'interrupted'
|
||||
: task.status === 'completed' || task.status === 'failed' ||
|
||||
task.status === 'cancelled' || task.status === 'interrupted'
|
||||
? task.status : undefined
|
||||
if (!terminal) return record
|
||||
return {
|
||||
...record,
|
||||
status: terminal === 'completed' && (record.kind === 'tool' || record.kind === 'approval' ||
|
||||
record.kind === 'subagent') ? 'interrupted' : terminal,
|
||||
detail: terminal === 'interrupted' ? `${record.detail}\n${interruptedDetail}` : record.detail
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference semantics of `activityHistory.update` on a plain newest-first
|
||||
* list (see `activityHistoryChangeSchema`), for tests and test doubles.
|
||||
|
||||
@@ -133,6 +133,56 @@ export const activityHistoryUpdateSchema = z
|
||||
})
|
||||
.strict()
|
||||
|
||||
/**
|
||||
* Activity history pages (PERF-15), newest first. A record ID shows once:
|
||||
* legacy duplicates after the first occurrence are left out. `before` is the
|
||||
* `nextBefore` of the previous page.
|
||||
*/
|
||||
export const activityHistoryFilterSchema = z.enum(['all', 'active', 'failed'])
|
||||
export const activityHistoryPageRequestSchema = z
|
||||
.object({
|
||||
before: z.number().int().optional(),
|
||||
limit: z.number().int().min(1).max(500),
|
||||
filter: activityHistoryFilterSchema.optional(),
|
||||
projectId: z.string().min(1).max(256).optional(),
|
||||
conversationId: z.string().min(1).max(256).optional()
|
||||
})
|
||||
.strict()
|
||||
export const activityHistorySummaryRequestSchema = z
|
||||
.object({
|
||||
/** Conversations whose title and status are needed (the loaded ones). */
|
||||
conversationIds: z.array(z.string().min(1).max(256)).max(5_000)
|
||||
})
|
||||
.strict()
|
||||
export const activityHistoryReconcileRequestSchema = z
|
||||
.object({
|
||||
activeRequestIds: z.array(z.string().min(1).max(256)).max(10_000),
|
||||
/** The localized line appended to records marked interrupted. */
|
||||
interruptedDetail: z.string().max(1_000)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ActivityHistoryFilter = z.infer<typeof activityHistoryFilterSchema>
|
||||
export type ActivityHistoryPageRequest = z.infer<typeof activityHistoryPageRequestSchema>
|
||||
export type ActivityHistoryPage = {
|
||||
records: ActivityRecord[]
|
||||
/** Set when older records may follow. */
|
||||
nextBefore?: number
|
||||
}
|
||||
export type ActivityHistorySummaryRequest = z.infer<typeof activityHistorySummaryRequestSchema>
|
||||
export type ActivityHistorySummary = {
|
||||
/** Record counts per filter, over the whole history. */
|
||||
counts: Record<ActivityHistoryFilter, number>
|
||||
/**
|
||||
* Per requested conversation: the title of its newest request record and
|
||||
* the conversation status (latest request's result, else latest result,
|
||||
* else latest record), over the whole history.
|
||||
*/
|
||||
conversations: Record<string, { title?: string; status: ActivityRecord['status'] }>
|
||||
legacyHistoryMayBeIncomplete: boolean
|
||||
}
|
||||
export type ActivityHistoryReconcileRequest = z.infer<typeof activityHistoryReconcileRequestSchema>
|
||||
|
||||
export type ActivityRecord = z.infer<typeof activityRecordSchema>
|
||||
export type ActivityHistorySnapshot = z.infer<
|
||||
typeof activityHistorySnapshotSchema
|
||||
|
||||
@@ -44,7 +44,12 @@ import {
|
||||
type AssistantTask,
|
||||
type ExecutionStats,
|
||||
type ExecutionStatsInput,
|
||||
type ActivityHistoryPage,
|
||||
type ActivityHistoryPageRequest,
|
||||
type ActivityHistoryReconcileRequest,
|
||||
type ActivityHistorySnapshot,
|
||||
type ActivityHistorySummary,
|
||||
type ActivityHistorySummaryRequest,
|
||||
type ActivityHistoryUpdate,
|
||||
type ActivityRecord,
|
||||
type TokenUsageSummary,
|
||||
@@ -2167,6 +2172,15 @@ export type DesktopApi = {
|
||||
update: (update: ActivityHistoryUpdate) => Promise<void>
|
||||
/** Deletes the whole history in one transaction. */
|
||||
clear: () => Promise<void>
|
||||
/** One page of shown records, newest first (see activityHistoryPageRequestSchema). */
|
||||
page: (request: ActivityHistoryPageRequest) => Promise<ActivityHistoryPage>
|
||||
/** Counts and conversation titles/status over the whole history. */
|
||||
summary: (request: ActivityHistorySummaryRequest) => Promise<ActivityHistorySummary>
|
||||
/**
|
||||
* Ends records left pending/running by requests that are no longer
|
||||
* active, against the stored tasks; returns how many changed.
|
||||
*/
|
||||
reconcile: (request: ActivityHistoryReconcileRequest) => Promise<number>
|
||||
}
|
||||
usage: {
|
||||
getTokenSummary: () => Promise<TokenUsageSummary>
|
||||
|
||||
@@ -257,6 +257,9 @@ export const ipcChannels = {
|
||||
activityHistoryReplace: 'activity-history:replace',
|
||||
activityHistoryUpdate: 'activity-history:update',
|
||||
activityHistoryClear: 'activity-history:clear',
|
||||
activityHistoryPage: 'activity-history:page',
|
||||
activityHistorySummary: 'activity-history:summary',
|
||||
activityHistoryReconcile: 'activity-history:reconcile',
|
||||
tokenUsageSummary: 'usage:token-summary',
|
||||
tokenUsageChanged: 'usage:token-changed',
|
||||
artifactsList: 'artifacts:list',
|
||||
|
||||
Reference in New Issue
Block a user