pi-extensible-workflows 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/README.md +72 -0
- package/dist/src/agent-execution.d.ts +213 -0
- package/dist/src/agent-execution.js +537 -0
- package/dist/src/ambient-workflow-evals.d.ts +112 -0
- package/dist/src/ambient-workflow-evals.js +375 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/doctor.d.ts +59 -0
- package/dist/src/doctor.js +269 -0
- package/dist/src/eval-capture-extension.d.ts +5 -0
- package/dist/src/eval-capture-extension.js +48 -0
- package/dist/src/index.d.ts +362 -0
- package/dist/src/index.js +2839 -0
- package/dist/src/persistence.d.ts +90 -0
- package/dist/src/persistence.js +530 -0
- package/dist/src/session-inspector.d.ts +59 -0
- package/dist/src/session-inspector.js +396 -0
- package/dist/src/workflow-evals-child.d.ts +1 -0
- package/dist/src/workflow-evals-child.js +13 -0
- package/dist/src/workflow-evals.d.ts +290 -0
- package/dist/src/workflow-evals.js +1221 -0
- package/evals/cases/custom-model-read.yaml +15 -0
- package/evals/cases/direct-answer.yaml +6 -0
- package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
- package/evals/cases/output-schema.yaml +22 -0
- package/evals/cases/parallel.yaml +15 -0
- package/evals/cases/pipeline.yaml +10 -0
- package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
- package/evals/cases/required-role.yaml +14 -0
- package/evals/cases/role-model-mixed.yaml +18 -0
- package/evals/cases/two-agents.yaml +10 -0
- package/package.json +65 -0
- package/skills/pi-extensible-workflows/SKILL.md +109 -0
- package/src/agent-execution.ts +529 -0
- package/src/ambient-workflow-evals.ts +452 -0
- package/src/cli.ts +23 -0
- package/src/doctor.ts +285 -0
- package/src/eval-capture-extension.ts +54 -0
- package/src/index.ts +2519 -0
- package/src/persistence.ts +470 -0
- package/src/session-inspector.ts +370 -0
- package/src/workflow-evals-child.ts +15 -0
- package/src/workflow-evals.ts +876 -0
- package/test/fixtures/ready-for-agent-tasks.md +9 -0
- package/test/fixtures/workflow-eval-roles/developer.md +18 -0
- package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
- package/test/fixtures/workflow-eval-roles/scout.md +17 -0
|
@@ -0,0 +1,2839 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import * as acorn from "acorn";
|
|
9
|
+
import { Script } from "node:vm";
|
|
10
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
11
|
+
import { Value } from "typebox/value";
|
|
12
|
+
import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
|
|
14
|
+
import { transcriptLines } from "./session-inspector.js";
|
|
15
|
+
import { acquireSessionLease, listRunIds, RunStore, structuralPath as operationPath } from "./persistence.js";
|
|
16
|
+
export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted"];
|
|
17
|
+
export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"];
|
|
18
|
+
export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
|
|
19
|
+
export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
|
|
20
|
+
const SETTLED_AGENT_STATES = new Set(["completed", "failed", "cancelled"]);
|
|
21
|
+
export const ERROR_CODES = [
|
|
22
|
+
"INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
|
|
23
|
+
"RUN_LIMIT_EXCEEDED", "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
|
|
24
|
+
"CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "INTERNAL_ERROR",
|
|
25
|
+
];
|
|
26
|
+
const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 2;
|
|
27
|
+
export class WorkflowError extends Error {
|
|
28
|
+
code;
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.name = "WorkflowError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const WORKFLOW_AUTHORED_ERROR = Symbol("workflowAuthoredError");
|
|
36
|
+
function errorText(error) { return error && typeof error === "object" && typeof error.message === "string" ? error.message : error instanceof Error ? error.message : String(error); }
|
|
37
|
+
function errorCode(error) {
|
|
38
|
+
if (error instanceof WorkflowError)
|
|
39
|
+
return ERROR_CODES.includes(error.code) ? error.code : undefined;
|
|
40
|
+
if (!error || typeof error !== "object")
|
|
41
|
+
return undefined;
|
|
42
|
+
const code = error.code;
|
|
43
|
+
return typeof code === "string" && ERROR_CODES.includes(code) ? code : undefined;
|
|
44
|
+
}
|
|
45
|
+
function markWorkflowAuthored(error, authored = false) {
|
|
46
|
+
if (authored)
|
|
47
|
+
Object.defineProperty(error, WORKFLOW_AUTHORED_ERROR, { value: true });
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
function isWorkflowAuthored(error) { return Boolean(error && typeof error === "object" && WORKFLOW_AUTHORED_ERROR in error); }
|
|
51
|
+
function workflowDetail(message) {
|
|
52
|
+
const detail = message.trim().replace(new RegExp(`\\b(?:${ERROR_CODES.join("|")})\\b:?\\s*`, "g"), "").replace(/^\s*[A-Z][A-Z0-9_]+:\s*/, "").split("\n").filter((line) => !/^\s*at\s/.test(line)).join("\n").replace(/^Run \S+(?=\s(?:exceeded|is))/i, "Run").replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "the workflow").replace(/^(?:Pi )session \S+(?=\s(?:is|has))/i, "session").replace(/^(Unknown scheduler run|Missing production ownership record|Persisted agent belongs to another run):\s*\S+/i, "$1").replace(/\b(?:runId|sessionId|callSite|occurrence|failedAt|id)[:=]\s*\S+/gi, "").replace(/\s{2,}/g, " ").trim();
|
|
53
|
+
return detail || "No further details were provided";
|
|
54
|
+
}
|
|
55
|
+
const WORKFLOW_ERROR_PROSE = {
|
|
56
|
+
INVALID_SETTINGS: (detail) => `The workflow settings are invalid: ${detail}.`,
|
|
57
|
+
INVALID_SYNTAX: (detail) => `The workflow source is invalid: ${detail}.`,
|
|
58
|
+
INVALID_METADATA: (detail) => `The workflow metadata is invalid: ${detail}.`,
|
|
59
|
+
DUPLICATE_NAME: (detail) => `The workflow contains a duplicate name: ${detail}.`,
|
|
60
|
+
INVALID_SCHEMA: (detail) => `The workflow schema is invalid: ${detail}.`,
|
|
61
|
+
REGISTRY_FROZEN: (detail) => `Workflow extension registration is closed: ${detail}.`,
|
|
62
|
+
GLOBAL_COLLISION: (detail) => `The workflow global name is already in use: ${detail}.`,
|
|
63
|
+
MISSING_WORKFLOW: (detail) => `The workflow primitive is unavailable: ${detail}.`,
|
|
64
|
+
UNKNOWN_MODEL: (detail) => `The workflow requested the unavailable model ${detail.replace(/^(?:Unknown model(?: for role [^:]+)?|Invalid model spec):\s*/, "")}.`,
|
|
65
|
+
UNKNOWN_TOOL: (detail) => `The workflow requested the unavailable tool ${detail.replace(/^Unknown tool:\s*/, "")}.`,
|
|
66
|
+
UNKNOWN_AGENT_TYPE: (detail) => `The workflow requested the unavailable agent role ${detail.replace(/^Unknown agent role:\s*/, "")}.`,
|
|
67
|
+
RUN_LIMIT_EXCEEDED: (detail) => `The workflow exceeded its agent launch limit: ${detail.replace(/^Run\s+exceeded\s+/i, "")}.`,
|
|
68
|
+
RUN_OWNED: (detail) => /already owned|active ownership/.test(detail) ? "The workflow session is already in use." : `The workflow session is already in use: ${detail}.`,
|
|
69
|
+
RPC_LIMIT_EXCEEDED: (detail) => `The workflow communication data exceeded its size limit: ${detail}.`,
|
|
70
|
+
AGENT_TIMEOUT: (detail) => `The workflow agent timed out: ${detail}.`,
|
|
71
|
+
AGENT_FAILED: (detail) => `The workflow agent failed: ${detail}.`,
|
|
72
|
+
RESULT_INVALID: (detail) => `The workflow produced an invalid result: ${detail}.`,
|
|
73
|
+
CANCELLED: (detail) => `The workflow was cancelled: ${detail}.`,
|
|
74
|
+
WORKER_UNRESPONSIVE: (detail) => `The workflow worker stopped responding: ${detail}.`,
|
|
75
|
+
WORKTREE_FAILED: (detail) => `The workflow worktree operation failed: ${detail}.`,
|
|
76
|
+
RESUME_INCOMPATIBLE: (detail) => `The workflow cannot resume this run: ${detail}.`,
|
|
77
|
+
INTERNAL_ERROR: (detail) => `The workflow encountered an internal error: ${detail}.`,
|
|
78
|
+
};
|
|
79
|
+
export function formatWorkflowFailure(error) {
|
|
80
|
+
if (isWorkflowAuthored(error))
|
|
81
|
+
return errorText(error);
|
|
82
|
+
const code = errorCode(error);
|
|
83
|
+
if (code)
|
|
84
|
+
return WORKFLOW_ERROR_PROSE[code](workflowDetail(errorText(error)));
|
|
85
|
+
if (error instanceof Error)
|
|
86
|
+
return error.message || "The workflow failed without an error message.";
|
|
87
|
+
return `The workflow failed with value ${String(error)}.`;
|
|
88
|
+
}
|
|
89
|
+
function workflowErrorFromWorker(error) {
|
|
90
|
+
const code = errorCode(error);
|
|
91
|
+
const typed = markWorkflowAuthored(new WorkflowError(code ?? "INTERNAL_ERROR", error.message), error.authored || !code);
|
|
92
|
+
return error.failedAt === undefined ? typed : Object.assign(typed, { failedAt: error.failedAt });
|
|
93
|
+
}
|
|
94
|
+
function asWorkflowError(error) {
|
|
95
|
+
const code = errorCode(error);
|
|
96
|
+
return markWorkflowAuthored(error instanceof WorkflowError && code ? error : new WorkflowError(code ?? "INTERNAL_ERROR", errorText(error)), isWorkflowAuthored(error) || !code);
|
|
97
|
+
}
|
|
98
|
+
function mainAgentError(error) {
|
|
99
|
+
const typed = asWorkflowError(error);
|
|
100
|
+
const presented = new WorkflowError(typed.code, formatWorkflowFailure(typed));
|
|
101
|
+
Object.assign(presented, typed);
|
|
102
|
+
presented.message = formatWorkflowFailure(typed);
|
|
103
|
+
return presented;
|
|
104
|
+
}
|
|
105
|
+
export class RunLifecycle {
|
|
106
|
+
changed;
|
|
107
|
+
#state;
|
|
108
|
+
#active = 0;
|
|
109
|
+
#waiters = [];
|
|
110
|
+
constructor(state = "running", changed) {
|
|
111
|
+
this.changed = changed;
|
|
112
|
+
this.#state = state;
|
|
113
|
+
}
|
|
114
|
+
get state() { return this.#state; }
|
|
115
|
+
async enter() {
|
|
116
|
+
while (this.#state === "pausing" || this.#state === "paused" || this.#state === "awaiting_input")
|
|
117
|
+
await new Promise((resolve) => { this.#waiters.push(resolve); });
|
|
118
|
+
if (this.#state !== "running")
|
|
119
|
+
throw new WorkflowError("CANCELLED", `Run is ${this.#state}`);
|
|
120
|
+
this.#active += 1;
|
|
121
|
+
}
|
|
122
|
+
async leave() {
|
|
123
|
+
if (this.#active > 0)
|
|
124
|
+
this.#active -= 1;
|
|
125
|
+
if (this.#state === "pausing" && this.#active === 0)
|
|
126
|
+
await this.#set("paused");
|
|
127
|
+
}
|
|
128
|
+
async enterAwaitingInput() {
|
|
129
|
+
while (this.#state === "pausing" || this.#state === "paused")
|
|
130
|
+
await new Promise((resolve) => { this.#waiters.push(resolve); });
|
|
131
|
+
if (this.#state === "awaiting_input")
|
|
132
|
+
return;
|
|
133
|
+
if (this.#state !== "running")
|
|
134
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot await input for ${this.#state} run`);
|
|
135
|
+
await this.#set("awaiting_input");
|
|
136
|
+
}
|
|
137
|
+
async resolveAwaitingInput() {
|
|
138
|
+
if (this.#state !== "awaiting_input")
|
|
139
|
+
return;
|
|
140
|
+
await this.#set("running");
|
|
141
|
+
for (const resolve of this.#waiters.splice(0))
|
|
142
|
+
resolve();
|
|
143
|
+
}
|
|
144
|
+
async pause() {
|
|
145
|
+
if (this.#state !== "running")
|
|
146
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot pause ${this.#state} run`);
|
|
147
|
+
await this.#set("pausing");
|
|
148
|
+
if (this.#active === 0)
|
|
149
|
+
await this.#set("paused");
|
|
150
|
+
}
|
|
151
|
+
async resume() {
|
|
152
|
+
if (this.#state !== "paused" && this.#state !== "interrupted")
|
|
153
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot resume ${this.#state} run`);
|
|
154
|
+
await this.#set("running");
|
|
155
|
+
for (const resolve of this.#waiters.splice(0))
|
|
156
|
+
resolve();
|
|
157
|
+
}
|
|
158
|
+
async providerPause() {
|
|
159
|
+
await this.leave();
|
|
160
|
+
if (this.#state === "running")
|
|
161
|
+
await this.pause();
|
|
162
|
+
await this.enter();
|
|
163
|
+
}
|
|
164
|
+
async terminal(state) {
|
|
165
|
+
if (["completed", "failed", "stopped"].includes(this.#state))
|
|
166
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `${this.#state} runs are terminal`);
|
|
167
|
+
await this.#set(state);
|
|
168
|
+
for (const resolve of this.#waiters.splice(0))
|
|
169
|
+
resolve();
|
|
170
|
+
}
|
|
171
|
+
async #set(state) { this.#state = state; await this.changed?.(state); }
|
|
172
|
+
}
|
|
173
|
+
export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8, maxAgentLaunches: 1000 });
|
|
174
|
+
function fail(code, message) { throw new WorkflowError(code, message); }
|
|
175
|
+
function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
176
|
+
function positiveInteger(value) { return Number.isInteger(value) && value > 0; }
|
|
177
|
+
export function parseModelReference(value) {
|
|
178
|
+
const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
|
|
179
|
+
if (!match?.[1] || !match[2])
|
|
180
|
+
fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
|
|
181
|
+
const thinking = match[3];
|
|
182
|
+
if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))
|
|
183
|
+
fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
|
|
184
|
+
return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking } : {}) };
|
|
185
|
+
}
|
|
186
|
+
function modelCapability(value) {
|
|
187
|
+
const parsed = parseModelReference(value);
|
|
188
|
+
return `${parsed.provider}/${parsed.model}`;
|
|
189
|
+
}
|
|
190
|
+
export function validateCheckpoint(value) {
|
|
191
|
+
if (!object(value) || Object.keys(value).some((key) => !["name", "prompt", "context"].includes(key)) || typeof value.name !== "string" || value.name.trim() === "" || typeof value.prompt !== "string" || !jsonValue(value.context))
|
|
192
|
+
fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
|
|
193
|
+
if (Buffer.byteLength(value.prompt) > 1024)
|
|
194
|
+
fail("INVALID_METADATA", "checkpoint prompt exceeds 1024 UTF-8 bytes");
|
|
195
|
+
if (Buffer.byteLength(JSON.stringify(value.context)) > 4096)
|
|
196
|
+
fail("INVALID_METADATA", "checkpoint context exceeds 4096 UTF-8 bytes");
|
|
197
|
+
return { name: value.name, prompt: value.prompt, context: value.context };
|
|
198
|
+
}
|
|
199
|
+
export function workflowSettingsPath(agentDir = getAgentDir()) { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
|
|
200
|
+
export function loadSettings(path = workflowSettingsPath()) {
|
|
201
|
+
let parsed;
|
|
202
|
+
try {
|
|
203
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
if (error.code === "ENOENT")
|
|
207
|
+
return DEFAULT_SETTINGS;
|
|
208
|
+
fail("INVALID_SETTINGS", `Invalid workflow settings: ${errorText(error)}`);
|
|
209
|
+
}
|
|
210
|
+
if (!object(parsed))
|
|
211
|
+
fail("INVALID_SETTINGS", "Workflow settings must be an object");
|
|
212
|
+
const allowed = new Set(["concurrency", "maxAgentLaunches"]);
|
|
213
|
+
const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
|
|
214
|
+
if (unknown)
|
|
215
|
+
fail("INVALID_SETTINGS", `Unknown workflow setting: ${unknown}`);
|
|
216
|
+
const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
|
|
217
|
+
const maxAgentLaunches = parsed.maxAgentLaunches === undefined ? DEFAULT_SETTINGS.maxAgentLaunches : parsed.maxAgentLaunches;
|
|
218
|
+
if (!positiveInteger(concurrency) || concurrency > 16)
|
|
219
|
+
fail("INVALID_SETTINGS", "concurrency must be an integer from 1 to 16");
|
|
220
|
+
if (!positiveInteger(maxAgentLaunches))
|
|
221
|
+
fail("INVALID_SETTINGS", "maxAgentLaunches must be a positive integer");
|
|
222
|
+
return Object.freeze({ concurrency, maxAgentLaunches });
|
|
223
|
+
}
|
|
224
|
+
export function parseRoleMarkdown(content, strict = false) {
|
|
225
|
+
if (!strict) {
|
|
226
|
+
if (!content.startsWith("---\n"))
|
|
227
|
+
return { prompt: content };
|
|
228
|
+
const end = content.indexOf("\n---", 4);
|
|
229
|
+
if (end < 0)
|
|
230
|
+
return { prompt: content };
|
|
231
|
+
const meta = {};
|
|
232
|
+
for (const line of content.slice(4, end).split("\n")) {
|
|
233
|
+
const match = /^(model|thinking|tools|description)\s*:\s*(.+)$/.exec(line.trim());
|
|
234
|
+
if (match?.[1] && match[2])
|
|
235
|
+
meta[match[1]] = match[2].trim();
|
|
236
|
+
}
|
|
237
|
+
const tools = meta.tools ? meta.tools.replace(/^\[|\]$/g, "").split(",").map((tool) => tool.trim().replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "")).filter(Boolean) : undefined;
|
|
238
|
+
const thinking = meta.thinking?.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
|
|
239
|
+
if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))
|
|
240
|
+
fail("INVALID_METADATA", `Invalid role thinking level: ${thinking}`);
|
|
241
|
+
const definition = { prompt: content.slice(end + 4).replace(/^\n/, "") };
|
|
242
|
+
if (meta.model)
|
|
243
|
+
definition.model = meta.model.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
|
|
244
|
+
if (meta.description)
|
|
245
|
+
definition.description = meta.description.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
|
|
246
|
+
if (thinking)
|
|
247
|
+
definition.thinking = thinking;
|
|
248
|
+
if (tools)
|
|
249
|
+
definition.tools = tools;
|
|
250
|
+
return definition;
|
|
251
|
+
}
|
|
252
|
+
const normalized = content.replace(/\r\n?/g, "\n");
|
|
253
|
+
if (normalized.startsWith("---\n") && normalized.indexOf("\n---", 3) < 0)
|
|
254
|
+
fail("INVALID_METADATA", "Role frontmatter is missing its closing delimiter");
|
|
255
|
+
let parsed;
|
|
256
|
+
try {
|
|
257
|
+
parsed = parseFrontmatter(content);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`);
|
|
261
|
+
}
|
|
262
|
+
if (!object(parsed.frontmatter))
|
|
263
|
+
fail("INVALID_METADATA", "Role frontmatter must be an object");
|
|
264
|
+
const { model, thinking, tools, description } = parsed.frontmatter;
|
|
265
|
+
if (model !== undefined && (typeof model !== "string" || model.trim() === ""))
|
|
266
|
+
fail("INVALID_METADATA", "Role model must be a non-empty string");
|
|
267
|
+
if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)))
|
|
268
|
+
fail("INVALID_METADATA", `Invalid role thinking level: ${typeof thinking === "string" ? thinking : typeof thinking}`);
|
|
269
|
+
if (description !== undefined && (typeof description !== "string" || description.trim() === "" || description.length > 1024 || /[\r\n]/.test(description)))
|
|
270
|
+
fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
|
|
271
|
+
if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === "")))
|
|
272
|
+
fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
|
|
273
|
+
return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => tool.trim()) } : {}) };
|
|
274
|
+
}
|
|
275
|
+
const ROLE_DIRECTORY = "pi-extensible-workflows";
|
|
276
|
+
export function workflowRoleDirectories(agentDir = getAgentDir()) {
|
|
277
|
+
return [join(agentDir, ROLE_DIRECTORY, "roles")];
|
|
278
|
+
}
|
|
279
|
+
function projectRoleDirectories(root) {
|
|
280
|
+
return [join(root, ROLE_DIRECTORY, "roles")];
|
|
281
|
+
}
|
|
282
|
+
function readAgentDefinitions(dir) {
|
|
283
|
+
try {
|
|
284
|
+
return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
|
|
285
|
+
.filter((entry) => entry.isFile() && extname(entry.name) === ".md")
|
|
286
|
+
.map((entry) => [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(join(dir, entry.name), "utf8"), true)]));
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
if (error.code === "ENOENT")
|
|
290
|
+
return {};
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function readRoleDefinitions(dirs) {
|
|
295
|
+
return Object.fromEntries(dirs.flatMap((dir) => Object.entries(readAgentDefinitions(dir))));
|
|
296
|
+
}
|
|
297
|
+
export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true) {
|
|
298
|
+
return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
|
|
299
|
+
}
|
|
300
|
+
function validateRolePolicies(definitions, roles, availableModels, rootTools) {
|
|
301
|
+
for (const role of roles) {
|
|
302
|
+
const definition = definitions[role];
|
|
303
|
+
if (!definition)
|
|
304
|
+
continue;
|
|
305
|
+
if (definition.model !== undefined && !availableModels.has(modelCapability(definition.model)))
|
|
306
|
+
fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${definition.model}`);
|
|
307
|
+
const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
|
|
308
|
+
if (missingTool)
|
|
309
|
+
fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function validateWorkflowMetadata(value) {
|
|
313
|
+
if (!object(value) || typeof value.name !== "string" || value.name.trim() === "")
|
|
314
|
+
fail("INVALID_METADATA", "Workflow metadata requires a non-empty name");
|
|
315
|
+
if (value.description !== undefined && (typeof value.description !== "string" || value.description.trim() === ""))
|
|
316
|
+
fail("INVALID_METADATA", "Workflow description must be a non-empty string when provided");
|
|
317
|
+
if (Object.keys(value).some((key) => !["name", "description"].includes(key)))
|
|
318
|
+
fail("INVALID_METADATA", "Unknown workflow metadata");
|
|
319
|
+
return Object.freeze({ name: value.name.trim(), ...(typeof value.description === "string" ? { description: value.description.trim() } : {}) });
|
|
320
|
+
}
|
|
321
|
+
function workflowBody(script) {
|
|
322
|
+
if (typeof script !== "string" || script.trim() === "")
|
|
323
|
+
fail("INVALID_SYNTAX", "Workflow script must be non-empty");
|
|
324
|
+
try {
|
|
325
|
+
const program = acorn.parse(script, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true });
|
|
326
|
+
const first = program.body[0];
|
|
327
|
+
if (first?.type === "ExportNamedDeclaration" && first.declaration?.type === "VariableDeclaration") {
|
|
328
|
+
const declarator = first.declaration.declarations[0];
|
|
329
|
+
if (declarator?.id.type === "Identifier" && declarator.id.name === "meta")
|
|
330
|
+
return script.slice(first.end).replace(/^\s*/, "");
|
|
331
|
+
}
|
|
332
|
+
return script;
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
fail("INVALID_SYNTAX", `Invalid workflow syntax: ${errorText(error)}`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
function parseWorkflow(script) {
|
|
339
|
+
const body = workflowBody(script);
|
|
340
|
+
try {
|
|
341
|
+
new Script(`(async()=>{${body}\n})`);
|
|
342
|
+
return acorn.parse(body, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true });
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
fail("INVALID_SYNTAX", `Invalid workflow syntax: ${errorText(error)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function astNode(value) {
|
|
349
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
350
|
+
}
|
|
351
|
+
function workflowCalls(program) {
|
|
352
|
+
const calls = [];
|
|
353
|
+
const visit = (node) => {
|
|
354
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name))
|
|
355
|
+
calls.push(node);
|
|
356
|
+
for (const value of Object.values(node)) {
|
|
357
|
+
if (Array.isArray(value)) {
|
|
358
|
+
for (const child of value)
|
|
359
|
+
if (astNode(child))
|
|
360
|
+
visit(child);
|
|
361
|
+
}
|
|
362
|
+
else if (astNode(value))
|
|
363
|
+
visit(value);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
visit(program);
|
|
367
|
+
return calls.sort((left, right) => left.start - right.start);
|
|
368
|
+
}
|
|
369
|
+
function workflowCallsWithStructure(program) {
|
|
370
|
+
const calls = [];
|
|
371
|
+
const visit = (node, context) => {
|
|
372
|
+
let current = context;
|
|
373
|
+
if (node.type === "Property" && current.structure.length) {
|
|
374
|
+
const scope = current.structure.at(-1);
|
|
375
|
+
const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
|
|
376
|
+
if (scope?.key === null && key)
|
|
377
|
+
current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
|
|
378
|
+
}
|
|
379
|
+
if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
|
|
380
|
+
const call = node;
|
|
381
|
+
const operation = call.callee.name;
|
|
382
|
+
const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
|
|
383
|
+
calls.push({ call, execution, structure: current.structure });
|
|
384
|
+
for (const [index, argument] of call.arguments.entries()) {
|
|
385
|
+
if (argument.type === "SpreadElement")
|
|
386
|
+
continue;
|
|
387
|
+
const scopeKind = operation === "parallel" && index === 1 ? "parallel" : operation === "pipeline" && index === 2 ? "pipeline" : undefined;
|
|
388
|
+
visit(argument, scopeKind ? { execution, structure: [...current.structure, { kind: scopeKind, name: staticString(callArgument(call, 0)), key: null }] } : current);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
for (const value of Object.values(node)) {
|
|
393
|
+
if (Array.isArray(value)) {
|
|
394
|
+
for (const child of value)
|
|
395
|
+
if (astNode(child))
|
|
396
|
+
visit(child, current);
|
|
397
|
+
}
|
|
398
|
+
else if (astNode(value))
|
|
399
|
+
visit(value, current);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
visit(program, { execution: "sequential", structure: [] });
|
|
403
|
+
return calls.sort((left, right) => left.call.start - right.call.start);
|
|
404
|
+
}
|
|
405
|
+
function validateDirectPrimitiveReferences(program, name) {
|
|
406
|
+
const visit = (node, parent) => {
|
|
407
|
+
if (node.type === "Identifier" && node.name === name) {
|
|
408
|
+
const directCall = parent?.type === "CallExpression" && parent.callee === node;
|
|
409
|
+
const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
|
|
410
|
+
if (!directCall && !propertyKey)
|
|
411
|
+
fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
|
|
412
|
+
}
|
|
413
|
+
for (const value of Object.values(node)) {
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
for (const child of value)
|
|
416
|
+
if (astNode(child))
|
|
417
|
+
visit(child, node);
|
|
418
|
+
}
|
|
419
|
+
else if (astNode(value))
|
|
420
|
+
visit(value, node);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
visit(program);
|
|
424
|
+
}
|
|
425
|
+
function hasIdentifier(node, name) {
|
|
426
|
+
if (node.type === "Identifier" && node.name === name)
|
|
427
|
+
return true;
|
|
428
|
+
return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
|
|
429
|
+
}
|
|
430
|
+
const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
|
|
431
|
+
const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
|
|
432
|
+
function callHasTrailingComma(source, call) {
|
|
433
|
+
let previous;
|
|
434
|
+
let current;
|
|
435
|
+
for (const token of acorn.tokenizer(source.slice(call.start, call.end), { ecmaVersion: "latest", sourceType: "module" })) {
|
|
436
|
+
previous = current;
|
|
437
|
+
current = token;
|
|
438
|
+
}
|
|
439
|
+
return current?.type.label === ")" && previous?.type.label === ",";
|
|
440
|
+
}
|
|
441
|
+
function instrumentWorkflow(script) {
|
|
442
|
+
const body = workflowBody(script);
|
|
443
|
+
if (!body.trim())
|
|
444
|
+
return body;
|
|
445
|
+
const program = parseWorkflow(body);
|
|
446
|
+
if (hasIdentifier(program, INTERNAL_AGENT_NAME))
|
|
447
|
+
fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
448
|
+
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
|
|
449
|
+
fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
450
|
+
const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree"].includes(call.callee.name));
|
|
451
|
+
const edits = calls.flatMap((call) => {
|
|
452
|
+
const callSite = `${String(call.start)}:${String(call.end)}`;
|
|
453
|
+
const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
|
|
454
|
+
return [
|
|
455
|
+
{ start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : INTERNAL_WORKTREE_NAME },
|
|
456
|
+
{ start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` },
|
|
457
|
+
];
|
|
458
|
+
}).sort((left, right) => right.start - left.start);
|
|
459
|
+
let instrumented = body;
|
|
460
|
+
for (const edit of edits)
|
|
461
|
+
instrumented = instrumented.slice(0, edit.start) + edit.text + instrumented.slice(edit.end);
|
|
462
|
+
return instrumented;
|
|
463
|
+
}
|
|
464
|
+
function literalString(node) {
|
|
465
|
+
return node?.type === "Literal" && typeof node.value === "string" ? node.value : undefined;
|
|
466
|
+
}
|
|
467
|
+
function propertyNode(node, name) {
|
|
468
|
+
if (node?.type !== "ObjectExpression")
|
|
469
|
+
return undefined;
|
|
470
|
+
for (let index = node.properties.length - 1; index >= 0; index -= 1) {
|
|
471
|
+
const property = node.properties[index];
|
|
472
|
+
if (!property || property.type === "SpreadElement" || property.computed)
|
|
473
|
+
return undefined;
|
|
474
|
+
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
475
|
+
if (key === name)
|
|
476
|
+
return property.value;
|
|
477
|
+
}
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
function stableName(node) {
|
|
481
|
+
if (!node)
|
|
482
|
+
return false;
|
|
483
|
+
if (node.type !== "ObjectExpression") {
|
|
484
|
+
if (["Literal", "ArrayExpression", "ArrowFunctionExpression", "FunctionExpression", "ClassExpression", "TemplateLiteral", "UnaryExpression", "UpdateExpression", "BinaryExpression"].includes(node.type))
|
|
485
|
+
return false;
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
let result = false;
|
|
489
|
+
for (const property of node.properties) {
|
|
490
|
+
if (property.type === "SpreadElement" || property.computed) {
|
|
491
|
+
result = undefined;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
495
|
+
if (key !== "name")
|
|
496
|
+
continue;
|
|
497
|
+
const value = literalString(property.value);
|
|
498
|
+
result = value === undefined ? property.value.type === "Literal" ? false : undefined : value.trim() !== "";
|
|
499
|
+
}
|
|
500
|
+
return result;
|
|
501
|
+
}
|
|
502
|
+
function jsonValue(value, seen = new Set()) {
|
|
503
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
504
|
+
return true;
|
|
505
|
+
if (typeof value === "number")
|
|
506
|
+
return Number.isFinite(value);
|
|
507
|
+
if (typeof value !== "object" || seen.has(value))
|
|
508
|
+
return false;
|
|
509
|
+
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
510
|
+
return false;
|
|
511
|
+
const keys = Reflect.ownKeys(value);
|
|
512
|
+
if (keys.some((key) => typeof key !== "string"))
|
|
513
|
+
return false;
|
|
514
|
+
seen.add(value);
|
|
515
|
+
const values = Array.isArray(value) ? Array.from(value) : keys.map((key) => value[key]);
|
|
516
|
+
const valid = values.every((item) => jsonValue(item, seen));
|
|
517
|
+
seen.delete(value);
|
|
518
|
+
return valid;
|
|
519
|
+
}
|
|
520
|
+
function workflowPrompt(template, values) {
|
|
521
|
+
if (typeof template !== "string")
|
|
522
|
+
fail("INVALID_METADATA", "prompt() template must be a string");
|
|
523
|
+
if (!object(values) || Array.isArray(values) || !jsonValue(values))
|
|
524
|
+
fail("INVALID_METADATA", "prompt() values must be a plain JSON-compatible object");
|
|
525
|
+
const placeholders = [...template.matchAll(/{{|}}|{([A-Za-z_$][\w$]*)}/g)].flatMap((match) => match[1] === undefined ? [] : [match[1]]);
|
|
526
|
+
const used = new Set(placeholders);
|
|
527
|
+
const keys = Object.keys(values);
|
|
528
|
+
const missing = placeholders.find((key) => !Object.prototype.hasOwnProperty.call(values, key));
|
|
529
|
+
if (missing)
|
|
530
|
+
fail("INVALID_METADATA", `Missing prompt value "${missing}"`);
|
|
531
|
+
const unused = keys.find((key) => !used.has(key));
|
|
532
|
+
if (unused !== undefined)
|
|
533
|
+
fail("INVALID_METADATA", `Unused prompt value "${unused}"`);
|
|
534
|
+
return template.replace(/{{|}}|{([A-Za-z_$][\w$]*)}/g, (match, key) => match === "{{" ? "{" : match === "}}" ? "}" : typeof values[key] === "string" ? values[key] : JSON.stringify(values[key], null, 2));
|
|
535
|
+
}
|
|
536
|
+
function validateSchema(schema, at = "schema") {
|
|
537
|
+
if (!object(schema) || Object.getPrototypeOf(schema) !== Object.prototype || !jsonValue(schema))
|
|
538
|
+
fail("INVALID_SCHEMA", `${at} must be a plain JSON-compatible Schema object`);
|
|
539
|
+
if (typeof schema.type !== "string" && !Array.isArray(schema.type) && schema.$ref === undefined && schema.anyOf === undefined && schema.oneOf === undefined && schema.allOf === undefined && schema.const === undefined && schema.enum === undefined)
|
|
540
|
+
fail("INVALID_SCHEMA", `${at} has no JSON Schema shape`);
|
|
541
|
+
if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string")))
|
|
542
|
+
fail("INVALID_SCHEMA", `${at}.required must be an array of strings`);
|
|
543
|
+
if (schema.properties !== undefined && !object(schema.properties))
|
|
544
|
+
fail("INVALID_SCHEMA", `${at}.properties must be an object`);
|
|
545
|
+
}
|
|
546
|
+
const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
|
|
547
|
+
function validateAgentOption(key, value) {
|
|
548
|
+
switch (key) {
|
|
549
|
+
case "label":
|
|
550
|
+
if (typeof value !== "string" || !value.trim())
|
|
551
|
+
fail("INVALID_METADATA", "agent label must be a non-empty string");
|
|
552
|
+
break;
|
|
553
|
+
case "model":
|
|
554
|
+
if (typeof value !== "string")
|
|
555
|
+
fail("INVALID_METADATA", "agent model must be a string");
|
|
556
|
+
parseModelReference(value);
|
|
557
|
+
break;
|
|
558
|
+
case "thinking":
|
|
559
|
+
if (typeof value !== "string" || !parseThinking(value))
|
|
560
|
+
fail("INVALID_METADATA", "agent thinking must be off, minimal, low, medium, high, xhigh, or max");
|
|
561
|
+
break;
|
|
562
|
+
case "tools":
|
|
563
|
+
if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string"))
|
|
564
|
+
fail("INVALID_METADATA", "agent tools must be an array of strings");
|
|
565
|
+
break;
|
|
566
|
+
case "role":
|
|
567
|
+
if (typeof value !== "string" || !value.trim())
|
|
568
|
+
fail("INVALID_METADATA", "agent role must be a non-empty string");
|
|
569
|
+
break;
|
|
570
|
+
case "outputSchema":
|
|
571
|
+
validateSchema(value, "agent outputSchema");
|
|
572
|
+
break;
|
|
573
|
+
case "retries":
|
|
574
|
+
if (!Number.isInteger(value) || value < 0)
|
|
575
|
+
fail("INVALID_METADATA", "agent retries must be a non-negative integer");
|
|
576
|
+
break;
|
|
577
|
+
case "timeoutMs":
|
|
578
|
+
if (value !== null && !positiveInteger(value))
|
|
579
|
+
fail("INVALID_METADATA", "agent timeoutMs must be null or a positive integer");
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function validateAgentOptions(value) {
|
|
584
|
+
if (!object(value) || !jsonValue(value))
|
|
585
|
+
fail("INVALID_METADATA", "agent options must be a JSON object");
|
|
586
|
+
const unknown = Object.keys(value).find((key) => !AGENT_OPTION_KEYS.has(key));
|
|
587
|
+
if (unknown)
|
|
588
|
+
fail("INVALID_METADATA", `Unknown agent option: ${unknown}`);
|
|
589
|
+
for (const [key, option] of Object.entries(value))
|
|
590
|
+
validateAgentOption(key, option);
|
|
591
|
+
if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key)))
|
|
592
|
+
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
593
|
+
return value;
|
|
594
|
+
}
|
|
595
|
+
function staticValue(node) {
|
|
596
|
+
if (!node)
|
|
597
|
+
return { known: false };
|
|
598
|
+
if (node.type === "Literal")
|
|
599
|
+
return { known: true, value: node.value };
|
|
600
|
+
if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
|
|
601
|
+
const argument = staticValue(node.argument);
|
|
602
|
+
return argument.known && typeof argument.value === "number" ? { known: true, value: node.operator === "-" ? -argument.value : argument.value } : { known: false };
|
|
603
|
+
}
|
|
604
|
+
if (node.type === "ArrayExpression") {
|
|
605
|
+
const values = [];
|
|
606
|
+
for (const element of node.elements) {
|
|
607
|
+
if (!element || element.type === "SpreadElement")
|
|
608
|
+
return { known: false };
|
|
609
|
+
const value = staticValue(element);
|
|
610
|
+
if (!value.known)
|
|
611
|
+
return { known: false };
|
|
612
|
+
values.push(value.value);
|
|
613
|
+
}
|
|
614
|
+
return { known: true, value: values };
|
|
615
|
+
}
|
|
616
|
+
if (node.type === "ObjectExpression") {
|
|
617
|
+
const value = {};
|
|
618
|
+
for (const property of node.properties) {
|
|
619
|
+
if (property.type === "SpreadElement" || property.computed)
|
|
620
|
+
return { known: false };
|
|
621
|
+
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
622
|
+
const child = staticValue(property.value);
|
|
623
|
+
if (!key || !child.known)
|
|
624
|
+
return { known: false };
|
|
625
|
+
value[key] = child.value;
|
|
626
|
+
}
|
|
627
|
+
return { known: true, value };
|
|
628
|
+
}
|
|
629
|
+
return { known: false };
|
|
630
|
+
}
|
|
631
|
+
function callArgument(call, index) {
|
|
632
|
+
const argument = call.arguments[index];
|
|
633
|
+
return argument?.type === "SpreadElement" ? undefined : argument;
|
|
634
|
+
}
|
|
635
|
+
function staticString(node) {
|
|
636
|
+
const value = staticValue(node);
|
|
637
|
+
return value.known && typeof value.value === "string" ? value.value : null;
|
|
638
|
+
}
|
|
639
|
+
export function inspectWorkflowScript(script) {
|
|
640
|
+
return workflowCallsWithStructure(parseWorkflow(script)).map(({ call, execution, structure }) => {
|
|
641
|
+
const kind = call.callee.name;
|
|
642
|
+
const first = callArgument(call, 0);
|
|
643
|
+
const options = callArgument(call, 1);
|
|
644
|
+
const placement = { execution, structure };
|
|
645
|
+
if (kind === "agent") {
|
|
646
|
+
const retries = staticValue(propertyNode(options, "retries"));
|
|
647
|
+
const outputSchema = staticValue(propertyNode(options, "outputSchema"));
|
|
648
|
+
const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
|
|
649
|
+
if (property.type === "SpreadElement" || property.computed)
|
|
650
|
+
return [];
|
|
651
|
+
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
652
|
+
return key ? [key] : [];
|
|
653
|
+
}) : [];
|
|
654
|
+
const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; }));
|
|
655
|
+
const base = { ...placement, kind, start: call.start, end: call.end, name: null, prompt: staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
|
|
656
|
+
return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
|
|
657
|
+
}
|
|
658
|
+
if (kind === "checkpoint")
|
|
659
|
+
return { ...placement, kind, start: call.start, end: call.end, name: staticString(propertyNode(first, "name")), prompt: staticString(propertyNode(first, "prompt")), model: null, role: null };
|
|
660
|
+
return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
function validateStaticAgentOptions(node) {
|
|
664
|
+
if (node?.type !== "ObjectExpression")
|
|
665
|
+
return;
|
|
666
|
+
for (const property of node.properties) {
|
|
667
|
+
if (property.type === "SpreadElement" || property.computed)
|
|
668
|
+
continue;
|
|
669
|
+
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
670
|
+
if (key && !AGENT_OPTION_KEYS.has(key))
|
|
671
|
+
fail("INVALID_METADATA", `Unknown agent option: ${key}`);
|
|
672
|
+
}
|
|
673
|
+
const role = propertyNode(node, "role");
|
|
674
|
+
if (role && ["model", "thinking", "tools"].some((key) => propertyNode(node, key) !== undefined))
|
|
675
|
+
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
676
|
+
for (const key of AGENT_OPTION_KEYS) {
|
|
677
|
+
const value = staticValue(propertyNode(node, key));
|
|
678
|
+
if (value.known)
|
|
679
|
+
validateAgentOption(key, value.value);
|
|
680
|
+
}
|
|
681
|
+
const options = staticValue(node);
|
|
682
|
+
if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
|
|
683
|
+
fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
|
|
684
|
+
}
|
|
685
|
+
function validateStaticWithWorktree(call) {
|
|
686
|
+
if (call.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
687
|
+
return;
|
|
688
|
+
if (call.arguments.length !== 1 && call.arguments.length !== 2)
|
|
689
|
+
fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
|
|
690
|
+
const callback = call.arguments[call.arguments.length - 1];
|
|
691
|
+
if (staticValue(callback).known)
|
|
692
|
+
fail("INVALID_METADATA", "withWorktree callback must be a function");
|
|
693
|
+
if (call.arguments.length === 2) {
|
|
694
|
+
const name = staticValue(call.arguments[0]);
|
|
695
|
+
if (name.known && (typeof name.value !== "string" || !name.value.trim()))
|
|
696
|
+
fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
const RESERVED_GLOBALS = new Set(["agent", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
|
|
700
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
701
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
702
|
+
export class WorkflowRegistry {
|
|
703
|
+
#extensions = new Map();
|
|
704
|
+
#globals = new Map();
|
|
705
|
+
#frozen = false;
|
|
706
|
+
get frozen() { return this.#frozen; }
|
|
707
|
+
freeze() { this.#frozen = true; }
|
|
708
|
+
register(extension) {
|
|
709
|
+
if (this.#frozen)
|
|
710
|
+
fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
|
|
711
|
+
if (!object(extension) || Object.keys(extension).some((key) => !["namespace", "version", "headline", "description", "functions", "variables", "workflows"].includes(key)) || typeof extension.namespace !== "string" || typeof extension.version !== "string" || !IDENTIFIER.test(extension.namespace) || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim())
|
|
712
|
+
fail("INVALID_METADATA", "Workflow extensions require a namespace, semantic version, headline, and description");
|
|
713
|
+
if (this.#extensions.has(extension.namespace))
|
|
714
|
+
fail("DUPLICATE_NAME", `Workflow extension already registered: ${extension.namespace}`);
|
|
715
|
+
const functions = extension.functions ?? {};
|
|
716
|
+
const variables = extension.variables ?? {};
|
|
717
|
+
const workflows = extension.workflows ?? {};
|
|
718
|
+
if (!object(functions) || !object(variables) || !object(workflows) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(workflows).length === 0))
|
|
719
|
+
fail("INVALID_METADATA", "Workflow extensions require functions, variables, or workflows");
|
|
720
|
+
const names = [...Object.keys(functions), ...Object.keys(variables)];
|
|
721
|
+
if (new Set(names).size !== names.length)
|
|
722
|
+
fail("GLOBAL_COLLISION", `Global name collision inside ${extension.namespace}`);
|
|
723
|
+
for (const name of names) {
|
|
724
|
+
if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_"))
|
|
725
|
+
fail("INVALID_METADATA", `Invalid global name: ${extension.namespace}.${name}`);
|
|
726
|
+
if (RESERVED_GLOBALS.has(name))
|
|
727
|
+
fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
|
|
728
|
+
const owner = this.#globals.get(name);
|
|
729
|
+
if (owner)
|
|
730
|
+
fail("GLOBAL_COLLISION", `Global name ${name} is already owned by ${owner}; cannot register ${extension.namespace}.${name}`);
|
|
731
|
+
}
|
|
732
|
+
for (const [name, fn] of Object.entries(functions)) {
|
|
733
|
+
if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function")
|
|
734
|
+
fail("INVALID_METADATA", `Invalid workflow function: ${extension.namespace}.${name}`);
|
|
735
|
+
validateSchema(fn.input, `${extension.namespace}.${name} input`);
|
|
736
|
+
validateSchema(fn.output, `${extension.namespace}.${name} output`);
|
|
737
|
+
if (fn.input.type !== "object")
|
|
738
|
+
fail("INVALID_SCHEMA", `${extension.namespace}.${name} input must describe one object`);
|
|
739
|
+
}
|
|
740
|
+
for (const [name, variable] of Object.entries(variables)) {
|
|
741
|
+
if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function")
|
|
742
|
+
fail("INVALID_METADATA", `Invalid workflow variable: ${extension.namespace}.${name}`);
|
|
743
|
+
validateSchema(variable.schema, `${extension.namespace}.${name} schema`);
|
|
744
|
+
}
|
|
745
|
+
for (const [name, workflow] of Object.entries(workflows)) {
|
|
746
|
+
if (!IDENTIFIER.test(name) || !object(workflow) || Object.keys(workflow).some((key) => !["description", "script"].includes(key)) || typeof workflow.description !== "string" || !workflow.description.trim() || typeof workflow.script !== "string" || !workflow.script.trim())
|
|
747
|
+
fail("INVALID_METADATA", `Invalid workflow script: ${extension.namespace}.${name}`);
|
|
748
|
+
parseWorkflow(workflow.script);
|
|
749
|
+
}
|
|
750
|
+
const stored = deepFreeze({ ...extension, functions, variables, workflows });
|
|
751
|
+
this.#extensions.set(extension.namespace, stored);
|
|
752
|
+
for (const name of names)
|
|
753
|
+
this.#globals.set(name, `${extension.namespace}.${name}`);
|
|
754
|
+
}
|
|
755
|
+
workflow(name) {
|
|
756
|
+
const separator = name.indexOf(".");
|
|
757
|
+
if (separator <= 0 || separator !== name.lastIndexOf(".") || !IDENTIFIER.test(name.slice(0, separator)) || !IDENTIFIER.test(name.slice(separator + 1)))
|
|
758
|
+
fail("MISSING_WORKFLOW", `Registered workflows require a qualified namespace.name: ${name}`);
|
|
759
|
+
const workflow = this.#extensions.get(name.slice(0, separator))?.workflows?.[name.slice(separator + 1)];
|
|
760
|
+
if (!workflow)
|
|
761
|
+
fail("MISSING_WORKFLOW", `Workflow is unavailable: ${name}`);
|
|
762
|
+
return workflow;
|
|
763
|
+
}
|
|
764
|
+
workflows() {
|
|
765
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.workflows ?? {}).map(([name, workflow]) => [`${namespace}.${name}`, workflow]))));
|
|
766
|
+
}
|
|
767
|
+
catalog() {
|
|
768
|
+
const functions = [];
|
|
769
|
+
const variables = [];
|
|
770
|
+
const workflows = [];
|
|
771
|
+
for (const [namespace, extension] of this.#extensions) {
|
|
772
|
+
for (const [name, fn] of Object.entries(extension.functions ?? {}))
|
|
773
|
+
functions.push({ name, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
|
|
774
|
+
for (const [name, variable] of Object.entries(extension.variables ?? {}))
|
|
775
|
+
variables.push({ name, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
|
|
776
|
+
for (const [name, workflow] of Object.entries(extension.workflows ?? {}))
|
|
777
|
+
workflows.push({ name: `${namespace}.${name}`, namespace, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: workflow.description });
|
|
778
|
+
}
|
|
779
|
+
const sort = (left, right) => left.namespace.localeCompare(right.namespace) || left.name.localeCompare(right.name);
|
|
780
|
+
return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), workflows: workflows.sort((left, right) => left.name.localeCompare(right.name)) });
|
|
781
|
+
}
|
|
782
|
+
globals() {
|
|
783
|
+
return Object.freeze(Object.fromEntries([...this.#extensions].flatMap(([namespace, extension]) => Object.keys(extension.functions ?? {}).map((name) => [name, { namespace, name }]))));
|
|
784
|
+
}
|
|
785
|
+
async invokeFunction(namespace, name, input, context, path, journal) {
|
|
786
|
+
const fn = this.#extensions.get(namespace)?.functions?.[name];
|
|
787
|
+
if (!fn)
|
|
788
|
+
fail("MISSING_WORKFLOW", `Workflow function is unavailable: ${namespace}.${name}`);
|
|
789
|
+
if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input))
|
|
790
|
+
fail("RESULT_INVALID", `Invalid input for ${namespace}.${name}`);
|
|
791
|
+
const replayed = journal.get(path);
|
|
792
|
+
if (replayed !== undefined) {
|
|
793
|
+
if (!jsonValue(replayed) || !Value.Check(fn.output, replayed))
|
|
794
|
+
fail("RESULT_INVALID", `Invalid replay for ${namespace}.${name}`);
|
|
795
|
+
return structuredClone(replayed);
|
|
796
|
+
}
|
|
797
|
+
const result = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
|
|
798
|
+
if (!jsonValue(result) || !Value.Check(fn.output, result))
|
|
799
|
+
fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
|
|
800
|
+
const stored = structuredClone(result);
|
|
801
|
+
journal.put(path, stored);
|
|
802
|
+
return structuredClone(stored);
|
|
803
|
+
}
|
|
804
|
+
variables() {
|
|
805
|
+
return [...this.#extensions].flatMap(([namespace, extension]) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ namespace, name, variable })));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
|
|
809
|
+
const globalRegistry = globalThis;
|
|
810
|
+
function createWorkflowRegistryApi(registry) {
|
|
811
|
+
return {
|
|
812
|
+
get frozen() { return registry.frozen; },
|
|
813
|
+
freeze: () => { registry.freeze(); },
|
|
814
|
+
register: (extension) => { registry.register(extension); },
|
|
815
|
+
workflow: (name) => registry.workflow(name),
|
|
816
|
+
workflows: () => registry.workflows(),
|
|
817
|
+
catalog: () => registry.catalog(),
|
|
818
|
+
globals: () => registry.globals(),
|
|
819
|
+
invokeFunction: (...args) => registry.invokeFunction(...args),
|
|
820
|
+
variables: () => registry.variables(),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
function workflowRegistryHost() {
|
|
824
|
+
return globalRegistry[WORKFLOW_REGISTRY_KEY] ??= { api: createWorkflowRegistryApi(new WorkflowRegistry()) };
|
|
825
|
+
}
|
|
826
|
+
function resetWorkflowRegistry() {
|
|
827
|
+
workflowRegistryHost().api = createWorkflowRegistryApi(new WorkflowRegistry());
|
|
828
|
+
}
|
|
829
|
+
function beginWorkflowExtensionLoading() {
|
|
830
|
+
if (workflowRegistryHost().api.frozen)
|
|
831
|
+
resetWorkflowRegistry();
|
|
832
|
+
}
|
|
833
|
+
function loadingRegistry() { return workflowRegistryHost().api; }
|
|
834
|
+
beginWorkflowExtensionLoading();
|
|
835
|
+
export function registerWorkflowExtension(extension) { loadingRegistry().register(extension); }
|
|
836
|
+
export function workflowCatalog() { return loadingRegistry().catalog(); }
|
|
837
|
+
export function registeredWorkflowDefinitions() { return loadingRegistry().workflows(); }
|
|
838
|
+
export function formatWorkflowPreview(args) {
|
|
839
|
+
const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : typeof args.workflow === "string" && args.workflow.trim() ? args.workflow : "workflow";
|
|
840
|
+
if (typeof args.script !== "string" || !args.script.trim())
|
|
841
|
+
return `workflow ${name}${typeof args.workflow === "string" ? "\nRegistered workflow" : ""}`;
|
|
842
|
+
return [`workflow ${name}`, typeof args.description === "string" && args.description.trim() ? args.description.trim() : ""].filter(Boolean).join("\n");
|
|
843
|
+
}
|
|
844
|
+
export const WORKFLOW_TOOL_LABEL = "Workflow";
|
|
845
|
+
export const WORKFLOW_TOOL_DESCRIPTION = "Run a deterministic JavaScript workflow";
|
|
846
|
+
export const WORKFLOW_TOOL_PROMPT_SNIPPET = "Run a deterministic, resumable JavaScript workflow that orchestrates subagents. Inline scripts require a name; registered workflows use their workflow name. Runs in the background by default; completion arrives as a follow-up message.";
|
|
847
|
+
export const WORKFLOW_TOOL_PARAMETERS = Type.Object({
|
|
848
|
+
name: Type.Optional(Type.String({ description: "Workflow name for inline scripts" })),
|
|
849
|
+
description: Type.Optional(Type.String({ description: "Optional human-readable workflow description" })),
|
|
850
|
+
script: Type.Optional(Type.String({ description: "Immutable workflow source without metadata" })),
|
|
851
|
+
workflow: Type.Optional(Type.String({ description: "Registered reusable workflow as namespace.name" })),
|
|
852
|
+
args: Type.Optional(Type.Unknown({ description: "JSON-compatible workflow arguments" })),
|
|
853
|
+
foreground: Type.Optional(Type.Boolean({ description: "Wait for completion instead of running in the background" })),
|
|
854
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 16 })),
|
|
855
|
+
maxAgentLaunches: Type.Optional(Type.Integer({ minimum: 1, description: "Total logical agent launches for the run, including nested agents; not concurrency" })),
|
|
856
|
+
});
|
|
857
|
+
function hasDynamicAgentRole(node) {
|
|
858
|
+
if (!node)
|
|
859
|
+
return false;
|
|
860
|
+
if (node.type !== "ObjectExpression")
|
|
861
|
+
return true;
|
|
862
|
+
for (let index = node.properties.length - 1; index >= 0; index -= 1) {
|
|
863
|
+
const property = node.properties[index];
|
|
864
|
+
if (!property || property.type === "SpreadElement" || property.computed)
|
|
865
|
+
return true;
|
|
866
|
+
const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
|
|
867
|
+
if (key === "role")
|
|
868
|
+
return literalString(property.value) === undefined;
|
|
869
|
+
}
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
export function preflight(script, capabilities, schemas = [], metadata = { name: "workflow" }) {
|
|
873
|
+
const checkedMetadata = validateWorkflowMetadata(metadata);
|
|
874
|
+
const program = parseWorkflow(script);
|
|
875
|
+
if (hasIdentifier(program, INTERNAL_AGENT_NAME))
|
|
876
|
+
fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
|
|
877
|
+
if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
|
|
878
|
+
fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
|
|
879
|
+
validateDirectPrimitiveReferences(program, "withWorktree");
|
|
880
|
+
for (const [index, schema] of schemas.entries())
|
|
881
|
+
validateSchema(schema, `schema[${String(index)}]`);
|
|
882
|
+
const calls = workflowCalls(program);
|
|
883
|
+
const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
|
|
884
|
+
for (const call of calls) {
|
|
885
|
+
const operation = call.callee.name;
|
|
886
|
+
if (operation === "agent")
|
|
887
|
+
validateStaticAgentOptions(call.arguments[1]);
|
|
888
|
+
if (operation === "withWorktree")
|
|
889
|
+
validateStaticWithWorktree(call);
|
|
890
|
+
if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
|
|
891
|
+
continue;
|
|
892
|
+
if (operation === "checkpoint" && stableName(call.arguments[0]) === false)
|
|
893
|
+
fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
|
|
894
|
+
if (operation === "parallel" && (call.arguments.length !== 2 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression"))
|
|
895
|
+
fail("INVALID_METADATA", "parallel requires an operation name string and tasks record");
|
|
896
|
+
if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression"))
|
|
897
|
+
fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
|
|
898
|
+
}
|
|
899
|
+
const agentCalls = calls.filter((call) => call.callee.name === "agent");
|
|
900
|
+
const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
|
|
901
|
+
const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
|
|
902
|
+
for (const [index, schema] of staticSchemas.entries())
|
|
903
|
+
validateSchema(schema, `agent outputSchema[${String(index)}]`);
|
|
904
|
+
const checkedSchemas = [...schemas, ...staticSchemas];
|
|
905
|
+
const models = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "model")); return value === undefined ? [] : [modelCapability(value)]; });
|
|
906
|
+
const tools = agentCalls.flatMap((call) => {
|
|
907
|
+
const value = propertyNode(call.arguments[1], "tools");
|
|
908
|
+
return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
|
|
909
|
+
});
|
|
910
|
+
const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
|
|
911
|
+
const missingModel = models.find((model) => !capabilities.models.has(model));
|
|
912
|
+
if (missingModel)
|
|
913
|
+
fail("UNKNOWN_MODEL", `Unknown model: ${missingModel}`);
|
|
914
|
+
const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
|
|
915
|
+
if (missingTool)
|
|
916
|
+
fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
|
|
917
|
+
const missingType = agentTypes.find((type) => !capabilities.agentTypes.has(type));
|
|
918
|
+
if (missingType)
|
|
919
|
+
fail("UNKNOWN_AGENT_TYPE", `Unknown agent type: ${missingType}`);
|
|
920
|
+
return Object.freeze({ metadata: deepFreeze(checkedMetadata), referenced: deepFreeze({ phases, models, tools, agentTypes }), schemas: deepFreeze(checkedSchemas), dynamicAgentRoles });
|
|
921
|
+
}
|
|
922
|
+
export function validateWorkflowLaunch(params, context) {
|
|
923
|
+
return validateWorkflowLaunchWithRegistry(params, context, loadingRegistry());
|
|
924
|
+
}
|
|
925
|
+
function validateWorkflowLaunchWithRegistry(params, context, registry) {
|
|
926
|
+
if (params.script !== undefined && params.workflow !== undefined)
|
|
927
|
+
fail("INVALID_METADATA", "Provide either script or workflow, not both");
|
|
928
|
+
const definition = typeof params.workflow === "string" ? registry.workflow(params.workflow) : undefined;
|
|
929
|
+
const script = typeof params.script === "string" && params.script.trim() ? params.script : definition?.script ?? "";
|
|
930
|
+
if (!script)
|
|
931
|
+
fail("INVALID_SYNTAX", "Provide script or registered workflow");
|
|
932
|
+
const workflowName = typeof params.name === "string" && params.name.trim() ? params.name.trim() : typeof params.workflow === "string" ? params.workflow : "";
|
|
933
|
+
if (!workflowName)
|
|
934
|
+
fail("INVALID_METADATA", "Inline workflows require name");
|
|
935
|
+
const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : definition?.description ? { description: definition.description } : {}) });
|
|
936
|
+
const globalAgentDefinitions = loadAgentDefinitions(context.cwd, undefined, false);
|
|
937
|
+
const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
|
|
938
|
+
const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
|
|
939
|
+
const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)) }, [], metadata);
|
|
940
|
+
const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
|
|
941
|
+
validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools);
|
|
942
|
+
return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames };
|
|
943
|
+
}
|
|
944
|
+
function deepFreeze(value) {
|
|
945
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
946
|
+
Object.freeze(value);
|
|
947
|
+
for (const child of Object.values(value))
|
|
948
|
+
deepFreeze(child);
|
|
949
|
+
}
|
|
950
|
+
return value;
|
|
951
|
+
}
|
|
952
|
+
export function createLaunchSnapshot(input) {
|
|
953
|
+
return deepFreeze(structuredClone({ ...input, identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION }));
|
|
954
|
+
}
|
|
955
|
+
export function loadLaunchSnapshot(input) {
|
|
956
|
+
return deepFreeze(structuredClone(input));
|
|
957
|
+
}
|
|
958
|
+
export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
959
|
+
export const HEARTBEAT_TIMEOUT_MS = 5000;
|
|
960
|
+
const OUTCOME_ERRORS = new Set(["AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID"]);
|
|
961
|
+
const WORK_RESULT_BRAND = "__workResult";
|
|
962
|
+
const childSource = String.raw `
|
|
963
|
+
"use strict";
|
|
964
|
+
const { AsyncLocalStorage } = require("node:async_hooks");
|
|
965
|
+
const vm = require("node:vm");
|
|
966
|
+
const LIMIT = parseInt(process.argv[2], 10);
|
|
967
|
+
const config = JSON.parse(process.argv[3]);
|
|
968
|
+
for (const key of ["getBuiltinModule","binding","_linkedBinding","dlopen","kill","abort","exit","reallyExit","_kill","umask","chdir","setuid","setgid","seteuid","setegid","setgroups","initgroups"]) {
|
|
969
|
+
if (key in process) process[key] = undefined;
|
|
970
|
+
}
|
|
971
|
+
let nextId = 0;
|
|
972
|
+
let cancelled = false;
|
|
973
|
+
const pending = new Map();
|
|
974
|
+
const inflight = new Set();
|
|
975
|
+
const hasMessage = error => Boolean(error && typeof error === "object" && typeof error.message === "string");
|
|
976
|
+
const errorText = error => hasMessage(error) ? error.message : String(error);
|
|
977
|
+
const errorCode = error => { if (!error || typeof error !== "object") return undefined; const code = error.code; return typeof code === "string" ? code : undefined; };
|
|
978
|
+
const errorAuthored = error => Boolean(error && typeof error === "object" && error.authored === true);
|
|
979
|
+
const workerError = error => { const code = errorCode(error); return { code: code || "INTERNAL_ERROR", message: errorText(error), ...(error && typeof error === "object" && typeof error.failedAt === "string" ? { failedAt: error.failedAt } : {}), ...(errorAuthored(error) || hasMessage(error) && !code ? { authored: true } : {}) }; };
|
|
980
|
+
const workflowError = error => Object.assign(new Error(errorText(error)), workerError(error));
|
|
981
|
+
function send(value) {
|
|
982
|
+
const json = JSON.stringify(value);
|
|
983
|
+
if (json === undefined || Buffer.byteLength(json) > LIMIT) throw Object.assign(new Error("RPC value exceeds the 10 MB JSON boundary"), { code: "RPC_LIMIT_EXCEEDED" });
|
|
984
|
+
process.send(json);
|
|
985
|
+
}
|
|
986
|
+
function rpc(method, args) {
|
|
987
|
+
if (cancelled) throw Object.assign(new Error("Workflow cancelled"), { code: "CANCELLED" });
|
|
988
|
+
const id = ++nextId;
|
|
989
|
+
send({ type: "rpc", id, method, args });
|
|
990
|
+
const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
991
|
+
inflight.add(promise);
|
|
992
|
+
void promise.then(() => inflight.delete(promise), () => inflight.delete(promise));
|
|
993
|
+
return promise;
|
|
994
|
+
}
|
|
995
|
+
process.on("message", raw => {
|
|
996
|
+
let message;
|
|
997
|
+
try {
|
|
998
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > LIMIT) throw Object.assign(new Error("RPC value exceeds the 10 MB JSON boundary"), { code: "RPC_LIMIT_EXCEEDED" });
|
|
999
|
+
message = JSON.parse(raw);
|
|
1000
|
+
} catch (error) { send({ type: "error", error: workerError(error) }); return; }
|
|
1001
|
+
if (message.type === "cancel") { cancelled = true; for (const { reject } of pending.values()) reject(Object.assign(new Error("Workflow cancelled"), { code: "CANCELLED" })); pending.clear(); return; }
|
|
1002
|
+
if (message.type !== "rpcResult") return;
|
|
1003
|
+
const request = pending.get(message.id);
|
|
1004
|
+
if (!request) return;
|
|
1005
|
+
pending.delete(message.id);
|
|
1006
|
+
if (message.ok) request.resolve(message.value);
|
|
1007
|
+
else request.reject(workflowError(message.error));
|
|
1008
|
+
});
|
|
1009
|
+
const heartbeat = setInterval(() => send({ type: "heartbeat" }), 1000);
|
|
1010
|
+
send({ type: "heartbeat" });
|
|
1011
|
+
const BRAND = "${WORK_RESULT_BRAND}";
|
|
1012
|
+
const workError = (code, message) => Object.assign(new Error(message), { code });
|
|
1013
|
+
const isBranded = value => value && typeof value === "object" && value[BRAND] === true;
|
|
1014
|
+
const unwrap = result => {
|
|
1015
|
+
if (!isBranded(result)) return result;
|
|
1016
|
+
if (result.ok) return result.value;
|
|
1017
|
+
throw Object.assign(workflowError(result.error), { failedAt: result.failedAt });
|
|
1018
|
+
};
|
|
1019
|
+
const named = (value, kind) => { if (typeof value !== "string" || !value.trim()) throw workError("INVALID_METADATA", kind + " requires a stable explicit name"); return value; };
|
|
1020
|
+
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
1021
|
+
const inheritedAgentPath = new AsyncLocalStorage();
|
|
1022
|
+
const agentOccurrences = new Map();
|
|
1023
|
+
const worktreeOwners = new AsyncLocalStorage();
|
|
1024
|
+
const worktreeOccurrences = new Map();
|
|
1025
|
+
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
1026
|
+
const rejectWorktree = () => { throw workError("INVALID_METADATA", "withWorktree calls must use a direct withWorktree(...) call; aliases and indirect calls are unsupported"); };
|
|
1027
|
+
const internalWithWorktree = async (...values) => {
|
|
1028
|
+
const callSite = values.pop();
|
|
1029
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing withWorktree call-site identity");
|
|
1030
|
+
if (values.length !== 1 && values.length !== 2) throw workError("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
|
|
1031
|
+
const callback = values[values.length - 1];
|
|
1032
|
+
if (typeof callback !== "function") throw workError("INVALID_METADATA", "withWorktree callback must be a function");
|
|
1033
|
+
let owner;
|
|
1034
|
+
if (values.length === 2) {
|
|
1035
|
+
if (typeof values[0] !== "string" || !values[0].trim()) throw workError("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
1036
|
+
owner = path("worktree", "named", values[0].trim());
|
|
1037
|
+
} else {
|
|
1038
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1039
|
+
const occurrenceKey = JSON.stringify([inherited, callSite]);
|
|
1040
|
+
const occurrence = (worktreeOccurrences.get(occurrenceKey) || 0) + 1;
|
|
1041
|
+
worktreeOccurrences.set(occurrenceKey, occurrence);
|
|
1042
|
+
owner = path("worktree", "unnamed", ...inherited, "callsite:" + callSite, "occurrence:" + String(occurrence));
|
|
1043
|
+
}
|
|
1044
|
+
return await worktreeOwners.run(owner, callback);
|
|
1045
|
+
};
|
|
1046
|
+
const internalAgent = (...values) => {
|
|
1047
|
+
const callSite = values.pop();
|
|
1048
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
1049
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1050
|
+
// ponytail: same-callsite races outside parallel/pipeline lack a stable structural scope and are unsupported.
|
|
1051
|
+
const occurrenceKey = JSON.stringify([inherited, callSite]);
|
|
1052
|
+
const occurrence = (agentOccurrences.get(occurrenceKey) || 0) + 1;
|
|
1053
|
+
agentOccurrences.set(occurrenceKey, occurrence);
|
|
1054
|
+
const options = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
1055
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
1056
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
|
|
1057
|
+
const result = rpc("agent", [values[0], options, identity]).then(unwrap);
|
|
1058
|
+
Object.defineProperties(result, {
|
|
1059
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before serialization"); } },
|
|
1060
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
|
|
1061
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
|
|
1062
|
+
});
|
|
1063
|
+
return result;
|
|
1064
|
+
};
|
|
1065
|
+
const agent = rejectAgent;
|
|
1066
|
+
const promptPath = (at, key) => /^[A-Za-z_$][\w$]*$/.test(key) ? at + "." + key : at + "[" + JSON.stringify(key) + "]";
|
|
1067
|
+
const plainPromptObject = value => {
|
|
1068
|
+
const proto = Object.getPrototypeOf(value);
|
|
1069
|
+
return proto === null || Object.getPrototypeOf(proto) === null && Object.prototype.hasOwnProperty.call(proto, "constructor") && typeof proto.constructor === "function" && Function.prototype.toString.call(proto.constructor) === Function.prototype.toString.call(Object);
|
|
1070
|
+
};
|
|
1071
|
+
const promptValue = (value, at, seen) => {
|
|
1072
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
1073
|
+
if (typeof value === "number") { if (!Number.isFinite(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" must be a finite number"); return; }
|
|
1074
|
+
if (typeof value !== "object") throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" cannot be " + typeof value);
|
|
1075
|
+
if (typeof value.then === "function") throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" is a Promise or thenable; await it before calling prompt()");
|
|
1076
|
+
if (!Array.isArray(value) && !plainPromptObject(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" must be a plain object");
|
|
1077
|
+
if (seen.has(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" contains a cycle");
|
|
1078
|
+
const keys = Reflect.ownKeys(value);
|
|
1079
|
+
const symbol = keys.find(key => typeof key === "symbol");
|
|
1080
|
+
if (symbol) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" contains a symbol key");
|
|
1081
|
+
seen.add(value);
|
|
1082
|
+
if (Array.isArray(value)) {
|
|
1083
|
+
for (let index = 0; index < value.length; index += 1) promptProperty(value, String(index), at + "[" + index + "]", seen);
|
|
1084
|
+
for (const key of keys) {
|
|
1085
|
+
const index = Number(key);
|
|
1086
|
+
if (key !== "length" && !(Number.isInteger(index) && index >= 0 && index < value.length && String(index) === key)) promptProperty(value, key, promptPath(at, key), seen);
|
|
1087
|
+
}
|
|
1088
|
+
} else for (const key of keys) promptProperty(value, key, promptPath(at, key), seen);
|
|
1089
|
+
seen.delete(value);
|
|
1090
|
+
};
|
|
1091
|
+
const promptProperty = (value, key, at, seen) => {
|
|
1092
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
1093
|
+
if (descriptor && (descriptor.get || descriptor.set)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" cannot use getters or setters");
|
|
1094
|
+
promptValue(descriptor && descriptor.value, at, seen);
|
|
1095
|
+
};
|
|
1096
|
+
const prompt = (template, values) => {
|
|
1097
|
+
if (typeof template !== "string") throw workError("INVALID_METADATA", "prompt() template must be a string");
|
|
1098
|
+
if (!values || typeof values !== "object" || Array.isArray(values) || !plainPromptObject(values)) throw workError("INVALID_METADATA", "prompt() values must be a plain object");
|
|
1099
|
+
const placeholders = [...template.matchAll(/{{|}}|{([A-Za-z_$][\w$]*)}/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]);
|
|
1100
|
+
const used = new Set(placeholders);
|
|
1101
|
+
const keys = Reflect.ownKeys(values);
|
|
1102
|
+
const symbol = keys.find(key => typeof key === "symbol");
|
|
1103
|
+
if (symbol) throw workError("INVALID_METADATA", "prompt() values must use string keys");
|
|
1104
|
+
const missing = placeholders.find(key => !Object.prototype.hasOwnProperty.call(values, key));
|
|
1105
|
+
if (missing) throw workError("INVALID_METADATA", "Missing prompt value \"" + missing + "\"");
|
|
1106
|
+
const unused = keys.find(key => !used.has(key));
|
|
1107
|
+
if (unused !== undefined) throw workError("INVALID_METADATA", "Unused prompt value \"" + unused + "\"");
|
|
1108
|
+
for (const key of keys) promptProperty(values, key, key, new Set());
|
|
1109
|
+
return template.replace(/{{|}}|{([A-Za-z_$][\w$]*)}/g, (match, key) => match === "{{" ? "{" : match === "}}" ? "}" : typeof values[key] === "string" ? values[key] : JSON.stringify(values[key], null, 2));
|
|
1110
|
+
};
|
|
1111
|
+
const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
|
|
1112
|
+
const phase = name => rpc("phase", [name]);
|
|
1113
|
+
const log = message => rpc("log", [message]);
|
|
1114
|
+
const functionOccurrences = new Map();
|
|
1115
|
+
const functionPath = (namespace, name) => {
|
|
1116
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
1117
|
+
const key = JSON.stringify([inherited, namespace, name]);
|
|
1118
|
+
const occurrence = (functionOccurrences.get(key) || 0) + 1;
|
|
1119
|
+
functionOccurrences.set(key, occurrence);
|
|
1120
|
+
return path("function", ...inherited, namespace, name, String(occurrence));
|
|
1121
|
+
};
|
|
1122
|
+
const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
|
|
1123
|
+
if (values.length !== 1 || !values[0] || typeof values[0] !== "object" || Array.isArray(values[0])) throw workError("RESULT_INVALID", local + " requires exactly one JSON object argument");
|
|
1124
|
+
const result = rpc("function", [target.namespace, target.name, values[0], functionPath(target.namespace, target.name), worktreeOwners.getStore() || null]).then(unwrap);
|
|
1125
|
+
Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
|
|
1126
|
+
return result;
|
|
1127
|
+
}])));
|
|
1128
|
+
const freeze = value => { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) freeze(child); } return value; };
|
|
1129
|
+
const recordEntries = (value, kind) => {
|
|
1130
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw workError("INVALID_METADATA", kind + " must be a record");
|
|
1131
|
+
return Object.entries(value);
|
|
1132
|
+
};
|
|
1133
|
+
const parallel = async (operationName, tasks) => {
|
|
1134
|
+
named(operationName, "parallel");
|
|
1135
|
+
const entries = recordEntries(tasks, "parallel tasks");
|
|
1136
|
+
for (const [name, run] of entries) {
|
|
1137
|
+
named(name, "parallel task");
|
|
1138
|
+
if (typeof run !== "function") throw workError("INVALID_METADATA", "parallel task values must be run functions");
|
|
1139
|
+
}
|
|
1140
|
+
const results = await Promise.all(entries.map(async ([name, run]) => {
|
|
1141
|
+
try {
|
|
1142
|
+
const parent = inheritedAgentPath.getStore() || [];
|
|
1143
|
+
return { name, ok: true, value: await inheritedAgentPath.run([...parent, operationName, name], run) };
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
if (errorCode(error) === "CANCELLED") throw error;
|
|
1146
|
+
const failedAt = error && typeof error === "object" && typeof error.failedAt === "string" ? error.failedAt : undefined;
|
|
1147
|
+
return { name, ok: false, failedAt: failedAt ? path(operationName, name, failedAt) : path(operationName, name), error: workerError(error) };
|
|
1148
|
+
}
|
|
1149
|
+
}));
|
|
1150
|
+
const failure = results.find(result => !result.ok);
|
|
1151
|
+
if (failure) throw Object.assign(workflowError(failure.error), { failedAt: failure.failedAt });
|
|
1152
|
+
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
1153
|
+
};
|
|
1154
|
+
const pipeline = async (operationName, items, stages) => {
|
|
1155
|
+
named(operationName, "pipeline");
|
|
1156
|
+
const itemEntries = recordEntries(items, "pipeline items");
|
|
1157
|
+
const stageEntries = recordEntries(stages, "pipeline stages");
|
|
1158
|
+
if (!stageEntries.length) throw workError("INVALID_METADATA", "pipeline requires at least one stage");
|
|
1159
|
+
for (const [name] of itemEntries) named(name, "pipeline item");
|
|
1160
|
+
for (const [stageName, run] of stageEntries) {
|
|
1161
|
+
named(stageName, "pipeline stage");
|
|
1162
|
+
if (typeof run !== "function") throw workError("INVALID_METADATA", "pipeline stage values must be run functions");
|
|
1163
|
+
}
|
|
1164
|
+
const results = await Promise.all(itemEntries.map(async ([name, initial]) => {
|
|
1165
|
+
let value = initial;
|
|
1166
|
+
let failedAt = path(operationName, name);
|
|
1167
|
+
try {
|
|
1168
|
+
for (const [stageName, run] of stageEntries) {
|
|
1169
|
+
failedAt = path(operationName, name, stageName);
|
|
1170
|
+
const parent = inheritedAgentPath.getStore() || [];
|
|
1171
|
+
value = await inheritedAgentPath.run([...parent, operationName, name, stageName], () => run(value));
|
|
1172
|
+
}
|
|
1173
|
+
return { name, ok: true, value };
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
if (errorCode(error) === "CANCELLED") throw error;
|
|
1176
|
+
const nestedFailedAt = error && typeof error === "object" && typeof error.failedAt === "string" ? error.failedAt : undefined;
|
|
1177
|
+
return { name, ok: false, failedAt: nestedFailedAt ? path(failedAt, nestedFailedAt) : failedAt, error: workerError(error) };
|
|
1178
|
+
}
|
|
1179
|
+
}));
|
|
1180
|
+
const failure = results.find(result => !result.ok);
|
|
1181
|
+
if (failure) throw Object.assign(workflowError(failure.error), { failedAt: failure.failedAt });
|
|
1182
|
+
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
1183
|
+
};
|
|
1184
|
+
const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
|
|
1185
|
+
const sandbox = { agent, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
1186
|
+
for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
|
|
1187
|
+
for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
|
|
1188
|
+
for (const name of ["Date","eval","Function","WebAssembly","process","require","module","exports","console","fetch","XMLHttpRequest","WebSocket","performance","crypto","setTimeout","setInterval","setImmediate","queueMicrotask","Intl","SharedArrayBuffer","Atomics"]) sandbox[name] = undefined;
|
|
1189
|
+
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
1190
|
+
const body = config.script;
|
|
1191
|
+
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree))
|
|
1192
|
+
.then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
|
|
1193
|
+
.catch(error => send({ type: "error", error: workerError(error) }))
|
|
1194
|
+
.finally(() => clearInterval(heartbeat));
|
|
1195
|
+
`;
|
|
1196
|
+
function encoded(value) {
|
|
1197
|
+
if (!jsonValue(value))
|
|
1198
|
+
fail("RPC_LIMIT_EXCEEDED", "RPC values must be JSON-compatible");
|
|
1199
|
+
const json = JSON.stringify(value);
|
|
1200
|
+
if (Buffer.byteLength(json) > RPC_LIMIT_BYTES)
|
|
1201
|
+
fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
|
|
1202
|
+
return json;
|
|
1203
|
+
}
|
|
1204
|
+
function readAgentIdentity(value) {
|
|
1205
|
+
if (!object(value))
|
|
1206
|
+
fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
1207
|
+
const structuralPath = value.structuralPath;
|
|
1208
|
+
const callSite = value.callSite;
|
|
1209
|
+
const occurrence = value.occurrence;
|
|
1210
|
+
const worktreeOwner = value.worktreeOwner;
|
|
1211
|
+
const parentBreadcrumb = value.parentBreadcrumb;
|
|
1212
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part) => typeof part === "string" && Boolean(part.trim())) || typeof callSite !== "string" || !callSite || !positiveInteger(occurrence) || parentBreadcrumb !== undefined && (typeof parentBreadcrumb !== "string" || !parentBreadcrumb.trim()) || worktreeOwner !== undefined && (typeof worktreeOwner !== "string" || !worktreeOwner))
|
|
1213
|
+
fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
1214
|
+
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
|
|
1215
|
+
}
|
|
1216
|
+
function agentIdentityPath(identity) {
|
|
1217
|
+
return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
1218
|
+
}
|
|
1219
|
+
function agentWorktree(identity) {
|
|
1220
|
+
return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
|
|
1221
|
+
}
|
|
1222
|
+
export function runWorkflow(script, args = null, bridge = {}, signal) {
|
|
1223
|
+
encoded(args);
|
|
1224
|
+
const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
|
|
1225
|
+
const childDir = mkdtempSync(join(tmpdir(), "pi-wf-"));
|
|
1226
|
+
const childFile = join(childDir, "child.cjs");
|
|
1227
|
+
writeFileSync(childFile, childSource);
|
|
1228
|
+
const child = fork(childFile, [String(RPC_LIMIT_BYTES), config], {
|
|
1229
|
+
execArgv: (() => {
|
|
1230
|
+
const filtered = [];
|
|
1231
|
+
const skip = new Set(["--input-type", "-e", "--eval", "-p", "--print"]);
|
|
1232
|
+
let skipNext = false;
|
|
1233
|
+
for (const arg of process.execArgv) {
|
|
1234
|
+
if (skipNext) {
|
|
1235
|
+
skipNext = false;
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
if (skip.has(arg) || skip.has(arg.split("=")[0] ?? "")) {
|
|
1239
|
+
if (!arg.includes("="))
|
|
1240
|
+
skipNext = true;
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
filtered.push(arg);
|
|
1244
|
+
}
|
|
1245
|
+
return [...filtered, "--max-old-space-size=128", "--permission", `--allow-fs-read=${childDir}`];
|
|
1246
|
+
})(),
|
|
1247
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
1248
|
+
serialization: "advanced",
|
|
1249
|
+
});
|
|
1250
|
+
const controller = new AbortController();
|
|
1251
|
+
let settled = false;
|
|
1252
|
+
let rejectResult = () => undefined;
|
|
1253
|
+
let watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
|
|
1254
|
+
const result = new Promise((resolve, reject) => {
|
|
1255
|
+
rejectResult = reject;
|
|
1256
|
+
child.on("message", (raw) => {
|
|
1257
|
+
try {
|
|
1258
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > RPC_LIMIT_BYTES)
|
|
1259
|
+
fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
|
|
1260
|
+
const message = JSON.parse(raw);
|
|
1261
|
+
if (!jsonValue(message))
|
|
1262
|
+
fail("RPC_LIMIT_EXCEEDED", "Worker RPC must contain JSON-compatible values");
|
|
1263
|
+
if (message.type === "heartbeat") {
|
|
1264
|
+
clearTimeout(watchdog);
|
|
1265
|
+
watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (message.type === "result") {
|
|
1269
|
+
encoded(message.value);
|
|
1270
|
+
finish();
|
|
1271
|
+
resolve(message.value ?? null);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (message.type === "error") {
|
|
1275
|
+
finish();
|
|
1276
|
+
reject(workflowErrorFromWorker(message.error ?? { code: "INTERNAL_ERROR", message: "Worker failed" }));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (message.type === "rpc" && message.id !== undefined)
|
|
1280
|
+
void handleRpc(message.id, message.method ?? "", message.args ?? []);
|
|
1281
|
+
}
|
|
1282
|
+
catch (error) {
|
|
1283
|
+
stop(error instanceof WorkflowError ? error.code : "INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
child.on("error", (error) => { stop("INTERNAL_ERROR", error.message); });
|
|
1287
|
+
child.on("exit", (code) => { if (!settled && code !== 0)
|
|
1288
|
+
stop("INTERNAL_ERROR", `Workflow child exited with code ${String(code)}`); });
|
|
1289
|
+
});
|
|
1290
|
+
function killChild() {
|
|
1291
|
+
if (!child.killed) {
|
|
1292
|
+
child.kill("SIGTERM");
|
|
1293
|
+
setTimeout(() => { if (!child.killed)
|
|
1294
|
+
child.kill("SIGKILL"); }, 1000).unref();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
function finish() { settled = true; clearTimeout(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
|
|
1298
|
+
function stop(code, message) { if (settled)
|
|
1299
|
+
return; controller.abort(); finish(); rejectResult(new WorkflowError(code, message)); }
|
|
1300
|
+
function branded(result) { return { ...result, [WORK_RESULT_BRAND]: true }; }
|
|
1301
|
+
async function handleRpc(id, method, values) {
|
|
1302
|
+
try {
|
|
1303
|
+
encoded(values);
|
|
1304
|
+
let value = null;
|
|
1305
|
+
if (method === "agent") {
|
|
1306
|
+
if (!bridge.agent)
|
|
1307
|
+
fail("AGENT_FAILED", "No agent bridge is available");
|
|
1308
|
+
if (typeof values[0] !== "string")
|
|
1309
|
+
fail("INTERNAL_ERROR", "agent prompt must be a string");
|
|
1310
|
+
const opts = validateAgentOptions(values[1]);
|
|
1311
|
+
const identity = readAgentIdentity(values[2]);
|
|
1312
|
+
const path = agentIdentityPath(identity);
|
|
1313
|
+
const label = typeof opts.label === "string" ? opts.label : typeof opts.role === "string" ? opts.role : "agent";
|
|
1314
|
+
try {
|
|
1315
|
+
const result = await bridge.agent(values[0], opts, controller.signal, identity);
|
|
1316
|
+
value = branded({ name: label, ok: true, value: result ?? null });
|
|
1317
|
+
}
|
|
1318
|
+
catch (error) {
|
|
1319
|
+
const typed = asWorkflowError(error);
|
|
1320
|
+
if (!OUTCOME_ERRORS.has(typed.code))
|
|
1321
|
+
throw typed;
|
|
1322
|
+
value = branded({ name: label, ok: false, failedAt: path, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
else if (method === "checkpoint") {
|
|
1326
|
+
if (!bridge.checkpoint || !object(values[0]))
|
|
1327
|
+
fail("INTERNAL_ERROR", "checkpoint requires an available bridge and object input");
|
|
1328
|
+
const name = typeof values[0].name === "string" ? values[0].name : "checkpoint";
|
|
1329
|
+
try {
|
|
1330
|
+
const result = await bridge.checkpoint(values[0], controller.signal);
|
|
1331
|
+
if (typeof result !== "boolean")
|
|
1332
|
+
fail("INTERNAL_ERROR", "checkpoint must return a boolean");
|
|
1333
|
+
value = branded({ name, ok: true, value: result ? "approved" : "rejected" });
|
|
1334
|
+
}
|
|
1335
|
+
catch (error) {
|
|
1336
|
+
const typed = asWorkflowError(error);
|
|
1337
|
+
if (!OUTCOME_ERRORS.has(typed.code))
|
|
1338
|
+
throw typed;
|
|
1339
|
+
value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
else if (method === "function") {
|
|
1343
|
+
const worktreeOwner = values[4] === undefined || values[4] === null ? undefined : typeof values[4] === "string" && values[4] ? values[4] : fail("INTERNAL_ERROR", "function worktree scope is invalid");
|
|
1344
|
+
if (!bridge.function || typeof values[0] !== "string" || typeof values[1] !== "string" || !object(values[2]) || typeof values[3] !== "string")
|
|
1345
|
+
fail("INTERNAL_ERROR", "function requires an available bridge, names, object input, and path");
|
|
1346
|
+
const name = values[0] + "." + values[1];
|
|
1347
|
+
try {
|
|
1348
|
+
const result = await bridge.function(values[0], values[1], values[2], values[3], controller.signal, worktreeOwner);
|
|
1349
|
+
value = branded({ name, ok: true, value: result ?? null });
|
|
1350
|
+
}
|
|
1351
|
+
catch (error) {
|
|
1352
|
+
const typed = asWorkflowError(error);
|
|
1353
|
+
if (!OUTCOME_ERRORS.has(typed.code))
|
|
1354
|
+
throw typed;
|
|
1355
|
+
value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
else if (method === "phase") {
|
|
1359
|
+
if (typeof values[0] !== "string")
|
|
1360
|
+
fail("INTERNAL_ERROR", "phase name must be a string");
|
|
1361
|
+
await bridge.phase?.(values[0]);
|
|
1362
|
+
}
|
|
1363
|
+
else if (method === "log") {
|
|
1364
|
+
if (typeof values[0] !== "string")
|
|
1365
|
+
fail("INTERNAL_ERROR", "log message must be a string");
|
|
1366
|
+
await bridge.log?.(values[0]);
|
|
1367
|
+
}
|
|
1368
|
+
else
|
|
1369
|
+
fail("INTERNAL_ERROR", `Unknown worker RPC method: ${method}`);
|
|
1370
|
+
encoded(value);
|
|
1371
|
+
child.send(encoded({ type: "rpcResult", id, ok: true, value }));
|
|
1372
|
+
}
|
|
1373
|
+
catch (error) {
|
|
1374
|
+
const typed = asWorkflowError(error);
|
|
1375
|
+
child.send(encoded({ type: "rpcResult", id, ok: false, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } }));
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
function cancel() {
|
|
1379
|
+
if (settled)
|
|
1380
|
+
return;
|
|
1381
|
+
controller.abort();
|
|
1382
|
+
child.send(encoded({ type: "cancel" }));
|
|
1383
|
+
stop("CANCELLED", "Workflow cancelled");
|
|
1384
|
+
}
|
|
1385
|
+
if (signal?.aborted)
|
|
1386
|
+
cancel();
|
|
1387
|
+
else
|
|
1388
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1389
|
+
return { result, cancel };
|
|
1390
|
+
}
|
|
1391
|
+
function nativeSessionReference(attempt) {
|
|
1392
|
+
return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
|
|
1393
|
+
}
|
|
1394
|
+
export async function persistActiveAgentAttempt(store, id, active) {
|
|
1395
|
+
await store.updateState((run) => {
|
|
1396
|
+
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
1397
|
+
if (!agent)
|
|
1398
|
+
throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1399
|
+
const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1400
|
+
const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
|
|
1401
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: active.attempt, attemptDetails: [{ ...active, accounting }] } : candidate), nativeSessions };
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
export async function persistAgentAttempts(store, id, attempts) {
|
|
1405
|
+
await store.updateState((run) => {
|
|
1406
|
+
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
1407
|
+
if (!agent)
|
|
1408
|
+
throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
1409
|
+
const total = attempts.reduce((sum, attempt) => ({ input: sum.input + attempt.accounting.input, output: sum.output + attempt.accounting.output, cacheRead: sum.cacheRead + attempt.accounting.cacheRead, cacheWrite: sum.cacheWrite + attempt.accounting.cacheWrite, cost: sum.cost + attempt.accounting.cost }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
1410
|
+
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting }));
|
|
1411
|
+
const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
|
|
1412
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), nativeSessions: [...run.nativeSessions.filter(({ sessionId }) => !sessionIds.has(sessionId)), ...attempts.map((attempt) => nativeSessionReference(attempt))] };
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
export function formatWorkflowProgress(run, spinner = "◇") {
|
|
1416
|
+
const done = run.agents.filter((agent) => SETTLED_AGENT_STATES.has(agent.state)).length;
|
|
1417
|
+
const lines = [`${run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? spinner : "◆"} Workflow: ${run.workflowName} (${String(done)}/${String(run.agents.length)} done)`];
|
|
1418
|
+
if (run.phase)
|
|
1419
|
+
lines.push(` Phase: ${run.phase}`);
|
|
1420
|
+
const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
|
|
1421
|
+
for (const [index, agent] of run.agents.entries()) {
|
|
1422
|
+
let depth = 0;
|
|
1423
|
+
for (let parent = agent.parentId; parent && byId.has(parent); parent = byId.get(parent)?.parentId)
|
|
1424
|
+
depth += 1;
|
|
1425
|
+
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? spinner : "○";
|
|
1426
|
+
const indent = " ".repeat(depth + 1);
|
|
1427
|
+
const activity = SETTLED_AGENT_STATES.has(agent.state) ? "" : formatAgentActivity(agent, spinner);
|
|
1428
|
+
lines.push(`${indent}#${String(index + 1)} ${icon} ${agent.parentBreadcrumb ? `${agent.parentBreadcrumb} > ` : ""}${agent.label ?? agent.name} [${agent.state}]${activity ? ` ${activity}` : ""}`);
|
|
1429
|
+
}
|
|
1430
|
+
return lines.join("\n");
|
|
1431
|
+
}
|
|
1432
|
+
function workflowToolUpdate(run) {
|
|
1433
|
+
return { content: [{ type: "text", text: formatWorkflowProgress(run) }], details: { runId: run.id, run } };
|
|
1434
|
+
}
|
|
1435
|
+
const workflowSpinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1436
|
+
function textBlock(text) {
|
|
1437
|
+
return {
|
|
1438
|
+
render(width) {
|
|
1439
|
+
return text.split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`);
|
|
1440
|
+
},
|
|
1441
|
+
invalidate() { },
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
function workflowProgressBlock(run) {
|
|
1445
|
+
return {
|
|
1446
|
+
render(width) {
|
|
1447
|
+
const frame = workflowSpinner[Math.floor(Date.now() / 80) % workflowSpinner.length] ?? "◇";
|
|
1448
|
+
return formatWorkflowProgress(run, frame).split("\n").map((line) => line.length <= width ? line : `${line.slice(0, Math.max(0, width - 1))}…`);
|
|
1449
|
+
},
|
|
1450
|
+
invalidate() { },
|
|
1451
|
+
};
|
|
1452
|
+
}
|
|
1453
|
+
const ATTENTION_ORDER = { awaiting_input: 0, running: 1, pausing: 2, paused: 3, interrupted: 4, failed: 5, queued: 6, stopped: 7, completed: 8 };
|
|
1454
|
+
function navigatorAttentionSort(entries) {
|
|
1455
|
+
return [...entries].sort((a, b) => (ATTENTION_ORDER[a.loaded.run.state] ?? 9) - (ATTENTION_ORDER[b.loaded.run.state] ?? 9));
|
|
1456
|
+
}
|
|
1457
|
+
function navigatorRunLabels(entries) {
|
|
1458
|
+
const nameCount = new Map();
|
|
1459
|
+
for (const { loaded: { run } } of entries)
|
|
1460
|
+
nameCount.set(run.workflowName, (nameCount.get(run.workflowName) ?? 0) + 1);
|
|
1461
|
+
return entries.map(({ store, loaded: { run } }) => {
|
|
1462
|
+
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1463
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1464
|
+
const suffix = (nameCount.get(run.workflowName) ?? 0) > 1 ? ` ${store.runId.slice(0, 8)}` : "";
|
|
1465
|
+
const cost = run.agents.reduce((sum, a) => sum + (a.accounting?.cost ?? 0), 0);
|
|
1466
|
+
const costStr = cost > 0 ? ` $${cost.toFixed(2)}` : "";
|
|
1467
|
+
return `${glyph} ${run.workflowName}${suffix} ${run.state} ${run.phase ?? ""} ${String(done)}/${String(run.agents.length)} agents${costStr}`;
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
function agentBreadcrumb(agent, byId) {
|
|
1471
|
+
const name = agent.label ?? agent.name;
|
|
1472
|
+
const parts = [name];
|
|
1473
|
+
const seen = new Set([agent.id]);
|
|
1474
|
+
for (let parentId = agent.parentId; parentId; parentId = byId.get(parentId)?.parentId) {
|
|
1475
|
+
if (seen.has(parentId))
|
|
1476
|
+
break; // ponytail: cycle guard for corrupt data
|
|
1477
|
+
seen.add(parentId);
|
|
1478
|
+
const parent = byId.get(parentId);
|
|
1479
|
+
if (parent)
|
|
1480
|
+
parts.unshift(parent.label ?? parent.name);
|
|
1481
|
+
else
|
|
1482
|
+
break;
|
|
1483
|
+
}
|
|
1484
|
+
return parts.length > 1 ? parts.join(" > ") : name;
|
|
1485
|
+
}
|
|
1486
|
+
function formatAgentActivity(agent, spinner) {
|
|
1487
|
+
if (agent.activity?.kind === "reasoning")
|
|
1488
|
+
return `${spinner} reasoning`;
|
|
1489
|
+
if (agent.activity?.kind === "text")
|
|
1490
|
+
return `${spinner} responding`;
|
|
1491
|
+
if (agent.activity?.kind === "tool")
|
|
1492
|
+
return `${spinner} ${agent.activity.text}`;
|
|
1493
|
+
const tool = [...(agent.toolCalls ?? [])].reverse().find(({ state }) => state === "running");
|
|
1494
|
+
return tool ? `${spinner} ${tool.name}` : "";
|
|
1495
|
+
}
|
|
1496
|
+
function formatAccounting(accounting) {
|
|
1497
|
+
const total = accounting.input + accounting.output + accounting.cacheRead + accounting.cacheWrite;
|
|
1498
|
+
return `${new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }).format(total).toLowerCase()} tok`;
|
|
1499
|
+
}
|
|
1500
|
+
export function formatNavigatorDashboard(run, checkpoints, worktrees) {
|
|
1501
|
+
const done = run.agents.filter((a) => SETTLED_AGENT_STATES.has(a.state)).length;
|
|
1502
|
+
const totalAccounting = run.agents.reduce((sum, a) => ({ input: sum.input + (a.accounting?.input ?? 0), output: sum.output + (a.accounting?.output ?? 0), cacheRead: sum.cacheRead + (a.accounting?.cacheRead ?? 0), cacheWrite: sum.cacheWrite + (a.accounting?.cacheWrite ?? 0), cost: sum.cost + (a.accounting?.cost ?? 0) }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
1503
|
+
const hasAccounting = run.agents.some((a) => a.accounting);
|
|
1504
|
+
const glyph = run.state === "completed" ? "✓" : run.state === "failed" || run.state === "stopped" ? "✗" : run.state === "running" ? "⠦" : run.state === "awaiting_input" ? "●" : "◆";
|
|
1505
|
+
const header = `${glyph} ${run.workflowName}`;
|
|
1506
|
+
const meta = [run.state, run.phase ? `phase: ${run.phase}` : "", `${String(done)}/${String(run.agents.length)} agents`, hasAccounting ? formatAccounting(totalAccounting) : "", totalAccounting.cost > 0 ? `$${totalAccounting.cost.toFixed(2)}` : ""].filter(Boolean).join(" · ");
|
|
1507
|
+
const lines = [header, meta];
|
|
1508
|
+
if (run.error)
|
|
1509
|
+
lines.push(`Error: ${run.error.code}: ${run.error.message}`);
|
|
1510
|
+
lines.push("");
|
|
1511
|
+
const byId = new Map(run.agents.map((a) => [a.id, a]));
|
|
1512
|
+
const activeStates = new Set(["running", "waiting_for_child", "queued", "retrying", "paused"]);
|
|
1513
|
+
const prioritised = [...run.agents].sort((a, b) => {
|
|
1514
|
+
const aActive = activeStates.has(a.state) || a.state === "failed" ? 0 : 1;
|
|
1515
|
+
const bActive = activeStates.has(b.state) || b.state === "failed" ? 0 : 1;
|
|
1516
|
+
return aActive - bActive;
|
|
1517
|
+
});
|
|
1518
|
+
for (const agent of prioritised) {
|
|
1519
|
+
const icon = agent.state === "completed" ? "✓" : agent.state === "failed" || agent.state === "cancelled" ? "✗" : agent.state === "running" ? "⠦" : "○";
|
|
1520
|
+
const breadcrumb = agentBreadcrumb(agent, byId);
|
|
1521
|
+
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
1522
|
+
const policy = [`model=${model}`, `tools=${agent.tools.join(",") || "(none)"}`, agent.role ? `role=${agent.role}` : ""];
|
|
1523
|
+
const tokens = agent.accounting ? formatAccounting(agent.accounting) : "";
|
|
1524
|
+
const parts = [`${icon} ${breadcrumb}`, agent.state, ...policy, tokens].filter(Boolean);
|
|
1525
|
+
lines.push(parts.join(" · "));
|
|
1526
|
+
if (agent.state === "failed" && agent.attemptDetails?.length) {
|
|
1527
|
+
const last = agent.attemptDetails[agent.attemptDetails.length - 1];
|
|
1528
|
+
if (last?.error)
|
|
1529
|
+
lines.push(` error: ${last.error.code}: ${last.error.message}`);
|
|
1530
|
+
}
|
|
1531
|
+
const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦") : "";
|
|
1532
|
+
if (activity)
|
|
1533
|
+
lines.push(` ${activity}`);
|
|
1534
|
+
}
|
|
1535
|
+
if (checkpoints.length) {
|
|
1536
|
+
lines.push("");
|
|
1537
|
+
for (const cp of checkpoints)
|
|
1538
|
+
lines.push(`● checkpoint ${cp.name}: ${cp.prompt}`);
|
|
1539
|
+
}
|
|
1540
|
+
if (worktrees.length) {
|
|
1541
|
+
lines.push("");
|
|
1542
|
+
for (const wt of worktrees)
|
|
1543
|
+
lines.push(`branch ${wt.branch} (${wt.path})`);
|
|
1544
|
+
}
|
|
1545
|
+
return lines.join("\n");
|
|
1546
|
+
}
|
|
1547
|
+
export function formatNavigatorRun(loaded, checkpoints, worktrees) {
|
|
1548
|
+
const { run, snapshot } = loaded;
|
|
1549
|
+
const lines = [
|
|
1550
|
+
`Workflow: ${run.workflowName}`,
|
|
1551
|
+
`Run: ${run.id}`,
|
|
1552
|
+
`Status: ${run.state}`,
|
|
1553
|
+
`Phase: ${run.phase ?? "(none)"}`,
|
|
1554
|
+
`Launch cwd: ${run.cwd}`,
|
|
1555
|
+
`Launch models: ${snapshot.models.join(", ") || "(none)"}`,
|
|
1556
|
+
];
|
|
1557
|
+
if (run.error)
|
|
1558
|
+
lines.push(`Run error: ${run.error.code}: ${run.error.message}`);
|
|
1559
|
+
lines.push("Agents / ownership:");
|
|
1560
|
+
if (!run.agents.length)
|
|
1561
|
+
lines.push(" (none)");
|
|
1562
|
+
for (const agent of run.agents) {
|
|
1563
|
+
const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
|
|
1564
|
+
const role = agent.role ? ` role=${agent.role}` : "";
|
|
1565
|
+
const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
|
|
1566
|
+
const accounting = agent.accounting ? ` input=${String(agent.accounting.input)} output=${String(agent.accounting.output)} cache-read=${String(agent.accounting.cacheRead)} cache-write=${String(agent.accounting.cacheWrite)} cost=${String(agent.accounting.cost)}` : "";
|
|
1567
|
+
lines.push(` ${agent.label ?? agent.name} (${agent.id}) state=${agent.state} parent=${agent.parentId ?? "root"} model=${model}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`);
|
|
1568
|
+
for (const attempt of agent.attemptDetails ?? [])
|
|
1569
|
+
lines.push(` attempt ${String(attempt.attempt)} transcript=${attempt.sessionFile}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
|
|
1570
|
+
for (const call of agent.toolCalls ?? [])
|
|
1571
|
+
lines.push(` tool ${call.name} state=${call.state}`);
|
|
1572
|
+
}
|
|
1573
|
+
lines.push("Checkpoints:");
|
|
1574
|
+
if (!checkpoints.length)
|
|
1575
|
+
lines.push(" (none)");
|
|
1576
|
+
for (const checkpoint of checkpoints)
|
|
1577
|
+
lines.push(` ${checkpoint.name}: ${checkpoint.prompt} context=${JSON.stringify(checkpoint.context)}`);
|
|
1578
|
+
lines.push("Worktrees / branches:");
|
|
1579
|
+
if (!worktrees.length)
|
|
1580
|
+
lines.push(" (none)");
|
|
1581
|
+
for (const worktree of worktrees)
|
|
1582
|
+
lines.push(` ${worktree.owner}: branch=${worktree.branch} path=${worktree.path} cwd=${worktree.cwd}`);
|
|
1583
|
+
lines.push("Native Pi transcript paths:");
|
|
1584
|
+
if (!run.nativeSessions.length)
|
|
1585
|
+
lines.push(" (none)");
|
|
1586
|
+
for (const session of run.nativeSessions)
|
|
1587
|
+
lines.push(` ${session.sessionId}: ${session.sessionFile}`);
|
|
1588
|
+
return lines.join("\n");
|
|
1589
|
+
}
|
|
1590
|
+
function formatCheckpointReview(checkpoint) {
|
|
1591
|
+
return [`Name: ${checkpoint.name}`, "Prompt:", checkpoint.prompt, "Context:", JSON.stringify(checkpoint.context, null, 2)].join("\n");
|
|
1592
|
+
}
|
|
1593
|
+
const DELIVERY_LIMIT_BYTES = 4 * 1024;
|
|
1594
|
+
const WORKFLOW_LOG_ENTRY = "workflow-log";
|
|
1595
|
+
function completionDelivery(name, value, resultPath, worktrees) {
|
|
1596
|
+
const locations = worktrees.length ? ` Changes: ${worktrees.map(({ branch, path }) => `${branch} (${path})`).join(", ")}.` : "";
|
|
1597
|
+
const message = `Workflow ${name} completed: ${JSON.stringify(value)}${locations}`;
|
|
1598
|
+
if (Buffer.byteLength(message) <= DELIVERY_LIMIT_BYTES)
|
|
1599
|
+
return message;
|
|
1600
|
+
const suffix = `... Full result: ${resultPath}${locations}`;
|
|
1601
|
+
const suffixBytes = Buffer.byteLength(suffix);
|
|
1602
|
+
if (suffixBytes >= DELIVERY_LIMIT_BYTES)
|
|
1603
|
+
return utf8Prefix(suffix, DELIVERY_LIMIT_BYTES);
|
|
1604
|
+
return utf8Prefix(message, DELIVERY_LIMIT_BYTES - suffixBytes) + suffix;
|
|
1605
|
+
}
|
|
1606
|
+
function utf8Prefix(value, maxBytes) {
|
|
1607
|
+
const bytes = Buffer.from(value);
|
|
1608
|
+
let end = Math.min(bytes.length, maxBytes);
|
|
1609
|
+
while (end < bytes.length && end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80)
|
|
1610
|
+
end -= 1;
|
|
1611
|
+
return bytes.subarray(0, end).toString("utf8");
|
|
1612
|
+
}
|
|
1613
|
+
function deliver(pi, content) {
|
|
1614
|
+
pi.sendMessage({ customType: "workflow", content, display: true }, { deliverAs: "followUp", triggerTurn: true });
|
|
1615
|
+
}
|
|
1616
|
+
function deliverFailure(pi, name, error) {
|
|
1617
|
+
deliver(pi, `Workflow ${name} failed: ${formatWorkflowFailure(error)}`);
|
|
1618
|
+
}
|
|
1619
|
+
const inheritedHostAgentPath = new AsyncLocalStorage();
|
|
1620
|
+
const inheritedHostWorktreeOwner = new AsyncLocalStorage();
|
|
1621
|
+
function namedRecord(value, kind) {
|
|
1622
|
+
if (!object(value))
|
|
1623
|
+
fail("INVALID_METADATA", `${kind} must be a record`);
|
|
1624
|
+
return Object.entries(value);
|
|
1625
|
+
}
|
|
1626
|
+
function hostWithWorktree(args, identity, occurrences) {
|
|
1627
|
+
if (args.length !== 1 && args.length !== 2)
|
|
1628
|
+
fail("INVALID_METADATA", "withWorktree requires a callback or a name and callback");
|
|
1629
|
+
const callback = args[args.length - 1];
|
|
1630
|
+
if (typeof callback !== "function")
|
|
1631
|
+
fail("INVALID_METADATA", "withWorktree callback must be a function");
|
|
1632
|
+
let owner;
|
|
1633
|
+
if (args.length === 2) {
|
|
1634
|
+
if (typeof args[0] !== "string" || !args[0].trim())
|
|
1635
|
+
fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
1636
|
+
owner = operationPath("worktree", "named", args[0].trim());
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
const structuralPath = inheritedHostAgentPath.getStore() ?? [];
|
|
1640
|
+
const key = `${identity}\0${JSON.stringify(structuralPath)}`;
|
|
1641
|
+
const occurrence = (occurrences.get(key) ?? 0) + 1;
|
|
1642
|
+
occurrences.set(key, occurrence);
|
|
1643
|
+
owner = operationPath("worktree", "unnamed", "function", identity, ...structuralPath, `occurrence:${String(occurrence)}`);
|
|
1644
|
+
}
|
|
1645
|
+
return inheritedHostWorktreeOwner.run(owner, async () => await callback());
|
|
1646
|
+
}
|
|
1647
|
+
function workflowRunContext(cwd, sessionId, runId, workflow, args, signal) {
|
|
1648
|
+
return Object.freeze({ cwd, sessionId, runId, workflow: deepFreeze(structuredClone(workflow)), args: deepFreeze(structuredClone(args)), signal });
|
|
1649
|
+
}
|
|
1650
|
+
async function resolveWorkflowVariables(run, controller, registry) {
|
|
1651
|
+
let first;
|
|
1652
|
+
const tasks = registry.variables().map(async ({ namespace, name, variable }) => {
|
|
1653
|
+
try {
|
|
1654
|
+
const result = await variable.resolve(run);
|
|
1655
|
+
if (!jsonValue(result) || !Value.Check(variable.schema, result))
|
|
1656
|
+
fail("RESULT_INVALID", `Invalid output from ${namespace}.${name}`);
|
|
1657
|
+
return [name, deepFreeze(structuredClone(result))];
|
|
1658
|
+
}
|
|
1659
|
+
catch (error) {
|
|
1660
|
+
const typed = errorCode(error) ? new WorkflowError(errorCode(error), `${namespace}.${name}: ${errorText(error)}`) : new WorkflowError("INTERNAL_ERROR", `${namespace}.${name}: ${errorText(error)}`);
|
|
1661
|
+
if (!first) {
|
|
1662
|
+
first = typed;
|
|
1663
|
+
controller.abort();
|
|
1664
|
+
}
|
|
1665
|
+
throw typed;
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
await Promise.allSettled(tasks);
|
|
1669
|
+
if (first)
|
|
1670
|
+
throw first;
|
|
1671
|
+
return Object.freeze(Object.fromEntries((await Promise.all(tasks)).map(([name, value]) => [name, value])));
|
|
1672
|
+
}
|
|
1673
|
+
async function hostParallel(rawOperation, rawTasks) {
|
|
1674
|
+
if (typeof rawOperation !== "string" || !rawOperation.trim())
|
|
1675
|
+
fail("INVALID_METADATA", "parallel requires a stable explicit name");
|
|
1676
|
+
const tasks = namedRecord(rawTasks, "parallel tasks");
|
|
1677
|
+
for (const [name, run] of tasks) {
|
|
1678
|
+
if (!name.trim())
|
|
1679
|
+
fail("INVALID_METADATA", "parallel task requires a stable explicit name");
|
|
1680
|
+
if (typeof run !== "function")
|
|
1681
|
+
fail("INVALID_METADATA", "parallel task values must be run functions");
|
|
1682
|
+
}
|
|
1683
|
+
const results = await Promise.all(tasks.map(async ([name, run]) => {
|
|
1684
|
+
try {
|
|
1685
|
+
const parent = inheritedHostAgentPath.getStore() ?? [];
|
|
1686
|
+
return { name, value: await inheritedHostAgentPath.run([...parent, rawOperation, name], run) };
|
|
1687
|
+
}
|
|
1688
|
+
catch (error) {
|
|
1689
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
|
|
1690
|
+
if (typed.code === "CANCELLED")
|
|
1691
|
+
throw typed;
|
|
1692
|
+
return { name, error: typed };
|
|
1693
|
+
}
|
|
1694
|
+
}));
|
|
1695
|
+
const failure = results.find((result) => result.error);
|
|
1696
|
+
if (failure?.error)
|
|
1697
|
+
throw failure.error;
|
|
1698
|
+
return Object.fromEntries(results.map((result) => [result.name, result.value]));
|
|
1699
|
+
}
|
|
1700
|
+
async function hostPipeline(rawOperation, rawItems, rawStages) {
|
|
1701
|
+
if (typeof rawOperation !== "string" || !rawOperation.trim())
|
|
1702
|
+
fail("INVALID_METADATA", "pipeline requires a stable explicit name");
|
|
1703
|
+
const items = namedRecord(rawItems, "pipeline items");
|
|
1704
|
+
const stages = namedRecord(rawStages, "pipeline stages");
|
|
1705
|
+
if (!stages.length)
|
|
1706
|
+
fail("INVALID_METADATA", "pipeline requires at least one stage");
|
|
1707
|
+
for (const [name] of items)
|
|
1708
|
+
if (!name.trim())
|
|
1709
|
+
fail("INVALID_METADATA", "pipeline item requires a stable explicit name");
|
|
1710
|
+
for (const [stageName, run] of stages) {
|
|
1711
|
+
if (!stageName.trim())
|
|
1712
|
+
fail("INVALID_METADATA", "pipeline stage requires a stable explicit name");
|
|
1713
|
+
if (typeof run !== "function")
|
|
1714
|
+
fail("INVALID_METADATA", "pipeline stage values must be run functions");
|
|
1715
|
+
}
|
|
1716
|
+
const results = await Promise.all(items.map(async ([name, initial]) => {
|
|
1717
|
+
let current = initial;
|
|
1718
|
+
try {
|
|
1719
|
+
for (const [stageName, run] of stages) {
|
|
1720
|
+
const parent = inheritedHostAgentPath.getStore() ?? [];
|
|
1721
|
+
current = await inheritedHostAgentPath.run([...parent, rawOperation, name, stageName], () => run(current));
|
|
1722
|
+
}
|
|
1723
|
+
return { name, value: current };
|
|
1724
|
+
}
|
|
1725
|
+
catch (error) {
|
|
1726
|
+
const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
|
|
1727
|
+
if (typed.code === "CANCELLED")
|
|
1728
|
+
throw typed;
|
|
1729
|
+
return { name, error: typed };
|
|
1730
|
+
}
|
|
1731
|
+
}));
|
|
1732
|
+
const failure = results.find((result) => result.error);
|
|
1733
|
+
if (failure?.error)
|
|
1734
|
+
throw failure.error;
|
|
1735
|
+
return Object.fromEntries(results.map((result) => [result.name, result.value]));
|
|
1736
|
+
}
|
|
1737
|
+
function nextNamedOccurrence(counters, label) {
|
|
1738
|
+
const count = (counters.get(label) ?? 0) + 1;
|
|
1739
|
+
counters.set(label, count);
|
|
1740
|
+
return count === 1 ? label : `${label}#${String(count)}`;
|
|
1741
|
+
}
|
|
1742
|
+
function withWorkflowFunctions(bridge, store, runContext, variables, registry) {
|
|
1743
|
+
const functionAgentOccurrences = new Map();
|
|
1744
|
+
const functionWorktreeOccurrences = new Map();
|
|
1745
|
+
return { ...bridge, functions: registry.globals(), variables, function: async (namespace, name, input, path, signal, worktreeOwner) => {
|
|
1746
|
+
const replayed = await store.replay(path);
|
|
1747
|
+
let stored;
|
|
1748
|
+
const sideEffects = [];
|
|
1749
|
+
const context = {
|
|
1750
|
+
run: runContext,
|
|
1751
|
+
agent: async (...args) => {
|
|
1752
|
+
if (!bridge.agent || typeof args[0] !== "string")
|
|
1753
|
+
fail("AGENT_FAILED", "No agent bridge is available");
|
|
1754
|
+
const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
|
|
1755
|
+
const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
|
|
1756
|
+
const structuralPath = inheritedHostAgentPath.getStore() ?? [];
|
|
1757
|
+
const key = `${path}\0${JSON.stringify(structuralPath)}`;
|
|
1758
|
+
const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
|
|
1759
|
+
functionAgentOccurrences.set(key, occurrence);
|
|
1760
|
+
return bridge.agent(args[0], options, signal, { structuralPath: [...structuralPath], callSite: `function:${path}`, occurrence, parentBreadcrumb: `${namespace}.${name}`, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
|
|
1761
|
+
},
|
|
1762
|
+
prompt: workflowPrompt,
|
|
1763
|
+
parallel: (...args) => hostParallel(args[0], args[1]),
|
|
1764
|
+
pipeline: (...args) => hostPipeline(args[0], args[1], args[2]),
|
|
1765
|
+
withWorktree: (...args) => hostWithWorktree(args, path, functionWorktreeOccurrences),
|
|
1766
|
+
checkpoint: async (...args) => {
|
|
1767
|
+
if (!bridge.checkpoint || !object(args[0]) || !jsonValue(args[0]))
|
|
1768
|
+
fail("INTERNAL_ERROR", "No checkpoint bridge is available");
|
|
1769
|
+
return bridge.checkpoint(args[0], signal);
|
|
1770
|
+
},
|
|
1771
|
+
phase: (name) => { sideEffects.push(Promise.resolve(bridge.phase?.(name))); },
|
|
1772
|
+
log: (message) => { sideEffects.push(Promise.resolve(bridge.log?.(message))); },
|
|
1773
|
+
};
|
|
1774
|
+
const result = await registry.invokeFunction(namespace, name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } });
|
|
1775
|
+
await Promise.all(sideEffects);
|
|
1776
|
+
if (!replayed)
|
|
1777
|
+
await store.complete(path, stored ?? result);
|
|
1778
|
+
return result;
|
|
1779
|
+
} };
|
|
1780
|
+
}
|
|
1781
|
+
function projectTrusted(ctx) {
|
|
1782
|
+
const check = object(ctx) ? ctx.isProjectTrusted : undefined;
|
|
1783
|
+
return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
|
|
1784
|
+
}
|
|
1785
|
+
function parseThinking(value) {
|
|
1786
|
+
switch (value) {
|
|
1787
|
+
case "off":
|
|
1788
|
+
case "minimal":
|
|
1789
|
+
case "low":
|
|
1790
|
+
case "medium":
|
|
1791
|
+
case "high":
|
|
1792
|
+
case "xhigh":
|
|
1793
|
+
case "max": return value;
|
|
1794
|
+
default: return undefined;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
export default function workflowExtension(pi, home, clipboard = copyToClipboard, createSession = createNativeAgentSession) {
|
|
1798
|
+
beginWorkflowExtensionLoading();
|
|
1799
|
+
const registry = loadingRegistry();
|
|
1800
|
+
const registerEntryRenderer = pi.registerEntryRenderer;
|
|
1801
|
+
registerEntryRenderer?.(WORKFLOW_LOG_ENTRY, (entry) => {
|
|
1802
|
+
const data = entry.data;
|
|
1803
|
+
return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
|
|
1804
|
+
});
|
|
1805
|
+
const logBridge = (lifecycle, workflowName) => async (message) => {
|
|
1806
|
+
await lifecycle.enter();
|
|
1807
|
+
try {
|
|
1808
|
+
pi.appendEntry(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) });
|
|
1809
|
+
}
|
|
1810
|
+
finally {
|
|
1811
|
+
await lifecycle.leave();
|
|
1812
|
+
}
|
|
1813
|
+
};
|
|
1814
|
+
const events = pi.events;
|
|
1815
|
+
pi.on("resources_discover", () => {
|
|
1816
|
+
if (!pi.getActiveTools().includes("workflow"))
|
|
1817
|
+
return;
|
|
1818
|
+
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
1819
|
+
const skillPath = [join(extensionDir, "../skills"), join(extensionDir, "../../skills")].find((path) => existsSync(path));
|
|
1820
|
+
return skillPath ? { skillPaths: [skillPath] } : undefined;
|
|
1821
|
+
});
|
|
1822
|
+
const emitAsyncStarted = (store, metadata) => {
|
|
1823
|
+
events?.emit(WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name });
|
|
1824
|
+
};
|
|
1825
|
+
const emitAsyncComplete = (store, state, error) => {
|
|
1826
|
+
events?.emit(WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: state === "complete", state, ...(state === "stopped" ? { stopped: true } : {}), ...(error ? { error: error.message } : {}) });
|
|
1827
|
+
};
|
|
1828
|
+
const runs = new Map();
|
|
1829
|
+
let sessionLease;
|
|
1830
|
+
let sessionLeasePromise;
|
|
1831
|
+
const ensureSessionLease = async (cwd, sessionId) => {
|
|
1832
|
+
if (sessionLease?.active)
|
|
1833
|
+
return;
|
|
1834
|
+
const pending = sessionLeasePromise ?? (sessionLeasePromise = acquireSessionLease(cwd, sessionId, home));
|
|
1835
|
+
try {
|
|
1836
|
+
sessionLease = await pending;
|
|
1837
|
+
}
|
|
1838
|
+
finally {
|
|
1839
|
+
if (sessionLeasePromise === pending)
|
|
1840
|
+
sessionLeasePromise = undefined;
|
|
1841
|
+
}
|
|
1842
|
+
};
|
|
1843
|
+
const releaseSessionLease = async () => {
|
|
1844
|
+
const lease = sessionLease ?? await sessionLeasePromise?.catch(() => undefined);
|
|
1845
|
+
sessionLease = undefined;
|
|
1846
|
+
sessionLeasePromise = undefined;
|
|
1847
|
+
await lease?.release();
|
|
1848
|
+
};
|
|
1849
|
+
const lifecycleFor = (store, state) => new RunLifecycle(state, async (next) => {
|
|
1850
|
+
const run = await store.updateState((current) => {
|
|
1851
|
+
const nextRun = { ...current, state: next };
|
|
1852
|
+
if (next === "running" || next === "completed")
|
|
1853
|
+
delete nextRun.error;
|
|
1854
|
+
return nextRun;
|
|
1855
|
+
});
|
|
1856
|
+
runs.get(store.runId)?.update?.(workflowToolUpdate(run));
|
|
1857
|
+
});
|
|
1858
|
+
const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
|
|
1859
|
+
const run = runs.get(runId);
|
|
1860
|
+
if (!run)
|
|
1861
|
+
throw new WorkflowError("INTERNAL_ERROR", `Unknown production run: ${runId}`);
|
|
1862
|
+
try {
|
|
1863
|
+
const onProgress = async (progress) => {
|
|
1864
|
+
let runState;
|
|
1865
|
+
if (progress.persist) {
|
|
1866
|
+
runState = await run.store.updateState((current) => current.agents.some((agent) => agent.id === id) ? { ...current, agents: current.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) } : current);
|
|
1867
|
+
}
|
|
1868
|
+
else {
|
|
1869
|
+
const loaded = await run.store.load();
|
|
1870
|
+
if (!loaded.run.agents.some((agent) => agent.id === id))
|
|
1871
|
+
return;
|
|
1872
|
+
runState = { ...loaded.run, agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
|
|
1873
|
+
}
|
|
1874
|
+
if (!runState.agents.some((agent) => agent.id === id))
|
|
1875
|
+
return;
|
|
1876
|
+
run.update?.(workflowToolUpdate(runState));
|
|
1877
|
+
};
|
|
1878
|
+
const onAttempt = async (attempt) => {
|
|
1879
|
+
await scheduler.flush();
|
|
1880
|
+
await persistActiveAgentAttempt(run.store, id, attempt);
|
|
1881
|
+
run.update?.(workflowToolUpdate((await run.store.load()).run));
|
|
1882
|
+
};
|
|
1883
|
+
const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); });
|
|
1884
|
+
await persistAgentAttempts(run.store, id, result.attempts);
|
|
1885
|
+
return result.value;
|
|
1886
|
+
}
|
|
1887
|
+
catch (error) {
|
|
1888
|
+
const attempts = error.attempts;
|
|
1889
|
+
if (attempts?.length)
|
|
1890
|
+
await persistAgentAttempts(run.store, id, attempts);
|
|
1891
|
+
throw error;
|
|
1892
|
+
}
|
|
1893
|
+
}, 16, async (runId, ownership) => {
|
|
1894
|
+
const run = runs.get(runId);
|
|
1895
|
+
if (!run)
|
|
1896
|
+
return;
|
|
1897
|
+
await run.store.saveOwnership(ownership);
|
|
1898
|
+
const runState = await run.store.updateState((current) => {
|
|
1899
|
+
const existing = new Map(current.agents.map((agent) => [agent.id, agent]));
|
|
1900
|
+
const agents = ownership.map((node) => {
|
|
1901
|
+
const previous = existing.get(node.id);
|
|
1902
|
+
const requested = { label: node.options.label, workflowName: run.metadata.name, ...(node.options.model ? { model: node.options.model } : {}), ...(node.options.thinking ? { thinking: node.options.thinking } : {}), ...(node.options.role ? { role: node.options.role } : {}), effectiveTools: node.options.tools };
|
|
1903
|
+
let effective;
|
|
1904
|
+
try {
|
|
1905
|
+
effective = run.executor.resolve(requested);
|
|
1906
|
+
}
|
|
1907
|
+
catch {
|
|
1908
|
+
effective = previous ? { model: previous.model, tools: previous.tools } : { model: node.options.model ? modelSpec(node.options.model, run.model) : { ...run.model, ...(node.options.thinking ? { thinking: node.options.thinking } : {}) }, tools: node.options.tools };
|
|
1909
|
+
}
|
|
1910
|
+
return { id: node.id, name: node.label, ...(node.options.requestedLabel ? { label: node.options.requestedLabel } : {}), path: node.id, state: node.state, ...(node.parentId ? { parentId: node.parentId } : {}), ...(node.options.parentBreadcrumb ? { parentBreadcrumb: node.options.parentBreadcrumb } : {}), ...(node.options.role ? { role: node.options.role } : {}), model: effective.model, tools: effective.tools, attempts: previous?.attempts ?? 0, ...(previous?.attemptDetails ? { attemptDetails: previous.attemptDetails } : {}), ...(previous?.accounting ? { accounting: previous.accounting } : {}), ...(previous?.toolCalls ? { toolCalls: previous.toolCalls } : {}), ...(previous?.activity ? { activity: previous.activity } : {}) };
|
|
1911
|
+
});
|
|
1912
|
+
return { ...current, agents };
|
|
1913
|
+
});
|
|
1914
|
+
run.update?.(workflowToolUpdate(runState));
|
|
1915
|
+
});
|
|
1916
|
+
const answerCheckpoint = async (runId, name, approved, silent = false) => {
|
|
1917
|
+
const run = runs.get(runId);
|
|
1918
|
+
if (!run)
|
|
1919
|
+
return false;
|
|
1920
|
+
const checkpoint = await run.store.answerCheckpoint(name, approved);
|
|
1921
|
+
if (!checkpoint)
|
|
1922
|
+
return false;
|
|
1923
|
+
if ((await run.store.awaitingCheckpoints()).length === 0)
|
|
1924
|
+
await run.lifecycle.resolveAwaitingInput();
|
|
1925
|
+
run.checkpointResolvers.get(checkpoint.path)?.(approved);
|
|
1926
|
+
run.checkpointResolvers.delete(checkpoint.path);
|
|
1927
|
+
if (!silent)
|
|
1928
|
+
deliver(pi, `Workflow ${run.metadata.name} checkpoint ${name}: ${approved ? "Approved" : "Rejected"}.`);
|
|
1929
|
+
return true;
|
|
1930
|
+
};
|
|
1931
|
+
const checkpointBridge = (runId, store, metadata, foreground, ui) => {
|
|
1932
|
+
const checkpointCounters = new Map();
|
|
1933
|
+
return async (raw, signal) => {
|
|
1934
|
+
const input = validateCheckpoint(raw);
|
|
1935
|
+
const label = nextNamedOccurrence(checkpointCounters, input.name);
|
|
1936
|
+
const path = operationPath("checkpoint", label);
|
|
1937
|
+
if (foreground && !ui?.select)
|
|
1938
|
+
fail("RESUME_INCOMPATIBLE", "Foreground checkpoints require UI");
|
|
1939
|
+
const alreadyAwaiting = (await store.awaitingCheckpoints()).some((checkpoint) => checkpoint.path === path);
|
|
1940
|
+
const replayed = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
1941
|
+
if (replayed !== undefined)
|
|
1942
|
+
return replayed;
|
|
1943
|
+
const run = runs.get(runId);
|
|
1944
|
+
await run?.lifecycle.enterAwaitingInput();
|
|
1945
|
+
if (!alreadyAwaiting && !ui?.select)
|
|
1946
|
+
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1947
|
+
const decision = new Promise((resolve, reject) => {
|
|
1948
|
+
run?.checkpointResolvers.set(path, resolve);
|
|
1949
|
+
if (signal.aborted)
|
|
1950
|
+
reject(new WorkflowError("CANCELLED", "Workflow cancelled"));
|
|
1951
|
+
else
|
|
1952
|
+
signal.addEventListener("abort", () => { run?.checkpointResolvers.delete(path); reject(new WorkflowError("CANCELLED", "Workflow cancelled")); }, { once: true });
|
|
1953
|
+
});
|
|
1954
|
+
const answered = await store.awaitCheckpoint({ ...input, name: label, path });
|
|
1955
|
+
if (answered !== undefined) {
|
|
1956
|
+
if ((await store.awaitingCheckpoints()).length === 0)
|
|
1957
|
+
await run?.lifecycle.resolveAwaitingInput();
|
|
1958
|
+
run?.checkpointResolvers.get(path)?.(answered);
|
|
1959
|
+
run?.checkpointResolvers.delete(path);
|
|
1960
|
+
}
|
|
1961
|
+
if (ui?.select)
|
|
1962
|
+
void (async () => {
|
|
1963
|
+
while (!signal.aborted && run?.checkpointResolvers.has(path)) {
|
|
1964
|
+
const choice = await ui.select?.(input.prompt, ["Approve", "Reject"]);
|
|
1965
|
+
if (!choice) {
|
|
1966
|
+
if (foreground)
|
|
1967
|
+
continue; // foreground: retry until answered
|
|
1968
|
+
// Background resume: user dismissed UI, fall back to LLM
|
|
1969
|
+
deliver(pi, `Workflow ${metadata.name} checkpoint ${label}: ${input.prompt}\nContext: ${JSON.stringify(input.context)}\nRespond with workflow_respond.`);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (await answerCheckpoint(runId, label, choice === "Approve", true))
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
})().catch(() => undefined);
|
|
1976
|
+
return decision;
|
|
1977
|
+
};
|
|
1978
|
+
};
|
|
1979
|
+
pi.registerTool({
|
|
1980
|
+
name: "workflow_respond",
|
|
1981
|
+
label: "Workflow Respond",
|
|
1982
|
+
description: "Approve or reject one pending workflow checkpoint",
|
|
1983
|
+
parameters: Type.Object({ runId: Type.String(), name: Type.String(), approved: Type.Boolean() }, { additionalProperties: false }),
|
|
1984
|
+
async execute(_id, params) {
|
|
1985
|
+
try {
|
|
1986
|
+
const accepted = await answerCheckpoint(params.runId, params.name, params.approved);
|
|
1987
|
+
return { content: [{ type: "text", text: accepted ? "Checkpoint response accepted." : "Checkpoint is not awaiting a response." }], details: { accepted } };
|
|
1988
|
+
}
|
|
1989
|
+
catch (error) {
|
|
1990
|
+
throw mainAgentError(error);
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
});
|
|
1994
|
+
let catalogRegistered = false;
|
|
1995
|
+
let sessionStarted = false;
|
|
1996
|
+
const registerCatalog = () => {
|
|
1997
|
+
if (catalogRegistered || !pi.getActiveTools().includes("workflow"))
|
|
1998
|
+
return;
|
|
1999
|
+
const catalog = registry.catalog();
|
|
2000
|
+
if (!catalog.functions.length && !catalog.variables.length && !catalog.workflows.length)
|
|
2001
|
+
return;
|
|
2002
|
+
pi.registerTool({
|
|
2003
|
+
name: "workflow_catalog",
|
|
2004
|
+
label: "Workflow Catalog",
|
|
2005
|
+
description: "List global workflow functions, variables, and reusable workflows",
|
|
2006
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
2007
|
+
async execute() { return { content: [{ type: "text", text: JSON.stringify(registry.catalog()) }], details: {} }; }
|
|
2008
|
+
});
|
|
2009
|
+
catalogRegistered = true;
|
|
2010
|
+
};
|
|
2011
|
+
const coldResumeRun = async (run, hasUI, ui, trustedProject) => {
|
|
2012
|
+
const loaded = await run.store.load();
|
|
2013
|
+
if (loaded.snapshot.identityVersion !== LAUNCH_SNAPSHOT_IDENTITY_VERSION)
|
|
2014
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow launch snapshot identity version is incompatible");
|
|
2015
|
+
if (loaded.snapshot.roles === undefined)
|
|
2016
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Workflow role definitions are missing from the launch snapshot");
|
|
2017
|
+
if ((loaded.snapshot.projectRoles?.length ?? 0) > 0 && !trustedProject)
|
|
2018
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", "Cannot restore project roles in an untrusted project");
|
|
2019
|
+
const missingRole = loaded.snapshot.agentTypes.find((role) => !loaded.snapshot.roles?.[role]);
|
|
2020
|
+
if (missingRole)
|
|
2021
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Role definition is missing from the launch snapshot: ${missingRole}`);
|
|
2022
|
+
const active = new Set(pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog"));
|
|
2023
|
+
const missing = loaded.snapshot.tools.filter((tool) => tool !== "workflow_catalog").find((tool) => !active.has(tool));
|
|
2024
|
+
if (missing)
|
|
2025
|
+
throw new WorkflowError("RESUME_INCOMPATIBLE", `Required tool is unavailable: ${missing}`);
|
|
2026
|
+
preflight(loaded.snapshot.script, { models: new Set(loaded.snapshot.models), tools: active, agentTypes: new Set(loaded.snapshot.agentTypes) }, loaded.snapshot.schemas, loaded.snapshot.metadata);
|
|
2027
|
+
const controller = new AbortController();
|
|
2028
|
+
run.abortController = controller;
|
|
2029
|
+
const runContext = workflowRunContext(run.store.cwd, run.store.sessionId, run.store.runId, loaded.snapshot.metadata, loaded.snapshot.args, controller.signal);
|
|
2030
|
+
let variables;
|
|
2031
|
+
try {
|
|
2032
|
+
variables = await resolveWorkflowVariables(runContext, controller, registry);
|
|
2033
|
+
}
|
|
2034
|
+
catch (error) {
|
|
2035
|
+
const typed = asWorkflowError(error);
|
|
2036
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2037
|
+
await run.lifecycle.terminal("failed").catch(() => undefined);
|
|
2038
|
+
await run.store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } }));
|
|
2039
|
+
}
|
|
2040
|
+
throw typed;
|
|
2041
|
+
}
|
|
2042
|
+
await scheduler.cancelRun(run.store.runId);
|
|
2043
|
+
await run.lifecycle.resume();
|
|
2044
|
+
const execution = runWorkflow(loaded.snapshot.script, loaded.snapshot.args, withWorkflowFunctions({ agent: async (prompt, options, signal, identity) => {
|
|
2045
|
+
await run.lifecycle.enter();
|
|
2046
|
+
try {
|
|
2047
|
+
const path = agentIdentityPath(identity);
|
|
2048
|
+
const replayed = await run.store.replay(path);
|
|
2049
|
+
if (replayed)
|
|
2050
|
+
return replayed.value;
|
|
2051
|
+
const worktree = agentWorktree(identity);
|
|
2052
|
+
const cwd = worktree.worktreeOwner ? (await run.store.worktree(worktree.worktreeOwner)).cwd : run.store.cwd;
|
|
2053
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2054
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2055
|
+
const thinking = parseThinking(options.thinking);
|
|
2056
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
2057
|
+
const resolved = run.executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
|
|
2058
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2059
|
+
const tools = resolved.tools;
|
|
2060
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2061
|
+
const spawned = scheduler.spawn(run.store.runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}) });
|
|
2062
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2063
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
2064
|
+
const outcome = await spawned.result.finally(() => { signal.removeEventListener("abort", cancel); });
|
|
2065
|
+
if (!outcome.ok)
|
|
2066
|
+
throw new WorkflowError(outcome.error.code, outcome.error.message);
|
|
2067
|
+
await run.store.complete(path, outcome.value);
|
|
2068
|
+
return outcome.value;
|
|
2069
|
+
}
|
|
2070
|
+
finally {
|
|
2071
|
+
await run.lifecycle.leave();
|
|
2072
|
+
}
|
|
2073
|
+
}, checkpoint: checkpointBridge(run.store.runId, run.store, run.metadata, false, hasUI ? ui : undefined), phase: async (phase) => { await run.lifecycle.enter(); try {
|
|
2074
|
+
await run.store.updateState((current) => ({ ...current, phase }));
|
|
2075
|
+
}
|
|
2076
|
+
finally {
|
|
2077
|
+
await run.lifecycle.leave();
|
|
2078
|
+
} }, log: logBridge(run.lifecycle, run.metadata.name) }, run.store, runContext, variables, registry), controller.signal);
|
|
2079
|
+
run.execution = execution;
|
|
2080
|
+
emitAsyncStarted(run.store, run.metadata);
|
|
2081
|
+
const completion = execution.result.then(async (value) => { await scheduler.flush(); const resultPath = await run.store.saveResult(value); await run.lifecycle.terminal("completed"); emitAsyncComplete(run.store, "complete"); return { value, resultPath }; }, async (error) => { await scheduler.flush(); if (run.lifecycle.state !== "stopped" && run.lifecycle.state !== "interrupted")
|
|
2082
|
+
await run.lifecycle.terminal("failed"); const typed = error instanceof WorkflowError ? error : new WorkflowError(errorCode(error) ?? "INTERNAL_ERROR", errorText(error)); await run.store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } })); emitAsyncComplete(run.store, run.lifecycle.state === "stopped" ? "stopped" : "failed", typed); if (run.lifecycle.state !== "stopped" && run.lifecycle.state !== "interrupted")
|
|
2083
|
+
deliverFailure(pi, run.metadata.name, error); });
|
|
2084
|
+
run.completion = completion;
|
|
2085
|
+
void completion;
|
|
2086
|
+
};
|
|
2087
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
2088
|
+
if (sessionStarted)
|
|
2089
|
+
return;
|
|
2090
|
+
sessionStarted = true;
|
|
2091
|
+
registry.freeze();
|
|
2092
|
+
registerCatalog();
|
|
2093
|
+
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2094
|
+
try {
|
|
2095
|
+
for (const runId of await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)) {
|
|
2096
|
+
if (runs.has(runId))
|
|
2097
|
+
continue;
|
|
2098
|
+
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2099
|
+
let loaded;
|
|
2100
|
+
try {
|
|
2101
|
+
loaded = await store.load();
|
|
2102
|
+
}
|
|
2103
|
+
catch {
|
|
2104
|
+
if (!await store.isComplete())
|
|
2105
|
+
await store.delete(true).catch(() => undefined);
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
if (["completed", "failed", "stopped"].includes(loaded.run.state))
|
|
2109
|
+
continue;
|
|
2110
|
+
if (loaded.run.state !== "interrupted") {
|
|
2111
|
+
await store.updateState((current) => ["completed", "failed", "stopped", "interrupted"].includes(current.state) ? current : { ...current, state: "interrupted" });
|
|
2112
|
+
loaded = { ...loaded, run: (await store.load()).run };
|
|
2113
|
+
}
|
|
2114
|
+
const model = modelSpec(loaded.snapshot.models[0] ?? "", { provider: ctx.model?.provider ?? "", model: ctx.model?.id ?? "", thinking: pi.getThinkingLevel() });
|
|
2115
|
+
const lifecycle = lifecycleFor(store, loaded.run.state);
|
|
2116
|
+
const providerPause = async () => { deliver(pi, `Workflow ${loaded.snapshot.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2117
|
+
const roleDefinitions = loaded.snapshot.roles ?? {};
|
|
2118
|
+
runs.set(runId, { executor: new WorkflowAgentExecutor({ cwd: ctx.cwd, model, tools: new Set(loaded.snapshot.tools.filter((tool) => pi.getActiveTools().includes(tool) && tool !== "workflow_catalog")), availableModels: new Set(loaded.snapshot.models), agentDefinitions: roleDefinitions, runStore: store, providerPause }, createSession), store, metadata: loaded.snapshot.metadata, model, lifecycle, abortController: new AbortController(), checkpointResolvers: new Map() });
|
|
2119
|
+
for (const checkpoint of await store.awaitingCheckpoints())
|
|
2120
|
+
deliver(pi, `Workflow ${loaded.snapshot.metadata.name} checkpoint ${checkpoint.name}: ${checkpoint.prompt}\nContext: ${JSON.stringify(checkpoint.context)}\nRespond with workflow_respond.`);
|
|
2121
|
+
scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.settings.maxAgentLaunches, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : []);
|
|
2122
|
+
}
|
|
2123
|
+
const resumeSelect = ctx.ui?.select;
|
|
2124
|
+
if (ctx.hasUI && resumeSelect) {
|
|
2125
|
+
const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
|
|
2126
|
+
if (interrupted.length > 0) {
|
|
2127
|
+
const labels = interrupted.map((r) => `Resume: ${r.metadata.name} (${r.store.runId.slice(0, 8)})`);
|
|
2128
|
+
const options = [...labels, ...(interrupted.length > 1 ? ["Resume all"] : []), "Skip"];
|
|
2129
|
+
const choice = await resumeSelect(`${String(interrupted.length)} interrupted workflow${interrupted.length > 1 ? "s" : ""} found`, options);
|
|
2130
|
+
if (choice && choice !== "Skip") {
|
|
2131
|
+
const toResume = choice === "Resume all" ? interrupted : interrupted.filter((_, i) => labels[i] === choice);
|
|
2132
|
+
for (const run of toResume) {
|
|
2133
|
+
try {
|
|
2134
|
+
await coldResumeRun(run, true, ctx.ui, projectTrusted(ctx));
|
|
2135
|
+
ctx.ui.notify(`Resumed workflow ${run.metadata.name}.`, "info");
|
|
2136
|
+
}
|
|
2137
|
+
catch (err) {
|
|
2138
|
+
ctx.ui.notify(`Cannot resume ${run.metadata.name}: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
catch (error) {
|
|
2146
|
+
await releaseSessionLease();
|
|
2147
|
+
throw error;
|
|
2148
|
+
}
|
|
2149
|
+
});
|
|
2150
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
2151
|
+
if (!pi.getActiveTools().includes("workflow"))
|
|
2152
|
+
return;
|
|
2153
|
+
const roles = Object.entries(loadAgentDefinitions(ctx.cwd, undefined, projectTrusted(ctx))).filter(([, definition]) => definition.description);
|
|
2154
|
+
if (!roles.length)
|
|
2155
|
+
return;
|
|
2156
|
+
const content = `Workflow role descriptions:\n${roles.map(([name, definition]) => `- \`${name}\`: ${String(definition.description)}`).join("\n")}`;
|
|
2157
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${content}` };
|
|
2158
|
+
});
|
|
2159
|
+
pi.registerTool({
|
|
2160
|
+
name: "workflow",
|
|
2161
|
+
label: WORKFLOW_TOOL_LABEL,
|
|
2162
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
2163
|
+
promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
|
|
2164
|
+
parameters: WORKFLOW_TOOL_PARAMETERS,
|
|
2165
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
2166
|
+
try {
|
|
2167
|
+
if (!ctx.model)
|
|
2168
|
+
throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
|
|
2169
|
+
const defaults = loadSettings();
|
|
2170
|
+
const settings = Object.freeze({ concurrency: params.concurrency ?? defaults.concurrency, maxAgentLaunches: params.maxAgentLaunches ?? defaults.maxAgentLaunches });
|
|
2171
|
+
const rootModel = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
|
|
2172
|
+
const rootModelName = `${rootModel.provider}/${rootModel.model}`;
|
|
2173
|
+
const modelRegistry = ctx.modelRegistry;
|
|
2174
|
+
const availableModels = new Set((modelRegistry?.getAvailable() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
|
|
2175
|
+
availableModels.add(rootModelName);
|
|
2176
|
+
const rootTools = pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_catalog");
|
|
2177
|
+
const trustedProject = projectTrusted(ctx);
|
|
2178
|
+
const validated = validateWorkflowLaunchWithRegistry(params, { cwd: ctx.cwd, projectTrusted: trustedProject, availableModels, rootTools: new Set(rootTools) }, registry);
|
|
2179
|
+
const { script, checked, agentDefinitions, projectAgentDefinitions, roleNames } = validated;
|
|
2180
|
+
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2181
|
+
const runId = randomUUID();
|
|
2182
|
+
const args = (params.args ?? null);
|
|
2183
|
+
encoded(args);
|
|
2184
|
+
const runController = new AbortController();
|
|
2185
|
+
if (signal?.aborted)
|
|
2186
|
+
runController.abort();
|
|
2187
|
+
else
|
|
2188
|
+
signal?.addEventListener("abort", () => { runController.abort(); }, { once: true });
|
|
2189
|
+
const runContext = workflowRunContext(ctx.cwd, ctx.sessionManager.getSessionId(), runId, checked.metadata, args, runController.signal);
|
|
2190
|
+
const variables = await resolveWorkflowVariables(runContext, runController, registry);
|
|
2191
|
+
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2192
|
+
const roles = Object.fromEntries(roleNames.map((role) => [role, agentDefinitions[role]]));
|
|
2193
|
+
const projectRoles = roleNames.filter((role) => projectAgentDefinitions[role] !== undefined);
|
|
2194
|
+
const roleModels = roleNames.flatMap((role) => { const model = agentDefinitions[role]?.model; return model ? [modelCapability(model)] : []; });
|
|
2195
|
+
const snapshotModels = [...new Set([rootModelName, ...checked.referenced.models, ...roleModels])];
|
|
2196
|
+
const snapshot = createLaunchSnapshot({ script, args, metadata: checked.metadata, settings, models: snapshotModels, tools: rootTools, agentTypes: checked.referenced.agentTypes, roles, projectRoles, schemas: checked.schemas });
|
|
2197
|
+
await store.create({ id: runId, workflowName: checked.metadata.name, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), state: "running", agents: [], nativeSessions: [] }, snapshot);
|
|
2198
|
+
const lifecycle = lifecycleFor(store, "running");
|
|
2199
|
+
const background = !params.foreground;
|
|
2200
|
+
const providerPause = async () => { if (background)
|
|
2201
|
+
deliver(pi, `Workflow ${checked.metadata.name} paused: provider limit.`); await lifecycle.providerPause(); };
|
|
2202
|
+
const executor = new WorkflowAgentExecutor({ cwd: ctx.cwd, model: rootModel, tools: new Set(rootTools), availableModels, agentDefinitions, runStore: store, providerPause }, createSession);
|
|
2203
|
+
runs.set(runId, { executor, store, metadata: checked.metadata, model: rootModel, lifecycle, abortController: runController, checkpointResolvers: new Map(), ...(params.foreground && onUpdate ? { update: onUpdate } : {}) });
|
|
2204
|
+
if (params.foreground && onUpdate)
|
|
2205
|
+
onUpdate(workflowToolUpdate((await store.load()).run));
|
|
2206
|
+
scheduler.addRun(runId, settings.concurrency, settings.maxAgentLaunches);
|
|
2207
|
+
const execution = runWorkflow(script, args, withWorkflowFunctions({ agent: async (prompt, options, agentSignal, identity) => {
|
|
2208
|
+
await lifecycle.enter();
|
|
2209
|
+
try {
|
|
2210
|
+
const path = agentIdentityPath(identity);
|
|
2211
|
+
const replayed = await store.replay(path);
|
|
2212
|
+
if (replayed)
|
|
2213
|
+
return replayed.value;
|
|
2214
|
+
const worktree = agentWorktree(identity);
|
|
2215
|
+
const cwd = worktree.worktreeOwner ? (await store.worktree(worktree.worktreeOwner)).cwd : ctx.cwd;
|
|
2216
|
+
const role = typeof options.role === "string" ? options.role : undefined;
|
|
2217
|
+
const model = typeof options.model === "string" ? options.model : undefined;
|
|
2218
|
+
const thinking = parseThinking(options.thinking);
|
|
2219
|
+
const requestedLabel = typeof options.label === "string" ? options.label : undefined;
|
|
2220
|
+
const resolved = executor.resolve({ label: requestedLabel ?? role ?? "agent", workflowName: checked.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(Array.isArray(options.tools) ? { tools: options.tools } : {}) });
|
|
2221
|
+
const label = displayAgentName(requestedLabel, role, resolved.model);
|
|
2222
|
+
const tools = resolved.tools;
|
|
2223
|
+
const schema = object(options.outputSchema) ? options.outputSchema : undefined;
|
|
2224
|
+
const spawned = scheduler.spawn(runId, prompt, { label, ...(requestedLabel ? { requestedLabel } : {}), ...(identity.parentBreadcrumb ? { parentBreadcrumb: identity.parentBreadcrumb } : {}), cwd, tools, ...worktree, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(schema ? { schema } : {}), ...(typeof options.retries === "number" && Number.isInteger(options.retries) && options.retries >= 0 ? { retries: options.retries } : {}), ...(positiveInteger(options.timeoutMs) || options.timeoutMs === null ? { timeoutMs: options.timeoutMs } : {}) });
|
|
2225
|
+
const cancel = () => { scheduler.cancel(spawned.id); };
|
|
2226
|
+
if (agentSignal.aborted)
|
|
2227
|
+
cancel();
|
|
2228
|
+
else
|
|
2229
|
+
agentSignal.addEventListener("abort", cancel, { once: true });
|
|
2230
|
+
const outcome = await spawned.result.finally(() => { agentSignal.removeEventListener("abort", cancel); });
|
|
2231
|
+
if (!outcome.ok)
|
|
2232
|
+
throw new WorkflowError(outcome.error.code, outcome.error.message);
|
|
2233
|
+
await store.complete(path, outcome.value);
|
|
2234
|
+
return outcome.value;
|
|
2235
|
+
}
|
|
2236
|
+
finally {
|
|
2237
|
+
await lifecycle.leave();
|
|
2238
|
+
}
|
|
2239
|
+
}, checkpoint: checkpointBridge(runId, store, checked.metadata, Boolean(params.foreground), params.foreground && ctx.hasUI ? ctx.ui : undefined), phase: async (phase) => {
|
|
2240
|
+
await lifecycle.enter();
|
|
2241
|
+
try {
|
|
2242
|
+
const run = await store.updateState((current) => ({ ...current, phase }));
|
|
2243
|
+
runs.get(runId)?.update?.(workflowToolUpdate(run));
|
|
2244
|
+
}
|
|
2245
|
+
finally {
|
|
2246
|
+
await lifecycle.leave();
|
|
2247
|
+
}
|
|
2248
|
+
}, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
|
|
2249
|
+
runs.get(runId).execution = execution;
|
|
2250
|
+
if (background)
|
|
2251
|
+
emitAsyncStarted(store, checked.metadata);
|
|
2252
|
+
const finish = execution.result.then(async (value) => { await scheduler.flush(); const resultPath = await store.saveResult(value); await lifecycle.terminal("completed"); return { value, resultPath }; }, async (error) => { await scheduler.flush(); const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error)); if (lifecycle.state !== "stopped" && lifecycle.state !== "interrupted")
|
|
2253
|
+
await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : "failed"); await store.updateState((current) => ({ ...current, error: { code: typed.code, message: typed.message } })); throw typed; });
|
|
2254
|
+
runs.get(runId).completion = finish;
|
|
2255
|
+
if (background) {
|
|
2256
|
+
void finish.then(async ({ value, resultPath }) => {
|
|
2257
|
+
emitAsyncComplete(store, "complete");
|
|
2258
|
+
deliver(pi, completionDelivery(checked.metadata.name, value, resultPath, await store.changedWorktrees()));
|
|
2259
|
+
}, (error) => { emitAsyncComplete(store, lifecycle.state === "stopped" ? "stopped" : "failed", error instanceof Error ? error : new Error(errorText(error))); deliverFailure(pi, checked.metadata.name, error); });
|
|
2260
|
+
return { content: [{ type: "text", text: JSON.stringify({ runId, state: "running" }) }], details: { runId, preview: `Started workflow ${runId}.` } };
|
|
2261
|
+
}
|
|
2262
|
+
const { value } = await finish;
|
|
2263
|
+
const run = (await store.load()).run;
|
|
2264
|
+
return { content: [{ type: "text", text: JSON.stringify(value) }], details: { runId, value, run } };
|
|
2265
|
+
}
|
|
2266
|
+
catch (error) {
|
|
2267
|
+
throw mainAgentError(error);
|
|
2268
|
+
}
|
|
2269
|
+
},
|
|
2270
|
+
renderCall(args) {
|
|
2271
|
+
return textBlock(formatWorkflowPreview(args));
|
|
2272
|
+
},
|
|
2273
|
+
renderResult(result, { isPartial }, _theme, context) {
|
|
2274
|
+
const details = result.details;
|
|
2275
|
+
const state = context.state;
|
|
2276
|
+
if (details?.run && isPartial && details.run.state === "running" && !state.workflowSpinner) {
|
|
2277
|
+
state.workflowSpinner = setInterval(context.invalidate, 80);
|
|
2278
|
+
state.workflowSpinner.unref();
|
|
2279
|
+
}
|
|
2280
|
+
else if ((!isPartial || details?.run?.state !== "running") && state.workflowSpinner) {
|
|
2281
|
+
clearInterval(state.workflowSpinner);
|
|
2282
|
+
delete state.workflowSpinner;
|
|
2283
|
+
}
|
|
2284
|
+
if (details?.run)
|
|
2285
|
+
return workflowProgressBlock(details.run);
|
|
2286
|
+
const content = result.content[0];
|
|
2287
|
+
return textBlock(isPartial ? "Workflow starting..." : details?.preview ?? (content?.type === "text" ? content.text : "Workflow finished"));
|
|
2288
|
+
},
|
|
2289
|
+
});
|
|
2290
|
+
pi.registerCommand("workflow", {
|
|
2291
|
+
description: "Inspect and control workflows for this Pi session",
|
|
2292
|
+
handler: async (args, ctx) => {
|
|
2293
|
+
const command = args.trim();
|
|
2294
|
+
if (command === "doctor") {
|
|
2295
|
+
const { doctor, doctorExitCode, formatDoctorReport } = await import("./doctor.js");
|
|
2296
|
+
const report = await doctor({ cwd: ctx.cwd, activeTools: pi.getActiveTools().filter((tool) => tool !== "workflow" && tool !== "workflow_respond") });
|
|
2297
|
+
ctx.ui.notify(formatDoctorReport(report), doctorExitCode(report) ? "warning" : "info");
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
await ensureSessionLease(ctx.cwd, ctx.sessionManager.getSessionId());
|
|
2301
|
+
const loadStores = async () => {
|
|
2302
|
+
const entries = await Promise.all((await listRunIds(ctx.cwd, ctx.sessionManager.getSessionId(), home)).map(async (runId) => {
|
|
2303
|
+
const store = new RunStore(ctx.cwd, ctx.sessionManager.getSessionId(), runId, home);
|
|
2304
|
+
try {
|
|
2305
|
+
return { store, loaded: await store.load() };
|
|
2306
|
+
}
|
|
2307
|
+
catch {
|
|
2308
|
+
if (!await store.isComplete())
|
|
2309
|
+
await store.delete(true).catch(() => undefined);
|
|
2310
|
+
return undefined;
|
|
2311
|
+
}
|
|
2312
|
+
}));
|
|
2313
|
+
return entries.filter((entry) => entry !== undefined);
|
|
2314
|
+
};
|
|
2315
|
+
let stores = await loadStores();
|
|
2316
|
+
const usage = "Usage: /workflow [doctor], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint]";
|
|
2317
|
+
const setWorkflowStatus = (text) => {
|
|
2318
|
+
const setStatus = ctx.ui.setStatus;
|
|
2319
|
+
setStatus?.call(ctx.ui, "workflow-stop", text);
|
|
2320
|
+
};
|
|
2321
|
+
const runAction = async (actionCommand, keepContext, status = setWorkflowStatus) => {
|
|
2322
|
+
const [action, runId, ...rest] = actionCommand.split(/\s+/);
|
|
2323
|
+
try {
|
|
2324
|
+
const run = runId ? runs.get(runId) : undefined;
|
|
2325
|
+
const storedEntry = runId ? stores.find(({ store }) => store.runId === runId) : undefined;
|
|
2326
|
+
const stored = storedEntry ? { store: storedEntry.store, loaded: await storedEntry.store.load() } : undefined;
|
|
2327
|
+
if ((action === "approve" || action === "reject") && runId && rest.length) {
|
|
2328
|
+
const accepted = await answerCheckpoint(runId, rest.join(" "), action === "approve", true);
|
|
2329
|
+
ctx.ui.notify(accepted ? `${action === "approve" ? "Approved" : "Rejected"} checkpoint ${rest.join(" ")}.` : "Checkpoint is not awaiting a response.", accepted ? "info" : "warning");
|
|
2330
|
+
return keepContext ? "dashboard" : "done";
|
|
2331
|
+
}
|
|
2332
|
+
if (action === "delete" && stored) {
|
|
2333
|
+
if (!["completed", "failed", "stopped"].includes(stored.loaded.run.state)) {
|
|
2334
|
+
ctx.ui.notify("Stop the workflow before deleting it.", "warning");
|
|
2335
|
+
return keepContext ? "dashboard" : "done";
|
|
2336
|
+
}
|
|
2337
|
+
if (!await ctx.ui.confirm("Delete workflow?", `Delete ${stored.loaded.run.workflowName} (${stored.store.runId}) and all owned artifacts? This cannot be undone.`))
|
|
2338
|
+
return keepContext ? "dashboard" : "done";
|
|
2339
|
+
await stored.store.delete(true);
|
|
2340
|
+
runs.delete(stored.store.runId);
|
|
2341
|
+
ctx.ui.notify(`Deleted workflow ${stored.store.runId}.`, "info");
|
|
2342
|
+
return keepContext ? "picker" : "done";
|
|
2343
|
+
}
|
|
2344
|
+
if (action === "pause" && run) {
|
|
2345
|
+
await run.lifecycle.pause();
|
|
2346
|
+
ctx.ui.notify(`Paused workflow ${run.store.runId}.`, "info");
|
|
2347
|
+
return keepContext ? "dashboard" : "done";
|
|
2348
|
+
}
|
|
2349
|
+
if (action === "resume" && run) {
|
|
2350
|
+
if (run.lifecycle.state === "interrupted")
|
|
2351
|
+
await coldResumeRun(run, ctx.hasUI, ctx.ui, projectTrusted(ctx));
|
|
2352
|
+
else
|
|
2353
|
+
await run.lifecycle.resume();
|
|
2354
|
+
ctx.ui.notify(`Resumed workflow ${run.store.runId}.`, "info");
|
|
2355
|
+
return keepContext ? "dashboard" : "done";
|
|
2356
|
+
}
|
|
2357
|
+
if (action === "stop" && run) {
|
|
2358
|
+
const workflowName = stored?.loaded.run.workflowName ?? run.metadata.name;
|
|
2359
|
+
if (keepContext && !await ctx.ui.confirm("Stop workflow?", `Stop workflow ${workflowName} (${run.store.runId})? This cannot be undone.`))
|
|
2360
|
+
return "dashboard";
|
|
2361
|
+
if (keepContext)
|
|
2362
|
+
status(`Stopping workflow ${workflowName}...`);
|
|
2363
|
+
await run.lifecycle.terminal("stopped");
|
|
2364
|
+
run.abortController.abort();
|
|
2365
|
+
run.execution?.cancel();
|
|
2366
|
+
await scheduler.cancelRun(run.store.runId);
|
|
2367
|
+
await scheduler.flush();
|
|
2368
|
+
if (keepContext)
|
|
2369
|
+
status(`Workflow ${run.store.runId} stopped.`);
|
|
2370
|
+
ctx.ui.notify(`Stopped workflow ${run.store.runId}.`, "info");
|
|
2371
|
+
return keepContext ? "dashboard" : "done";
|
|
2372
|
+
}
|
|
2373
|
+
if (keepContext && action && runId) {
|
|
2374
|
+
ctx.ui.notify(`Cannot ${action} workflow ${runId}: the run is no longer available.`, "warning");
|
|
2375
|
+
return "dashboard";
|
|
2376
|
+
}
|
|
2377
|
+
ctx.ui.notify(usage, "warning");
|
|
2378
|
+
return "done";
|
|
2379
|
+
}
|
|
2380
|
+
catch (error) {
|
|
2381
|
+
if (!keepContext)
|
|
2382
|
+
throw error;
|
|
2383
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2384
|
+
if (action === "stop")
|
|
2385
|
+
status(`Could not stop workflow ${runId ?? ""}: ${message}`);
|
|
2386
|
+
ctx.ui.notify(`Cannot ${action ?? "workflow action"}${runId ? ` for ${runId}` : ""}: ${message}`, "warning");
|
|
2387
|
+
return "dashboard";
|
|
2388
|
+
}
|
|
2389
|
+
};
|
|
2390
|
+
if (!command) {
|
|
2391
|
+
for (;;) {
|
|
2392
|
+
if (!stores.length) {
|
|
2393
|
+
ctx.ui.notify("No workflow runs in this session.", "info");
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
if (!ctx.hasUI) {
|
|
2397
|
+
const details = await Promise.all(stores.map(async ({ store, loaded }) => formatNavigatorRun(loaded, await store.awaitingCheckpoints(), await store.worktrees())));
|
|
2398
|
+
ctx.ui.notify(details.join("\n\n"), "info");
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const sorted = navigatorAttentionSort(stores);
|
|
2402
|
+
const labels = navigatorRunLabels(sorted);
|
|
2403
|
+
const terminalStates = new Set(["completed", "failed", "stopped"]);
|
|
2404
|
+
const hasCompleted = sorted.some(({ loaded: { run } }) => run.state === "completed");
|
|
2405
|
+
const pickerOptions = [...labels, ...(hasCompleted ? ["Delete all completed"] : []), "Close"];
|
|
2406
|
+
const runChoice = await ctx.ui.select("Workflows\n", pickerOptions);
|
|
2407
|
+
if (!runChoice || runChoice === "Close")
|
|
2408
|
+
return;
|
|
2409
|
+
if (runChoice === "Delete all completed") {
|
|
2410
|
+
if (!await ctx.ui.confirm("Delete completed runs?", "Delete all completed workflow runs and their artifacts? This cannot be undone."))
|
|
2411
|
+
continue;
|
|
2412
|
+
for (const entry of sorted) {
|
|
2413
|
+
if (entry.loaded.run.state === "completed") {
|
|
2414
|
+
await entry.store.delete(true);
|
|
2415
|
+
runs.delete(entry.store.runId);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
ctx.ui.notify("Deleted all completed workflow runs.", "info");
|
|
2419
|
+
stores = await loadStores();
|
|
2420
|
+
continue;
|
|
2421
|
+
}
|
|
2422
|
+
const runIndex = labels.indexOf(runChoice);
|
|
2423
|
+
if (runIndex < 0)
|
|
2424
|
+
return;
|
|
2425
|
+
const selected = sorted[runIndex];
|
|
2426
|
+
if (!selected)
|
|
2427
|
+
return;
|
|
2428
|
+
const { store } = selected;
|
|
2429
|
+
const copyArtifact = async (value, artifact) => {
|
|
2430
|
+
try {
|
|
2431
|
+
await clipboard(value);
|
|
2432
|
+
ctx.ui.notify(`Copied ${artifact}.`, "info");
|
|
2433
|
+
}
|
|
2434
|
+
catch (error) {
|
|
2435
|
+
ctx.ui.notify(`Failed to copy ${artifact}: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
const loadDashboard = async () => {
|
|
2439
|
+
const loaded = await store.load();
|
|
2440
|
+
const checkpoints = await store.awaitingCheckpoints();
|
|
2441
|
+
const worktrees = await store.worktrees();
|
|
2442
|
+
const actions = new Map();
|
|
2443
|
+
const copies = new Map();
|
|
2444
|
+
const reviews = new Map();
|
|
2445
|
+
const add = (label, value) => { actions.set(label, `${value} ${store.runId}`); };
|
|
2446
|
+
const addCopy = (label, value, artifact) => { actions.set(label, "copy"); copies.set(label, { value, artifact }); };
|
|
2447
|
+
if (loaded.run.state === "running")
|
|
2448
|
+
add("Pause", "pause");
|
|
2449
|
+
if (["paused", "interrupted"].includes(loaded.run.state))
|
|
2450
|
+
add("Resume", "resume");
|
|
2451
|
+
if (!terminalStates.has(loaded.run.state))
|
|
2452
|
+
add("Stop", "stop");
|
|
2453
|
+
for (const cp of checkpoints) {
|
|
2454
|
+
if (ctx.mode === "tui") {
|
|
2455
|
+
const label = `Review ${cp.name}`;
|
|
2456
|
+
actions.set(label, "review");
|
|
2457
|
+
reviews.set(label, cp);
|
|
2458
|
+
}
|
|
2459
|
+
else {
|
|
2460
|
+
actions.set(`Approve ${cp.name}`, `approve ${store.runId} ${cp.name}`);
|
|
2461
|
+
actions.set(`Reject ${cp.name}`, `reject ${store.runId} ${cp.name}`);
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
if (ctx.mode !== "tui")
|
|
2465
|
+
actions.set("Refresh", "refresh");
|
|
2466
|
+
else
|
|
2467
|
+
actions.set("View script", "view-script");
|
|
2468
|
+
const transcripts = [...new Set([...loaded.run.agents.flatMap((agent) => (agent.attemptDetails ?? []).map((attempt) => attempt.sessionFile)), ...loaded.run.nativeSessions.map(({ sessionFile }) => sessionFile)])];
|
|
2469
|
+
if (ctx.mode === "tui" && transcripts.length)
|
|
2470
|
+
actions.set("View transcript", "view-transcript");
|
|
2471
|
+
if (transcripts.length)
|
|
2472
|
+
actions.set("Transcript paths", "transcripts");
|
|
2473
|
+
if (terminalStates.has(loaded.run.state))
|
|
2474
|
+
add("Delete", "delete");
|
|
2475
|
+
if (ctx.mode === "tui") {
|
|
2476
|
+
addCopy("Copy run path", store.directory, "run path");
|
|
2477
|
+
addCopy("Copy run ID", store.runId, "run ID");
|
|
2478
|
+
for (const worktree of worktrees) {
|
|
2479
|
+
addCopy(`Copy branch (${worktree.owner})`, worktree.branch, "branch");
|
|
2480
|
+
addCopy(`Copy worktree path (${worktree.owner})`, worktree.path, "worktree path");
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return { dashboard: formatNavigatorDashboard(loaded.run, checkpoints, worktrees), actions, copies, reviews, transcripts, script: loaded.snapshot.script };
|
|
2484
|
+
};
|
|
2485
|
+
for (;;) {
|
|
2486
|
+
let view = await loadDashboard();
|
|
2487
|
+
const actionChoice = ctx.mode === "tui"
|
|
2488
|
+
? await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
2489
|
+
let options = [...view.actions.keys(), "Close"];
|
|
2490
|
+
let selectedIndex = 0;
|
|
2491
|
+
let dashboardOffset = 0;
|
|
2492
|
+
let refreshing = false;
|
|
2493
|
+
let disposed = false;
|
|
2494
|
+
let stopRequested = false;
|
|
2495
|
+
let stopStatus;
|
|
2496
|
+
const terminalRows = () => Math.max(1, (tui.terminal?.rows) ?? 24);
|
|
2497
|
+
const keyLabels = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
|
|
2498
|
+
const keyLabel = (binding, fallback) => {
|
|
2499
|
+
const getKeys = keybindings.getKeys;
|
|
2500
|
+
const keys = getKeys?.call(keybindings, binding);
|
|
2501
|
+
return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
|
|
2502
|
+
};
|
|
2503
|
+
const dashboardLayout = () => {
|
|
2504
|
+
const rows = terminalRows();
|
|
2505
|
+
const hintRows = rows >= 4 ? 1 : 0;
|
|
2506
|
+
const separatorRows = rows >= 6 ? 1 : 0;
|
|
2507
|
+
const available = Math.max(1, rows - hintRows - separatorRows);
|
|
2508
|
+
const actionViewport = Math.min(options.length, Math.max(1, Math.floor(available / 2)));
|
|
2509
|
+
return { rows, hintRows, separatorRows, actionViewport, dashboardViewport: available - actionViewport };
|
|
2510
|
+
};
|
|
2511
|
+
const updateDashboard = async (selectedOption) => {
|
|
2512
|
+
const next = await loadDashboard();
|
|
2513
|
+
if (disposed)
|
|
2514
|
+
return;
|
|
2515
|
+
view = next;
|
|
2516
|
+
options = [...view.actions.keys(), "Close"];
|
|
2517
|
+
selectedIndex = Math.max(0, options.indexOf(selectedOption ?? ""));
|
|
2518
|
+
tui.requestRender();
|
|
2519
|
+
};
|
|
2520
|
+
const requestStop = () => {
|
|
2521
|
+
if (stopRequested)
|
|
2522
|
+
return;
|
|
2523
|
+
stopRequested = true;
|
|
2524
|
+
stopStatus = undefined;
|
|
2525
|
+
setWorkflowStatus(undefined);
|
|
2526
|
+
const selectedOption = options[selectedIndex];
|
|
2527
|
+
void runAction(`stop ${store.runId}`, true, (status) => {
|
|
2528
|
+
stopStatus = status;
|
|
2529
|
+
setWorkflowStatus(status);
|
|
2530
|
+
if (!disposed)
|
|
2531
|
+
tui.requestRender();
|
|
2532
|
+
}).then(() => updateDashboard(selectedOption)).catch((error) => {
|
|
2533
|
+
if (disposed)
|
|
2534
|
+
return;
|
|
2535
|
+
stopStatus = `Could not stop workflow ${store.runId}: ${error instanceof Error ? error.message : String(error)}`;
|
|
2536
|
+
tui.requestRender();
|
|
2537
|
+
}).finally(() => {
|
|
2538
|
+
stopRequested = false;
|
|
2539
|
+
if (!disposed)
|
|
2540
|
+
tui.requestRender();
|
|
2541
|
+
});
|
|
2542
|
+
};
|
|
2543
|
+
const timer = setInterval(() => {
|
|
2544
|
+
if (refreshing || stopRequested)
|
|
2545
|
+
return;
|
|
2546
|
+
refreshing = true;
|
|
2547
|
+
const selectedOption = options[selectedIndex];
|
|
2548
|
+
void updateDashboard(selectedOption).catch(() => undefined).finally(() => { refreshing = false; });
|
|
2549
|
+
}, 1000);
|
|
2550
|
+
timer.unref();
|
|
2551
|
+
return {
|
|
2552
|
+
render(width) {
|
|
2553
|
+
const dashboard = stopStatus ? `${view.dashboard}\n\n${stopStatus}` : view.dashboard;
|
|
2554
|
+
const dashboardLines = truncateToVisualLines(theme.fg("accent", dashboard), Number.MAX_SAFE_INTEGER, width, 1).visualLines;
|
|
2555
|
+
const actionLines = options.map((option, index) => truncateToVisualLines(`${index === selectedIndex ? "→ " : " "}${option}`, Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "");
|
|
2556
|
+
const layout = dashboardLayout();
|
|
2557
|
+
const hint = truncateToVisualLines(theme.fg("dim", `${keyLabel("tui.select.up", "↑")}/${keyLabel("tui.select.down", "↓")} navigate${dashboardLines.length > layout.dashboardViewport ? ` · ${keyLabel("tui.select.pageUp", "pgup")}/${keyLabel("tui.select.pageDown", "pgdn")} scroll` : ""} · ${keyLabel("tui.select.confirm", "enter")} select · ${keyLabel("tui.select.cancel", "esc")} close · auto-refresh 1s`), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2558
|
+
const compact = [...dashboardLines, "", ...actionLines, "", hint];
|
|
2559
|
+
if (compact.length <= layout.rows) {
|
|
2560
|
+
dashboardOffset = 0;
|
|
2561
|
+
return compact;
|
|
2562
|
+
}
|
|
2563
|
+
const maxOffset = Math.max(0, dashboardLines.length - layout.dashboardViewport);
|
|
2564
|
+
dashboardOffset = Math.max(0, Math.min(maxOffset, dashboardOffset));
|
|
2565
|
+
const actionStart = Math.min(Math.max(0, selectedIndex - layout.actionViewport + 1), Math.max(0, options.length - layout.actionViewport));
|
|
2566
|
+
return [
|
|
2567
|
+
...dashboardLines.slice(dashboardOffset, dashboardOffset + layout.dashboardViewport),
|
|
2568
|
+
...(layout.separatorRows && layout.dashboardViewport ? [""] : []),
|
|
2569
|
+
...actionLines.slice(actionStart, actionStart + layout.actionViewport),
|
|
2570
|
+
...(layout.hintRows ? [hint] : []),
|
|
2571
|
+
];
|
|
2572
|
+
},
|
|
2573
|
+
invalidate() { },
|
|
2574
|
+
handleInput(data) {
|
|
2575
|
+
if (stopRequested)
|
|
2576
|
+
return;
|
|
2577
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
2578
|
+
selectedIndex = (selectedIndex + options.length - 1) % options.length;
|
|
2579
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
2580
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
2581
|
+
else if (keybindings.matches(data, "tui.select.pageUp")) {
|
|
2582
|
+
dashboardOffset = Math.max(0, dashboardOffset - Math.max(1, dashboardLayout().dashboardViewport));
|
|
2583
|
+
}
|
|
2584
|
+
else if (keybindings.matches(data, "tui.select.pageDown")) {
|
|
2585
|
+
dashboardOffset += Math.max(1, dashboardLayout().dashboardViewport);
|
|
2586
|
+
}
|
|
2587
|
+
else if (keybindings.matches(data, "tui.select.confirm")) {
|
|
2588
|
+
if (options[selectedIndex] === "Stop")
|
|
2589
|
+
requestStop();
|
|
2590
|
+
else
|
|
2591
|
+
done(options[selectedIndex]);
|
|
2592
|
+
}
|
|
2593
|
+
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
2594
|
+
done(undefined);
|
|
2595
|
+
tui.requestRender();
|
|
2596
|
+
},
|
|
2597
|
+
dispose() { disposed = true; clearInterval(timer); setWorkflowStatus(undefined); },
|
|
2598
|
+
};
|
|
2599
|
+
}, { overlay: true })
|
|
2600
|
+
: await ctx.ui.select(view.dashboard, [...view.actions.keys(), "Close"]);
|
|
2601
|
+
if (!actionChoice || actionChoice === "Close")
|
|
2602
|
+
return;
|
|
2603
|
+
if (actionChoice === "Refresh")
|
|
2604
|
+
continue;
|
|
2605
|
+
if (actionChoice === "View script") {
|
|
2606
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
2607
|
+
const highlighted = highlightCode(view.script, "javascript");
|
|
2608
|
+
let offset = 0;
|
|
2609
|
+
let renderedLines = [];
|
|
2610
|
+
const viewport = () => Math.max(1, ((tui.terminal?.rows) ?? 24) - 3);
|
|
2611
|
+
const move = (delta) => {
|
|
2612
|
+
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2613
|
+
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2614
|
+
};
|
|
2615
|
+
return {
|
|
2616
|
+
render(width) {
|
|
2617
|
+
renderedLines = highlighted.flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2618
|
+
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2619
|
+
offset = Math.min(offset, maxOffset);
|
|
2620
|
+
return [
|
|
2621
|
+
theme.fg("accent", "Workflow script"),
|
|
2622
|
+
...renderedLines.slice(offset, offset + viewport()),
|
|
2623
|
+
"",
|
|
2624
|
+
theme.fg("dim", "↑↓/pgup/pgdn scroll · esc close"),
|
|
2625
|
+
];
|
|
2626
|
+
},
|
|
2627
|
+
invalidate() { },
|
|
2628
|
+
handleInput(data) {
|
|
2629
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
2630
|
+
move(-1);
|
|
2631
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
2632
|
+
move(1);
|
|
2633
|
+
else if (keybindings.matches(data, "tui.select.pageUp"))
|
|
2634
|
+
move(-viewport());
|
|
2635
|
+
else if (keybindings.matches(data, "tui.select.pageDown"))
|
|
2636
|
+
move(viewport());
|
|
2637
|
+
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
2638
|
+
done(undefined);
|
|
2639
|
+
tui.requestRender();
|
|
2640
|
+
},
|
|
2641
|
+
};
|
|
2642
|
+
});
|
|
2643
|
+
continue;
|
|
2644
|
+
}
|
|
2645
|
+
if (actionChoice === "View transcript") {
|
|
2646
|
+
const transcript = await ctx.ui.select("Native Pi transcripts", [...view.transcripts, "Back"]);
|
|
2647
|
+
if (transcript && transcript !== "Back") {
|
|
2648
|
+
try {
|
|
2649
|
+
const entries = SessionManager.open(transcript).buildContextEntries();
|
|
2650
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
2651
|
+
let offset;
|
|
2652
|
+
let renderedLines = [];
|
|
2653
|
+
const terminalRows = () => Math.max(1, (tui.terminal?.rows) ?? 24);
|
|
2654
|
+
const viewport = () => Math.max(1, terminalRows() - 3);
|
|
2655
|
+
const move = (delta) => {
|
|
2656
|
+
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2657
|
+
offset = Math.max(0, Math.min(maxOffset, (offset ?? maxOffset) + delta));
|
|
2658
|
+
};
|
|
2659
|
+
return {
|
|
2660
|
+
render(width) {
|
|
2661
|
+
renderedLines = transcriptLines(entries).flatMap((line) => line ? truncateToVisualLines(line, Number.MAX_SAFE_INTEGER, width, 0).visualLines : [""]);
|
|
2662
|
+
const maxOffset = Math.max(0, renderedLines.length - viewport());
|
|
2663
|
+
offset = Math.min(offset ?? maxOffset, maxOffset);
|
|
2664
|
+
return [
|
|
2665
|
+
theme.fg("accent", "Native Pi transcript"),
|
|
2666
|
+
...renderedLines.slice(offset, offset + viewport()),
|
|
2667
|
+
"",
|
|
2668
|
+
theme.fg("dim", "↑↓/pgup/pgdn scroll · Home/End jump · esc close"),
|
|
2669
|
+
];
|
|
2670
|
+
},
|
|
2671
|
+
invalidate() { },
|
|
2672
|
+
handleInput(data) {
|
|
2673
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
2674
|
+
move(-1);
|
|
2675
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
2676
|
+
move(1);
|
|
2677
|
+
else if (keybindings.matches(data, "tui.select.pageUp"))
|
|
2678
|
+
move(-viewport());
|
|
2679
|
+
else if (keybindings.matches(data, "tui.select.pageDown"))
|
|
2680
|
+
move(viewport());
|
|
2681
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineStart"))
|
|
2682
|
+
offset = 0;
|
|
2683
|
+
else if (keybindings.matches(data, "tui.editor.cursorLineEnd"))
|
|
2684
|
+
offset = Math.max(0, renderedLines.length - viewport());
|
|
2685
|
+
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
2686
|
+
done(undefined);
|
|
2687
|
+
tui.requestRender();
|
|
2688
|
+
},
|
|
2689
|
+
};
|
|
2690
|
+
}, { overlay: true });
|
|
2691
|
+
}
|
|
2692
|
+
catch (error) {
|
|
2693
|
+
ctx.ui.notify(`Cannot open transcript ${transcript}: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
continue;
|
|
2697
|
+
}
|
|
2698
|
+
if (actionChoice === "Transcript paths") {
|
|
2699
|
+
const transcript = await ctx.ui.select("Native Pi transcript paths", [...view.transcripts, "Back"]);
|
|
2700
|
+
if (transcript && transcript !== "Back") {
|
|
2701
|
+
if (ctx.mode === "tui")
|
|
2702
|
+
await copyArtifact(transcript, "transcript path");
|
|
2703
|
+
else
|
|
2704
|
+
ctx.ui.notify(transcript, "info");
|
|
2705
|
+
}
|
|
2706
|
+
continue;
|
|
2707
|
+
}
|
|
2708
|
+
const copy = view.copies.get(actionChoice);
|
|
2709
|
+
if (copy) {
|
|
2710
|
+
await copyArtifact(copy.value, copy.artifact);
|
|
2711
|
+
continue;
|
|
2712
|
+
}
|
|
2713
|
+
if (actionChoice.startsWith("Review ")) {
|
|
2714
|
+
const checkpoint = view.reviews.get(actionChoice);
|
|
2715
|
+
if (!checkpoint)
|
|
2716
|
+
continue;
|
|
2717
|
+
const decision = await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
2718
|
+
const options = ["Approve", "Reject", "Cancel"];
|
|
2719
|
+
let selectedIndex = 0;
|
|
2720
|
+
let offset = 0;
|
|
2721
|
+
let renderedLines = [];
|
|
2722
|
+
const layout = () => {
|
|
2723
|
+
const rows = Math.max(1, ((tui.terminal?.rows) ?? 24));
|
|
2724
|
+
const compactControls = rows < 4;
|
|
2725
|
+
const titleRows = rows >= 5 ? 1 : 0;
|
|
2726
|
+
const hintRows = rows >= 8 ? 1 : 0;
|
|
2727
|
+
const separatorRows = rows >= 8 ? 2 : 0;
|
|
2728
|
+
const controlRows = compactControls ? 1 : options.length;
|
|
2729
|
+
const contentViewport = Math.max(0, rows - titleRows - hintRows - separatorRows - controlRows);
|
|
2730
|
+
return { rows, compactControls, titleRows, hintRows, separatorRows, contentViewport };
|
|
2731
|
+
};
|
|
2732
|
+
const move = (delta) => {
|
|
2733
|
+
const maxOffset = Math.max(0, renderedLines.length - layout().contentViewport);
|
|
2734
|
+
offset = Math.max(0, Math.min(maxOffset, offset + delta));
|
|
2735
|
+
};
|
|
2736
|
+
return {
|
|
2737
|
+
render(width) {
|
|
2738
|
+
renderedLines = truncateToVisualLines(formatCheckpointReview(checkpoint), Number.MAX_SAFE_INTEGER, width, 0).visualLines;
|
|
2739
|
+
const currentLayout = layout();
|
|
2740
|
+
const maxOffset = Math.max(0, renderedLines.length - currentLayout.contentViewport);
|
|
2741
|
+
offset = Math.min(offset, maxOffset);
|
|
2742
|
+
const hint = truncateToVisualLines(theme.fg("dim", "↑↓/pgup/pgdn scroll · enter select · esc cancel"), Number.MAX_SAFE_INTEGER, width, 1).visualLines[0] ?? "";
|
|
2743
|
+
const controls = currentLayout.compactControls
|
|
2744
|
+
? [options.map((option, index) => `${index === selectedIndex ? "[" : " "}${option}${index === selectedIndex ? "]" : " "}`).join(" ")]
|
|
2745
|
+
: options.map((option, index) => `${index === selectedIndex ? "→ " : " "}${option}`);
|
|
2746
|
+
return [
|
|
2747
|
+
...(currentLayout.titleRows ? [theme.fg("accent", "Checkpoint review")] : []),
|
|
2748
|
+
...renderedLines.slice(offset, offset + currentLayout.contentViewport),
|
|
2749
|
+
...(currentLayout.separatorRows ? [""] : []),
|
|
2750
|
+
...controls,
|
|
2751
|
+
...(currentLayout.separatorRows ? [""] : []),
|
|
2752
|
+
...(currentLayout.hintRows ? [hint] : []),
|
|
2753
|
+
];
|
|
2754
|
+
},
|
|
2755
|
+
invalidate() { },
|
|
2756
|
+
handleInput(data) {
|
|
2757
|
+
if (keybindings.matches(data, "tui.select.up"))
|
|
2758
|
+
selectedIndex = (selectedIndex + options.length - 1) % options.length;
|
|
2759
|
+
else if (keybindings.matches(data, "tui.select.down"))
|
|
2760
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
2761
|
+
else if (keybindings.matches(data, "tui.select.pageUp"))
|
|
2762
|
+
move(-layout().contentViewport);
|
|
2763
|
+
else if (keybindings.matches(data, "tui.select.pageDown"))
|
|
2764
|
+
move(layout().contentViewport);
|
|
2765
|
+
else if (keybindings.matches(data, "tui.select.confirm"))
|
|
2766
|
+
done(options[selectedIndex] === "Cancel" ? undefined : options[selectedIndex]);
|
|
2767
|
+
else if (keybindings.matches(data, "tui.select.cancel"))
|
|
2768
|
+
done(undefined);
|
|
2769
|
+
tui.requestRender();
|
|
2770
|
+
},
|
|
2771
|
+
};
|
|
2772
|
+
});
|
|
2773
|
+
if (decision) {
|
|
2774
|
+
const accepted = await answerCheckpoint(store.runId, checkpoint.name, decision === "Approve", true);
|
|
2775
|
+
if (!accepted)
|
|
2776
|
+
ctx.ui.notify("Checkpoint is not awaiting a response.", "warning");
|
|
2777
|
+
}
|
|
2778
|
+
continue;
|
|
2779
|
+
}
|
|
2780
|
+
const actionCommand = view.actions.get(actionChoice);
|
|
2781
|
+
if (!actionCommand) {
|
|
2782
|
+
ctx.ui.notify(`Cannot select workflow action: ${actionChoice}`, "warning");
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
const outcome = await runAction(actionCommand, true);
|
|
2786
|
+
if (outcome === "picker") {
|
|
2787
|
+
stores = await loadStores();
|
|
2788
|
+
break;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2793
|
+
await runAction(command, false);
|
|
2794
|
+
},
|
|
2795
|
+
});
|
|
2796
|
+
pi.on("session_shutdown", async () => {
|
|
2797
|
+
try {
|
|
2798
|
+
await Promise.all([...runs.entries()].map(async ([runId, run]) => {
|
|
2799
|
+
if (["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2800
|
+
await run.completion?.catch(() => undefined);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) {
|
|
2804
|
+
try {
|
|
2805
|
+
await run.lifecycle.terminal("interrupted");
|
|
2806
|
+
}
|
|
2807
|
+
catch (error) {
|
|
2808
|
+
if (!["completed", "failed", "stopped"].includes(run.lifecycle.state))
|
|
2809
|
+
throw error;
|
|
2810
|
+
}
|
|
2811
|
+
run.abortController.abort();
|
|
2812
|
+
run.execution?.cancel();
|
|
2813
|
+
await scheduler.cancelRun(runId);
|
|
2814
|
+
}
|
|
2815
|
+
await run.completion?.catch(() => undefined);
|
|
2816
|
+
}));
|
|
2817
|
+
await scheduler.flush();
|
|
2818
|
+
}
|
|
2819
|
+
finally {
|
|
2820
|
+
await releaseSessionLease();
|
|
2821
|
+
resetWorkflowRegistry();
|
|
2822
|
+
}
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
function displayAgentName(label, role, model) {
|
|
2826
|
+
return label ?? role ?? model.model;
|
|
2827
|
+
}
|
|
2828
|
+
function modelSpec(value, fallback) {
|
|
2829
|
+
try {
|
|
2830
|
+
const parsed = parseModelReference(value);
|
|
2831
|
+
return { ...parsed, ...(parsed.thinking || !fallback.thinking ? {} : { thinking: fallback.thinking }) };
|
|
2832
|
+
}
|
|
2833
|
+
catch {
|
|
2834
|
+
return fallback;
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
export { acquireSessionLease, projectStorageKey, RunStore, runsDirectory, SessionLease, structuralPath } from "./persistence.js";
|
|
2838
|
+
export { FairAgentScheduler, WorkflowAgentExecutor } from "./agent-execution.js";
|
|
2839
|
+
export { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
|