pi-workflow-engine 0.13.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
- package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
- package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
- package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
- package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
- package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
- package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
- package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
- package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
- package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
- package/USAGE.md +31 -17
- package/package.json +1 -2
- package/skills/workflow-code-review-actions/SKILL.md +12 -23
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
- package/.pi/settings.json +0 -3
|
@@ -1,127 +1,345 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
2
3
|
import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { formatCount } from "../text.ts";
|
|
5
|
+
import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
|
|
6
|
+
import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
|
|
5
7
|
|
|
8
|
+
/** pi caps string widgets at this many rows; the component keeps to the same budget. */
|
|
6
9
|
const MAX_WIDGET_LINES = 10;
|
|
10
|
+
/** Width used for surfaces that only accept pre-rendered strings (RPC). */
|
|
11
|
+
export const STRING_WIDGET_WIDTH = 100;
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
13
|
+
const INSPECTOR_COMMAND = "/workflow:inspector";
|
|
14
|
+
const MAX_BAR_WIDTH = 24;
|
|
15
|
+
const MIN_BAR_WIDTH = 8;
|
|
16
|
+
const MAX_PHASE_COLUMN = 18;
|
|
17
|
+
const MAX_LABEL_COLUMN = 26;
|
|
18
|
+
const FAILURE_LOG = /\b(fail(ed|ure|ing)?|error)\b/i;
|
|
19
|
+
|
|
20
|
+
let inspectorShortcut: string | null = null;
|
|
21
|
+
|
|
22
|
+
/** The widget footer names the configured inspector shortcut; registration sets it once from the shortcut config. */
|
|
23
|
+
export function setWorkflowInspectorShortcut(shortcut: string | null): void {
|
|
24
|
+
inspectorShortcut = shortcut;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function workflowInspectorHint(): string {
|
|
28
|
+
return inspectorShortcut ?? INSPECTOR_COMMAND;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The live run widget, every line fitted to `width`:
|
|
33
|
+
* header (name, bar, finished/total, elapsed, usage) → phases in chronological order, the active
|
|
34
|
+
* phase expanded into agent rows → upcoming declared phases → counters with the inspector hint.
|
|
35
|
+
*/
|
|
36
|
+
export function renderWorkflowWidget(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const inner = Math.max(1, width - 2);
|
|
12
39
|
const counts = countAgents(snapshot.phases);
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
lines.push(...visibleBodyLinesToRender);
|
|
34
|
-
if (hidden > 0 && lines.length < MAX_WIDGET_LINES) {
|
|
35
|
-
lines.push(`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hidden} more`)}`);
|
|
40
|
+
const phases = classifyPhases(snapshot);
|
|
41
|
+
const failureLog = failureLogLine(snapshot, theme);
|
|
42
|
+
const footer = footerLine(snapshot, workflowInspectorHint(), inner, theme);
|
|
43
|
+
const upcoming = upcomingLine(snapshot, theme);
|
|
44
|
+
|
|
45
|
+
const bodyBudget = MAX_WIDGET_LINES - 2 - (failureLog ? 1 : 0);
|
|
46
|
+
const body = phaseBody(phases, upcoming, bodyBudget, inner, now, theme);
|
|
47
|
+
const lines = [headerLine(snapshot, counts, inner, now, theme), ...body];
|
|
48
|
+
if (failureLog) lines.push(failureLog);
|
|
49
|
+
lines.push(footer);
|
|
50
|
+
return lines.map((line) => ` ${truncateToWidth(line, inner, "…")}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A TUI widget that re-renders its lines at the terminal's current width on every frame. */
|
|
54
|
+
export class WidthAwareWidget implements Component {
|
|
55
|
+
constructor(private readonly lines: (width: number) => string[]) {}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
return this.lines(width);
|
|
36
59
|
}
|
|
37
|
-
if (footer && lines.length < MAX_WIDGET_LINES) lines.push(footer);
|
|
38
60
|
|
|
39
|
-
|
|
61
|
+
invalidate(): void {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
|
|
65
|
+
export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
|
|
66
|
+
const counts = countAgents(snapshot.phases);
|
|
67
|
+
const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts.done + counts.failed}/${counts.total}` : snapshot.currentPhase;
|
|
68
|
+
const line = joinParts([
|
|
69
|
+
`${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
|
|
70
|
+
`${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
|
|
71
|
+
theme.fg("muted", progress),
|
|
72
|
+
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
73
|
+
theme.fg("dim", formatElapsed(Date.now() - snapshot.startedAt)),
|
|
74
|
+
], theme);
|
|
75
|
+
return [` ${spread(line, keyHints([[workflowInspectorHint(), "inspect"]], theme), Math.max(1, width - 2))}`];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ClassifiedPhases {
|
|
79
|
+
/** Every shown phase in chronological order; finished and active phases interleave when pipelines overlap. */
|
|
80
|
+
readonly ordered: readonly PhaseSnapshot[];
|
|
81
|
+
/** The current phase plus any phase that still has running or queued agents. */
|
|
82
|
+
readonly active: readonly PhaseSnapshot[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
|
|
86
|
+
// The implicit "Workflow" bucket only matters when agents ran before any phase() call.
|
|
87
|
+
const ordered = snapshot.phases.filter((phase) => phase.agents.length > 0 || phase.title !== "Workflow");
|
|
88
|
+
const active = ordered.filter((phase) =>
|
|
89
|
+
phase.title === snapshot.currentPhase || phase.agents.some((agent) => agent.status === "running" || agent.status === "queued"));
|
|
90
|
+
return { ordered, active };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
|
|
94
|
+
const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
|
|
95
|
+
const finished = counts.done + counts.failed;
|
|
96
|
+
const stats = joinParts([
|
|
97
|
+
counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
|
|
98
|
+
theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
|
|
99
|
+
compactUsage(snapshot.usage, theme),
|
|
100
|
+
], theme);
|
|
101
|
+
const barWidth = Math.min(MAX_BAR_WIDTH, width - visibleWidth(name) - visibleWidth(stats) - 4);
|
|
102
|
+
if (counts.total === 0 || barWidth < MIN_BAR_WIDTH) return `${name} ${stats}`;
|
|
103
|
+
return `${name} ${progressBar(finished, counts.total, barWidth, theme)} ${stats}`;
|
|
40
104
|
}
|
|
41
105
|
|
|
42
|
-
interface
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
failed: number;
|
|
47
|
-
total: number;
|
|
106
|
+
interface AgentRowPlan {
|
|
107
|
+
readonly phase: PhaseSnapshot;
|
|
108
|
+
readonly shown: readonly AgentRowSnapshot[];
|
|
109
|
+
readonly hidden: number;
|
|
48
110
|
}
|
|
49
111
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
112
|
+
function phaseBody(
|
|
113
|
+
phases: ClassifiedPhases,
|
|
114
|
+
upcoming: string | undefined,
|
|
115
|
+
budget: number,
|
|
116
|
+
width: number,
|
|
117
|
+
now: number,
|
|
118
|
+
theme: Theme,
|
|
119
|
+
): string[] {
|
|
120
|
+
let ordered: readonly (PhaseSnapshot | "collapsed")[] = phases.ordered;
|
|
121
|
+
const candidates = phases.active.reduce((total, phase) => total + phase.agents.length, 0);
|
|
122
|
+
const wantedRows = Math.min(candidates, 3);
|
|
123
|
+
const fixedLines = () => ordered.length + (upcoming ? 1 : 0);
|
|
124
|
+
// Keep agent rows visible on long workflows: fold everything finished before the first active phase into one line.
|
|
125
|
+
if (budget - fixedLines() < wantedRows) {
|
|
126
|
+
const firstActive = phases.ordered.findIndex((phase) => phases.active.includes(phase));
|
|
127
|
+
const foldUntil = firstActive === -1 ? phases.ordered.length : firstActive;
|
|
128
|
+
if (foldUntil > 1) ordered = ["collapsed", ...phases.ordered.slice(foldUntil)];
|
|
129
|
+
}
|
|
130
|
+
const showUpcoming = upcoming !== undefined && budget - ordered.length >= 1;
|
|
131
|
+
const rowBudget = Math.max(0, budget - ordered.length - (showUpcoming ? 1 : 0));
|
|
132
|
+
const plans = planAgentRows(phases.active, rowBudget);
|
|
133
|
+
|
|
134
|
+
const visiblePhases = ordered.filter((entry): entry is PhaseSnapshot => entry !== "collapsed");
|
|
135
|
+
const titleWidth = Math.min(MAX_PHASE_COLUMN, Math.max(0, ...visiblePhases.map((phase) => visibleWidth(phase.title))));
|
|
136
|
+
const labelWidth = agentLabelWidth(plans, width);
|
|
137
|
+
const folded = phases.ordered.slice(0, phases.ordered.length - visiblePhases.length);
|
|
138
|
+
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
for (const entry of ordered) {
|
|
141
|
+
if (entry === "collapsed") {
|
|
142
|
+
lines.push(foldedPhasesLine(folded, theme));
|
|
143
|
+
continue;
|
|
56
144
|
}
|
|
145
|
+
const plan = plans.get(entry);
|
|
146
|
+
if (!plan) {
|
|
147
|
+
lines.push(finishedPhaseLine(entry, titleWidth, theme));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
lines.push(activePhaseLine(plan, titleWidth, theme));
|
|
151
|
+
for (const agent of plan.shown) lines.push(agentLine(agent, entry, labelWidth, width, now, theme));
|
|
57
152
|
}
|
|
58
|
-
|
|
153
|
+
if (showUpcoming && upcoming) lines.push(upcoming);
|
|
154
|
+
return lines.slice(0, budget);
|
|
59
155
|
}
|
|
60
156
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Spread the row budget over active phases by importance — a reserved share for failures so they always
|
|
159
|
+
* show, then running, remaining failures, done, queued — while each phase lists its chosen rows as
|
|
160
|
+
* running, failed, done, queued.
|
|
161
|
+
*/
|
|
162
|
+
function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
|
|
163
|
+
const byStatus = (status: AgentRowSnapshot["status"]) =>
|
|
164
|
+
active.flatMap((phase) => phase.agents.filter((agent) => agent.status === status));
|
|
165
|
+
const failed = byStatus("failed");
|
|
166
|
+
const chosen = new Set<AgentRowSnapshot>();
|
|
167
|
+
let remaining = budget;
|
|
168
|
+
const take = (agents: readonly AgentRowSnapshot[], limit = remaining) => {
|
|
169
|
+
for (const agent of agents) {
|
|
170
|
+
if (remaining <= 0 || limit <= 0) return;
|
|
171
|
+
if (chosen.has(agent)) continue;
|
|
172
|
+
chosen.add(agent);
|
|
173
|
+
remaining--;
|
|
174
|
+
limit--;
|
|
175
|
+
}
|
|
72
176
|
};
|
|
177
|
+
take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
|
|
178
|
+
take(byStatus("running"));
|
|
179
|
+
take(failed);
|
|
180
|
+
take(byStatus("done"));
|
|
181
|
+
take(byStatus("queued"));
|
|
73
182
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
183
|
+
const rank = { running: 0, failed: 1, done: 2, queued: 3 } as const;
|
|
184
|
+
const plans = new Map<PhaseSnapshot, AgentRowPlan>();
|
|
185
|
+
for (const phase of active) {
|
|
186
|
+
const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
|
|
187
|
+
plans.set(phase, { phase, shown, hidden: phase.agents.length - shown.length });
|
|
79
188
|
}
|
|
189
|
+
return plans;
|
|
190
|
+
}
|
|
80
191
|
|
|
81
|
-
|
|
192
|
+
function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width: number): number {
|
|
193
|
+
const labels = [...plans.values()].flatMap((plan) => plan.shown.map((agent) => visibleWidth(agentLabel(agent, plan.phase))));
|
|
194
|
+
return Math.min(MAX_LABEL_COLUMN, Math.floor(width * 0.4), Math.max(0, ...labels));
|
|
82
195
|
}
|
|
83
196
|
|
|
84
|
-
function
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
):
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
197
|
+
function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
|
|
198
|
+
const counts = countAgents([phase]);
|
|
199
|
+
const glyph = counts.failed === 0
|
|
200
|
+
? theme.fg("success", GLYPH.done)
|
|
201
|
+
: counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
|
|
202
|
+
const span = phaseSpan(phase.agents);
|
|
203
|
+
const detail = joinParts([
|
|
204
|
+
counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
|
|
205
|
+
span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
|
|
206
|
+
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
207
|
+
], theme);
|
|
208
|
+
return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
|
|
212
|
+
const counts = countAgents(phases);
|
|
213
|
+
const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
|
|
214
|
+
const titles = phases.map((phase) => phase.title).join(" · ");
|
|
215
|
+
const detail = joinParts([
|
|
216
|
+
theme.fg("muted", plural(counts.total, "agent")),
|
|
217
|
+
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
218
|
+
], theme);
|
|
219
|
+
return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme): string {
|
|
223
|
+
const counts = countAgents([plan.phase]);
|
|
224
|
+
const detail = joinParts([
|
|
225
|
+
counts.running > 0 ? theme.fg("accent", `${counts.running} running`) : undefined,
|
|
226
|
+
counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
|
|
227
|
+
counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
|
|
228
|
+
counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
|
|
229
|
+
plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
|
|
230
|
+
], theme);
|
|
231
|
+
return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function agentLine(agent: AgentRowSnapshot, phase: PhaseSnapshot, labelWidth: number, width: number, now: number, theme: Theme): string {
|
|
235
|
+
const glyph = agentGlyph(agent, theme);
|
|
236
|
+
const label = fit(agentLabel(agent, phase), labelWidth);
|
|
237
|
+
const head = ` ${glyph} ${theme.fg(agent.status === "running" || agent.status === "failed" ? "text" : "muted", label)} `;
|
|
238
|
+
return head + truncateToWidth(agentDetail(agent, now, theme), Math.max(0, width - visibleWidth(head)), "…");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
|
|
242
|
+
switch (agent.status) {
|
|
243
|
+
case "running":
|
|
244
|
+
return theme.fg("accent", GLYPH.running);
|
|
245
|
+
case "failed":
|
|
246
|
+
return theme.fg("error", GLYPH.failed);
|
|
247
|
+
case "done":
|
|
248
|
+
return theme.fg("success", GLYPH.done);
|
|
249
|
+
case "queued":
|
|
250
|
+
return theme.fg("dim", GLYPH.queued);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Running: what it is doing now; done: how much it did; failed: why. */
|
|
255
|
+
function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string {
|
|
256
|
+
const elapsed = agent.startedAt === undefined ? undefined : (agent.doneAt ?? now) - agent.startedAt;
|
|
257
|
+
switch (agent.status) {
|
|
258
|
+
case "running":
|
|
259
|
+
return joinParts([agent.lastTool && theme.fg("muted", agent.lastTool), elapsed !== undefined && theme.fg("dim", formatElapsed(elapsed))], theme);
|
|
260
|
+
case "done":
|
|
261
|
+
return joinParts([
|
|
262
|
+
agent.toolUses > 0 && theme.fg("dim", plural(agent.toolUses, "tool")),
|
|
263
|
+
elapsed !== undefined && theme.fg("dim", shortDuration(elapsed)),
|
|
264
|
+
], theme);
|
|
265
|
+
case "failed":
|
|
266
|
+
return theme.fg("error", oneLine(agent.error ?? "failed"));
|
|
267
|
+
case "queued":
|
|
268
|
+
return theme.fg("dim", "queued");
|
|
96
269
|
}
|
|
97
270
|
}
|
|
98
271
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
272
|
+
/** `find:logic-bugs` under the Find phase reads as `logic-bugs`; the phase line already says Find. */
|
|
273
|
+
function agentLabel(agent: AgentRowSnapshot, phase: PhaseSnapshot): string {
|
|
274
|
+
const phaseName = phase.title.split(" ▸ ").pop()?.toLowerCase() ?? "";
|
|
275
|
+
const prefix = `${phaseName}:`;
|
|
276
|
+
return phaseName && agent.label.toLowerCase().startsWith(prefix) && agent.label.length > prefix.length
|
|
277
|
+
? agent.label.slice(prefix.length)
|
|
278
|
+
: agent.label;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Declared `meta.phases` not reached yet, after the last one the run has entered. */
|
|
282
|
+
function upcomingLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
283
|
+
const planned = snapshot.plannedPhases ?? [];
|
|
284
|
+
const seen = new Set(snapshot.phases.map((phase) => phase.title));
|
|
285
|
+
const lastSeen = planned.reduce((last, title, index) => (seen.has(title) ? index : last), -1);
|
|
286
|
+
const upcoming = planned.slice(lastSeen + 1).filter((title) => !seen.has(title));
|
|
287
|
+
if (upcoming.length === 0) return undefined;
|
|
288
|
+
return ` ${theme.fg("dim", GLYPH.queued)} ${theme.fg("dim", upcoming.join(" · "))}`;
|
|
103
289
|
}
|
|
104
290
|
|
|
105
|
-
|
|
291
|
+
/**
|
|
292
|
+
* The latest log when it reports a failure the phase and agent lines do not already count, such as a
|
|
293
|
+
* provider retry or an observer error. Failed agents are reported by their rows and phase totals.
|
|
294
|
+
*/
|
|
295
|
+
function failureLogLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
296
|
+
const latest = snapshot.logs.at(-1);
|
|
297
|
+
if (!latest || !FAILURE_LOG.test(latest)) return undefined;
|
|
298
|
+
const failedLabels = snapshot.phases.flatMap((phase) => phase.agents.filter((agent) => agent.status === "failed").map((agent) => agent.label));
|
|
299
|
+
if (failedLabels.some((label) => latest.startsWith(label))) return undefined;
|
|
300
|
+
return ` ${theme.fg("warning", `${GLYPH.warning} ${oneLine(latest)}`)}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function footerLine(snapshot: WorkflowProgressSnapshot, inspectorHint: string, width: number, theme: Theme): string {
|
|
304
|
+
const hint = keyHints([[inspectorHint, "inspect"]], theme);
|
|
305
|
+
const counters = counterText(snapshot, theme);
|
|
306
|
+
return counters ? spread(` ${counters}`, hint, width) : spread("", hint, width);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** `3 files · 3 candidates · 2 confirmed`: zeros hidden, verdict labels lower-cased, duplicates dropped. */
|
|
310
|
+
function counterText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
311
|
+
const seen = new Set<string>();
|
|
106
312
|
const parts: string[] = [];
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
313
|
+
for (const counter of snapshot.counters) {
|
|
314
|
+
const label = counter.label.toLowerCase().replaceAll("_", " ");
|
|
315
|
+
if (counter.value === 0 || seen.has(label)) continue;
|
|
316
|
+
seen.add(label);
|
|
317
|
+
parts.push(`${theme.fg("text", formatCount(counter.value))} ${theme.fg(counterColor(counter.key), label)}`);
|
|
318
|
+
}
|
|
319
|
+
return parts.length > 0 ? parts.join(dot(theme)) : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function counterColor(key: string): ThemeColor {
|
|
323
|
+
return key === "kept" ? "success" : "muted";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function phaseSpan(agents: readonly AgentRowSnapshot[]): number | undefined {
|
|
327
|
+
const starts = agents.flatMap((agent) => (agent.startedAt === undefined ? [] : [agent.startedAt]));
|
|
328
|
+
const ends = agents.flatMap((agent) => (agent.doneAt === undefined ? [] : [agent.doneAt]));
|
|
329
|
+
if (starts.length === 0 || ends.length === 0) return undefined;
|
|
330
|
+
return Math.max(...ends) - Math.min(...starts);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** `0.6s`, `4.2s`, then `12s` / `1m 4s` for finished spans. */
|
|
334
|
+
function shortDuration(ms: number): string {
|
|
335
|
+
if (ms < 10_000) return `${(Math.max(0, ms) / 1_000).toFixed(1)}s`;
|
|
336
|
+
return formatDuration(ms);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function plural(count: number, noun: string): string {
|
|
340
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function oneLine(text: string): string {
|
|
344
|
+
return text.replace(/\s+/g, " ").trim();
|
|
127
345
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isFiniteNumber, isRecord } from "./guards.ts";
|
|
2
|
+
import { formatCount } from "./text.ts";
|
|
3
|
+
|
|
1
4
|
export interface WorkflowUsageCost {
|
|
2
5
|
readonly input: number;
|
|
3
6
|
readonly output: number;
|
|
@@ -94,10 +97,8 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink {
|
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
snapshot(): WorkflowUsageSnapshot {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
usage: cloneTotals(agent.usage),
|
|
100
|
-
}));
|
|
100
|
+
// Recorded entries are never mutated; copying the array keeps each snapshot point-in-time.
|
|
101
|
+
const agents = [...this.agents];
|
|
101
102
|
return {
|
|
102
103
|
agents,
|
|
103
104
|
totals: sumTotals(agents.map((agent) => agent.usage)),
|
|
@@ -118,7 +119,7 @@ export function isWorkflowUsageSnapshot(value: unknown): value is WorkflowUsageS
|
|
|
118
119
|
if (!isRecord(value)) return false;
|
|
119
120
|
if (!Array.isArray(value.agents) || !value.agents.every(isWorkflowAgentUsage)) return false;
|
|
120
121
|
if (!isWorkflowUsageTotals(value.totals)) return false;
|
|
121
|
-
return
|
|
122
|
+
return isFiniteNumber(value.assistantMessages);
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSnapshot {
|
|
@@ -144,9 +145,9 @@ function formatWorkflowUsageComponents(totals: WorkflowUsageTotals): string[] {
|
|
|
144
145
|
appendUsageComponent(parts, "output", totals.output, totals.coverage.output);
|
|
145
146
|
|
|
146
147
|
const knownComponents = totals.input + totals.output + totals.cacheRead + totals.cacheWrite;
|
|
147
|
-
if (parts.length === 0 && totals.totalTokens > 0) parts.push(`tokens ${
|
|
148
|
+
if (parts.length === 0 && totals.totalTokens > 0) parts.push(`tokens ${formatCount(totals.totalTokens)}`);
|
|
148
149
|
else if (!hasCompleteCoverage(totals.coverage) && totals.totalTokens !== knownComponents) {
|
|
149
|
-
parts.push(`total ${
|
|
150
|
+
parts.push(`total ${formatCount(totals.totalTokens)}`);
|
|
150
151
|
}
|
|
151
152
|
return parts;
|
|
152
153
|
}
|
|
@@ -158,7 +159,7 @@ function appendUsageComponent(
|
|
|
158
159
|
coverage: WorkflowUsageCoverage,
|
|
159
160
|
): void {
|
|
160
161
|
if (value <= 0 || coverage === "none") return;
|
|
161
|
-
const count =
|
|
162
|
+
const count = formatCount(value);
|
|
162
163
|
parts.push(`${label} ${coverage === "partial" ? `≥${count}` : count}`);
|
|
163
164
|
}
|
|
164
165
|
|
|
@@ -168,17 +169,17 @@ function isWorkflowAgentUsage(value: unknown): value is WorkflowAgentUsage {
|
|
|
168
169
|
if (value.phase !== undefined && typeof value.phase !== "string") return false;
|
|
169
170
|
if (value.provider !== undefined && typeof value.provider !== "string") return false;
|
|
170
171
|
if (value.model !== undefined && typeof value.model !== "string") return false;
|
|
171
|
-
if (
|
|
172
|
+
if (!isFiniteNumber(value.assistantMessages)) return false;
|
|
172
173
|
return isWorkflowUsageTotals(value.usage);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
function isWorkflowUsageTotals(value: unknown): value is WorkflowUsageTotals {
|
|
176
177
|
if (!isRecord(value)) return false;
|
|
177
|
-
if (
|
|
178
|
-
if (
|
|
179
|
-
if (
|
|
180
|
-
if (
|
|
181
|
-
if (
|
|
178
|
+
if (!isFiniteNumber(value.input)) return false;
|
|
179
|
+
if (!isFiniteNumber(value.output)) return false;
|
|
180
|
+
if (!isFiniteNumber(value.cacheRead)) return false;
|
|
181
|
+
if (!isFiniteNumber(value.cacheWrite)) return false;
|
|
182
|
+
if (!isFiniteNumber(value.totalTokens)) return false;
|
|
182
183
|
if (!isWorkflowUsageCoverage(value.coverage)) return false;
|
|
183
184
|
return isWorkflowUsageCost(value.cost);
|
|
184
185
|
}
|
|
@@ -196,11 +197,11 @@ function isWorkflowUsageCoverage(value: unknown): value is WorkflowUsageComponen
|
|
|
196
197
|
function isWorkflowUsageCost(value: unknown): value is WorkflowUsageCost {
|
|
197
198
|
if (!isRecord(value)) return false;
|
|
198
199
|
return (
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
isFiniteNumber(value.input) &&
|
|
201
|
+
isFiniteNumber(value.output) &&
|
|
202
|
+
isFiniteNumber(value.cacheRead) &&
|
|
203
|
+
isFiniteNumber(value.cacheWrite) &&
|
|
204
|
+
isFiniteNumber(value.total)
|
|
204
205
|
);
|
|
205
206
|
}
|
|
206
207
|
|
|
@@ -321,18 +322,6 @@ function isCoverageValue(value: unknown): value is WorkflowUsageCoverage {
|
|
|
321
322
|
return value === "none" || value === "partial" || value === "complete";
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
function formatUsageCount(count: number): string {
|
|
325
|
-
if (count < 1000) return count.toString();
|
|
326
|
-
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
327
|
-
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
328
|
-
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
329
|
-
return `${Math.round(count / 1000000)}M`;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
325
|
function finiteNumber(value: unknown): number | undefined {
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
337
|
-
return typeof value === "object" && value !== null;
|
|
326
|
+
return isFiniteNumber(value) ? value : undefined;
|
|
338
327
|
}
|