pi-workflow-engine 0.1.0 → 0.2.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/package.json +2 -1
- package/src/agent-runner.ts +8 -4
- package/src/engine.ts +19 -2
- package/src/index.ts +79 -14
- package/src/progress.ts +258 -23
- package/src/types.ts +31 -0
- package/src/ui/workflow-format.ts +83 -0
- package/src/ui/workflow-inspector.ts +248 -0
- package/src/ui/workflow-result-renderer.ts +149 -0
- package/src/ui/workflow-widget.ts +143 -0
- package/workflows/code-review.ts +174 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-workflow-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test:ui": "bun scripts/test-workflow-ui.ts",
|
|
19
20
|
"prepublishOnly": "tsc --noEmit"
|
|
20
21
|
},
|
|
21
22
|
"peerDependencies": {
|
package/src/agent-runner.ts
CHANGED
|
@@ -49,11 +49,13 @@ function lastAssistantText(state: AgentState): string {
|
|
|
49
49
|
export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
|
|
50
50
|
const label = opts.label ?? "agent";
|
|
51
51
|
|
|
52
|
-
//
|
|
52
|
+
// Track queued agents before acquiring a global concurrency slot.
|
|
53
|
+
const rowId = rc.progress.agentQueued(opts.phase, label);
|
|
53
54
|
return rc.semaphore.run(async () => {
|
|
54
|
-
rc.progress.agentStart(opts.phase, label);
|
|
55
|
+
rc.progress.agentStart(opts.phase, label, rowId);
|
|
55
56
|
|
|
56
57
|
let captured: unknown = null;
|
|
58
|
+
let failed = false;
|
|
57
59
|
const customTools: ToolDefinition[] = opts.schema
|
|
58
60
|
? [
|
|
59
61
|
defineTool({
|
|
@@ -92,7 +94,7 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
92
94
|
|
|
93
95
|
const unsubscribe = session.subscribe((event) => {
|
|
94
96
|
if (event.type === "tool_execution_start" && event.toolName !== FINAL_TOOL) {
|
|
95
|
-
rc.progress.agentTool(label, event.toolName);
|
|
97
|
+
rc.progress.agentTool(label, event.toolName, rowId);
|
|
96
98
|
}
|
|
97
99
|
});
|
|
98
100
|
|
|
@@ -107,12 +109,14 @@ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOption
|
|
|
107
109
|
}
|
|
108
110
|
return lastAssistantText(session.state);
|
|
109
111
|
} catch (error) {
|
|
112
|
+
failed = true;
|
|
113
|
+
rc.progress.agentFailed(label, error, rowId);
|
|
110
114
|
rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
111
115
|
throw error;
|
|
112
116
|
} finally {
|
|
113
117
|
unsubscribe();
|
|
114
118
|
session.dispose();
|
|
115
|
-
rc.progress.agentDone(label);
|
|
119
|
+
if (!failed) rc.progress.agentDone(label, rowId);
|
|
116
120
|
}
|
|
117
121
|
});
|
|
118
122
|
}
|
package/src/engine.ts
CHANGED
|
@@ -3,7 +3,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { parallel, pipeline, Semaphore } from "./concurrency.ts";
|
|
4
4
|
import { runAgent, type RunContext } from "./agent-runner.ts";
|
|
5
5
|
import { ProgressTracker } from "./progress.ts";
|
|
6
|
-
import type { AgentOptions, WorkflowApi, WorkflowModule } from "./types.ts";
|
|
6
|
+
import type { AgentOptions, WorkflowApi, WorkflowModule, WorkflowRunOptions } from "./types.ts";
|
|
7
|
+
import { WorkflowInspector } from "./ui/workflow-inspector.ts";
|
|
7
8
|
|
|
8
9
|
/** Default global cap on concurrent agents per run. */
|
|
9
10
|
const DEFAULT_CONCURRENCY = Math.min(8, Math.max(2, cpus().length));
|
|
@@ -12,8 +13,22 @@ const DEFAULT_CONCURRENCY = Math.min(8, Math.max(2, cpus().length));
|
|
|
12
13
|
* Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
|
|
13
14
|
* to a shared semaphore + progress tracker), invoke the workflow, return its result.
|
|
14
15
|
*/
|
|
15
|
-
export async function runWorkflow(
|
|
16
|
+
export async function runWorkflow(
|
|
17
|
+
ctx: ExtensionContext,
|
|
18
|
+
mod: WorkflowModule,
|
|
19
|
+
args: string,
|
|
20
|
+
options: WorkflowRunOptions = {},
|
|
21
|
+
): Promise<unknown> {
|
|
16
22
|
const progress = new ProgressTracker(ctx, mod.meta.name);
|
|
23
|
+
if (options.inspect && ctx.hasUI) {
|
|
24
|
+
void ctx.ui
|
|
25
|
+
.custom<void>(
|
|
26
|
+
(tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
|
|
27
|
+
{ overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
|
|
28
|
+
)
|
|
29
|
+
.catch((error: unknown) => progress.log(`inspector failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
const rc: RunContext = {
|
|
18
33
|
cwd: ctx.cwd,
|
|
19
34
|
hostModel: ctx.model,
|
|
@@ -31,7 +46,9 @@ export async function runWorkflow(ctx: ExtensionContext, mod: WorkflowModule, ar
|
|
|
31
46
|
pipeline,
|
|
32
47
|
phase: (title) => progress.phase(title),
|
|
33
48
|
log: (message) => progress.log(message),
|
|
49
|
+
progress: (event) => progress.event(event),
|
|
34
50
|
args,
|
|
51
|
+
cwd: ctx.cwd,
|
|
35
52
|
signal: ctx.signal,
|
|
36
53
|
};
|
|
37
54
|
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
5
|
import { discoverWorkflows } from "./discovery.ts";
|
|
5
6
|
import { runWorkflow } from "./engine.ts";
|
|
7
|
+
import type { WorkflowModule, WorkflowRunOptions } from "./types.ts";
|
|
8
|
+
import { isWorkflowResult, renderWorkflowResult, type WorkflowResultEnvelope } from "./ui/workflow-result-renderer.ts";
|
|
6
9
|
|
|
7
10
|
/** Repo root (this file lives in <repo>/src/index.ts). */
|
|
8
11
|
const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
|
|
@@ -18,34 +21,84 @@ function formatReport(name: string, result: unknown): string {
|
|
|
18
21
|
return [`## Workflow: ${name}`, "", "```json", JSON.stringify(result, null, 2), "```"].join("\n");
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
function workflowEnvelope(name: string, result: unknown): WorkflowResultEnvelope {
|
|
25
|
+
return { name, result, completedAt: Date.now() };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WorkflowInvocation {
|
|
29
|
+
name: string;
|
|
30
|
+
args: string;
|
|
31
|
+
options: WorkflowRunOptions;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseWorkflowInvocation(input: string): WorkflowInvocation {
|
|
35
|
+
const trimmed = input.trim();
|
|
36
|
+
const space = trimmed.indexOf(" ");
|
|
37
|
+
const name = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
38
|
+
const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
39
|
+
const inspect = /(?:^|\s)--inspect(?:\s|$)/.test(rest);
|
|
40
|
+
const cleanedArgs = rest.replace(/(?:^|\s)--inspect(?=\s|$)/g, " ").trim();
|
|
41
|
+
return { name, args: cleanedArgs, options: inspect ? { inspect: true } : {} };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function pickWorkflow(
|
|
45
|
+
workflows: ReadonlyMap<string, WorkflowModule>,
|
|
46
|
+
ctx: ExtensionCommandContext,
|
|
47
|
+
): Promise<WorkflowInvocation | undefined> {
|
|
48
|
+
const choices = [...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`);
|
|
49
|
+
const choice = await ctx.ui.select("Run workflow", choices);
|
|
50
|
+
if (!choice) return undefined;
|
|
51
|
+
|
|
52
|
+
const separator = choice.indexOf(" — ");
|
|
53
|
+
const name = separator === -1 ? choice : choice.slice(0, separator);
|
|
54
|
+
const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
|
|
55
|
+
const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
|
|
56
|
+
return { name, args, options: { inspect } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sendWorkflowResult(
|
|
60
|
+
pi: ExtensionAPI,
|
|
61
|
+
ctx: ExtensionCommandContext,
|
|
62
|
+
name: string,
|
|
63
|
+
mod: WorkflowModule,
|
|
64
|
+
args: string,
|
|
65
|
+
options: WorkflowRunOptions,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
const result = await runWorkflow(ctx, mod, args, options);
|
|
68
|
+
pi.sendMessage(
|
|
69
|
+
{ customType: "workflow-result", content: formatReport(name, result), display: true, details: workflowEnvelope(name, result) },
|
|
70
|
+
{ triggerTurn: false },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
export default function workflowEngine(pi: ExtensionAPI): void {
|
|
75
|
+
pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
|
|
76
|
+
const details = message.details;
|
|
77
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
78
|
+
return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
|
|
79
|
+
});
|
|
80
|
+
|
|
22
81
|
// /workflow <name> [args] — user-invoked.
|
|
23
82
|
pi.registerCommand("workflow", {
|
|
24
83
|
description: "Run a multi-agent workflow: /workflow <name> [args]",
|
|
25
84
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
26
|
-
const trimmed = args.trim();
|
|
27
|
-
const space = trimmed.indexOf(" ");
|
|
28
|
-
const name = space === -1 ? trimmed : trimmed.slice(0, space);
|
|
29
|
-
const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
|
|
30
|
-
|
|
31
85
|
const workflows = await discoverWorkflows(REPO_DIR);
|
|
32
86
|
const available = [...workflows.keys()].join(", ") || "(none)";
|
|
87
|
+
const direct = parseWorkflowInvocation(args);
|
|
88
|
+
const invocation = direct.name ? direct : ctx.hasUI ? await pickWorkflow(workflows, ctx) : undefined;
|
|
33
89
|
|
|
34
|
-
if (!
|
|
90
|
+
if (!invocation) {
|
|
35
91
|
ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
|
|
36
92
|
return;
|
|
37
93
|
}
|
|
38
|
-
|
|
94
|
+
|
|
95
|
+
const mod = workflows.get(invocation.name);
|
|
39
96
|
if (!mod) {
|
|
40
|
-
ctx.ui.notify(`Unknown workflow "${name}". Available: ${available}`, "error");
|
|
97
|
+
ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
|
|
41
98
|
return;
|
|
42
99
|
}
|
|
43
100
|
|
|
44
|
-
|
|
45
|
-
pi.sendMessage(
|
|
46
|
-
{ customType: "workflow-result", content: formatReport(name, result), display: true, details: result },
|
|
47
|
-
{ triggerTurn: false },
|
|
48
|
-
);
|
|
101
|
+
await sendWorkflowResult(pi, ctx, invocation.name, mod, invocation.args, invocation.options);
|
|
49
102
|
},
|
|
50
103
|
});
|
|
51
104
|
|
|
@@ -59,6 +112,18 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
59
112
|
name: Type.String({ description: "Workflow name, e.g. code-review" }),
|
|
60
113
|
args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
|
|
61
114
|
}),
|
|
115
|
+
renderCall(args, theme) {
|
|
116
|
+
const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
|
|
117
|
+
return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name)}${suffix}`, 0, 0);
|
|
118
|
+
},
|
|
119
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
120
|
+
if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
|
|
121
|
+
const details = result.details;
|
|
122
|
+
if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
|
|
123
|
+
const first = result.content[0];
|
|
124
|
+
const text = first?.type === "text" ? first.text : "Workflow finished.";
|
|
125
|
+
return new Text(theme.fg("muted", text), 0, 0);
|
|
126
|
+
},
|
|
62
127
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
63
128
|
const workflows = await discoverWorkflows(REPO_DIR);
|
|
64
129
|
const mod = workflows.get(params.name);
|
|
@@ -70,7 +135,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
|
|
|
70
135
|
};
|
|
71
136
|
}
|
|
72
137
|
const result = await runWorkflow(ctx, mod, params.args ?? "");
|
|
73
|
-
return { content: [{ type: "text", text: summarize(result) }], details: result };
|
|
138
|
+
return { content: [{ type: "text", text: summarize(result) }], details: workflowEnvelope(params.name, result) };
|
|
74
139
|
},
|
|
75
140
|
});
|
|
76
141
|
}
|
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:
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
270
|
+
const error = agent.status === "failed" && agent.error ? ` · ${agent.error}` : "";
|
|
271
|
+
out.push(` ${icon} ${agent.label}${tool}${error}`);
|
|
92
272
|
}
|
|
93
273
|
}
|
|
94
|
-
|
|
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)
|
|
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
|
-
|
|
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,8 +71,12 @@ 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;
|
|
78
|
+
/** Working directory of the host session (typically the repo root). */
|
|
79
|
+
cwd: string;
|
|
49
80
|
/** Abort signal for the run, propagated from the host. */
|
|
50
81
|
signal: AbortSignal | undefined;
|
|
51
82
|
}
|