pi-extensible-workflows 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -47
- package/dist/src/agent-execution.d.ts +4 -0
- package/dist/src/agent-execution.js +146 -60
- package/dist/src/bundles.d.ts +53 -0
- package/dist/src/bundles.js +457 -0
- package/dist/src/cli.d.ts +3 -1
- package/dist/src/cli.js +146 -21
- package/dist/src/doctor-cleanup.js +4 -2
- package/dist/src/eval-capture-extension.js +16 -1
- package/dist/src/execution.js +13 -4
- package/dist/src/host.d.ts +51 -8
- package/dist/src/host.js +1181 -487
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +3 -1
- package/dist/src/persistence.d.ts +40 -1
- package/dist/src/persistence.js +51 -0
- package/dist/src/session-inspector.d.ts +12 -2
- package/dist/src/session-inspector.js +36 -2
- package/dist/src/types.d.ts +19 -0
- package/dist/src/types.js +1 -0
- package/dist/src/validation.js +42 -2
- package/dist/src/workflow-artifacts.d.ts +13 -0
- package/dist/src/workflow-artifacts.js +39 -0
- package/dist/src/workflow-evals.d.ts +7 -1
- package/dist/src/workflow-evals.js +23 -3
- package/evals/cases/recovery-completed-worktree.yaml +17 -0
- package/evals/cases/recovery-failed-run.yaml +14 -0
- package/examples/workflow-extension-template/README.md +37 -0
- package/examples/workflow-extension-template/extension.test.mjs +59 -0
- package/examples/workflow-extension-template/index.js +51 -0
- package/examples/workflow-extension-template/roles/reviewer.md +4 -0
- package/package.json +3 -2
- package/skills/pi-extensible-workflows/SKILL.md +21 -28
- package/src/agent-execution.ts +102 -30
- package/src/bundles.ts +471 -0
- package/src/cli.ts +118 -21
- package/src/doctor-cleanup.ts +3 -2
- package/src/eval-capture-extension.ts +15 -1
- package/src/execution.ts +13 -4
- package/src/host.ts +992 -447
- package/src/index.ts +4 -2
- package/src/persistence.ts +53 -1
- package/src/session-inspector.ts +36 -4
- package/src/types.ts +12 -5
- package/src/validation.ts +33 -2
- package/src/workflow-artifacts.ts +34 -0
- package/src/workflow-evals.ts +24 -3
package/src/cli.ts
CHANGED
|
@@ -8,12 +8,13 @@ import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionF
|
|
|
8
8
|
import { Value } from "typebox/value";
|
|
9
9
|
import { doctor, doctorExitCode, formatDoctorReport, type DoctorOptions } from "./doctor.js";
|
|
10
10
|
import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport, type DoctorCleanupOptions } from "./doctor-cleanup.js";
|
|
11
|
-
import workflowExtension, { formatWorkflowProgress, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
|
|
12
|
-
import {
|
|
11
|
+
import workflowExtension, { formatWorkflowProgress, loadAgentDefinitions, registeredWorkflowFunctions, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath, type JsonSchema, type JsonValue, type WorkflowProgressStyles } from "./index.js";
|
|
12
|
+
import { portableEngineVersion, portablePiVersion, writePortableWorkflowBundle } from "./bundles.js";
|
|
13
|
+
import { runSessionInspector, transcriptFileLines, type InspectMode } from "./session-inspector.js";
|
|
13
14
|
import type { PersistedRun } from "./persistence.js";
|
|
14
15
|
import type { WorkflowCatalogFunction } from "./index.js";
|
|
15
16
|
|
|
16
|
-
export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string) => Promise<void>; transcript?: (sessionFile: string) => Promise<void>; stderr?: (text: string) => void; signal?: AbortSignal; trustOverride?: boolean; isTTY?: boolean }
|
|
17
|
+
export interface CliOptions extends DoctorOptions { inspect?: (sessionId?: string, mode?: InspectMode, failedOnly?: boolean) => Promise<void>; transcript?: (sessionFile: string) => Promise<void>; stderr?: (text: string) => void; signal?: AbortSignal; trustOverride?: boolean; isTTY?: boolean; skillPaths?: readonly string[] }
|
|
17
18
|
|
|
18
19
|
type CliScalar = "string" | "integer" | "number" | "boolean";
|
|
19
20
|
type CliField = { name: string; option: string; schema: Record<string, unknown>; type: CliScalar | "array"; itemType?: CliScalar; required: boolean };
|
|
@@ -158,7 +159,24 @@ function launcherHelpLines(): string[] {
|
|
|
158
159
|
];
|
|
159
160
|
}
|
|
160
161
|
function workflowUsage(): string { return [`Usage: pi-extensible-workflows run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
161
|
-
function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
162
|
+
function exportUsage(): string { return [`Usage: pi-extensible-workflows export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]`, "", "Launcher options:", ...launcherHelpLines()].join("\n") + "\n"; }
|
|
163
|
+
function bundleUsage(): string { return [`Usage: pi-extensible-workflows bundle <workflow-name> [--name <command>] [--output <directory>] [--force]`, "", "The bundle contains a launcher, manifest, workflow payload, and external-runtime setup instructions.", "Repeat --role, --alias, --tool, --command, or --environment to declare recipient requirements.", "Use --extension, --skill, --resource, and --dependency to copy selected payload resources."].join("\n") + "\n"; }
|
|
164
|
+
function parseInspectArgs(rawArgs: readonly string[]): { sessionId?: string; mode: InspectMode; failedOnly: boolean } {
|
|
165
|
+
let sessionId: string | undefined;
|
|
166
|
+
let mode: InspectMode = "tui";
|
|
167
|
+
let failedOnly = false;
|
|
168
|
+
for (const arg of rawArgs) {
|
|
169
|
+
if (arg === "--json" || arg === "--summary") {
|
|
170
|
+
const next: InspectMode = arg === "--json" ? "json" : "summary";
|
|
171
|
+
if (mode !== "tui" && mode !== next) throw new Error("inspect accepts only one output mode");
|
|
172
|
+
mode = next;
|
|
173
|
+
} else if (arg === "--failed") failedOnly = true;
|
|
174
|
+
else if (arg.startsWith("--")) throw new Error(`Unknown inspect option: ${arg}`);
|
|
175
|
+
else if (sessionId !== undefined) throw new Error(`Unexpected argument: ${arg}`);
|
|
176
|
+
else sessionId = arg;
|
|
177
|
+
}
|
|
178
|
+
return { ...(sessionId ? { sessionId } : {}), mode: failedOnly && mode === "tui" ? "summary" : mode, failedOnly };
|
|
179
|
+
}
|
|
162
180
|
export function parseDoctorCleanupArgs(rawArgs: readonly string[]): Required<Pick<DoctorCleanupOptions, "olderThanDays" | "yes">> {
|
|
163
181
|
let olderThanDays = 90;
|
|
164
182
|
let yes = false;
|
|
@@ -193,7 +211,7 @@ function stripTrustOptions(rawArgs: readonly string[]): { args: string[]; trustO
|
|
|
193
211
|
}
|
|
194
212
|
return { args, ...(trustOverride !== undefined ? { trustOverride } : {}) };
|
|
195
213
|
}
|
|
196
|
-
type WorkflowIo = { write: (text: string) => void; stderr: (text: string) => void; cwd?: string; agentDir?: string; trustOverride?: boolean; isTTY?: boolean; signal?: AbortSignal };
|
|
214
|
+
type WorkflowIo = { write: (text: string) => void; stderr: (text: string) => void; cwd?: string; agentDir?: string; trustOverride?: boolean; isTTY?: boolean; signal?: AbortSignal; skillPaths?: readonly string[] };
|
|
197
215
|
|
|
198
216
|
type HeadlessWorkflowTool = { execute: (toolCallId: string, params: Record<string, JsonValue>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, context: unknown) => Promise<{ content: Array<{ type: string; text: string }>; details?: unknown }> };
|
|
199
217
|
type ShutdownHandler = (event: unknown, context: unknown) => Promise<void> | void;
|
|
@@ -235,7 +253,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
|
|
|
235
253
|
cwd,
|
|
236
254
|
agentDir,
|
|
237
255
|
settingsManager,
|
|
238
|
-
resourceLoaderOptions: {},
|
|
256
|
+
resourceLoaderOptions: { ...(options.skillPaths?.length ? { additionalSkillPaths: [...options.skillPaths] } : {}) },
|
|
239
257
|
resourceLoaderReloadOptions: { resolveProjectTrust },
|
|
240
258
|
});
|
|
241
259
|
const extensions = services.resourceLoader.getExtensions();
|
|
@@ -251,7 +269,7 @@ async function createWorkflowRuntime(options: WorkflowIo, shutdownHandlers: Shut
|
|
|
251
269
|
sendMessage() {},
|
|
252
270
|
events: { emit() {} },
|
|
253
271
|
};
|
|
254
|
-
workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir);
|
|
272
|
+
workflowExtension(headlessPi as never, homedir(), undefined, undefined, agentDir, options.skillPaths);
|
|
255
273
|
const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow") as HeadlessWorkflowTool | undefined;
|
|
256
274
|
if (!workflowTool) throw new Error("The workflow runtime could not be initialized");
|
|
257
275
|
return { catalog: workflowCatalog({ cwd, projectTrusted: settingsManager.isProjectTrusted(), globalSettingsPath: workflowSettingsPath(agentDir) }), services, workflowTool, shutdownHandlers };
|
|
@@ -304,23 +322,34 @@ class CliProgress {
|
|
|
304
322
|
#lines = 0;
|
|
305
323
|
#frame = 0;
|
|
306
324
|
#run: PersistedRun | undefined;
|
|
325
|
+
#runId: string | undefined;
|
|
307
326
|
#timer: ReturnType<typeof setInterval> | undefined;
|
|
308
327
|
#interactive: boolean;
|
|
309
328
|
#styles: WorkflowProgressStyles;
|
|
310
|
-
constructor(private readonly stderr: (text: string) => void, tty: boolean) {
|
|
329
|
+
constructor(private readonly stderr: (text: string) => void, tty: boolean, private readonly onRunId: (runId: string) => void) {
|
|
311
330
|
this.#interactive = tty && process.env.NO_COLOR === undefined && process.env.TERM !== "dumb";
|
|
312
331
|
this.#styles = terminalProgressStyles(this.#interactive);
|
|
313
332
|
}
|
|
314
333
|
update(run: PersistedRun): void {
|
|
315
|
-
|
|
316
|
-
if (!this.#interactive) { if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); } return; }
|
|
334
|
+
if (this.#runId !== run.id) { this.#runId = run.id; this.onRunId(run.id); }
|
|
317
335
|
this.#run = run;
|
|
336
|
+
if (!this.#interactive) {
|
|
337
|
+
this.#timer ??= setInterval(() => { this.render(); }, 1000);
|
|
338
|
+
this.#timer.unref();
|
|
339
|
+
this.render();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
318
342
|
this.#timer ??= setInterval(() => { this.render(); }, 80);
|
|
319
343
|
this.#timer.unref();
|
|
320
344
|
this.render();
|
|
321
345
|
}
|
|
322
346
|
render(): void {
|
|
323
347
|
if (!this.#run) return;
|
|
348
|
+
if (!this.#interactive) {
|
|
349
|
+
const stable = formatWorkflowProgress(this.#run, "◇", this.#styles);
|
|
350
|
+
if (stable !== this.#lastStable) { this.#lastStable = stable; this.stderr(`${stable}\n`); }
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
324
353
|
const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"][this.#frame++ % 10] ?? "◇";
|
|
325
354
|
const width = process.stderr.columns || 80;
|
|
326
355
|
const text = truncateWorkflowProgress(formatWorkflowProgress(this.#run, spinner, this.#styles), width).join("\n");
|
|
@@ -334,16 +363,21 @@ class CliProgress {
|
|
|
334
363
|
}
|
|
335
364
|
}
|
|
336
365
|
|
|
337
|
-
|
|
366
|
+
type CliWorkflowResult = { value: JsonValue; runId?: string };
|
|
367
|
+
async function invokeWorkflow(fn: WorkflowCatalogFunction, args: Record<string, JsonValue>, runtime: WorkflowRuntime, options: WorkflowIo, context: unknown): Promise<CliWorkflowResult> {
|
|
338
368
|
if (!Value.Check(fn.input, args)) throw new Error(`Invalid input for ${fn.name}`);
|
|
339
|
-
|
|
369
|
+
let announcedRunId: string | undefined;
|
|
370
|
+
const announceRunId = (runId: string) => { if (announcedRunId === runId) return; announcedRunId = runId; options.stderr(`Run ID: ${runId}\n`); };
|
|
371
|
+
const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY, announceRunId);
|
|
340
372
|
try {
|
|
341
373
|
const result = await runtime.workflowTool.execute(randomUUID(), { workflow: fn.name, args, foreground: true }, options.signal, (update: unknown) => { if (object(update) && object(update.details) && object(update.details.run)) progress.update(update.details.run as unknown as PersistedRun); }, context);
|
|
342
374
|
const details = object(result.details) ? result.details : {};
|
|
343
|
-
|
|
375
|
+
const runId = typeof details.runId === "string" ? details.runId : undefined;
|
|
376
|
+
if (runId) announceRunId(runId);
|
|
377
|
+
if (has(details, "value")) return { value: details.value as JsonValue, ...(runId ? { runId } : {}) };
|
|
344
378
|
const first = result.content[0];
|
|
345
379
|
if (!first || first.type !== "text") throw new Error("Workflow returned no result");
|
|
346
|
-
try { return parseJsonInput(first.text); } catch { throw new Error("Workflow returned invalid JSON"); }
|
|
380
|
+
try { return { value: parseJsonInput(first.text), ...(runId ? { runId } : {}) }; } catch { throw new Error("Workflow returned invalid JSON"); }
|
|
347
381
|
} finally {
|
|
348
382
|
progress.finish();
|
|
349
383
|
}
|
|
@@ -385,8 +419,8 @@ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo):
|
|
|
385
419
|
if (!fn) throw new Error(`Unknown workflow function: ${name}`);
|
|
386
420
|
if (help) { options.write(formatWorkflowCliHelp(fn)); return 0; }
|
|
387
421
|
const input = parseWorkflowCliArgs(fn.input, args.slice(1));
|
|
388
|
-
const
|
|
389
|
-
options.write(`${JSON.stringify(value)}\n`);
|
|
422
|
+
const result = await invokeWorkflow(fn, input, runtime, options, context);
|
|
423
|
+
options.write(`${JSON.stringify(result.value)}\n`);
|
|
390
424
|
return 0;
|
|
391
425
|
});
|
|
392
426
|
}
|
|
@@ -394,6 +428,7 @@ async function runWorkflowCli(rawArgs: readonly string[], options: WorkflowIo):
|
|
|
394
428
|
async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
|
|
395
429
|
const parsed = stripTrustOptions(rawArgs);
|
|
396
430
|
const args = parsed.args;
|
|
431
|
+
if (args.includes("--bundle")) return bundleWorkflowCli(args.filter((arg) => arg !== "--bundle"), options);
|
|
397
432
|
if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(exportUsage()); return args.length ? 0 : 1; }
|
|
398
433
|
const workflowName = args[0] as string;
|
|
399
434
|
return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
|
|
@@ -429,6 +464,62 @@ async function exportWorkflowCli(rawArgs: readonly string[], options: WorkflowIo
|
|
|
429
464
|
});
|
|
430
465
|
}
|
|
431
466
|
|
|
467
|
+
async function bundleWorkflowCli(rawArgs: readonly string[], options: WorkflowIo): Promise<number> {
|
|
468
|
+
const parsed = stripTrustOptions(rawArgs);
|
|
469
|
+
const args = parsed.args;
|
|
470
|
+
if (!args.length || args[0] === "--help" || args[0] === "-h") { options.write(bundleUsage()); return args.length ? 0 : 1; }
|
|
471
|
+
const workflowName = args[0] as string;
|
|
472
|
+
return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
|
|
473
|
+
let name: string | undefined;
|
|
474
|
+
let output: string | undefined;
|
|
475
|
+
let force = false;
|
|
476
|
+
const requirements = { roles: [] as string[], aliases: [] as string[], tools: [] as string[], commands: [] as string[], environment: [] as string[] };
|
|
477
|
+
const resources = { extensions: [] as string[], skills: [] as string[], static: [] as string[], dependencies: [] as string[] };
|
|
478
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
479
|
+
const arg = args[index] as string;
|
|
480
|
+
if (arg === "--force") { force = true; continue; }
|
|
481
|
+
const equals = arg.indexOf("=");
|
|
482
|
+
const option = equals >= 0 ? arg.slice(0, equals) : arg;
|
|
483
|
+
const requirementOptions = { "--role": "roles", "--alias": "aliases", "--tool": "tools", "--command": "commands", "--environment": "environment" } as const;
|
|
484
|
+
const resourceOptions = { "--extension": "extensions", "--skill": "skills", "--resource": "static", "--dependency": "dependencies" } as const;
|
|
485
|
+
if (option === "--name" || option === "--output" || Object.prototype.hasOwnProperty.call(requirementOptions, option) || Object.prototype.hasOwnProperty.call(resourceOptions, option)) {
|
|
486
|
+
const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
|
|
487
|
+
if (!value) throw new Error(`Missing value for ${option}`);
|
|
488
|
+
if (option === "--name") name = value;
|
|
489
|
+
else if (option === "--output") output = value;
|
|
490
|
+
else if (Object.prototype.hasOwnProperty.call(requirementOptions, option)) requirements[requirementOptions[option as keyof typeof requirementOptions]].push(value);
|
|
491
|
+
else resources[resourceOptions[option as keyof typeof resourceOptions]].push(value);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (arg === "--help" || arg === "-h") { options.write(bundleUsage()); return 0; }
|
|
495
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
496
|
+
}
|
|
497
|
+
const fn = runtime.catalog.functions.find((candidate) => candidate.name === workflowName);
|
|
498
|
+
if (!fn) throw new Error(`Unknown workflow function: ${workflowName}`);
|
|
499
|
+
const registered = registeredWorkflowFunctions()[workflowName];
|
|
500
|
+
if (!registered) throw new Error(`Workflow ${workflowName} is not exportable because its registered implementation is unavailable`);
|
|
501
|
+
const definitions = requirements.roles.length ? loadAgentDefinitions(options.cwd ?? process.cwd(), options.agentDir ?? getAgentDir(), runtime.services.settingsManager.isProjectTrusted()) : {};
|
|
502
|
+
const roles = Object.fromEntries(requirements.roles.map((role) => {
|
|
503
|
+
if (!role || role === "." || role === ".." || role.includes("/") || role.includes("\\")) throw new Error(`Invalid role name for bundle: ${role}`);
|
|
504
|
+
const definition = definitions[role];
|
|
505
|
+
if (!definition) throw new Error(`Unknown role for bundle: ${role}`);
|
|
506
|
+
return [role, definition];
|
|
507
|
+
}));
|
|
508
|
+
const command = commandName(name ?? kebabCase(workflowName));
|
|
509
|
+
if (!command) throw new Error("Command name must be a non-empty name without path separators");
|
|
510
|
+
const destination = output ?? join(homedir(), ".local", "share", "pi-extensible-workflows", "bundles", command);
|
|
511
|
+
const aliasTargets = Object.fromEntries(requirements.aliases.flatMap((name) => {
|
|
512
|
+
const target = runtime.catalog.modelAliases?.[name];
|
|
513
|
+
return typeof target === "string" ? [[name, target]] : [];
|
|
514
|
+
}));
|
|
515
|
+
const selectedResources = Object.values(resources).some((entries) => entries.length) ? resources : undefined;
|
|
516
|
+
writePortableWorkflowBundle({ destination, command, workflow: fn, functionSource: registered.run.toString(), requirements, aliasTargets, roles, ...(selectedResources ? { resources: selectedResources } : {}), piVersion: portablePiVersion(), engineVersion: portableEngineVersion(), force });
|
|
517
|
+
options.write(`Bundled ${workflowName} at ${destination}\n`);
|
|
518
|
+
options.write(`Run ${join(destination, command)} setup before launching the workflow.\n`);
|
|
519
|
+
return 0;
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
432
523
|
export async function runCli(args: readonly string[], options: CliOptions = {}, write: (text: string) => void = (text) => { process.stdout.write(text); }): Promise<number> {
|
|
433
524
|
const stderr = options.stderr ?? ((text: string) => { process.stderr.write(text); });
|
|
434
525
|
if (args[0] === "doctor" && args.length === 1) {
|
|
@@ -446,8 +537,13 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
|
|
|
446
537
|
return doctorCleanupExitCode(report);
|
|
447
538
|
} catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
448
539
|
}
|
|
449
|
-
if (args[0] === "inspect"
|
|
450
|
-
try {
|
|
540
|
+
if (args[0] === "inspect") {
|
|
541
|
+
try {
|
|
542
|
+
const parsed = parseInspectArgs(args.slice(1));
|
|
543
|
+
if (options.inspect) await options.inspect(parsed.sessionId, parsed.mode, parsed.failedOnly);
|
|
544
|
+
else await runSessionInspector(parsed.sessionId, parsed.mode, options.cwd ?? process.cwd(), undefined, write, parsed.failedOnly);
|
|
545
|
+
return 0;
|
|
546
|
+
}
|
|
451
547
|
catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
452
548
|
}
|
|
453
549
|
if (args[0] === "transcript" && args.length === 2) {
|
|
@@ -457,13 +553,14 @@ export async function runCli(args: readonly string[], options: CliOptions = {},
|
|
|
457
553
|
return 0;
|
|
458
554
|
} catch (error) { write(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
459
555
|
}
|
|
460
|
-
if (args[0] === "run" || args[0] === "export") {
|
|
556
|
+
if (args[0] === "bundle" || args[0] === "run" || args[0] === "export") {
|
|
461
557
|
try {
|
|
462
|
-
const workflowOptions: WorkflowIo = { write, stderr, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.agentDir !== undefined ? { agentDir: options.agentDir } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.trustOverride !== undefined ? { trustOverride: options.trustOverride } : {}), ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}) };
|
|
558
|
+
const workflowOptions: WorkflowIo = { write, stderr, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}), ...(options.agentDir !== undefined ? { agentDir: options.agentDir } : {}), ...(options.signal ? { signal: options.signal } : {}), ...(options.trustOverride !== undefined ? { trustOverride: options.trustOverride } : {}), ...(options.isTTY !== undefined ? { isTTY: options.isTTY } : {}), ...(options.skillPaths?.length ? { skillPaths: [...options.skillPaths] } : {}) };
|
|
559
|
+
if (args[0] === "bundle") return await bundleWorkflowCli(args.slice(1), workflowOptions);
|
|
463
560
|
return args[0] === "run" ? await runWorkflowCli(args.slice(1), workflowOptions) : await exportWorkflowCli(args.slice(1), workflowOptions);
|
|
464
561
|
} catch (error) { stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`); return 1; }
|
|
465
562
|
}
|
|
466
|
-
write("Usage: pi-extensible-workflows doctor | inspect [session-id] | transcript <session-file> | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force]\n");
|
|
563
|
+
write("Usage: pi-extensible-workflows doctor | inspect [session-id] [--json|--summary] [--failed] | transcript <session-file> | bundle <workflow-name> [--name <command>] [--output <path>] [--force] | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]\n");
|
|
467
564
|
return 1;
|
|
468
565
|
}
|
|
469
566
|
|
package/src/doctor-cleanup.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, Run
|
|
|
11
11
|
const TERMINAL_STATES = new Set<RunState>(["completed", "failed", "stopped"]);
|
|
12
12
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
13
|
const REQUIRED_RUN_FILES = ["workflow.js", "state.json", "snapshot.json", "journal.json", "ownership.json", "worktrees.json", "borrowed-worktrees.json", "system-prompts.json"] as const;
|
|
14
|
-
const OPTIONAL_RUN_FILES = new Set(["result.json"]);
|
|
14
|
+
const OPTIONAL_RUN_FILES = new Set(["result.json", "summary.json"]);
|
|
15
15
|
const RUN_DIRECTORIES = new Set(["worktrees"]);
|
|
16
16
|
const RUN_FILES = new Set<string>([...REQUIRED_RUN_FILES, ...OPTIONAL_RUN_FILES]);
|
|
17
17
|
const AGENT_STATES = new Set(["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"]);
|
|
@@ -46,7 +46,7 @@ function accounting(value: unknown, label: string): void { if (!object(value)) t
|
|
|
46
46
|
function resourceExclusions(value: unknown, label: string): void { if (value === undefined) return; if (!object(value)) throw new Error(`${label} is invalid`); if (value.skills !== undefined) stringList(value.skills, `${label}.skills`); if (value.extensions !== undefined) stringList(value.extensions, `${label}.extensions`); }
|
|
47
47
|
function agentDefinition(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); optionalString(value.prompt, `${label}.prompt`); optionalString(value.description, `${label}.description`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string)) throw new Error(`${label}.thinking is invalid`); if (value.tools !== undefined) stringList(value.tools, `${label}.tools`); resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`); }
|
|
48
48
|
function validateScheduledOptions(value: unknown, label: string): void { if (!object(value) || typeof value.label !== "string" || !value.label || typeof value.cwd !== "string" || !value.cwd) throw new Error(`${label} is invalid`); optionalString(value.requestedLabel, `${label}.requestedLabel`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); stringList(value.tools, `${label}.tools`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking as string)) throw new Error(`${label}.thinking is invalid`); optionalString(value.role, `${label}.role`); if (value.schema !== undefined) validateSchema(value.schema, `${label}.schema`); if (value.retries !== undefined) nonNegativeInteger(value.retries, `${label}.retries`); if (value.timeoutMs !== undefined && value.timeoutMs !== null) positiveInteger(value.timeoutMs, `${label}.timeoutMs`); if (value.agentOptions !== undefined && (!object(value.agentOptions) || !jsonValue(value.agentOptions))) throw new Error(`${label}.agentOptions is invalid`); if (value.agentIdentity !== undefined) { if (!object(value.agentIdentity) || !Array.isArray(value.agentIdentity.structuralPath) || value.agentIdentity.structuralPath.some((part) => typeof part !== "string") || typeof value.agentIdentity.callSite !== "string") throw new Error(`${label}.agentIdentity is invalid`); positiveInteger(value.agentIdentity.occurrence, `${label}.agentIdentity.occurrence`); optionalString(value.agentIdentity.parentBreadcrumb, `${label}.agentIdentity.parentBreadcrumb`); optionalString(value.agentIdentity.worktreeOwner, `${label}.agentIdentity.worktreeOwner`); } }
|
|
49
|
-
function validateAgent(value: unknown, label: string): void { if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || typeof value.state !== "string" || !AGENT_STATES.has(value.state)) throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined) stringList(value.structuralPath, `${label}.structuralPath`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.role, `${label}.role`); optionalString(value.requestedModel, `${label}.requestedModel`); model(value.model, `${label}.model`); stringList(value.tools, `${label}.tools`); nonNegativeInteger(value.attempts, `${label}.attempts`); if (value.attemptDetails !== undefined) { if (!Array.isArray(value.attemptDetails)) throw new Error(`${label}.attemptDetails is invalid`); for (const [index, attempt] of value.attemptDetails.entries()) { const at = `${label}.attemptDetails[${String(index)}]`; if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile) throw new Error(`${at} is invalid`); accounting(attempt.accounting, `${at}.accounting`); if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string")) throw new Error(`${at}.error is invalid`); if (attempt.setup !== undefined) { if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string") throw new Error(`${at}.setup is invalid`); model(attempt.setup.model, `${at}.setup.model`); stringList(attempt.setup.tools, `${at}.setup.tools`); resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`); } } } if (value.accounting !== undefined) accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) { if (!Array.isArray(value.toolCalls)) throw new Error(`${label}.toolCalls is invalid`); for (const call of value.toolCalls) if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state as string)) throw new Error(`${label}.toolCalls is invalid`); } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind as string) || typeof value.activity.text !== "string")) throw new Error(`${label}.activity is invalid`); }
|
|
49
|
+
function validateAgent(value: unknown, label: string): void { if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || typeof value.state !== "string" || !AGENT_STATES.has(value.state)) throw new Error(`${label} is invalid`); optionalString(value.label, `${label}.label`); optionalString(value.parentId, `${label}.parentId`); if (value.structuralPath !== undefined) stringList(value.structuralPath, `${label}.structuralPath`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.role, `${label}.role`); optionalString(value.requestedModel, `${label}.requestedModel`); model(value.model, `${label}.model`); stringList(value.tools, `${label}.tools`); nonNegativeInteger(value.attempts, `${label}.attempts`); if (value.lastEventAt !== undefined) finiteNumber(value.lastEventAt, `${label}.lastEventAt`); if (value.attemptDetails !== undefined) { if (!Array.isArray(value.attemptDetails)) throw new Error(`${label}.attemptDetails is invalid`); for (const [index, attempt] of value.attemptDetails.entries()) { const at = `${label}.attemptDetails[${String(index)}]`; if (!object(attempt) || !Number.isSafeInteger(attempt.attempt) || Number(attempt.attempt) < 1 || typeof attempt.sessionId !== "string" || !attempt.sessionId || typeof attempt.sessionFile !== "string" || !attempt.sessionFile) throw new Error(`${at} is invalid`); accounting(attempt.accounting, `${at}.accounting`); if (attempt.error !== undefined && (!object(attempt.error) || typeof attempt.error.code !== "string" || typeof attempt.error.message !== "string")) throw new Error(`${at}.error is invalid`); if (attempt.setup !== undefined) { if (!object(attempt.setup) || !Array.isArray(attempt.setup.hookNames) || attempt.setup.hookNames.some((name) => typeof name !== "string") || typeof attempt.setup.cwd !== "string") throw new Error(`${at}.setup is invalid`); model(attempt.setup.model, `${at}.setup.model`); stringList(attempt.setup.tools, `${at}.setup.tools`); resourceExclusions(attempt.setup.disabledAgentResources, `${at}.setup.disabledAgentResources`); } } } if (value.accounting !== undefined) accounting(value.accounting, `${label}.accounting`); if (value.toolCalls !== undefined) { if (!Array.isArray(value.toolCalls)) throw new Error(`${label}.toolCalls is invalid`); for (const call of value.toolCalls) if (!object(call) || typeof call.id !== "string" || typeof call.name !== "string" || !["running", "completed", "failed"].includes(call.state as string)) throw new Error(`${label}.toolCalls is invalid`); } if (value.activity !== undefined && (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind as string) || typeof value.activity.text !== "string")) throw new Error(`${label}.activity is invalid`); }
|
|
50
50
|
function validateUsage(value: unknown, label: string): void { if (!object(value)) throw new Error(`${label} is invalid`); for (const key of ["tokens", "costUsd", "durationMs", "agentLaunches"]) finiteNumber(value[key], `${label}.${key}`); }
|
|
51
51
|
function validateBudgetEvents(value: unknown): void { if (value === undefined) return; if (!Array.isArray(value)) throw new Error("Persisted budget events are invalid"); for (const [index, event] of value.entries()) { const label = `budgetEvents[${String(index)}]`; if (!object(event) || typeof event.type !== "string" || !BUDGET_EVENT_TYPES.has(event.type) || !Number.isSafeInteger(event.budgetVersion) || Number(event.budgetVersion) < 1 || !Array.isArray(event.dimensions) || event.dimensions.some((dimension) => typeof dimension !== "string" || !BUDGET_DIMENSIONS.has(dimension)) || typeof event.at !== "number" || !Number.isFinite(event.at) || event.limits === undefined) throw new Error(`${label} is invalid`); validateUsage(event.usage, `${label}.usage`); validateBudget(event.limits); } }
|
|
52
52
|
function validateRunRecord(run: PersistedRun): void {
|
|
@@ -74,6 +74,7 @@ function validateRunRecord(run: PersistedRun): void {
|
|
|
74
74
|
}
|
|
75
75
|
for (const [index, session] of (value.nativeSessions as unknown[]).entries()) if (!object(session) || typeof session.sessionId !== "string" || !session.sessionId || typeof session.sessionFile !== "string" || !session.sessionFile) throw new Error(`nativeSessions[${String(index)}] is invalid`);
|
|
76
76
|
optionalString(value.parentRunId, "Persisted parent run");
|
|
77
|
+
optionalString(value.failedAt, "Persisted failed path");
|
|
77
78
|
if (value.retry !== undefined) {
|
|
78
79
|
if (!object(value.retry) || typeof value.retry.sourceRunId !== "string" || !value.retry.sourceRunId || typeof value.retry.lineageRootRunId !== "string" || !value.retry.lineageRootRunId) throw new Error("Persisted retry provenance is invalid");
|
|
79
80
|
const sourceRunId = value.retry.sourceRunId;
|
|
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { validateWorkflowLaunch, WorkflowError, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET, type WorkflowValidationParameters } from "./index.js";
|
|
5
|
+
import { validateWorkflowLaunch, WorkflowError, WORKFLOW_RETRY_PARAMETERS, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET, type WorkflowValidationParameters } from "./index.js";
|
|
6
6
|
|
|
7
7
|
export const CAPTURE_IDENTITY = "pi-extensible-workflows-eval-capture-v1";
|
|
8
8
|
export const CAPTURE_ERROR_PREFIX = `${CAPTURE_IDENTITY}:`;
|
|
@@ -51,4 +51,18 @@ export default function evalCaptureExtension(pi: ExtensionAPI): void {
|
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
} as never);
|
|
54
|
+
pi.registerTool({
|
|
55
|
+
name: "workflow_retry",
|
|
56
|
+
label: "Workflow Retry",
|
|
57
|
+
description: "Capture a recovery selection without executing it",
|
|
58
|
+
parameters: WORKFLOW_RETRY_PARAMETERS,
|
|
59
|
+
async execute(_id: string, params: { runId: string }) {
|
|
60
|
+
if (!params.runId.trim()) throw new WorkflowError("RESUME_INCOMPATIBLE", `${CAPTURE_ERROR_PREFIX}RESUME_INCOMPATIBLE: workflow_retry requires an explicit run ID`);
|
|
61
|
+
const selection = { tool: "workflow_retry", arguments: { runId: params.runId } };
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: "text" as const, text: JSON.stringify({ captured: true, ...selection }) }],
|
|
64
|
+
details: { captured: true, captureIdentity: CAPTURE_IDENTITY, realWorkflowAgentsLaunched: 0, selection },
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
} as never);
|
|
54
68
|
}
|
package/src/execution.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { fork, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
@@ -78,6 +78,7 @@ const named = (value, kind) => { if (typeof value !== "string" || !value.trim())
|
|
|
78
78
|
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
79
79
|
const inheritedAgentPath = new AsyncLocalStorage();
|
|
80
80
|
const agentOccurrences = new Map();
|
|
81
|
+
const agentInflight = new Set();
|
|
81
82
|
const shellOccurrences = new Map();
|
|
82
83
|
const worktreeOwners = new AsyncLocalStorage();
|
|
83
84
|
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
@@ -98,14 +99,22 @@ const internalAgent = (...values) => {
|
|
|
98
99
|
const callSite = values.pop();
|
|
99
100
|
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
100
101
|
const inherited = inheritedAgentPath.getStore() || [];
|
|
101
|
-
// ponytail: same-callsite races outside parallel/pipeline lack a stable structural scope and are unsupported.
|
|
102
102
|
const occurrenceKey = JSON.stringify([inherited, callSite]);
|
|
103
|
+
if (agentInflight.has(occurrenceKey)) throw workError("INVALID_METADATA", "Concurrent agent calls from the same source call site are unsupported; use parallel(...) or pipeline(...)");
|
|
104
|
+
agentInflight.add(occurrenceKey);
|
|
103
105
|
const occurrence = (agentOccurrences.get(occurrenceKey) || 0) + 1;
|
|
104
106
|
agentOccurrences.set(occurrenceKey, occurrence);
|
|
105
107
|
const options = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
106
108
|
const worktreeOwner = worktreeOwners.getStore();
|
|
107
109
|
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
|
|
108
|
-
|
|
110
|
+
let result;
|
|
111
|
+
try {
|
|
112
|
+
result = rpc("agent", [values[0], options, identity]).then(unwrap);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
agentInflight.delete(occurrenceKey);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
void result.then(() => agentInflight.delete(occurrenceKey), () => agentInflight.delete(occurrenceKey));
|
|
109
118
|
Object.defineProperties(result, {
|
|
110
119
|
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before serialization"); } },
|
|
111
120
|
toString: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
|
|
@@ -386,7 +395,7 @@ function workflowErrorFromWorker(error: WorkerErrorShape): WorkflowError {
|
|
|
386
395
|
export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
|
|
387
396
|
encoded(args);
|
|
388
397
|
const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
|
|
389
|
-
const childDir = mkdtempSync(join(tmpdir(), "pi-wf-"));
|
|
398
|
+
const childDir = realpathSync(mkdtempSync(join(tmpdir(), "pi-wf-")));
|
|
390
399
|
const childFile = join(childDir, "child.cjs");
|
|
391
400
|
writeFileSync(childFile, childSource);
|
|
392
401
|
const child: ChildProcess = fork(childFile, [String(RPC_LIMIT_BYTES), config], {
|