-
Notifications
You must be signed in to change notification settings - Fork 40
fix(workflows): bound dashboard history hydration #319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -62,11 +62,16 @@ import { | |
| type WorkflowLogEntry, | ||
| workflowGraphRecords, | ||
| } from "./model.ts"; | ||
| import { measureWorkflowDetailsBytes } from "./retention.ts"; | ||
| import { writeFileAtomic } from "./serialization.ts"; | ||
| import { WorkflowTranscriptAdapter } from "./transcript.ts"; | ||
|
|
||
| const NOTICE_TTL_MS = 4000; | ||
| const MIN_HEIGHT = 10; | ||
| // This is a UI projection bound. It is deliberately independent from the | ||
| // session-memory settled-run retention policy; disk remains canonical. | ||
| const DEFAULT_WORKFLOW_DASHBOARD_MAX_RUNS = 32; | ||
| const DEFAULT_WORKFLOW_DASHBOARD_MAX_BYTES = 2 * 1024 * 1024; | ||
|
|
||
| function wrapSelection(index: number, delta: number, length: number): number { | ||
| if (length === 0) return 0; | ||
|
|
@@ -79,6 +84,23 @@ export interface RunEntry { | |
| live: boolean; | ||
| } | ||
|
|
||
| export interface RunEntryLoadOptions { | ||
| /** Explicitly opened runs remain visible even when the list is bounded. */ | ||
| initialRunId?: string; | ||
| /** Maximum number of non-pinned persisted entries kept in the list. */ | ||
| maxRuns?: number; | ||
| /** Maximum serialized UTF-8 bytes kept by non-pinned persisted entries. */ | ||
| maxBytes?: number; | ||
| } | ||
|
|
||
| export interface RunEntryLoadResult { | ||
| entries: RunEntry[]; | ||
| /** Persisted entries omitted by the dashboard projection bound. */ | ||
| omittedRuns: number; | ||
| /** Serialized bytes belonging to omitted persisted entries. */ | ||
| omittedBytes: number; | ||
| } | ||
|
|
||
| function runsDir(): string { | ||
| return path.join(getAgentDir(), "workflows"); | ||
| } | ||
|
|
@@ -563,25 +585,101 @@ export function sessionWorkflowRunIds(ctx: ExtensionContext): Set<string> { | |
| return runIds; | ||
| } | ||
|
|
||
| export function loadRunEntries( | ||
| function configuredLimit( | ||
| value: number | undefined, | ||
| fallback: number, | ||
| name: string, | ||
| ) { | ||
| if (value === undefined) return fallback; | ||
| if (!Number.isSafeInteger(value) || value < 0) | ||
| throw new RangeError(`${name} must be a non-negative safe integer`); | ||
| return value; | ||
| } | ||
|
|
||
| function compareRunEntries(a: RunEntry, b: RunEntry) { | ||
| return ( | ||
| b.details.startedAt - a.details.startedAt || a.runId.localeCompare(b.runId) | ||
| ); | ||
| } | ||
|
|
||
| function measureRunEntryBytes(details: WorkflowDetails) { | ||
| try { | ||
| return measureWorkflowDetailsBytes(details); | ||
| } catch { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Build the bounded dashboard list without hydrating result/transcript | ||
| * artifacts. Details are loaded lazily when the user opens a run. | ||
| */ | ||
| export function loadRunEntryProjection( | ||
| active: Map<string, WorkflowDetails>, | ||
| sessionId: string, | ||
| referencedRunIds: ReadonlySet<string>, | ||
| /** Hide runs untouched by the current request; live runs always show. */ | ||
| startedSince = 0, | ||
| /** Bounded settled projections used only if canonical disk state is unreadable. */ | ||
| retained: ReadonlyMap<string, WorkflowDetails> = new Map(), | ||
| ): RunEntry[] { | ||
| const entries: RunEntry[] = []; | ||
| const runIds = new Set([...listPersistedRunIds(), ...retained.keys()]); | ||
| options: RunEntryLoadOptions = {}, | ||
| ): RunEntryLoadResult { | ||
| const maxRuns = configuredLimit( | ||
| options.maxRuns, | ||
| DEFAULT_WORKFLOW_DASHBOARD_MAX_RUNS, | ||
| "maxRuns", | ||
| ); | ||
| const maxBytes = configuredLimit( | ||
| options.maxBytes, | ||
| DEFAULT_WORKFLOW_DASHBOARD_MAX_BYTES, | ||
| "maxBytes", | ||
| ); | ||
| const pinned = new Map<string, RunEntry>(); | ||
| const bounded: { entry: RunEntry; bytes: number }[] = []; | ||
| let boundedBytes = 0; | ||
| let omittedRuns = 0; | ||
| let omittedBytes = 0; | ||
|
|
||
| const omit = (bytes: number | undefined) => { | ||
| omittedRuns++; | ||
| if (bytes !== undefined) omittedBytes += bytes; | ||
| }; | ||
|
|
||
| const addBounded = (entry: RunEntry) => { | ||
| const bytes = measureRunEntryBytes(entry.details); | ||
| if (bytes === undefined) { | ||
| omit(undefined); | ||
| return; | ||
| } | ||
| bounded.push({ entry, bytes }); | ||
| bounded.sort((a, b) => compareRunEntries(a.entry, b.entry)); | ||
| boundedBytes += bytes; | ||
| while (bounded.length > maxRuns || boundedBytes > maxBytes) { | ||
| const oldest = bounded.pop(); | ||
| if (!oldest) break; | ||
| boundedBytes -= oldest.bytes; | ||
| omit(oldest.bytes); | ||
| } | ||
| }; | ||
|
|
||
| const addEntry = (entry: RunEntry, keepPinned: boolean) => { | ||
| if (keepPinned) pinned.set(entry.runId, entry); | ||
| else addBounded(entry); | ||
| }; | ||
|
|
||
| const runIds = new Set([ | ||
| ...listPersistedRunIds(), | ||
| ...active.keys(), | ||
| ...retained.keys(), | ||
| ]); | ||
| for (const runId of runIds) { | ||
| const live = active.get(runId); | ||
| if (live) { | ||
| entries.push({ runId, details: live, live: true }); | ||
| addEntry({ runId, details: live, live: true }, true); | ||
| continue; | ||
| } | ||
| const persisted = readPersistedWorkflowDetails(runId, { | ||
| hydrateArtifacts: true, | ||
| hydrateArtifacts: false, | ||
| }); | ||
| const retainedDetails = retained.get(runId); | ||
| const details = persisted ?? retainedDetails; | ||
|
|
@@ -597,10 +695,43 @@ export function loadRunEntries( | |
| ) { | ||
| continue; | ||
| } | ||
| // A session reference makes a cross-session run eligible, but it remains | ||
| // subject to the dashboard bound. Only active runs and the explicit target | ||
| // are pinned so a long session cannot defeat count/byte limits. | ||
| recoverStaleWorkflowDetails(details); | ||
| entries.push({ runId, details, live: false }); | ||
| addEntry( | ||
| { runId, details, live: false }, | ||
| options.initialRunId !== undefined && | ||
| runId.toLowerCase() === options.initialRunId.toLowerCase(), | ||
| ); | ||
| } | ||
| return entries.sort((a, b) => b.details.startedAt - a.details.startedAt); | ||
|
|
||
| return { | ||
| entries: [ | ||
| ...pinned.values(), | ||
| ...bounded.map((candidate) => candidate.entry), | ||
| ].sort(compareRunEntries), | ||
| omittedRuns, | ||
| omittedBytes, | ||
| }; | ||
| } | ||
|
|
||
| export function loadRunEntries( | ||
| active: Map<string, WorkflowDetails>, | ||
| sessionId: string, | ||
| referencedRunIds: ReadonlySet<string>, | ||
| startedSince = 0, | ||
| retained: ReadonlyMap<string, WorkflowDetails> = new Map(), | ||
| options: RunEntryLoadOptions = {}, | ||
| ): RunEntry[] { | ||
| return loadRunEntryProjection( | ||
| active, | ||
| sessionId, | ||
| referencedRunIds, | ||
| startedSince, | ||
| retained, | ||
| options, | ||
| ).entries; | ||
| } | ||
|
|
||
| export function workflowGraphSummary( | ||
|
|
@@ -717,6 +848,7 @@ export class WorkflowDashboard { | |
| private transcriptPage?: AgentSessionPage; | ||
| private current?: RunEntry; | ||
| private openedDirectly = false; | ||
| private omittedRuns = 0; | ||
| private notice?: string; | ||
| private noticeAt = 0; | ||
| private disposed = false; | ||
|
|
@@ -732,6 +864,7 @@ export class WorkflowDashboard { | |
| private close: () => void; | ||
| private onAbort?: (runId: string) => boolean; | ||
| private initialToolsExpanded: boolean; | ||
| private initialRunId?: string; | ||
|
|
||
| constructor( | ||
| tui: TUI, | ||
|
|
@@ -758,22 +891,31 @@ export class WorkflowDashboard { | |
| this.close = close; | ||
| this.onAbort = onAbort; | ||
| this.initialToolsExpanded = initialToolsExpanded; | ||
| const initialResolution = initialRunId | ||
| ? resolveWorkflowRunTarget(initialRunId, [ | ||
| ...listPersistedRunIds(), | ||
| ...this.getActive().keys(), | ||
| ...this.getRetained().keys(), | ||
| ]) | ||
| : undefined; | ||
| this.initialRunId = initialResolution?.ok | ||
| ? initialResolution.runId | ||
| : undefined; | ||
| this.refresh(); | ||
| if (initialRunId) { | ||
| const resolution = resolveWorkflowRunTarget( | ||
| initialRunId, | ||
| this.entries.map((entry) => entry.runId), | ||
| ); | ||
| if (resolution.ok) { | ||
| if (initialResolution) { | ||
| if (initialResolution.ok) { | ||
| const entry = this.entries.find( | ||
| (candidate) => candidate.runId === resolution.runId, | ||
| (candidate) => candidate.runId === initialResolution.runId, | ||
| ); | ||
| if (entry) { | ||
| this.listIndex = this.entries.indexOf(entry); | ||
| this.enterEntry(entry, true); | ||
| } else { | ||
| this.notice = `Workflow run ${initialResolution.runId} could not be read.`; | ||
| this.noticeAt = Date.now(); | ||
| } | ||
| } else { | ||
| this.notice = resolution.error; | ||
| this.notice = initialResolution.error; | ||
| this.noticeAt = Date.now(); | ||
| } | ||
| } | ||
|
|
@@ -818,13 +960,20 @@ export class WorkflowDashboard { | |
|
|
||
| private refresh() { | ||
| const selected = this.entries[this.listIndex]?.runId; | ||
| this.entries = loadRunEntries( | ||
| const pinnedRunId = | ||
| this.view === "list" | ||
| ? this.initialRunId | ||
| : (this.current?.runId ?? this.initialRunId); | ||
| const projection = loadRunEntryProjection( | ||
| this.getActive(), | ||
| this.sessionId, | ||
| this.referencedRunIds, | ||
| this.startedSince, | ||
| this.getRetained(), | ||
| { initialRunId: pinnedRunId }, | ||
| ); | ||
| this.entries = projection.entries; | ||
| this.omittedRuns = projection.omittedRuns; | ||
| if (selected) { | ||
| const index = this.entries.findIndex((e) => e.runId === selected); | ||
| if (index >= 0) this.listIndex = index; | ||
|
|
@@ -837,18 +986,47 @@ export class WorkflowDashboard { | |
| const refreshed = this.entries.find( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 当前打开的详情不能依赖有界列表才能完成 hydration refresh 的 projection 只 pin |
||
| (e) => e.runId === this.current?.runId, | ||
| ); | ||
| if (refreshed) this.current = refreshed; | ||
| if (refreshed) { | ||
| if (!this.current.live && !refreshed.live) { | ||
| // Keep the selected detail's already-hydrated artifacts while the | ||
| // list projection is refreshed from compact metadata. | ||
| this.current = { ...refreshed, details: this.current.details }; | ||
| } else if (this.current.live && !refreshed.live) { | ||
| // A live run may settle while its detail view is open. Hydrate the | ||
| // newly canonical state once so the transcript does not disappear. | ||
| const details = | ||
| readPersistedWorkflowDetails(refreshed.runId, { | ||
| hydrateArtifacts: true, | ||
| }) ?? refreshed.details; | ||
| this.current = { | ||
| ...refreshed, | ||
| details: recoverStaleWorkflowDetails(details), | ||
| }; | ||
| } else { | ||
| this.current = refreshed; | ||
| } | ||
| } | ||
| } | ||
| if (this.notice && Date.now() - this.noticeAt > NOTICE_TTL_MS) | ||
| this.notice = undefined; | ||
| } | ||
|
|
||
| private enterEntry(entry: RunEntry, directly: boolean) { | ||
| this.current = entry; | ||
| const details = entry.live | ||
| ? entry.details | ||
| : recoverStaleWorkflowDetails( | ||
| readPersistedWorkflowDetails(entry.runId, { | ||
| hydrateArtifacts: true, | ||
| }) ?? entry.details, | ||
| ); | ||
| this.current = { | ||
| ...entry, | ||
| details, | ||
| }; | ||
| this.openedDirectly = directly; | ||
| const groups = phaseGroups(entry.details, true); | ||
| const groups = phaseGroups(this.current.details, true); | ||
| const currentPhase = groups.findIndex( | ||
| (group) => group.title === entry.details.currentPhase, | ||
| (group) => group.title === this.current?.details.currentPhase, | ||
| ); | ||
| this.phaseIndex = Math.max(0, currentPhase); | ||
| this.agentIndex = 0; | ||
|
|
@@ -1096,16 +1274,24 @@ export class WorkflowDashboard { | |
| private renderList(width: number, height: number): string[] { | ||
| const theme = this.theme; | ||
| const lines: string[] = []; | ||
| const omittedNotice = | ||
| this.omittedRuns > 0 | ||
| ? theme.fg( | ||
| "dim", | ||
| `${this.omittedRuns} run${this.omittedRuns === 1 ? "" : "s"} omitted from this view; full artifacts remain on disk.`, | ||
| ) | ||
| : undefined; | ||
| lines.push( | ||
| screenTitleLine( | ||
| theme, | ||
| "Workflows", | ||
| `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}`, | ||
| `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}${this.omittedRuns > 0 ? ` · ${this.omittedRuns} omitted` : ""}`, | ||
| width, | ||
| ), | ||
| ); | ||
| if (omittedNotice) lines.push(omittedNotice); | ||
|
|
||
| const panelHeight = height - 2; | ||
| const panelHeight = Math.max(1, height - 2 - (omittedNotice ? 1 : 0)); | ||
| const bodyHeight = Math.max(0, panelHeight - 2); | ||
|
|
||
| if (this.entries.length === 0) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] 这里仍会同步列出所有持久 run,并在下面逐个读取/解析
workflow.json;refreshTimer()又在存在 live/notice 时每 120 ms 调用整个 projection。结果数组虽然只保留 32 条/2 MiB,扫描和 I/O 本身仍随历史线性增长。exact-head 基准在 1,000 条时 warm refresh 已约 136–166 ms,5,000 条约 706–780 ms,Dashboard 会在长历史下持续卡住。请把全量持久扫描移出 spinner tick,周期刷新只处理 active/current detail,或使用现有生命周期事件做有界增量更新。