Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 209 additions & 23 deletions extensions/workflows/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");
}
Expand Down Expand Up @@ -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([

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] 这里仍会同步列出所有持久 run,并在下面逐个读取/解析 workflow.jsonrefreshTimer() 又在存在 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,或使用现有生命周期事件做有界增量更新。

...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;
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -732,6 +864,7 @@ export class WorkflowDashboard {
private close: () => void;
private onAbort?: (runId: string) => boolean;
private initialToolsExpanded: boolean;
private initialRunId?: string;

constructor(
tui: TUI,
Expand All @@ -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();
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -837,18 +986,47 @@ export class WorkflowDashboard {
const refreshed = this.entries.find(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 当前打开的详情不能依赖有界列表才能完成 hydration

refresh 的 projection 只 pin initialRunId。通过普通列表打开较早的 live run 后,它完成并落出32条历史限额时,这里找不到 refreshed,后面的 live→settled hydration 完全跳过。33条记录复现结果:omittedRuns=1、targetListed=false、current.live=true、artifactReads=0,磁盘上已有 transcript 但当前详情读不到。请在详情打开期间 pin current.runId(或独立刷新当前详情),并覆盖完成时被列表省略的回归;无需让所有历史引用突破限额。

(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;
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 21 additions & 3 deletions extensions/workflows/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,29 @@ export function resolveWorkflowRunTarget(

const normalizedTarget = trimmed.toLowerCase();
const runIds = [...new Set([...candidates].filter(isWorkflowRunId))].sort();
if (isWorkflowRunId(normalizedTarget) && runIds.includes(normalizedTarget)) {
return { ok: true, runId: normalizedTarget } as const;
const normalizedRunIds = runIds.map((runId) => ({
runId,
normalized: runId.toLowerCase(),
}));
if (isWorkflowRunId(normalizedTarget)) {
const exactCase = runIds.find((runId) => runId === trimmed);
if (exactCase) return { ok: true, runId: exactCase } as const;
const exactMatches = normalizedRunIds.filter(
(candidate) => candidate.normalized === normalizedTarget,
);
if (exactMatches.length === 1)
return { ok: true, runId: exactMatches[0]!.runId } as const;
if (exactMatches.length > 1) {
return {
ok: false,
error: `Workflow run suffix "${sanitizeLine(trimmed, 80)}" is ambiguous. Matches: ${boundedRunList(exactMatches.map((candidate) => candidate.runId))}. Use a longer suffix or full run id.`,
} as const;
}
}

const matches = runIds.filter((runId) => runId.endsWith(normalizedTarget));
const matches = normalizedRunIds
.filter((candidate) => candidate.normalized.endsWith(normalizedTarget))
.map((candidate) => candidate.runId);
if (matches.length === 1) return { ok: true, runId: matches[0]! } as const;

const displayTarget = sanitizeLine(trimmed, 80);
Expand Down
Loading
Loading