pi-workflow-engine 0.3.0 → 0.5.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 +369 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +238 -0
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +28 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +171 -0
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +152 -0
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +83 -0
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/engine.ts +36 -8
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +396 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +46 -0
- package/.pi/extensions/pi-workflow-engine/src/perf.ts +139 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/progress.ts +82 -35
- package/{src → .pi/extensions/pi-workflow-engine/src}/types.ts +12 -4
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-format.ts +59 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-inspector.ts +8 -6
- package/{src → .pi/extensions/pi-workflow-engine/src}/ui/workflow-result-renderer.ts +11 -8
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +182 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +263 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +57 -0
- package/{src → .pi/extensions/pi-workflow-engine/src}/workflows.ts +8 -2
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/code-review.ts +57 -125
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/diagnose.ts +12 -73
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/perf-review.ts +41 -148
- package/{workflows → .pi/extensions/pi-workflow-engine/workflows}/refactor-scout.ts +43 -151
- package/.pi/settings.json +3 -0
- package/README.md +101 -27
- package/USAGE.md +183 -0
- package/assets/preview-candidate-core.png +0 -0
- package/assets/preview-candidate-relic-emblem.png +0 -0
- package/assets/preview-candidate-robot-squad.png +0 -0
- package/assets/preview.png +0 -0
- package/package.json +33 -6
- package/src/agent-runner.ts +0 -122
- package/src/concurrency.ts +0 -55
- package/src/discovery.ts +0 -54
- package/src/index.ts +0 -141
- package/src/ui/workflow-widget.ts +0 -143
- package/workflows/ping.ts +0 -28
- /package/{src → .pi/extensions/pi-workflow-engine/src}/advisory-schema.ts +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
|
|
5
5
|
import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
|
|
6
6
|
|
|
7
7
|
export type AgentRowStatus = "queued" | "running" | "done" | "failed";
|
|
@@ -46,6 +46,7 @@ export interface WorkflowProgressSnapshot {
|
|
|
46
46
|
readonly counters: readonly WorkflowCounterSnapshot[];
|
|
47
47
|
readonly summary: readonly [string, string | number][];
|
|
48
48
|
readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
|
|
49
|
+
readonly laneOverflow: readonly [string, number][];
|
|
49
50
|
readonly logs: readonly string[];
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -81,6 +82,7 @@ interface WorkflowLaneItem {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
const LOG_LIMIT = 24;
|
|
85
|
+
export const DEFAULT_LANE_ITEM_LIMIT = 200;
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
88
|
* Tracks live workflow state for widgets, footer/status text, result renderers,
|
|
@@ -92,7 +94,11 @@ export class ProgressTracker {
|
|
|
92
94
|
private readonly counters = new Map<string, WorkflowCounter>();
|
|
93
95
|
private readonly summary = new Map<string, string | number>();
|
|
94
96
|
private readonly lanes = new Map<string, WorkflowLaneItem[]>();
|
|
97
|
+
private readonly laneOverflow = new Map<string, number>();
|
|
98
|
+
private readonly rowsById = new Map<number, AgentRow>();
|
|
99
|
+
private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0 };
|
|
95
100
|
private readonly startedAt = Date.now();
|
|
101
|
+
private readonly laneItemLimit = laneItemLimitFromEnv();
|
|
96
102
|
private doneAt: number | undefined;
|
|
97
103
|
private currentPhase = "Workflow";
|
|
98
104
|
private nextAgentId = 1;
|
|
@@ -101,6 +107,7 @@ export class ProgressTracker {
|
|
|
101
107
|
private tui: Pick<TUI, "requestRender"> | undefined;
|
|
102
108
|
private widgetInterval: ReturnType<typeof setInterval> | undefined;
|
|
103
109
|
private lastStatusText: string | undefined;
|
|
110
|
+
private renderQueued = false;
|
|
104
111
|
|
|
105
112
|
constructor(
|
|
106
113
|
private readonly ctx: ExtensionContext,
|
|
@@ -156,6 +163,7 @@ export class ProgressTracker {
|
|
|
156
163
|
details: event.details,
|
|
157
164
|
createdAt: Date.now(),
|
|
158
165
|
});
|
|
166
|
+
this.pruneLane(event.lane, lane);
|
|
159
167
|
this.lanes.set(event.lane, lane);
|
|
160
168
|
break;
|
|
161
169
|
}
|
|
@@ -168,7 +176,10 @@ export class ProgressTracker {
|
|
|
168
176
|
|
|
169
177
|
agentQueued(phase: string | undefined, label: string): number {
|
|
170
178
|
const id = this.nextAgentId++;
|
|
171
|
-
|
|
179
|
+
const row = { label, id, status: "queued" as const, toolUses: 0 };
|
|
180
|
+
this.ensurePhase(phase ?? this.currentPhase).agents.push(row);
|
|
181
|
+
this.rowsById.set(id, row);
|
|
182
|
+
this.agentCounts.queued++;
|
|
172
183
|
this.render();
|
|
173
184
|
return id;
|
|
174
185
|
}
|
|
@@ -176,17 +187,20 @@ export class ProgressTracker {
|
|
|
176
187
|
agentStart(phase: string | undefined, label: string, id?: number): void {
|
|
177
188
|
const row = id === undefined ? undefined : this.findRowById(id);
|
|
178
189
|
if (row) {
|
|
179
|
-
row
|
|
190
|
+
this.transitionAgentStatus(row, "running");
|
|
180
191
|
row.startedAt = Date.now();
|
|
181
192
|
row.error = undefined;
|
|
182
193
|
} else {
|
|
183
|
-
|
|
194
|
+
const nextRow = {
|
|
184
195
|
label,
|
|
185
196
|
id: this.nextAgentId++,
|
|
186
|
-
status: "running",
|
|
197
|
+
status: "running" as const,
|
|
187
198
|
startedAt: Date.now(),
|
|
188
199
|
toolUses: 0,
|
|
189
|
-
}
|
|
200
|
+
};
|
|
201
|
+
this.ensurePhase(phase ?? this.currentPhase).agents.push(nextRow);
|
|
202
|
+
this.rowsById.set(nextRow.id, nextRow);
|
|
203
|
+
this.agentCounts.running++;
|
|
190
204
|
}
|
|
191
205
|
this.render();
|
|
192
206
|
}
|
|
@@ -203,7 +217,7 @@ export class ProgressTracker {
|
|
|
203
217
|
agentDone(label: string, id?: number): void {
|
|
204
218
|
const row = this.findRow(label, id);
|
|
205
219
|
if (row && row.status !== "failed") {
|
|
206
|
-
row
|
|
220
|
+
this.transitionAgentStatus(row, "done");
|
|
207
221
|
row.doneAt = Date.now();
|
|
208
222
|
}
|
|
209
223
|
this.render();
|
|
@@ -212,7 +226,7 @@ export class ProgressTracker {
|
|
|
212
226
|
agentFailed(label: string, error: unknown, id?: number): void {
|
|
213
227
|
const row = this.findRow(label, id);
|
|
214
228
|
if (row) {
|
|
215
|
-
row
|
|
229
|
+
this.transitionAgentStatus(row, "failed");
|
|
216
230
|
row.doneAt = Date.now();
|
|
217
231
|
row.error = errorMessage(error);
|
|
218
232
|
}
|
|
@@ -232,10 +246,40 @@ export class ProgressTracker {
|
|
|
232
246
|
counters: [...this.counters.values()].map((counter) => ({ ...counter })),
|
|
233
247
|
summary: [...this.summary.entries()],
|
|
234
248
|
lanes: [...this.lanes.entries()].map(([lane, items]) => [lane, items.map((item) => ({ ...item }))]),
|
|
249
|
+
laneOverflow: [...this.laneOverflow.entries()],
|
|
235
250
|
logs: [...this.logs],
|
|
236
251
|
};
|
|
237
252
|
}
|
|
238
253
|
|
|
254
|
+
statusCounts(): WorkflowStatusCounts {
|
|
255
|
+
return this.statusCountsSnapshot();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private statusCountsSnapshot(): WorkflowStatusCounts {
|
|
259
|
+
return {
|
|
260
|
+
queued: this.agentCounts.queued,
|
|
261
|
+
running: this.agentCounts.running,
|
|
262
|
+
done: this.agentCounts.done,
|
|
263
|
+
failed: this.agentCounts.failed,
|
|
264
|
+
total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private transitionAgentStatus(row: AgentRow, nextStatus: AgentRowStatus): void {
|
|
269
|
+
if (row.status === nextStatus) return;
|
|
270
|
+
this.agentCounts[row.status]--;
|
|
271
|
+
row.status = nextStatus;
|
|
272
|
+
this.agentCounts[nextStatus]++;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private pruneLane(laneName: string, lane: WorkflowLaneItem[]): void {
|
|
276
|
+
if (this.laneItemLimit <= 0) return;
|
|
277
|
+
while (lane.length > this.laneItemLimit) {
|
|
278
|
+
lane.shift();
|
|
279
|
+
this.laneOverflow.set(laneName, (this.laneOverflow.get(laneName) ?? 0) + 1);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
239
283
|
private findRow(label: string, id?: number): AgentRow | undefined {
|
|
240
284
|
if (id !== undefined) return this.findRowById(id);
|
|
241
285
|
for (let i = this.phases.length - 1; i >= 0; i--) {
|
|
@@ -245,48 +289,44 @@ export class ProgressTracker {
|
|
|
245
289
|
if (running) return running;
|
|
246
290
|
}
|
|
247
291
|
for (let i = this.phases.length - 1; i >= 0; i--) {
|
|
248
|
-
const
|
|
249
|
-
if (
|
|
292
|
+
const matching = this.phases[i].agents.find((agent) => agent.label === label);
|
|
293
|
+
if (matching) return matching;
|
|
250
294
|
}
|
|
251
295
|
return undefined;
|
|
252
296
|
}
|
|
253
297
|
|
|
254
298
|
private findRowById(id: number): AgentRow | undefined {
|
|
255
|
-
|
|
256
|
-
const row = phase.agents.find((agent) => agent.id === id);
|
|
257
|
-
if (row) return row;
|
|
258
|
-
}
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
private lines(): string[] {
|
|
263
|
-
const out: string[] = [`⚙ ${this.title}`];
|
|
264
|
-
for (const phase of this.phases) {
|
|
265
|
-
if (phase.agents.length === 0 && phase.title === "Workflow") continue;
|
|
266
|
-
out.push(` ${phase.title}`);
|
|
267
|
-
for (const agent of phase.agents) {
|
|
268
|
-
const icon = agent.status === "done" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "queued" ? "○" : "⏳";
|
|
269
|
-
const tool = agent.status === "running" && agent.lastTool ? ` · ${agent.lastTool}` : "";
|
|
270
|
-
const error = agent.status === "failed" && agent.error ? ` · ${agent.error}` : "";
|
|
271
|
-
out.push(` ${icon} ${agent.label}${tool}${error}`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
const counterLine = [...this.counters.values()].map((counter) => `${counter.label}: ${counter.value}`).join(" · ");
|
|
275
|
-
if (counterLine) out.push(` ${counterLine}`);
|
|
276
|
-
for (const line of this.logs.slice(-6)) out.push(` · ${line}`);
|
|
277
|
-
return out;
|
|
299
|
+
return this.rowsById.get(id);
|
|
278
300
|
}
|
|
279
301
|
|
|
280
302
|
private render(): void {
|
|
281
303
|
if (!this.ctx.hasUI) return;
|
|
282
304
|
this.ensureWidget();
|
|
283
305
|
this.widget?.invalidate();
|
|
284
|
-
this.
|
|
306
|
+
this.requestRenderSoon();
|
|
285
307
|
this.publishStatus();
|
|
286
308
|
}
|
|
287
309
|
|
|
310
|
+
private requestRenderSoon(): void {
|
|
311
|
+
if (this.renderQueued) return;
|
|
312
|
+
this.renderQueued = true;
|
|
313
|
+
queueMicrotask(() => {
|
|
314
|
+
this.renderQueued = false;
|
|
315
|
+
this.tui?.requestRender();
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
288
319
|
private publishStatus(): void {
|
|
289
|
-
const next =
|
|
320
|
+
const next = statusTextFromCounts(
|
|
321
|
+
{
|
|
322
|
+
title: this.title,
|
|
323
|
+
doneAt: this.doneAt,
|
|
324
|
+
currentPhase: this.currentPhase,
|
|
325
|
+
counters: [...this.counters.values()].map((counter) => ({ ...counter })),
|
|
326
|
+
},
|
|
327
|
+
this.statusCountsSnapshot(),
|
|
328
|
+
this.ctx.ui.theme,
|
|
329
|
+
);
|
|
290
330
|
if (next === this.lastStatusText) return;
|
|
291
331
|
this.ctx.ui.setStatus("workflow", next);
|
|
292
332
|
this.lastStatusText = next;
|
|
@@ -332,12 +372,19 @@ export class ProgressTracker {
|
|
|
332
372
|
this.ctx.ui.setWidget("workflow", undefined);
|
|
333
373
|
this.ctx.ui.setStatus("workflow", status);
|
|
334
374
|
this.lastStatusText = status;
|
|
375
|
+
this.renderQueued = false;
|
|
335
376
|
this.widgetRegistered = false;
|
|
336
377
|
this.widget = undefined;
|
|
337
378
|
this.tui = undefined;
|
|
338
379
|
}
|
|
339
380
|
}
|
|
340
381
|
|
|
382
|
+
function laneItemLimitFromEnv(): number {
|
|
383
|
+
const parsed = Number(process.env.PI_WORKFLOW_LANE_ITEM_LIMIT ?? "");
|
|
384
|
+
if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_LANE_ITEM_LIMIT;
|
|
385
|
+
return Math.trunc(parsed);
|
|
386
|
+
}
|
|
387
|
+
|
|
341
388
|
function errorMessage(error: unknown): string {
|
|
342
389
|
return error instanceof Error ? error.message : String(error);
|
|
343
390
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Static, TSchema } from "typebox";
|
|
2
2
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { Pipeline } from "./concurrency.ts";
|
|
4
|
+
import type { PerfSink, PerfSnapshot } from "./perf.ts";
|
|
3
5
|
|
|
4
6
|
/** Metadata every workflow module must export. */
|
|
5
7
|
export interface WorkflowMeta {
|
|
@@ -18,6 +20,15 @@ export interface WorkflowRunStats {
|
|
|
18
20
|
|
|
19
21
|
export interface WorkflowRunOptions {
|
|
20
22
|
inspect?: boolean;
|
|
23
|
+
perf?: boolean;
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
parallelSubmissionLimit?: number;
|
|
26
|
+
/** Additional abort signal to compose with the host context signal. */
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
/** Internal recorder override for command/tool invocation timing. */
|
|
29
|
+
perfRecorder?: PerfSink;
|
|
30
|
+
/** Called with the final performance snapshot when perf is enabled. */
|
|
31
|
+
onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
34
|
/** Options for a single `agent()` call. */
|
|
@@ -63,10 +74,7 @@ export interface WorkflowApi {
|
|
|
63
74
|
/** Run every thunk concurrently and wait for all (a barrier). */
|
|
64
75
|
parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
|
|
65
76
|
/** Run each item through all stages independently — no barrier between stages. */
|
|
66
|
-
pipeline
|
|
67
|
-
items: unknown[],
|
|
68
|
-
...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
|
|
69
|
-
): Promise<unknown[]>;
|
|
77
|
+
pipeline: Pipeline;
|
|
70
78
|
/** Start a new phase; subsequent agents group under it in the progress tree. */
|
|
71
79
|
phase(title: string): void;
|
|
72
80
|
/** Emit a progress line. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import type { WorkflowProgressSnapshot } from "../progress.ts";
|
|
3
|
+
import type { AgentRowSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
|
|
4
4
|
import type { WorkflowLaneItemStatus } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed";
|
|
@@ -58,24 +58,77 @@ export function truncateDisplay(text: string, width: number): string {
|
|
|
58
58
|
return truncateToWidth(text, width);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export interface AgentDetailOptions {
|
|
62
|
+
now?: number;
|
|
63
|
+
includeQueuedStatus?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function agentDetailParts(agent: AgentRowSnapshot, now?: number): string[];
|
|
67
|
+
export function agentDetailParts(agent: AgentRowSnapshot, options?: AgentDetailOptions): string[];
|
|
68
|
+
export function agentDetailParts(agent: AgentRowSnapshot, optionsOrNow: AgentDetailOptions | number = {}): string[] {
|
|
69
|
+
const options = typeof optionsOrNow === "number" ? { now: optionsOrNow } : optionsOrNow;
|
|
70
|
+
const now = options.now ?? Date.now();
|
|
71
|
+
const includeQueuedStatus = options.includeQueuedStatus ?? true;
|
|
72
|
+
const parts: string[] = [];
|
|
73
|
+
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool${agent.toolUses === 1 ? "" : "s"}`);
|
|
74
|
+
if (agent.lastTool) parts.push(agent.lastTool);
|
|
75
|
+
if (agent.startedAt !== undefined) parts.push(formatDuration((agent.doneAt ?? now) - agent.startedAt));
|
|
76
|
+
else if (includeQueuedStatus && agent.status === "queued") parts.push("queued");
|
|
77
|
+
if (agent.status === "failed" && agent.error) parts.push(agent.error);
|
|
78
|
+
return parts;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function agentLabelColor(agent: AgentRowSnapshot): WorkflowThemeColor {
|
|
82
|
+
return agent.status === "running" ? "text" : "muted";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface WorkflowStatusCounts {
|
|
86
|
+
readonly queued: number;
|
|
87
|
+
readonly running: number;
|
|
88
|
+
readonly done: number;
|
|
89
|
+
readonly failed: number;
|
|
90
|
+
readonly total: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface WorkflowStatusSource {
|
|
94
|
+
readonly title: string;
|
|
95
|
+
readonly doneAt?: number;
|
|
96
|
+
readonly currentPhase: string;
|
|
97
|
+
readonly counters: readonly { readonly key: string; readonly label: string; readonly value: number }[];
|
|
98
|
+
}
|
|
99
|
+
|
|
61
100
|
export function statusText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
101
|
+
const counts = countSnapshotAgents(snapshot);
|
|
102
|
+
return statusTextFromCounts(snapshot, counts, theme);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function statusTextFromCounts(snapshot: WorkflowStatusSource, counts: WorkflowStatusCounts, theme: Theme): string | undefined {
|
|
62
106
|
if (snapshot.doneAt !== undefined) return undefined;
|
|
63
107
|
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const active = agents.filter((agent) => agent.status === "running" || agent.status === "queued").length;
|
|
67
|
-
const total = agents.length;
|
|
108
|
+
const complete = counts.done + counts.failed;
|
|
109
|
+
const active = counts.running + counts.queued;
|
|
68
110
|
const kept = snapshot.counters.find((counter) => counter.key === "kept" || counter.label.toLowerCase() === "kept");
|
|
69
111
|
const displayName = snapshot.title === "code-review" ? "review" : snapshot.title;
|
|
70
112
|
|
|
71
113
|
const parts = [theme.fg("accent", displayName), theme.fg("muted", snapshot.currentPhase)];
|
|
72
|
-
if (total > 0) parts.push(theme.fg("muted", `${complete}/${total}`));
|
|
114
|
+
if (counts.total > 0) parts.push(theme.fg("muted", `${complete}/${counts.total}`));
|
|
73
115
|
if (kept) parts.push(theme.fg("success", `${formatCount(kept.value)} kept`));
|
|
74
116
|
else if (active > 0) parts.push(theme.fg("muted", `${active} active`));
|
|
75
117
|
|
|
76
118
|
return parts.join(theme.fg("dim", " · "));
|
|
77
119
|
}
|
|
78
120
|
|
|
121
|
+
function countSnapshotAgents(snapshot: WorkflowProgressSnapshot): WorkflowStatusCounts {
|
|
122
|
+
const counts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
|
|
123
|
+
for (const phase of snapshot.phases) {
|
|
124
|
+
for (const agent of phase.agents) {
|
|
125
|
+
counts[agent.status]++;
|
|
126
|
+
counts.total++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return counts;
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
function formatCompact(value: number): string {
|
|
80
133
|
if (value >= 100) return `${Math.round(value)}`;
|
|
81
134
|
const rounded = Math.round(value * 10) / 10;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { matchesKey, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { AgentRowSnapshot, WorkflowLaneItemSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
|
|
4
|
-
import { formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
|
|
4
|
+
import { agentDetailParts, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
|
|
5
5
|
|
|
6
6
|
type Section = "Overview" | "Agents" | "Findings" | "Logs";
|
|
7
7
|
|
|
@@ -195,10 +195,9 @@ export class WorkflowInspector {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
private agentLine(agent: AgentRowSnapshot, selected: boolean): string {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
const text = `${statusIcon(agent.status, this.theme)} ${agent.label} ${this.theme.fg("dim", `· ${details.join(" · ")}`)}`;
|
|
198
|
+
const details = agentDetailParts(agent);
|
|
199
|
+
const suffix = details.length > 0 ? ` ${this.theme.fg("dim", `· ${details.join(" · ")}`)}` : "";
|
|
200
|
+
const text = `${statusIcon(agent.status, this.theme)} ${agent.label}${suffix}`;
|
|
202
201
|
return selected ? this.theme.bg("selectedBg", text) : text;
|
|
203
202
|
}
|
|
204
203
|
|
|
@@ -212,8 +211,11 @@ export class WorkflowInspector {
|
|
|
212
211
|
private findingLines(snapshot: WorkflowProgressSnapshot, _width: number): InspectorLine[] {
|
|
213
212
|
const lines: InspectorLine[] = [];
|
|
214
213
|
let selectableIndex = -1;
|
|
214
|
+
const overflowByLane = new Map(snapshot.laneOverflow);
|
|
215
215
|
for (const [lane, items] of snapshot.lanes) {
|
|
216
|
-
|
|
216
|
+
const hidden = overflowByLane.get(lane) ?? 0;
|
|
217
|
+
const countText = hidden > 0 ? `${items.length} shown, ${hidden} hidden` : `${items.length}`;
|
|
218
|
+
lines.push({ text: this.theme.fg("dim", `${lane} (${countText})`) });
|
|
217
219
|
items.forEach((item, index) => {
|
|
218
220
|
selectableIndex++;
|
|
219
221
|
const key = `finding:${lane}:${index}:${item.createdAt}`;
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Box, type Component, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { AdvisoryFinding, AdvisoryLocation, AdvisoryReport } from "../advisory-schema.ts";
|
|
4
|
+
import type { PerfAggregate } from "../perf.ts";
|
|
4
5
|
import { badge, formatCount, type WorkflowThemeColor } from "./workflow-format.ts";
|
|
5
6
|
|
|
7
|
+
export interface WorkflowPerfDetails {
|
|
8
|
+
readonly enabled: boolean;
|
|
9
|
+
readonly startedAt: number;
|
|
10
|
+
readonly aggregates: readonly PerfAggregate[];
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export interface WorkflowResultEnvelope {
|
|
7
14
|
name: string;
|
|
8
15
|
result: unknown;
|
|
9
16
|
completedAt: number;
|
|
17
|
+
perf?: WorkflowPerfDetails;
|
|
10
18
|
}
|
|
11
19
|
|
|
12
20
|
export interface AdvisoryWorkflowResult extends AdvisoryReport {
|
|
@@ -18,7 +26,7 @@ export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelop
|
|
|
18
26
|
return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
function isAdvisoryLocation(value: unknown): value is AdvisoryLocation {
|
|
22
30
|
if (!isRecord(value)) return false;
|
|
23
31
|
if (typeof value.file !== "string") return false;
|
|
24
32
|
if (value.line !== undefined && typeof value.line !== "number") return false;
|
|
@@ -26,7 +34,7 @@ export function isAdvisoryLocation(value: unknown): value is AdvisoryLocation {
|
|
|
26
34
|
return true;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
function isAdvisoryFinding(value: unknown): value is AdvisoryFinding {
|
|
30
38
|
if (!isRecord(value)) return false;
|
|
31
39
|
if (typeof value.summary !== "string") return false;
|
|
32
40
|
if (typeof value.category !== "string") return false;
|
|
@@ -142,7 +150,7 @@ function renderGenericWorkflowResult(name: string, result: unknown, expanded: bo
|
|
|
142
150
|
const summary = extractSummary(result);
|
|
143
151
|
if (summary) lines.push(theme.fg("muted", summary));
|
|
144
152
|
if (expanded) lines.push(theme.fg("dim", safeJson(result)));
|
|
145
|
-
else if (!summary) lines.push(theme.fg("dim",
|
|
153
|
+
else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
|
|
146
154
|
return lines.join("\n");
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -164,11 +172,6 @@ function extractSummary(value: unknown): string | undefined {
|
|
|
164
172
|
return undefined;
|
|
165
173
|
}
|
|
166
174
|
|
|
167
|
-
function compactJson(value: unknown): string {
|
|
168
|
-
const json = safeJson(value).replace(/\s+/g, " ").trim();
|
|
169
|
-
return json.length > 240 ? `${json.slice(0, 237)}...` : json;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
175
|
function safeJson(value: unknown): string {
|
|
173
176
|
try {
|
|
174
177
|
return JSON.stringify(value, null, 2) ?? String(value);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress.ts";
|
|
3
|
+
import { agentDetailParts, agentLabelColor, formatCount, formatDuration, statusIcon, truncateDisplay } from "./workflow-format.ts";
|
|
4
|
+
|
|
5
|
+
const MAX_WIDGET_LINES = 12;
|
|
6
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
|
|
8
|
+
export interface WorkflowWidget {
|
|
9
|
+
nextFrame(): void;
|
|
10
|
+
render(width: number, theme: Theme): string[];
|
|
11
|
+
invalidate(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createWorkflowWidget(snapshotProvider: () => WorkflowProgressSnapshot): WorkflowWidget {
|
|
15
|
+
return new LiveWorkflowWidget(snapshotProvider);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function renderWorkflowWidgetLines(
|
|
19
|
+
snapshot: WorkflowProgressSnapshot,
|
|
20
|
+
frame: number,
|
|
21
|
+
width: number,
|
|
22
|
+
theme: Theme,
|
|
23
|
+
): string[] {
|
|
24
|
+
const safeWidth = Math.max(1, width);
|
|
25
|
+
const counts = countAgents(snapshot.phases);
|
|
26
|
+
const active = counts.running + counts.queued;
|
|
27
|
+
const elapsed = formatDuration((snapshot.doneAt ?? Date.now()) - snapshot.startedAt);
|
|
28
|
+
const spinner = active > 0 ? theme.fg("accent", SPINNER[frame % SPINNER.length]) : theme.fg("success", "✓");
|
|
29
|
+
|
|
30
|
+
const headingParts = [`${counts.done}/${counts.total} done`, elapsed];
|
|
31
|
+
if (counts.running > 0) headingParts.unshift(`${counts.running} running`);
|
|
32
|
+
if (counts.queued > 0) headingParts.unshift(`${counts.queued} queued`);
|
|
33
|
+
if (counts.failed > 0) headingParts.unshift(theme.fg("error", `${counts.failed} failed`));
|
|
34
|
+
|
|
35
|
+
const lines: string[] = [
|
|
36
|
+
truncateDisplay(
|
|
37
|
+
`${spinner} ${theme.bold(snapshot.title)} ${theme.fg("dim", "·")} ${theme.fg("muted", snapshot.currentPhase)} ${theme.fg("dim", "·")} ${headingParts.join(` ${theme.fg("dim", "·")} `)}`,
|
|
38
|
+
safeWidth,
|
|
39
|
+
),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const footer = footerLine(snapshot, theme);
|
|
43
|
+
const bodyBudget = Math.max(0, MAX_WIDGET_LINES - lines.length - (footer ? 1 : 0));
|
|
44
|
+
const body = visibleBodyLines(snapshot.phases, bodyBudget, theme);
|
|
45
|
+
const reserveHiddenLine = body.hidden > 0 && bodyBudget > 0;
|
|
46
|
+
const visibleBodyLinesToRender = reserveHiddenLine ? body.lines.slice(0, Math.max(0, bodyBudget - 1)) : body.lines;
|
|
47
|
+
const hidden = body.hidden + (body.lines.length - visibleBodyLinesToRender.length);
|
|
48
|
+
|
|
49
|
+
for (const line of visibleBodyLinesToRender) lines.push(truncateDisplay(line, safeWidth));
|
|
50
|
+
if (hidden > 0 && lines.length < MAX_WIDGET_LINES) {
|
|
51
|
+
lines.push(truncateDisplay(`${theme.fg("dim", "└─")} ${theme.fg("dim", `+${hidden} more`)}`, safeWidth));
|
|
52
|
+
}
|
|
53
|
+
if (footer && lines.length < MAX_WIDGET_LINES) lines.push(truncateDisplay(footer, safeWidth));
|
|
54
|
+
|
|
55
|
+
return lines.slice(0, MAX_WIDGET_LINES).map((line) => truncateDisplay(line, safeWidth));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class LiveWorkflowWidget implements WorkflowWidget {
|
|
59
|
+
private frame = 0;
|
|
60
|
+
private cachedWidth: number | undefined;
|
|
61
|
+
private cachedFrame: number | undefined;
|
|
62
|
+
private cachedDoneAt: number | undefined;
|
|
63
|
+
private cachedLines: string[] | undefined;
|
|
64
|
+
|
|
65
|
+
constructor(private readonly snapshotProvider: () => WorkflowProgressSnapshot) {}
|
|
66
|
+
|
|
67
|
+
nextFrame(): void {
|
|
68
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
69
|
+
this.invalidate();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(width: number, theme: Theme): string[] {
|
|
73
|
+
const snapshot = this.snapshotProvider();
|
|
74
|
+
if (
|
|
75
|
+
this.cachedLines &&
|
|
76
|
+
this.cachedWidth === width &&
|
|
77
|
+
this.cachedFrame === this.frame &&
|
|
78
|
+
this.cachedDoneAt === snapshot.doneAt
|
|
79
|
+
) {
|
|
80
|
+
return this.cachedLines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.cachedLines = renderWorkflowWidgetLines(snapshot, this.frame, width, theme);
|
|
84
|
+
this.cachedWidth = width;
|
|
85
|
+
this.cachedFrame = this.frame;
|
|
86
|
+
this.cachedDoneAt = snapshot.doneAt;
|
|
87
|
+
return this.cachedLines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
invalidate(): void {
|
|
91
|
+
this.cachedWidth = undefined;
|
|
92
|
+
this.cachedFrame = undefined;
|
|
93
|
+
this.cachedDoneAt = undefined;
|
|
94
|
+
this.cachedLines = undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface AgentCounts {
|
|
99
|
+
queued: number;
|
|
100
|
+
running: number;
|
|
101
|
+
done: number;
|
|
102
|
+
failed: number;
|
|
103
|
+
total: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function countAgents(phases: readonly PhaseSnapshot[]): AgentCounts {
|
|
107
|
+
const counts: AgentCounts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
|
|
108
|
+
for (const phase of phases) {
|
|
109
|
+
for (const agent of phase.agents) {
|
|
110
|
+
counts[agent.status]++;
|
|
111
|
+
counts.total++;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return counts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function visibleBodyLines(phases: readonly PhaseSnapshot[], budget: number, theme: Theme): { lines: string[]; hidden: number } {
|
|
118
|
+
const lines: string[] = [];
|
|
119
|
+
let totalRows = 0;
|
|
120
|
+
const visit = (phase: PhaseSnapshot): void => {
|
|
121
|
+
if (phase.agents.length === 0 && phase.title === "Workflow") return;
|
|
122
|
+
const counts = countPhaseAgents(phase);
|
|
123
|
+
totalRows++;
|
|
124
|
+
if (lines.length < budget) lines.push(phaseLine(phase.title, counts, theme));
|
|
125
|
+
appendAgentGroup(phase.agents, (agent) => agent.status === "running" || agent.status === "queued", lines, budget, theme, () => totalRows++);
|
|
126
|
+
appendAgentGroup(phase.agents, (agent) => agent.status === "failed", lines, budget, theme, () => totalRows++);
|
|
127
|
+
appendAgentGroup(phase.agents, (agent) => agent.status === "done", lines, budget, theme, () => totalRows++);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
for (const phase of phases) {
|
|
131
|
+
if (phase.agents.some((agent) => agent.status === "running" || agent.status === "queued")) visit(phase);
|
|
132
|
+
}
|
|
133
|
+
for (const phase of phases) {
|
|
134
|
+
if (!phase.agents.some((agent) => agent.status === "running" || agent.status === "queued")) visit(phase);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { lines, hidden: Math.max(0, totalRows - lines.length) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function appendAgentGroup(
|
|
141
|
+
agents: readonly AgentRowSnapshot[],
|
|
142
|
+
include: (agent: AgentRowSnapshot) => boolean,
|
|
143
|
+
lines: string[],
|
|
144
|
+
budget: number,
|
|
145
|
+
theme: Theme,
|
|
146
|
+
countRow: () => void,
|
|
147
|
+
): void {
|
|
148
|
+
for (const agent of agents) {
|
|
149
|
+
if (!include(agent)) continue;
|
|
150
|
+
countRow();
|
|
151
|
+
if (lines.length < budget) lines.push(agentLine(agent, theme));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function countPhaseAgents(phase: PhaseSnapshot): AgentCounts {
|
|
156
|
+
const counts: AgentCounts = { queued: 0, running: 0, done: 0, failed: 0, total: phase.agents.length };
|
|
157
|
+
for (const agent of phase.agents) counts[agent.status]++;
|
|
158
|
+
return counts;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function phaseLine(title: string, counts: AgentCounts, theme: Theme): string {
|
|
162
|
+
const parts: string[] = [];
|
|
163
|
+
if (counts.running > 0) parts.push(`${counts.running} running`);
|
|
164
|
+
if (counts.done > 0) parts.push(`${counts.done} done`);
|
|
165
|
+
if (counts.failed > 0) parts.push(`${counts.failed} failed`);
|
|
166
|
+
return `${theme.fg("dim", "├─")} ${theme.fg(counts.running > 0 ? "accent" : "muted", title)}${parts.length ? ` ${theme.fg("dim", `(${parts.join(" · ")})`)}` : ""}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function agentLine(agent: AgentRowSnapshot, theme: Theme): string {
|
|
170
|
+
const detailParts = agentDetailParts(agent, { includeQueuedStatus: false });
|
|
171
|
+
const activity = detailParts.length > 0 ? ` ${theme.fg("dim", `· ${detailParts.join(" · ")}`)}` : "";
|
|
172
|
+
return `${theme.fg("dim", "│ ")} ${statusIcon(agent.status, theme)} ${theme.fg(agentLabelColor(agent), agent.label)}${activity}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function footerLine(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
|
|
176
|
+
const counters = snapshot.counters.slice(0, 4).map((counter) => `${counter.label} ${formatCount(counter.value)}`);
|
|
177
|
+
const latestLog = snapshot.logs[snapshot.logs.length - 1];
|
|
178
|
+
const parts = counters.length > 0 ? counters : [];
|
|
179
|
+
if (latestLog) parts.push(latestLog);
|
|
180
|
+
if (parts.length === 0) return undefined;
|
|
181
|
+
return `${theme.fg("dim", "└─")} ${theme.fg("dim", parts.join(" · "))}`;
|
|
182
|
+
}
|