pi-workflow-engine 0.1.1 → 0.3.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/src/progress.ts CHANGED
@@ -1,9 +1,63 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { TUI } from "@earendil-works/pi-tui";
3
+ import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
4
+ import { statusText } from "./ui/workflow-format.ts";
5
+ import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
6
+
7
+ export type AgentRowStatus = "queued" | "running" | "done" | "failed";
8
+
9
+ export interface AgentRowSnapshot {
10
+ readonly id: number;
11
+ readonly label: string;
12
+ readonly status: AgentRowStatus;
13
+ readonly startedAt?: number;
14
+ readonly doneAt?: number;
15
+ readonly toolUses: number;
16
+ readonly lastTool?: string;
17
+ readonly error?: string;
18
+ }
19
+
20
+ export interface PhaseSnapshot {
21
+ readonly title: string;
22
+ readonly agents: readonly AgentRowSnapshot[];
23
+ }
24
+
25
+ export interface WorkflowCounterSnapshot {
26
+ readonly key: string;
27
+ readonly label: string;
28
+ readonly value: number;
29
+ }
30
+
31
+ export interface WorkflowLaneItemSnapshot {
32
+ readonly lane: string;
33
+ readonly title: string;
34
+ readonly subtitle?: string;
35
+ readonly status: WorkflowLaneItemStatus;
36
+ readonly details?: string;
37
+ readonly createdAt: number;
38
+ }
39
+
40
+ export interface WorkflowProgressSnapshot {
41
+ readonly title: string;
42
+ readonly startedAt: number;
43
+ readonly doneAt?: number;
44
+ readonly currentPhase: string;
45
+ readonly phases: readonly PhaseSnapshot[];
46
+ readonly counters: readonly WorkflowCounterSnapshot[];
47
+ readonly summary: readonly [string, string | number][];
48
+ readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
49
+ readonly logs: readonly string[];
50
+ }
2
51
 
3
52
  interface AgentRow {
53
+ id: number;
4
54
  label: string;
5
- status: "running" | "done";
55
+ status: AgentRowStatus;
56
+ startedAt?: number;
57
+ doneAt?: number;
58
+ toolUses: number;
6
59
  lastTool?: string;
60
+ error?: string;
7
61
  }
8
62
 
