pi-extensible-workflows 1.0.1 → 3.0.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 +9 -2
- package/dist/src/agent-execution.d.ts +13 -14
- package/dist/src/agent-execution.js +110 -197
- package/dist/src/budget.d.ts +38 -0
- package/dist/src/budget.js +160 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +536 -6
- package/dist/src/doctor.d.ts +4 -4
- package/dist/src/doctor.js +9 -29
- package/dist/src/execution.d.ts +17 -0
- package/dist/src/execution.js +630 -0
- package/dist/src/herdr.d.ts +12 -0
- package/dist/src/herdr.js +74 -0
- package/dist/src/host.d.ts +62 -0
- package/dist/src/host.js +2696 -0
- package/dist/src/index.d.ts +11 -557
- package/dist/src/index.js +8 -3974
- package/dist/src/persistence.d.ts +32 -19
- package/dist/src/persistence.js +310 -70
- package/dist/src/registry.d.ts +31 -0
- package/dist/src/registry.js +169 -0
- package/dist/src/session-inspector.d.ts +1 -0
- package/dist/src/session-inspector.js +4 -1
- package/dist/src/types.d.ts +565 -0
- package/dist/src/types.js +27 -0
- package/dist/src/utils.d.ts +26 -0
- package/dist/src/utils.js +128 -0
- package/dist/src/validation.d.ts +24 -0
- package/dist/src/validation.js +740 -0
- package/dist/src/workflow-evals.js +2 -1
- package/package.json +4 -3
- package/skills/pi-extensible-workflows/SKILL.md +52 -67
- package/src/agent-execution.ts +84 -147
- package/src/budget.ts +75 -0
- package/src/cli.ts +427 -6
- package/src/doctor.ts +13 -32
- package/src/execution.ts +540 -0
- package/src/herdr.ts +73 -0
- package/src/host.ts +2265 -0
- package/src/index.ts +11 -3453
- package/src/persistence.ts +255 -47
- package/src/registry.ts +157 -0
- package/src/session-inspector.ts +5 -1
- package/src/types.ts +113 -0
- package/src/utils.ts +108 -0
- package/src/validation.ts +616 -0
- package/src/workflow-evals.ts +2 -1
package/src/execution.ts
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import { fork, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
import { RunStore, structuralPath as operationPath } from "./persistence.js";
|
|
7
|
+
import type { AgentAttempt } from "./agent-execution.js";
|
|
8
|
+
import type { AgentIdentity, JsonValue, ShellIdentity, ShellOptions, ShellResult, WorkflowBridge, WorkflowErrorCode, WorkflowExecution } from "./types.js";
|
|
9
|
+
import { WorkflowError } from "./types.js";
|
|
10
|
+
import { asWorkflowError, errorText, fail, isWorkflowAuthored, jsonValue, markWorkflowAuthored, object, positiveInteger } from "./utils.js";
|
|
11
|
+
import { instrumentWorkflow, validateAgentOptions, validateShellCommand, validateShellOptions } from "./validation.js";
|
|
12
|
+
|
|
13
|
+
export const RPC_LIMIT_BYTES = 10 * 1024 * 1024;
|
|
14
|
+
type WorkerErrorShape = { code?: string; message: string; authored?: boolean; failedAt?: string };
|
|
15
|
+
export const HEARTBEAT_TIMEOUT_MS = 5000;
|
|
16
|
+
|
|
17
|
+
const OUTCOME_ERRORS = new Set<string>(["AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID"]);
|
|
18
|
+
const WORK_RESULT_BRAND = "__workResult";
|
|
19
|
+
|
|
20
|
+
const childSource = String.raw`
|
|
21
|
+
"use strict";
|
|
22
|
+
const { AsyncLocalStorage } = require("node:async_hooks");
|
|
23
|
+
const vm = require("node:vm");
|
|
24
|
+
const LIMIT = parseInt(process.argv[2], 10);
|
|
25
|
+
const config = JSON.parse(process.argv[3]);
|
|
26
|
+
for (const key of ["getBuiltinModule","binding","_linkedBinding","dlopen","kill","abort","exit","reallyExit","_kill","umask","chdir","setuid","setgid","seteuid","setegid","setgroups","initgroups"]) {
|
|
27
|
+
if (key in process) process[key] = undefined;
|
|
28
|
+
}
|
|
29
|
+
let nextId = 0;
|
|
30
|
+
let cancelled = false;
|
|
31
|
+
const pending = new Map();
|
|
32
|
+
const inflight = new Set();
|
|
33
|
+
const hasMessage = error => Boolean(error && typeof error === "object" && typeof error.message === "string");
|
|
34
|
+
const errorText = error => hasMessage(error) ? error.message : String(error);
|
|
35
|
+
const errorCode = error => { if (!error || typeof error !== "object") return undefined; const code = error.code; return typeof code === "string" ? code : undefined; };
|
|
36
|
+
const errorAuthored = error => Boolean(error && typeof error === "object" && error.authored === true);
|
|
37
|
+
const workerError = error => { const code = errorCode(error); return { code: code || "INTERNAL_ERROR", message: errorText(error), ...(error && typeof error === "object" && typeof error.failedAt === "string" ? { failedAt: error.failedAt } : {}), ...(errorAuthored(error) || hasMessage(error) && !code ? { authored: true } : {}) }; };
|
|
38
|
+
const workflowError = error => Object.assign(new Error(errorText(error)), workerError(error));
|
|
39
|
+
function send(value) {
|
|
40
|
+
const json = JSON.stringify(value);
|
|
41
|
+
if (json === undefined || Buffer.byteLength(json) > LIMIT) throw Object.assign(new Error("RPC value exceeds the 10 MB JSON boundary"), { code: "RPC_LIMIT_EXCEEDED" });
|
|
42
|
+
process.send(json);
|
|
43
|
+
}
|
|
44
|
+
function rpc(method, args) {
|
|
45
|
+
if (cancelled) throw Object.assign(new Error("Workflow cancelled"), { code: "CANCELLED" });
|
|
46
|
+
const id = ++nextId;
|
|
47
|
+
send({ type: "rpc", id, method, args });
|
|
48
|
+
const promise = new Promise((resolve, reject) => pending.set(id, { resolve, reject }));
|
|
49
|
+
inflight.add(promise);
|
|
50
|
+
void promise.then(() => inflight.delete(promise), () => inflight.delete(promise));
|
|
51
|
+
return promise;
|
|
52
|
+
}
|
|
53
|
+
process.on("message", raw => {
|
|
54
|
+
let message;
|
|
55
|
+
try {
|
|
56
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > LIMIT) throw Object.assign(new Error("RPC value exceeds the 10 MB JSON boundary"), { code: "RPC_LIMIT_EXCEEDED" });
|
|
57
|
+
message = JSON.parse(raw);
|
|
58
|
+
} catch (error) { send({ type: "error", error: workerError(error) }); return; }
|
|
59
|
+
if (message.type === "cancel") { cancelled = true; for (const { reject } of pending.values()) reject(Object.assign(new Error("Workflow cancelled"), { code: "CANCELLED" })); pending.clear(); return; }
|
|
60
|
+
if (message.type !== "rpcResult") return;
|
|
61
|
+
const request = pending.get(message.id);
|
|
62
|
+
if (!request) return;
|
|
63
|
+
pending.delete(message.id);
|
|
64
|
+
if (message.ok) request.resolve(message.value);
|
|
65
|
+
else request.reject(workflowError(message.error));
|
|
66
|
+
});
|
|
67
|
+
const heartbeat = setInterval(() => send({ type: "heartbeat" }), 1000);
|
|
68
|
+
send({ type: "heartbeat" });
|
|
69
|
+
const BRAND = "${WORK_RESULT_BRAND}";
|
|
70
|
+
const workError = (code, message) => Object.assign(new Error(message), { code });
|
|
71
|
+
const isBranded = value => value && typeof value === "object" && value[BRAND] === true;
|
|
72
|
+
const unwrap = result => {
|
|
73
|
+
if (!isBranded(result)) return result;
|
|
74
|
+
if (result.ok) return result.value;
|
|
75
|
+
throw Object.assign(workflowError(result.error), { failedAt: result.failedAt });
|
|
76
|
+
};
|
|
77
|
+
const named = (value, kind) => { if (typeof value !== "string" || !value.trim()) throw workError("INVALID_METADATA", kind + " requires a stable explicit name"); return value; };
|
|
78
|
+
const path = (...names) => names.map(encodeURIComponent).join("/");
|
|
79
|
+
const inheritedAgentPath = new AsyncLocalStorage();
|
|
80
|
+
const agentOccurrences = new Map();
|
|
81
|
+
const shellOccurrences = new Map();
|
|
82
|
+
const worktreeOwners = new AsyncLocalStorage();
|
|
83
|
+
const rejectAgent = () => { throw workError("INVALID_METADATA", "Workflow agent calls must use a direct agent(...) call; aliases and indirect calls are unsupported"); };
|
|
84
|
+
const rejectShell = () => { throw workError("INVALID_METADATA", "Workflow shell calls must use a direct shell(...) call; aliases and indirect calls are unsupported"); };
|
|
85
|
+
const rejectWorktree = () => { throw workError("INVALID_METADATA", "withWorktree calls must use a direct withWorktree(...) call; aliases and indirect calls are unsupported"); };
|
|
86
|
+
const internalWithWorktree = async (...values) => {
|
|
87
|
+
if (values.length !== 2) throw workError("INVALID_METADATA", "withWorktree requires a name and callback");
|
|
88
|
+
const name = values[0];
|
|
89
|
+
const callback = values[1];
|
|
90
|
+
if (typeof name !== "string" || !name.trim()) throw workError("INVALID_METADATA", "withWorktree name must be a non-empty string");
|
|
91
|
+
if (typeof callback !== "function") throw workError("INVALID_METADATA", "withWorktree callback must be a function");
|
|
92
|
+
const owner = path("worktree", "named", name.trim());
|
|
93
|
+
const reference = await rpc("worktree", [owner]);
|
|
94
|
+
if (!reference || typeof reference !== "object" || typeof reference.path !== "string" || typeof reference.branch !== "string") throw workError("WORKTREE_FAILED", "Worktree reference is invalid");
|
|
95
|
+
return await worktreeOwners.run(owner, () => callback(Object.freeze({ path: reference.path, branch: reference.branch })));
|
|
96
|
+
};
|
|
97
|
+
const internalAgent = (...values) => {
|
|
98
|
+
const callSite = values.pop();
|
|
99
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow agent call-site identity");
|
|
100
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
101
|
+
// ponytail: same-callsite races outside parallel/pipeline lack a stable structural scope and are unsupported.
|
|
102
|
+
const occurrenceKey = JSON.stringify([inherited, callSite]);
|
|
103
|
+
const occurrence = (agentOccurrences.get(occurrenceKey) || 0) + 1;
|
|
104
|
+
agentOccurrences.set(occurrenceKey, occurrence);
|
|
105
|
+
const options = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
106
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
107
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
|
|
108
|
+
const result = rpc("agent", [values[0], options, identity]).then(unwrap);
|
|
109
|
+
Object.defineProperties(result, {
|
|
110
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before serialization"); } },
|
|
111
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
|
|
112
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow agent result is a Promise; await it before interpolation"); } },
|
|
113
|
+
});
|
|
114
|
+
return result;
|
|
115
|
+
};
|
|
116
|
+
const internalShell = (...values) => {
|
|
117
|
+
const callSite = values.pop();
|
|
118
|
+
if (typeof callSite !== "string") throw workError("INTERNAL_ERROR", "Missing workflow shell call-site identity");
|
|
119
|
+
if (values.length !== 1 && values.length !== 2) throw workError("INVALID_METADATA", "shell requires a command string and optional options");
|
|
120
|
+
const command = values[0];
|
|
121
|
+
if (typeof command !== "string") throw workError("INVALID_METADATA", "shell command must be a string");
|
|
122
|
+
const options = values.length < 2 || values[1] === undefined ? {} : values[1];
|
|
123
|
+
if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "timeoutMs" && key !== "env")) throw workError("INVALID_METADATA", "shell options must contain only timeoutMs and env");
|
|
124
|
+
if (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0)) throw workError("INVALID_METADATA", "shell timeoutMs must be a positive integer");
|
|
125
|
+
if (options.env !== undefined && (!options.env || typeof options.env !== "object" || Array.isArray(options.env) || Object.values(options.env).some(value => typeof value !== "string"))) throw workError("INVALID_METADATA", "shell env must be an object of strings");
|
|
126
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
127
|
+
const worktreeOwner = worktreeOwners.getStore();
|
|
128
|
+
const occurrenceKey = JSON.stringify([inherited, callSite, worktreeOwner || null]);
|
|
129
|
+
const occurrence = (shellOccurrences.get(occurrenceKey) || 0) + 1;
|
|
130
|
+
shellOccurrences.set(occurrenceKey, occurrence);
|
|
131
|
+
const identity = { structuralPath: [...inherited], callSite, occurrence, ...(worktreeOwner ? { worktreeOwner } : {}) };
|
|
132
|
+
const result = rpc("shell", [command, options, identity]);
|
|
133
|
+
Object.defineProperties(result, {
|
|
134
|
+
toJSON: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before serialization"); } },
|
|
135
|
+
toString: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before interpolation"); } },
|
|
136
|
+
[Symbol.toPrimitive]: { value() { throw workError("INVALID_METADATA", "Workflow shell result is a Promise; await it before interpolation"); } },
|
|
137
|
+
});
|
|
138
|
+
return result;
|
|
139
|
+
};
|
|
140
|
+
const shell = rejectShell;
|
|
141
|
+
const agent = rejectAgent;
|
|
142
|
+
const promptPath = (at, key) => /^[A-Za-z_$][\w$]*$/.test(key) ? at + "." + key : at + "[" + JSON.stringify(key) + "]";
|
|
143
|
+
const plainPromptObject = value => {
|
|
144
|
+
const proto = Object.getPrototypeOf(value);
|
|
145
|
+
return proto === null || Object.getPrototypeOf(proto) === null && Object.prototype.hasOwnProperty.call(proto, "constructor") && typeof proto.constructor === "function" && Function.prototype.toString.call(proto.constructor) === Function.prototype.toString.call(Object);
|
|
146
|
+
};
|
|
147
|
+
const promptValue = (value, at, seen) => {
|
|
148
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
149
|
+
if (typeof value === "number") { if (!Number.isFinite(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" must be a finite number"); return; }
|
|
150
|
+
if (typeof value !== "object") throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" cannot be " + typeof value);
|
|
151
|
+
if (typeof value.then === "function") throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" is a Promise or thenable; await it before calling prompt()");
|
|
152
|
+
if (!Array.isArray(value) && !plainPromptObject(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" must be a plain object");
|
|
153
|
+
if (seen.has(value)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" contains a cycle");
|
|
154
|
+
const keys = Reflect.ownKeys(value);
|
|
155
|
+
const symbol = keys.find(key => typeof key === "symbol");
|
|
156
|
+
if (symbol) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" contains a symbol key");
|
|
157
|
+
seen.add(value);
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
for (let index = 0; index < value.length; index += 1) promptProperty(value, String(index), at + "[" + index + "]", seen);
|
|
160
|
+
for (const key of keys) {
|
|
161
|
+
const index = Number(key);
|
|
162
|
+
if (key !== "length" && !(Number.isInteger(index) && index >= 0 && index < value.length && String(index) === key)) promptProperty(value, key, promptPath(at, key), seen);
|
|
163
|
+
}
|
|
164
|
+
} else for (const key of keys) promptProperty(value, key, promptPath(at, key), seen);
|
|
165
|
+
seen.delete(value);
|
|
166
|
+
};
|
|
167
|
+
const promptProperty = (value, key, at, seen) => {
|
|
168
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
169
|
+
if (descriptor && (descriptor.get || descriptor.set)) throw workError("INVALID_METADATA", "Prompt value \"" + at + "\" cannot use getters or setters");
|
|
170
|
+
promptValue(descriptor && descriptor.value, at, seen);
|
|
171
|
+
};
|
|
172
|
+
const prompt = (template, values) => {
|
|
173
|
+
if (typeof template !== "string") throw workError("INVALID_METADATA", "prompt() template must be a string");
|
|
174
|
+
if (!values || typeof values !== "object" || Array.isArray(values) || !plainPromptObject(values)) throw workError("INVALID_METADATA", "prompt() values must be a plain object");
|
|
175
|
+
const placeholders = [...template.matchAll(/{{|}}|{([A-Za-z_$][\w$]*)}/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]);
|
|
176
|
+
const used = new Set(placeholders);
|
|
177
|
+
const keys = Reflect.ownKeys(values);
|
|
178
|
+
const symbol = keys.find(key => typeof key === "symbol");
|
|
179
|
+
if (symbol) throw workError("INVALID_METADATA", "prompt() values must use string keys");
|
|
180
|
+
const missing = placeholders.find(key => !Object.prototype.hasOwnProperty.call(values, key));
|
|
181
|
+
if (missing) throw workError("INVALID_METADATA", "Missing prompt value \"" + missing + "\"");
|
|
182
|
+
const unused = keys.find(key => !used.has(key));
|
|
183
|
+
if (unused !== undefined) throw workError("INVALID_METADATA", "Unused prompt value \"" + unused + "\"");
|
|
184
|
+
for (const key of keys) promptProperty(values, key, key, new Set());
|
|
185
|
+
return template.replace(/{{|}}|{([A-Za-z_$][\w$]*)}/g, (match, key) => match === "{{" ? "{" : match === "}}" ? "}" : typeof values[key] === "string" ? values[key] : JSON.stringify(values[key], null, 2));
|
|
186
|
+
};
|
|
187
|
+
const checkpoint = input => rpc("checkpoint", [input]).then(unwrap);
|
|
188
|
+
const phase = name => rpc("phase", [name]);
|
|
189
|
+
const log = message => rpc("log", [message]);
|
|
190
|
+
const functionOccurrences = new Map();
|
|
191
|
+
const functionPath = name => {
|
|
192
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
193
|
+
const key = JSON.stringify([inherited, name]);
|
|
194
|
+
const occurrence = (functionOccurrences.get(key) || 0) + 1;
|
|
195
|
+
functionOccurrences.set(key, occurrence);
|
|
196
|
+
return path("function", ...inherited, name, String(occurrence));
|
|
197
|
+
};
|
|
198
|
+
const functions = Object.freeze(Object.fromEntries(Object.entries(config.functions || {}).map(([local, target]) => [local, (...values) => {
|
|
199
|
+
if (values.length !== 1 || !values[0] || typeof values[0] !== "object" || Array.isArray(values[0])) throw workError("RESULT_INVALID", local + " requires exactly one JSON object argument");
|
|
200
|
+
const inherited = inheritedAgentPath.getStore() || [];
|
|
201
|
+
const result = rpc("function", [target.name, values[0], functionPath(target.name), worktreeOwners.getStore() || null, inherited]).then(unwrap);
|
|
202
|
+
Object.defineProperty(result, "toJSON", { value() { throw workError("INVALID_METADATA", "Workflow function result is a Promise; await it before serialization"); } });
|
|
203
|
+
return result;
|
|
204
|
+
}])));
|
|
205
|
+
const freeze = value => { if (value && typeof value === "object" && !Object.isFrozen(value)) { Object.freeze(value); for (const child of Object.values(value)) freeze(child); } return value; };
|
|
206
|
+
const recordEntries = (value, kind) => {
|
|
207
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw workError("INVALID_METADATA", kind + " must be a record");
|
|
208
|
+
return Object.entries(value);
|
|
209
|
+
};
|
|
210
|
+
const parallel = async (operationName, tasks) => {
|
|
211
|
+
named(operationName, "parallel");
|
|
212
|
+
const entries = recordEntries(tasks, "parallel tasks");
|
|
213
|
+
for (const [name, run] of entries) {
|
|
214
|
+
named(name, "parallel task");
|
|
215
|
+
if (typeof run !== "function") throw workError("INVALID_METADATA", "parallel task values must be run functions");
|
|
216
|
+
}
|
|
217
|
+
const results = await Promise.all(entries.map(async ([name, run]) => {
|
|
218
|
+
try {
|
|
219
|
+
const parent = inheritedAgentPath.getStore() || [];
|
|
220
|
+
return { name, ok: true, value: await inheritedAgentPath.run([...parent, operationName, name], run) };
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (errorCode(error) === "CANCELLED") throw error;
|
|
223
|
+
const failedAt = error && typeof error === "object" && typeof error.failedAt === "string" ? error.failedAt : undefined;
|
|
224
|
+
return { name, ok: false, failedAt: failedAt ? path(operationName, name, failedAt) : path(operationName, name), error: workerError(error) };
|
|
225
|
+
}
|
|
226
|
+
}));
|
|
227
|
+
const failure = results.find(result => !result.ok);
|
|
228
|
+
if (failure) throw Object.assign(workflowError(failure.error), { failedAt: failure.failedAt });
|
|
229
|
+
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
230
|
+
};
|
|
231
|
+
const pipeline = async (operationName, items, stages) => {
|
|
232
|
+
named(operationName, "pipeline");
|
|
233
|
+
const itemEntries = recordEntries(items, "pipeline items");
|
|
234
|
+
const stageEntries = recordEntries(stages, "pipeline stages");
|
|
235
|
+
if (!stageEntries.length) throw workError("INVALID_METADATA", "pipeline requires at least one stage");
|
|
236
|
+
for (const [name] of itemEntries) named(name, "pipeline item");
|
|
237
|
+
for (const [stageName, run] of stageEntries) {
|
|
238
|
+
named(stageName, "pipeline stage");
|
|
239
|
+
if (typeof run !== "function") throw workError("INVALID_METADATA", "pipeline stage values must be run functions");
|
|
240
|
+
}
|
|
241
|
+
const results = await Promise.all(itemEntries.map(async ([name, initial]) => {
|
|
242
|
+
let value = initial;
|
|
243
|
+
let failedAt = path(operationName, name);
|
|
244
|
+
try {
|
|
245
|
+
for (const [stageName, run] of stageEntries) {
|
|
246
|
+
failedAt = path(operationName, name, stageName);
|
|
247
|
+
const parent = inheritedAgentPath.getStore() || [];
|
|
248
|
+
value = await inheritedAgentPath.run([...parent, operationName, name, stageName], () => run(value));
|
|
249
|
+
}
|
|
250
|
+
return { name, ok: true, value };
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (errorCode(error) === "CANCELLED") throw error;
|
|
253
|
+
const nestedFailedAt = error && typeof error === "object" && typeof error.failedAt === "string" ? error.failedAt : undefined;
|
|
254
|
+
return { name, ok: false, failedAt: nestedFailedAt ? path(failedAt, nestedFailedAt) : failedAt, error: workerError(error) };
|
|
255
|
+
}
|
|
256
|
+
}));
|
|
257
|
+
const failure = results.find(result => !result.ok);
|
|
258
|
+
if (failure) throw Object.assign(workflowError(failure.error), { failedAt: failure.failedAt });
|
|
259
|
+
return Object.fromEntries(results.map(result => [result.name, result.value]));
|
|
260
|
+
};
|
|
261
|
+
const safeMath = Object.fromEntries(Object.getOwnPropertyNames(Math).filter(name => name !== "random").map(name => [name, Math[name]]));
|
|
262
|
+
const sandbox = { agent, shell, withWorktree: rejectWorktree, prompt, checkpoint, parallel, pipeline, phase, log, args: config.args, Promise, JSON, Math: Object.freeze(safeMath) };
|
|
263
|
+
for (const [name, fn] of Object.entries(functions)) Object.defineProperty(sandbox, name, { value: fn, writable: false, configurable: false });
|
|
264
|
+
for (const [name, value] of Object.entries(config.variables || {})) Object.defineProperty(sandbox, name, { value: freeze(value), writable: false, configurable: false });
|
|
265
|
+
for (const name of ["Date","eval","Function","WebAssembly","process","require","module","exports","console","fetch","XMLHttpRequest","WebSocket","performance","crypto","setTimeout","setInterval","setImmediate","queueMicrotask","Intl","SharedArrayBuffer","Atomics"]) sandbox[name] = undefined;
|
|
266
|
+
const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
|
|
267
|
+
const body = config.script;
|
|
268
|
+
Promise.resolve().then(() => new vm.Script("(async(__pi_extensible_workflows_agent,__pi_extensible_workflows_withWorktree,__pi_extensible_workflows_shell)=>{" + body + "\n})", { filename: "workflow.js" }).runInContext(context)(internalAgent, internalWithWorktree, internalShell))
|
|
269
|
+
.then(async value => { await Promise.all(inflight); send({ type: "result", value: value === undefined ? null : value }); })
|
|
270
|
+
.catch(error => send({ type: "error", error: workerError(error) }))
|
|
271
|
+
.finally(() => clearInterval(heartbeat));
|
|
272
|
+
`;
|
|
273
|
+
|
|
274
|
+
export function encoded(value: unknown): string {
|
|
275
|
+
if (!jsonValue(value)) fail("RPC_LIMIT_EXCEEDED", "RPC values must be JSON-compatible");
|
|
276
|
+
const json = JSON.stringify(value);
|
|
277
|
+
if (Buffer.byteLength(json) > RPC_LIMIT_BYTES) fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
|
|
278
|
+
return json;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function encodedRpcResult(id: number, value: JsonValue): string {
|
|
282
|
+
return encoded({ type: "rpcResult", id, ok: true, value });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readAgentIdentity(value: unknown): AgentIdentity {
|
|
286
|
+
if (!object(value)) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
287
|
+
const structuralPath = value.structuralPath;
|
|
288
|
+
const callSite = value.callSite;
|
|
289
|
+
const occurrence = value.occurrence;
|
|
290
|
+
const worktreeOwner = value.worktreeOwner;
|
|
291
|
+
const parentBreadcrumb = value.parentBreadcrumb;
|
|
292
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim())) || typeof callSite !== "string" || !callSite || !positiveInteger(occurrence) || parentBreadcrumb !== undefined && (typeof parentBreadcrumb !== "string" || !parentBreadcrumb.trim()) || worktreeOwner !== undefined && (typeof worktreeOwner !== "string" || !worktreeOwner)) fail("INTERNAL_ERROR", "Invalid workflow agent identity");
|
|
293
|
+
return { structuralPath: [...structuralPath], callSite, occurrence, ...(typeof parentBreadcrumb === "string" ? { parentBreadcrumb } : {}), ...(typeof worktreeOwner === "string" ? { worktreeOwner } : {}) };
|
|
294
|
+
}
|
|
295
|
+
function readShellIdentity(value: unknown): ShellIdentity {
|
|
296
|
+
const identity = readAgentIdentity(value);
|
|
297
|
+
return { structuralPath: identity.structuralPath, callSite: identity.callSite, occurrence: identity.occurrence, ...(identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {}) };
|
|
298
|
+
}
|
|
299
|
+
export function agentIdentityPath(identity: AgentIdentity): string {
|
|
300
|
+
return operationPath("agent", ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
301
|
+
}
|
|
302
|
+
export function shellIdentityPath(identity: ShellIdentity): string {
|
|
303
|
+
return operationPath("shell", ...(identity.worktreeOwner ? ["worktree", identity.worktreeOwner] : []), ...identity.structuralPath, `callsite:${identity.callSite}`, `occurrence:${String(identity.occurrence)}`);
|
|
304
|
+
}
|
|
305
|
+
export function readShellResult(value: unknown): ShellResult {
|
|
306
|
+
if (!object(value) || (value.exitCode !== null && !Number.isInteger(value.exitCode)) || typeof value.stdout !== "string" || typeof value.stderr !== "string") fail("SHELL_FAILED", "Shell bridge returned an invalid result");
|
|
307
|
+
return { exitCode: value.exitCode as number | null, stdout: value.stdout, stderr: value.stderr };
|
|
308
|
+
}
|
|
309
|
+
export function agentWorktree(identity: AgentIdentity): { worktreeOwner?: string } {
|
|
310
|
+
return identity.worktreeOwner ? { worktreeOwner: identity.worktreeOwner } : {};
|
|
311
|
+
}
|
|
312
|
+
function shellProcessKill(child: ChildProcess): void {
|
|
313
|
+
let forceKill: ReturnType<typeof setTimeout> | undefined;
|
|
314
|
+
const killProcessTree = (signal: "SIGTERM" | "SIGKILL") => {
|
|
315
|
+
try {
|
|
316
|
+
if (child.pid && process.platform !== "win32") process.kill(-child.pid, signal);
|
|
317
|
+
else if (child.pid && process.platform === "win32") {
|
|
318
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true });
|
|
319
|
+
killer.unref();
|
|
320
|
+
} else child.kill(signal);
|
|
321
|
+
} catch {
|
|
322
|
+
try { child.kill(signal); } catch { /* The process may already have exited. */ }
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
child.once("close", () => { if (forceKill) clearTimeout(forceKill); });
|
|
326
|
+
killProcessTree("SIGTERM");
|
|
327
|
+
forceKill = setTimeout(() => { forceKill = undefined; killProcessTree("SIGKILL"); }, 1000);
|
|
328
|
+
forceKill.unref();
|
|
329
|
+
}
|
|
330
|
+
export function executeShellCommand(command: string, options: ShellOptions, signal: AbortSignal, cwd = process.cwd()): Promise<ShellResult> {
|
|
331
|
+
return new Promise((resolve, reject) => {
|
|
332
|
+
let child: ChildProcess;
|
|
333
|
+
try { child = spawn(command, { shell: true, cwd, env: { ...process.env, ...(options.env ?? {}) }, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"] }); }
|
|
334
|
+
catch (error) { reject(new WorkflowError("SHELL_FAILED", errorText(error))); return; }
|
|
335
|
+
let settled = false;
|
|
336
|
+
let timedOut = false;
|
|
337
|
+
let outputBytes = 0;
|
|
338
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
339
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
340
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
341
|
+
let stdout = "";
|
|
342
|
+
let stderr = "";
|
|
343
|
+
const cleanup = () => {
|
|
344
|
+
if (timeout) clearTimeout(timeout);
|
|
345
|
+
signal.removeEventListener("abort", onAbort);
|
|
346
|
+
};
|
|
347
|
+
const failShell = (error: WorkflowError) => {
|
|
348
|
+
if (settled) return;
|
|
349
|
+
settled = true;
|
|
350
|
+
cleanup();
|
|
351
|
+
shellProcessKill(child);
|
|
352
|
+
reject(error);
|
|
353
|
+
};
|
|
354
|
+
const onAbort = () => { failShell(new WorkflowError("CANCELLED", "Workflow cancelled")); };
|
|
355
|
+
const capture = (target: "stdout" | "stderr", chunk: Buffer) => {
|
|
356
|
+
if (settled) return;
|
|
357
|
+
outputBytes += chunk.byteLength;
|
|
358
|
+
if (outputBytes > RPC_LIMIT_BYTES) { failShell(new WorkflowError("RPC_LIMIT_EXCEEDED", "Shell result exceeds the 10 MB JSON boundary")); return; }
|
|
359
|
+
if (target === "stdout") stdout += stdoutDecoder.write(chunk); else stderr += stderrDecoder.write(chunk);
|
|
360
|
+
};
|
|
361
|
+
child.stdout?.on("data", (chunk: Buffer) => { capture("stdout", chunk); });
|
|
362
|
+
child.stderr?.on("data", (chunk: Buffer) => { capture("stderr", chunk); });
|
|
363
|
+
child.once("error", (error) => { failShell(new WorkflowError("SHELL_FAILED", errorText(error))); });
|
|
364
|
+
child.once("close", (exitCode) => {
|
|
365
|
+
if (settled) return;
|
|
366
|
+
settled = true;
|
|
367
|
+
cleanup();
|
|
368
|
+
stdout += stdoutDecoder.end();
|
|
369
|
+
stderr += stderrDecoder.end();
|
|
370
|
+
if (signal.aborted) { reject(new WorkflowError("CANCELLED", "Workflow cancelled")); return; }
|
|
371
|
+
if (timedOut) { reject(new WorkflowError("SHELL_FAILED", `Shell command timed out after ${String(options.timeoutMs)}ms`)); return; }
|
|
372
|
+
const result = { exitCode: exitCode === null ? null : exitCode, stdout, stderr };
|
|
373
|
+
try { encodedRpcResult(Number.MAX_SAFE_INTEGER, result); } catch (error) { reject(error instanceof WorkflowError ? error : new WorkflowError("RPC_LIMIT_EXCEEDED", errorText(error))); return; }
|
|
374
|
+
resolve(result);
|
|
375
|
+
});
|
|
376
|
+
if (signal.aborted) { onAbort(); return; }
|
|
377
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
378
|
+
if (options.timeoutMs !== undefined) timeout = setTimeout(() => { timedOut = true; shellProcessKill(child); }, options.timeoutMs);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function workflowErrorFromWorker(error: WorkerErrorShape): WorkflowError {
|
|
382
|
+
const code = typeof error.code === "string" ? error.code as WorkflowErrorCode : "INTERNAL_ERROR";
|
|
383
|
+
const typed = markWorkflowAuthored(new WorkflowError(code, error.message), Boolean(error.authored) || error.code === undefined);
|
|
384
|
+
return error.failedAt === undefined ? typed : Object.assign(typed, { failedAt: error.failedAt });
|
|
385
|
+
}
|
|
386
|
+
export function runWorkflow(script: string, args: JsonValue = null, bridge: WorkflowBridge = {}, signal?: AbortSignal): WorkflowExecution {
|
|
387
|
+
encoded(args);
|
|
388
|
+
const config = JSON.stringify({ script: instrumentWorkflow(script), args: structuredClone(args), functions: bridge.functions ?? {}, variables: bridge.variables ?? {} });
|
|
389
|
+
const childDir = mkdtempSync(join(tmpdir(), "pi-wf-"));
|
|
390
|
+
const childFile = join(childDir, "child.cjs");
|
|
391
|
+
writeFileSync(childFile, childSource);
|
|
392
|
+
const child: ChildProcess = fork(childFile, [String(RPC_LIMIT_BYTES), config], {
|
|
393
|
+
execArgv: (() => {
|
|
394
|
+
const filtered: string[] = [];
|
|
395
|
+
const skip = new Set(["--input-type", "-e", "--eval", "-p", "--print"]);
|
|
396
|
+
let skipNext = false;
|
|
397
|
+
for (const arg of process.execArgv) {
|
|
398
|
+
if (skipNext) { skipNext = false; continue; }
|
|
399
|
+
if (skip.has(arg) || skip.has(arg.split("=")[0] ?? "")) { if (!arg.includes("=")) skipNext = true; continue; }
|
|
400
|
+
filtered.push(arg);
|
|
401
|
+
}
|
|
402
|
+
return [...filtered, "--max-old-space-size=128", "--permission", `--allow-fs-read=${childDir}`];
|
|
403
|
+
})(),
|
|
404
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
|
405
|
+
serialization: "advanced",
|
|
406
|
+
});
|
|
407
|
+
const controller = new AbortController();
|
|
408
|
+
let settled = false;
|
|
409
|
+
let rejectResult: (error: WorkflowError) => void = () => undefined;
|
|
410
|
+
let watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS);
|
|
411
|
+
const result = new Promise<JsonValue>((resolve, reject) => {
|
|
412
|
+
rejectResult = reject;
|
|
413
|
+
child.on("message", (raw: unknown) => {
|
|
414
|
+
try {
|
|
415
|
+
if (typeof raw !== "string" || Buffer.byteLength(raw) > RPC_LIMIT_BYTES) fail("RPC_LIMIT_EXCEEDED", "RPC value exceeds the 10 MB JSON boundary");
|
|
416
|
+
const message = JSON.parse(raw) as { type?: string; id?: number; method?: string; args?: JsonValue[]; ok?: boolean; value?: JsonValue; error?: WorkerErrorShape };
|
|
417
|
+
if (!jsonValue(message)) fail("RPC_LIMIT_EXCEEDED", "Worker RPC must contain JSON-compatible values");
|
|
418
|
+
if (message.type === "heartbeat") { clearTimeout(watchdog); watchdog = setTimeout(() => { stop("WORKER_UNRESPONSIVE", "Workflow worker missed its five-second heartbeat"); }, HEARTBEAT_TIMEOUT_MS); return; }
|
|
419
|
+
if (message.type === "result") { encoded(message.value); finish(); resolve(message.value ?? null); return; }
|
|
420
|
+
if (message.type === "error") { finish(); reject(workflowErrorFromWorker(message.error ?? { code: "INTERNAL_ERROR", message: "Worker failed" })); return; }
|
|
421
|
+
if (message.type === "rpc" && message.id !== undefined) void handleRpc(message.id, message.method ?? "", message.args ?? []);
|
|
422
|
+
} catch (error) { stop(error instanceof WorkflowError ? error.code : "INTERNAL_ERROR", error instanceof Error ? error.message : String(error)); }
|
|
423
|
+
});
|
|
424
|
+
child.on("error", (error: Error) => { stop("INTERNAL_ERROR", error.message); });
|
|
425
|
+
child.on("exit", (code) => { if (!settled && code !== 0) stop("INTERNAL_ERROR", `Workflow child exited with code ${String(code)}`); });
|
|
426
|
+
});
|
|
427
|
+
function killChild() {
|
|
428
|
+
if (!child.killed) {
|
|
429
|
+
child.kill("SIGTERM");
|
|
430
|
+
setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); }, 1000).unref();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
function finish() { settled = true; clearTimeout(watchdog); signal?.removeEventListener("abort", cancel); killChild(); rmSync(childDir, { recursive: true, force: true }); }
|
|
434
|
+
function stop(code: WorkflowErrorCode, message: string) { if (settled) return; controller.abort(); finish(); rejectResult(new WorkflowError(code, message)); }
|
|
435
|
+
function branded(result: Record<string, JsonValue>): JsonValue { return { ...result, [WORK_RESULT_BRAND]: true }; }
|
|
436
|
+
async function handleRpc(id: number, method: string, values: JsonValue[]) {
|
|
437
|
+
try {
|
|
438
|
+
encoded(values);
|
|
439
|
+
let value: JsonValue = null;
|
|
440
|
+
if (method === "agent") {
|
|
441
|
+
if (!bridge.agent) fail("AGENT_FAILED", "No agent bridge is available");
|
|
442
|
+
if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "agent prompt must be a string");
|
|
443
|
+
const opts = validateAgentOptions(values[1]);
|
|
444
|
+
const identity = readAgentIdentity(values[2]);
|
|
445
|
+
const path = agentIdentityPath(identity);
|
|
446
|
+
const label = typeof opts.label === "string" ? opts.label : typeof opts.role === "string" ? opts.role : "agent";
|
|
447
|
+
try {
|
|
448
|
+
const result = await bridge.agent(values[0], opts, controller.signal, identity);
|
|
449
|
+
value = branded({ name: label, ok: true, value: result ?? null });
|
|
450
|
+
} catch (error) {
|
|
451
|
+
const typed = asWorkflowError(error);
|
|
452
|
+
if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
|
|
453
|
+
value = branded({ name: label, ok: false, failedAt: path, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
454
|
+
}
|
|
455
|
+
} else if (method === "shell") {
|
|
456
|
+
if (!bridge.shell) fail("SHELL_FAILED", "No shell bridge is available");
|
|
457
|
+
const command = validateShellCommand(values[0]);
|
|
458
|
+
const options = validateShellOptions(values[1]);
|
|
459
|
+
const identity = readShellIdentity(values[2]);
|
|
460
|
+
value = readShellResult(await bridge.shell(command, options, controller.signal, identity)) as unknown as JsonValue;
|
|
461
|
+
} else if (method === "checkpoint") {
|
|
462
|
+
if (!bridge.checkpoint || !object(values[0])) fail("INTERNAL_ERROR", "checkpoint requires an available bridge and object input");
|
|
463
|
+
const name = typeof values[0].name === "string" ? values[0].name : "checkpoint";
|
|
464
|
+
try {
|
|
465
|
+
const result = await bridge.checkpoint(values[0], controller.signal);
|
|
466
|
+
if (typeof result !== "boolean") fail("INTERNAL_ERROR", "checkpoint must return a boolean");
|
|
467
|
+
value = branded({ name, ok: true, value: result ? "approved" : "rejected" });
|
|
468
|
+
} catch (error) {
|
|
469
|
+
const typed = asWorkflowError(error);
|
|
470
|
+
if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
|
|
471
|
+
value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
472
|
+
}
|
|
473
|
+
} else if (method === "function") {
|
|
474
|
+
const worktreeOwner = values[3] === undefined || values[3] === null ? undefined : typeof values[3] === "string" && values[3] ? values[3] : fail("INTERNAL_ERROR", "function worktree scope is invalid");
|
|
475
|
+
const structuralPath = values[4] === undefined ? [] : values[4];
|
|
476
|
+
if (!Array.isArray(structuralPath) || !structuralPath.every((part): part is string => typeof part === "string" && Boolean(part.trim()))) fail("INTERNAL_ERROR", "function structural scope is invalid");
|
|
477
|
+
if (!bridge.function || typeof values[0] !== "string" || !object(values[1]) || typeof values[2] !== "string") fail("INTERNAL_ERROR", "function requires an available bridge, name, object input, and path");
|
|
478
|
+
const name = values[0];
|
|
479
|
+
try {
|
|
480
|
+
const result = await bridge.function(values[0], values[1], values[2], controller.signal, worktreeOwner, structuralPath);
|
|
481
|
+
value = branded({ name, ok: true, value: result ?? null });
|
|
482
|
+
} catch (error) {
|
|
483
|
+
const typed = asWorkflowError(error);
|
|
484
|
+
if (!OUTCOME_ERRORS.has(typed.code)) throw typed;
|
|
485
|
+
value = branded({ name, ok: false, failedAt: name, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } });
|
|
486
|
+
}
|
|
487
|
+
} else if (method === "worktree") {
|
|
488
|
+
if (!bridge.worktree || typeof values[0] !== "string" || !values[0]) fail("INTERNAL_ERROR", "worktree requires an active host bridge and scope");
|
|
489
|
+
value = await bridge.worktree(values[0], controller.signal);
|
|
490
|
+
} else if (method === "phase") {
|
|
491
|
+
if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "phase name must be a string");
|
|
492
|
+
await bridge.phase?.(values[0]);
|
|
493
|
+
} else if (method === "log") {
|
|
494
|
+
if (typeof values[0] !== "string") fail("INTERNAL_ERROR", "log message must be a string");
|
|
495
|
+
await bridge.log?.(values[0]);
|
|
496
|
+
}
|
|
497
|
+
else fail("INTERNAL_ERROR", `Unknown worker RPC method: ${method}`);
|
|
498
|
+
encoded(value);
|
|
499
|
+
child.send(encodedRpcResult(id, value));
|
|
500
|
+
} catch (error) {
|
|
501
|
+
const typed = asWorkflowError(error);
|
|
502
|
+
child.send(encoded({ type: "rpcResult", id, ok: false, error: { code: typed.code, message: typed.message, ...(isWorkflowAuthored(typed) ? { authored: true } : {}) } }));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function cancel() {
|
|
506
|
+
if (settled) return;
|
|
507
|
+
controller.abort();
|
|
508
|
+
child.send(encoded({ type: "cancel" }));
|
|
509
|
+
stop("CANCELLED", "Workflow cancelled");
|
|
510
|
+
}
|
|
511
|
+
if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
|
|
512
|
+
return { result, cancel };
|
|
513
|
+
}
|
|
514
|
+
function nativeSessionReference(attempt: Pick<AgentAttempt, "sessionId" | "sessionFile">): { sessionId: string; sessionFile: string } {
|
|
515
|
+
return { sessionId: attempt.sessionId, sessionFile: attempt.sessionFile };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export async function persistActiveAgentAttempt(store: RunStore, id: string, active: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">): Promise<void> {
|
|
519
|
+
await store.updateState((run) => {
|
|
520
|
+
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
521
|
+
if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
522
|
+
const accounting = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
523
|
+
const details = [...(agent.attemptDetails ?? []).filter((candidate) => candidate.attempt !== active.attempt), { ...active, accounting }];
|
|
524
|
+
const nativeSessions = run.nativeSessions.some(({ sessionId }) => sessionId === active.sessionId) ? run.nativeSessions : [...run.nativeSessions, nativeSessionReference(active)];
|
|
525
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: Math.max(candidate.attempts, active.attempt), attemptDetails: details } : candidate), nativeSessions };
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function persistAgentAttempts(store: RunStore, id: string, attempts: readonly AgentAttempt[]): Promise<void> {
|
|
530
|
+
await store.updateState((run) => {
|
|
531
|
+
const agent = run.agents.find((candidate) => candidate.id === id);
|
|
532
|
+
if (!agent) throw new WorkflowError("INTERNAL_ERROR", `Missing production ownership record: ${id}`);
|
|
533
|
+
const total = attempts.reduce((sum, attempt) => ({ input: sum.input + attempt.accounting.input, output: sum.output + attempt.accounting.output, cacheRead: sum.cacheRead + attempt.accounting.cacheRead, cacheWrite: sum.cacheWrite + attempt.accounting.cacheWrite, cost: sum.cost + attempt.accounting.cost }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
|
|
534
|
+
const attemptDetails = attempts.map(({ attempt, sessionId, sessionFile, error, accounting, setup }) => ({ attempt, sessionId, sessionFile, ...(error ? { error } : {}), accounting, ...(setup ? { setup } : {}) }));
|
|
535
|
+
const sessionIds = new Set(attempts.map(({ sessionId }) => sessionId));
|
|
536
|
+
return { ...run, agents: run.agents.map((candidate) => candidate.id === id ? { ...candidate, attempts: attempts.length, attemptDetails, accounting: total } : candidate), nativeSessions: [...run.nativeSessions.filter(({ sessionId }) => !sessionIds.has(sessionId)), ...attempts.map((attempt) => nativeSessionReference(attempt))] };
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export type { AgentIdentity, ShellIdentity, WorkflowBridge, WorkflowExecution } from "./types.js";
|
package/src/herdr.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
export type HerdrPaneAction = "inspect" | "transcript" | "fork";
|
|
5
|
+
export interface HerdrPaneRequest { action: HerdrPaneAction; cwd: string; sessionId?: string; original?: string; readOnly?: boolean }
|
|
6
|
+
export type HerdrCommandRunner = (args: readonly string[]) => Promise<string>;
|
|
7
|
+
|
|
8
|
+
const runHerdr: HerdrCommandRunner = (args) => new Promise<string>((resolve, reject) => {
|
|
9
|
+
execFile("herdr", [...args], { encoding: "utf8", maxBuffer: 1024 * 1024 }, (error, stdout) => {
|
|
10
|
+
if (error) { reject(new Error(error.message)); return; }
|
|
11
|
+
resolve(stdout);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function herdrPaneId(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
16
|
+
if (env.HERDR_ENV !== "1") return undefined;
|
|
17
|
+
const paneId = env.HERDR_PANE_ID?.trim();
|
|
18
|
+
return paneId || undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
22
|
+
function json(value: string): unknown { return JSON.parse(value) as unknown; }
|
|
23
|
+
function record(value: unknown): Record<string, unknown> | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined; }
|
|
24
|
+
function paneLayout(value: unknown, targetPane: string): { width: number; height: number } {
|
|
25
|
+
const root = record(value);
|
|
26
|
+
const result = record(root?.result);
|
|
27
|
+
const layout = record(result?.layout);
|
|
28
|
+
const rawPanes = layout?.panes;
|
|
29
|
+
if (!Array.isArray(rawPanes)) throw new Error("Herdr returned an invalid pane layout.");
|
|
30
|
+
const panes: unknown[] = rawPanes;
|
|
31
|
+
const pane = panes.find((candidate: unknown) => record(candidate)?.pane_id === targetPane);
|
|
32
|
+
const rect = record(record(pane)?.rect);
|
|
33
|
+
const width = rect?.width;
|
|
34
|
+
const height = rect?.height;
|
|
35
|
+
if (width === undefined || height === undefined || typeof width !== "number" || typeof height !== "number") throw new Error("Herdr returned an invalid target pane geometry.");
|
|
36
|
+
return { width, height };
|
|
37
|
+
}
|
|
38
|
+
function splitPaneId(value: unknown): string {
|
|
39
|
+
const pane = record(record(record(value)?.result)?.pane);
|
|
40
|
+
const paneId = pane?.pane_id;
|
|
41
|
+
if (typeof paneId !== "string" || !paneId) throw new Error("Herdr returned an invalid created pane ID.");
|
|
42
|
+
return paneId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function commandFor(request: HerdrPaneRequest): string {
|
|
46
|
+
const cliPath = fileURLToPath(new URL("./cli.js", import.meta.resolve("pi-extensible-workflows")));
|
|
47
|
+
const environment = ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR"].flatMap((name) => process.env[name] === undefined ? [] : [`${name}=${shellQuote(process.env[name] ?? "")}`]);
|
|
48
|
+
const command = request.action === "inspect"
|
|
49
|
+
? [shellQuote(process.execPath), shellQuote(cliPath), "inspect", shellQuote(request.sessionId ?? "")]
|
|
50
|
+
: request.action === "transcript"
|
|
51
|
+
? [shellQuote(process.execPath), shellQuote(cliPath), "transcript", shellQuote(request.original ?? "")]
|
|
52
|
+
: ["pi", "--fork", shellQuote(request.original ?? ""), ...(request.readOnly ? ["--tools", shellQuote("read,grep,find,ls")] : [])];
|
|
53
|
+
return `cd ${shellQuote(request.cwd)} && ${environment.length ? `${environment.join(" ")} ` : ""}${command.join(" ")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function herdrPaneCommand(request: HerdrPaneRequest): string { return commandFor(request); }
|
|
57
|
+
|
|
58
|
+
export async function openHerdrPane(request: HerdrPaneRequest, runner: HerdrCommandRunner = runHerdr): Promise<string> {
|
|
59
|
+
const targetPane = herdrPaneId();
|
|
60
|
+
if (!targetPane) throw new Error("Pane actions require a Herdr-managed session with HERDR_PANE_ID.");
|
|
61
|
+
if (!request.cwd) throw new Error("Pane actions require a working directory.");
|
|
62
|
+
if ((request.action === "inspect" && !request.sessionId) || (request.action !== "inspect" && !request.original)) throw new Error("Pane action is missing its session source.");
|
|
63
|
+
const layout = paneLayout(json(await runner(["pane", "layout", "--pane", targetPane])), targetPane);
|
|
64
|
+
const direction = layout.width > layout.height ? "right" : "down";
|
|
65
|
+
const paneId = splitPaneId(json(await runner(["pane", "split", targetPane, "--direction", direction, "--no-focus"])));
|
|
66
|
+
try {
|
|
67
|
+
await runner(["pane", "run", paneId, commandFor(request)]);
|
|
68
|
+
return paneId;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
await runner(["pane", "close", paneId]).catch(() => undefined);
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|