@wichayutdew/pi-workflows 0.1.1
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/LICENSE +201 -0
- package/README.md +752 -0
- package/agents/step.md +17 -0
- package/dist/index.js +4576 -0
- package/examples/mr-comments.workflow.yaml +115 -0
- package/examples/prompts/mr-comments/implement.md +8 -0
- package/examples/prompts/mr-comments/inspect.md +5 -0
- package/examples/prompts/mr-comments/plan.md +13 -0
- package/examples/prompts/mr-comments/verify.md +7 -0
- package/examples/settings.yaml +19 -0
- package/package.json +81 -0
- package/schemas/settings.schema.json +22 -0
- package/schemas/workflow.schema.json +585 -0
- package/src/command-names.ts +46 -0
- package/src/commands.ts +80 -0
- package/src/config/ceiling.ts +153 -0
- package/src/config/command-conflicts.ts +31 -0
- package/src/config/load.ts +327 -0
- package/src/config/types.ts +187 -0
- package/src/config/validate.ts +1145 -0
- package/src/digest.ts +23 -0
- package/src/engine/checkpoint.ts +30 -0
- package/src/engine/resume.ts +44 -0
- package/src/engine/state.ts +186 -0
- package/src/engine/transitions.ts +426 -0
- package/src/harness.ts +1676 -0
- package/src/index.ts +15 -0
- package/src/integrations/plannotator.ts +235 -0
- package/src/integrations/prompt-gate.ts +54 -0
- package/src/integrations/subagents/child-runtime.ts +306 -0
- package/src/integrations/subagents/client.ts +239 -0
- package/src/integrations/subagents/protocol.ts +304 -0
- package/src/policy/approved-commands.ts +225 -0
- package/src/policy/bash.ts +355 -0
- package/src/policy/completion-batch.ts +36 -0
- package/src/policy/immutable-input.ts +18 -0
- package/src/policy/tools.ts +150 -0
- package/src/preflight.ts +76 -0
- package/src/prompt.ts +146 -0
- package/src/runtime/completion-tool.ts +22 -0
- package/src/runtime/main-step-runtime.ts +227 -0
- package/src/runtime/serial-task-queue.ts +17 -0
- package/src/runtime/step-result.ts +85 -0
- package/src/workflow-list.ts +25 -0
- package/src/workflow-status.ts +611 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4576 @@
|
|
|
1
|
+
// src/harness.ts
|
|
2
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { readFile as readFile2, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
6
|
+
import { join as join2 } from "node:path";
|
|
7
|
+
|
|
8
|
+
// src/commands.ts
|
|
9
|
+
function splitFirst(value) {
|
|
10
|
+
const trimmed = value.trim();
|
|
11
|
+
const separator = trimmed.search(/\s/);
|
|
12
|
+
if (separator === -1)
|
|
13
|
+
return [trimmed, ""];
|
|
14
|
+
return [trimmed.slice(0, separator), trimmed.slice(separator).trim()];
|
|
15
|
+
}
|
|
16
|
+
function registerHarnessCommands(pi, controller) {
|
|
17
|
+
pi.registerCommand("workflow-list", {
|
|
18
|
+
description: "List loaded declarative workflows",
|
|
19
|
+
handler: async (_args, ctx) => controller.list(ctx)
|
|
20
|
+
});
|
|
21
|
+
pi.registerCommand("workflow-start", {
|
|
22
|
+
description: "Start a workflow: /workflow-start <id> [input]",
|
|
23
|
+
getArgumentCompletions: (prefix) => {
|
|
24
|
+
const items = controller.workflowIds().filter((id) => id.startsWith(prefix)).map((id) => ({ value: id, label: id }));
|
|
25
|
+
return items.length > 0 ? items : null;
|
|
26
|
+
},
|
|
27
|
+
handler: async (args, ctx) => {
|
|
28
|
+
const [workflowId, input] = splitFirst(args);
|
|
29
|
+
if (!workflowId) {
|
|
30
|
+
ctx.ui.notify("Usage: /workflow-start <id> [input]", "warning");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await controller.start(workflowId, input, ctx);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
pi.registerCommand("workflow-pause", {
|
|
37
|
+
description: "Pause the active workflow without losing its checkpoint",
|
|
38
|
+
handler: async (reason, ctx) => controller.pause(reason.trim(), ctx)
|
|
39
|
+
});
|
|
40
|
+
pi.registerCommand("workflow-resume", {
|
|
41
|
+
description: "Reload configuration and resume the paused workflow",
|
|
42
|
+
handler: async (_args, ctx) => controller.resume(ctx)
|
|
43
|
+
});
|
|
44
|
+
pi.registerCommand("workflow-abort", {
|
|
45
|
+
description: "Abort the active workflow",
|
|
46
|
+
handler: async (reason, ctx) => controller.abort(reason.trim(), ctx)
|
|
47
|
+
});
|
|
48
|
+
pi.registerCommand("workflow-reload", {
|
|
49
|
+
description: "Reload workflow files while no workflow is running",
|
|
50
|
+
handler: async (_args, ctx) => controller.reload(ctx)
|
|
51
|
+
});
|
|
52
|
+
pi.registerCommand("workflow-status", {
|
|
53
|
+
description: "Open the active workflow status board",
|
|
54
|
+
handler: async (_args, ctx) => controller.status(ctx)
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// src/config/load.ts
|
|
59
|
+
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
60
|
+
import { homedir } from "node:os";
|
|
61
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
62
|
+
import { parseDocument } from "yaml";
|
|
63
|
+
|
|
64
|
+
// src/digest.ts
|
|
65
|
+
import { createHash } from "node:crypto";
|
|
66
|
+
function canonicalize(value) {
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return value.map(canonicalize);
|
|
69
|
+
}
|
|
70
|
+
if (value !== null && typeof value === "object") {
|
|
71
|
+
return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, child]) => [key, canonicalize(child)]));
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function digest(value) {
|
|
76
|
+
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/config/ceiling.ts
|
|
80
|
+
function selectorAllowed(requested, ceiling) {
|
|
81
|
+
const separator = requested.indexOf("/");
|
|
82
|
+
if (separator === -1)
|
|
83
|
+
return ceiling.includes(requested);
|
|
84
|
+
const server = requested.slice(0, separator);
|
|
85
|
+
return ceiling.includes(server) || ceiling.includes(requested);
|
|
86
|
+
}
|
|
87
|
+
function ruleKey(rule) {
|
|
88
|
+
return JSON.stringify([rule.executable, rule.argsPrefix]);
|
|
89
|
+
}
|
|
90
|
+
function bashWithinCeiling(requested, ceiling) {
|
|
91
|
+
if (ceiling.mode === "unrestricted")
|
|
92
|
+
return true;
|
|
93
|
+
if (requested.mode === "deny")
|
|
94
|
+
return true;
|
|
95
|
+
if (ceiling.mode === "deny")
|
|
96
|
+
return false;
|
|
97
|
+
if (ceiling.mode === "read-only")
|
|
98
|
+
return requested.mode === "read-only";
|
|
99
|
+
if (requested.mode !== "allow-list")
|
|
100
|
+
return false;
|
|
101
|
+
const allowedRules = new Set(ceiling.allow.map(ruleKey));
|
|
102
|
+
const allowedSources = new Set(ceiling.approvedSources ?? []);
|
|
103
|
+
return requested.allow.every((rule) => allowedRules.has(ruleKey(rule))) && (requested.approvedSources ?? []).every((source) => allowedSources.has(source));
|
|
104
|
+
}
|
|
105
|
+
function checkWorkflowAgainstCeiling(workflow, ceiling) {
|
|
106
|
+
const errors = [];
|
|
107
|
+
for (const [stepId, step] of Object.entries(workflow.steps)) {
|
|
108
|
+
const path = `workflow.steps.${stepId}.permissions`;
|
|
109
|
+
const subagentPath = `workflow.steps.${stepId}.subagent`;
|
|
110
|
+
for (const tool of step.permissions.tools) {
|
|
111
|
+
if (!ceiling.tools.includes(tool)) {
|
|
112
|
+
errors.push(`${path}.tools: "${tool}" exceeds the user permission ceiling`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const selector of step.permissions.mcp) {
|
|
116
|
+
if (!selectorAllowed(selector, ceiling.mcp)) {
|
|
117
|
+
errors.push(`${path}.mcp: "${selector}" exceeds the user permission ceiling`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const extension of step.permissions.extensions) {
|
|
121
|
+
if (!ceiling.extensions.includes(extension)) {
|
|
122
|
+
errors.push(`${path}.extensions: "${extension}" exceeds the user permission ceiling`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const skill of step.permissions.skills) {
|
|
126
|
+
if (!ceiling.skills.includes(skill)) {
|
|
127
|
+
errors.push(`${path}.skills: "${skill}" exceeds the user permission ceiling`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!bashWithinCeiling(step.permissions.bash, ceiling.bash)) {
|
|
131
|
+
errors.push(`${path}.bash: exceeds the user permission ceiling`);
|
|
132
|
+
}
|
|
133
|
+
if (!step.subagent)
|
|
134
|
+
continue;
|
|
135
|
+
if (!ceiling.subagent) {
|
|
136
|
+
errors.push(`${subagentPath}: subagent execution exceeds the user permission ceiling`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!ceiling.subagent.agents.includes(step.subagent.agent)) {
|
|
140
|
+
errors.push(`${subagentPath}.agent: "${step.subagent.agent}" exceeds the user permission ceiling`);
|
|
141
|
+
}
|
|
142
|
+
if (!ceiling.subagent.contexts.includes(step.subagent.context)) {
|
|
143
|
+
errors.push(`${subagentPath}.context: "${step.subagent.context}" exceeds the user permission ceiling`);
|
|
144
|
+
}
|
|
145
|
+
if (step.subagent.model && !ceiling.subagent.models.includes(step.subagent.model)) {
|
|
146
|
+
errors.push(`${subagentPath}.model: "${step.subagent.model}" exceeds the user permission ceiling`);
|
|
147
|
+
}
|
|
148
|
+
if (step.subagent.timeoutMs > ceiling.subagent.maxTimeoutMs) {
|
|
149
|
+
errors.push(`${subagentPath}.timeoutMs: exceeds the user permission ceiling`);
|
|
150
|
+
}
|
|
151
|
+
if (step.subagent.artifacts && !ceiling.subagent.artifacts) {
|
|
152
|
+
errors.push(`${subagentPath}.artifacts: exceeds the user permission ceiling`);
|
|
153
|
+
}
|
|
154
|
+
if (!step.subagent.turnBudget) {
|
|
155
|
+
errors.push(`${subagentPath}.turnBudget: required for a project workflow`);
|
|
156
|
+
} else {
|
|
157
|
+
if (step.subagent.turnBudget.maxTurns > ceiling.subagent.maxTurns) {
|
|
158
|
+
errors.push(`${subagentPath}.turnBudget.maxTurns: exceeds the user permission ceiling`);
|
|
159
|
+
}
|
|
160
|
+
if ((step.subagent.turnBudget.graceTurns ?? 0) > ceiling.subagent.maxGraceTurns) {
|
|
161
|
+
errors.push(`${subagentPath}.turnBudget.graceTurns: exceeds the user permission ceiling`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!step.subagent.toolBudget) {
|
|
165
|
+
errors.push(`${subagentPath}.toolBudget: required for a project workflow`);
|
|
166
|
+
} else {
|
|
167
|
+
if (step.subagent.toolBudget.hard > ceiling.subagent.maxToolCalls) {
|
|
168
|
+
errors.push(`${subagentPath}.toolBudget.hard: exceeds the user permission ceiling`);
|
|
169
|
+
}
|
|
170
|
+
if (step.subagent.toolBudget.block !== "*") {
|
|
171
|
+
errors.push(`${subagentPath}.toolBudget.block: must be "*" for a project workflow`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return errors;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/config/types.ts
|
|
179
|
+
var WORKFLOW_SCHEMA_VERSION = 1;
|
|
180
|
+
var SUBAGENT_RUNTIME_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)*$/;
|
|
181
|
+
var EMPTY_PERMISSIONS = {
|
|
182
|
+
tools: [],
|
|
183
|
+
mcp: [],
|
|
184
|
+
extensions: [],
|
|
185
|
+
skills: [],
|
|
186
|
+
bash: { mode: "deny", allow: [] }
|
|
187
|
+
};
|
|
188
|
+
var DEFAULT_STEP_SUBAGENT = {
|
|
189
|
+
agent: "pi-workflows.step",
|
|
190
|
+
context: "fresh",
|
|
191
|
+
timeoutMs: 900000,
|
|
192
|
+
artifacts: false
|
|
193
|
+
};
|
|
194
|
+
var DEFAULT_SETTINGS = {
|
|
195
|
+
version: WORKFLOW_SCHEMA_VERSION,
|
|
196
|
+
allowProjectWorkflows: false
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// src/command-names.ts
|
|
200
|
+
var HARNESS_COMMAND_NAMES = [
|
|
201
|
+
"workflow-abort",
|
|
202
|
+
"workflow-list",
|
|
203
|
+
"workflow-pause",
|
|
204
|
+
"workflow-reload",
|
|
205
|
+
"workflow-resume",
|
|
206
|
+
"workflow-start",
|
|
207
|
+
"workflow-status"
|
|
208
|
+
];
|
|
209
|
+
var PI_BUILTIN_COMMAND_NAMES = [
|
|
210
|
+
"arminsayshi",
|
|
211
|
+
"changelog",
|
|
212
|
+
"clone",
|
|
213
|
+
"compact",
|
|
214
|
+
"copy",
|
|
215
|
+
"debug",
|
|
216
|
+
"dementedelves",
|
|
217
|
+
"export",
|
|
218
|
+
"fork",
|
|
219
|
+
"hotkeys",
|
|
220
|
+
"import",
|
|
221
|
+
"login",
|
|
222
|
+
"logout",
|
|
223
|
+
"model",
|
|
224
|
+
"name",
|
|
225
|
+
"new",
|
|
226
|
+
"quit",
|
|
227
|
+
"reload",
|
|
228
|
+
"resume",
|
|
229
|
+
"scoped-models",
|
|
230
|
+
"session",
|
|
231
|
+
"settings",
|
|
232
|
+
"share",
|
|
233
|
+
"tree",
|
|
234
|
+
"trust"
|
|
235
|
+
];
|
|
236
|
+
var RESERVED_COMMAND_NAMES = new Set([
|
|
237
|
+
...HARNESS_COMMAND_NAMES,
|
|
238
|
+
...PI_BUILTIN_COMMAND_NAMES
|
|
239
|
+
]);
|
|
240
|
+
|
|
241
|
+
// src/config/validate.ts
|
|
242
|
+
var IDENTIFIER_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
243
|
+
var OUTCOME_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
244
|
+
var TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/;
|
|
245
|
+
var RESOURCE_SELECTOR_PATTERN = /^[A-Za-z0-9_@./:+-]+$/;
|
|
246
|
+
var MCP_SELECTOR_PATTERN = /^[A-Za-z0-9_.:-]+(?:\/[A-Za-z0-9_.:-]+)?$/;
|
|
247
|
+
var EXECUTABLE_PATTERN = /^[A-Za-z0-9_./+-]+$/;
|
|
248
|
+
var BASH_APPROVAL_SOURCE_PATTERN = /^(verification-worker|verification-reviewer|remote-actions)$/;
|
|
249
|
+
var PROMPT_VARIABLES = new Set([
|
|
250
|
+
"workflow.input",
|
|
251
|
+
"workflow.id",
|
|
252
|
+
"run.id",
|
|
253
|
+
"step.id",
|
|
254
|
+
"step.title",
|
|
255
|
+
"last.summary",
|
|
256
|
+
"gate.feedback"
|
|
257
|
+
]);
|
|
258
|
+
function isObject(value) {
|
|
259
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
260
|
+
}
|
|
261
|
+
function rejectUnknownKeys(value, allowed, path, errors) {
|
|
262
|
+
const allowedSet = new Set(allowed);
|
|
263
|
+
for (const key of Object.keys(value)) {
|
|
264
|
+
if (!allowedSet.has(key)) {
|
|
265
|
+
errors.push(`${path}: unknown property "${key}"`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function readString(value, path, errors, options = {}) {
|
|
270
|
+
if (typeof value !== "string") {
|
|
271
|
+
errors.push(`${path}: expected a string`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const result = value.trim();
|
|
275
|
+
if ((options.nonEmpty ?? true) && !result) {
|
|
276
|
+
errors.push(`${path}: must not be empty`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (options.pattern && !options.pattern.test(result)) {
|
|
280
|
+
errors.push(`${path}: invalid value "${result}"`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
function readInteger(value, fallback, path, errors, limits) {
|
|
286
|
+
if (value === undefined)
|
|
287
|
+
return fallback;
|
|
288
|
+
if (!Number.isInteger(value) || value < limits.min || value > limits.max) {
|
|
289
|
+
errors.push(`${path}: expected an integer from ${limits.min} to ${limits.max}`);
|
|
290
|
+
return fallback;
|
|
291
|
+
}
|
|
292
|
+
return value;
|
|
293
|
+
}
|
|
294
|
+
function readBoolean(value, fallback, path, errors) {
|
|
295
|
+
if (value === undefined)
|
|
296
|
+
return fallback;
|
|
297
|
+
if (typeof value !== "boolean") {
|
|
298
|
+
errors.push(`${path}: expected a boolean`);
|
|
299
|
+
return fallback;
|
|
300
|
+
}
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
function readStringList(value, path, errors, pattern) {
|
|
304
|
+
if (value === undefined)
|
|
305
|
+
return [];
|
|
306
|
+
if (!Array.isArray(value)) {
|
|
307
|
+
errors.push(`${path}: expected an array of strings`);
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
const result = [];
|
|
311
|
+
const seen = new Set;
|
|
312
|
+
value.forEach((item, index) => {
|
|
313
|
+
const parsed = readString(item, `${path}[${index}]`, errors, { pattern });
|
|
314
|
+
if (!parsed)
|
|
315
|
+
return;
|
|
316
|
+
if (seen.has(parsed)) {
|
|
317
|
+
errors.push(`${path}[${index}]: duplicate value "${parsed}"`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
seen.add(parsed);
|
|
321
|
+
result.push(parsed);
|
|
322
|
+
});
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
function parseBashRule(value, path, errors) {
|
|
326
|
+
if (!isObject(value)) {
|
|
327
|
+
errors.push(`${path}: expected an object`);
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
rejectUnknownKeys(value, ["executable", "argsPrefix", "argsPrefixes"], path, errors);
|
|
331
|
+
const executable = readString(value.executable, `${path}.executable`, errors, {
|
|
332
|
+
pattern: EXECUTABLE_PATTERN
|
|
333
|
+
});
|
|
334
|
+
if (value.argsPrefix !== undefined && value.argsPrefixes !== undefined) {
|
|
335
|
+
errors.push(`${path}: argsPrefix and argsPrefixes are mutually exclusive`);
|
|
336
|
+
}
|
|
337
|
+
if (value.argsPrefixes !== undefined) {
|
|
338
|
+
if (!Array.isArray(value.argsPrefixes)) {
|
|
339
|
+
errors.push(`${path}.argsPrefixes: expected an array of argument arrays`);
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
if (value.argsPrefixes.length === 0) {
|
|
343
|
+
errors.push(`${path}.argsPrefixes: at least one prefix is required`);
|
|
344
|
+
}
|
|
345
|
+
const prefixes = [];
|
|
346
|
+
const seen = new Set;
|
|
347
|
+
value.argsPrefixes.forEach((candidate, index) => {
|
|
348
|
+
const prefixPath = `${path}.argsPrefixes[${index}]`;
|
|
349
|
+
const prefix = readStringList(candidate, prefixPath, errors, /^[^\s]+$/);
|
|
350
|
+
if (Array.isArray(candidate) && candidate.length === 0) {
|
|
351
|
+
errors.push(`${prefixPath}: at least one argument is required`);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (prefix.length === 0)
|
|
355
|
+
return;
|
|
356
|
+
const key = JSON.stringify(prefix);
|
|
357
|
+
if (seen.has(key)) {
|
|
358
|
+
errors.push(`${prefixPath}: duplicate argument prefix`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
seen.add(key);
|
|
362
|
+
prefixes.push(prefix);
|
|
363
|
+
});
|
|
364
|
+
return executable ? prefixes.map((argsPrefix2) => ({ executable, argsPrefix: argsPrefix2 })) : [];
|
|
365
|
+
}
|
|
366
|
+
const argsPrefix = readStringList(value.argsPrefix, `${path}.argsPrefix`, errors, /^[^\s]+$/);
|
|
367
|
+
return executable ? [{ executable, argsPrefix }] : [];
|
|
368
|
+
}
|
|
369
|
+
function parseBashPermission(value, path, errors) {
|
|
370
|
+
if (value === undefined) {
|
|
371
|
+
return { ...EMPTY_PERMISSIONS.bash, allow: [] };
|
|
372
|
+
}
|
|
373
|
+
if (!isObject(value)) {
|
|
374
|
+
errors.push(`${path}: expected an object`);
|
|
375
|
+
return { ...EMPTY_PERMISSIONS.bash, allow: [] };
|
|
376
|
+
}
|
|
377
|
+
rejectUnknownKeys(value, ["mode", "allow", "approvedSources"], path, errors);
|
|
378
|
+
const mode = readString(value.mode, `${path}.mode`, errors);
|
|
379
|
+
const validMode = mode === "deny" || mode === "read-only" || mode === "allow-list" || mode === "unrestricted";
|
|
380
|
+
if (!validMode) {
|
|
381
|
+
errors.push(`${path}.mode: expected deny, read-only, allow-list, or unrestricted`);
|
|
382
|
+
}
|
|
383
|
+
const allow = [];
|
|
384
|
+
if (value.allow !== undefined) {
|
|
385
|
+
if (!Array.isArray(value.allow)) {
|
|
386
|
+
errors.push(`${path}.allow: expected an array`);
|
|
387
|
+
} else {
|
|
388
|
+
value.allow.forEach((rule, index) => {
|
|
389
|
+
allow.push(...parseBashRule(rule, `${path}.allow[${index}]`, errors));
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const approvedSources = (value.approvedSources === undefined ? [] : readStringList(value.approvedSources, `${path}.approvedSources`, errors, BASH_APPROVAL_SOURCE_PATTERN)).filter((source) => {
|
|
394
|
+
const valid = source === "verification-worker" || source === "verification-reviewer" || source === "remote-actions";
|
|
395
|
+
if (!valid) {
|
|
396
|
+
errors.push(`${path}.approvedSources: expected verification-worker, verification-reviewer, or remote-actions`);
|
|
397
|
+
}
|
|
398
|
+
return valid;
|
|
399
|
+
});
|
|
400
|
+
const normalizedMode = validMode ? mode : "deny";
|
|
401
|
+
if (normalizedMode !== "allow-list" && allow.length > 0) {
|
|
402
|
+
errors.push(`${path}.allow: only valid when mode is "allow-list"`);
|
|
403
|
+
}
|
|
404
|
+
if (normalizedMode === "allow-list" && allow.length === 0) {
|
|
405
|
+
if (approvedSources.length === 0) {
|
|
406
|
+
errors.push(`${path}: allow-list mode requires an allow rule or an approved command source`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (normalizedMode !== "allow-list" && approvedSources.length > 0) {
|
|
410
|
+
errors.push(`${path}.approvedSources: only valid when mode is "allow-list"`);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
mode: normalizedMode,
|
|
414
|
+
allow,
|
|
415
|
+
...approvedSources.length > 0 ? { approvedSources } : {}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
function parsePermissions(value, path, errors) {
|
|
419
|
+
if (value === undefined) {
|
|
420
|
+
return {
|
|
421
|
+
tools: [],
|
|
422
|
+
mcp: [],
|
|
423
|
+
extensions: [],
|
|
424
|
+
skills: [],
|
|
425
|
+
bash: { mode: "deny", allow: [] }
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
if (!isObject(value)) {
|
|
429
|
+
errors.push(`${path}: expected an object`);
|
|
430
|
+
return {
|
|
431
|
+
tools: [],
|
|
432
|
+
mcp: [],
|
|
433
|
+
extensions: [],
|
|
434
|
+
skills: [],
|
|
435
|
+
bash: { mode: "deny", allow: [] }
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
rejectUnknownKeys(value, ["tools", "mcp", "extensions", "skills", "bash"], path, errors);
|
|
439
|
+
const permissions = {
|
|
440
|
+
tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
|
|
441
|
+
mcp: readStringList(value.mcp, `${path}.mcp`, errors, MCP_SELECTOR_PATTERN),
|
|
442
|
+
extensions: readStringList(value.extensions, `${path}.extensions`, errors, RESOURCE_SELECTOR_PATTERN),
|
|
443
|
+
skills: readStringList(value.skills, `${path}.skills`, errors, RESOURCE_SELECTOR_PATTERN),
|
|
444
|
+
bash: parseBashPermission(value.bash, `${path}.bash`, errors)
|
|
445
|
+
};
|
|
446
|
+
if (permissions.bash.mode !== "deny" && !permissions.tools.includes("bash")) {
|
|
447
|
+
errors.push(`${path}.tools: must include "bash" when Bash is enabled`);
|
|
448
|
+
}
|
|
449
|
+
return permissions;
|
|
450
|
+
}
|
|
451
|
+
function parseRequirements(value, permissions, path, errors) {
|
|
452
|
+
if (value === undefined) {
|
|
453
|
+
return {
|
|
454
|
+
tools: [],
|
|
455
|
+
extensions: [],
|
|
456
|
+
skills: []
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
if (!isObject(value)) {
|
|
460
|
+
errors.push(`${path}: expected an object`);
|
|
461
|
+
return {
|
|
462
|
+
tools: [],
|
|
463
|
+
extensions: [],
|
|
464
|
+
skills: []
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
rejectUnknownKeys(value, ["tools", "extensions", "skills"], path, errors);
|
|
468
|
+
const requirements = {
|
|
469
|
+
tools: readStringList(value.tools, `${path}.tools`, errors, TOOL_PATTERN),
|
|
470
|
+
extensions: readStringList(value.extensions, `${path}.extensions`, errors, RESOURCE_SELECTOR_PATTERN),
|
|
471
|
+
skills: readStringList(value.skills, `${path}.skills`, errors, RESOURCE_SELECTOR_PATTERN)
|
|
472
|
+
};
|
|
473
|
+
for (const tool of requirements.tools) {
|
|
474
|
+
if (!permissions.tools.includes(tool) && !(tool === "mcp" && permissions.mcp.length > 0)) {
|
|
475
|
+
errors.push(`${path}.tools: required tool "${tool}" is not allowed by this step`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const extension of requirements.extensions) {
|
|
479
|
+
if (!permissions.extensions.includes(extension)) {
|
|
480
|
+
errors.push(`${path}.extensions: required extension "${extension}" is not allowed by this step`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
for (const skill of requirements.skills) {
|
|
484
|
+
if (!permissions.skills.includes(skill)) {
|
|
485
|
+
errors.push(`${path}.skills: required skill "${skill}" is not allowed by this step`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return requirements;
|
|
489
|
+
}
|
|
490
|
+
function parseSubagentTurnBudget(value, path, errors) {
|
|
491
|
+
if (value === undefined)
|
|
492
|
+
return;
|
|
493
|
+
if (!isObject(value)) {
|
|
494
|
+
errors.push(`${path}: expected an object`);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
rejectUnknownKeys(value, ["maxTurns", "graceTurns"], path, errors);
|
|
498
|
+
const maxTurns = readInteger(value.maxTurns, 0, `${path}.maxTurns`, errors, {
|
|
499
|
+
min: 1,
|
|
500
|
+
max: 1000
|
|
501
|
+
});
|
|
502
|
+
const graceTurns = value.graceTurns === undefined ? undefined : readInteger(value.graceTurns, 0, `${path}.graceTurns`, errors, {
|
|
503
|
+
min: 0,
|
|
504
|
+
max: 100
|
|
505
|
+
});
|
|
506
|
+
if (maxTurns === 0)
|
|
507
|
+
return;
|
|
508
|
+
return {
|
|
509
|
+
maxTurns,
|
|
510
|
+
...graceTurns !== undefined ? { graceTurns } : {}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function parseSubagentToolBudget(value, path, errors) {
|
|
514
|
+
if (value === undefined)
|
|
515
|
+
return;
|
|
516
|
+
if (!isObject(value)) {
|
|
517
|
+
errors.push(`${path}: expected an object`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
rejectUnknownKeys(value, ["soft", "hard", "block"], path, errors);
|
|
521
|
+
const hard = readInteger(value.hard, 0, `${path}.hard`, errors, {
|
|
522
|
+
min: 1,
|
|
523
|
+
max: 1e5
|
|
524
|
+
});
|
|
525
|
+
const soft = value.soft === undefined ? undefined : readInteger(value.soft, 0, `${path}.soft`, errors, {
|
|
526
|
+
min: 1,
|
|
527
|
+
max: 1e5
|
|
528
|
+
});
|
|
529
|
+
let block;
|
|
530
|
+
if (value.block === "*") {
|
|
531
|
+
block = "*";
|
|
532
|
+
} else if (value.block !== undefined) {
|
|
533
|
+
block = readStringList(value.block, `${path}.block`, errors, TOOL_PATTERN);
|
|
534
|
+
if (block.length === 0) {
|
|
535
|
+
errors.push(`${path}.block: expected "*" or at least one tool name`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (soft !== undefined && hard > 0 && soft > hard) {
|
|
539
|
+
errors.push(`${path}.soft: must not exceed hard`);
|
|
540
|
+
}
|
|
541
|
+
if (hard === 0)
|
|
542
|
+
return;
|
|
543
|
+
return {
|
|
544
|
+
hard,
|
|
545
|
+
...soft !== undefined ? { soft } : {},
|
|
546
|
+
...block !== undefined ? { block } : {}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
function parseStepSubagent(value, path, errors) {
|
|
550
|
+
if (value === undefined)
|
|
551
|
+
return;
|
|
552
|
+
if (typeof value === "string") {
|
|
553
|
+
const agent2 = readString(value, path, errors, {
|
|
554
|
+
pattern: SUBAGENT_RUNTIME_NAME_PATTERN
|
|
555
|
+
}) ?? DEFAULT_STEP_SUBAGENT.agent;
|
|
556
|
+
return { ...DEFAULT_STEP_SUBAGENT, agent: agent2 };
|
|
557
|
+
}
|
|
558
|
+
if (!isObject(value)) {
|
|
559
|
+
errors.push(`${path}: expected a workflow subagent name or object`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
rejectUnknownKeys(value, [
|
|
563
|
+
"agent",
|
|
564
|
+
"context",
|
|
565
|
+
"model",
|
|
566
|
+
"timeoutMs",
|
|
567
|
+
"turnBudget",
|
|
568
|
+
"toolBudget",
|
|
569
|
+
"artifacts"
|
|
570
|
+
], path, errors);
|
|
571
|
+
const agent = value.agent === undefined ? DEFAULT_STEP_SUBAGENT.agent : readString(value.agent, `${path}.agent`, errors, {
|
|
572
|
+
pattern: SUBAGENT_RUNTIME_NAME_PATTERN
|
|
573
|
+
}) ?? DEFAULT_STEP_SUBAGENT.agent;
|
|
574
|
+
const contextValue = value.context === undefined ? DEFAULT_STEP_SUBAGENT.context : readString(value.context, `${path}.context`, errors);
|
|
575
|
+
const context = contextValue === "fork" || contextValue === "fresh" ? contextValue : DEFAULT_STEP_SUBAGENT.context;
|
|
576
|
+
if (contextValue !== "fork" && contextValue !== "fresh") {
|
|
577
|
+
errors.push(`${path}.context: expected fresh or fork`);
|
|
578
|
+
}
|
|
579
|
+
const model = value.model === undefined ? undefined : readString(value.model, `${path}.model`, errors, {
|
|
580
|
+
pattern: RESOURCE_SELECTOR_PATTERN
|
|
581
|
+
});
|
|
582
|
+
const timeoutMs = readInteger(value.timeoutMs, DEFAULT_STEP_SUBAGENT.timeoutMs, `${path}.timeoutMs`, errors, { min: 1000, max: 86400000 });
|
|
583
|
+
const turnBudget = parseSubagentTurnBudget(value.turnBudget, `${path}.turnBudget`, errors);
|
|
584
|
+
const toolBudget = parseSubagentToolBudget(value.toolBudget, `${path}.toolBudget`, errors);
|
|
585
|
+
const artifacts = readBoolean(value.artifacts, DEFAULT_STEP_SUBAGENT.artifacts, `${path}.artifacts`, errors);
|
|
586
|
+
return {
|
|
587
|
+
agent,
|
|
588
|
+
context,
|
|
589
|
+
...model ? { model } : {},
|
|
590
|
+
timeoutMs,
|
|
591
|
+
...turnBudget ? { turnBudget } : {},
|
|
592
|
+
...toolBudget ? { toolBudget } : {},
|
|
593
|
+
artifacts
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function parsePrompt(value, path, errors) {
|
|
597
|
+
if (typeof value === "string") {
|
|
598
|
+
const inline = readString(value, path, errors);
|
|
599
|
+
return inline ? { inline } : undefined;
|
|
600
|
+
}
|
|
601
|
+
if (!isObject(value)) {
|
|
602
|
+
errors.push(`${path}: expected a string or an object`);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
rejectUnknownKeys(value, ["file"], path, errors);
|
|
606
|
+
const file = readString(value.file, `${path}.file`, errors);
|
|
607
|
+
if (!file)
|
|
608
|
+
return;
|
|
609
|
+
if (file.startsWith("/") || file.includes("\x00")) {
|
|
610
|
+
errors.push(`${path}.file: expected a safe relative path`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
return { file };
|
|
614
|
+
}
|
|
615
|
+
function parseTransitions(value, path, errors) {
|
|
616
|
+
if (!isObject(value)) {
|
|
617
|
+
errors.push(`${path}: expected an object`);
|
|
618
|
+
return {};
|
|
619
|
+
}
|
|
620
|
+
const transitions = {};
|
|
621
|
+
for (const [outcome, targetValue] of Object.entries(value)) {
|
|
622
|
+
if (!OUTCOME_PATTERN.test(outcome)) {
|
|
623
|
+
errors.push(`${path}: invalid outcome "${outcome}"`);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const target = readString(targetValue, `${path}.${outcome}`, errors);
|
|
627
|
+
if (target)
|
|
628
|
+
transitions[outcome] = target;
|
|
629
|
+
}
|
|
630
|
+
if (Object.keys(transitions).length === 0) {
|
|
631
|
+
errors.push(`${path}: at least one transition is required`);
|
|
632
|
+
}
|
|
633
|
+
return transitions;
|
|
634
|
+
}
|
|
635
|
+
function parseGate(value, path, errors) {
|
|
636
|
+
if (value === undefined)
|
|
637
|
+
return;
|
|
638
|
+
if (!isObject(value)) {
|
|
639
|
+
errors.push(`${path}: expected an object`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
rejectUnknownKeys(value, [
|
|
643
|
+
"provider",
|
|
644
|
+
"submitOutcome",
|
|
645
|
+
"approvedOutcome",
|
|
646
|
+
"rejectedOutcome",
|
|
647
|
+
"timeoutMs"
|
|
648
|
+
], path, errors);
|
|
649
|
+
const providerValue = value.provider === undefined ? "prompt" : readString(value.provider, `${path}.provider`, errors);
|
|
650
|
+
const provider = providerValue === "prompt" || providerValue === "plannotator" ? providerValue : undefined;
|
|
651
|
+
if (!provider) {
|
|
652
|
+
errors.push(`${path}.provider: expected prompt or plannotator`);
|
|
653
|
+
}
|
|
654
|
+
const submitOutcome = readString(value.submitOutcome, `${path}.submitOutcome`, errors, {
|
|
655
|
+
pattern: OUTCOME_PATTERN
|
|
656
|
+
});
|
|
657
|
+
const approvedOutcome = readString(value.approvedOutcome, `${path}.approvedOutcome`, errors, {
|
|
658
|
+
pattern: OUTCOME_PATTERN
|
|
659
|
+
});
|
|
660
|
+
const rejectedOutcome = readString(value.rejectedOutcome, `${path}.rejectedOutcome`, errors, {
|
|
661
|
+
pattern: OUTCOME_PATTERN
|
|
662
|
+
});
|
|
663
|
+
if (provider === "prompt" && value.timeoutMs !== undefined) {
|
|
664
|
+
errors.push(`${path}.timeoutMs: only valid with provider "plannotator"`);
|
|
665
|
+
}
|
|
666
|
+
if (!submitOutcome || !approvedOutcome || !rejectedOutcome || !provider) {
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (approvedOutcome === rejectedOutcome) {
|
|
670
|
+
errors.push(`${path}: approvedOutcome and rejectedOutcome must differ`);
|
|
671
|
+
}
|
|
672
|
+
if (provider === "prompt") {
|
|
673
|
+
return {
|
|
674
|
+
provider,
|
|
675
|
+
submitOutcome,
|
|
676
|
+
approvedOutcome,
|
|
677
|
+
rejectedOutcome
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
provider,
|
|
682
|
+
submitOutcome,
|
|
683
|
+
approvedOutcome,
|
|
684
|
+
rejectedOutcome,
|
|
685
|
+
timeoutMs: readInteger(value.timeoutMs, 5000, `${path}.timeoutMs`, errors, {
|
|
686
|
+
min: 1000,
|
|
687
|
+
max: 30000
|
|
688
|
+
})
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function parseStep(value, stepId, path, errors) {
|
|
692
|
+
if (!isObject(value)) {
|
|
693
|
+
errors.push(`${path}: expected an object`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
rejectUnknownKeys(value, [
|
|
697
|
+
"title",
|
|
698
|
+
"prompt",
|
|
699
|
+
"subagent",
|
|
700
|
+
"permissions",
|
|
701
|
+
"requires",
|
|
702
|
+
"transitions",
|
|
703
|
+
"gate"
|
|
704
|
+
], path, errors);
|
|
705
|
+
const title = value.title === undefined ? stepId : readString(value.title, `${path}.title`, errors);
|
|
706
|
+
const prompt = parsePrompt(value.prompt, `${path}.prompt`, errors);
|
|
707
|
+
const subagent = parseStepSubagent(value.subagent, `${path}.subagent`, errors);
|
|
708
|
+
const permissions = parsePermissions(value.permissions, `${path}.permissions`, errors);
|
|
709
|
+
const requires = parseRequirements(value.requires, permissions, `${path}.requires`, errors);
|
|
710
|
+
const transitions = parseTransitions(value.transitions, `${path}.transitions`, errors);
|
|
711
|
+
const gate = parseGate(value.gate, `${path}.gate`, errors);
|
|
712
|
+
if (gate) {
|
|
713
|
+
if (!Object.hasOwn(transitions, gate.approvedOutcome)) {
|
|
714
|
+
errors.push(`${path}.transitions: missing gate outcome "${gate.approvedOutcome}"`);
|
|
715
|
+
}
|
|
716
|
+
if (!Object.hasOwn(transitions, gate.rejectedOutcome)) {
|
|
717
|
+
errors.push(`${path}.transitions: missing gate outcome "${gate.rejectedOutcome}"`);
|
|
718
|
+
}
|
|
719
|
+
if (Object.hasOwn(transitions, gate.submitOutcome)) {
|
|
720
|
+
errors.push(`${path}.transitions: submitOutcome is handled by the gate and must not be a transition`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (!title || !prompt)
|
|
724
|
+
return;
|
|
725
|
+
return {
|
|
726
|
+
title,
|
|
727
|
+
prompt,
|
|
728
|
+
...subagent ? { subagent } : {},
|
|
729
|
+
permissions,
|
|
730
|
+
requires,
|
|
731
|
+
transitions,
|
|
732
|
+
...gate ? { gate } : {}
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
function validatePromptText(text, path) {
|
|
736
|
+
const errors = [];
|
|
737
|
+
for (const match of text.matchAll(/\{\{([^{}]+)\}\}/g)) {
|
|
738
|
+
const variable = match[1]?.trim() ?? "";
|
|
739
|
+
if (!PROMPT_VARIABLES.has(variable)) {
|
|
740
|
+
errors.push(`${path}: unknown prompt variable "{{${variable}}}"`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return errors;
|
|
744
|
+
}
|
|
745
|
+
function validateWorkflow(value) {
|
|
746
|
+
const errors = [];
|
|
747
|
+
if (!isObject(value)) {
|
|
748
|
+
return { errors: ["workflow: expected an object"] };
|
|
749
|
+
}
|
|
750
|
+
rejectUnknownKeys(value, [
|
|
751
|
+
"$schema",
|
|
752
|
+
"version",
|
|
753
|
+
"id",
|
|
754
|
+
"command",
|
|
755
|
+
"description",
|
|
756
|
+
"start",
|
|
757
|
+
"maxStepVisits",
|
|
758
|
+
"summaryMaxChars",
|
|
759
|
+
"steps"
|
|
760
|
+
], "workflow", errors);
|
|
761
|
+
if (value.$schema !== undefined && typeof value.$schema !== "string") {
|
|
762
|
+
errors.push("workflow.$schema: expected a string");
|
|
763
|
+
}
|
|
764
|
+
if (value.version !== WORKFLOW_SCHEMA_VERSION) {
|
|
765
|
+
errors.push(`workflow.version: expected ${WORKFLOW_SCHEMA_VERSION}`);
|
|
766
|
+
}
|
|
767
|
+
const id = readString(value.id, "workflow.id", errors, {
|
|
768
|
+
pattern: IDENTIFIER_PATTERN
|
|
769
|
+
});
|
|
770
|
+
const command = readString(value.command, "workflow.command", errors, {
|
|
771
|
+
pattern: IDENTIFIER_PATTERN
|
|
772
|
+
});
|
|
773
|
+
const description = readString(value.description, "workflow.description", errors);
|
|
774
|
+
const start = readString(value.start, "workflow.start", errors, {
|
|
775
|
+
pattern: IDENTIFIER_PATTERN
|
|
776
|
+
});
|
|
777
|
+
const maxStepVisits = readInteger(value.maxStepVisits, 5, "workflow.maxStepVisits", errors, {
|
|
778
|
+
min: 1,
|
|
779
|
+
max: 100
|
|
780
|
+
});
|
|
781
|
+
const summaryMaxChars = readInteger(value.summaryMaxChars, 4000, "workflow.summaryMaxChars", errors, { min: 100, max: 50000 });
|
|
782
|
+
if (command && RESERVED_COMMAND_NAMES.has(command)) {
|
|
783
|
+
errors.push(`workflow.command: "${command}" is reserved by Pi or the harness`);
|
|
784
|
+
}
|
|
785
|
+
const steps = {};
|
|
786
|
+
if (!isObject(value.steps)) {
|
|
787
|
+
errors.push("workflow.steps: expected an object");
|
|
788
|
+
} else {
|
|
789
|
+
for (const [stepId, stepValue] of Object.entries(value.steps)) {
|
|
790
|
+
if (!IDENTIFIER_PATTERN.test(stepId)) {
|
|
791
|
+
errors.push(`workflow.steps: invalid step id "${stepId}"`);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
const step = parseStep(stepValue, stepId, `workflow.steps.${stepId}`, errors);
|
|
795
|
+
if (step)
|
|
796
|
+
steps[stepId] = step;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (Object.keys(steps).length === 0) {
|
|
800
|
+
errors.push("workflow.steps: at least one step is required");
|
|
801
|
+
}
|
|
802
|
+
if (start && !Object.hasOwn(steps, start)) {
|
|
803
|
+
errors.push(`workflow.start: unknown step "${start}"`);
|
|
804
|
+
}
|
|
805
|
+
for (const [stepId, step] of Object.entries(steps)) {
|
|
806
|
+
for (const [outcome, target] of Object.entries(step.transitions)) {
|
|
807
|
+
if (target !== "$done" && target !== "$pause" && !Object.hasOwn(steps, target)) {
|
|
808
|
+
errors.push(`workflow.steps.${stepId}.transitions.${outcome}: unknown target "${target}"`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (errors.length > 0 || !id || !command || !description || !start) {
|
|
813
|
+
return { errors };
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
value: {
|
|
817
|
+
version: WORKFLOW_SCHEMA_VERSION,
|
|
818
|
+
id,
|
|
819
|
+
command,
|
|
820
|
+
description,
|
|
821
|
+
start,
|
|
822
|
+
maxStepVisits,
|
|
823
|
+
summaryMaxChars,
|
|
824
|
+
steps
|
|
825
|
+
},
|
|
826
|
+
errors
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function parsePermissionCeiling(value, path, errors) {
|
|
830
|
+
if (value === undefined)
|
|
831
|
+
return;
|
|
832
|
+
if (!isObject(value)) {
|
|
833
|
+
errors.push(`${path}: expected an object`);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
rejectUnknownKeys(value, ["tools", "mcp", "extensions", "skills", "bash", "subagent"], path, errors);
|
|
837
|
+
const permissions = parsePermissions({
|
|
838
|
+
...value.tools !== undefined ? { tools: value.tools } : {},
|
|
839
|
+
...value.mcp !== undefined ? { mcp: value.mcp } : {},
|
|
840
|
+
...value.extensions !== undefined ? { extensions: value.extensions } : {},
|
|
841
|
+
...value.skills !== undefined ? { skills: value.skills } : {},
|
|
842
|
+
...value.bash !== undefined ? { bash: value.bash } : {}
|
|
843
|
+
}, path, errors);
|
|
844
|
+
const subagent = value.subagent === undefined ? undefined : parseSubagentPermissionCeiling(value.subagent, `${path}.subagent`, errors);
|
|
845
|
+
return {
|
|
846
|
+
...permissions,
|
|
847
|
+
...subagent ? { subagent } : {}
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function parseSubagentPermissionCeiling(value, path, errors) {
|
|
851
|
+
if (!isObject(value)) {
|
|
852
|
+
errors.push(`${path}: expected an object`);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
rejectUnknownKeys(value, [
|
|
856
|
+
"agents",
|
|
857
|
+
"contexts",
|
|
858
|
+
"models",
|
|
859
|
+
"maxTimeoutMs",
|
|
860
|
+
"maxTurns",
|
|
861
|
+
"maxGraceTurns",
|
|
862
|
+
"maxToolCalls",
|
|
863
|
+
"artifacts"
|
|
864
|
+
], path, errors);
|
|
865
|
+
const agents = readStringList(value.agents, `${path}.agents`, errors, SUBAGENT_RUNTIME_NAME_PATTERN);
|
|
866
|
+
const contexts = readStringList(value.contexts, `${path}.contexts`, errors, /^(?:fresh|fork)$/);
|
|
867
|
+
const models = readStringList(value.models, `${path}.models`, errors, RESOURCE_SELECTOR_PATTERN);
|
|
868
|
+
if (agents.length === 0) {
|
|
869
|
+
errors.push(`${path}.agents: at least one subagent is required`);
|
|
870
|
+
}
|
|
871
|
+
if (contexts.length === 0) {
|
|
872
|
+
errors.push(`${path}.contexts: at least one context mode is required`);
|
|
873
|
+
}
|
|
874
|
+
for (const field of [
|
|
875
|
+
"maxTimeoutMs",
|
|
876
|
+
"maxTurns",
|
|
877
|
+
"maxGraceTurns",
|
|
878
|
+
"maxToolCalls",
|
|
879
|
+
"artifacts"
|
|
880
|
+
]) {
|
|
881
|
+
if (value[field] === undefined) {
|
|
882
|
+
errors.push(`${path}.${field}: required`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const maxTimeoutMs = readInteger(value.maxTimeoutMs, 0, `${path}.maxTimeoutMs`, errors, { min: 1000, max: 86400000 });
|
|
886
|
+
const maxTurns = readInteger(value.maxTurns, 0, `${path}.maxTurns`, errors, {
|
|
887
|
+
min: 1,
|
|
888
|
+
max: 1000
|
|
889
|
+
});
|
|
890
|
+
const maxGraceTurns = readInteger(value.maxGraceTurns, 0, `${path}.maxGraceTurns`, errors, { min: 0, max: 100 });
|
|
891
|
+
const maxToolCalls = readInteger(value.maxToolCalls, 0, `${path}.maxToolCalls`, errors, { min: 1, max: 1e5 });
|
|
892
|
+
const artifacts = readBoolean(value.artifacts, false, `${path}.artifacts`, errors);
|
|
893
|
+
return {
|
|
894
|
+
agents,
|
|
895
|
+
contexts,
|
|
896
|
+
models,
|
|
897
|
+
maxTimeoutMs,
|
|
898
|
+
maxTurns,
|
|
899
|
+
maxGraceTurns,
|
|
900
|
+
maxToolCalls,
|
|
901
|
+
artifacts
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function validateSettings(value) {
|
|
905
|
+
const errors = [];
|
|
906
|
+
if (!isObject(value)) {
|
|
907
|
+
return { errors: ["settings: expected an object"] };
|
|
908
|
+
}
|
|
909
|
+
rejectUnknownKeys(value, ["$schema", "version", "allowProjectWorkflows", "permissionCeiling"], "settings", errors);
|
|
910
|
+
if (value.$schema !== undefined && typeof value.$schema !== "string") {
|
|
911
|
+
errors.push("settings.$schema: expected a string");
|
|
912
|
+
}
|
|
913
|
+
if (value.version !== WORKFLOW_SCHEMA_VERSION) {
|
|
914
|
+
errors.push(`settings.version: expected ${WORKFLOW_SCHEMA_VERSION}`);
|
|
915
|
+
}
|
|
916
|
+
let allowProjectWorkflows = false;
|
|
917
|
+
if (typeof value.allowProjectWorkflows === "boolean") {
|
|
918
|
+
allowProjectWorkflows = value.allowProjectWorkflows;
|
|
919
|
+
} else if (value.allowProjectWorkflows !== undefined) {
|
|
920
|
+
errors.push("settings.allowProjectWorkflows: expected a boolean");
|
|
921
|
+
}
|
|
922
|
+
const permissionCeiling = parsePermissionCeiling(value.permissionCeiling, "settings.permissionCeiling", errors);
|
|
923
|
+
if (allowProjectWorkflows && !permissionCeiling) {
|
|
924
|
+
errors.push("settings.permissionCeiling: required when project workflows are enabled");
|
|
925
|
+
}
|
|
926
|
+
if (errors.length > 0)
|
|
927
|
+
return { errors };
|
|
928
|
+
return {
|
|
929
|
+
value: {
|
|
930
|
+
...DEFAULT_SETTINGS,
|
|
931
|
+
allowProjectWorkflows,
|
|
932
|
+
...permissionCeiling ? { permissionCeiling } : {}
|
|
933
|
+
},
|
|
934
|
+
errors
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/config/load.ts
|
|
939
|
+
function diagnostic(path, message, level = "error") {
|
|
940
|
+
return { path, message, level };
|
|
941
|
+
}
|
|
942
|
+
async function readYaml(path, kind) {
|
|
943
|
+
const text = await readFile(path, "utf8");
|
|
944
|
+
const document = parseDocument(text, {
|
|
945
|
+
customTags: [],
|
|
946
|
+
merge: false,
|
|
947
|
+
prettyErrors: true,
|
|
948
|
+
resolveKnownTags: false,
|
|
949
|
+
schema: "core",
|
|
950
|
+
strict: true,
|
|
951
|
+
stringKeys: true,
|
|
952
|
+
uniqueKeys: true,
|
|
953
|
+
version: "1.2"
|
|
954
|
+
});
|
|
955
|
+
const issues = [...document.errors, ...document.warnings];
|
|
956
|
+
if (issues.length > 0) {
|
|
957
|
+
throw new Error(issues.map((issue) => issue.message).join(`
|
|
958
|
+
`));
|
|
959
|
+
}
|
|
960
|
+
if (document.directives.yaml.explicit && document.directives.yaml.version !== "1.2") {
|
|
961
|
+
throw new Error(`${kind} YAML must use version 1.2`);
|
|
962
|
+
}
|
|
963
|
+
return document.toJS({ maxAliasCount: 100 });
|
|
964
|
+
}
|
|
965
|
+
function isInside(root, candidate) {
|
|
966
|
+
const pathFromRoot = relative(root, candidate);
|
|
967
|
+
return pathFromRoot === "" || pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot);
|
|
968
|
+
}
|
|
969
|
+
async function loadPrompt(sourcePath, definition, stepId) {
|
|
970
|
+
const prompt = definition.steps[stepId]?.prompt;
|
|
971
|
+
if (!prompt)
|
|
972
|
+
throw new Error(`unknown step "${stepId}"`);
|
|
973
|
+
if ("inline" in prompt)
|
|
974
|
+
return prompt.inline;
|
|
975
|
+
const sourceDirectory = await realpath(dirname(sourcePath));
|
|
976
|
+
const requestedPath = resolve(sourceDirectory, prompt.file);
|
|
977
|
+
if (!isInside(sourceDirectory, requestedPath)) {
|
|
978
|
+
throw new Error(`prompt file escapes workflow directory: ${prompt.file}`);
|
|
979
|
+
}
|
|
980
|
+
const actualPath = await realpath(requestedPath);
|
|
981
|
+
if (!isInside(sourceDirectory, actualPath)) {
|
|
982
|
+
throw new Error(`prompt file symlink escapes workflow directory: ${prompt.file}`);
|
|
983
|
+
}
|
|
984
|
+
return readFile(actualPath, "utf8");
|
|
985
|
+
}
|
|
986
|
+
async function loadWorkflowFile(sourcePath, sourceKind) {
|
|
987
|
+
const raw = await readYaml(sourcePath, "workflow");
|
|
988
|
+
const validation = validateWorkflow(raw);
|
|
989
|
+
if (!validation.value) {
|
|
990
|
+
throw new Error(validation.errors.join(`
|
|
991
|
+
`));
|
|
992
|
+
}
|
|
993
|
+
const definition = validation.value;
|
|
994
|
+
const prompts = {};
|
|
995
|
+
for (const stepId of Object.keys(definition.steps)) {
|
|
996
|
+
const text = await loadPrompt(sourcePath, definition, stepId);
|
|
997
|
+
const promptErrors = validatePromptText(text, `workflow.steps.${stepId}.prompt`);
|
|
998
|
+
if (promptErrors.length > 0) {
|
|
999
|
+
throw new Error(promptErrors.join(`
|
|
1000
|
+
`));
|
|
1001
|
+
}
|
|
1002
|
+
prompts[stepId] = text;
|
|
1003
|
+
}
|
|
1004
|
+
const stepDigests = Object.fromEntries(Object.entries(definition.steps).map(([stepId, step]) => [
|
|
1005
|
+
stepId,
|
|
1006
|
+
digest({ step, prompt: prompts[stepId] })
|
|
1007
|
+
]));
|
|
1008
|
+
return {
|
|
1009
|
+
definition,
|
|
1010
|
+
prompts,
|
|
1011
|
+
digest: digest({ definition, prompts }),
|
|
1012
|
+
stepDigests,
|
|
1013
|
+
sourcePath,
|
|
1014
|
+
sourceKind
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
async function loadWorkflowDirectory(directory, sourceKind) {
|
|
1018
|
+
let entries;
|
|
1019
|
+
try {
|
|
1020
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
const code = error.code;
|
|
1023
|
+
if (code === "ENOENT")
|
|
1024
|
+
return { workflows: [], diagnostics: [] };
|
|
1025
|
+
return {
|
|
1026
|
+
workflows: [],
|
|
1027
|
+
diagnostics: [
|
|
1028
|
+
diagnostic(directory, `cannot read workflow directory: ${String(error)}`)
|
|
1029
|
+
]
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const workflows = [];
|
|
1033
|
+
const diagnostics = [];
|
|
1034
|
+
const files = entries.filter((entry) => entry.isFile() && /\.workflow\.ya?ml$/i.test(entry.name)).map((entry) => join(directory, entry.name)).sort();
|
|
1035
|
+
for (const path of files) {
|
|
1036
|
+
try {
|
|
1037
|
+
workflows.push(await loadWorkflowFile(path, sourceKind));
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
diagnostics.push(diagnostic(path, error instanceof Error ? error.message : String(error)));
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return { workflows, diagnostics };
|
|
1043
|
+
}
|
|
1044
|
+
async function loadSettings(userDirectory) {
|
|
1045
|
+
const path = join(userDirectory, "settings.yaml");
|
|
1046
|
+
try {
|
|
1047
|
+
const validation = validateSettings(await readYaml(path, "settings"));
|
|
1048
|
+
if (!validation.value) {
|
|
1049
|
+
return {
|
|
1050
|
+
settings: DEFAULT_SETTINGS,
|
|
1051
|
+
diagnostics: validation.errors.map((message) => diagnostic(path, message))
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
return { settings: validation.value, diagnostics: [] };
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
const code = error.code;
|
|
1057
|
+
if (code === "ENOENT")
|
|
1058
|
+
return { settings: DEFAULT_SETTINGS, diagnostics: [] };
|
|
1059
|
+
return {
|
|
1060
|
+
settings: DEFAULT_SETTINGS,
|
|
1061
|
+
diagnostics: [
|
|
1062
|
+
diagnostic(path, `cannot read settings: ${error instanceof Error ? error.message : String(error)}`)
|
|
1063
|
+
]
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
function addWorkflow(catalog, commands, workflow, diagnostics) {
|
|
1068
|
+
const id = workflow.definition.id;
|
|
1069
|
+
const command = workflow.definition.command;
|
|
1070
|
+
const existing = catalog.get(id);
|
|
1071
|
+
if (existing) {
|
|
1072
|
+
diagnostics.push(diagnostic(workflow.sourcePath, `workflow id "${id}" already belongs to ${existing.sourcePath}; overrides are not allowed`));
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const existingCommand = commands.get(command);
|
|
1076
|
+
if (existingCommand) {
|
|
1077
|
+
diagnostics.push(diagnostic(workflow.sourcePath, `command "/${command}" already belongs to workflow "${existingCommand}"`));
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
catalog.set(id, workflow);
|
|
1081
|
+
commands.set(command, id);
|
|
1082
|
+
}
|
|
1083
|
+
function defaultUserWorkflowDirectory() {
|
|
1084
|
+
const explicit = process.env.PI_WORKFLOWS_DIR?.trim();
|
|
1085
|
+
if (explicit)
|
|
1086
|
+
return resolve(explicit);
|
|
1087
|
+
const agentDirectory = process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent");
|
|
1088
|
+
return join(agentDirectory, "workflows");
|
|
1089
|
+
}
|
|
1090
|
+
async function loadCatalog(options) {
|
|
1091
|
+
const userDirectory = resolve(options.userDirectory ?? defaultUserWorkflowDirectory());
|
|
1092
|
+
const diagnostics = [];
|
|
1093
|
+
const settingsResult = await loadSettings(userDirectory);
|
|
1094
|
+
diagnostics.push(...settingsResult.diagnostics);
|
|
1095
|
+
const catalog = new Map;
|
|
1096
|
+
const commands = new Map;
|
|
1097
|
+
const userResult = await loadWorkflowDirectory(userDirectory, "user");
|
|
1098
|
+
diagnostics.push(...userResult.diagnostics);
|
|
1099
|
+
for (const workflow of userResult.workflows) {
|
|
1100
|
+
addWorkflow(catalog, commands, workflow, diagnostics);
|
|
1101
|
+
}
|
|
1102
|
+
const projectDirectory = join(resolve(options.cwd), ".pi", "workflows");
|
|
1103
|
+
if (settingsResult.settings.allowProjectWorkflows) {
|
|
1104
|
+
if (!options.projectTrusted) {
|
|
1105
|
+
diagnostics.push(diagnostic(projectDirectory, "project workflows were skipped because the project is not trusted", "warning"));
|
|
1106
|
+
} else if (!settingsResult.settings.permissionCeiling) {
|
|
1107
|
+
diagnostics.push(diagnostic(projectDirectory, "project workflows were skipped because no user permission ceiling is configured"));
|
|
1108
|
+
} else {
|
|
1109
|
+
const projectResult = await loadWorkflowDirectory(projectDirectory, "project");
|
|
1110
|
+
diagnostics.push(...projectResult.diagnostics);
|
|
1111
|
+
for (const workflow of projectResult.workflows) {
|
|
1112
|
+
const ceilingErrors = checkWorkflowAgainstCeiling(workflow.definition, settingsResult.settings.permissionCeiling);
|
|
1113
|
+
if (ceilingErrors.length > 0) {
|
|
1114
|
+
diagnostics.push(...ceilingErrors.map((message) => diagnostic(workflow.sourcePath, message)));
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
addWorkflow(catalog, commands, workflow, diagnostics);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return {
|
|
1122
|
+
workflows: catalog,
|
|
1123
|
+
settings: settingsResult.settings,
|
|
1124
|
+
diagnostics,
|
|
1125
|
+
userDirectory,
|
|
1126
|
+
...settingsResult.settings.allowProjectWorkflows ? { projectDirectory } : {}
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// src/config/command-conflicts.ts
|
|
1131
|
+
function isSuffixedInvocation(name, command) {
|
|
1132
|
+
if (!name.startsWith(`${command}:`))
|
|
1133
|
+
return false;
|
|
1134
|
+
return /^\d+$/.test(name.slice(command.length + 1));
|
|
1135
|
+
}
|
|
1136
|
+
function hasRuntimeCommandConflict(command, availableCommands, ownedAliases) {
|
|
1137
|
+
if (availableCommands.some((candidate) => isSuffixedInvocation(candidate.name, command))) {
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
return !ownedAliases.has(command) && availableCommands.some((candidate) => candidate.name === command);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// src/engine/transitions.ts
|
|
1144
|
+
function currentStep(workflow, run) {
|
|
1145
|
+
return workflow.definition.steps[run.currentStepId];
|
|
1146
|
+
}
|
|
1147
|
+
function withUpdate(run, changes, now) {
|
|
1148
|
+
return { ...run, ...changes, updatedAt: now };
|
|
1149
|
+
}
|
|
1150
|
+
function allowedOutcomes(workflow, run) {
|
|
1151
|
+
const step = currentStep(workflow, run);
|
|
1152
|
+
if (!step)
|
|
1153
|
+
return [];
|
|
1154
|
+
const gateResolutionOutcomes = step.gate ? new Set([step.gate.approvedOutcome, step.gate.rejectedOutcome]) : undefined;
|
|
1155
|
+
return [
|
|
1156
|
+
...Object.keys(step.transitions).filter((outcome) => !gateResolutionOutcomes?.has(outcome)),
|
|
1157
|
+
...step.gate ? [step.gate.submitOutcome] : []
|
|
1158
|
+
];
|
|
1159
|
+
}
|
|
1160
|
+
function pauseRun(run, reason, now) {
|
|
1161
|
+
if (run.status !== "running" && run.status !== "awaiting-gate") {
|
|
1162
|
+
return withUpdate(run, { pauseReason: reason || run.pauseReason }, now);
|
|
1163
|
+
}
|
|
1164
|
+
return withUpdate(run, {
|
|
1165
|
+
status: "paused",
|
|
1166
|
+
pausedFrom: run.status,
|
|
1167
|
+
pauseReason: reason || `Paused during step "${run.currentStepId}"`
|
|
1168
|
+
}, now);
|
|
1169
|
+
}
|
|
1170
|
+
function resumeRun(run, now) {
|
|
1171
|
+
if (run.status !== "paused")
|
|
1172
|
+
return run;
|
|
1173
|
+
return withUpdate(run, {
|
|
1174
|
+
status: run.pausedFrom ?? (run.pendingGate ? "awaiting-gate" : "running"),
|
|
1175
|
+
pauseReason: undefined,
|
|
1176
|
+
pausedFrom: undefined
|
|
1177
|
+
}, now);
|
|
1178
|
+
}
|
|
1179
|
+
function abortRun(run, reason, now) {
|
|
1180
|
+
return withUpdate(run, {
|
|
1181
|
+
status: "aborted",
|
|
1182
|
+
pauseReason: reason || "Aborted by user",
|
|
1183
|
+
pausedFrom: undefined,
|
|
1184
|
+
pendingGate: undefined
|
|
1185
|
+
}, now);
|
|
1186
|
+
}
|
|
1187
|
+
function advanceRun(workflow, run, outcome, summary, now) {
|
|
1188
|
+
if (run.status !== "running") {
|
|
1189
|
+
throw new Error(`workflow is ${run.status}; only a running workflow can advance`);
|
|
1190
|
+
}
|
|
1191
|
+
const step = currentStep(workflow, run);
|
|
1192
|
+
if (!step)
|
|
1193
|
+
throw new Error(`current step "${run.currentStepId}" no longer exists`);
|
|
1194
|
+
if (step.gate?.submitOutcome === outcome) {
|
|
1195
|
+
throw new Error(`outcome "${outcome}" must be submitted through the configured gate`);
|
|
1196
|
+
}
|
|
1197
|
+
const target = step.transitions[outcome];
|
|
1198
|
+
if (!target) {
|
|
1199
|
+
throw new Error(`outcome "${outcome}" is not valid for step "${run.currentStepId}"`);
|
|
1200
|
+
}
|
|
1201
|
+
if (target === "$pause") {
|
|
1202
|
+
return withUpdate(run, {
|
|
1203
|
+
status: "paused",
|
|
1204
|
+
pausedFrom: "running",
|
|
1205
|
+
pauseReason: summary || `Step "${run.currentStepId}" requested a pause`,
|
|
1206
|
+
lastSummary: summary
|
|
1207
|
+
}, now);
|
|
1208
|
+
}
|
|
1209
|
+
const completed = {
|
|
1210
|
+
stepId: run.currentStepId,
|
|
1211
|
+
stepDigest: run.currentStepDigest,
|
|
1212
|
+
outcome,
|
|
1213
|
+
summary,
|
|
1214
|
+
completedAt: now
|
|
1215
|
+
};
|
|
1216
|
+
if (target === "$done") {
|
|
1217
|
+
return withUpdate(run, {
|
|
1218
|
+
status: "completed",
|
|
1219
|
+
history: [...run.history, completed],
|
|
1220
|
+
stepHandoff: summary,
|
|
1221
|
+
lastSummary: summary,
|
|
1222
|
+
gateFeedback: "",
|
|
1223
|
+
pausedFrom: undefined,
|
|
1224
|
+
pendingGate: undefined
|
|
1225
|
+
}, now);
|
|
1226
|
+
}
|
|
1227
|
+
const nextStep = workflow.definition.steps[target];
|
|
1228
|
+
if (!nextStep)
|
|
1229
|
+
throw new Error(`transition target "${target}" does not exist`);
|
|
1230
|
+
const nextVisitCount = (run.visits[target] ?? 0) + 1;
|
|
1231
|
+
const visits = { ...run.visits, [target]: nextVisitCount };
|
|
1232
|
+
const overVisitLimit = nextVisitCount > workflow.definition.maxStepVisits;
|
|
1233
|
+
return withUpdate(run, {
|
|
1234
|
+
status: overVisitLimit ? "paused" : "running",
|
|
1235
|
+
currentStepId: target,
|
|
1236
|
+
currentStepDigest: workflow.stepDigests[target] ?? "",
|
|
1237
|
+
visits,
|
|
1238
|
+
history: [...run.history, completed],
|
|
1239
|
+
stepHandoff: summary,
|
|
1240
|
+
lastSummary: summary,
|
|
1241
|
+
gateFeedback: "",
|
|
1242
|
+
...overVisitLimit ? {
|
|
1243
|
+
pausedFrom: "running",
|
|
1244
|
+
pauseReason: `Step "${target}" exceeded maxStepVisits (${workflow.definition.maxStepVisits})`
|
|
1245
|
+
} : { pausedFrom: undefined, pauseReason: undefined }
|
|
1246
|
+
}, now);
|
|
1247
|
+
}
|
|
1248
|
+
function beginGate(workflow, run, outcome, artifact, requestId, now) {
|
|
1249
|
+
if (run.status !== "running") {
|
|
1250
|
+
throw new Error(`workflow is ${run.status}; gate submission requires a running workflow`);
|
|
1251
|
+
}
|
|
1252
|
+
const step = currentStep(workflow, run);
|
|
1253
|
+
if (!step?.gate)
|
|
1254
|
+
throw new Error(`step "${run.currentStepId}" has no gate`);
|
|
1255
|
+
if (outcome !== step.gate.submitOutcome) {
|
|
1256
|
+
throw new Error(`gate expects outcome "${step.gate.submitOutcome}"`);
|
|
1257
|
+
}
|
|
1258
|
+
if (!artifact.trim())
|
|
1259
|
+
throw new Error("gate submission requires a non-empty artifact");
|
|
1260
|
+
if (!requestId)
|
|
1261
|
+
throw new Error("gate submission requires a request id");
|
|
1262
|
+
return withUpdate(run, {
|
|
1263
|
+
status: "awaiting-gate",
|
|
1264
|
+
pendingGate: {
|
|
1265
|
+
provider: step.gate.provider,
|
|
1266
|
+
requestId,
|
|
1267
|
+
stepId: run.currentStepId,
|
|
1268
|
+
artifact,
|
|
1269
|
+
submittedOutcome: outcome,
|
|
1270
|
+
requestedAt: now
|
|
1271
|
+
}
|
|
1272
|
+
}, now);
|
|
1273
|
+
}
|
|
1274
|
+
function attachGateReviewId(run, reviewId, now) {
|
|
1275
|
+
if (!run.pendingGate)
|
|
1276
|
+
throw new Error("workflow has no pending gate");
|
|
1277
|
+
if (run.pendingGate.provider !== "plannotator") {
|
|
1278
|
+
throw new Error("only a Plannotator gate can have a review id");
|
|
1279
|
+
}
|
|
1280
|
+
return withUpdate(run, { pendingGate: { ...run.pendingGate, reviewId } }, now);
|
|
1281
|
+
}
|
|
1282
|
+
function failGate(run, reason, now) {
|
|
1283
|
+
if (!run.pendingGate)
|
|
1284
|
+
return run;
|
|
1285
|
+
return withUpdate(run, {
|
|
1286
|
+
status: "running",
|
|
1287
|
+
pendingGate: undefined,
|
|
1288
|
+
gateFeedback: reason
|
|
1289
|
+
}, now);
|
|
1290
|
+
}
|
|
1291
|
+
function storeGateResolution(run, resolution, now) {
|
|
1292
|
+
if (!run.pendingGate)
|
|
1293
|
+
return run;
|
|
1294
|
+
return withUpdate(run, { pendingGate: { ...run.pendingGate, resolution } }, now);
|
|
1295
|
+
}
|
|
1296
|
+
function resolveGate(workflow, run, resolution, now) {
|
|
1297
|
+
const pending = run.pendingGate;
|
|
1298
|
+
if (!pending)
|
|
1299
|
+
throw new Error("workflow has no pending gate");
|
|
1300
|
+
const step = workflow.definition.steps[pending.stepId];
|
|
1301
|
+
if (!step?.gate)
|
|
1302
|
+
throw new Error(`gated step "${pending.stepId}" no longer exists`);
|
|
1303
|
+
if (run.currentStepId !== pending.stepId) {
|
|
1304
|
+
throw new Error("gate result does not match the current step");
|
|
1305
|
+
}
|
|
1306
|
+
const outcome = resolution.approved ? step.gate.approvedOutcome : step.gate.rejectedOutcome;
|
|
1307
|
+
const summary = resolution.approved ? pending.artifact : resolution.feedback ? `Gate ${resolution.approved ? "approved" : "rejected"}: ${resolution.feedback}` : `Gate ${resolution.approved ? "approved" : "rejected"}`;
|
|
1308
|
+
const runnable = withUpdate(run, {
|
|
1309
|
+
status: "running",
|
|
1310
|
+
pendingGate: undefined,
|
|
1311
|
+
...resolution.approved ? { reviewedArtifact: pending.artifact } : {},
|
|
1312
|
+
pausedFrom: undefined,
|
|
1313
|
+
pauseReason: undefined,
|
|
1314
|
+
gateFeedback: resolution.feedback
|
|
1315
|
+
}, now);
|
|
1316
|
+
const advanced = advanceRun(workflow, runnable, outcome, summary, now);
|
|
1317
|
+
return {
|
|
1318
|
+
...advanced,
|
|
1319
|
+
gateFeedback: resolution.feedback
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
function rebuildVisits(history, currentStepId) {
|
|
1323
|
+
const visits = {};
|
|
1324
|
+
for (const entry of history) {
|
|
1325
|
+
visits[entry.stepId] = (visits[entry.stepId] ?? 0) + 1;
|
|
1326
|
+
}
|
|
1327
|
+
visits[currentStepId] = (visits[currentStepId] ?? 0) + 1;
|
|
1328
|
+
return visits;
|
|
1329
|
+
}
|
|
1330
|
+
function retainedReviewedArtifact(workflow, run, history) {
|
|
1331
|
+
const reviewedArtifact = run.reviewedArtifact ?? "";
|
|
1332
|
+
if (!reviewedArtifact)
|
|
1333
|
+
return "";
|
|
1334
|
+
const sourceRetained = history.some((entry) => {
|
|
1335
|
+
const gate = workflow.definition.steps[entry.stepId]?.gate;
|
|
1336
|
+
return gate !== undefined && entry.outcome === gate.approvedOutcome && entry.summary === reviewedArtifact;
|
|
1337
|
+
});
|
|
1338
|
+
return sourceRetained ? reviewedArtifact : "";
|
|
1339
|
+
}
|
|
1340
|
+
function reconcileRun(run, workflow, now) {
|
|
1341
|
+
if (run.workflowId !== workflow.definition.id) {
|
|
1342
|
+
return {
|
|
1343
|
+
changed: false,
|
|
1344
|
+
error: `run belongs to "${run.workflowId}", not "${workflow.definition.id}"`
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
if (run.workflowDigest === workflow.digest) {
|
|
1348
|
+
return { run, changed: false };
|
|
1349
|
+
}
|
|
1350
|
+
if (!workflow.definition.steps[run.currentStepId]) {
|
|
1351
|
+
return {
|
|
1352
|
+
changed: true,
|
|
1353
|
+
error: `current step "${run.currentStepId}" was removed; abort or restore the configuration`
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
const changedHistoryIndex = run.history.findIndex((entry) => workflow.stepDigests[entry.stepId] !== entry.stepDigest);
|
|
1357
|
+
if (changedHistoryIndex >= 0) {
|
|
1358
|
+
const changedEntry = run.history[changedHistoryIndex];
|
|
1359
|
+
if (!changedEntry || !workflow.definition.steps[changedEntry.stepId]) {
|
|
1360
|
+
return {
|
|
1361
|
+
changed: true,
|
|
1362
|
+
error: "a completed step was removed; abort or restore the configuration"
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
const retainedHistory = run.history.slice(0, changedHistoryIndex);
|
|
1366
|
+
const restartedStep = changedEntry.stepId;
|
|
1367
|
+
const stepHandoff = retainedHistory.at(-1)?.summary ?? "";
|
|
1368
|
+
const reviewedArtifact = retainedReviewedArtifact(workflow, run, retainedHistory);
|
|
1369
|
+
return {
|
|
1370
|
+
changed: true,
|
|
1371
|
+
restartedStep,
|
|
1372
|
+
run: withUpdate(run, {
|
|
1373
|
+
workflowDigest: workflow.digest,
|
|
1374
|
+
status: "paused",
|
|
1375
|
+
currentStepId: restartedStep,
|
|
1376
|
+
currentStepDigest: workflow.stepDigests[restartedStep] ?? "",
|
|
1377
|
+
history: retainedHistory,
|
|
1378
|
+
visits: rebuildVisits(retainedHistory, restartedStep),
|
|
1379
|
+
reviewedArtifact,
|
|
1380
|
+
stepHandoff,
|
|
1381
|
+
lastSummary: stepHandoff,
|
|
1382
|
+
pendingGate: undefined,
|
|
1383
|
+
pausedFrom: "running",
|
|
1384
|
+
pauseReason: `Configuration changed; restarted step "${restartedStep}"`,
|
|
1385
|
+
gateFeedback: ""
|
|
1386
|
+
}, now)
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
const currentDigest = workflow.stepDigests[run.currentStepId] ?? "";
|
|
1390
|
+
const currentChanged = currentDigest !== run.currentStepDigest;
|
|
1391
|
+
return {
|
|
1392
|
+
changed: true,
|
|
1393
|
+
...currentChanged ? { restartedStep: run.currentStepId } : {},
|
|
1394
|
+
run: withUpdate(run, {
|
|
1395
|
+
workflowDigest: workflow.digest,
|
|
1396
|
+
currentStepDigest: currentDigest,
|
|
1397
|
+
...currentChanged ? {
|
|
1398
|
+
status: "paused",
|
|
1399
|
+
pendingGate: undefined,
|
|
1400
|
+
pausedFrom: "running",
|
|
1401
|
+
pauseReason: `Configuration changed; restarted step "${run.currentStepId}"`,
|
|
1402
|
+
gateFeedback: ""
|
|
1403
|
+
} : {}
|
|
1404
|
+
}, now)
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/engine/state.ts
|
|
1409
|
+
var RUN_STATE_VERSION = 1;
|
|
1410
|
+
function createRun(workflow, input, baselineTools, runId, now) {
|
|
1411
|
+
const start = workflow.definition.start;
|
|
1412
|
+
return {
|
|
1413
|
+
stateVersion: RUN_STATE_VERSION,
|
|
1414
|
+
runId,
|
|
1415
|
+
workflowId: workflow.definition.id,
|
|
1416
|
+
workflowDigest: workflow.digest,
|
|
1417
|
+
input,
|
|
1418
|
+
status: "running",
|
|
1419
|
+
currentStepId: start,
|
|
1420
|
+
currentStepDigest: workflow.stepDigests[start] ?? "",
|
|
1421
|
+
baselineTools: [...new Set(baselineTools)],
|
|
1422
|
+
visits: { [start]: 1 },
|
|
1423
|
+
history: [],
|
|
1424
|
+
startedAt: now,
|
|
1425
|
+
updatedAt: now,
|
|
1426
|
+
reviewedArtifact: "",
|
|
1427
|
+
stepHandoff: "",
|
|
1428
|
+
lastSummary: "",
|
|
1429
|
+
gateFeedback: ""
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
function isWorkflowRun(value) {
|
|
1433
|
+
if (value === null || typeof value !== "object")
|
|
1434
|
+
return false;
|
|
1435
|
+
const run = value;
|
|
1436
|
+
const historyIsValid = Array.isArray(run.history) && run.history.every((entry) => entry !== null && typeof entry === "object" && typeof entry.stepId === "string" && typeof entry.stepDigest === "string" && typeof entry.outcome === "string" && typeof entry.summary === "string" && typeof entry.completedAt === "number");
|
|
1437
|
+
const visitsAreValid = run.visits !== null && typeof run.visits === "object" && !Array.isArray(run.visits) && Object.values(run.visits).every((count) => Number.isInteger(count) && count >= 0);
|
|
1438
|
+
const gateIsValid = run.pendingGate === undefined || run.pendingGate !== null && typeof run.pendingGate === "object" && (run.pendingGate.provider === "prompt" || run.pendingGate.provider === "plannotator") && typeof run.pendingGate.requestId === "string" && run.pendingGate.requestId.length > 0 && typeof run.pendingGate.stepId === "string" && typeof run.pendingGate.artifact === "string" && (run.pendingGate.summary === undefined || typeof run.pendingGate.summary === "string") && typeof run.pendingGate.submittedOutcome === "string" && typeof run.pendingGate.requestedAt === "number" && (run.pendingGate.reviewId === undefined || typeof run.pendingGate.reviewId === "string") && (run.pendingGate.resolution === undefined || run.pendingGate.resolution !== null && typeof run.pendingGate.resolution === "object" && typeof run.pendingGate.resolution.approved === "boolean" && typeof run.pendingGate.resolution.feedback === "string" && typeof run.pendingGate.resolution.resolvedAt === "number");
|
|
1439
|
+
const optionalsAreValid = (run.reviewedArtifact === undefined || typeof run.reviewedArtifact === "string") && (run.stepHandoff === undefined || typeof run.stepHandoff === "string") && (run.pauseReason === undefined || typeof run.pauseReason === "string") && (run.pausedFrom === undefined || run.pausedFrom === "running" || run.pausedFrom === "awaiting-gate");
|
|
1440
|
+
const statusIsValid = run.status === "running" || run.status === "paused" || run.status === "awaiting-gate" || run.status === "completed" || run.status === "aborted";
|
|
1441
|
+
const pauseStateIsValid = run.status === "paused" ? run.pausedFrom === "running" || run.pausedFrom === "awaiting-gate" : run.pausedFrom === undefined;
|
|
1442
|
+
const gateStateIsValid = !gateIsValid ? false : run.pendingGate === undefined ? run.status !== "awaiting-gate" && run.pausedFrom !== "awaiting-gate" : run.pendingGate.stepId === run.currentStepId && (run.status === "awaiting-gate" || run.status === "paused" && run.pausedFrom === "awaiting-gate");
|
|
1443
|
+
return run.stateVersion === RUN_STATE_VERSION && typeof run.runId === "string" && typeof run.workflowId === "string" && typeof run.workflowDigest === "string" && typeof run.input === "string" && typeof run.currentStepId === "string" && typeof run.currentStepDigest === "string" && Array.isArray(run.baselineTools) && run.baselineTools.every((tool) => typeof tool === "string") && historyIsValid && visitsAreValid && gateIsValid && optionalsAreValid && statusIsValid && pauseStateIsValid && gateStateIsValid && typeof run.startedAt === "number" && typeof run.updatedAt === "number" && typeof run.lastSummary === "string" && typeof run.gateFeedback === "string";
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/engine/checkpoint.ts
|
|
1447
|
+
function readLatestCheckpoint(entries, customType) {
|
|
1448
|
+
for (let index = entries.length - 1;index >= 0; index -= 1) {
|
|
1449
|
+
const entry = entries[index];
|
|
1450
|
+
if (entry?.type !== "custom" || entry.customType !== customType)
|
|
1451
|
+
continue;
|
|
1452
|
+
return isWorkflowRun(entry.data) ? { status: "valid", run: structuredClone(entry.data) } : { status: "invalid" };
|
|
1453
|
+
}
|
|
1454
|
+
return { status: "none" };
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// src/engine/resume.ts
|
|
1458
|
+
function captureResumeCheckpoint(run, sessionEpoch) {
|
|
1459
|
+
return {
|
|
1460
|
+
sessionEpoch,
|
|
1461
|
+
runId: run.runId,
|
|
1462
|
+
workflowId: run.workflowId,
|
|
1463
|
+
currentStepId: run.currentStepId,
|
|
1464
|
+
...run.pendingGate?.reviewId ? { reviewId: run.pendingGate.reviewId } : {}
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
function matchesResumeCheckpoint(run, sessionEpoch, checkpoint) {
|
|
1468
|
+
return sessionEpoch === checkpoint.sessionEpoch && run?.status === "paused" && run.runId === checkpoint.runId && run.workflowId === checkpoint.workflowId && run.currentStepId === checkpoint.currentStepId && run.pendingGate?.reviewId === checkpoint.reviewId;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// src/integrations/plannotator.ts
|
|
1472
|
+
var PLANNOTATOR_REQUEST_CHANNEL = "plannotator:request";
|
|
1473
|
+
var PLANNOTATOR_RESULT_CHANNEL = "plannotator:review-result";
|
|
1474
|
+
function errorText(value, fallback) {
|
|
1475
|
+
return typeof value.error === "string" && value.error.trim() ? value.error : fallback;
|
|
1476
|
+
}
|
|
1477
|
+
function normalizeStartResponse(value) {
|
|
1478
|
+
if (value === null || typeof value !== "object") {
|
|
1479
|
+
return {
|
|
1480
|
+
status: "error",
|
|
1481
|
+
error: "Plannotator returned an invalid response"
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
const response = value;
|
|
1485
|
+
if (response.status === "unavailable") {
|
|
1486
|
+
return {
|
|
1487
|
+
status: "unavailable",
|
|
1488
|
+
error: errorText(response, "Plannotator is unavailable")
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
if (response.status === "error") {
|
|
1492
|
+
return {
|
|
1493
|
+
status: "error",
|
|
1494
|
+
error: errorText(response, "Plannotator failed")
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
const result = response.result !== null && typeof response.result === "object" ? response.result : undefined;
|
|
1498
|
+
if (response.status === "handled" && result?.status === "pending" && typeof result.reviewId === "string") {
|
|
1499
|
+
return {
|
|
1500
|
+
status: "handled",
|
|
1501
|
+
result: { status: "pending", reviewId: result.reviewId }
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
return {
|
|
1505
|
+
status: "error",
|
|
1506
|
+
error: "Plannotator returned an invalid start result"
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
function normalizeStatusResponse(value, requestedReviewId) {
|
|
1510
|
+
if (value === null || typeof value !== "object") {
|
|
1511
|
+
return {
|
|
1512
|
+
status: "error",
|
|
1513
|
+
error: "Plannotator returned an invalid response"
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
const response = value;
|
|
1517
|
+
if (response.status === "unavailable") {
|
|
1518
|
+
return {
|
|
1519
|
+
status: "unavailable",
|
|
1520
|
+
error: errorText(response, "Plannotator is unavailable")
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
if (response.status === "error") {
|
|
1524
|
+
return {
|
|
1525
|
+
status: "error",
|
|
1526
|
+
error: errorText(response, "Plannotator failed")
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
const result = response.result !== null && typeof response.result === "object" ? response.result : undefined;
|
|
1530
|
+
if (response.status !== "handled" || !result) {
|
|
1531
|
+
return {
|
|
1532
|
+
status: "error",
|
|
1533
|
+
error: "Plannotator returned an invalid status result"
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
if (result.status === "pending" || result.status === "missing") {
|
|
1537
|
+
return { status: "handled", result: { status: result.status } };
|
|
1538
|
+
}
|
|
1539
|
+
if (result.status === "completed" && typeof result.reviewId === "string" && typeof result.approved === "boolean") {
|
|
1540
|
+
if (result.reviewId !== requestedReviewId) {
|
|
1541
|
+
return {
|
|
1542
|
+
status: "error",
|
|
1543
|
+
error: "Plannotator returned a result for a different review"
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
return {
|
|
1547
|
+
status: "handled",
|
|
1548
|
+
result: {
|
|
1549
|
+
status: "completed",
|
|
1550
|
+
reviewId: result.reviewId,
|
|
1551
|
+
approved: result.approved,
|
|
1552
|
+
feedback: typeof result.feedback === "string" ? result.feedback : ""
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
return {
|
|
1557
|
+
status: "error",
|
|
1558
|
+
error: "Plannotator returned an invalid status result"
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
function parsePlannotatorResult(value) {
|
|
1562
|
+
if (value === null || typeof value !== "object")
|
|
1563
|
+
return;
|
|
1564
|
+
const result = value;
|
|
1565
|
+
if (typeof result.reviewId !== "string" || typeof result.approved !== "boolean") {
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
reviewId: result.reviewId,
|
|
1570
|
+
approved: result.approved,
|
|
1571
|
+
feedback: typeof result.feedback === "string" ? result.feedback : ""
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
function requestPlannotatorReview(events, requestId, content, origin, timeoutMs) {
|
|
1575
|
+
return new Promise((resolve2) => {
|
|
1576
|
+
let settled = false;
|
|
1577
|
+
const finish = (response) => {
|
|
1578
|
+
if (settled)
|
|
1579
|
+
return;
|
|
1580
|
+
settled = true;
|
|
1581
|
+
clearTimeout(timer);
|
|
1582
|
+
resolve2(normalizeStartResponse(response));
|
|
1583
|
+
};
|
|
1584
|
+
const timer = setTimeout(() => finish({
|
|
1585
|
+
status: "unavailable",
|
|
1586
|
+
error: `Plannotator did not respond within ${timeoutMs}ms`
|
|
1587
|
+
}), timeoutMs);
|
|
1588
|
+
timer.unref?.();
|
|
1589
|
+
events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
|
|
1590
|
+
requestId,
|
|
1591
|
+
action: "plan-review",
|
|
1592
|
+
payload: {
|
|
1593
|
+
planContent: content,
|
|
1594
|
+
origin
|
|
1595
|
+
},
|
|
1596
|
+
respond: finish
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
function requestPlannotatorReviewStatus(events, requestId, reviewId, timeoutMs) {
|
|
1601
|
+
return new Promise((resolve2) => {
|
|
1602
|
+
let settled = false;
|
|
1603
|
+
const finish = (response) => {
|
|
1604
|
+
if (settled)
|
|
1605
|
+
return;
|
|
1606
|
+
settled = true;
|
|
1607
|
+
clearTimeout(timer);
|
|
1608
|
+
resolve2(normalizeStatusResponse(response, reviewId));
|
|
1609
|
+
};
|
|
1610
|
+
const timer = setTimeout(() => finish({
|
|
1611
|
+
status: "unavailable",
|
|
1612
|
+
error: `Plannotator did not respond within ${timeoutMs}ms`
|
|
1613
|
+
}), timeoutMs);
|
|
1614
|
+
timer.unref?.();
|
|
1615
|
+
events.emit(PLANNOTATOR_REQUEST_CHANNEL, {
|
|
1616
|
+
requestId,
|
|
1617
|
+
action: "review-status",
|
|
1618
|
+
payload: { reviewId },
|
|
1619
|
+
respond: finish
|
|
1620
|
+
});
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// src/integrations/prompt-gate.ts
|
|
1625
|
+
var APPROVE = "Approve";
|
|
1626
|
+
var REQUEST_CHANGES = "Request changes";
|
|
1627
|
+
var PAUSE = "Pause workflow";
|
|
1628
|
+
async function requestPromptGateReview(ui, title, artifact, signal) {
|
|
1629
|
+
const choice = await ui.select(`${title}
|
|
1630
|
+
|
|
1631
|
+
${artifact}`, [APPROVE, REQUEST_CHANGES, PAUSE], ...signal ? [{ signal }] : []);
|
|
1632
|
+
if (choice === APPROVE) {
|
|
1633
|
+
return { status: "resolved", approved: true, feedback: "" };
|
|
1634
|
+
}
|
|
1635
|
+
if (choice !== REQUEST_CHANGES) {
|
|
1636
|
+
return { status: "dismissed" };
|
|
1637
|
+
}
|
|
1638
|
+
while (true) {
|
|
1639
|
+
const feedback = await ui.input("Workflow review feedback", "Describe the required changes", ...signal ? [{ signal }] : []);
|
|
1640
|
+
if (feedback === undefined) {
|
|
1641
|
+
return { status: "dismissed" };
|
|
1642
|
+
}
|
|
1643
|
+
if (feedback.trim()) {
|
|
1644
|
+
return {
|
|
1645
|
+
status: "resolved",
|
|
1646
|
+
approved: false,
|
|
1647
|
+
feedback: feedback.trim()
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
ui.notify("Feedback cannot be empty", "warning");
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
// src/integrations/subagents/protocol.ts
|
|
1655
|
+
import { tmpdir } from "node:os";
|
|
1656
|
+
import { basename, dirname as dirname2, relative as relative2, resolve as resolve2 } from "node:path";
|
|
1657
|
+
|
|
1658
|
+
// src/runtime/step-result.ts
|
|
1659
|
+
function isObject2(value) {
|
|
1660
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1661
|
+
}
|
|
1662
|
+
function parseWorkflowStepResult(value, policy) {
|
|
1663
|
+
if (!isObject2(value))
|
|
1664
|
+
throw new Error("workflow step result must be an object");
|
|
1665
|
+
const allowedKeys = new Set([
|
|
1666
|
+
"version",
|
|
1667
|
+
"policyDigest",
|
|
1668
|
+
"outcome",
|
|
1669
|
+
"summary",
|
|
1670
|
+
"artifact"
|
|
1671
|
+
]);
|
|
1672
|
+
const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key));
|
|
1673
|
+
if (unknownKey) {
|
|
1674
|
+
throw new Error(`workflow step result has unknown property "${unknownKey}"`);
|
|
1675
|
+
}
|
|
1676
|
+
if (value.version !== 1)
|
|
1677
|
+
throw new Error("unsupported workflow step result version");
|
|
1678
|
+
if (value.policyDigest !== policy.policyDigest) {
|
|
1679
|
+
throw new Error("workflow step result does not match the active policy");
|
|
1680
|
+
}
|
|
1681
|
+
if (typeof value.outcome !== "string" || !policy.outcomes.includes(value.outcome)) {
|
|
1682
|
+
throw new Error(`workflow step returned invalid outcome "${String(value.outcome)}"`);
|
|
1683
|
+
}
|
|
1684
|
+
if (typeof value.summary !== "string") {
|
|
1685
|
+
throw new Error("workflow step summary must be a string");
|
|
1686
|
+
}
|
|
1687
|
+
const summary = value.summary.trim();
|
|
1688
|
+
if (!summary) {
|
|
1689
|
+
throw new Error("workflow step summary must not be empty");
|
|
1690
|
+
}
|
|
1691
|
+
if (summary.length > policy.summaryMaxChars) {
|
|
1692
|
+
throw new Error(`workflow step summary exceeds ${policy.summaryMaxChars} characters`);
|
|
1693
|
+
}
|
|
1694
|
+
if (value.artifact !== undefined && typeof value.artifact !== "string") {
|
|
1695
|
+
throw new Error("workflow step artifact must be a string");
|
|
1696
|
+
}
|
|
1697
|
+
const artifact = typeof value.artifact === "string" ? value.artifact : undefined;
|
|
1698
|
+
if (artifact !== undefined && artifact.length > 200000) {
|
|
1699
|
+
throw new Error("workflow step artifact exceeds 200000 characters");
|
|
1700
|
+
}
|
|
1701
|
+
if (value.outcome === policy.gateSubmitOutcome && (!artifact || !artifact.trim())) {
|
|
1702
|
+
throw new Error("workflow gate outcome requires a non-empty artifact");
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
version: 1,
|
|
1706
|
+
policyDigest: policy.policyDigest,
|
|
1707
|
+
outcome: value.outcome,
|
|
1708
|
+
summary,
|
|
1709
|
+
...artifact !== undefined ? { artifact } : {}
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// src/integrations/subagents/protocol.ts
|
|
1714
|
+
var SUBAGENT_DELEGATION_PROTOCOL_VERSION = 1;
|
|
1715
|
+
var SUBAGENT_DELEGATION_REQUEST_EVENT = "prompt-template:subagent:request";
|
|
1716
|
+
var SUBAGENT_DELEGATION_STARTED_EVENT = "prompt-template:subagent:started";
|
|
1717
|
+
var SUBAGENT_DELEGATION_UPDATE_EVENT = "prompt-template:subagent:update";
|
|
1718
|
+
var SUBAGENT_DELEGATION_RESPONSE_EVENT = "prompt-template:subagent:response";
|
|
1719
|
+
var SUBAGENT_DELEGATION_CANCEL_EVENT = "prompt-template:subagent:cancel";
|
|
1720
|
+
var CHILD_POLICY_OPEN = "<pi-workflows-policy-v1>";
|
|
1721
|
+
var CHILD_POLICY_CLOSE = "</pi-workflows-policy-v1>";
|
|
1722
|
+
var FORK_TASK_BOUNDARY = `
|
|
1723
|
+
|
|
1724
|
+
Task:
|
|
1725
|
+
`;
|
|
1726
|
+
var POLICY_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
1727
|
+
var CAPABILITY_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
|
1728
|
+
var RESULT_FILE_NAME = "result.json";
|
|
1729
|
+
var CAPABILITY_FILE_NAME = "capability";
|
|
1730
|
+
var RESULT_DIRECTORY_PREFIX = "pi-workflows-step-";
|
|
1731
|
+
function isObject3(value) {
|
|
1732
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1733
|
+
}
|
|
1734
|
+
function isStringArray(value) {
|
|
1735
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1736
|
+
}
|
|
1737
|
+
function isStepPermissions(value) {
|
|
1738
|
+
if (!isObject3(value) || !isObject3(value.bash))
|
|
1739
|
+
return false;
|
|
1740
|
+
const bash = value.bash;
|
|
1741
|
+
const modeIsValid = bash.mode === "deny" || bash.mode === "read-only" || bash.mode === "allow-list" || bash.mode === "unrestricted";
|
|
1742
|
+
const rulesAreValid = Array.isArray(bash.allow) && bash.allow.every((rule) => isObject3(rule) && typeof rule.executable === "string" && isStringArray(rule.argsPrefix));
|
|
1743
|
+
const approvedSourcesAreValid = bash.approvedSources === undefined || isStringArray(bash.approvedSources) && new Set(bash.approvedSources).size === bash.approvedSources.length && bash.approvedSources.every((source) => source === "verification-worker" || source === "verification-reviewer" || source === "remote-actions");
|
|
1744
|
+
const approvalShapeIsValid = bash.mode === "allow-list" ? bash.allow.length > 0 || Array.isArray(bash.approvedSources) && bash.approvedSources.length > 0 : bash.approvedSources === undefined;
|
|
1745
|
+
return isStringArray(value.tools) && isStringArray(value.mcp) && isStringArray(value.extensions) && isStringArray(value.skills) && modeIsValid && rulesAreValid && approvedSourcesAreValid && approvalShapeIsValid;
|
|
1746
|
+
}
|
|
1747
|
+
function isSafeStepFilePath(path, expectedName) {
|
|
1748
|
+
const base = resolve2(tmpdir());
|
|
1749
|
+
const candidate = resolve2(path);
|
|
1750
|
+
const fromBase = relative2(base, candidate);
|
|
1751
|
+
return fromBase !== "" && !fromBase.startsWith("..") && !fromBase.includes("\x00") && basename(candidate) === expectedName && basename(dirname2(candidate)).startsWith(RESULT_DIRECTORY_PREFIX);
|
|
1752
|
+
}
|
|
1753
|
+
function isSafeStepResultPath(path) {
|
|
1754
|
+
return isSafeStepFilePath(path, RESULT_FILE_NAME);
|
|
1755
|
+
}
|
|
1756
|
+
function isSafeStepCapabilityPath(path) {
|
|
1757
|
+
return isSafeStepFilePath(path, CAPABILITY_FILE_NAME);
|
|
1758
|
+
}
|
|
1759
|
+
function isSubagentRuntimeName(name) {
|
|
1760
|
+
return Boolean(name && SUBAGENT_RUNTIME_NAME_PATTERN.test(name));
|
|
1761
|
+
}
|
|
1762
|
+
function parseChildPolicy(value) {
|
|
1763
|
+
if (!isObject3(value))
|
|
1764
|
+
throw new Error("child policy must be an object");
|
|
1765
|
+
const allowedKeys = new Set([
|
|
1766
|
+
"version",
|
|
1767
|
+
"requestId",
|
|
1768
|
+
"agent",
|
|
1769
|
+
"workflowId",
|
|
1770
|
+
"runId",
|
|
1771
|
+
"stepId",
|
|
1772
|
+
"stepTitle",
|
|
1773
|
+
"policyDigest",
|
|
1774
|
+
"capabilityPath",
|
|
1775
|
+
"capabilityToken",
|
|
1776
|
+
"resultPath",
|
|
1777
|
+
"permissions",
|
|
1778
|
+
"approvedBashCommands",
|
|
1779
|
+
"outcomes",
|
|
1780
|
+
"summaryMaxChars",
|
|
1781
|
+
"gateSubmitOutcome"
|
|
1782
|
+
]);
|
|
1783
|
+
const unknownKey = Object.keys(value).find((key) => !allowedKeys.has(key));
|
|
1784
|
+
if (unknownKey) {
|
|
1785
|
+
throw new Error(`child policy has unknown property "${unknownKey}"`);
|
|
1786
|
+
}
|
|
1787
|
+
const stringFields = [
|
|
1788
|
+
"requestId",
|
|
1789
|
+
"agent",
|
|
1790
|
+
"workflowId",
|
|
1791
|
+
"runId",
|
|
1792
|
+
"stepId",
|
|
1793
|
+
"stepTitle",
|
|
1794
|
+
"policyDigest",
|
|
1795
|
+
"capabilityPath",
|
|
1796
|
+
"capabilityToken",
|
|
1797
|
+
"resultPath"
|
|
1798
|
+
];
|
|
1799
|
+
for (const field of stringFields) {
|
|
1800
|
+
if (typeof value[field] !== "string" || !value[field]) {
|
|
1801
|
+
throw new Error(`child policy ${field} must be a non-empty string`);
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
if (value.version !== 1)
|
|
1805
|
+
throw new Error("unsupported child policy version");
|
|
1806
|
+
if (!POLICY_DIGEST_PATTERN.test(value.policyDigest)) {
|
|
1807
|
+
throw new Error("child policy digest is invalid");
|
|
1808
|
+
}
|
|
1809
|
+
if (!isSubagentRuntimeName(value.agent)) {
|
|
1810
|
+
throw new Error("child policy agent is not a valid subagent runtime name");
|
|
1811
|
+
}
|
|
1812
|
+
if (!CAPABILITY_TOKEN_PATTERN.test(value.capabilityToken)) {
|
|
1813
|
+
throw new Error("child policy capability token is invalid");
|
|
1814
|
+
}
|
|
1815
|
+
if (!isSafeStepCapabilityPath(value.capabilityPath)) {
|
|
1816
|
+
throw new Error("child policy capability path is outside its temporary directory");
|
|
1817
|
+
}
|
|
1818
|
+
if (!isSafeStepResultPath(value.resultPath)) {
|
|
1819
|
+
throw new Error("child policy result path is outside its temporary directory");
|
|
1820
|
+
}
|
|
1821
|
+
if (dirname2(resolve2(value.capabilityPath)) !== dirname2(resolve2(value.resultPath))) {
|
|
1822
|
+
throw new Error("child policy files must share one temporary directory");
|
|
1823
|
+
}
|
|
1824
|
+
if (!isStepPermissions(value.permissions)) {
|
|
1825
|
+
throw new Error("child policy permissions are invalid");
|
|
1826
|
+
}
|
|
1827
|
+
if (value.approvedBashCommands !== undefined && (!isStringArray(value.approvedBashCommands) || new Set(value.approvedBashCommands).size !== value.approvedBashCommands.length)) {
|
|
1828
|
+
throw new Error("child policy approved Bash commands are invalid");
|
|
1829
|
+
}
|
|
1830
|
+
if (!isStringArray(value.outcomes) || value.outcomes.length === 0 || new Set(value.outcomes).size !== value.outcomes.length) {
|
|
1831
|
+
throw new Error("child policy outcomes are invalid");
|
|
1832
|
+
}
|
|
1833
|
+
if (!Number.isInteger(value.summaryMaxChars) || value.summaryMaxChars < 100 || value.summaryMaxChars > 50000) {
|
|
1834
|
+
throw new Error("child policy summaryMaxChars is invalid");
|
|
1835
|
+
}
|
|
1836
|
+
if (value.gateSubmitOutcome !== undefined && (typeof value.gateSubmitOutcome !== "string" || !value.outcomes.includes(value.gateSubmitOutcome))) {
|
|
1837
|
+
throw new Error("child policy gate outcome is invalid");
|
|
1838
|
+
}
|
|
1839
|
+
return value;
|
|
1840
|
+
}
|
|
1841
|
+
function encodeChildPolicy(policy) {
|
|
1842
|
+
const encoded = Buffer.from(JSON.stringify(policy), "utf8").toString("base64url");
|
|
1843
|
+
return `${CHILD_POLICY_OPEN}${encoded}${CHILD_POLICY_CLOSE}`;
|
|
1844
|
+
}
|
|
1845
|
+
function extractChildPolicy(text) {
|
|
1846
|
+
let start = 0;
|
|
1847
|
+
if (!text.startsWith(CHILD_POLICY_OPEN)) {
|
|
1848
|
+
const forkStart = text.indexOf(`${FORK_TASK_BOUNDARY}${CHILD_POLICY_OPEN}`);
|
|
1849
|
+
if (forkStart === -1)
|
|
1850
|
+
return;
|
|
1851
|
+
start = forkStart + FORK_TASK_BOUNDARY.length;
|
|
1852
|
+
}
|
|
1853
|
+
const payloadStart = start + CHILD_POLICY_OPEN.length;
|
|
1854
|
+
const end = text.indexOf(CHILD_POLICY_CLOSE, payloadStart);
|
|
1855
|
+
if (end === -1 || text.indexOf(CHILD_POLICY_OPEN, payloadStart) !== -1) {
|
|
1856
|
+
throw new Error("delegated task contains an invalid child policy envelope");
|
|
1857
|
+
}
|
|
1858
|
+
const encoded = text.slice(payloadStart, end);
|
|
1859
|
+
let decoded;
|
|
1860
|
+
try {
|
|
1861
|
+
decoded = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
1862
|
+
} catch {
|
|
1863
|
+
throw new Error("delegated task child policy cannot be decoded");
|
|
1864
|
+
}
|
|
1865
|
+
const task = `${text.slice(0, start)}${text.slice(end + CHILD_POLICY_CLOSE.length)}`.trim();
|
|
1866
|
+
if (!task)
|
|
1867
|
+
throw new Error("delegated task is empty after policy extraction");
|
|
1868
|
+
return { policy: parseChildPolicy(decoded), task };
|
|
1869
|
+
}
|
|
1870
|
+
function parseDelegatedStepResult(value, policy) {
|
|
1871
|
+
try {
|
|
1872
|
+
return parseWorkflowStepResult(value, policy);
|
|
1873
|
+
} catch (error) {
|
|
1874
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1875
|
+
throw new Error(message.replaceAll("workflow step", "delegated step"), {
|
|
1876
|
+
cause: error
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// src/integrations/subagents/client.ts
|
|
1882
|
+
var DELEGATION_STATUSES = new Set([
|
|
1883
|
+
"completed",
|
|
1884
|
+
"failed",
|
|
1885
|
+
"timed_out",
|
|
1886
|
+
"cancelled",
|
|
1887
|
+
"interrupted",
|
|
1888
|
+
"turn_budget_exhausted",
|
|
1889
|
+
"tool_budget_exhausted",
|
|
1890
|
+
"acceptance_failed",
|
|
1891
|
+
"invalid_request",
|
|
1892
|
+
"unavailable_context"
|
|
1893
|
+
]);
|
|
1894
|
+
function requestIdOf(value) {
|
|
1895
|
+
if (value === null || typeof value !== "object")
|
|
1896
|
+
return;
|
|
1897
|
+
const requestId = value.requestId;
|
|
1898
|
+
return typeof requestId === "string" ? requestId : undefined;
|
|
1899
|
+
}
|
|
1900
|
+
function parseResponse(value) {
|
|
1901
|
+
if (value === null || typeof value !== "object")
|
|
1902
|
+
return;
|
|
1903
|
+
const response = value;
|
|
1904
|
+
if (response.version !== SUBAGENT_DELEGATION_PROTOCOL_VERSION || typeof response.requestId !== "string" || typeof response.status !== "string" || !DELEGATION_STATUSES.has(response.status)) {
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
return response;
|
|
1908
|
+
}
|
|
1909
|
+
function parseUpdate(value) {
|
|
1910
|
+
if (value === null || typeof value !== "object")
|
|
1911
|
+
return;
|
|
1912
|
+
const update = value;
|
|
1913
|
+
if (update.version !== SUBAGENT_DELEGATION_PROTOCOL_VERSION || typeof update.requestId !== "string") {
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
return update;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
class SubagentDelegationClient {
|
|
1920
|
+
events;
|
|
1921
|
+
active;
|
|
1922
|
+
constructor(events) {
|
|
1923
|
+
this.events = events;
|
|
1924
|
+
}
|
|
1925
|
+
get activeRequestId() {
|
|
1926
|
+
return this.active?.requestId;
|
|
1927
|
+
}
|
|
1928
|
+
delegate(request, options = {}) {
|
|
1929
|
+
if (this.active) {
|
|
1930
|
+
return Promise.reject(new Error(`subagent request "${this.active.requestId}" is still active`));
|
|
1931
|
+
}
|
|
1932
|
+
if (options.signal?.aborted) {
|
|
1933
|
+
return Promise.reject(new Error("subagent delegation was cancelled"));
|
|
1934
|
+
}
|
|
1935
|
+
let start = () => {
|
|
1936
|
+
return;
|
|
1937
|
+
};
|
|
1938
|
+
let requestCancellation = () => {
|
|
1939
|
+
return;
|
|
1940
|
+
};
|
|
1941
|
+
let resolveTerminal = () => {
|
|
1942
|
+
return;
|
|
1943
|
+
};
|
|
1944
|
+
const terminal = new Promise((resolve3) => {
|
|
1945
|
+
resolveTerminal = resolve3;
|
|
1946
|
+
});
|
|
1947
|
+
const delegation = new Promise((resolve3, reject) => {
|
|
1948
|
+
let settled = false;
|
|
1949
|
+
let cancellationRequested = false;
|
|
1950
|
+
const subscriptions = [];
|
|
1951
|
+
const startTimeoutMs = options.startTimeoutMs ?? 3000;
|
|
1952
|
+
const overallTimeoutMs = (request.timeoutMs ?? 900000) + 5000;
|
|
1953
|
+
const subscribe = (event, handler) => {
|
|
1954
|
+
const unsubscribe = this.events.on(event, handler);
|
|
1955
|
+
if (typeof unsubscribe === "function")
|
|
1956
|
+
subscriptions.push(unsubscribe);
|
|
1957
|
+
};
|
|
1958
|
+
const stopLocalWatchers = () => {
|
|
1959
|
+
clearTimeout(startTimer);
|
|
1960
|
+
clearTimeout(overallTimer);
|
|
1961
|
+
options.signal?.removeEventListener("abort", abort);
|
|
1962
|
+
};
|
|
1963
|
+
const cleanup = () => {
|
|
1964
|
+
stopLocalWatchers();
|
|
1965
|
+
for (const unsubscribe of subscriptions)
|
|
1966
|
+
unsubscribe();
|
|
1967
|
+
if (this.active?.requestId === request.requestId)
|
|
1968
|
+
this.active = undefined;
|
|
1969
|
+
};
|
|
1970
|
+
const finish = (result) => {
|
|
1971
|
+
if (settled)
|
|
1972
|
+
return;
|
|
1973
|
+
settled = true;
|
|
1974
|
+
cleanup();
|
|
1975
|
+
if ("response" in result)
|
|
1976
|
+
resolve3(result.response);
|
|
1977
|
+
else
|
|
1978
|
+
reject(result.error);
|
|
1979
|
+
};
|
|
1980
|
+
const emitCancel = () => {
|
|
1981
|
+
if (cancellationRequested || settled)
|
|
1982
|
+
return;
|
|
1983
|
+
cancellationRequested = true;
|
|
1984
|
+
this.events.emit(SUBAGENT_DELEGATION_CANCEL_EVENT, {
|
|
1985
|
+
version: SUBAGENT_DELEGATION_PROTOCOL_VERSION,
|
|
1986
|
+
requestId: request.requestId
|
|
1987
|
+
});
|
|
1988
|
+
};
|
|
1989
|
+
const failAndCancel = (reason) => {
|
|
1990
|
+
if (settled)
|
|
1991
|
+
return;
|
|
1992
|
+
emitCancel();
|
|
1993
|
+
if (settled)
|
|
1994
|
+
return;
|
|
1995
|
+
settled = true;
|
|
1996
|
+
stopLocalWatchers();
|
|
1997
|
+
reject(new Error(reason));
|
|
1998
|
+
};
|
|
1999
|
+
const abort = () => {
|
|
2000
|
+
failAndCancel("subagent delegation was cancelled");
|
|
2001
|
+
};
|
|
2002
|
+
requestCancellation = emitCancel;
|
|
2003
|
+
subscribe(SUBAGENT_DELEGATION_STARTED_EVENT, (data) => {
|
|
2004
|
+
if (requestIdOf(data) !== request.requestId)
|
|
2005
|
+
return;
|
|
2006
|
+
clearTimeout(startTimer);
|
|
2007
|
+
});
|
|
2008
|
+
subscribe(SUBAGENT_DELEGATION_UPDATE_EVENT, (data) => {
|
|
2009
|
+
const update = parseUpdate(data);
|
|
2010
|
+
if (!update || update.requestId !== request.requestId)
|
|
2011
|
+
return;
|
|
2012
|
+
options.onUpdate?.(update);
|
|
2013
|
+
});
|
|
2014
|
+
subscribe(SUBAGENT_DELEGATION_RESPONSE_EVENT, (data) => {
|
|
2015
|
+
const response = parseResponse(data);
|
|
2016
|
+
if (!response || response.requestId !== request.requestId)
|
|
2017
|
+
return;
|
|
2018
|
+
resolveTerminal();
|
|
2019
|
+
if (settled) {
|
|
2020
|
+
cleanup();
|
|
2021
|
+
options.onLateTerminal?.(response);
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
finish({ response });
|
|
2025
|
+
});
|
|
2026
|
+
const startTimer = setTimeout(() => {
|
|
2027
|
+
failAndCancel("pi-subagents did not accept the delegation request; verify it is installed and loaded");
|
|
2028
|
+
}, startTimeoutMs);
|
|
2029
|
+
const overallTimer = setTimeout(() => {
|
|
2030
|
+
failAndCancel("pi-subagents did not settle the delegation request before its deadline");
|
|
2031
|
+
}, overallTimeoutMs);
|
|
2032
|
+
startTimer.unref?.();
|
|
2033
|
+
overallTimer.unref?.();
|
|
2034
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
2035
|
+
start = () => this.events.emit(SUBAGENT_DELEGATION_REQUEST_EVENT, request);
|
|
2036
|
+
});
|
|
2037
|
+
this.active = {
|
|
2038
|
+
requestId: request.requestId,
|
|
2039
|
+
requestCancellation,
|
|
2040
|
+
terminal
|
|
2041
|
+
};
|
|
2042
|
+
start();
|
|
2043
|
+
return delegation;
|
|
2044
|
+
}
|
|
2045
|
+
async cancelActiveAndWait(waitMs = 5000) {
|
|
2046
|
+
const active = this.active;
|
|
2047
|
+
if (!active)
|
|
2048
|
+
return true;
|
|
2049
|
+
active.requestCancellation();
|
|
2050
|
+
return new Promise((resolve3) => {
|
|
2051
|
+
let finished = false;
|
|
2052
|
+
const finish = (confirmed) => {
|
|
2053
|
+
if (finished)
|
|
2054
|
+
return;
|
|
2055
|
+
finished = true;
|
|
2056
|
+
clearTimeout(timer);
|
|
2057
|
+
resolve3(confirmed);
|
|
2058
|
+
};
|
|
2059
|
+
const timer = setTimeout(() => finish(false), waitMs);
|
|
2060
|
+
active.terminal.then(() => finish(true));
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/preflight.ts
|
|
2066
|
+
function sourceMatches(resource, selector) {
|
|
2067
|
+
const source = `${resource.sourceInfo?.source ?? ""}
|
|
2068
|
+
${resource.sourceInfo?.path ?? ""}`;
|
|
2069
|
+
return source.toLowerCase().includes(selector.toLowerCase());
|
|
2070
|
+
}
|
|
2071
|
+
function preflightStep(step, inventory) {
|
|
2072
|
+
const errors = [];
|
|
2073
|
+
const toolNames = new Set(inventory.tools.map((tool) => tool.name));
|
|
2074
|
+
const subagentTool = inventory.tools.find((tool) => tool.name === "subagent" && sourceMatches(tool, "pi-subagents"));
|
|
2075
|
+
if (step.subagent && !subagentTool) {
|
|
2076
|
+
errors.push('pi-subagents is required, but its "subagent" tool is not installed or detectable');
|
|
2077
|
+
}
|
|
2078
|
+
for (const tool of step.requires.tools) {
|
|
2079
|
+
if (!toolNames.has(tool)) {
|
|
2080
|
+
errors.push(`required tool "${tool}" is not installed`);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
if (step.permissions.mcp.length > 0 && !toolNames.has("mcp")) {
|
|
2084
|
+
errors.push('MCP selectors are configured, but the "mcp" proxy tool is not installed');
|
|
2085
|
+
}
|
|
2086
|
+
const extensionResources = [...inventory.tools, ...inventory.commands];
|
|
2087
|
+
if (step.gate?.provider === "plannotator" && !step.requires.extensions.includes("plannotator") && !extensionResources.some((resource) => sourceMatches(resource, "plannotator"))) {
|
|
2088
|
+
errors.push("Plannotator is required by this gate, but its extension is not installed or detectable");
|
|
2089
|
+
}
|
|
2090
|
+
for (const extension of step.requires.extensions) {
|
|
2091
|
+
if (!extensionResources.some((resource) => sourceMatches(resource, extension))) {
|
|
2092
|
+
errors.push(`required extension "${extension}" is not detectable`);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
for (const skill of step.requires.skills) {
|
|
2096
|
+
if (!inventory.skills.has(skill)) {
|
|
2097
|
+
errors.push(`required skill "${skill}" is not loaded`);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return errors;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// src/prompt.ts
|
|
2104
|
+
function formatList(values) {
|
|
2105
|
+
return values.length > 0 ? values.join(", ") : "(none)";
|
|
2106
|
+
}
|
|
2107
|
+
function currentStepHandoff(run) {
|
|
2108
|
+
const incoming = run.stepHandoff ?? "";
|
|
2109
|
+
if (!incoming || incoming === run.lastSummary)
|
|
2110
|
+
return run.lastSummary;
|
|
2111
|
+
if (!run.lastSummary)
|
|
2112
|
+
return incoming;
|
|
2113
|
+
return [
|
|
2114
|
+
"Incoming approved or previous-step handoff:",
|
|
2115
|
+
incoming,
|
|
2116
|
+
"",
|
|
2117
|
+
"Latest paused attempt:",
|
|
2118
|
+
run.lastSummary
|
|
2119
|
+
].join(`
|
|
2120
|
+
`);
|
|
2121
|
+
}
|
|
2122
|
+
function renderTemplate(template, values) {
|
|
2123
|
+
return template.replace(/\{\{([^{}]+)\}\}/g, (_match, rawName) => {
|
|
2124
|
+
const name = rawName.trim();
|
|
2125
|
+
return values[name] ?? "";
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
function templateValues(workflow, run, step) {
|
|
2129
|
+
return {
|
|
2130
|
+
"workflow.input": run.input,
|
|
2131
|
+
"workflow.id": workflow.definition.id,
|
|
2132
|
+
"run.id": run.runId,
|
|
2133
|
+
"step.id": run.currentStepId,
|
|
2134
|
+
"step.title": step.title,
|
|
2135
|
+
"last.summary": currentStepHandoff(run),
|
|
2136
|
+
"gate.feedback": run.gateFeedback
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
function buildStepTask(workflow, run, execution, policyEnvelope) {
|
|
2140
|
+
const step = workflow.definition.steps[run.currentStepId];
|
|
2141
|
+
if (!step)
|
|
2142
|
+
throw new Error(`unknown workflow step "${run.currentStepId}"`);
|
|
2143
|
+
const prompt = renderTemplate(workflow.prompts[run.currentStepId] ?? "", templateValues(workflow, run, step));
|
|
2144
|
+
const outcomes = allowedOutcomes(workflow, run);
|
|
2145
|
+
const allowedOutcomeSet = new Set(outcomes);
|
|
2146
|
+
const transitionLines = Object.entries(step.transitions).filter(([outcome]) => allowedOutcomeSet.has(outcome)).map(([outcome, target]) => `- ${outcome}: ${target}`).join(`
|
|
2147
|
+
`);
|
|
2148
|
+
const gateLine = step.gate ? `- ${step.gate.submitOutcome}: submit the artifact to ${step.gate.provider}; include the full artifact argument` : "";
|
|
2149
|
+
const delegated = execution === "delegated";
|
|
2150
|
+
return [
|
|
2151
|
+
...policyEnvelope ? [policyEnvelope, ""] : [],
|
|
2152
|
+
`# ${delegated ? "Delegated" : "Main-agent"} declarative workflow step`,
|
|
2153
|
+
"",
|
|
2154
|
+
`Workflow: ${workflow.definition.id}`,
|
|
2155
|
+
`Run: ${run.runId}`,
|
|
2156
|
+
`Step: ${run.currentStepId} (${step.title})`,
|
|
2157
|
+
"",
|
|
2158
|
+
"## Step instructions",
|
|
2159
|
+
"",
|
|
2160
|
+
prompt,
|
|
2161
|
+
"",
|
|
2162
|
+
`## Enforced ${delegated ? "child" : "step"} resources`,
|
|
2163
|
+
"",
|
|
2164
|
+
`Pi tools: ${formatList(step.permissions.tools)}`,
|
|
2165
|
+
`MCP selectors: ${formatList(step.permissions.mcp)}`,
|
|
2166
|
+
`Extension selectors: ${formatList(step.permissions.extensions)}`,
|
|
2167
|
+
`Skills: ${formatList(step.permissions.skills)}`,
|
|
2168
|
+
`Bash policy: ${step.permissions.bash.mode}`,
|
|
2169
|
+
"",
|
|
2170
|
+
`Use only the listed skills for this step. Tool calls are enforced ${delegated ? "inside this child process" : "by the workflow harness"}.`,
|
|
2171
|
+
"",
|
|
2172
|
+
"## Completion contract",
|
|
2173
|
+
"",
|
|
2174
|
+
`Call \`workflow_complete_step\` exactly once, after all work for this ${delegated ? "delegated" : "main-agent"} step is complete.`,
|
|
2175
|
+
`Valid outcomes: ${outcomes.join(", ")}`,
|
|
2176
|
+
transitionLines,
|
|
2177
|
+
gateLine,
|
|
2178
|
+
"",
|
|
2179
|
+
"Put a concise handoff in `summary`. Do not call the completion tool alongside other tool calls. If the workflow definition or environment is wrong, use an outcome that transitions to `$pause`."
|
|
2180
|
+
].join(`
|
|
2181
|
+
`);
|
|
2182
|
+
}
|
|
2183
|
+
function buildDelegatedStepTask(workflow, run, policyEnvelope) {
|
|
2184
|
+
return buildStepTask(workflow, run, "delegated", policyEnvelope);
|
|
2185
|
+
}
|
|
2186
|
+
function buildMainStepTask(workflow, run) {
|
|
2187
|
+
return buildStepTask(workflow, run, "main");
|
|
2188
|
+
}
|
|
2189
|
+
function buildMainWorkflowNotice(workflow, run) {
|
|
2190
|
+
const step = workflow.definition.steps[run.currentStepId];
|
|
2191
|
+
if (!step)
|
|
2192
|
+
throw new Error(`unknown workflow step "${run.currentStepId}"`);
|
|
2193
|
+
if (!step.subagent) {
|
|
2194
|
+
return [
|
|
2195
|
+
"# Active main-agent workflow",
|
|
2196
|
+
"",
|
|
2197
|
+
`Workflow "${workflow.definition.id}" is running step "${run.currentStepId}" (${step.title}) in this session.`,
|
|
2198
|
+
"Perform only the active workflow step with its allowed resources.",
|
|
2199
|
+
"Call `workflow_complete_step` exactly once when finished.",
|
|
2200
|
+
"Use `/workflow-pause` to halt and repair the workflow before resuming."
|
|
2201
|
+
].join(`
|
|
2202
|
+
`);
|
|
2203
|
+
}
|
|
2204
|
+
return [
|
|
2205
|
+
"# Active subagent workflow",
|
|
2206
|
+
"",
|
|
2207
|
+
`Workflow "${workflow.definition.id}" is running step "${run.currentStepId}" (${step.title}) in a separate pi-subagents child process.`,
|
|
2208
|
+
"Do not perform the workflow step in this main session.",
|
|
2209
|
+
"Use `/workflow-status` to inspect it or `/workflow-pause` to cancel the child and repair the workflow before resuming."
|
|
2210
|
+
].join(`
|
|
2211
|
+
`);
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
// src/policy/approved-commands.ts
|
|
2215
|
+
import { basename as basename3 } from "node:path";
|
|
2216
|
+
|
|
2217
|
+
// src/policy/bash.ts
|
|
2218
|
+
import { basename as basename2 } from "node:path";
|
|
2219
|
+
var UNQUOTED_SHELL_META = new Set([
|
|
2220
|
+
";",
|
|
2221
|
+
"&",
|
|
2222
|
+
"|",
|
|
2223
|
+
"<",
|
|
2224
|
+
">",
|
|
2225
|
+
`
|
|
2226
|
+
`,
|
|
2227
|
+
"\r",
|
|
2228
|
+
"`",
|
|
2229
|
+
"$",
|
|
2230
|
+
"(",
|
|
2231
|
+
")",
|
|
2232
|
+
"{",
|
|
2233
|
+
"}",
|
|
2234
|
+
"#",
|
|
2235
|
+
"\x00"
|
|
2236
|
+
]);
|
|
2237
|
+
var PATHNAME_EXPANSION = new Set(["*", "?", "[", "]", "~"]);
|
|
2238
|
+
var WRAPPER_COMMANDS = new Set([
|
|
2239
|
+
"bash",
|
|
2240
|
+
"builtin",
|
|
2241
|
+
"command",
|
|
2242
|
+
"env",
|
|
2243
|
+
"exec",
|
|
2244
|
+
"fish",
|
|
2245
|
+
"sh",
|
|
2246
|
+
"time",
|
|
2247
|
+
"xargs",
|
|
2248
|
+
"zsh"
|
|
2249
|
+
]);
|
|
2250
|
+
var READ_ONLY_EXECUTABLES = new Set([
|
|
2251
|
+
"grep",
|
|
2252
|
+
"head",
|
|
2253
|
+
"ls",
|
|
2254
|
+
"pwd",
|
|
2255
|
+
"rg",
|
|
2256
|
+
"stat",
|
|
2257
|
+
"tail",
|
|
2258
|
+
"wc"
|
|
2259
|
+
]);
|
|
2260
|
+
var READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
2261
|
+
"diff",
|
|
2262
|
+
"grep",
|
|
2263
|
+
"log",
|
|
2264
|
+
"ls-files",
|
|
2265
|
+
"rev-parse",
|
|
2266
|
+
"show",
|
|
2267
|
+
"status"
|
|
2268
|
+
]);
|
|
2269
|
+
var DANGEROUS_GIT_OPTIONS = [
|
|
2270
|
+
"--config-env",
|
|
2271
|
+
"--exec",
|
|
2272
|
+
"--ext-diff",
|
|
2273
|
+
"--open-files-in-pager",
|
|
2274
|
+
"--output",
|
|
2275
|
+
"--textconv"
|
|
2276
|
+
];
|
|
2277
|
+
var DANGEROUS_GIT_SHORT_OPTIONS = [
|
|
2278
|
+
{ subcommand: "grep", option: "-O" }
|
|
2279
|
+
];
|
|
2280
|
+
var HOSTED_API_MUTATION_OPTIONS = [
|
|
2281
|
+
"--field",
|
|
2282
|
+
"--form",
|
|
2283
|
+
"--input",
|
|
2284
|
+
"--method",
|
|
2285
|
+
"--raw-field",
|
|
2286
|
+
"-F",
|
|
2287
|
+
"-X",
|
|
2288
|
+
"-f"
|
|
2289
|
+
];
|
|
2290
|
+
function reject(reason) {
|
|
2291
|
+
return { allowed: false, reason };
|
|
2292
|
+
}
|
|
2293
|
+
function tokenizeRestrictedCommand(command) {
|
|
2294
|
+
if (!command.trim())
|
|
2295
|
+
return { error: "empty Bash command" };
|
|
2296
|
+
const tokens = [];
|
|
2297
|
+
let token = "";
|
|
2298
|
+
let quote;
|
|
2299
|
+
let escaping = false;
|
|
2300
|
+
let tokenStarted = false;
|
|
2301
|
+
for (const character of command) {
|
|
2302
|
+
if (quote === "'") {
|
|
2303
|
+
if (character === `
|
|
2304
|
+
` || character === "\r" || character === "\x00") {
|
|
2305
|
+
return { error: "multiline and null characters are not allowed" };
|
|
2306
|
+
}
|
|
2307
|
+
if (character === "'") {
|
|
2308
|
+
quote = undefined;
|
|
2309
|
+
} else {
|
|
2310
|
+
token += character;
|
|
2311
|
+
}
|
|
2312
|
+
tokenStarted = true;
|
|
2313
|
+
continue;
|
|
2314
|
+
}
|
|
2315
|
+
if (quote === '"') {
|
|
2316
|
+
if (character === `
|
|
2317
|
+
` || character === "\r" || character === "\x00") {
|
|
2318
|
+
return { error: "multiline and null characters are not allowed" };
|
|
2319
|
+
}
|
|
2320
|
+
if (character === '"') {
|
|
2321
|
+
quote = undefined;
|
|
2322
|
+
} else if (character === "$" || character === "`" || character === "\\") {
|
|
2323
|
+
return {
|
|
2324
|
+
error: "substitutions and escapes are not allowed inside double quotes"
|
|
2325
|
+
};
|
|
2326
|
+
} else {
|
|
2327
|
+
token += character;
|
|
2328
|
+
}
|
|
2329
|
+
tokenStarted = true;
|
|
2330
|
+
continue;
|
|
2331
|
+
}
|
|
2332
|
+
if (escaping) {
|
|
2333
|
+
if (character === `
|
|
2334
|
+
` || character === "\r" || character === "\x00") {
|
|
2335
|
+
return { error: "multiline and null characters are not allowed" };
|
|
2336
|
+
}
|
|
2337
|
+
token += character;
|
|
2338
|
+
escaping = false;
|
|
2339
|
+
tokenStarted = true;
|
|
2340
|
+
continue;
|
|
2341
|
+
}
|
|
2342
|
+
if (character === "\\") {
|
|
2343
|
+
escaping = true;
|
|
2344
|
+
tokenStarted = true;
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
if (quote) {
|
|
2348
|
+
if (character === quote) {
|
|
2349
|
+
quote = undefined;
|
|
2350
|
+
} else {
|
|
2351
|
+
token += character;
|
|
2352
|
+
}
|
|
2353
|
+
tokenStarted = true;
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2356
|
+
if (character === "'" || character === '"') {
|
|
2357
|
+
quote = character;
|
|
2358
|
+
tokenStarted = true;
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
if (UNQUOTED_SHELL_META.has(character)) {
|
|
2362
|
+
return {
|
|
2363
|
+
error: "shell operators, substitutions, expansions, and comments are not allowed"
|
|
2364
|
+
};
|
|
2365
|
+
}
|
|
2366
|
+
if (PATHNAME_EXPANSION.has(character)) {
|
|
2367
|
+
return {
|
|
2368
|
+
error: "unquoted pathname and tilde expansion are not allowed"
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
if (/\s/.test(character)) {
|
|
2372
|
+
if (tokenStarted) {
|
|
2373
|
+
tokens.push(token);
|
|
2374
|
+
token = "";
|
|
2375
|
+
tokenStarted = false;
|
|
2376
|
+
}
|
|
2377
|
+
continue;
|
|
2378
|
+
}
|
|
2379
|
+
token += character;
|
|
2380
|
+
tokenStarted = true;
|
|
2381
|
+
}
|
|
2382
|
+
if (escaping)
|
|
2383
|
+
return { error: "trailing Bash escape is not allowed" };
|
|
2384
|
+
if (quote)
|
|
2385
|
+
return { error: "unterminated Bash quote" };
|
|
2386
|
+
if (tokenStarted)
|
|
2387
|
+
tokens.push(token);
|
|
2388
|
+
if (tokens.length === 0)
|
|
2389
|
+
return { error: "empty Bash command" };
|
|
2390
|
+
return { tokens };
|
|
2391
|
+
}
|
|
2392
|
+
function parseRestrictedGitCommand(tokens) {
|
|
2393
|
+
let index = 1;
|
|
2394
|
+
while (index < tokens.length) {
|
|
2395
|
+
const token = tokens[index];
|
|
2396
|
+
if (token === "-C") {
|
|
2397
|
+
if (!tokens[index + 1])
|
|
2398
|
+
return;
|
|
2399
|
+
index += 2;
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
if (token === "--no-pager") {
|
|
2403
|
+
index += 1;
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
if (!token || token.startsWith("-"))
|
|
2407
|
+
return;
|
|
2408
|
+
return { subcommand: token, subcommandIndex: index };
|
|
2409
|
+
}
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
function matchesRule(tokens, rule) {
|
|
2413
|
+
if (tokens[0] !== rule.executable)
|
|
2414
|
+
return false;
|
|
2415
|
+
return rule.argsPrefix.every((expected, index) => tokens[index + 1] === expected);
|
|
2416
|
+
}
|
|
2417
|
+
function hasOption(tokens, option) {
|
|
2418
|
+
return tokens.some((token) => token === option || token.startsWith(`${option}=`));
|
|
2419
|
+
}
|
|
2420
|
+
function authorizeReadOnly(tokens) {
|
|
2421
|
+
const executable = tokens[0] ?? "";
|
|
2422
|
+
if (READ_ONLY_EXECUTABLES.has(executable)) {
|
|
2423
|
+
if (executable === "rg" && (hasOption(tokens, "--pre") || hasOption(tokens, "--pre-glob"))) {
|
|
2424
|
+
return reject("rg preprocessors are not allowed in read-only mode");
|
|
2425
|
+
}
|
|
2426
|
+
return { allowed: true, tokens: [...tokens] };
|
|
2427
|
+
}
|
|
2428
|
+
if (executable !== "git") {
|
|
2429
|
+
return reject(`"${executable}" is not in the read-only Bash preset`);
|
|
2430
|
+
}
|
|
2431
|
+
const gitCommand = parseRestrictedGitCommand(tokens);
|
|
2432
|
+
if (!gitCommand || !READ_ONLY_GIT_SUBCOMMANDS.has(gitCommand.subcommand)) {
|
|
2433
|
+
return reject(`git subcommand "${gitCommand?.subcommand ?? ""}" is not read-only`);
|
|
2434
|
+
}
|
|
2435
|
+
const dangerousOption = DANGEROUS_GIT_OPTIONS.find((option) => hasOption(tokens, option));
|
|
2436
|
+
if (dangerousOption) {
|
|
2437
|
+
return reject(`git option "${dangerousOption}" is not allowed in read-only mode`);
|
|
2438
|
+
}
|
|
2439
|
+
const dangerousShortOption = DANGEROUS_GIT_SHORT_OPTIONS.find(({ subcommand: matchedSubcommand, option }) => gitCommand.subcommand === matchedSubcommand && tokens.slice(gitCommand.subcommandIndex + 1).some((token) => token === option || token.startsWith(option)));
|
|
2440
|
+
if (dangerousShortOption) {
|
|
2441
|
+
return reject(`git option "${dangerousShortOption.option}" is not allowed in read-only mode`);
|
|
2442
|
+
}
|
|
2443
|
+
return { allowed: true, tokens: [...tokens] };
|
|
2444
|
+
}
|
|
2445
|
+
function usesReadOnlyPreset(tokens) {
|
|
2446
|
+
const executable = tokens[0] ?? "";
|
|
2447
|
+
return READ_ONLY_EXECUTABLES.has(executable) || executable === "git" && READ_ONLY_GIT_SUBCOMMANDS.has(parseRestrictedGitCommand(tokens)?.subcommand ?? "");
|
|
2448
|
+
}
|
|
2449
|
+
function authorizeHostedApiRead(tokens) {
|
|
2450
|
+
const executable = basename2(tokens[0] ?? "");
|
|
2451
|
+
if (executable !== "gh" && executable !== "glab" || tokens[1] !== "api") {
|
|
2452
|
+
return { allowed: true, tokens: [...tokens] };
|
|
2453
|
+
}
|
|
2454
|
+
const mutationOption = HOSTED_API_MUTATION_OPTIONS.find((option) => tokens.slice(2).some((token) => token === option || token.startsWith(`${option}=`) || option.length === 2 && token.startsWith(option)));
|
|
2455
|
+
if (mutationOption) {
|
|
2456
|
+
return reject(`${executable} api option "${mutationOption}" is not allowed by a static read-only rule`);
|
|
2457
|
+
}
|
|
2458
|
+
return { allowed: true, tokens: [...tokens] };
|
|
2459
|
+
}
|
|
2460
|
+
function authorizeBash(command, permission, approvedCommands = []) {
|
|
2461
|
+
if ((permission.approvedSources?.length ?? 0) > 0 && approvedCommands.includes(command)) {
|
|
2462
|
+
return { allowed: true };
|
|
2463
|
+
}
|
|
2464
|
+
if (permission.mode === "unrestricted") {
|
|
2465
|
+
return { allowed: true };
|
|
2466
|
+
}
|
|
2467
|
+
if (permission.mode === "deny") {
|
|
2468
|
+
return reject("Bash is disabled for this workflow step");
|
|
2469
|
+
}
|
|
2470
|
+
const parsed = tokenizeRestrictedCommand(command);
|
|
2471
|
+
if (!parsed.tokens)
|
|
2472
|
+
return reject(parsed.error ?? "invalid Bash command");
|
|
2473
|
+
const executable = parsed.tokens[0] ?? "";
|
|
2474
|
+
if (WRAPPER_COMMANDS.has(basename2(executable))) {
|
|
2475
|
+
return reject(`shell wrapper "${executable}" is not allowed in restricted mode`);
|
|
2476
|
+
}
|
|
2477
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(executable)) {
|
|
2478
|
+
return reject("environment assignments are not allowed in restricted mode");
|
|
2479
|
+
}
|
|
2480
|
+
if (permission.mode === "read-only") {
|
|
2481
|
+
return authorizeReadOnly(parsed.tokens);
|
|
2482
|
+
}
|
|
2483
|
+
const rule = permission.allow.find((candidate) => matchesRule(parsed.tokens ?? [], candidate));
|
|
2484
|
+
if (!rule) {
|
|
2485
|
+
return reject(`command does not match this step's Bash allow-list`);
|
|
2486
|
+
}
|
|
2487
|
+
if (usesReadOnlyPreset(parsed.tokens)) {
|
|
2488
|
+
return authorizeReadOnly(parsed.tokens);
|
|
2489
|
+
}
|
|
2490
|
+
return authorizeHostedApiRead(parsed.tokens);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// src/policy/approved-commands.ts
|
|
2494
|
+
var SHELL_WRAPPERS = new Set([
|
|
2495
|
+
"bash",
|
|
2496
|
+
"env",
|
|
2497
|
+
"exec",
|
|
2498
|
+
"fish",
|
|
2499
|
+
"sh",
|
|
2500
|
+
"xargs",
|
|
2501
|
+
"zsh"
|
|
2502
|
+
]);
|
|
2503
|
+
var REMOTE_EXECUTABLES = new Set(["curl", "scp", "ssh", "rsync", "wget"]);
|
|
2504
|
+
var FORBIDDEN_LONG_PUSH_OPTIONS = [
|
|
2505
|
+
"--force",
|
|
2506
|
+
"--force-if-includes",
|
|
2507
|
+
"--force-with-lease",
|
|
2508
|
+
"--all",
|
|
2509
|
+
"--delete",
|
|
2510
|
+
"--mirror",
|
|
2511
|
+
"--prune",
|
|
2512
|
+
"--tags"
|
|
2513
|
+
];
|
|
2514
|
+
var PUBLISH_EXECUTABLES = new Set(["bun", "cargo", "npm", "pnpm", "yarn"]);
|
|
2515
|
+
var LOCAL_VERIFICATION_GIT_SUBCOMMANDS = new Set([
|
|
2516
|
+
"add",
|
|
2517
|
+
"branch",
|
|
2518
|
+
"commit",
|
|
2519
|
+
"diff",
|
|
2520
|
+
"grep",
|
|
2521
|
+
"log",
|
|
2522
|
+
"ls-files",
|
|
2523
|
+
"rev-parse",
|
|
2524
|
+
"show",
|
|
2525
|
+
"status",
|
|
2526
|
+
"worktree"
|
|
2527
|
+
]);
|
|
2528
|
+
function hasDestructivePushShortOption(token) {
|
|
2529
|
+
return token.startsWith("-") && !token.startsWith("--") && (token.slice(1).includes("f") || token.slice(1).includes("d"));
|
|
2530
|
+
}
|
|
2531
|
+
function hasForbiddenLongPushOption(token) {
|
|
2532
|
+
if (!token.startsWith("--") || token === "--")
|
|
2533
|
+
return false;
|
|
2534
|
+
const optionName = token.split("=", 1)[0] ?? token;
|
|
2535
|
+
return FORBIDDEN_LONG_PUSH_OPTIONS.some((option) => option.startsWith(optionName));
|
|
2536
|
+
}
|
|
2537
|
+
function hasEmptyPushRefspecSide(token) {
|
|
2538
|
+
const separator = token.indexOf(":");
|
|
2539
|
+
return separator >= 0 && (separator === 0 || separator === token.length - 1);
|
|
2540
|
+
}
|
|
2541
|
+
function isObject4(value) {
|
|
2542
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2543
|
+
}
|
|
2544
|
+
function parseJsonDocuments(text) {
|
|
2545
|
+
const documents = [];
|
|
2546
|
+
const trimmed = text.trim();
|
|
2547
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
2548
|
+
try {
|
|
2549
|
+
documents.push(JSON.parse(trimmed));
|
|
2550
|
+
} catch {}
|
|
2551
|
+
}
|
|
2552
|
+
const fences = /```(?:json)?[ \t]*\r?\n([\s\S]*?)```/gi;
|
|
2553
|
+
for (const match of text.matchAll(fences)) {
|
|
2554
|
+
const candidate = match[1]?.trim();
|
|
2555
|
+
if (!candidate)
|
|
2556
|
+
continue;
|
|
2557
|
+
try {
|
|
2558
|
+
documents.push(JSON.parse(candidate));
|
|
2559
|
+
} catch {}
|
|
2560
|
+
}
|
|
2561
|
+
return documents;
|
|
2562
|
+
}
|
|
2563
|
+
function verificationCommands(value, role) {
|
|
2564
|
+
if (!isObject4(value) || !Array.isArray(value.repositories))
|
|
2565
|
+
return [];
|
|
2566
|
+
const commands = [];
|
|
2567
|
+
for (const repository of value.repositories) {
|
|
2568
|
+
if (!isObject4(repository) || !Array.isArray(repository[role]))
|
|
2569
|
+
continue;
|
|
2570
|
+
for (const check of repository[role]) {
|
|
2571
|
+
if (isObject4(check) && typeof check.command === "string") {
|
|
2572
|
+
commands.push(check.command);
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
return commands;
|
|
2577
|
+
}
|
|
2578
|
+
function remoteActionCommands(value) {
|
|
2579
|
+
if (!isObject4(value) || !Array.isArray(value.actions))
|
|
2580
|
+
return [];
|
|
2581
|
+
const commands = [];
|
|
2582
|
+
for (const action of value.actions) {
|
|
2583
|
+
if (isObject4(action) && action.toolName === "bash" && isObject4(action.input) && typeof action.input.command === "string") {
|
|
2584
|
+
commands.push(action.input.command);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return commands;
|
|
2588
|
+
}
|
|
2589
|
+
function containsPublishOperation(tokens) {
|
|
2590
|
+
return tokens.slice(1).some((token) => token.length >= 3 && "publish".startsWith(token));
|
|
2591
|
+
}
|
|
2592
|
+
function safeVerificationCommand(command) {
|
|
2593
|
+
const parsed = tokenizeRestrictedCommand(command);
|
|
2594
|
+
if (!parsed.tokens)
|
|
2595
|
+
return false;
|
|
2596
|
+
const executable = basename3(parsed.tokens[0] ?? "");
|
|
2597
|
+
if (SHELL_WRAPPERS.has(executable) || REMOTE_EXECUTABLES.has(executable)) {
|
|
2598
|
+
return false;
|
|
2599
|
+
}
|
|
2600
|
+
const subcommand = executable === "git" ? parseRestrictedGitCommand(parsed.tokens)?.subcommand : parsed.tokens[1];
|
|
2601
|
+
if (executable === "git" && (!subcommand || !LOCAL_VERIFICATION_GIT_SUBCOMMANDS.has(subcommand))) {
|
|
2602
|
+
return false;
|
|
2603
|
+
}
|
|
2604
|
+
if (executable === "gh" || executable === "glab")
|
|
2605
|
+
return false;
|
|
2606
|
+
if (PUBLISH_EXECUTABLES.has(executable) && containsPublishOperation(parsed.tokens)) {
|
|
2607
|
+
return false;
|
|
2608
|
+
}
|
|
2609
|
+
if (executable === "docker" && parsed.tokens.slice(1).includes("push")) {
|
|
2610
|
+
return false;
|
|
2611
|
+
}
|
|
2612
|
+
return true;
|
|
2613
|
+
}
|
|
2614
|
+
function safeRemoteActionCommand(command) {
|
|
2615
|
+
const parsed = tokenizeRestrictedCommand(command);
|
|
2616
|
+
if (!parsed.tokens)
|
|
2617
|
+
return false;
|
|
2618
|
+
const executable = parsed.tokens[0];
|
|
2619
|
+
const subcommand = executable === "git" ? parseRestrictedGitCommand(parsed.tokens)?.subcommand : parsed.tokens[1];
|
|
2620
|
+
if (executable === "gh" || executable === "glab") {
|
|
2621
|
+
if (subcommand !== "api")
|
|
2622
|
+
return false;
|
|
2623
|
+
return !parsed.tokens.slice(2).some((token, index, apiTokens) => {
|
|
2624
|
+
const upper = token.toUpperCase();
|
|
2625
|
+
return upper === "--METHOD=DELETE" || upper === "-XDELETE" || (upper === "--METHOD" || upper === "-X") && apiTokens[index + 1]?.toUpperCase() === "DELETE";
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
if (executable !== "git" || subcommand !== "push")
|
|
2629
|
+
return false;
|
|
2630
|
+
return !parsed.tokens.slice(1).some((token) => hasForbiddenLongPushOption(token) || token.startsWith("+") || hasEmptyPushRefspecSide(token) || hasDestructivePushShortOption(token));
|
|
2631
|
+
}
|
|
2632
|
+
function extractApprovedBashCommands(artifact, sources) {
|
|
2633
|
+
if (!artifact.trim() || sources.length === 0)
|
|
2634
|
+
return [];
|
|
2635
|
+
const commands = [];
|
|
2636
|
+
for (const document of parseJsonDocuments(artifact)) {
|
|
2637
|
+
for (const source of sources) {
|
|
2638
|
+
if (source === "verification-worker") {
|
|
2639
|
+
commands.push(...verificationCommands(document, "worker").filter(safeVerificationCommand));
|
|
2640
|
+
} else if (source === "verification-reviewer") {
|
|
2641
|
+
commands.push(...verificationCommands(document, "reviewer").filter(safeVerificationCommand));
|
|
2642
|
+
} else {
|
|
2643
|
+
commands.push(...remoteActionCommands(document).filter(safeRemoteActionCommand));
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
return [...new Set(commands)];
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// src/policy/completion-batch.ts
|
|
2651
|
+
function toolCalls(message) {
|
|
2652
|
+
if (message === null || typeof message !== "object")
|
|
2653
|
+
return [];
|
|
2654
|
+
const candidate = message;
|
|
2655
|
+
if (candidate.role !== "assistant" || !Array.isArray(candidate.content))
|
|
2656
|
+
return [];
|
|
2657
|
+
return candidate.content.filter((item) => item !== null && typeof item === "object" && item.type === "toolCall" && typeof item.id === "string" && typeof item.name === "string");
|
|
2658
|
+
}
|
|
2659
|
+
function invalidCompletionCallIds(message, completionTool) {
|
|
2660
|
+
const calls = toolCalls(message);
|
|
2661
|
+
if (calls.length === 1 && calls[0]?.name === completionTool)
|
|
2662
|
+
return new Set;
|
|
2663
|
+
return new Set(calls.filter((call) => call.name === completionTool).map((call) => call.id));
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// src/policy/immutable-input.ts
|
|
2667
|
+
function freezeToolInput(input) {
|
|
2668
|
+
const seen = new WeakSet;
|
|
2669
|
+
const freeze = (value) => {
|
|
2670
|
+
if (value === null || typeof value !== "object" || seen.has(value))
|
|
2671
|
+
return;
|
|
2672
|
+
seen.add(value);
|
|
2673
|
+
for (const child of Object.values(value))
|
|
2674
|
+
freeze(child);
|
|
2675
|
+
Object.freeze(value);
|
|
2676
|
+
};
|
|
2677
|
+
freeze(input);
|
|
2678
|
+
return input;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// src/policy/tools.ts
|
|
2682
|
+
function reject2(reason) {
|
|
2683
|
+
return { allowed: false, reason };
|
|
2684
|
+
}
|
|
2685
|
+
function sourceText(tool) {
|
|
2686
|
+
return `${tool.sourceInfo?.source ?? ""}
|
|
2687
|
+
${tool.sourceInfo?.path ?? ""}`.toLowerCase();
|
|
2688
|
+
}
|
|
2689
|
+
function isMcpAdapterTool(tool) {
|
|
2690
|
+
return sourceText(tool).includes("pi-mcp-adapter");
|
|
2691
|
+
}
|
|
2692
|
+
function matchesExtensionSelector(tool, selector) {
|
|
2693
|
+
const source = tool.sourceInfo?.source;
|
|
2694
|
+
if (source === "builtin" || source === "sdk")
|
|
2695
|
+
return false;
|
|
2696
|
+
return sourceText(tool).includes(selector.toLowerCase());
|
|
2697
|
+
}
|
|
2698
|
+
function resolveActiveTools(inventory, step, completionToolName) {
|
|
2699
|
+
const exact = new Set(step.permissions.tools);
|
|
2700
|
+
const selected = inventory.filter((tool) => tool.name === completionToolName || exact.has(tool.name) || tool.name === "mcp" && step.permissions.mcp.length > 0 || !isMcpAdapterTool(tool) && step.permissions.extensions.some((selector) => matchesExtensionSelector(tool, selector))).map((tool) => tool.name);
|
|
2701
|
+
return [...new Set(selected)];
|
|
2702
|
+
}
|
|
2703
|
+
function selectorAllows(selectors, server, tool) {
|
|
2704
|
+
return selectors.some((selector) => {
|
|
2705
|
+
const separator = selector.indexOf("/");
|
|
2706
|
+
if (separator === -1)
|
|
2707
|
+
return selector === server;
|
|
2708
|
+
return selector.slice(0, separator) === server && selector.slice(separator + 1) === tool;
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
function authorizeMcpProxy(input, selectors) {
|
|
2712
|
+
if (selectors.length === 0) {
|
|
2713
|
+
return reject2("MCP access is disabled for this workflow step");
|
|
2714
|
+
}
|
|
2715
|
+
const unsupportedModes = [
|
|
2716
|
+
"action",
|
|
2717
|
+
"connect",
|
|
2718
|
+
"describe",
|
|
2719
|
+
"search",
|
|
2720
|
+
"regex",
|
|
2721
|
+
"includeSchemas"
|
|
2722
|
+
].filter((field) => input[field] !== undefined);
|
|
2723
|
+
if (unsupportedModes.length > 0) {
|
|
2724
|
+
return reject2(`MCP proxy mode "${unsupportedModes[0]}" is disabled; use an explicit server and tool`);
|
|
2725
|
+
}
|
|
2726
|
+
if (typeof input.server !== "string" || !input.server.trim()) {
|
|
2727
|
+
return reject2("MCP proxy calls must name an explicit server");
|
|
2728
|
+
}
|
|
2729
|
+
if (typeof input.tool !== "string" || !input.tool.trim()) {
|
|
2730
|
+
return reject2("MCP proxy calls must name an explicit tool");
|
|
2731
|
+
}
|
|
2732
|
+
const server = input.server.trim();
|
|
2733
|
+
const tool = input.tool.trim();
|
|
2734
|
+
if (!selectorAllows(selectors, server, tool)) {
|
|
2735
|
+
return reject2(`MCP tool "${server}/${tool}" is not allowed for this workflow step`);
|
|
2736
|
+
}
|
|
2737
|
+
return { allowed: true };
|
|
2738
|
+
}
|
|
2739
|
+
function authorizeToolCall(toolName, input, step, inventory, approvedBashCommands = []) {
|
|
2740
|
+
const tool = inventory.find((candidate) => candidate.name === toolName);
|
|
2741
|
+
const allowedByName = step.permissions.tools.includes(toolName);
|
|
2742
|
+
const allowedByExtension = tool !== undefined && !isMcpAdapterTool(tool) && step.permissions.extensions.some((selector) => matchesExtensionSelector(tool, selector));
|
|
2743
|
+
if (toolName === "mcp") {
|
|
2744
|
+
return authorizeMcpProxy(input, step.permissions.mcp);
|
|
2745
|
+
}
|
|
2746
|
+
if (!allowedByName && !allowedByExtension) {
|
|
2747
|
+
return reject2(`tool "${toolName}" is not allowed for this workflow step`);
|
|
2748
|
+
}
|
|
2749
|
+
if (toolName === "bash") {
|
|
2750
|
+
const command = input.command;
|
|
2751
|
+
if (typeof command !== "string")
|
|
2752
|
+
return reject2("Bash call is missing command text");
|
|
2753
|
+
const result = authorizeBash(command, step.permissions.bash, approvedBashCommands);
|
|
2754
|
+
return result.allowed ? { allowed: true } : reject2(result.reason ?? "Bash command is not allowed");
|
|
2755
|
+
}
|
|
2756
|
+
return { allowed: true };
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
// src/runtime/completion-tool.ts
|
|
2760
|
+
import { Type } from "typebox";
|
|
2761
|
+
var WORKFLOW_COMPLETION_TOOL = "workflow_complete_step";
|
|
2762
|
+
var WORKFLOW_COMPLETION_PARAMETERS = Type.Object({
|
|
2763
|
+
outcome: Type.String({
|
|
2764
|
+
description: "One exact outcome allowed by the active workflow step"
|
|
2765
|
+
}),
|
|
2766
|
+
summary: Type.String({
|
|
2767
|
+
description: "Concise checkpoint and handoff for the next workflow step",
|
|
2768
|
+
maxLength: 50000
|
|
2769
|
+
}),
|
|
2770
|
+
artifact: Type.Optional(Type.String({
|
|
2771
|
+
description: "Full artifact required when submitting to a review gate",
|
|
2772
|
+
maxLength: 200000
|
|
2773
|
+
}))
|
|
2774
|
+
}, { additionalProperties: false });
|
|
2775
|
+
|
|
2776
|
+
// src/runtime/main-step-runtime.ts
|
|
2777
|
+
class MainStepRuntime {
|
|
2778
|
+
pi;
|
|
2779
|
+
active;
|
|
2780
|
+
pendingResult;
|
|
2781
|
+
invalidCompletionCalls = new Set;
|
|
2782
|
+
suspended = false;
|
|
2783
|
+
constructor(pi) {
|
|
2784
|
+
this.pi = pi;
|
|
2785
|
+
this.registerLifecycle();
|
|
2786
|
+
this.registerPolicy();
|
|
2787
|
+
this.registerCompletionTool();
|
|
2788
|
+
}
|
|
2789
|
+
get activeStepId() {
|
|
2790
|
+
return this.active?.stepId;
|
|
2791
|
+
}
|
|
2792
|
+
activate(execution) {
|
|
2793
|
+
if (this.active) {
|
|
2794
|
+
throw new Error(`main workflow step "${this.active.stepId}" is still active`);
|
|
2795
|
+
}
|
|
2796
|
+
this.suspended = false;
|
|
2797
|
+
this.active = execution;
|
|
2798
|
+
this.pendingResult = undefined;
|
|
2799
|
+
this.invalidCompletionCalls.clear();
|
|
2800
|
+
this.pi.setActiveTools(resolveActiveTools(this.pi.getAllTools(), execution.step, WORKFLOW_COMPLETION_TOOL));
|
|
2801
|
+
}
|
|
2802
|
+
deactivate() {
|
|
2803
|
+
const wasActive = this.active !== undefined;
|
|
2804
|
+
this.active = undefined;
|
|
2805
|
+
this.pendingResult = undefined;
|
|
2806
|
+
this.invalidCompletionCalls.clear();
|
|
2807
|
+
return wasActive;
|
|
2808
|
+
}
|
|
2809
|
+
suspend() {
|
|
2810
|
+
const wasActive = this.deactivate();
|
|
2811
|
+
if (wasActive) {
|
|
2812
|
+
this.suspended = true;
|
|
2813
|
+
this.pi.setActiveTools([]);
|
|
2814
|
+
}
|
|
2815
|
+
return wasActive;
|
|
2816
|
+
}
|
|
2817
|
+
release() {
|
|
2818
|
+
this.suspended = false;
|
|
2819
|
+
}
|
|
2820
|
+
registerLifecycle() {
|
|
2821
|
+
const reset = () => {
|
|
2822
|
+
this.suspended = false;
|
|
2823
|
+
this.deactivate();
|
|
2824
|
+
this.pi.setActiveTools(this.pi.getActiveTools().filter((tool) => tool !== WORKFLOW_COMPLETION_TOOL));
|
|
2825
|
+
};
|
|
2826
|
+
this.pi.on("session_start", reset);
|
|
2827
|
+
this.pi.on("session_tree", reset);
|
|
2828
|
+
this.pi.on("session_shutdown", () => {
|
|
2829
|
+
this.suspended = false;
|
|
2830
|
+
this.deactivate();
|
|
2831
|
+
});
|
|
2832
|
+
this.pi.on("agent_settled", (_event, context) => {
|
|
2833
|
+
const active = this.active;
|
|
2834
|
+
if (!active)
|
|
2835
|
+
return;
|
|
2836
|
+
const result = this.pendingResult;
|
|
2837
|
+
this.active = undefined;
|
|
2838
|
+
this.pendingResult = undefined;
|
|
2839
|
+
this.invalidCompletionCalls.clear();
|
|
2840
|
+
return active.onSettled(result, context);
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
registerPolicy() {
|
|
2844
|
+
this.pi.on("turn_start", () => {
|
|
2845
|
+
this.invalidCompletionCalls.clear();
|
|
2846
|
+
});
|
|
2847
|
+
this.pi.on("message_end", (event) => {
|
|
2848
|
+
if (!this.active)
|
|
2849
|
+
return;
|
|
2850
|
+
const invalid = invalidCompletionCallIds(event.message, WORKFLOW_COMPLETION_TOOL);
|
|
2851
|
+
if (invalid.size > 0 || event.message.role === "assistant") {
|
|
2852
|
+
this.invalidCompletionCalls = invalid;
|
|
2853
|
+
}
|
|
2854
|
+
});
|
|
2855
|
+
this.pi.on("tool_call", (event) => {
|
|
2856
|
+
if (this.suspended) {
|
|
2857
|
+
return {
|
|
2858
|
+
block: true,
|
|
2859
|
+
reason: "Main-agent workflow step is suspended"
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
if (!this.active) {
|
|
2863
|
+
if (event.toolName === WORKFLOW_COMPLETION_TOOL) {
|
|
2864
|
+
return {
|
|
2865
|
+
block: true,
|
|
2866
|
+
reason: "No main-agent workflow step is active"
|
|
2867
|
+
};
|
|
2868
|
+
}
|
|
2869
|
+
return;
|
|
2870
|
+
}
|
|
2871
|
+
if (this.invalidCompletionCalls.has(event.toolCallId)) {
|
|
2872
|
+
return {
|
|
2873
|
+
block: true,
|
|
2874
|
+
reason: `${WORKFLOW_COMPLETION_TOOL} must be the only tool call in its message`
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
if (event.toolName === WORKFLOW_COMPLETION_TOOL) {
|
|
2878
|
+
freezeToolInput(event.input);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
const authorization = authorizeToolCall(event.toolName, event.input, this.active.step, this.pi.getAllTools(), this.active.approvedBashCommands);
|
|
2882
|
+
if (!authorization.allowed) {
|
|
2883
|
+
return {
|
|
2884
|
+
block: true,
|
|
2885
|
+
reason: authorization.reason ?? "Tool blocked by main workflow policy"
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
freezeToolInput(event.input);
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
registerCompletionTool() {
|
|
2892
|
+
this.pi.registerTool({
|
|
2893
|
+
name: WORKFLOW_COMPLETION_TOOL,
|
|
2894
|
+
label: "Complete Workflow Step",
|
|
2895
|
+
description: "Return one validated result from an active workflow step",
|
|
2896
|
+
promptSnippet: "Complete the active workflow step",
|
|
2897
|
+
promptGuidelines: [
|
|
2898
|
+
"Call workflow_complete_step alone after all active workflow-step work is complete."
|
|
2899
|
+
],
|
|
2900
|
+
parameters: WORKFLOW_COMPLETION_PARAMETERS,
|
|
2901
|
+
executionMode: "sequential",
|
|
2902
|
+
execute: async (_toolCallId, params) => {
|
|
2903
|
+
if (!this.active) {
|
|
2904
|
+
throw new Error("No main-agent workflow step is active");
|
|
2905
|
+
}
|
|
2906
|
+
if (this.pendingResult) {
|
|
2907
|
+
throw new Error("Main-agent workflow step already produced a result");
|
|
2908
|
+
}
|
|
2909
|
+
const result = parseWorkflowStepResult({
|
|
2910
|
+
version: 1,
|
|
2911
|
+
policyDigest: this.active.policyDigest,
|
|
2912
|
+
outcome: params.outcome,
|
|
2913
|
+
summary: params.summary,
|
|
2914
|
+
...params.artifact !== undefined ? { artifact: params.artifact } : {}
|
|
2915
|
+
}, this.active);
|
|
2916
|
+
this.pendingResult = result;
|
|
2917
|
+
this.pi.setActiveTools([]);
|
|
2918
|
+
return {
|
|
2919
|
+
content: [
|
|
2920
|
+
{
|
|
2921
|
+
type: "text",
|
|
2922
|
+
text: `Captured workflow step outcome "${result.outcome}".`
|
|
2923
|
+
}
|
|
2924
|
+
],
|
|
2925
|
+
details: {
|
|
2926
|
+
workflowId: this.active.workflowId,
|
|
2927
|
+
runId: this.active.runId,
|
|
2928
|
+
stepId: this.active.stepId,
|
|
2929
|
+
outcome: result.outcome
|
|
2930
|
+
},
|
|
2931
|
+
terminate: true
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// src/runtime/serial-task-queue.ts
|
|
2939
|
+
class SerialTaskQueue {
|
|
2940
|
+
tail = Promise.resolve();
|
|
2941
|
+
run(task) {
|
|
2942
|
+
const result = this.tail.then(task, task);
|
|
2943
|
+
this.tail = result.then(() => {
|
|
2944
|
+
return;
|
|
2945
|
+
}, () => {
|
|
2946
|
+
return;
|
|
2947
|
+
});
|
|
2948
|
+
return result;
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// src/workflow-list.ts
|
|
2953
|
+
function escapeMarkdownTableCell(value) {
|
|
2954
|
+
return value.replaceAll("\\", "\\\\").replaceAll("|", "\\|").replace(/\r\n|\r|\n/g, " ");
|
|
2955
|
+
}
|
|
2956
|
+
function formatWorkflowList(workflows) {
|
|
2957
|
+
return [
|
|
2958
|
+
"| Workflow | Command | Description |",
|
|
2959
|
+
"| --- | --- | --- |",
|
|
2960
|
+
...workflows.map((workflow) => `| \`${workflow.id}\` | \`/${workflow.command}\` | ${escapeMarkdownTableCell(workflow.description)} |`)
|
|
2961
|
+
].join(`
|
|
2962
|
+
`);
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
// src/workflow-status.ts
|
|
2966
|
+
import {
|
|
2967
|
+
matchesKey,
|
|
2968
|
+
truncateToWidth,
|
|
2969
|
+
visibleWidth,
|
|
2970
|
+
wrapTextWithAnsi
|
|
2971
|
+
} from "@earendil-works/pi-tui";
|
|
2972
|
+
var REFRESH_INTERVAL_MS = 1000;
|
|
2973
|
+
var WIDE_LAYOUT_MIN_COLUMNS = 92;
|
|
2974
|
+
var MAX_PATH_ROWS = 16;
|
|
2975
|
+
function formatWorkflowStatusText(snapshot) {
|
|
2976
|
+
const { run } = snapshot;
|
|
2977
|
+
const lines = [
|
|
2978
|
+
`Workflow: ${run.workflowId}`,
|
|
2979
|
+
`Run: ${run.runId}`,
|
|
2980
|
+
`Status: ${run.status}`,
|
|
2981
|
+
`Step: ${run.currentStepId}`,
|
|
2982
|
+
`Completed steps: ${run.history.length}`
|
|
2983
|
+
];
|
|
2984
|
+
if (run.pendingGate?.reviewId) {
|
|
2985
|
+
lines.push(`Review: ${run.pendingGate.reviewId}`);
|
|
2986
|
+
}
|
|
2987
|
+
if (snapshot.execution?.kind === "subagent") {
|
|
2988
|
+
lines.push(`Subagent: ${snapshot.execution.agent} (${snapshot.execution.requestId})`, `Progress: ${snapshot.execution.progress}`);
|
|
2989
|
+
} else if (snapshot.execution?.kind === "main") {
|
|
2990
|
+
lines.push("Execution: main agent");
|
|
2991
|
+
}
|
|
2992
|
+
if (run.pauseReason)
|
|
2993
|
+
lines.push(`Reason: ${run.pauseReason}`);
|
|
2994
|
+
return lines.join(`
|
|
2995
|
+
`);
|
|
2996
|
+
}
|
|
2997
|
+
async function showWorkflowStatus(ctx, getSnapshot) {
|
|
2998
|
+
await ctx.ui.custom((tui, theme, _keybindings, done) => {
|
|
2999
|
+
const view = new WorkflowStatusView(getSnapshot, tui, theme, done);
|
|
3000
|
+
view.start();
|
|
3001
|
+
return view;
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
class WorkflowStatusView {
|
|
3006
|
+
getSnapshot;
|
|
3007
|
+
tui;
|
|
3008
|
+
theme;
|
|
3009
|
+
done;
|
|
3010
|
+
timer;
|
|
3011
|
+
closed = false;
|
|
3012
|
+
constructor(getSnapshot, tui, theme, done) {
|
|
3013
|
+
this.getSnapshot = getSnapshot;
|
|
3014
|
+
this.tui = tui;
|
|
3015
|
+
this.theme = theme;
|
|
3016
|
+
this.done = done;
|
|
3017
|
+
}
|
|
3018
|
+
start() {
|
|
3019
|
+
this.timer = setInterval(() => this.tui.requestRender(), REFRESH_INTERVAL_MS);
|
|
3020
|
+
this.timer.unref?.();
|
|
3021
|
+
}
|
|
3022
|
+
dispose() {
|
|
3023
|
+
if (this.timer)
|
|
3024
|
+
clearInterval(this.timer);
|
|
3025
|
+
this.timer = undefined;
|
|
3026
|
+
}
|
|
3027
|
+
invalidate() {}
|
|
3028
|
+
handleInput(data) {
|
|
3029
|
+
if (data === "q" || data === "Q" || matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "ctrl+d")) {
|
|
3030
|
+
this.close();
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
render(width) {
|
|
3034
|
+
const viewportWidth = Math.max(1, Math.floor(width || 1));
|
|
3035
|
+
const snapshot = this.getSnapshot();
|
|
3036
|
+
if (viewportWidth < 12) {
|
|
3037
|
+
const label = snapshot ? `${statusGlyph(this.theme, snapshot.run.status)} ${snapshot.run.workflowId} ${statusLabel(snapshot.run.status)}` : "No workflow";
|
|
3038
|
+
return [truncateToWidth(label, viewportWidth, "…", true)];
|
|
3039
|
+
}
|
|
3040
|
+
const contentWidth = viewportWidth - 2;
|
|
3041
|
+
const lines = snapshot ? renderBoard(this.theme, snapshot, contentWidth) : renderEmptyBoard(this.theme, contentWidth);
|
|
3042
|
+
return lines.map((line) => padAnsi(truncateToWidth(line, contentWidth, "…"), viewportWidth));
|
|
3043
|
+
}
|
|
3044
|
+
close() {
|
|
3045
|
+
if (this.closed)
|
|
3046
|
+
return;
|
|
3047
|
+
this.closed = true;
|
|
3048
|
+
this.dispose();
|
|
3049
|
+
this.done();
|
|
3050
|
+
this.tui.requestRender(true);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
3053
|
+
function renderBoard(theme, snapshot, width) {
|
|
3054
|
+
const header = boxed(theme, "✦ Workflow Status", width, renderHeaderLines(theme, snapshot, width - 4), "borderAccent");
|
|
3055
|
+
let body;
|
|
3056
|
+
if (width >= WIDE_LAYOUT_MIN_COLUMNS) {
|
|
3057
|
+
const gap = 2;
|
|
3058
|
+
const summaryWidth = Math.min(42, Math.max(36, Math.floor(width * 0.36)));
|
|
3059
|
+
const pathWidth = width - summaryWidth - gap;
|
|
3060
|
+
const summary = boxed(theme, "Run Summary", summaryWidth, renderSummaryLines(theme, snapshot, summaryWidth - 4));
|
|
3061
|
+
const path = boxed(theme, "Execution Path", pathWidth, renderPathLines(theme, snapshot, pathWidth - 4), "borderAccent");
|
|
3062
|
+
body = joinPanels(summary, summaryWidth, path, pathWidth, gap);
|
|
3063
|
+
} else {
|
|
3064
|
+
body = [
|
|
3065
|
+
...boxed(theme, "Run Summary", width, renderSummaryLines(theme, snapshot, width - 4)),
|
|
3066
|
+
"",
|
|
3067
|
+
...boxed(theme, "Execution Path", width, renderPathLines(theme, snapshot, width - 4), "borderAccent")
|
|
3068
|
+
];
|
|
3069
|
+
}
|
|
3070
|
+
return [
|
|
3071
|
+
...header,
|
|
3072
|
+
"",
|
|
3073
|
+
...body,
|
|
3074
|
+
"",
|
|
3075
|
+
theme.fg("dim", "q / Esc close · live refresh")
|
|
3076
|
+
];
|
|
3077
|
+
}
|
|
3078
|
+
function renderEmptyBoard(theme, width) {
|
|
3079
|
+
return [
|
|
3080
|
+
...boxed(theme, "✦ Workflow Status", width, [theme.fg("muted", "No workflow checkpoint in this session")], "borderAccent"),
|
|
3081
|
+
"",
|
|
3082
|
+
theme.fg("dim", "q / Esc close")
|
|
3083
|
+
];
|
|
3084
|
+
}
|
|
3085
|
+
function renderHeaderLines(theme, snapshot, width) {
|
|
3086
|
+
const { run } = snapshot;
|
|
3087
|
+
const workflowName = inline(run.workflowId);
|
|
3088
|
+
const status = statusBadge(theme, run.status);
|
|
3089
|
+
const completed = theme.fg("success", `${run.history.length} completed attempt${run.history.length === 1 ? "" : "s"}`);
|
|
3090
|
+
const firstLine = [
|
|
3091
|
+
statusGlyph(theme, run.status),
|
|
3092
|
+
theme.bold(workflowName),
|
|
3093
|
+
status,
|
|
3094
|
+
theme.fg("muted", "·"),
|
|
3095
|
+
completed
|
|
3096
|
+
].join(" ");
|
|
3097
|
+
const currentTitle = stepTitle(snapshot.workflow, run.currentStepId);
|
|
3098
|
+
const visit = Math.max(1, run.visits[run.currentStepId] ?? 1);
|
|
3099
|
+
const elapsed = formatElapsed(elapsedMs(snapshot));
|
|
3100
|
+
const secondLine = [
|
|
3101
|
+
theme.fg("muted", "step"),
|
|
3102
|
+
theme.fg("text", formatStepName(currentTitle, run.currentStepId)),
|
|
3103
|
+
theme.fg("muted", `· visit ${visit} · elapsed ${elapsed}`)
|
|
3104
|
+
].join(" ");
|
|
3105
|
+
return [
|
|
3106
|
+
truncateToWidth(firstLine, width),
|
|
3107
|
+
truncateToWidth(secondLine, width)
|
|
3108
|
+
];
|
|
3109
|
+
}
|
|
3110
|
+
function renderSummaryLines(theme, snapshot, width) {
|
|
3111
|
+
const { run, workflow } = snapshot;
|
|
3112
|
+
const lines = [
|
|
3113
|
+
...keyValueLines(theme, "workflow", run.workflowId, width),
|
|
3114
|
+
...keyValueLines(theme, "run", run.runId, width),
|
|
3115
|
+
...keyValueLines(theme, "status", statusLabel(run.status), width, statusColor(run.status)),
|
|
3116
|
+
...keyValueLines(theme, "current", formatStepName(stepTitle(workflow, run.currentStepId), run.currentStepId), width),
|
|
3117
|
+
...keyValueLines(theme, "visit", String(Math.max(1, run.visits[run.currentStepId] ?? 1)), width),
|
|
3118
|
+
...keyValueLines(theme, "started", formatTimestamp(run.startedAt), width),
|
|
3119
|
+
...keyValueLines(theme, "updated", `${formatTimestamp(run.updatedAt)} · ${formatElapsed(elapsedMs(snapshot))}`, width)
|
|
3120
|
+
];
|
|
3121
|
+
const execution = formatExecution(snapshot.execution);
|
|
3122
|
+
if (execution) {
|
|
3123
|
+
lines.push(...keyValueLines(theme, "execution", execution, width, "accent"));
|
|
3124
|
+
}
|
|
3125
|
+
if (run.pendingGate) {
|
|
3126
|
+
const review = run.pendingGate.reviewId ? `${run.pendingGate.provider} · ${run.pendingGate.reviewId}` : `${run.pendingGate.provider} · opening`;
|
|
3127
|
+
lines.push(...keyValueLines(theme, "review", review, width, "warning"));
|
|
3128
|
+
}
|
|
3129
|
+
if (!workflow) {
|
|
3130
|
+
lines.push(...keyValueLines(theme, "config", "workflow definition is not loaded", width, "warning"));
|
|
3131
|
+
} else if (workflow.digest !== run.workflowDigest) {
|
|
3132
|
+
lines.push(...keyValueLines(theme, "config", "definition changed since this checkpoint", width, "warning"));
|
|
3133
|
+
}
|
|
3134
|
+
if (run.pauseReason) {
|
|
3135
|
+
lines.push(...keyValueLines(theme, "reason", run.pauseReason, width, run.status === "aborted" ? "error" : "warning"));
|
|
3136
|
+
}
|
|
3137
|
+
return lines;
|
|
3138
|
+
}
|
|
3139
|
+
function renderPathLines(theme, snapshot, width) {
|
|
3140
|
+
const entries = buildPathEntries(snapshot);
|
|
3141
|
+
if (entries.length === 0) {
|
|
3142
|
+
return [theme.fg("muted", "No step attempts recorded")];
|
|
3143
|
+
}
|
|
3144
|
+
const hidden = Math.max(0, entries.length - MAX_PATH_ROWS);
|
|
3145
|
+
const visible = entries.slice(hidden);
|
|
3146
|
+
const lines = hidden > 0 ? [
|
|
3147
|
+
theme.fg("dim", `… ${hidden} earlier attempt${hidden === 1 ? "" : "s"}`)
|
|
3148
|
+
] : [];
|
|
3149
|
+
for (const entry of visible) {
|
|
3150
|
+
const visit = entry.visit > 1 ? theme.fg("dim", ` · visit ${entry.visit}`) : "";
|
|
3151
|
+
const left = `${statusGlyph(theme, entry.status)} ${theme.fg(entry.current ? "text" : "muted", entry.title)}${visit}`;
|
|
3152
|
+
const right = entry.outcome ? `${statusLabel(entry.status)} · ${inline(entry.outcome)}` : statusLabel(entry.status);
|
|
3153
|
+
const row = joinColumns(left, theme.fg(statusColor(entry.status), right), width, Math.max(12, Math.floor(width * 0.58)));
|
|
3154
|
+
lines.push(entry.current ? theme.bg("selectedBg", padAnsi(row, width)) : truncateToWidth(row, width));
|
|
3155
|
+
}
|
|
3156
|
+
return lines;
|
|
3157
|
+
}
|
|
3158
|
+
function buildPathEntries(snapshot) {
|
|
3159
|
+
const { run, workflow } = snapshot;
|
|
3160
|
+
const visits = new Map;
|
|
3161
|
+
const entries = run.history.map((entry) => {
|
|
3162
|
+
const visit = (visits.get(entry.stepId) ?? 0) + 1;
|
|
3163
|
+
visits.set(entry.stepId, visit);
|
|
3164
|
+
return historyPathEntry(workflow, entry, visit);
|
|
3165
|
+
});
|
|
3166
|
+
if (run.status !== "completed") {
|
|
3167
|
+
entries.push({
|
|
3168
|
+
stepId: run.currentStepId,
|
|
3169
|
+
title: stepTitle(workflow, run.currentStepId),
|
|
3170
|
+
status: run.status,
|
|
3171
|
+
visit: Math.max(visits.get(run.currentStepId) ?? 0, run.visits[run.currentStepId] ?? 1),
|
|
3172
|
+
current: true
|
|
3173
|
+
});
|
|
3174
|
+
} else if (entries.length === 0 || entries.at(-1)?.stepId !== run.currentStepId) {
|
|
3175
|
+
entries.push({
|
|
3176
|
+
stepId: run.currentStepId,
|
|
3177
|
+
title: stepTitle(workflow, run.currentStepId),
|
|
3178
|
+
status: "completed",
|
|
3179
|
+
visit: Math.max(1, run.visits[run.currentStepId] ?? 1),
|
|
3180
|
+
current: true
|
|
3181
|
+
});
|
|
3182
|
+
}
|
|
3183
|
+
return entries;
|
|
3184
|
+
}
|
|
3185
|
+
function historyPathEntry(workflow, entry, visit) {
|
|
3186
|
+
return {
|
|
3187
|
+
stepId: entry.stepId,
|
|
3188
|
+
title: stepTitle(workflow, entry.stepId),
|
|
3189
|
+
status: "completed",
|
|
3190
|
+
visit,
|
|
3191
|
+
outcome: entry.outcome,
|
|
3192
|
+
current: false
|
|
3193
|
+
};
|
|
3194
|
+
}
|
|
3195
|
+
function keyValueLines(theme, label, rawValue, width, valueColor = "text") {
|
|
3196
|
+
const safeWidth = Math.max(1, width);
|
|
3197
|
+
const labelWidth = Math.min(10, Math.max(7, label.length + 1));
|
|
3198
|
+
const valueWidth = Math.max(1, safeWidth - labelWidth);
|
|
3199
|
+
const value = theme.fg(valueColor, inline(rawValue));
|
|
3200
|
+
const wrapped = wrapTextWithAnsi(value, valueWidth);
|
|
3201
|
+
const prefix = theme.fg("muted", label.padEnd(labelWidth));
|
|
3202
|
+
return (wrapped.length > 0 ? wrapped : [""]).map((line, index) => index === 0 ? `${prefix}${line}` : `${" ".repeat(labelWidth)}${line}`);
|
|
3203
|
+
}
|
|
3204
|
+
function boxed(theme, title, width, content, color = "borderMuted") {
|
|
3205
|
+
const safeWidth = Math.max(8, Math.floor(width));
|
|
3206
|
+
const bodyWidth = Math.max(1, safeWidth - 4);
|
|
3207
|
+
const topLabel = `╭─ ${title} `;
|
|
3208
|
+
const top = `${topLabel}${"─".repeat(Math.max(0, safeWidth - visibleWidth(topLabel) - 1))}╮`;
|
|
3209
|
+
const bottom = `╰${"─".repeat(Math.max(0, safeWidth - 2))}╯`;
|
|
3210
|
+
const body = content.length > 0 ? content : [""];
|
|
3211
|
+
return [
|
|
3212
|
+
theme.fg(color, top),
|
|
3213
|
+
...body.map((line) => `${theme.fg(color, "│")} ${padAnsi(truncateToWidth(line, bodyWidth), bodyWidth)} ${theme.fg(color, "│")}`),
|
|
3214
|
+
theme.fg(color, bottom)
|
|
3215
|
+
];
|
|
3216
|
+
}
|
|
3217
|
+
function joinPanels(left, leftWidth, right, rightWidth, gap) {
|
|
3218
|
+
const height = Math.max(left.length, right.length);
|
|
3219
|
+
return Array.from({ length: height }, (_, index) => {
|
|
3220
|
+
const leftLine = padAnsi(left[index] ?? "", leftWidth);
|
|
3221
|
+
const rightLine = padAnsi(right[index] ?? "", rightWidth);
|
|
3222
|
+
return `${leftLine}${" ".repeat(gap)}${rightLine}`;
|
|
3223
|
+
});
|
|
3224
|
+
}
|
|
3225
|
+
function joinColumns(left, right, width, leftWidth) {
|
|
3226
|
+
const safeLeftWidth = Math.max(1, Math.min(leftWidth, width - 2));
|
|
3227
|
+
const rightWidth = Math.max(1, width - safeLeftWidth - 1);
|
|
3228
|
+
return `${padAnsi(truncateToWidth(left, safeLeftWidth), safeLeftWidth)} ${truncateToWidth(right, rightWidth)}`;
|
|
3229
|
+
}
|
|
3230
|
+
function padAnsi(value, width) {
|
|
3231
|
+
const visible = visibleWidth(value);
|
|
3232
|
+
if (visible >= width)
|
|
3233
|
+
return value;
|
|
3234
|
+
return `${value}${" ".repeat(width - visible)}`;
|
|
3235
|
+
}
|
|
3236
|
+
function statusGlyph(theme, status) {
|
|
3237
|
+
if (status === "completed")
|
|
3238
|
+
return theme.fg("success", "✓");
|
|
3239
|
+
if (status === "running")
|
|
3240
|
+
return theme.fg("accent", "↻");
|
|
3241
|
+
if (status === "paused" || status === "awaiting-gate") {
|
|
3242
|
+
return theme.fg("warning", "◆");
|
|
3243
|
+
}
|
|
3244
|
+
if (status === "aborted")
|
|
3245
|
+
return theme.fg("error", "✕");
|
|
3246
|
+
return theme.fg("dim", "•");
|
|
3247
|
+
}
|
|
3248
|
+
function statusColor(status) {
|
|
3249
|
+
if (status === "completed")
|
|
3250
|
+
return "success";
|
|
3251
|
+
if (status === "running")
|
|
3252
|
+
return "accent";
|
|
3253
|
+
if (status === "paused" || status === "awaiting-gate")
|
|
3254
|
+
return "warning";
|
|
3255
|
+
if (status === "aborted")
|
|
3256
|
+
return "error";
|
|
3257
|
+
return "dim";
|
|
3258
|
+
}
|
|
3259
|
+
function statusLabel(status) {
|
|
3260
|
+
return status === "awaiting-gate" ? "AWAITING REVIEW" : status.toUpperCase().replaceAll("-", " ");
|
|
3261
|
+
}
|
|
3262
|
+
function statusBadge(theme, status) {
|
|
3263
|
+
return theme.fg(statusColor(status), theme.bold(`[${statusLabel(status)}]`));
|
|
3264
|
+
}
|
|
3265
|
+
function stepTitle(workflow, stepId) {
|
|
3266
|
+
return inline(workflow?.definition.steps[stepId]?.title ?? stepId);
|
|
3267
|
+
}
|
|
3268
|
+
function formatStepName(title, stepId) {
|
|
3269
|
+
const safeStepId = inline(stepId);
|
|
3270
|
+
return title === safeStepId ? title : `${title} (${safeStepId})`;
|
|
3271
|
+
}
|
|
3272
|
+
function formatExecution(execution) {
|
|
3273
|
+
if (!execution)
|
|
3274
|
+
return;
|
|
3275
|
+
if (execution.kind === "main")
|
|
3276
|
+
return "main agent";
|
|
3277
|
+
return `${execution.agent} · ${execution.progress} · ${execution.requestId}`;
|
|
3278
|
+
}
|
|
3279
|
+
function inline(value) {
|
|
3280
|
+
return value.replace(/\s+/g, " ").trim();
|
|
3281
|
+
}
|
|
3282
|
+
function elapsedMs(snapshot) {
|
|
3283
|
+
const { run } = snapshot;
|
|
3284
|
+
const end = run.status === "running" || run.status === "awaiting-gate" ? snapshot.now : run.updatedAt;
|
|
3285
|
+
return Math.max(0, end - run.startedAt);
|
|
3286
|
+
}
|
|
3287
|
+
function formatElapsed(milliseconds) {
|
|
3288
|
+
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
|
|
3289
|
+
const days = Math.floor(totalSeconds / 86400);
|
|
3290
|
+
const hours = Math.floor(totalSeconds % 86400 / 3600);
|
|
3291
|
+
const minutes = Math.floor(totalSeconds % 3600 / 60);
|
|
3292
|
+
const seconds = totalSeconds % 60;
|
|
3293
|
+
if (days > 0)
|
|
3294
|
+
return `${days}d ${hours}h`;
|
|
3295
|
+
if (hours > 0)
|
|
3296
|
+
return `${hours}h ${minutes}m`;
|
|
3297
|
+
if (minutes > 0)
|
|
3298
|
+
return `${minutes}m ${seconds}s`;
|
|
3299
|
+
return `${seconds}s`;
|
|
3300
|
+
}
|
|
3301
|
+
function formatTimestamp(milliseconds) {
|
|
3302
|
+
const value = new Date(milliseconds);
|
|
3303
|
+
if (!Number.isFinite(value.getTime()))
|
|
3304
|
+
return "unknown";
|
|
3305
|
+
const year = value.getFullYear();
|
|
3306
|
+
const month = String(value.getMonth() + 1).padStart(2, "0");
|
|
3307
|
+
const day = String(value.getDate()).padStart(2, "0");
|
|
3308
|
+
const hours = String(value.getHours()).padStart(2, "0");
|
|
3309
|
+
const minutes = String(value.getMinutes()).padStart(2, "0");
|
|
3310
|
+
const seconds = String(value.getSeconds()).padStart(2, "0");
|
|
3311
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
// src/harness.ts
|
|
3315
|
+
var STATE_ENTRY_TYPE = "pi-workflows-state-v1";
|
|
3316
|
+
var STATUS_KEY = "pi-workflows";
|
|
3317
|
+
function emptyCatalog() {
|
|
3318
|
+
return {
|
|
3319
|
+
workflows: new Map,
|
|
3320
|
+
settings: DEFAULT_SETTINGS,
|
|
3321
|
+
diagnostics: [],
|
|
3322
|
+
userDirectory: ""
|
|
3323
|
+
};
|
|
3324
|
+
}
|
|
3325
|
+
function formatDiagnostics(catalog) {
|
|
3326
|
+
const shown = catalog.diagnostics.slice(0, 3).map((item) => `${item.path}: ${item.message}`);
|
|
3327
|
+
const remaining = catalog.diagnostics.length - shown.length;
|
|
3328
|
+
return [
|
|
3329
|
+
...shown,
|
|
3330
|
+
...remaining > 0 ? [`${remaining} more diagnostic(s)`] : []
|
|
3331
|
+
].join(`
|
|
3332
|
+
`);
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
class WorkflowHarness {
|
|
3336
|
+
pi;
|
|
3337
|
+
subagents;
|
|
3338
|
+
mainSteps;
|
|
3339
|
+
catalog = emptyCatalog();
|
|
3340
|
+
run;
|
|
3341
|
+
latestContext;
|
|
3342
|
+
availableSkills = new Set;
|
|
3343
|
+
sessionActive = false;
|
|
3344
|
+
sessionEpoch = 0;
|
|
3345
|
+
activeDelegation;
|
|
3346
|
+
activePromptReview;
|
|
3347
|
+
registeredWorkflowCommands = new Set;
|
|
3348
|
+
catalogLoadSequence = 0;
|
|
3349
|
+
mutationQueue = new SerialTaskQueue;
|
|
3350
|
+
constructor(pi) {
|
|
3351
|
+
this.pi = pi;
|
|
3352
|
+
this.subagents = new SubagentDelegationClient(pi.events);
|
|
3353
|
+
this.mainSteps = new MainStepRuntime(pi);
|
|
3354
|
+
registerHarnessCommands(pi, this);
|
|
3355
|
+
this.registerLifecycle();
|
|
3356
|
+
this.registerPolicy();
|
|
3357
|
+
this.registerPlannotatorResults();
|
|
3358
|
+
}
|
|
3359
|
+
workflowIds() {
|
|
3360
|
+
return [...this.catalog.workflows.keys()].sort();
|
|
3361
|
+
}
|
|
3362
|
+
async list(ctx) {
|
|
3363
|
+
const workflows = [...this.catalog.workflows.values()].sort((left, right) => left.definition.id.localeCompare(right.definition.id));
|
|
3364
|
+
if (workflows.length === 0) {
|
|
3365
|
+
ctx.ui.notify(`No workflows loaded from ${this.catalog.userDirectory}`, this.catalog.diagnostics.length > 0 ? "warning" : "info");
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
this.pi.sendMessage({
|
|
3369
|
+
customType: "workflow-list",
|
|
3370
|
+
content: formatWorkflowList(workflows.map((workflow) => workflow.definition)),
|
|
3371
|
+
display: true
|
|
3372
|
+
});
|
|
3373
|
+
}
|
|
3374
|
+
start(workflowId, input, ctx) {
|
|
3375
|
+
return this.enqueueMutation(ctx, (sessionEpoch) => this.startNow(workflowId, input, ctx, sessionEpoch));
|
|
3376
|
+
}
|
|
3377
|
+
async startNow(workflowId, input, ctx, sessionEpoch) {
|
|
3378
|
+
if (this.activeDelegation) {
|
|
3379
|
+
ctx.ui.notify(`Cannot start a workflow while subagent "${this.activeDelegation.agent}" is still cancelling`, "warning");
|
|
3380
|
+
return;
|
|
3381
|
+
}
|
|
3382
|
+
if (this.run && this.run.status !== "completed" && this.run.status !== "aborted") {
|
|
3383
|
+
ctx.ui.notify(`Workflow "${this.run.workflowId}" is ${this.run.status}; resume or abort it first`, "warning");
|
|
3384
|
+
return;
|
|
3385
|
+
}
|
|
3386
|
+
if (!ctx.isIdle()) {
|
|
3387
|
+
ctx.abort();
|
|
3388
|
+
await ctx.waitForIdle();
|
|
3389
|
+
}
|
|
3390
|
+
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
3391
|
+
ctx.ui.notify("Workflow start was superseded by a session change", "warning");
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
this.captureSkills(ctx.getSystemPromptOptions().skills);
|
|
3395
|
+
if (!await this.reloadCatalog(ctx, false)) {
|
|
3396
|
+
ctx.ui.notify("Workflow start was superseded by a newer configuration load", "warning");
|
|
3397
|
+
return;
|
|
3398
|
+
}
|
|
3399
|
+
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
3400
|
+
ctx.ui.notify("Workflow start was superseded by a session change", "warning");
|
|
3401
|
+
return;
|
|
3402
|
+
}
|
|
3403
|
+
const workflow = this.catalog.workflows.get(workflowId);
|
|
3404
|
+
if (!workflow) {
|
|
3405
|
+
ctx.ui.notify(`Workflow "${workflowId}" is not loaded`, "error");
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
const preflightErrors = this.preflight(workflow, workflow.definition.start);
|
|
3409
|
+
if (preflightErrors.length > 0) {
|
|
3410
|
+
ctx.ui.notify(`Cannot start workflow:
|
|
3411
|
+
${preflightErrors.join(`
|
|
3412
|
+
`)}`, "error");
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
3415
|
+
const baselineTools = this.pi.getActiveTools();
|
|
3416
|
+
this.run = createRun(workflow, input.trim(), baselineTools, randomUUID(), Date.now());
|
|
3417
|
+
this.persist();
|
|
3418
|
+
this.isolateMainSessionTools();
|
|
3419
|
+
this.updateStatus();
|
|
3420
|
+
this.launchCurrentStep(workflow);
|
|
3421
|
+
}
|
|
3422
|
+
pause(reason, ctx) {
|
|
3423
|
+
return this.enqueueMutation(ctx, () => this.pauseNow(reason, ctx));
|
|
3424
|
+
}
|
|
3425
|
+
async pauseNow(reason, ctx) {
|
|
3426
|
+
if (!this.run || this.run.status === "completed" || this.run.status === "aborted") {
|
|
3427
|
+
ctx.ui.notify("No active workflow to pause", "warning");
|
|
3428
|
+
return;
|
|
3429
|
+
}
|
|
3430
|
+
if (this.run.status === "paused") {
|
|
3431
|
+
ctx.ui.notify(`Workflow is already paused${this.run.pauseReason ? `: ${this.run.pauseReason}` : ""}`, "info");
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
this.cancelPromptReview();
|
|
3435
|
+
const mainSuspended = this.mainSteps.suspend();
|
|
3436
|
+
const cancellationConfirmed = await this.cancelActiveDelegation("Workflow paused by user");
|
|
3437
|
+
if (!ctx.isIdle()) {
|
|
3438
|
+
ctx.abort();
|
|
3439
|
+
if (mainSuspended)
|
|
3440
|
+
await ctx.waitForIdle();
|
|
3441
|
+
}
|
|
3442
|
+
this.run = pauseRun(this.run, reason, Date.now());
|
|
3443
|
+
this.persist();
|
|
3444
|
+
if (cancellationConfirmed) {
|
|
3445
|
+
this.restoreBaselineTools();
|
|
3446
|
+
} else {
|
|
3447
|
+
this.isolateMainSessionTools();
|
|
3448
|
+
}
|
|
3449
|
+
this.updateStatus();
|
|
3450
|
+
ctx.ui.notify(cancellationConfirmed ? `Paused "${this.run.workflowId}" at step "${this.run.currentStepId}". Fix the issue, then run /workflow-resume.` : `Pause recorded at "${this.run.currentStepId}", but child cancellation is not confirmed. Main tools remain isolated until it exits.`, cancellationConfirmed ? "info" : "warning");
|
|
3451
|
+
}
|
|
3452
|
+
resume(ctx) {
|
|
3453
|
+
return this.enqueueMutation(ctx, () => this.resumeNow(ctx));
|
|
3454
|
+
}
|
|
3455
|
+
async resumeNow(ctx) {
|
|
3456
|
+
if (!this.run || this.run.status !== "paused") {
|
|
3457
|
+
ctx.ui.notify("No paused workflow to resume", "warning");
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
if (this.activeDelegation) {
|
|
3461
|
+
ctx.ui.notify(`Cannot resume while subagent "${this.activeDelegation.agent}" is still cancelling`, "warning");
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
const checkpoint = captureResumeCheckpoint(this.run, this.sessionEpoch);
|
|
3465
|
+
if (!ctx.isIdle()) {
|
|
3466
|
+
ctx.abort();
|
|
3467
|
+
await ctx.waitForIdle();
|
|
3468
|
+
}
|
|
3469
|
+
if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
|
|
3470
|
+
ctx.ui.notify("Resume was superseded by another workflow or session change", "warning");
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
this.captureSkills(ctx.getSystemPromptOptions().skills);
|
|
3474
|
+
await this.reloadCatalog(ctx, false);
|
|
3475
|
+
if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
|
|
3476
|
+
ctx.ui.notify("Resume was superseded by another workflow or session change", "warning");
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
let workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
3480
|
+
if (!workflow) {
|
|
3481
|
+
ctx.ui.notify(`Workflow "${this.run.workflowId}" is no longer loaded; restore it or abort`, "error");
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
const reconciled = reconcileRun(this.run, workflow, Date.now());
|
|
3485
|
+
if (!reconciled.run) {
|
|
3486
|
+
ctx.ui.notify(reconciled.error ?? "Cannot reconcile workflow configuration", "error");
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
let resumed = reconciled.run;
|
|
3490
|
+
if (resumed.pendingGate?.provider === "plannotator" && !resumed.pendingGate.reviewId) {
|
|
3491
|
+
resumed = failGate(resumed, "Gate submission was interrupted before a review id was recorded; submit it again", Date.now());
|
|
3492
|
+
}
|
|
3493
|
+
if (resumed.pendingGate?.provider === "plannotator" && resumed.pendingGate.reviewId && !resumed.pendingGate.resolution) {
|
|
3494
|
+
const requestedReviewId = resumed.pendingGate.reviewId;
|
|
3495
|
+
const gateStep = workflow.definition.steps[resumed.pendingGate.stepId];
|
|
3496
|
+
const statusResponse = await requestPlannotatorReviewStatus(this.pi.events, `${resumed.runId}:review-status:${randomUUID()}`, requestedReviewId, gateStep?.gate?.provider === "plannotator" ? gateStep.gate.timeoutMs : 5000);
|
|
3497
|
+
if (!matchesResumeCheckpoint(this.run, this.sessionEpoch, checkpoint)) {
|
|
3498
|
+
ctx.ui.notify("Resume was superseded by another workflow or session change", "warning");
|
|
3499
|
+
return;
|
|
3500
|
+
}
|
|
3501
|
+
workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
3502
|
+
if (!workflow) {
|
|
3503
|
+
ctx.ui.notify(`Workflow "${this.run.workflowId}" is no longer loaded; restore it or abort`, "error");
|
|
3504
|
+
return;
|
|
3505
|
+
}
|
|
3506
|
+
const latest = reconcileRun(this.run, workflow, Date.now());
|
|
3507
|
+
if (!latest.run) {
|
|
3508
|
+
ctx.ui.notify(latest.error ?? "Cannot reconcile workflow configuration", "error");
|
|
3509
|
+
return;
|
|
3510
|
+
}
|
|
3511
|
+
resumed = latest.run;
|
|
3512
|
+
if (!resumed.pendingGate?.resolution && resumed.pendingGate?.reviewId === requestedReviewId && statusResponse.status !== "handled") {
|
|
3513
|
+
ctx.ui.notify(statusResponse.error ?? "Cannot query the pending Plannotator review", "error");
|
|
3514
|
+
return;
|
|
3515
|
+
}
|
|
3516
|
+
if (!resumed.pendingGate?.resolution && resumed.pendingGate?.reviewId === requestedReviewId && statusResponse.status === "handled" && statusResponse.result.status === "completed") {
|
|
3517
|
+
resumed = storeGateResolution(resumed, {
|
|
3518
|
+
approved: statusResponse.result.approved,
|
|
3519
|
+
feedback: statusResponse.result.feedback,
|
|
3520
|
+
resolvedAt: Date.now()
|
|
3521
|
+
}, Date.now());
|
|
3522
|
+
} else if (!resumed.pendingGate?.resolution && resumed.pendingGate?.reviewId === requestedReviewId && statusResponse.status === "handled" && statusResponse.result.status === "missing") {
|
|
3523
|
+
resumed = failGate(resumed, "Plannotator no longer has the pending review; submit it again", Date.now());
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
const storedResolution = resumed.pendingGate?.resolution;
|
|
3527
|
+
if (storedResolution) {
|
|
3528
|
+
try {
|
|
3529
|
+
resumed = resolveGate(workflow, resumed, storedResolution, Date.now());
|
|
3530
|
+
} catch (error) {
|
|
3531
|
+
ctx.ui.notify(`Cannot apply stored gate result: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
3534
|
+
} else {
|
|
3535
|
+
resumed = resumeRun(resumed, Date.now());
|
|
3536
|
+
}
|
|
3537
|
+
this.run = resumed;
|
|
3538
|
+
if (this.run.status === "awaiting-gate") {
|
|
3539
|
+
this.persist();
|
|
3540
|
+
this.restoreBaselineTools();
|
|
3541
|
+
this.updateStatus();
|
|
3542
|
+
if (this.run.pendingGate?.provider === "prompt") {
|
|
3543
|
+
this.launchPromptReview(workflow, this.run, ctx);
|
|
3544
|
+
ctx.ui.notify("Workflow resumed with built-in review open", "info");
|
|
3545
|
+
return;
|
|
3546
|
+
}
|
|
3547
|
+
ctx.ui.notify(`Workflow resumed and is waiting for review ${this.run.pendingGate?.reviewId ?? ""}`.trim(), "info");
|
|
3548
|
+
return;
|
|
3549
|
+
}
|
|
3550
|
+
if (this.run.status !== "running") {
|
|
3551
|
+
this.persist();
|
|
3552
|
+
this.restoreBaselineTools();
|
|
3553
|
+
this.updateStatus();
|
|
3554
|
+
ctx.ui.notify(`Workflow is now ${this.run.status}`, "info");
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
const preflightErrors = this.preflight(workflow, this.run.currentStepId);
|
|
3558
|
+
if (preflightErrors.length > 0) {
|
|
3559
|
+
this.run = pauseRun(this.run, `Step preflight failed: ${preflightErrors.join("; ")}`, Date.now());
|
|
3560
|
+
this.persist();
|
|
3561
|
+
this.restoreBaselineTools();
|
|
3562
|
+
this.updateStatus();
|
|
3563
|
+
ctx.ui.notify(`Cannot resume workflow:
|
|
3564
|
+
${preflightErrors.join(`
|
|
3565
|
+
`)}`, "error");
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
this.persist();
|
|
3569
|
+
this.isolateMainSessionTools();
|
|
3570
|
+
this.updateStatus();
|
|
3571
|
+
this.launchCurrentStep(workflow);
|
|
3572
|
+
}
|
|
3573
|
+
abort(reason, ctx) {
|
|
3574
|
+
return this.enqueueMutation(ctx, () => this.abortNow(reason, ctx));
|
|
3575
|
+
}
|
|
3576
|
+
async abortNow(reason, ctx) {
|
|
3577
|
+
if (!this.run || this.run.status === "completed" || this.run.status === "aborted") {
|
|
3578
|
+
ctx.ui.notify("No active workflow to abort", "warning");
|
|
3579
|
+
return;
|
|
3580
|
+
}
|
|
3581
|
+
this.cancelPromptReview();
|
|
3582
|
+
const mainSuspended = this.mainSteps.suspend();
|
|
3583
|
+
const cancellationConfirmed = await this.cancelActiveDelegation("Workflow aborted by user");
|
|
3584
|
+
if (!ctx.isIdle()) {
|
|
3585
|
+
ctx.abort();
|
|
3586
|
+
if (mainSuspended)
|
|
3587
|
+
await ctx.waitForIdle();
|
|
3588
|
+
}
|
|
3589
|
+
this.run = abortRun(this.run, reason, Date.now());
|
|
3590
|
+
this.persist();
|
|
3591
|
+
if (cancellationConfirmed) {
|
|
3592
|
+
this.restoreBaselineTools();
|
|
3593
|
+
} else {
|
|
3594
|
+
this.isolateMainSessionTools();
|
|
3595
|
+
}
|
|
3596
|
+
this.updateStatus();
|
|
3597
|
+
ctx.ui.notify(cancellationConfirmed ? `Aborted workflow "${this.run.workflowId}"` : `Workflow "${this.run.workflowId}" is aborted, but its child has not confirmed cancellation; main tools remain isolated`, cancellationConfirmed ? "info" : "warning");
|
|
3598
|
+
}
|
|
3599
|
+
reload(ctx) {
|
|
3600
|
+
return this.enqueueMutation(ctx, () => this.reloadNow(ctx));
|
|
3601
|
+
}
|
|
3602
|
+
async reloadNow(ctx) {
|
|
3603
|
+
if (this.run && (this.run.status === "running" || this.run.status === "awaiting-gate")) {
|
|
3604
|
+
ctx.ui.notify("Pause the workflow before reloading its configuration", "warning");
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
this.captureSkills(ctx.getSystemPromptOptions().skills);
|
|
3608
|
+
await this.reloadCatalog(ctx, true);
|
|
3609
|
+
}
|
|
3610
|
+
async status(ctx) {
|
|
3611
|
+
const snapshot = this.workflowStatusSnapshot();
|
|
3612
|
+
if (!snapshot) {
|
|
3613
|
+
ctx.ui.notify("No workflow checkpoint in this session", "info");
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
if (ctx.hasUI && ctx.mode === "tui") {
|
|
3617
|
+
await showWorkflowStatus(ctx, () => this.workflowStatusSnapshot());
|
|
3618
|
+
return;
|
|
3619
|
+
}
|
|
3620
|
+
ctx.ui.notify(formatWorkflowStatusText(snapshot), "info");
|
|
3621
|
+
}
|
|
3622
|
+
workflowStatusSnapshot() {
|
|
3623
|
+
if (!this.run)
|
|
3624
|
+
return;
|
|
3625
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
3626
|
+
let execution;
|
|
3627
|
+
if (this.activeDelegation) {
|
|
3628
|
+
execution = {
|
|
3629
|
+
kind: "subagent",
|
|
3630
|
+
agent: this.activeDelegation.agent,
|
|
3631
|
+
requestId: this.activeDelegation.requestId,
|
|
3632
|
+
progress: this.activeDelegation.progress ?? "starting"
|
|
3633
|
+
};
|
|
3634
|
+
} else if (this.mainSteps.activeStepId) {
|
|
3635
|
+
execution = { kind: "main" };
|
|
3636
|
+
}
|
|
3637
|
+
return {
|
|
3638
|
+
run: this.run,
|
|
3639
|
+
now: Date.now(),
|
|
3640
|
+
...workflow ? { workflow } : {},
|
|
3641
|
+
...execution ? { execution } : {}
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
3644
|
+
registerLifecycle() {
|
|
3645
|
+
this.pi.on("session_start", async (_event, ctx) => {
|
|
3646
|
+
this.sessionEpoch += 1;
|
|
3647
|
+
this.sessionActive = false;
|
|
3648
|
+
this.cancelPromptReview();
|
|
3649
|
+
this.mainSteps.deactivate();
|
|
3650
|
+
await this.cancelActiveDelegation("Pi session changed");
|
|
3651
|
+
if (this.run)
|
|
3652
|
+
this.restoreBaselineTools();
|
|
3653
|
+
this.run = undefined;
|
|
3654
|
+
this.latestContext = ctx;
|
|
3655
|
+
if (!await this.reloadCatalog(ctx, false))
|
|
3656
|
+
return;
|
|
3657
|
+
this.restoreFromSession(ctx);
|
|
3658
|
+
this.sessionActive = true;
|
|
3659
|
+
});
|
|
3660
|
+
this.pi.on("session_tree", async (_event, ctx) => {
|
|
3661
|
+
this.sessionEpoch += 1;
|
|
3662
|
+
this.sessionActive = false;
|
|
3663
|
+
this.cancelPromptReview();
|
|
3664
|
+
this.mainSteps.deactivate();
|
|
3665
|
+
await this.cancelActiveDelegation("Pi session tree changed");
|
|
3666
|
+
this.latestContext = ctx;
|
|
3667
|
+
if (!await this.reloadCatalog(ctx, false))
|
|
3668
|
+
return;
|
|
3669
|
+
this.restoreFromSession(ctx);
|
|
3670
|
+
this.sessionActive = true;
|
|
3671
|
+
});
|
|
3672
|
+
this.pi.on("session_shutdown", async () => {
|
|
3673
|
+
this.sessionEpoch += 1;
|
|
3674
|
+
this.sessionActive = false;
|
|
3675
|
+
this.cancelPromptReview();
|
|
3676
|
+
this.mainSteps.deactivate();
|
|
3677
|
+
await this.cancelActiveDelegation("Pi session shut down");
|
|
3678
|
+
if (this.run)
|
|
3679
|
+
this.restoreBaselineTools();
|
|
3680
|
+
this.run = undefined;
|
|
3681
|
+
this.latestContext = undefined;
|
|
3682
|
+
});
|
|
3683
|
+
}
|
|
3684
|
+
registerPolicy() {
|
|
3685
|
+
this.pi.on("before_agent_start", (event, ctx) => {
|
|
3686
|
+
this.latestContext = ctx;
|
|
3687
|
+
this.captureSkills(event.systemPromptOptions.skills);
|
|
3688
|
+
if (!this.run || this.run.status !== "running")
|
|
3689
|
+
return;
|
|
3690
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
3691
|
+
if (!workflow) {
|
|
3692
|
+
this.run = pauseRun(this.run, "Workflow configuration disappeared; reload or restore it", Date.now());
|
|
3693
|
+
this.persist();
|
|
3694
|
+
this.restoreBaselineTools();
|
|
3695
|
+
this.updateStatus();
|
|
3696
|
+
return;
|
|
3697
|
+
}
|
|
3698
|
+
return {
|
|
3699
|
+
systemPrompt: `${event.systemPrompt}
|
|
3700
|
+
|
|
3701
|
+
${buildMainWorkflowNotice(workflow, this.run)}`
|
|
3702
|
+
};
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
launchCurrentStep(workflow) {
|
|
3706
|
+
const run = this.run;
|
|
3707
|
+
if (!run || run.status !== "running" || this.activeDelegation || this.mainSteps.activeStepId) {
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
const step = workflow.definition.steps[run.currentStepId];
|
|
3711
|
+
if (!step) {
|
|
3712
|
+
this.pauseForExecutionFailure("Workflow", `Step "${run.currentStepId}" is missing from the workflow`);
|
|
3713
|
+
return;
|
|
3714
|
+
}
|
|
3715
|
+
const subagent = step.subagent;
|
|
3716
|
+
if (!subagent) {
|
|
3717
|
+
this.launchMainStep(workflow, run, step);
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
const requestId = `${run.runId}:${run.currentStepId}:${randomUUID()}`;
|
|
3721
|
+
const resultDirectory = mkdtempSync(join2(tmpdir2(), "pi-workflows-step-"));
|
|
3722
|
+
const capabilityPath = join2(resultDirectory, "capability");
|
|
3723
|
+
const capabilityToken = randomBytes(32).toString("hex");
|
|
3724
|
+
const resultPath = join2(resultDirectory, "result.json");
|
|
3725
|
+
writeFileSync(capabilityPath, capabilityToken, {
|
|
3726
|
+
encoding: "utf8",
|
|
3727
|
+
flag: "wx",
|
|
3728
|
+
mode: 384
|
|
3729
|
+
});
|
|
3730
|
+
const approvedBashCommands = extractApprovedBashCommands(run.reviewedArtifact ?? "", step.permissions.bash.approvedSources ?? []);
|
|
3731
|
+
const policyDigest = digest({
|
|
3732
|
+
version: 1,
|
|
3733
|
+
requestId,
|
|
3734
|
+
agent: subagent.agent,
|
|
3735
|
+
runId: run.runId,
|
|
3736
|
+
stepId: run.currentStepId,
|
|
3737
|
+
stepDigest: run.currentStepDigest,
|
|
3738
|
+
capabilityPath,
|
|
3739
|
+
resultPath,
|
|
3740
|
+
approvedBashCommands
|
|
3741
|
+
});
|
|
3742
|
+
const policy = {
|
|
3743
|
+
version: 1,
|
|
3744
|
+
requestId,
|
|
3745
|
+
agent: subagent.agent,
|
|
3746
|
+
workflowId: workflow.definition.id,
|
|
3747
|
+
runId: run.runId,
|
|
3748
|
+
stepId: run.currentStepId,
|
|
3749
|
+
stepTitle: step.title,
|
|
3750
|
+
policyDigest,
|
|
3751
|
+
capabilityPath,
|
|
3752
|
+
capabilityToken,
|
|
3753
|
+
resultPath,
|
|
3754
|
+
permissions: structuredClone(step.permissions),
|
|
3755
|
+
...approvedBashCommands.length > 0 ? { approvedBashCommands } : {},
|
|
3756
|
+
outcomes: allowedOutcomes(workflow, run),
|
|
3757
|
+
summaryMaxChars: workflow.definition.summaryMaxChars,
|
|
3758
|
+
...step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {}
|
|
3759
|
+
};
|
|
3760
|
+
const active = {
|
|
3761
|
+
requestId,
|
|
3762
|
+
runId: run.runId,
|
|
3763
|
+
stepId: run.currentStepId,
|
|
3764
|
+
stepDigest: run.currentStepDigest,
|
|
3765
|
+
sessionEpoch: this.sessionEpoch,
|
|
3766
|
+
resultDirectory,
|
|
3767
|
+
policy,
|
|
3768
|
+
agent: subagent.agent
|
|
3769
|
+
};
|
|
3770
|
+
const request = {
|
|
3771
|
+
version: 1,
|
|
3772
|
+
requestId,
|
|
3773
|
+
agent: subagent.agent,
|
|
3774
|
+
task: buildDelegatedStepTask(workflow, run, encodeChildPolicy(policy)),
|
|
3775
|
+
context: subagent.context,
|
|
3776
|
+
cwd: this.latestContext?.cwd ?? process.cwd(),
|
|
3777
|
+
timeoutMs: subagent.timeoutMs,
|
|
3778
|
+
skill: step.permissions.skills.length > 0 ? [...step.permissions.skills] : false,
|
|
3779
|
+
acceptance: {
|
|
3780
|
+
level: "none",
|
|
3781
|
+
reason: "Pi Workflows owns correlated step completion and human-review gates"
|
|
3782
|
+
},
|
|
3783
|
+
artifacts: subagent.artifacts,
|
|
3784
|
+
...subagent.model ? { model: subagent.model } : {},
|
|
3785
|
+
...subagent.turnBudget ? { turnBudget: structuredClone(subagent.turnBudget) } : {},
|
|
3786
|
+
...subagent.toolBudget ? { toolBudget: structuredClone(subagent.toolBudget) } : {}
|
|
3787
|
+
};
|
|
3788
|
+
this.activeDelegation = active;
|
|
3789
|
+
this.updateStatus();
|
|
3790
|
+
this.latestContext?.ui.notify(`Delegated "${run.currentStepId}" to subagent "${subagent.agent}"`, "info");
|
|
3791
|
+
this.subagents.delegate(request, {
|
|
3792
|
+
onUpdate: (update) => this.handleDelegationUpdate(active, update),
|
|
3793
|
+
onLateTerminal: (response) => this.queueDelegationResponse(active, response)
|
|
3794
|
+
}).then((response) => this.queueDelegationResponse(active, response), (error) => this.queueDelegationFailure(active, error instanceof Error ? error.message : String(error)));
|
|
3795
|
+
}
|
|
3796
|
+
launchMainStep(workflow, run, step) {
|
|
3797
|
+
const approvedBashCommands = extractApprovedBashCommands(run.reviewedArtifact ?? "", step.permissions.bash.approvedSources ?? []);
|
|
3798
|
+
const identity = {
|
|
3799
|
+
runId: run.runId,
|
|
3800
|
+
stepId: run.currentStepId,
|
|
3801
|
+
stepDigest: run.currentStepDigest,
|
|
3802
|
+
sessionEpoch: this.sessionEpoch
|
|
3803
|
+
};
|
|
3804
|
+
const policyDigest = digest({
|
|
3805
|
+
version: 1,
|
|
3806
|
+
execution: "main",
|
|
3807
|
+
workflowId: workflow.definition.id,
|
|
3808
|
+
runId: run.runId,
|
|
3809
|
+
stepId: run.currentStepId,
|
|
3810
|
+
stepDigest: run.currentStepDigest,
|
|
3811
|
+
permissions: step.permissions,
|
|
3812
|
+
approvedBashCommands,
|
|
3813
|
+
nonce: randomUUID()
|
|
3814
|
+
});
|
|
3815
|
+
const execution = {
|
|
3816
|
+
workflowId: workflow.definition.id,
|
|
3817
|
+
runId: run.runId,
|
|
3818
|
+
stepId: run.currentStepId,
|
|
3819
|
+
stepDigest: run.currentStepDigest,
|
|
3820
|
+
policyDigest,
|
|
3821
|
+
step: structuredClone(step),
|
|
3822
|
+
approvedBashCommands,
|
|
3823
|
+
outcomes: allowedOutcomes(workflow, run),
|
|
3824
|
+
summaryMaxChars: workflow.definition.summaryMaxChars,
|
|
3825
|
+
...step.gate ? { gateSubmitOutcome: step.gate.submitOutcome } : {},
|
|
3826
|
+
onSettled: (result, context) => this.queueMainStepResult(identity, result, context)
|
|
3827
|
+
};
|
|
3828
|
+
try {
|
|
3829
|
+
this.mainSteps.activate(execution);
|
|
3830
|
+
this.updateStatus();
|
|
3831
|
+
this.latestContext?.ui.notify(`Started "${run.currentStepId}" in the main agent`, "info");
|
|
3832
|
+
this.pi.sendUserMessage(buildMainStepTask(workflow, run), {
|
|
3833
|
+
deliverAs: "followUp"
|
|
3834
|
+
});
|
|
3835
|
+
} catch (error) {
|
|
3836
|
+
this.mainSteps.deactivate();
|
|
3837
|
+
this.pauseForExecutionFailure("Main-agent step", error instanceof Error ? error.message : String(error));
|
|
3838
|
+
}
|
|
3839
|
+
}
|
|
3840
|
+
queueMainStepResult(identity, result, context) {
|
|
3841
|
+
return this.mutationQueue.run(() => this.finishMainStep(identity, result, context)).catch((error) => {
|
|
3842
|
+
this.pauseForExecutionFailure("Main-agent step", error instanceof Error ? error.message : String(error));
|
|
3843
|
+
});
|
|
3844
|
+
}
|
|
3845
|
+
async finishMainStep(identity, result, context) {
|
|
3846
|
+
this.latestContext = context;
|
|
3847
|
+
if (!this.sessionActive || this.sessionEpoch !== identity.sessionEpoch || !this.run || this.run.status !== "running" || this.run.runId !== identity.runId || this.run.currentStepId !== identity.stepId || this.run.currentStepDigest !== identity.stepDigest) {
|
|
3848
|
+
return;
|
|
3849
|
+
}
|
|
3850
|
+
if (!result) {
|
|
3851
|
+
throw new Error("agent settled without calling workflow_complete_step exactly once");
|
|
3852
|
+
}
|
|
3853
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
3854
|
+
const step = workflow?.definition.steps[this.run.currentStepId];
|
|
3855
|
+
if (!workflow || !step) {
|
|
3856
|
+
throw new Error("Active workflow configuration is unavailable");
|
|
3857
|
+
}
|
|
3858
|
+
if (step.gate?.submitOutcome === result.outcome) {
|
|
3859
|
+
await this.submitGate(workflow, this.run, result.outcome, result.artifact ?? "");
|
|
3860
|
+
return;
|
|
3861
|
+
}
|
|
3862
|
+
this.run = advanceRun(workflow, this.run, result.outcome, result.summary, Date.now());
|
|
3863
|
+
this.settleAfterTransition(workflow);
|
|
3864
|
+
}
|
|
3865
|
+
handleDelegationUpdate(active, update) {
|
|
3866
|
+
if (this.activeDelegation !== active)
|
|
3867
|
+
return;
|
|
3868
|
+
const progress = [
|
|
3869
|
+
update.currentTool ? `tool ${update.currentTool}` : undefined,
|
|
3870
|
+
update.toolCount !== undefined ? `${update.toolCount} calls` : undefined,
|
|
3871
|
+
update.tokens !== undefined ? `${update.tokens} tokens` : undefined
|
|
3872
|
+
].filter((part) => part !== undefined);
|
|
3873
|
+
active.progress = progress.join(", ") || "running";
|
|
3874
|
+
this.updateStatus();
|
|
3875
|
+
}
|
|
3876
|
+
queueDelegationResponse(active, response) {
|
|
3877
|
+
this.mutationQueue.run(() => this.finishDelegation(active, response)).catch((error) => {
|
|
3878
|
+
this.pauseForDelegationFailure(error instanceof Error ? error.message : String(error));
|
|
3879
|
+
});
|
|
3880
|
+
}
|
|
3881
|
+
queueDelegationFailure(active, reason) {
|
|
3882
|
+
this.mutationQueue.run(async () => {
|
|
3883
|
+
if (this.activeDelegation !== active) {
|
|
3884
|
+
await this.cleanupDelegation(active);
|
|
3885
|
+
return;
|
|
3886
|
+
}
|
|
3887
|
+
if (this.subagents.activeRequestId === active.requestId) {
|
|
3888
|
+
this.retainUnconfirmedDelegation(active, reason);
|
|
3889
|
+
return;
|
|
3890
|
+
}
|
|
3891
|
+
this.activeDelegation = undefined;
|
|
3892
|
+
await this.cleanupDelegation(active);
|
|
3893
|
+
this.pauseForDelegationFailure(reason);
|
|
3894
|
+
}).catch((error) => {
|
|
3895
|
+
this.pauseForDelegationFailure(error instanceof Error ? error.message : String(error));
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
async finishDelegation(active, response) {
|
|
3899
|
+
if (this.activeDelegation !== active) {
|
|
3900
|
+
await this.cleanupDelegation(active);
|
|
3901
|
+
return;
|
|
3902
|
+
}
|
|
3903
|
+
this.activeDelegation = undefined;
|
|
3904
|
+
try {
|
|
3905
|
+
if (!this.sessionActive || this.sessionEpoch !== active.sessionEpoch || !this.run || this.run.status !== "running" || this.run.runId !== active.runId || this.run.currentStepId !== active.stepId || this.run.currentStepDigest !== active.stepDigest) {
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
if (response.status !== "completed") {
|
|
3909
|
+
throw new Error(`Subagent "${active.agent}" ${response.status.replaceAll("_", " ")}${response.error ? `: ${response.error}` : ""}`);
|
|
3910
|
+
}
|
|
3911
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
3912
|
+
const step = workflow?.definition.steps[this.run.currentStepId];
|
|
3913
|
+
if (!workflow || !step) {
|
|
3914
|
+
throw new Error("Active workflow configuration is unavailable");
|
|
3915
|
+
}
|
|
3916
|
+
const requiredSkillWarning = step.requires.skills.length > 0 ? response.warnings?.find((warning) => /skill/i.test(warning)) : undefined;
|
|
3917
|
+
if (requiredSkillWarning) {
|
|
3918
|
+
throw new Error(`Subagent skill preflight failed: ${requiredSkillWarning}`);
|
|
3919
|
+
}
|
|
3920
|
+
const rawResult = JSON.parse(await readFile2(active.policy.resultPath, "utf8"));
|
|
3921
|
+
const result = parseDelegatedStepResult(rawResult, active.policy);
|
|
3922
|
+
if (step.gate?.submitOutcome === result.outcome) {
|
|
3923
|
+
await this.submitGate(workflow, this.run, result.outcome, result.artifact ?? "");
|
|
3924
|
+
return;
|
|
3925
|
+
}
|
|
3926
|
+
this.run = advanceRun(workflow, this.run, result.outcome, result.summary, Date.now());
|
|
3927
|
+
this.settleAfterTransition(workflow);
|
|
3928
|
+
} catch (error) {
|
|
3929
|
+
this.pauseForDelegationFailure(error instanceof Error ? error.message : String(error));
|
|
3930
|
+
} finally {
|
|
3931
|
+
await this.cleanupDelegation(active);
|
|
3932
|
+
if (active.cancelling)
|
|
3933
|
+
this.releaseMainAfterCancellation(active);
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
async cancelActiveDelegation(reason) {
|
|
3937
|
+
const active = this.activeDelegation;
|
|
3938
|
+
if (!active)
|
|
3939
|
+
return true;
|
|
3940
|
+
active.cancelling = true;
|
|
3941
|
+
active.progress = "cancelling";
|
|
3942
|
+
this.updateStatus();
|
|
3943
|
+
if (this.subagents.activeRequestId !== active.requestId) {
|
|
3944
|
+
active.progress = "cancellation unconfirmed";
|
|
3945
|
+
this.updateStatus();
|
|
3946
|
+
this.latestContext?.ui.notify(`${reason}; the delegation channel already closed without a terminal response`, "warning");
|
|
3947
|
+
return false;
|
|
3948
|
+
}
|
|
3949
|
+
const confirmed = await this.subagents.cancelActiveAndWait();
|
|
3950
|
+
if (confirmed && this.activeDelegation === active) {
|
|
3951
|
+
this.activeDelegation = undefined;
|
|
3952
|
+
await this.cleanupDelegation(active);
|
|
3953
|
+
} else if (!confirmed) {
|
|
3954
|
+
this.latestContext?.ui.notify(`${reason}; waiting for subagent "${active.agent}" to confirm termination`, "warning");
|
|
3955
|
+
}
|
|
3956
|
+
return confirmed;
|
|
3957
|
+
}
|
|
3958
|
+
async cleanupDelegation(active) {
|
|
3959
|
+
await rm(active.resultDirectory, { recursive: true, force: true });
|
|
3960
|
+
}
|
|
3961
|
+
pauseForDelegationFailure(reason) {
|
|
3962
|
+
this.pauseForExecutionFailure("Subagent step", reason);
|
|
3963
|
+
}
|
|
3964
|
+
pauseForExecutionFailure(label, reason) {
|
|
3965
|
+
if (!this.run || this.run.status !== "running")
|
|
3966
|
+
return;
|
|
3967
|
+
this.mainSteps.deactivate();
|
|
3968
|
+
this.run = pauseRun(this.run, `${label} failed: ${reason}`, Date.now());
|
|
3969
|
+
this.persist();
|
|
3970
|
+
if (this.activeDelegation) {
|
|
3971
|
+
this.isolateMainSessionTools();
|
|
3972
|
+
} else {
|
|
3973
|
+
this.restoreBaselineTools();
|
|
3974
|
+
}
|
|
3975
|
+
this.updateStatus();
|
|
3976
|
+
this.latestContext?.ui.notify(`Workflow paused at "${this.run.currentStepId}": ${reason}`, "error");
|
|
3977
|
+
}
|
|
3978
|
+
retainUnconfirmedDelegation(active, reason) {
|
|
3979
|
+
active.cancelling = true;
|
|
3980
|
+
active.progress = "cancellation unconfirmed";
|
|
3981
|
+
if (this.run?.status === "running") {
|
|
3982
|
+
this.run = pauseRun(this.run, `Subagent step failed: ${reason}`, Date.now());
|
|
3983
|
+
this.persist();
|
|
3984
|
+
}
|
|
3985
|
+
this.isolateMainSessionTools();
|
|
3986
|
+
this.updateStatus();
|
|
3987
|
+
this.latestContext?.ui.notify(`Workflow paused, but subagent "${active.agent}" has not confirmed termination. Main tools and resume remain blocked; restart Pi if no terminal response arrives.`, "error");
|
|
3988
|
+
}
|
|
3989
|
+
releaseMainAfterCancellation(active) {
|
|
3990
|
+
if (this.activeDelegation === active)
|
|
3991
|
+
this.activeDelegation = undefined;
|
|
3992
|
+
if (!this.activeDelegation && this.run && this.run.status !== "running" && this.run.status !== "awaiting-gate") {
|
|
3993
|
+
this.restoreBaselineTools();
|
|
3994
|
+
this.updateStatus();
|
|
3995
|
+
this.latestContext?.ui.notify(`Subagent "${active.agent}" has terminated; main tools are restored`, "info");
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
async submitGate(workflow, originalRun, outcome, artifact) {
|
|
3999
|
+
const requestSessionEpoch = this.sessionEpoch;
|
|
4000
|
+
const requestId = `${originalRun.runId}:${originalRun.currentStepId}:${randomUUID()}`;
|
|
4001
|
+
const step = workflow.definition.steps[originalRun.currentStepId];
|
|
4002
|
+
if (!step?.gate)
|
|
4003
|
+
throw new Error("Current step has no gate");
|
|
4004
|
+
this.run = beginGate(workflow, originalRun, outcome, artifact, requestId, Date.now());
|
|
4005
|
+
this.persist();
|
|
4006
|
+
this.restoreBaselineTools();
|
|
4007
|
+
this.updateStatus();
|
|
4008
|
+
if (step.gate.provider === "prompt") {
|
|
4009
|
+
this.launchPromptReview(workflow, this.run, this.latestContext);
|
|
4010
|
+
return;
|
|
4011
|
+
}
|
|
4012
|
+
const response = await requestPlannotatorReview(this.pi.events, requestId, artifact, `pi-workflows:${workflow.definition.id}:${originalRun.currentStepId}`, step.gate.timeoutMs);
|
|
4013
|
+
if (!this.sessionActive || this.sessionEpoch !== requestSessionEpoch || !this.run || this.run.runId !== originalRun.runId || this.run.currentStepId !== originalRun.currentStepId || this.run.pendingGate?.requestId !== requestId || this.run.pendingGate.reviewId !== undefined) {
|
|
4014
|
+
throw new Error("Gate request was superseded by a workflow state change");
|
|
4015
|
+
}
|
|
4016
|
+
if (this.run.status !== "awaiting-gate" && !(this.run.status === "paused" && this.run.pendingGate)) {
|
|
4017
|
+
throw new Error("Gate request was superseded by a workflow state change");
|
|
4018
|
+
}
|
|
4019
|
+
if (response.status !== "handled") {
|
|
4020
|
+
const reason = response.error ?? "Plannotator is unavailable";
|
|
4021
|
+
const gateFailed = failGate(this.run, reason, Date.now());
|
|
4022
|
+
this.run = this.run.status === "paused" ? pauseRun(gateFailed, reason, Date.now()) : gateFailed;
|
|
4023
|
+
this.persist();
|
|
4024
|
+
if (this.run.status === "running") {
|
|
4025
|
+
this.isolateMainSessionTools();
|
|
4026
|
+
} else {
|
|
4027
|
+
this.restoreBaselineTools();
|
|
4028
|
+
}
|
|
4029
|
+
this.updateStatus();
|
|
4030
|
+
throw new Error(reason);
|
|
4031
|
+
}
|
|
4032
|
+
this.run = attachGateReviewId(this.run, response.result.reviewId, Date.now());
|
|
4033
|
+
this.persist();
|
|
4034
|
+
this.updateStatus();
|
|
4035
|
+
this.latestContext?.ui.notify(`Submitted "${originalRun.currentStepId}" for Plannotator review ${response.result.reviewId}`, "info");
|
|
4036
|
+
}
|
|
4037
|
+
launchPromptReview(workflow, run, context) {
|
|
4038
|
+
const pendingGate = run.pendingGate;
|
|
4039
|
+
if (!pendingGate || pendingGate.provider !== "prompt")
|
|
4040
|
+
return;
|
|
4041
|
+
if (!context?.hasUI) {
|
|
4042
|
+
this.pausePromptGate(pendingGate.requestId, "Built-in review requires Pi TUI or RPC mode; resume there to continue");
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
if (this.activePromptReview?.requestId === pendingGate.requestId)
|
|
4046
|
+
return;
|
|
4047
|
+
this.cancelPromptReview();
|
|
4048
|
+
const active = {
|
|
4049
|
+
requestId: pendingGate.requestId,
|
|
4050
|
+
runId: run.runId,
|
|
4051
|
+
stepId: pendingGate.stepId,
|
|
4052
|
+
sessionEpoch: this.sessionEpoch,
|
|
4053
|
+
abortController: new AbortController
|
|
4054
|
+
};
|
|
4055
|
+
this.activePromptReview = active;
|
|
4056
|
+
requestPromptGateReview(context.ui, `Review ${workflow.definition.id}:${pendingGate.stepId}`, pendingGate.artifact, active.abortController.signal).then((result) => this.queuePromptReviewResult(active, result), (error) => this.queuePromptReviewFailure(active, error instanceof Error ? error.message : String(error)));
|
|
4057
|
+
}
|
|
4058
|
+
queuePromptReviewResult(active, result) {
|
|
4059
|
+
this.mutationQueue.run(() => this.finishPromptReview(active, result)).catch((error) => {
|
|
4060
|
+
this.latestContext?.ui.notify(`Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
4061
|
+
});
|
|
4062
|
+
}
|
|
4063
|
+
queuePromptReviewFailure(active, reason) {
|
|
4064
|
+
this.mutationQueue.run(async () => {
|
|
4065
|
+
if (this.activePromptReview !== active)
|
|
4066
|
+
return;
|
|
4067
|
+
this.activePromptReview = undefined;
|
|
4068
|
+
this.pausePromptGate(active.requestId, `Built-in review failed: ${reason}`);
|
|
4069
|
+
}).catch((error) => {
|
|
4070
|
+
this.latestContext?.ui.notify(`Cannot pause failed built-in review: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
4071
|
+
});
|
|
4072
|
+
}
|
|
4073
|
+
async finishPromptReview(active, result) {
|
|
4074
|
+
if (this.activePromptReview !== active)
|
|
4075
|
+
return;
|
|
4076
|
+
this.activePromptReview = undefined;
|
|
4077
|
+
if (!this.sessionActive || this.sessionEpoch !== active.sessionEpoch || !this.run || this.run.runId !== active.runId || this.run.currentStepId !== active.stepId || this.run.pendingGate?.provider !== "prompt" || this.run.pendingGate.requestId !== active.requestId) {
|
|
4078
|
+
return;
|
|
4079
|
+
}
|
|
4080
|
+
if (result.status === "dismissed") {
|
|
4081
|
+
this.pausePromptGate(active.requestId, "Built-in review was dismissed; resume to reopen it");
|
|
4082
|
+
return;
|
|
4083
|
+
}
|
|
4084
|
+
const resolution = {
|
|
4085
|
+
approved: result.approved,
|
|
4086
|
+
feedback: result.feedback,
|
|
4087
|
+
resolvedAt: Date.now()
|
|
4088
|
+
};
|
|
4089
|
+
if (this.run.status === "paused") {
|
|
4090
|
+
this.run = storeGateResolution(this.run, resolution, Date.now());
|
|
4091
|
+
this.persist();
|
|
4092
|
+
this.latestContext?.ui.notify("Built-in review finished while paused. Run /workflow-resume to apply it.", "info");
|
|
4093
|
+
return;
|
|
4094
|
+
}
|
|
4095
|
+
if (this.run.status !== "awaiting-gate")
|
|
4096
|
+
return;
|
|
4097
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
4098
|
+
if (!workflow) {
|
|
4099
|
+
this.pausePromptGate(active.requestId, "Built-in review finished, but workflow configuration is unavailable");
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
try {
|
|
4103
|
+
this.run = resolveGate(workflow, this.run, resolution, Date.now());
|
|
4104
|
+
this.settleAfterTransition(workflow);
|
|
4105
|
+
} catch (error) {
|
|
4106
|
+
this.pausePromptGate(active.requestId, `Cannot apply built-in review: ${error instanceof Error ? error.message : String(error)}`);
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
pausePromptGate(requestId, reason) {
|
|
4110
|
+
if (!this.run || this.run.pendingGate?.provider !== "prompt" || this.run.pendingGate.requestId !== requestId) {
|
|
4111
|
+
return;
|
|
4112
|
+
}
|
|
4113
|
+
if (this.run.status === "awaiting-gate") {
|
|
4114
|
+
this.run = pauseRun(this.run, reason, Date.now());
|
|
4115
|
+
}
|
|
4116
|
+
this.persist();
|
|
4117
|
+
this.restoreBaselineTools();
|
|
4118
|
+
this.updateStatus();
|
|
4119
|
+
this.latestContext?.ui.notify(`Workflow paused at "${this.run.currentStepId}": ${reason}`, "warning");
|
|
4120
|
+
}
|
|
4121
|
+
cancelPromptReview() {
|
|
4122
|
+
const active = this.activePromptReview;
|
|
4123
|
+
if (!active)
|
|
4124
|
+
return;
|
|
4125
|
+
this.activePromptReview = undefined;
|
|
4126
|
+
active.abortController.abort();
|
|
4127
|
+
}
|
|
4128
|
+
registerPlannotatorResults() {
|
|
4129
|
+
this.pi.events.on(PLANNOTATOR_RESULT_CHANNEL, (data) => {
|
|
4130
|
+
this.mutationQueue.run(() => this.handlePlannotatorResult(data)).catch((error) => {
|
|
4131
|
+
this.latestContext?.ui.notify(`Cannot apply Plannotator result: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
4132
|
+
});
|
|
4133
|
+
});
|
|
4134
|
+
}
|
|
4135
|
+
async handlePlannotatorResult(data) {
|
|
4136
|
+
if (!this.sessionActive || this.run?.pendingGate?.provider !== "plannotator" || !this.run.pendingGate.reviewId) {
|
|
4137
|
+
return;
|
|
4138
|
+
}
|
|
4139
|
+
const result = parsePlannotatorResult(data);
|
|
4140
|
+
if (!result || result.reviewId !== this.run.pendingGate.reviewId)
|
|
4141
|
+
return;
|
|
4142
|
+
const resolution = {
|
|
4143
|
+
approved: result.approved,
|
|
4144
|
+
feedback: result.feedback,
|
|
4145
|
+
resolvedAt: Date.now()
|
|
4146
|
+
};
|
|
4147
|
+
if (this.run.status === "paused") {
|
|
4148
|
+
this.run = storeGateResolution(this.run, resolution, Date.now());
|
|
4149
|
+
this.persist();
|
|
4150
|
+
this.latestContext?.ui.notify(`Review ${result.reviewId} finished while paused. Run /workflow-resume to apply it.`, "info");
|
|
4151
|
+
return;
|
|
4152
|
+
}
|
|
4153
|
+
if (this.run.status !== "awaiting-gate")
|
|
4154
|
+
return;
|
|
4155
|
+
const workflow = this.catalog.workflows.get(this.run.workflowId);
|
|
4156
|
+
if (!workflow) {
|
|
4157
|
+
this.run = pauseRun(this.run, "Gate result arrived, but workflow configuration is unavailable", Date.now());
|
|
4158
|
+
this.persist();
|
|
4159
|
+
this.restoreBaselineTools();
|
|
4160
|
+
this.updateStatus();
|
|
4161
|
+
return;
|
|
4162
|
+
}
|
|
4163
|
+
try {
|
|
4164
|
+
this.run = resolveGate(workflow, this.run, resolution, Date.now());
|
|
4165
|
+
this.settleAfterTransition(workflow);
|
|
4166
|
+
} catch (error) {
|
|
4167
|
+
this.run = pauseRun(this.run, `Cannot apply gate result: ${error instanceof Error ? error.message : String(error)}`, Date.now());
|
|
4168
|
+
this.persist();
|
|
4169
|
+
this.restoreBaselineTools();
|
|
4170
|
+
this.updateStatus();
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
settleAfterTransition(workflow) {
|
|
4174
|
+
if (!this.run)
|
|
4175
|
+
return;
|
|
4176
|
+
if (this.run.status === "running") {
|
|
4177
|
+
const preflightErrors = this.preflight(workflow, this.run.currentStepId);
|
|
4178
|
+
if (preflightErrors.length > 0) {
|
|
4179
|
+
this.run = pauseRun(this.run, `Step preflight failed: ${preflightErrors.join("; ")}`, Date.now());
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
this.persist();
|
|
4183
|
+
if (this.run.status !== "running") {
|
|
4184
|
+
this.restoreBaselineTools();
|
|
4185
|
+
this.updateStatus();
|
|
4186
|
+
if (this.run.status === "completed") {
|
|
4187
|
+
this.latestContext?.ui.notify(`Workflow "${this.run.workflowId}" completed`, "info");
|
|
4188
|
+
} else if (this.run.status === "paused") {
|
|
4189
|
+
this.latestContext?.ui.notify(`Workflow paused: ${this.run.pauseReason ?? "manual action required"}`, "warning");
|
|
4190
|
+
}
|
|
4191
|
+
return;
|
|
4192
|
+
}
|
|
4193
|
+
this.isolateMainSessionTools();
|
|
4194
|
+
this.updateStatus();
|
|
4195
|
+
this.launchCurrentStep(workflow);
|
|
4196
|
+
}
|
|
4197
|
+
preflight(workflow, stepId) {
|
|
4198
|
+
const step = workflow.definition.steps[stepId];
|
|
4199
|
+
if (!step)
|
|
4200
|
+
return [`step "${stepId}" does not exist`];
|
|
4201
|
+
return preflightStep(step, {
|
|
4202
|
+
tools: this.pi.getAllTools(),
|
|
4203
|
+
commands: this.pi.getCommands(),
|
|
4204
|
+
skills: this.availableSkills
|
|
4205
|
+
});
|
|
4206
|
+
}
|
|
4207
|
+
isolateMainSessionTools() {
|
|
4208
|
+
this.pi.setActiveTools([]);
|
|
4209
|
+
}
|
|
4210
|
+
restoreBaselineTools() {
|
|
4211
|
+
this.mainSteps.release();
|
|
4212
|
+
if (this.run) {
|
|
4213
|
+
this.pi.setActiveTools(this.run.baselineTools);
|
|
4214
|
+
return;
|
|
4215
|
+
}
|
|
4216
|
+
this.pi.setActiveTools(this.pi.getActiveTools());
|
|
4217
|
+
}
|
|
4218
|
+
captureSkills(skills) {
|
|
4219
|
+
if (!skills)
|
|
4220
|
+
return;
|
|
4221
|
+
this.availableSkills = new Set(skills.map((skill) => skill.name));
|
|
4222
|
+
}
|
|
4223
|
+
enqueueMutation(ctx, operation) {
|
|
4224
|
+
if (!this.sessionActive) {
|
|
4225
|
+
ctx.ui.notify("The Pi session is still initializing", "warning");
|
|
4226
|
+
return Promise.resolve();
|
|
4227
|
+
}
|
|
4228
|
+
const sessionEpoch = this.sessionEpoch;
|
|
4229
|
+
return this.mutationQueue.run(async () => {
|
|
4230
|
+
if (!this.sessionActive || this.sessionEpoch !== sessionEpoch) {
|
|
4231
|
+
ctx.ui.notify("Workflow command was superseded by a session change", "warning");
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
await operation(sessionEpoch);
|
|
4235
|
+
});
|
|
4236
|
+
}
|
|
4237
|
+
persist() {
|
|
4238
|
+
if (this.run) {
|
|
4239
|
+
this.pi.appendEntry(STATE_ENTRY_TYPE, structuredClone(this.run));
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
restoreFromSession(ctx) {
|
|
4243
|
+
this.latestContext = ctx;
|
|
4244
|
+
const previousBaseline = this.run?.baselineTools;
|
|
4245
|
+
const entries = ctx.sessionManager.getBranch();
|
|
4246
|
+
const checkpoint = readLatestCheckpoint(entries, STATE_ENTRY_TYPE);
|
|
4247
|
+
this.run = checkpoint.status === "valid" ? checkpoint.run : undefined;
|
|
4248
|
+
if (checkpoint.status === "invalid") {
|
|
4249
|
+
ctx.ui.notify("The newest workflow checkpoint is invalid or from an unsupported version; recovery stopped", "error");
|
|
4250
|
+
}
|
|
4251
|
+
if (this.run && (this.run.status === "running" || this.run.status === "awaiting-gate")) {
|
|
4252
|
+
this.run = pauseRun(this.run, "Session was restored; inspect the checkpoint before resuming", Date.now());
|
|
4253
|
+
this.persist();
|
|
4254
|
+
}
|
|
4255
|
+
if (!this.run && previousBaseline) {
|
|
4256
|
+
this.pi.setActiveTools(previousBaseline);
|
|
4257
|
+
} else {
|
|
4258
|
+
this.restoreBaselineTools();
|
|
4259
|
+
}
|
|
4260
|
+
if (this.activeDelegation)
|
|
4261
|
+
this.isolateMainSessionTools();
|
|
4262
|
+
this.updateStatus();
|
|
4263
|
+
}
|
|
4264
|
+
async reloadCatalog(ctx, announce) {
|
|
4265
|
+
const loadSequence = ++this.catalogLoadSequence;
|
|
4266
|
+
const sessionEpoch = this.sessionEpoch;
|
|
4267
|
+
const catalog = await loadCatalog({
|
|
4268
|
+
cwd: ctx.cwd,
|
|
4269
|
+
projectTrusted: ctx.isProjectTrusted()
|
|
4270
|
+
});
|
|
4271
|
+
if (loadSequence !== this.catalogLoadSequence || sessionEpoch !== this.sessionEpoch) {
|
|
4272
|
+
return false;
|
|
4273
|
+
}
|
|
4274
|
+
this.latestContext = ctx;
|
|
4275
|
+
const availableCommands = this.pi.getCommands();
|
|
4276
|
+
for (const [workflowId, workflow] of catalog.workflows) {
|
|
4277
|
+
const command = workflow.definition.command;
|
|
4278
|
+
if (hasRuntimeCommandConflict(command, availableCommands, this.registeredWorkflowCommands)) {
|
|
4279
|
+
catalog.workflows.delete(workflowId);
|
|
4280
|
+
catalog.diagnostics.push({
|
|
4281
|
+
level: "error",
|
|
4282
|
+
path: workflow.sourcePath,
|
|
4283
|
+
message: `command "/${command}" conflicts with another loaded Pi resource`
|
|
4284
|
+
});
|
|
4285
|
+
}
|
|
4286
|
+
}
|
|
4287
|
+
this.catalog = catalog;
|
|
4288
|
+
for (const workflow of catalog.workflows.values()) {
|
|
4289
|
+
this.pi.registerCommand(workflow.definition.command, {
|
|
4290
|
+
description: workflow.definition.description,
|
|
4291
|
+
handler: async (args, commandContext) => this.start(workflow.definition.id, args, commandContext)
|
|
4292
|
+
});
|
|
4293
|
+
this.registeredWorkflowCommands.add(workflow.definition.command);
|
|
4294
|
+
}
|
|
4295
|
+
if (announce) {
|
|
4296
|
+
const diagnosticText = formatDiagnostics(this.catalog);
|
|
4297
|
+
ctx.ui.notify(diagnosticText ? `Loaded ${this.catalog.workflows.size} workflow(s)
|
|
4298
|
+
${diagnosticText}` : `Loaded ${this.catalog.workflows.size} workflow(s)`, diagnosticText ? "warning" : "info");
|
|
4299
|
+
} else if (this.catalog.diagnostics.some((item) => item.level === "error")) {
|
|
4300
|
+
ctx.ui.notify(`Workflow configuration errors:
|
|
4301
|
+
${formatDiagnostics(this.catalog)}`, "warning");
|
|
4302
|
+
}
|
|
4303
|
+
return true;
|
|
4304
|
+
}
|
|
4305
|
+
updateStatus() {
|
|
4306
|
+
if (!this.latestContext)
|
|
4307
|
+
return;
|
|
4308
|
+
if (!this.run) {
|
|
4309
|
+
this.latestContext.ui.setStatus(STATUS_KEY, undefined);
|
|
4310
|
+
return;
|
|
4311
|
+
}
|
|
4312
|
+
const delegation = this.activeDelegation ? `; ${this.activeDelegation.agent}: ${this.activeDelegation.progress ?? "starting"}` : this.mainSteps.activeStepId ? "; main agent: running" : "";
|
|
4313
|
+
this.latestContext.ui.setStatus(STATUS_KEY, `${this.run.workflowId}: ${this.run.currentStepId} (${this.run.status}${delegation})`);
|
|
4314
|
+
}
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
// src/integrations/subagents/child-runtime.ts
|
|
4318
|
+
import { randomUUID as randomUUID2, timingSafeEqual } from "node:crypto";
|
|
4319
|
+
import {
|
|
4320
|
+
existsSync,
|
|
4321
|
+
readFileSync,
|
|
4322
|
+
renameSync,
|
|
4323
|
+
unlinkSync,
|
|
4324
|
+
writeFileSync as writeFileSync2
|
|
4325
|
+
} from "node:fs";
|
|
4326
|
+
var CHILD_COMPLETION_TOOL = WORKFLOW_COMPLETION_TOOL;
|
|
4327
|
+
function policyStep(policy) {
|
|
4328
|
+
return {
|
|
4329
|
+
title: policy.stepTitle,
|
|
4330
|
+
prompt: { inline: "Delegated workflow step" },
|
|
4331
|
+
subagent: {
|
|
4332
|
+
agent: policy.agent,
|
|
4333
|
+
context: "fresh",
|
|
4334
|
+
timeoutMs: 900000,
|
|
4335
|
+
artifacts: false
|
|
4336
|
+
},
|
|
4337
|
+
permissions: policy.permissions,
|
|
4338
|
+
requires: { tools: [], extensions: [], skills: [] },
|
|
4339
|
+
transitions: {}
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
function childSystemPrompt(policy) {
|
|
4343
|
+
return [
|
|
4344
|
+
"# Pi Workflows delegated step",
|
|
4345
|
+
"",
|
|
4346
|
+
`Workflow: ${policy.workflowId}`,
|
|
4347
|
+
`Run: ${policy.runId}`,
|
|
4348
|
+
`Step: ${policy.stepId} (${policy.stepTitle})`,
|
|
4349
|
+
"",
|
|
4350
|
+
"The parent workflow harness owns orchestration and state transitions.",
|
|
4351
|
+
"Perform only this delegated step. Its child-side tool policy is enforced.",
|
|
4352
|
+
"When finished, call `workflow_complete_step` exactly once and as the only tool call in that message.",
|
|
4353
|
+
`Valid outcomes: ${policy.outcomes.join(", ")}`,
|
|
4354
|
+
`Summary limit: ${policy.summaryMaxChars} characters`,
|
|
4355
|
+
...policy.gateSubmitOutcome ? [
|
|
4356
|
+
`Outcome "${policy.gateSubmitOutcome}" requires the complete gate artifact.`
|
|
4357
|
+
] : [],
|
|
4358
|
+
"If the workflow definition or environment is wrong, choose an outcome that transitions to $pause."
|
|
4359
|
+
].join(`
|
|
4360
|
+
`);
|
|
4361
|
+
}
|
|
4362
|
+
function writeResult(policy, result) {
|
|
4363
|
+
if (existsSync(policy.resultPath)) {
|
|
4364
|
+
throw new Error("Delegated workflow step already produced a result");
|
|
4365
|
+
}
|
|
4366
|
+
const temporaryPath = `${policy.resultPath}.${randomUUID2()}.tmp`;
|
|
4367
|
+
try {
|
|
4368
|
+
writeFileSync2(temporaryPath, JSON.stringify(result), {
|
|
4369
|
+
encoding: "utf8",
|
|
4370
|
+
flag: "wx",
|
|
4371
|
+
mode: 384
|
|
4372
|
+
});
|
|
4373
|
+
renameSync(temporaryPath, policy.resultPath);
|
|
4374
|
+
} catch (error) {
|
|
4375
|
+
try {
|
|
4376
|
+
unlinkSync(temporaryPath);
|
|
4377
|
+
} catch {}
|
|
4378
|
+
throw error;
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
function verifyCapability(policy, childAgent) {
|
|
4382
|
+
if (!isSubagentRuntimeName(childAgent) || childAgent !== policy.agent) {
|
|
4383
|
+
throw new Error("child agent does not match the delegated workflow policy");
|
|
4384
|
+
}
|
|
4385
|
+
let actual;
|
|
4386
|
+
try {
|
|
4387
|
+
actual = Buffer.from(readFileSync(policy.capabilityPath, "utf8"), "utf8");
|
|
4388
|
+
} catch {
|
|
4389
|
+
throw new Error("delegated workflow capability is missing");
|
|
4390
|
+
}
|
|
4391
|
+
const expected = Buffer.from(policy.capabilityToken, "utf8");
|
|
4392
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
4393
|
+
throw new Error("delegated workflow capability is invalid");
|
|
4394
|
+
}
|
|
4395
|
+
unlinkSync(policy.capabilityPath);
|
|
4396
|
+
}
|
|
4397
|
+
function registerSubagentChildRuntime(pi, options = {}) {
|
|
4398
|
+
let activePolicy;
|
|
4399
|
+
let policyError;
|
|
4400
|
+
let invalidCompletionCalls = new Set;
|
|
4401
|
+
let effectiveTools = new Set;
|
|
4402
|
+
let completionRegistered = false;
|
|
4403
|
+
const childAgent = options.childAgent ?? process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
|
|
4404
|
+
const registerCompletionTool = () => {
|
|
4405
|
+
if (completionRegistered)
|
|
4406
|
+
return;
|
|
4407
|
+
completionRegistered = true;
|
|
4408
|
+
pi.registerTool({
|
|
4409
|
+
name: CHILD_COMPLETION_TOOL,
|
|
4410
|
+
label: "Complete Delegated Workflow Step",
|
|
4411
|
+
description: "Return one validated result from a pi-workflows delegated child step",
|
|
4412
|
+
promptSnippet: "Complete the delegated workflow step",
|
|
4413
|
+
promptGuidelines: [
|
|
4414
|
+
"Call workflow_complete_step alone after all delegated work is complete."
|
|
4415
|
+
],
|
|
4416
|
+
parameters: WORKFLOW_COMPLETION_PARAMETERS,
|
|
4417
|
+
executionMode: "sequential",
|
|
4418
|
+
execute: async (_toolCallId, params) => {
|
|
4419
|
+
if (!activePolicy) {
|
|
4420
|
+
throw new Error("No delegated workflow policy is active");
|
|
4421
|
+
}
|
|
4422
|
+
if (policyError)
|
|
4423
|
+
throw new Error(policyError);
|
|
4424
|
+
const result = parseDelegatedStepResult({
|
|
4425
|
+
version: 1,
|
|
4426
|
+
policyDigest: activePolicy.policyDigest,
|
|
4427
|
+
outcome: params.outcome,
|
|
4428
|
+
summary: params.summary,
|
|
4429
|
+
...params.artifact !== undefined ? { artifact: params.artifact } : {}
|
|
4430
|
+
}, activePolicy);
|
|
4431
|
+
writeResult(activePolicy, result);
|
|
4432
|
+
return {
|
|
4433
|
+
content: [
|
|
4434
|
+
{
|
|
4435
|
+
type: "text",
|
|
4436
|
+
text: `Captured workflow step outcome "${result.outcome}".`
|
|
4437
|
+
}
|
|
4438
|
+
],
|
|
4439
|
+
details: {
|
|
4440
|
+
workflowId: activePolicy.workflowId,
|
|
4441
|
+
runId: activePolicy.runId,
|
|
4442
|
+
stepId: activePolicy.stepId,
|
|
4443
|
+
outcome: result.outcome
|
|
4444
|
+
},
|
|
4445
|
+
terminate: true
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
});
|
|
4449
|
+
};
|
|
4450
|
+
pi.on("input", (event) => {
|
|
4451
|
+
let extracted;
|
|
4452
|
+
try {
|
|
4453
|
+
extracted = extractChildPolicy(event.text);
|
|
4454
|
+
} catch (error) {
|
|
4455
|
+
policyError = error instanceof Error ? error.message : String(error);
|
|
4456
|
+
pi.setActiveTools([]);
|
|
4457
|
+
return {
|
|
4458
|
+
action: "transform",
|
|
4459
|
+
text: `Delegated workflow policy is invalid: ${policyError}`,
|
|
4460
|
+
...event.images ? { images: event.images } : {}
|
|
4461
|
+
};
|
|
4462
|
+
}
|
|
4463
|
+
if (!extracted)
|
|
4464
|
+
return;
|
|
4465
|
+
if (activePolicy) {
|
|
4466
|
+
policyError = "child received more than one workflow policy";
|
|
4467
|
+
pi.setActiveTools([]);
|
|
4468
|
+
return {
|
|
4469
|
+
action: "transform",
|
|
4470
|
+
text: `Delegated workflow policy is invalid: ${policyError}`,
|
|
4471
|
+
...event.images ? { images: event.images } : {}
|
|
4472
|
+
};
|
|
4473
|
+
}
|
|
4474
|
+
try {
|
|
4475
|
+
verifyCapability(extracted.policy, childAgent);
|
|
4476
|
+
const profileTools = new Set(pi.getActiveTools());
|
|
4477
|
+
activePolicy = extracted.policy;
|
|
4478
|
+
policyError = undefined;
|
|
4479
|
+
registerCompletionTool();
|
|
4480
|
+
effectiveTools = new Set(resolveActiveTools(pi.getAllTools(), policyStep(activePolicy), CHILD_COMPLETION_TOOL).filter((toolName) => toolName === CHILD_COMPLETION_TOOL || profileTools.has(toolName)));
|
|
4481
|
+
} catch (error) {
|
|
4482
|
+
policyError = error instanceof Error ? error.message : String(error);
|
|
4483
|
+
effectiveTools.clear();
|
|
4484
|
+
pi.setActiveTools([]);
|
|
4485
|
+
return {
|
|
4486
|
+
action: "transform",
|
|
4487
|
+
text: `Delegated workflow policy is invalid: ${policyError}`,
|
|
4488
|
+
...event.images ? { images: event.images } : {}
|
|
4489
|
+
};
|
|
4490
|
+
}
|
|
4491
|
+
pi.setActiveTools([...effectiveTools]);
|
|
4492
|
+
return {
|
|
4493
|
+
action: "transform",
|
|
4494
|
+
text: extracted.task,
|
|
4495
|
+
...event.images ? { images: event.images } : {}
|
|
4496
|
+
};
|
|
4497
|
+
});
|
|
4498
|
+
pi.on("before_agent_start", (event) => {
|
|
4499
|
+
if (!activePolicy) {
|
|
4500
|
+
if (policyError)
|
|
4501
|
+
pi.setActiveTools([]);
|
|
4502
|
+
return;
|
|
4503
|
+
}
|
|
4504
|
+
return {
|
|
4505
|
+
systemPrompt: `${event.systemPrompt}
|
|
4506
|
+
|
|
4507
|
+
${childSystemPrompt(activePolicy)}`
|
|
4508
|
+
};
|
|
4509
|
+
});
|
|
4510
|
+
pi.on("turn_start", () => {
|
|
4511
|
+
invalidCompletionCalls.clear();
|
|
4512
|
+
});
|
|
4513
|
+
pi.on("message_end", (event) => {
|
|
4514
|
+
if (!activePolicy)
|
|
4515
|
+
return;
|
|
4516
|
+
const invalid = invalidCompletionCallIds(event.message, CHILD_COMPLETION_TOOL);
|
|
4517
|
+
if (invalid.size > 0 || event.message.role === "assistant") {
|
|
4518
|
+
invalidCompletionCalls = invalid;
|
|
4519
|
+
}
|
|
4520
|
+
});
|
|
4521
|
+
pi.on("tool_call", (event) => {
|
|
4522
|
+
if (!activePolicy) {
|
|
4523
|
+
if (!policyError)
|
|
4524
|
+
return;
|
|
4525
|
+
return {
|
|
4526
|
+
block: true,
|
|
4527
|
+
reason: policyError
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
4530
|
+
if (invalidCompletionCalls.has(event.toolCallId)) {
|
|
4531
|
+
return {
|
|
4532
|
+
block: true,
|
|
4533
|
+
reason: `${CHILD_COMPLETION_TOOL} must be the only tool call in its message`
|
|
4534
|
+
};
|
|
4535
|
+
}
|
|
4536
|
+
if (event.toolName === CHILD_COMPLETION_TOOL) {
|
|
4537
|
+
if (policyError) {
|
|
4538
|
+
return {
|
|
4539
|
+
block: true,
|
|
4540
|
+
reason: policyError
|
|
4541
|
+
};
|
|
4542
|
+
}
|
|
4543
|
+
freezeToolInput(event.input);
|
|
4544
|
+
return;
|
|
4545
|
+
}
|
|
4546
|
+
if (!effectiveTools.has(event.toolName)) {
|
|
4547
|
+
return {
|
|
4548
|
+
block: true,
|
|
4549
|
+
reason: `tool "${event.toolName}" is not enabled by subagent "${childAgent ?? "unknown"}"`
|
|
4550
|
+
};
|
|
4551
|
+
}
|
|
4552
|
+
const authorization = authorizeToolCall(event.toolName, event.input, policyStep(activePolicy), pi.getAllTools(), activePolicy.approvedBashCommands ?? []);
|
|
4553
|
+
if (!authorization.allowed) {
|
|
4554
|
+
return {
|
|
4555
|
+
block: true,
|
|
4556
|
+
reason: authorization.reason ?? "Tool blocked by workflow child policy"
|
|
4557
|
+
};
|
|
4558
|
+
}
|
|
4559
|
+
freezeToolInput(event.input);
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
// src/index.ts
|
|
4564
|
+
function piWorkflowsExtension(pi) {
|
|
4565
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") {
|
|
4566
|
+
const childAgent = process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
|
|
4567
|
+
if (isSubagentRuntimeName(childAgent)) {
|
|
4568
|
+
registerSubagentChildRuntime(pi, { childAgent });
|
|
4569
|
+
}
|
|
4570
|
+
return;
|
|
4571
|
+
}
|
|
4572
|
+
new WorkflowHarness(pi);
|
|
4573
|
+
}
|
|
4574
|
+
export {
|
|
4575
|
+
piWorkflowsExtension as default
|
|
4576
|
+
};
|