9
63
  interface Phase {
@@ -11,21 +65,48 @@ interface Phase {
11
65
  agents: AgentRow[];
12
66
  }
13
67
 
68
+ interface WorkflowCounter {
69
+ key: string;
70
+ label: string;
71
+ value: number;
72
+ }
73
+
74
+ interface WorkflowLaneItem {
75
+ lane: string;
76
+ title: string;
77
+ subtitle?: string;
78
+ status: WorkflowLaneItemStatus;
79
+ details?: string;
80
+ createdAt: number;
81
+ }
82
+
83
+ const LOG_LIMIT = 24;
84
+
14
85
  /**
15
- * Renders a live phase/agent tree into the pi TUI via `ctx.ui.setWidget`.
16
- * In non-interactive modes (print/JSON/RPC) it falls back to terse stderr breadcrumbs
17
- * so it never corrupts stdout.
86
+ * Tracks live workflow state for widgets, footer/status text, result renderers,
87
+ * and headless stderr breadcrumbs.
18
88
  */
19
89
  export class ProgressTracker {
20
90
  private readonly phases: Phase[] = [];
21
91
  private readonly logs: string[] = [];
22
- private current = "Workflow";
92
+ private readonly counters = new Map<string, WorkflowCounter>();
93
+ private readonly summary = new Map<string, string | number>();
94
+ private readonly lanes = new Map<string, WorkflowLaneItem[]>();
95
+ private readonly startedAt = Date.now();
96
+ private doneAt: number | undefined;
97
+ private currentPhase = "Workflow";
98
+ private nextAgentId = 1;
99
+ private widget: WorkflowWidget | undefined;
100
+ private widgetRegistered = false;
101
+ private tui: Pick<TUI, "requestRender"> | undefined;
102
+ private widgetInterval: ReturnType<typeof setInterval> | undefined;
103
+ private lastStatusText: string | undefined;
23
104
 
24
105
  constructor(
25
106
  private readonly ctx: ExtensionContext,
26
107
  private readonly title: string,
27
108
  ) {
28
- this.ensurePhase(this.current);
109
+ this.ensurePhase(this.currentPhase);
29
110
  }
30
111
 
31
112
  private ensurePhase(title: string): Phase {
@@ -38,7 +119,7 @@ export class ProgressTracker {
38
119
  }
39
120
 
40
121
  phase(title: string): void {
41
- this.current = title;
122
+ this.currentPhase = title;
42
123
  this.ensurePhase(title);
43
124
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${title}\n`);
44
125
  this.render();
@@ -46,31 +127,121 @@ export class ProgressTracker {
46
127
 
47
128
  log(message: string): void {
48
129
  this.logs.push(message);
49
- if (this.logs.length > 6) this.logs.shift();
130
+ while (this.logs.length > LOG_LIMIT) this.logs.shift();
50
131
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${message}\n`);
51
132
  this.render();
52
133
  }
53
134
 
54
- agentStart(phase: string | undefined, label: string): void {
55
- this.ensurePhase(phase ?? this.current).agents.push({ label, status: "running" });
135
+ event(event: WorkflowProgressEvent): void {
136
+ switch (event.type) {
137
+ case "counter":
138
+ this.counters.set(event.key, { key: event.key, label: event.label, value: event.value });
139
+ break;
140
+ case "counter_delta": {
141
+ const current = this.counters.get(event.key);
142
+ this.counters.set(event.key, {
143
+ key: event.key,
144
+ label: event.label,
145
+ value: (current?.value ?? 0) + event.delta,
146
+ });
147
+ break;
148
+ }
149
+ case "lane_item": {
150
+ const lane = this.lanes.get(event.lane) ?? [];
151
+ lane.push({
152
+ lane: event.lane,
153
+ title: event.title,
154
+ subtitle: event.subtitle,
155
+ status: event.status,
156
+ details: event.details,
157
+ createdAt: Date.now(),
158
+ });
159
+ this.lanes.set(event.lane, lane);
160
+ break;
161
+ }
162
+ case "summary":
163
+ this.summary.set(event.key, event.value);
164
+ break;
165
+ }
166
+ this.render();
167
+ }
168
+
169
+ agentQueued(phase: string | undefined, label: string): number {
170
+ const id = this.nextAgentId++;
171
+ this.ensurePhase(phase ?? this.currentPhase).agents.push({ label, id, status: "queued", toolUses: 0 });
172
+ this.render();
173
+ return id;
174
+ }
175
+
176
+ agentStart(phase: string | undefined, label: string, id?: number): void {
177
+ const row = id === undefined ? undefined : this.findRowById(id);
178
+ if (row) {
179
+ row.status = "running";
180
+ row.startedAt = Date.now();
181
+ row.error = undefined;
182
+ } else {
183
+ this.ensurePhase(phase ?? this.currentPhase).agents.push({
184
+ label,
185
+ id: this.nextAgentId++,
186
+ status: "running",
187
+ startedAt: Date.now(),
188
+ toolUses: 0,
189
+ });
190
+ }
56
191
  this.render();
57
192
  }
58
193
 
59
- agentTool(label: string, tool: string): void {
60
- const row = this.findRow(label);
61
- if (row) row.lastTool = tool;
194
+ agentTool(label: string, tool: string, id?: number): void {
195
+ const row = this.findRow(label, id);
196
+ if (row) {
197
+ row.lastTool = tool;
198
+ row.toolUses += 1;
199
+ }
62
200
  this.render();
63
201
  }
64
202
 
65
- agentDone(label: string): void {
66
- const row = this.findRow(label);
67
- if (row && row.status === "running") row.status = "done";
203
+ agentDone(label: string, id?: number): void {
204
+ const row = this.findRow(label, id);
205
+ if (row && row.status !== "failed") {
206
+ row.status = "done";
207
+ row.doneAt = Date.now();
208
+ }
68
209
  this.render();
69
210
  }
70
211
 
71
- private findRow(label: string): AgentRow | undefined {
212
+ agentFailed(label: string, error: unknown, id?: number): void {
213
+ const row = this.findRow(label, id);
214
+ if (row) {
215
+ row.status = "failed";
216
+ row.doneAt = Date.now();
217
+ row.error = errorMessage(error);
218
+ }
219
+ this.render();
220
+ }
221
+
222
+ snapshot(): WorkflowProgressSnapshot {
223
+ return {
224
+ title: this.title,
225
+ startedAt: this.startedAt,
226
+ doneAt: this.doneAt,
227
+ currentPhase: this.currentPhase,
228
+ phases: this.phases.map((phase) => ({
229
+ title: phase.title,
230
+ agents: phase.agents.map((agent) => ({ ...agent })),
231
+ })),
232
+ counters: [...this.counters.values()].map((counter) => ({ ...counter })),
233
+ summary: [...this.summary.entries()],
234
+ lanes: [...this.lanes.entries()].map(([lane, items]) => [lane, items.map((item) => ({ ...item }))]),
235
+ logs: [...this.logs],
236
+ };
237
+ }
238
+
239
+ private findRow(label: string, id?: number): AgentRow | undefined {
240
+ if (id !== undefined) return this.findRowById(id);
72
241
  for (let i = this.phases.length - 1; i >= 0; i--) {
73
- const running = this.phases[i].agents.find((agent) => agent.label === label && agent.status === "running");
242
+ const running = this.phases[i].agents.find(
243
+ (agent) => agent.label === label && (agent.status === "running" || agent.status === "queued"),
244
+ );
74
245
  if (running) return running;
75
246
  }
76
247
  for (let i = this.phases.length - 1; i >= 0; i--) {
@@ -80,29 +251,93 @@ export class ProgressTracker {
80
251
  return undefined;
81
252
  }
82
253
 
254
+ private findRowById(id: number): AgentRow | undefined {
255
+ for (const phase of this.phases) {
256
+ const row = phase.agents.find((agent) => agent.id === id);
257
+ if (row) return row;
258
+ }
259
+ return undefined;
260
+ }
261
+
83
262
  private lines(): string[] {
84
263
  const out: string[] = [`⚙ ${this.title}`];
85
264
  for (const phase of this.phases) {
86
265
  if (phase.agents.length === 0 && phase.title === "Workflow") continue;
87
266
  out.push(` ${phase.title}`);
88
267
  for (const agent of phase.agents) {
89
- const icon = agent.status === "done" ? "✓" : "⏳";
268
+ const icon = agent.status === "done" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "queued" ? "○" : "⏳";
90
269
  const tool = agent.status === "running" && agent.lastTool ? ` · ${agent.lastTool}` : "";
91
- out.push(` ${icon} ${agent.label}${tool}`);
270
+ const error = agent.status === "failed" && agent.error ? ` · ${agent.error}` : "";
271
+ out.push(` ${icon} ${agent.label}${tool}${error}`);
92
272
  }
93
273
  }
94
- for (const line of this.logs) out.push(` · ${line}`);
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}`);
95
277
  return out;
96
278
  }
97
279
 
98
280
  private render(): void {
99
- if (this.ctx.hasUI) this.ctx.ui.setWidget("workflow", this.lines());
281
+ if (!this.ctx.hasUI) return;
282
+ this.ensureWidget();
283
+ this.widget?.invalidate();
284
+ this.tui?.requestRender();
285
+ this.publishStatus();
286
+ }
287
+
288
+ private publishStatus(): void {
289
+ const next = statusText(this.snapshot(), this.ctx.ui.theme);
290
+ if (next === this.lastStatusText) return;
291
+ this.ctx.ui.setStatus("workflow", next);
292
+ this.lastStatusText = next;
293
+ }
294
+
295
+ private ensureWidget(): void {
296
+ if (this.widgetRegistered) return;
297
+ this.widget = createWorkflowWidget(() => this.snapshot());
298
+ this.ctx.ui.setWidget(
299
+ "workflow",
300
+ (tui, theme) => {
301
+ this.tui = tui;
302
+ return {
303
+ render: (width?: number) => this.widget?.render(width ?? tui.terminal.columns, theme) ?? [],
304
+ invalidate: () => this.widget?.invalidate(),
305
+ };
306
+ },
307
+ { placement: "aboveEditor" },
308
+ );
309
+ this.widgetRegistered = true;
310
+ this.startWidgetTimer();
100
311
  }
101
312
 
102
- /** Clear the widget; optionally leave a one-line status in the footer. */
313
+ private startWidgetTimer(): void {
314
+ if (this.widgetInterval !== undefined) return;
315
+ this.widgetInterval = setInterval(() => {
316
+ this.widget?.nextFrame();
317
+ this.tui?.requestRender();
318
+ }, 100);
319
+ }
320
+
321
+ private stopWidgetTimer(): void {
322
+ if (this.widgetInterval === undefined) return;
323
+ clearInterval(this.widgetInterval);
324
+ this.widgetInterval = undefined;
325
+ }
326
+
327
+ /** Clear live workflow surfaces; optionally leave a one-line final status. */
103
328
  done(status?: string): void {
329
+ this.doneAt = Date.now();
330
+ this.stopWidgetTimer();
104
331
  if (!this.ctx.hasUI) return;
105
332
  this.ctx.ui.setWidget("workflow", undefined);
106
333
  this.ctx.ui.setStatus("workflow", status);
334
+ this.lastStatusText = status;
335
+ this.widgetRegistered = false;
336
+ this.widget = undefined;
337
+ this.tui = undefined;
107
338
  }
108
339
  }
340
+
341
+ function errorMessage(error: unknown): string {
342
+ return error instanceof Error ? error.message : String(error);
343
+ }
package/src/types.ts CHANGED
@@ -8,7 +8,34 @@ export interface WorkflowMeta {
8
8
  phases?: Array<{ title: string }>;
9
9
  }
10
10
 
11
+ export interface WorkflowRunStats {
12
+ files: number;
13
+ candidates: number;
14
+ verified: number;
15
+ kept: number;
16
+ [key: string]: string | number;
17
+ }
18
+
19
+ export interface WorkflowRunOptions {
20
+ inspect?: boolean;
21
+ }
22
+
11
23
  /** Options for a single `agent()` call. */
24
+ export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
25
+
26
+ export type WorkflowProgressEvent =
27
+ | { type: "counter"; key: string; label: string; value: number }
28
+ | { type: "counter_delta"; key: string; label: string; delta: number }
29
+ | {
30
+ type: "lane_item";
31
+ lane: string;
32
+ title: string;
33
+ subtitle?: string;
34
+ status: WorkflowLaneItemStatus;
35
+ details?: string;
36
+ }
37
+ | { type: "summary"; key: string; value: string | number };
38
+
12
39
  export interface AgentOptions<S extends TSchema = TSchema> {
13
40
  /** Label shown in the progress tree (e.g. "find:logic-bugs"). */
14
41
  label?: string;
@@ -44,6 +71,8 @@ export interface WorkflowApi {
44
71
  phase(title: string): void;
45
72
  /** Emit a progress line. */
46
73
  log(message: string): void;
74
+ /** Emit a structured progress event for native workflow UI surfaces. */
75
+ progress(event: WorkflowProgressEvent): void;
47
76
  /** Raw argument string passed after the workflow name. */
48
77
  args: string;
49
78
  /** Working directory of the host session (typically the repo root). */
@@ -0,0 +1,83 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { WorkflowProgressSnapshot } from "../progress.ts";
4
+ import type { WorkflowLaneItemStatus } from "../types.ts";
5
+
6
+ export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed";
7
+ export type WorkflowThemeColor = Parameters<Theme["fg"]>[0];
8
+
9
+ export function formatDuration(ms: number): string {
10
+ if (!Number.isFinite(ms) || ms <= 0) return "0s";
11
+ if (ms < 1_000) return `${Math.round(ms)}ms`;
12
+
13
+ const totalSeconds = Math.floor(ms / 1_000);
14
+ if (totalSeconds < 60) return `${totalSeconds}s`;
15
+
16
+ const minutes = Math.floor(totalSeconds / 60);
17
+ const seconds = totalSeconds % 60;
18
+ if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
19
+
20
+ const hours = Math.floor(minutes / 60);
21
+ const remainingMinutes = minutes % 60;
22
+ return remainingMinutes === 0 ? `${hours}h` : `${hours}h ${remainingMinutes}m`;
23
+ }
24
+
25
+ export function formatCount(n: number): string {
26
+ if (!Number.isFinite(n)) return "0";
27
+ const sign = n < 0 ? "-" : "";
28
+ const value = Math.abs(n);
29
+ if (value < 1_000) return `${Math.trunc(n)}`;
30
+ if (value < 1_000_000) return `${sign}${formatCompact(value / 1_000)}k`;
31
+ return `${sign}${formatCompact(value / 1_000_000)}m`;
32
+ }
33
+
34
+ export function statusIcon(status: WorkflowDisplayStatus, theme: Theme): string {
35
+ switch (status) {
36
+ case "success":
37
+ case "done":
38
+ return theme.fg("success", "✓");
39
+ case "warning":
40
+ return theme.fg("warning", "!");
41
+ case "error":
42
+ case "failed":
43
+ return theme.fg("error", "✗");
44
+ case "running":
45
+ return theme.fg("accent", "●");
46
+ case "queued":
47
+ case "pending":
48
+ return theme.fg("dim", "○");
49
+ }
50
+ }
51
+
52
+ export function badge(label: string, color: WorkflowThemeColor, theme: Theme): string {
53
+ return theme.fg(color, `[${label}]`);
54
+ }
55
+
56
+ export function truncateDisplay(text: string, width: number): string {
57
+ if (width <= 0) return "";
58
+ return truncateToWidth(text, width);
59
+ }
60
+
61
+ export function statusText(snapshot: WorkflowProgressSnapshot, theme: Theme): string | undefined {
62
+ if (snapshot.doneAt !== undefined) return undefined;
63
+
64
+ const agents = snapshot.phases.flatMap((phase) => phase.agents);
65
+ const complete = agents.filter((agent) => agent.status === "done" || agent.status === "failed").length;
66
+ const active = agents.filter((agent) => agent.status === "running" || agent.status === "queued").length;
67
+ const total = agents.length;
68
+ const kept = snapshot.counters.find((counter) => counter.key === "kept" || counter.label.toLowerCase() === "kept");
69
+ const displayName = snapshot.title === "code-review" ? "review" : snapshot.title;
70
+
71
+ const parts = [theme.fg("accent", displayName), theme.fg("muted", snapshot.currentPhase)];
72
+ if (total > 0) parts.push(theme.fg("muted", `${complete}/${total}`));
73
+ if (kept) parts.push(theme.fg("success", `${formatCount(kept.value)} kept`));
74
+ else if (active > 0) parts.push(theme.fg("muted", `${active} active`));
75
+
76
+ return parts.join(theme.fg("dim", " · "));
77
+ }
78
+
79
+ function formatCompact(value: number): string {
80
+ if (value >= 100) return `${Math.round(value)}`;
81
+ const rounded = Math.round(value * 10) / 10;
82
+ return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1);
83
+ }