Files
goodbuddy/src/renderer/src/activity-store.ts
T
mesalogo 56ebd40bd8 feat: unify execution and add workspace file imports
Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content.

Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation.

Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit.

Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
2026-10-04 20:02:59 +08:00

597 lines
22 KiB
TypeScript

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'
const LEGACY_MAX_ACTIVITY_RECORDS = 500
const LEGACY_MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const LEGACY_MAX_STORED_JSON_LENGTH = 2_000_000
const LEGACY_NEAR_STORAGE_LIMIT_LENGTH =
LEGACY_MAX_STORED_JSON_LENGTH - 10_000
export type ActivityRecord = SharedActivityRecord
export type LegacyActivityHistory = {
records: ActivityRecord[]
historyMayBeIncomplete: boolean
}
function getLocalStorage(): Storage | undefined {
try {
return typeof window === 'undefined' ? undefined : window.localStorage
} catch {
return undefined
}
}
function parseActivityRecord(value: unknown): ActivityRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
const parsed = activityRecordSchema.safeParse({
...candidate,
scope: candidate.scope ?? { kind: 'unavailable' }
})
return parsed.success ? parsed.data : undefined
}
export function upsertActivityRecord(
records: ActivityRecord[],
incoming: ActivityRecord
): ActivityRecord[]
export function upsertActivityRecord(
records: readonly ActivityRecord[],
incoming: ActivityRecord
): readonly ActivityRecord[]
export function upsertActivityRecord(
records: readonly ActivityRecord[],
incoming: ActivityRecord
): readonly ActivityRecord[] {
if (
(incoming.kind !== 'tool' && incoming.kind !== 'subagent') ||
!incoming.callId
) {
return [incoming, ...records]
}
const existingIndex = records.findIndex(
(record) =>
record.kind === incoming.kind &&
record.requestId === incoming.requestId &&
record.callId === incoming.callId
)
if (existingIndex < 0) {
return [incoming, ...records]
}
const existing = records[existingIndex]!
// Matching fields and the original identity/scope cannot change on an update.
const unchanged =
existing.conversationId === incoming.conversationId &&
existing.title === incoming.title &&
existing.detail === incoming.detail &&
existing.status === incoming.status
if (unchanged && existingIndex === 0) {
return records
}
return [
unchanged
? existing
: {
...incoming,
id: existing.id,
createdAt: existing.createdAt,
scope: existing.scope
},
...records.filter((_, index) => index !== existingIndex)
]
}
export function mergeActivityRecords(
primary: readonly ActivityRecord[],
secondary: readonly ActivityRecord[]
): ActivityRecord[] {
const seen = new Set<string>()
return [...primary, ...secondary].filter((record) => {
if (seen.has(record.id)) {
return false
}
seen.add(record.id)
return true
})
}
function taskTerminalStatus(
task: AssistantTask
): ActivityRecord['status'] | undefined {
if (task.status === 'completed') {
return 'completed'
}
if (task.status === 'failed') {
return 'failed'
}
if (task.status === 'cancelled') {
return 'cancelled'
}
if (task.status === 'interrupted') {
return 'interrupted'
}
return undefined
}
export function reconcileActivityRecords(
records: readonly ActivityRecord[],
tasks: readonly AssistantTask[],
activeRequestIds: ReadonlySet<string> = new Set()
): ActivityRecord[] {
const tasksById = new Map(tasks.map((task) => [task.id, task]))
return records.map((record) => {
if (
activeRequestIds.has(record.requestId) ||
(record.status !== 'pending' && record.status !== 'running')
) {
return record
}
const task = tasksById.get(record.requestId)
const terminalStatus = task
? taskTerminalStatus(task)
: 'interrupted'
if (!terminalStatus) {
return record
}
return {
...record,
status:
terminalStatus === 'completed' &&
(record.kind === 'tool' ||
record.kind === 'approval' ||
record.kind === 'subagent')
? 'interrupted'
: terminalStatus,
detail:
terminalStatus === 'interrupted'
? `${record.detail}\n${i18n.t(
'records.interruptedOnRestart',
{ ns: 'activity' }
)}`
: record.detail
}
})
}
/**
* Reads the previous Renderer-owned history once for migration to SQLite.
* The legacy writer silently stopped at fixed record and payload limits, so
* reaching one of those boundaries is retained as durable uncertainty.
*/
export function loadLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): LegacyActivityHistory {
if (!storage) {
return { records: [], historyMayBeIncomplete: false }
}
try {
const serialized = storage.getItem(ACTIVITY_STORAGE_KEY)
if (serialized === null) {
return { records: [], historyMayBeIncomplete: false }
}
const parsed: unknown = JSON.parse(serialized)
if (!Array.isArray(parsed)) {
return { records: [], historyMayBeIncomplete: false }
}
const records: ActivityRecord[] = []
for (const candidate of parsed) {
const record = parseActivityRecord(candidate)
if (record) {
records.push(record)
}
}
return {
records,
historyMayBeIncomplete:
records.length >= LEGACY_MAX_ACTIVITY_RECORDS ||
serialized.length >= LEGACY_NEAR_STORAGE_LIMIT_LENGTH ||
records.some(
(record) =>
record.detail.length === LEGACY_MAX_ACTIVITY_DETAIL_LENGTH
)
}
} catch {
return { records: [], historyMayBeIncomplete: false }
}
}
export function clearLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): void {
if (!storage) {
return
}
try {
storage.removeItem(ACTIVITY_STORAGE_KEY)
} catch {
// A failed cleanup leaves the migration source intact for the next start.
}
}
/**
* 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 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). 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 (`getPanel`) instead of the live list so
* streaming runs do not re-render it; `refreshPanel` updates it.
*/
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
/** 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>()
const notify = (set: Set<Listener>): void => {
for (const listener of [...set]) listener()
}
const subscribe = (set: Set<Listener>) => (listener: Listener): (() => void) => {
set.add(listener)
return () => {
set.delete(listener)
}
}
const setPage = (next: ActivityPageState): void => {
page = next
notify(listeners)
}
const enqueue = (entry: ActivityOutboxEntry): void => {
const wasEmpty = outbox.length === 0
// 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)
}
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 result
}
/** 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 emptySummary = (): ActivityHistorySummary => ({
counts: { all: 0, active: 0, failed: 0 }, conversations: {}, legacyHistoryMayBeIncomplete: false
})
return {
/** The loaded records, newest first. */
getRecords(): readonly ActivityRecord[] {
return page.records
},
getPage(): ActivityPageState {
return page
},
getGeneration(): number {
return generation
},
subscribe: subscribe(listeners),
/** 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 records on the Activity page. */
refreshPanel(): void {
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
},
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 {
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 {
apply({ type: 'update-request', requestId, status, ...(detail === undefined ? {} : { detail }) })
},
/** Drops every record of a request that never started. */
removeByRequest(requestId: string): void {
apply({ type: 'remove-request', requestId })
},
/** Drops the provisional tool record a subagent replaces. */
removeToolByCallId(requestId: string, callId: string): void {
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 {
apply({ type: 'settle-request', requestId, status, detailLine })
},
/**
* 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).
*/
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
* are dropped: the clear supersedes them. Before the stored history
* loaded only this session's records go, as before.
*/
clear(): void {
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 {
generation++
page = { filter: 'all', records: [], hasMore: false }
panel = page
summary = summary && emptySummary()
outbox = []
notify(listeners)
notify(panelListeners)
},
/**
* 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[] {
return outbox
},
subscribeOutbox: subscribe(outboxListeners),
/** Removes and returns the first `count` outbox entries. */
takeOutbox(count: number): ActivityOutboxEntry[] {
sending = outbox.splice(0, count)
return sending
},
/**
* 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 {
sending = []
if (outbox[0]?.type === 'clear') return
outbox = [...entries, ...outbox]
}
}
}
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))
})
}