@openhorizon/workflows 0.1.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/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/cli.js +2162 -0
- package/package.json +52 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
|
|
6
|
+
// src/runners/cursor.ts
|
|
7
|
+
import { Agent, CursorAgentError } from "@cursor/sdk";
|
|
8
|
+
|
|
9
|
+
// src/runtime/prompts.ts
|
|
10
|
+
var SUBAGENT_CONTRACT_TEXT = `You are a subagent spawned by a workflow orchestration script. Use the tools available to complete the task.
|
|
11
|
+
|
|
12
|
+
CRITICAL: Your final text response is returned **verbatim** as a string to the calling script — it is your return value, not a message to a human.
|
|
13
|
+
- Output the literal result (data, JSON, text). Do NOT output confirmations like "Done." or "Sent."
|
|
14
|
+
- If asked for JSON, return ONLY the raw JSON — no code fences, no prose, no markdown.
|
|
15
|
+
- Do NOT address the user. Put your answer in your final text response.
|
|
16
|
+
- Be concise. The script will parse your output.`;
|
|
17
|
+
var SUBAGENT_CONTRACT_SCHEMA = `You are a subagent spawned by a workflow orchestration script. Use the tools available to complete the task.
|
|
18
|
+
|
|
19
|
+
CRITICAL: You MUST call the StructuredOutput tool exactly once to return your final answer. The tool's input schema defines the required shape.
|
|
20
|
+
- Do your work (read files, run commands, etc.), then call StructuredOutput with your answer.
|
|
21
|
+
- Do NOT put your answer in a text response. The script reads ONLY the StructuredOutput tool call.
|
|
22
|
+
- If the schema validation fails, read the error and call StructuredOutput again with a corrected shape.
|
|
23
|
+
- After calling StructuredOutput successfully, end your turn. No acknowledgment needed.`;
|
|
24
|
+
var SUBAGENT_STOP_NUDGE = `You did not call StructuredOutput. You MUST call StructuredOutput to return your answer — the tool input IS your answer. Call it now.`;
|
|
25
|
+
var AGENT_CAP_MESSAGE = `Workflow agent() call cap reached (1000). This usually means a loop using budget.remaining() never terminates because no token budget was set — remaining() returns Infinity when budget.total is null. Add a hard iteration cap to the loop, or pass a token budget.`;
|
|
26
|
+
var budgetExceededMessage = (spent, total) => `Workflow token budget exceeded (${spent.toLocaleString()} / ${total.toLocaleString()} output tokens). Stopping further agent() calls. In-flight agents will complete; their results are preserved.`;
|
|
27
|
+
var ERR_PARALLEL_NOT_ARRAY = `parallel() expects an array of functions`;
|
|
28
|
+
var ERR_PARALLEL_NOT_FUNCTIONS = `parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)`;
|
|
29
|
+
var ERR_PIPELINE_NOT_ARRAY = `pipeline() expects an array as the first argument`;
|
|
30
|
+
var ERR_PIPELINE_STAGES = `pipeline() stages must be functions: pipeline(items, item => ..., result => ...)`;
|
|
31
|
+
var ERR_ISOLATION_REMOTE = `agent({isolation:'remote'}) is not available in this build`;
|
|
32
|
+
var ERR_WORKFLOW_ABORTED = `Workflow aborted`;
|
|
33
|
+
var ERR_NO_STRUCTURED_OUTPUT = `agent({schema}): subagent completed without calling StructuredOutput (after 2 in-conversation nudges)`;
|
|
34
|
+
var ERR_WORKFLOW_HOOK_UNAVAILABLE = `workflow() is not available in this build — inline the child workflow's agents directly.`;
|
|
35
|
+
var ERR_AGENT_TYPE_UNAVAILABLE = `agent({agentType}) is not available in this build — omit agentType.`;
|
|
36
|
+
var WORKFLOW_TOOL_DESCRIPTION = `Execute a workflow script that orchestrates multiple subagents deterministically. Workflows run in the background — this tool returns immediately with a run ID. Poll workflow_status({runId}) for live progress, or call workflow_wait({runId}) to block until it finishes (re-call it if it returns while the run is still going).
|
|
37
|
+
|
|
38
|
+
A workflow structures work across many agents — to be comprehensive (decompose and cover in parallel), to be confident (independent perspectives and adversarial checks before committing), or to take on scale one context can't hold (migrations, audits, broad sweeps). The script is where you encode that structure: what fans out, what verifies, what synthesizes.
|
|
39
|
+
|
|
40
|
+
Workflows can spawn dozens of agents and consume a large amount of tokens. Only launch one when the user asked for multi-agent orchestration (or a workflow by name) or has agreed to the scale; otherwise describe what a workflow could do and ask first.
|
|
41
|
+
|
|
42
|
+
The right move is often **hybrid**: scout inline first (list the files, find the channels, scope the diff) to discover the work-list, then launch a workflow to pipeline over it. For larger work, run several workflows in sequence — read each result before deciding the next phase.
|
|
43
|
+
|
|
44
|
+
Pass the script inline via \`script\`. Every invocation persists its script to a file and returns the path in the tool result. To iterate on a workflow, edit that file and re-invoke with \`{scriptPath: "<path>"}\` instead of resending the full script.
|
|
45
|
+
|
|
46
|
+
Every script must begin with \`export const meta = {...}\`:
|
|
47
|
+
export const meta = {
|
|
48
|
+
name: 'find-flaky-tests',
|
|
49
|
+
description: 'Find flaky tests and propose fixes', // one-line summary
|
|
50
|
+
phases: [ // one entry per phase() call
|
|
51
|
+
{ title: 'Scan', detail: 'grep test logs for retries' },
|
|
52
|
+
{ title: 'Fix', detail: 'one agent per flaky test' },
|
|
53
|
+
],
|
|
54
|
+
}
|
|
55
|
+
// script body starts here — use agent()/parallel()/pipeline()/phase()/log()
|
|
56
|
+
phase('Scan')
|
|
57
|
+
const flaky = await agent('grep CI logs for retry markers', {schema: FLAKY_SCHEMA})
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
The \`meta\` object must be a PURE LITERAL — no variables, function calls, spreads, or template interpolation. Required fields: \`name\`, \`description\`. Optional: \`whenToUse\`, \`phases\`. Use the SAME phase titles in meta.phases as in phase() calls — titles are matched exactly.
|
|
61
|
+
|
|
62
|
+
Script body hooks:
|
|
63
|
+
- agent(prompt: string, opts?: {label?: string, phase?: string, schema?: object, model?: string, isolation?: 'worktree'}): Promise<any> — spawn a subagent. Without schema, returns its final text as a string. With schema (a JSON Schema object), the subagent is forced to call a StructuredOutput tool and agent() returns the validated object — no parsing needed. Returns null if the subagent dies on a terminal error after retries (filter with .filter(Boolean)). opts.label overrides the display label. opts.phase explicitly assigns this agent to a progress group (use this inside pipeline()/parallel() stages to avoid races on the global phase() state). opts.model overrides the model for this agent call; omit it to use the server's default model. opts.isolation: 'worktree' runs the agent in a fresh git worktree — use ONLY when agents mutate files in parallel and would otherwise conflict; the worktree is auto-removed if unchanged.
|
|
64
|
+
- pipeline(items, stage1, stage2, ...): Promise<any[]> — run each item through all stages independently, NO barrier between stages. Item A can be in stage 3 while item B is still in stage 1. This is the DEFAULT for multi-stage work. Wall-clock = slowest single-item chain, not sum-of-slowest-per-stage. Every stage callback receives (prevResult, originalItem, index). A stage that throws drops that item to \`null\` and skips its remaining stages.
|
|
65
|
+
- parallel(thunks: Array<() => Promise<any>>): Promise<any[]> — run tasks concurrently. This is a BARRIER: awaits all thunks before returning. A thunk that throws resolves to \`null\` in the result array — the call itself never rejects, so \`.filter(Boolean)\` before using the results. Use ONLY when you genuinely need all results together.
|
|
66
|
+
- log(message: string): void — emit a progress message (shown in workflow_status output)
|
|
67
|
+
- phase(title: string): void — start a new phase; subsequent agent() calls are grouped under this title
|
|
68
|
+
- args: any — the value passed as the workflow tool's \`args\` input, verbatim (undefined if not provided). Pass arrays/objects as actual JSON values, NOT as a JSON-encoded string.
|
|
69
|
+
- budget: {total: number|null, spent(): number, remaining(): number} — this run's output-token budget (from the workflow tool's \`tokenBudget\` input; total is null when none was set). The target is a HARD ceiling: once spent() reaches total, further agent() calls throw. Use for dynamic loops: \`while (budget.total && budget.remaining() > 50_000) { ... }\`.
|
|
70
|
+
|
|
71
|
+
Subagents are told their final text IS the return value (not a human-facing message), so they return raw data. For structured output, use the schema option — validation happens at the tool boundary so the subagent retries on mismatch.
|
|
72
|
+
|
|
73
|
+
Scripts are plain JavaScript, NOT TypeScript — type annotations, interfaces, and generics fail to parse. The script body runs in an async context — use await directly. Standard JS built-ins (JSON, Math, Array, etc.) are available — EXCEPT \`Date.now()\`/\`Math.random()\`/argless \`new Date()\`, which throw (they would break resume); pass timestamps in via \`args\` and for randomness vary the agent prompt/label by index. No filesystem or Node.js API access.
|
|
74
|
+
|
|
75
|
+
DEFAULT TO pipeline(). Only reach for a barrier (parallel between stages) when stage N needs cross-item context from ALL of stage N-1 (dedup/merge across the full set, early-exit on zero total, prompts that reference "the other findings"). "I need to flatten/map/filter first" is NOT a reason — do it inside a pipeline stage.
|
|
76
|
+
|
|
77
|
+
Concurrent agent() calls are capped per workflow — excess calls queue and run as slots free up. You can still pass 100 items to parallel()/pipeline() and they all complete. Total agent count across a workflow's lifetime is capped at 1000. A single parallel()/pipeline() call accepts at most 4096 items; passing more is an explicit error, not a silent truncation.
|
|
78
|
+
|
|
79
|
+
The canonical multi-stage pattern — pipeline by default, each dimension verifies as soon as its review completes:
|
|
80
|
+
export const meta = {
|
|
81
|
+
name: 'review-changes',
|
|
82
|
+
description: 'Review changed files across dimensions, verify each finding',
|
|
83
|
+
phases: [{ title: 'Review' }, { title: 'Verify' }],
|
|
84
|
+
}
|
|
85
|
+
const DIMENSIONS = [{key: 'bugs', prompt: '...'}, {key: 'perf', prompt: '...'}]
|
|
86
|
+
const results = await pipeline(
|
|
87
|
+
DIMENSIONS,
|
|
88
|
+
d => agent(d.prompt, {label: \`review:\${d.key}\`, phase: 'Review', schema: FINDINGS_SCHEMA}),
|
|
89
|
+
review => parallel(review.findings.map(f => () =>
|
|
90
|
+
agent(\`Adversarially verify: \${f.title}\`, {label: \`verify:\${f.file}\`, phase: 'Verify', schema: VERDICT_SCHEMA})
|
|
91
|
+
.then(v => ({...f, verdict: v}))
|
|
92
|
+
))
|
|
93
|
+
)
|
|
94
|
+
return { confirmed: results.flat().filter(Boolean).filter(f => f.verdict?.isReal) }
|
|
95
|
+
|
|
96
|
+
Quality patterns — pick by task and compose freely:
|
|
97
|
+
- Adversarial verify: spawn N independent skeptics per finding, each prompted to REFUTE. Kill if the majority refute.
|
|
98
|
+
- Perspective-diverse verify: give each verifier a distinct lens (correctness, security, perf, does-it-reproduce) instead of N identical refuters.
|
|
99
|
+
- Judge panel: generate N independent attempts from different angles, score with parallel judges, synthesize from the winner.
|
|
100
|
+
- Loop-until-dry: for unknown-size discovery, keep spawning finders until K consecutive rounds return nothing new.
|
|
101
|
+
- Completeness critic: a final agent that asks "what's missing?" — what it finds becomes the next round of work.
|
|
102
|
+
- No silent caps: if a workflow bounds coverage (top-N, sampling), log() what was dropped.
|
|
103
|
+
|
|
104
|
+
Use this tool for multi-step orchestration where control flow should be deterministic (loops, conditionals, fan-out) rather than model-driven.
|
|
105
|
+
|
|
106
|
+
## Resume
|
|
107
|
+
|
|
108
|
+
The tool result includes a runId. To resume after a stop, failure, or script edit, relaunch with workflow({scriptPath, resumeFromRunId}) — the longest unchanged prefix of agent() calls returns cached results instantly; the first edited/new call and everything after it runs live. Same script + same args → 100% cache hit. Stop a still-running workflow first with workflow_stop({runId}).`;
|
|
109
|
+
|
|
110
|
+
// src/runners/cursor.ts
|
|
111
|
+
var disposeAgent = async (agent) => {
|
|
112
|
+
const sym = Symbol.asyncDispose;
|
|
113
|
+
if (sym === undefined)
|
|
114
|
+
return;
|
|
115
|
+
const dispose = agent[sym];
|
|
116
|
+
if (typeof dispose === "function") {
|
|
117
|
+
await dispose.call(agent);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
var makeCursorRunner = (config) => ({
|
|
121
|
+
run: async (params) => {
|
|
122
|
+
let outputTokens = 0;
|
|
123
|
+
let toolCalls = 0;
|
|
124
|
+
let structured;
|
|
125
|
+
let structuredFound = false;
|
|
126
|
+
const structuredOutputTool = params.schema !== undefined ? {
|
|
127
|
+
description: "Return your final answer. The input schema defines the required shape. Call exactly once.",
|
|
128
|
+
inputSchema: params.schema,
|
|
129
|
+
execute: (args) => {
|
|
130
|
+
const verdict = params.validate?.(args) ?? { ok: true };
|
|
131
|
+
if (verdict.ok) {
|
|
132
|
+
structured = args;
|
|
133
|
+
structuredFound = true;
|
|
134
|
+
return "Structured output recorded.";
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
content: [{ type: "text", text: verdict.message }],
|
|
138
|
+
isError: true
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
} : undefined;
|
|
142
|
+
const onDelta = ({ update }) => {
|
|
143
|
+
params.onActivity?.();
|
|
144
|
+
if (update.type === "tool-call-completed")
|
|
145
|
+
toolCalls += 1;
|
|
146
|
+
if (update.type === "turn-ended") {
|
|
147
|
+
const out = update.usage?.outputTokens;
|
|
148
|
+
if (typeof out === "number" && out > 0) {
|
|
149
|
+
outputTokens += out;
|
|
150
|
+
params.onTokens?.(out);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
let agent;
|
|
155
|
+
let currentRun;
|
|
156
|
+
const onAbort = () => {
|
|
157
|
+
currentRun?.cancel().catch(() => {});
|
|
158
|
+
};
|
|
159
|
+
try {
|
|
160
|
+
if (params.signal.aborted) {
|
|
161
|
+
return { text: "", aborted: true, outputTokens, toolCalls };
|
|
162
|
+
}
|
|
163
|
+
params.signal.addEventListener("abort", onAbort, { once: true });
|
|
164
|
+
agent = await Agent.create({
|
|
165
|
+
...config.apiKey !== undefined ? { apiKey: config.apiKey } : {},
|
|
166
|
+
model: { id: params.model ?? config.defaultModel },
|
|
167
|
+
local: {
|
|
168
|
+
cwd: params.cwd,
|
|
169
|
+
...structuredOutputTool !== undefined ? { customTools: { StructuredOutput: structuredOutputTool } } : {}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
const promptText = `${params.contract}
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
Task:
|
|
177
|
+
${params.prompt}`;
|
|
178
|
+
let message = promptText;
|
|
179
|
+
let nudges = 0;
|
|
180
|
+
for (;; ) {
|
|
181
|
+
const run = await agent.send(message, { onDelta });
|
|
182
|
+
currentRun = run;
|
|
183
|
+
if (params.signal.aborted)
|
|
184
|
+
onAbort();
|
|
185
|
+
const result = await run.wait();
|
|
186
|
+
currentRun = undefined;
|
|
187
|
+
if (result.status === "cancelled") {
|
|
188
|
+
return params.signal.aborted ? {
|
|
189
|
+
text: result.result ?? "",
|
|
190
|
+
aborted: true,
|
|
191
|
+
outputTokens,
|
|
192
|
+
toolCalls
|
|
193
|
+
} : {
|
|
194
|
+
text: result.result ?? "",
|
|
195
|
+
apiError: "run cancelled",
|
|
196
|
+
outputTokens,
|
|
197
|
+
toolCalls
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (result.status !== "finished") {
|
|
201
|
+
return {
|
|
202
|
+
text: result.result ?? "",
|
|
203
|
+
apiError: `Cursor run ended with status "${result.status}"`,
|
|
204
|
+
outputTokens,
|
|
205
|
+
toolCalls
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (params.schema !== undefined && !structuredFound && nudges < 2) {
|
|
209
|
+
nudges += 1;
|
|
210
|
+
message = SUBAGENT_STOP_NUDGE;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
text: result.result ?? "",
|
|
215
|
+
...structuredFound ? { structured } : {},
|
|
216
|
+
outputTokens,
|
|
217
|
+
toolCalls
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if (params.signal.aborted) {
|
|
222
|
+
return { text: "", aborted: true, outputTokens, toolCalls };
|
|
223
|
+
}
|
|
224
|
+
const message = err instanceof CursorAgentError ? err.message : err instanceof Error ? err.message : String(err);
|
|
225
|
+
return { text: "", apiError: message, outputTokens, toolCalls };
|
|
226
|
+
} finally {
|
|
227
|
+
params.signal.removeEventListener("abort", onAbort);
|
|
228
|
+
if (agent !== undefined)
|
|
229
|
+
await disposeAgent(agent).catch(() => {});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// src/runtime/constants.ts
|
|
235
|
+
import { cpus } from "node:os";
|
|
236
|
+
var AGENT_CAP = 1000;
|
|
237
|
+
var DEFAULT_STALL_MS = 180000;
|
|
238
|
+
var MAX_STALL_RETRIES = 5;
|
|
239
|
+
var THROTTLE_BACKOFF_MS = 45000;
|
|
240
|
+
var SYNC_RUN_TIMEOUT_MS = 30000;
|
|
241
|
+
var MAX_VM_BOUNDARY_ITEMS = 4096;
|
|
242
|
+
var PREVIEW_TRUNCATE = 400;
|
|
243
|
+
var MAX_SCRIPT_BYTES = 524288;
|
|
244
|
+
var LOG_BUFFER_CAP = 1000;
|
|
245
|
+
var CACHE_KEY_PREFIX = "v2";
|
|
246
|
+
var RESULT_TEXT_CAP = 8000;
|
|
247
|
+
var concurrencyCap = (cores) => Math.min(16, Math.max(2, cores - 2));
|
|
248
|
+
var envConcurrency = Number(process.env["WORKFLOWS_MAX_CONCURRENCY"]);
|
|
249
|
+
var LOCAL_CONCURRENCY = Number.isInteger(envConcurrency) && envConcurrency > 0 ? envConcurrency : concurrencyCap(cpus().length);
|
|
250
|
+
var DETERMINISM_RE = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
|
|
251
|
+
var previewTruncate = (x) => {
|
|
252
|
+
const s = typeof x === "string" ? x : JSON.stringify(x);
|
|
253
|
+
const str = s ?? "";
|
|
254
|
+
return str.length > PREVIEW_TRUNCATE ? str.slice(0, PREVIEW_TRUNCATE) + "…" : str;
|
|
255
|
+
};
|
|
256
|
+
var DEFAULT_MODEL = process.env["WORKFLOWS_MODEL"] ?? "auto";
|
|
257
|
+
|
|
258
|
+
// src/runtime/orchestrator.ts
|
|
259
|
+
import { writeFileSync } from "node:fs";
|
|
260
|
+
|
|
261
|
+
// src/runtime/journal.ts
|
|
262
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
263
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
264
|
+
import { homedir } from "node:os";
|
|
265
|
+
import { join } from "node:path";
|
|
266
|
+
var CANON_KEYS = ["schema", "model", "isolation", "agentType"];
|
|
267
|
+
var sortedClone = (value) => {
|
|
268
|
+
if (typeof value === "function")
|
|
269
|
+
return;
|
|
270
|
+
if (Array.isArray(value)) {
|
|
271
|
+
const out = [];
|
|
272
|
+
const rawLength = value.length;
|
|
273
|
+
const length = Number.isSafeInteger(rawLength) ? rawLength : 0;
|
|
274
|
+
for (let i = 0;i < length; i++)
|
|
275
|
+
out[i] = sortedClone(value[i]);
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
if (value && typeof value === "object") {
|
|
279
|
+
const out = {};
|
|
280
|
+
for (const key of Object.keys(value).sort()) {
|
|
281
|
+
if (key === "__proto__")
|
|
282
|
+
continue;
|
|
283
|
+
out[key] = sortedClone(value[key]);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
return value;
|
|
288
|
+
};
|
|
289
|
+
var canonOpts = (opts) => {
|
|
290
|
+
if (!opts)
|
|
291
|
+
return "{}";
|
|
292
|
+
const picked = {};
|
|
293
|
+
for (const key of CANON_KEYS) {
|
|
294
|
+
const value = opts[key];
|
|
295
|
+
if (value === undefined || typeof value === "function")
|
|
296
|
+
continue;
|
|
297
|
+
picked[key] = value;
|
|
298
|
+
}
|
|
299
|
+
return JSON.stringify(sortedClone(picked));
|
|
300
|
+
};
|
|
301
|
+
var cacheKey = (prompt, opts, prevKey) => {
|
|
302
|
+
const h = createHash("sha256");
|
|
303
|
+
h.update(prevKey);
|
|
304
|
+
h.update("\x00");
|
|
305
|
+
h.update(prompt);
|
|
306
|
+
h.update("\x00");
|
|
307
|
+
h.update(canonOpts(opts));
|
|
308
|
+
return `${CACHE_KEY_PREFIX}:` + h.digest("hex");
|
|
309
|
+
};
|
|
310
|
+
var buildReplayMap = (entries) => {
|
|
311
|
+
const results = new Map;
|
|
312
|
+
const started = new Map;
|
|
313
|
+
for (const e of entries) {
|
|
314
|
+
if (e.type === "result") {
|
|
315
|
+
results.set(e.key, e);
|
|
316
|
+
} else {
|
|
317
|
+
const list = started.get(e.key) ?? [];
|
|
318
|
+
list.push(e);
|
|
319
|
+
started.set(e.key, list);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { results, started };
|
|
323
|
+
};
|
|
324
|
+
var RUN_ID_RE = /^wf_[a-z0-9-]{6,}$/;
|
|
325
|
+
var mintRunId = (resumeFromRunId) => resumeFromRunId ?? "wf_" + randomUUID().slice(0, 12);
|
|
326
|
+
var stateHome = () => process.env["OPENHORIZON_WORKFLOWS_DIR"] ?? join(homedir(), ".openhorizon", "workflows");
|
|
327
|
+
var runDirFor = (runId) => join(stateHome(), "runs", runId);
|
|
328
|
+
var journalPathFor = (runDir) => join(runDir, "journal.jsonl");
|
|
329
|
+
var scriptPathFor = (runDir) => join(runDir, "script.js");
|
|
330
|
+
var statePathFor = (runDir) => join(runDir, "state.json");
|
|
331
|
+
var resultPathFor = (runDir) => join(runDir, "result.json");
|
|
332
|
+
var progressPathFor = (runDir) => join(runDir, "progress.jsonl");
|
|
333
|
+
var ensureRunDir = (runDir) => {
|
|
334
|
+
try {
|
|
335
|
+
mkdirSync(runDir, { recursive: true, mode: 448 });
|
|
336
|
+
} catch {}
|
|
337
|
+
};
|
|
338
|
+
var appendJsonl = (path, row) => {
|
|
339
|
+
try {
|
|
340
|
+
appendFileSync(path, JSON.stringify(row) + `
|
|
341
|
+
`);
|
|
342
|
+
} catch {}
|
|
343
|
+
};
|
|
344
|
+
var makeJournal = (journalPath) => ({
|
|
345
|
+
append: (entry) => appendJsonl(journalPath, entry)
|
|
346
|
+
});
|
|
347
|
+
var readJournal = (journalPath) => {
|
|
348
|
+
if (!existsSync(journalPath))
|
|
349
|
+
return [];
|
|
350
|
+
let raw;
|
|
351
|
+
try {
|
|
352
|
+
raw = readFileSync(journalPath, "utf8");
|
|
353
|
+
} catch {
|
|
354
|
+
return [];
|
|
355
|
+
}
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const line of raw.split(`
|
|
358
|
+
`)) {
|
|
359
|
+
const t = line.trim();
|
|
360
|
+
if (t.length === 0)
|
|
361
|
+
continue;
|
|
362
|
+
try {
|
|
363
|
+
out.push(JSON.parse(t));
|
|
364
|
+
} catch {}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// src/runtime/sandbox.ts
|
|
370
|
+
import vm from "node:vm";
|
|
371
|
+
import * as acorn from "acorn";
|
|
372
|
+
var RESERVED_IDENTIFIER_PREFIX = "__wRg$";
|
|
373
|
+
var vmBoundaryError = (message, name = "Error", stack) => {
|
|
374
|
+
const toString = () => `${name}: ${message}`;
|
|
375
|
+
Object.setPrototypeOf(toString, null);
|
|
376
|
+
return Object.assign(Object.create(null), {
|
|
377
|
+
name,
|
|
378
|
+
message,
|
|
379
|
+
stack: stack ?? `${name}: ${message}`,
|
|
380
|
+
toString
|
|
381
|
+
});
|
|
382
|
+
};
|
|
383
|
+
var hostErrorInfo = (err) => {
|
|
384
|
+
let msg;
|
|
385
|
+
try {
|
|
386
|
+
const m = err?.message;
|
|
387
|
+
msg = typeof m === "string" ? m : typeof err === "string" ? err : "<non-string error>";
|
|
388
|
+
} catch {
|
|
389
|
+
msg = "<unprintable thrown value>";
|
|
390
|
+
}
|
|
391
|
+
let name = "Error";
|
|
392
|
+
try {
|
|
393
|
+
const n = err?.name;
|
|
394
|
+
if (typeof n === "string")
|
|
395
|
+
name = n;
|
|
396
|
+
} catch {}
|
|
397
|
+
let stack;
|
|
398
|
+
try {
|
|
399
|
+
const s = err?.stack;
|
|
400
|
+
if (typeof s === "string")
|
|
401
|
+
stack = s;
|
|
402
|
+
} catch {}
|
|
403
|
+
return { msg, name, stack };
|
|
404
|
+
};
|
|
405
|
+
var boundaryErrorInfo = (err) => {
|
|
406
|
+
let name = "";
|
|
407
|
+
try {
|
|
408
|
+
const n = err?.name;
|
|
409
|
+
if (typeof n === "string")
|
|
410
|
+
name = n;
|
|
411
|
+
} catch {}
|
|
412
|
+
let msg;
|
|
413
|
+
try {
|
|
414
|
+
const m = err?.message;
|
|
415
|
+
msg = typeof m === "string" ? m : typeof err === "string" ? err : "<non-string reason>";
|
|
416
|
+
} catch {
|
|
417
|
+
msg = "<unprintable>";
|
|
418
|
+
}
|
|
419
|
+
return { name, msg };
|
|
420
|
+
};
|
|
421
|
+
var sanitizeHostSyncFn = (fn) => {
|
|
422
|
+
const wrapped = (...args) => {
|
|
423
|
+
try {
|
|
424
|
+
return fn(...args);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const { msg, name, stack } = hostErrorInfo(err);
|
|
427
|
+
throw vmBoundaryError(msg, name, stack);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
Object.setPrototypeOf(wrapped, null);
|
|
431
|
+
return wrapped;
|
|
432
|
+
};
|
|
433
|
+
var sanitizeHostAsyncFn = (fn) => {
|
|
434
|
+
const wrapped = async (...args) => {
|
|
435
|
+
try {
|
|
436
|
+
return await fn(...args);
|
|
437
|
+
} catch (err) {
|
|
438
|
+
const { msg, name, stack } = hostErrorInfo(err);
|
|
439
|
+
throw vmBoundaryError(msg, name, stack);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
Object.setPrototypeOf(wrapped, null);
|
|
443
|
+
return wrapped;
|
|
444
|
+
};
|
|
445
|
+
var nullProtoHostFn = (fn) => {
|
|
446
|
+
Object.setPrototypeOf(fn, null);
|
|
447
|
+
try {
|
|
448
|
+
delete fn.constructor;
|
|
449
|
+
} catch {}
|
|
450
|
+
try {
|
|
451
|
+
delete fn.prototype;
|
|
452
|
+
} catch {}
|
|
453
|
+
return fn;
|
|
454
|
+
};
|
|
455
|
+
var isNode = (v) => typeof v === "object" && v !== null && typeof v.type === "string";
|
|
456
|
+
var walkWithAncestors = (node, visit, ancestors) => {
|
|
457
|
+
visit(node, ancestors);
|
|
458
|
+
ancestors.push(node);
|
|
459
|
+
for (const key of Object.keys(node)) {
|
|
460
|
+
if (key === "loc" || key === "range")
|
|
461
|
+
continue;
|
|
462
|
+
const value = node[key];
|
|
463
|
+
if (Array.isArray(value)) {
|
|
464
|
+
for (const child of value) {
|
|
465
|
+
if (isNode(child))
|
|
466
|
+
walkWithAncestors(child, visit, ancestors);
|
|
467
|
+
}
|
|
468
|
+
} else if (isNode(value)) {
|
|
469
|
+
walkWithAncestors(value, visit, ancestors);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
ancestors.pop();
|
|
473
|
+
};
|
|
474
|
+
var nearestFunction = (ancestors) => {
|
|
475
|
+
for (let i = ancestors.length - 1;i >= 0; i--) {
|
|
476
|
+
const node = ancestors[i];
|
|
477
|
+
if (node !== undefined && (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression")) {
|
|
478
|
+
return node;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
};
|
|
483
|
+
var REWRITE_PREFIX = `(async () => {'use strict';
|
|
484
|
+
`;
|
|
485
|
+
var REWRITE_SUFFIX = `
|
|
486
|
+
})()`;
|
|
487
|
+
var rewriteAwaits = (body) => {
|
|
488
|
+
const wrapped = `${REWRITE_PREFIX}${body}${REWRITE_SUFFIX}`;
|
|
489
|
+
const ast = acorn.parse(wrapped, {
|
|
490
|
+
ecmaVersion: "latest",
|
|
491
|
+
sourceType: "script",
|
|
492
|
+
allowHashBang: true
|
|
493
|
+
});
|
|
494
|
+
const edits = [];
|
|
495
|
+
const wrapSpan = (node) => {
|
|
496
|
+
if (!node)
|
|
497
|
+
return;
|
|
498
|
+
edits.push([node.start, ` ${RESERVED_IDENTIFIER_PREFIX}((`], [node.end, "))"]);
|
|
499
|
+
};
|
|
500
|
+
walkWithAncestors(ast, (node, ancestors) => {
|
|
501
|
+
if (typeof node.name === "string" && node.name.startsWith(RESERVED_IDENTIFIER_PREFIX)) {
|
|
502
|
+
throw new SyntaxError(`Identifier '${node.name}' is reserved.`);
|
|
503
|
+
}
|
|
504
|
+
switch (node.type) {
|
|
505
|
+
case "WithStatement":
|
|
506
|
+
throw new SyntaxError("'with' statements are not supported in workflow scripts.");
|
|
507
|
+
case "VariableDeclaration":
|
|
508
|
+
if (node.kind === "await using") {
|
|
509
|
+
throw new SyntaxError("'await using' declarations are not supported in workflow scripts.");
|
|
510
|
+
}
|
|
511
|
+
break;
|
|
512
|
+
case "AwaitExpression":
|
|
513
|
+
wrapSpan(node.argument);
|
|
514
|
+
break;
|
|
515
|
+
case "ArrowFunctionExpression":
|
|
516
|
+
if (node.async === true && node.expression === true) {
|
|
517
|
+
wrapSpan(node.body);
|
|
518
|
+
}
|
|
519
|
+
break;
|
|
520
|
+
case "ForOfStatement":
|
|
521
|
+
if (node.await === true) {
|
|
522
|
+
edits.push([node.right.start, ` ${RESERVED_IDENTIFIER_PREFIX}a((`], [node.right.end, "))"]);
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
case "ReturnStatement": {
|
|
526
|
+
const fn = nearestFunction(ancestors);
|
|
527
|
+
if (fn?.async !== true)
|
|
528
|
+
break;
|
|
529
|
+
if (fn.generator === true) {
|
|
530
|
+
if (node.argument) {
|
|
531
|
+
edits.push([node.argument.start, ` await ${RESERVED_IDENTIFIER_PREFIX}((`], [node.argument.end, "))"]);
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
wrapSpan(node.argument);
|
|
535
|
+
}
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
case "YieldExpression": {
|
|
539
|
+
const fn = nearestFunction(ancestors);
|
|
540
|
+
if (!(fn?.async === true && fn.generator === true))
|
|
541
|
+
break;
|
|
542
|
+
if (node.delegate === true) {
|
|
543
|
+
if (node.argument) {
|
|
544
|
+
edits.push([node.argument.start, ` ${RESERVED_IDENTIFIER_PREFIX}a((`], [node.argument.end, "))"]);
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
wrapSpan(node.argument);
|
|
548
|
+
}
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}, []);
|
|
553
|
+
if (edits.length === 0)
|
|
554
|
+
return body;
|
|
555
|
+
edits.sort((a, b) => b[0] - a[0]);
|
|
556
|
+
let out = wrapped;
|
|
557
|
+
for (const [pos, text] of edits) {
|
|
558
|
+
out = out.slice(0, pos) + text + out.slice(pos);
|
|
559
|
+
}
|
|
560
|
+
return out.slice(REWRITE_PREFIX.length, out.length - REWRITE_SUFFIX.length);
|
|
561
|
+
};
|
|
562
|
+
var P = RESERVED_IDENTIFIER_PREFIX;
|
|
563
|
+
var ASYNC_ITERATOR_HELPER = `${P}it => ({[Symbol.asyncIterator](){const ${P}ai = ${P}it[Symbol.asyncIterator];if (${P}ai != null && typeof ${P}ai !== 'function') throw new TypeError('@@asyncIterator is not a function');const ${P}i = ${P}ai != null ? ${P}ai.call(${P}it) : ${P}it[Symbol.iterator]();if (${P}i === null || (typeof ${P}i !== 'object' && typeof ${P}i !== 'function')) throw new TypeError('Iterator is not an object');const ${P}nxt = ${P}i.next;if (typeof ${P}nxt !== 'function') throw new TypeError('Iterator.next is not a function');const ${P}ret = ${P}i.return;const ${P}thr = ${P}i.throw;const ${P}w = s => ${P}(s).then(s => { if (s === null || (typeof s !== 'object' && typeof s !== 'function')) throw new TypeError('Iterator result is not an object'); const done = s.done; return ${P}(s.value).then(value => ({value, done})) });return {next:v=>${P}w(${P}nxt.call(${P}i,v)),return:v=>${P}w(typeof ${P}ret==='function'?${P}ret.call(${P}i,v):{value:v,done:true}),throw:e=>typeof ${P}thr==='function'?${P}w(${P}thr.call(${P}i,e)):${P}(typeof ${P}ret==='function'?${P}ret.call(${P}i):undefined).then(()=>{throw new TypeError('The iterator does not provide a throw method')})}}})`;
|
|
564
|
+
var compileScript = (scriptBody) => {
|
|
565
|
+
try {
|
|
566
|
+
Function(`async function _check() {'use strict';
|
|
567
|
+
${scriptBody}
|
|
568
|
+
}`);
|
|
569
|
+
const rewritten = rewriteAwaits(scriptBody);
|
|
570
|
+
const source = `((${P} => ((${P}a) => async () => {'use strict';
|
|
571
|
+
${rewritten}
|
|
572
|
+
})(${ASYNC_ITERATOR_HELPER}))(Promise.resolve.bind(Promise)))()`;
|
|
573
|
+
return {
|
|
574
|
+
ok: true,
|
|
575
|
+
vmScript: new vm.Script(source, {
|
|
576
|
+
filename: "workflow.js",
|
|
577
|
+
importModuleDynamically: () => {
|
|
578
|
+
throw vmBoundaryError("import() is not available in workflow scripts.");
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
};
|
|
582
|
+
} catch (err) {
|
|
583
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
584
|
+
return { ok: false, error: `SyntaxError: ${msg}` };
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
var NOW_ERR = "Date.now() / new Date() are unavailable in workflow scripts (breaks resume). Stamp results after the workflow returns, or pass timestamps via args.";
|
|
588
|
+
var RANDOM_ERR = "Math.random() is unavailable in workflow scripts (breaks resume). For N independent samples, include the index in the agent label or prompt.";
|
|
589
|
+
var CLOCK_SHIM_SRC = `(() => {
|
|
590
|
+
const NOW_ERR = ${JSON.stringify(NOW_ERR)};
|
|
591
|
+
const RANDOM_ERR = ${JSON.stringify(RANDOM_ERR)};
|
|
592
|
+
Math.random = function random() { throw new Error(RANDOM_ERR) };
|
|
593
|
+
const RealDate = Date;
|
|
594
|
+
RealDate.now = function now() { throw new Error(NOW_ERR) };
|
|
595
|
+
function ShimDate(...a) {
|
|
596
|
+
if (!new.target) throw new Error(NOW_ERR); // bare Date() → now-string
|
|
597
|
+
if (a.length === 0) throw new Error(NOW_ERR);
|
|
598
|
+
return Reflect.construct(RealDate, a, new.target);
|
|
599
|
+
}
|
|
600
|
+
ShimDate.now = RealDate.now;
|
|
601
|
+
ShimDate.parse = RealDate.parse;
|
|
602
|
+
ShimDate.UTC = RealDate.UTC;
|
|
603
|
+
ShimDate.prototype = RealDate.prototype;
|
|
604
|
+
// Close the (new Date(x)).constructor backdoor to RealDate.now — point
|
|
605
|
+
// .constructor at the shim, then freeze RealDate so it can't be undone.
|
|
606
|
+
RealDate.prototype.constructor = ShimDate;
|
|
607
|
+
Object.freeze(RealDate);
|
|
608
|
+
globalThis.Date = ShimDate;
|
|
609
|
+
})()`;
|
|
610
|
+
var HARDEN_SRC = `(() => {
|
|
611
|
+
Object.defineProperty(Error, 'prepareStackTrace', {
|
|
612
|
+
value: (err, sites) => String(err.stack ?? err),
|
|
613
|
+
writable: false, configurable: false,
|
|
614
|
+
});
|
|
615
|
+
// Delete globals with no REPL use case that either run callbacks on the
|
|
616
|
+
// host event loop outside any try/catch (FinalizationRegistry — same
|
|
617
|
+
// DoS shape as a throwing setTimeout callback) or expose shared-memory
|
|
618
|
+
// primitives (Atomics/SharedArrayBuffer — no cross-realm use, pure
|
|
619
|
+
// attack-surface reduction).
|
|
620
|
+
for (const g of ['ShadowRealm', 'WebAssembly', 'FinalizationRegistry',
|
|
621
|
+
'WeakRef', 'Atomics', 'SharedArrayBuffer',
|
|
622
|
+
'queueMicrotask',
|
|
623
|
+
// eval is NOT deleted here — hardenVMIntrinsics is
|
|
624
|
+
// shared with REPLTool (codeGeneration:{strings:true}).
|
|
625
|
+
// WorkflowTool blocks eval via codeGeneration:false.
|
|
626
|
+
// JSC debug/shell globals — present only if
|
|
627
|
+
// JSC_useDollarVM=1 or similar, but $vm is a full
|
|
628
|
+
// escape (createGlobalObject, addressOf, runScript).
|
|
629
|
+
'$vm', 'gc', 'edenGC', 'fullGC', 'print', 'readFile',
|
|
630
|
+
'Loader']) {
|
|
631
|
+
delete globalThis[g];
|
|
632
|
+
}
|
|
633
|
+
// SES-style enable-property-override: convert common shadowed data props
|
|
634
|
+
// to accessors whose setter defineProperty's onto the receiver. Otherwise
|
|
635
|
+
// freezing makes them non-writable, and [[Set]] on an instance (e.g.
|
|
636
|
+
// "this.name='X'" in an Error subclass ctor) throws in strict / no-ops in
|
|
637
|
+
// sloppy — the TC39 "override mistake".
|
|
638
|
+
function enableOverride(proto, key) {
|
|
639
|
+
const d = Object.getOwnPropertyDescriptor(proto, key);
|
|
640
|
+
if (!d || 'get' in d) return;
|
|
641
|
+
const v = d.value;
|
|
642
|
+
Object.defineProperty(proto, key, {
|
|
643
|
+
get() { return v },
|
|
644
|
+
set(nv) {
|
|
645
|
+
if (this === proto) return;
|
|
646
|
+
Object.defineProperty(this, key, { value: nv, writable: true, enumerable: true, configurable: true });
|
|
647
|
+
},
|
|
648
|
+
enumerable: d.enumerable, configurable: true,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
const errorCtors = [Error, EvalError, RangeError, ReferenceError, SyntaxError, TypeError, URIError, AggregateError, globalThis.SuppressedError].filter(Boolean);
|
|
652
|
+
const errorProtos = errorCtors.map(C => C.prototype);
|
|
653
|
+
for (const [proto, keys] of [
|
|
654
|
+
// All Object.prototype data props — Object.assign({}, {propertyIsEnumerable:x})
|
|
655
|
+
// and friends would otherwise throw post-freeze. Accessor props (__proto__,
|
|
656
|
+
// __define/lookupGetter__) are skipped by the 'get' in d guard above.
|
|
657
|
+
[Object.prototype, Object.getOwnPropertyNames(Object.prototype)],
|
|
658
|
+
[Function.prototype, ['toString', 'constructor', 'name', 'length']],
|
|
659
|
+
[Array.prototype, ['toString', 'constructor']],
|
|
660
|
+
[Date.prototype, ['toString', 'toLocaleString', 'valueOf', 'constructor']],
|
|
661
|
+
...errorProtos.map(p => [p, ['name', 'message', 'toString', 'constructor']]),
|
|
662
|
+
]) for (const k of keys) enableOverride(proto, k);
|
|
663
|
+
// Error subclasses each have their own .prototype; freezing only Error
|
|
664
|
+
// leaves TypeError.prototype.then etc. writable. SuppressedError is
|
|
665
|
+
// from the explicit-resource-management proposal (bun/JSC ship it).
|
|
666
|
+
for (const C of [Promise, Object, Array, Function, globalThis.Iterator,
|
|
667
|
+
Map, Set, WeakMap, WeakSet,
|
|
668
|
+
String, Number, Boolean, Symbol, BigInt,
|
|
669
|
+
Date, RegExp, ArrayBuffer, DataView,
|
|
670
|
+
...errorCtors,
|
|
671
|
+
typeof URL !== 'undefined' ? URL : undefined,
|
|
672
|
+
].filter(Boolean)) {
|
|
673
|
+
Object.freeze(C);
|
|
674
|
+
Object.freeze(C.prototype);
|
|
675
|
+
}
|
|
676
|
+
// %TypedArray% (shared prototype of all typed arrays) + each concrete.
|
|
677
|
+
for (const C of [Object.getPrototypeOf(Int8Array),
|
|
678
|
+
Int8Array, Uint8Array, Uint8ClampedArray,
|
|
679
|
+
Int16Array, Uint16Array, Int32Array, Uint32Array,
|
|
680
|
+
globalThis.Float16Array, Float32Array, Float64Array,
|
|
681
|
+
BigInt64Array, BigUint64Array].filter(Boolean)) {
|
|
682
|
+
Object.freeze(C);
|
|
683
|
+
Object.freeze(C.prototype);
|
|
684
|
+
}
|
|
685
|
+
// %AsyncFunction%, %GeneratorFunction%, %AsyncGeneratorFunction% and
|
|
686
|
+
// their .prototype are not reachable as globals — walk from instances.
|
|
687
|
+
for (const f of [async()=>{}, function*(){}, async function*(){}]) {
|
|
688
|
+
Object.freeze(f.constructor);
|
|
689
|
+
Object.freeze(f.constructor.prototype);
|
|
690
|
+
}
|
|
691
|
+
for (const C of [globalThis.DisposableStack, globalThis.AsyncDisposableStack,
|
|
692
|
+
globalThis.Intl].filter(Boolean)) {
|
|
693
|
+
Object.freeze(C);
|
|
694
|
+
if (C.prototype) Object.freeze(C.prototype);
|
|
695
|
+
}
|
|
696
|
+
// Namespace objects (no .prototype) — VM code could otherwise set
|
|
697
|
+
// JSON.then/Math.then/Reflect.then and any host await on the namespace
|
|
698
|
+
// object (or on a VM value that aliases it) becomes a thenable escape.
|
|
699
|
+
// Proxy has no .prototype but freeze closes Proxy.revocable tampering.
|
|
700
|
+
for (const ns of [JSON, Math, Reflect, Proxy]) Object.freeze(ns);
|
|
701
|
+
// globalThis can't be frozen (populateContext writes to it), but pinning
|
|
702
|
+
// .then as non-configurable undefined prevents the sandbox object itself
|
|
703
|
+
// from becoming a thenable via direct assignment, defineProperty, or
|
|
704
|
+
// registerTool('then',...).
|
|
705
|
+
Object.defineProperty(globalThis, 'then', {
|
|
706
|
+
value: undefined, writable: false, configurable: false,
|
|
707
|
+
});
|
|
708
|
+
// Intl.* sub-constructors each have their own .prototype — freezing the
|
|
709
|
+
// Intl namespace above does NOT freeze Intl.Collator.prototype etc.
|
|
710
|
+
// Same own-property-.then escape shape as Promise.prototype.then if any
|
|
711
|
+
// host code ever awaits an Intl.* instance.
|
|
712
|
+
if (typeof Intl !== 'undefined') {
|
|
713
|
+
for (const k of Object.getOwnPropertyNames(Intl)) {
|
|
714
|
+
const C = Intl[k];
|
|
715
|
+
if (typeof C === 'function') {
|
|
716
|
+
Object.freeze(C);
|
|
717
|
+
if (C.prototype) Object.freeze(C.prototype);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const it of [
|
|
722
|
+
[][Symbol.iterator](),
|
|
723
|
+
''[Symbol.iterator](),
|
|
724
|
+
new Map()[Symbol.iterator](),
|
|
725
|
+
new Set()[Symbol.iterator](),
|
|
726
|
+
'a'.matchAll(/a/g),
|
|
727
|
+
// Iterator helpers (map/from) are stage-4 but guard for older runtimes.
|
|
728
|
+
...(typeof Iterator !== 'undefined' && Iterator.from ? [
|
|
729
|
+
[].values().map(x=>x),
|
|
730
|
+
// %WrapForValidIteratorPrototype% — Iterator.from(non-Iterator) wraps
|
|
731
|
+
// via a distinct intrinsic prototype not reachable from any other path.
|
|
732
|
+
Iterator.from({next:()=>({done:true})}),
|
|
733
|
+
] : []),
|
|
734
|
+
(function*(){})(),
|
|
735
|
+
(async function*(){})(),
|
|
736
|
+
// %SegmentsPrototype% + %SegmentIteratorPrototype% — host for..of on a
|
|
737
|
+
// VM Segments object would otherwise see a writable .then on the chain.
|
|
738
|
+
...(typeof Intl !== 'undefined' && Intl.Segmenter ? (s => [s, s[Symbol.iterator]()])(new Intl.Segmenter().segment('a')) : []),
|
|
739
|
+
]) {
|
|
740
|
+
for (let p = Object.getPrototypeOf(it); p; p = Object.getPrototypeOf(p)) {
|
|
741
|
+
Object.freeze(p);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
})()`;
|
|
745
|
+
var createSandboxContext = (contextObject) => {
|
|
746
|
+
const ctx = vm.createContext(contextObject, {
|
|
747
|
+
codeGeneration: { strings: false, wasm: false }
|
|
748
|
+
});
|
|
749
|
+
vm.runInContext(CLOCK_SHIM_SRC, ctx);
|
|
750
|
+
vm.runInContext(HARDEN_SRC, ctx);
|
|
751
|
+
return ctx;
|
|
752
|
+
};
|
|
753
|
+
var makeInContextHookWrapper = (ctx) => vm.runInContext("(hostFn => async (...a) => hostFn(...a))", ctx);
|
|
754
|
+
var makeInContextApplier = (ctx) => vm.runInContext("((fn, ...args) => fn(...args))", ctx);
|
|
755
|
+
var makeBoxedSettler = (ctx) => vm.runInContext("(async v => ({__proto__: null, v: await v}))", ctx);
|
|
756
|
+
var makeInContextInvoker = (ctx) => vm.runInContext("(fn => { fn() })", ctx);
|
|
757
|
+
var makeInContextCloner = (ctx) => vm.runInContext(`(() => {
|
|
758
|
+
const _WeakMap = WeakMap, _isArray = Array.isArray,
|
|
759
|
+
_keys = Object.keys, _defineProperty = Object.defineProperty,
|
|
760
|
+
_Error = Error, _isSafeInteger = Number.isSafeInteger,
|
|
761
|
+
_Symbol = Symbol
|
|
762
|
+
// Module-private symbol tagging the boundary-cap error so the
|
|
763
|
+
// per-element/per-key catch blocks below can tell it apart from an
|
|
764
|
+
// INCIDENTAL throw (a hostile getter / Proxy trap on a single value).
|
|
765
|
+
// The cap error must propagate out of the whole clone at any nesting
|
|
766
|
+
// depth (matching top-level behavior); incidental throws still degrade
|
|
767
|
+
// that one slot to undefined. The symbol is unreachable from user code,
|
|
768
|
+
// so it can't be forged; the isCap read is wrapped so a hostile thrown
|
|
769
|
+
// value's get trap can at most fake "true" (→ benign propagation),
|
|
770
|
+
// never run code we act on.
|
|
771
|
+
const _CAP = _Symbol('vmArrayCap')
|
|
772
|
+
function capErr(msg) {
|
|
773
|
+
const e = new _Error(msg)
|
|
774
|
+
try { e[_CAP] = true } catch {}
|
|
775
|
+
return e
|
|
776
|
+
}
|
|
777
|
+
function isCap(e) {
|
|
778
|
+
try { return typeof e === 'object' && e !== null && e[_CAP] === true } catch { return false }
|
|
779
|
+
}
|
|
780
|
+
return (hostVal) => {
|
|
781
|
+
const seen = new _WeakMap()
|
|
782
|
+
function c(v) {
|
|
783
|
+
if (typeof v === 'function') return undefined
|
|
784
|
+
if (v === null || typeof v !== 'object') return v
|
|
785
|
+
const hit = seen.get(v); if (hit !== undefined) return hit
|
|
786
|
+
if (_isArray(v)) {
|
|
787
|
+
// Read length ONCE — re-reading v.length per iteration lets a
|
|
788
|
+
// Proxy length getter that increments make i < len never false
|
|
789
|
+
// (infinite host-thread hang outside the VM sync-timeout).
|
|
790
|
+
const len = v.length
|
|
791
|
+
if (typeof len !== 'number' || !_isSafeInteger(len)) {
|
|
792
|
+
throw capErr('array length is not a safe integer across the workflow VM boundary')
|
|
793
|
+
}
|
|
794
|
+
if (len > ${MAX_VM_BOUNDARY_ITEMS}) {
|
|
795
|
+
throw capErr('array length ' + len + ' exceeds the maximum of ${MAX_VM_BOUNDARY_ITEMS} supported across the workflow VM boundary')
|
|
796
|
+
}
|
|
797
|
+
const out = []; seen.set(v, out)
|
|
798
|
+
for (let i = 0; i < len; i++) {
|
|
799
|
+
try { out[i] = c(v[i]) } catch (e) { if (isCap(e)) throw e; out[i] = undefined }
|
|
800
|
+
}
|
|
801
|
+
return out
|
|
802
|
+
}
|
|
803
|
+
const out = {}; seen.set(v, out)
|
|
804
|
+
let ks; try { ks = _keys(v) } catch { return out }
|
|
805
|
+
for (const k of ks) {
|
|
806
|
+
if (k === '__proto__') continue
|
|
807
|
+
try {
|
|
808
|
+
const vk = v[k]
|
|
809
|
+
if (typeof vk === 'function') continue
|
|
810
|
+
_defineProperty(out, k, { value: c(vk), writable: true, enumerable: true, configurable: true })
|
|
811
|
+
} catch (e) { if (isCap(e)) throw e }
|
|
812
|
+
}
|
|
813
|
+
return out
|
|
814
|
+
}
|
|
815
|
+
return c(hostVal)
|
|
816
|
+
}
|
|
817
|
+
})()`, ctx);
|
|
818
|
+
var cloneJsonIntoContext = (ctx, json) => vm.runInContext(`JSON.parse(${JSON.stringify(json)})`, ctx);
|
|
819
|
+
var makeDeterministicTimers = (signal) => {
|
|
820
|
+
const pending = new Set;
|
|
821
|
+
let invoke = (fn) => fn();
|
|
822
|
+
signal?.addEventListener("abort", () => {
|
|
823
|
+
for (const id of pending)
|
|
824
|
+
clearTimeout(id);
|
|
825
|
+
pending.clear();
|
|
826
|
+
}, { once: true });
|
|
827
|
+
return {
|
|
828
|
+
setTimeout: sanitizeHostSyncFn((cb, ms) => {
|
|
829
|
+
if (signal?.aborted)
|
|
830
|
+
return 0;
|
|
831
|
+
const id = Number(setTimeout(() => {
|
|
832
|
+
try {
|
|
833
|
+
invoke(cb);
|
|
834
|
+
} catch {}
|
|
835
|
+
}, ms));
|
|
836
|
+
pending.add(id);
|
|
837
|
+
return id;
|
|
838
|
+
}),
|
|
839
|
+
clearTimeout: sanitizeHostSyncFn((id) => {
|
|
840
|
+
pending.delete(id);
|
|
841
|
+
clearTimeout(id);
|
|
842
|
+
}),
|
|
843
|
+
bindVMInvoke: (fn) => {
|
|
844
|
+
invoke = fn;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
};
|
|
848
|
+
var makeConsoleShim = (log) => {
|
|
849
|
+
const fmt = (args) => args.map((a) => {
|
|
850
|
+
if (typeof a === "string")
|
|
851
|
+
return a;
|
|
852
|
+
try {
|
|
853
|
+
return JSON.stringify(a);
|
|
854
|
+
} catch {
|
|
855
|
+
return `[${typeof a}]`;
|
|
856
|
+
}
|
|
857
|
+
}).join(" ");
|
|
858
|
+
const sink = (prefix) => sanitizeHostSyncFn((...args) => log(prefix + fmt(args)));
|
|
859
|
+
return Object.assign(Object.create(null), {
|
|
860
|
+
log: sink(""),
|
|
861
|
+
info: sink(""),
|
|
862
|
+
debug: sink(""),
|
|
863
|
+
error: sink("[error] "),
|
|
864
|
+
warn: sink("[warn] ")
|
|
865
|
+
});
|
|
866
|
+
};
|
|
867
|
+
var VM_ARRAY_CAP = Symbol("vmArrayCap");
|
|
868
|
+
var capError = (message) => {
|
|
869
|
+
const e = new Error(message);
|
|
870
|
+
try {
|
|
871
|
+
e[VM_ARRAY_CAP] = true;
|
|
872
|
+
} catch {}
|
|
873
|
+
return e;
|
|
874
|
+
};
|
|
875
|
+
var isCapError = (e) => {
|
|
876
|
+
try {
|
|
877
|
+
return typeof e === "object" && e !== null && e[VM_ARRAY_CAP] === true;
|
|
878
|
+
} catch {
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
var readBoundaryArrayLength = (arr) => {
|
|
883
|
+
let len;
|
|
884
|
+
try {
|
|
885
|
+
len = arr.length;
|
|
886
|
+
} catch {
|
|
887
|
+
throw new Error("unable to read array length across the workflow VM boundary");
|
|
888
|
+
}
|
|
889
|
+
if (typeof len !== "number" || !Number.isSafeInteger(len)) {
|
|
890
|
+
throw capError("array length is not a safe integer across the workflow VM boundary");
|
|
891
|
+
}
|
|
892
|
+
if (len > MAX_VM_BOUNDARY_ITEMS) {
|
|
893
|
+
throw capError(`array length ${len} exceeds the maximum of ${MAX_VM_BOUNDARY_ITEMS} supported across the workflow VM boundary`);
|
|
894
|
+
}
|
|
895
|
+
return len;
|
|
896
|
+
};
|
|
897
|
+
var extractBoundaryArray = (value) => {
|
|
898
|
+
if (value === null || typeof value !== "object")
|
|
899
|
+
return [];
|
|
900
|
+
const len = readBoundaryArrayLength(value);
|
|
901
|
+
const out = [];
|
|
902
|
+
for (let i = 0;i < len; i++) {
|
|
903
|
+
try {
|
|
904
|
+
out[i] = value[i];
|
|
905
|
+
} catch {
|
|
906
|
+
out[i] = undefined;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return out;
|
|
910
|
+
};
|
|
911
|
+
var hostSafeCopy = (value, seen = new WeakMap) => {
|
|
912
|
+
if (typeof value === "function")
|
|
913
|
+
return;
|
|
914
|
+
if (value === null || typeof value !== "object")
|
|
915
|
+
return value;
|
|
916
|
+
const hit = seen.get(value);
|
|
917
|
+
if (hit !== undefined)
|
|
918
|
+
return hit;
|
|
919
|
+
if (Array.isArray(value)) {
|
|
920
|
+
const out2 = [];
|
|
921
|
+
seen.set(value, out2);
|
|
922
|
+
const len = readBoundaryArrayLength(value);
|
|
923
|
+
for (let i = 0;i < len; i++) {
|
|
924
|
+
try {
|
|
925
|
+
out2[i] = hostSafeCopy(value[i], seen);
|
|
926
|
+
} catch (e) {
|
|
927
|
+
if (isCapError(e))
|
|
928
|
+
throw e;
|
|
929
|
+
out2[i] = undefined;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return out2;
|
|
933
|
+
}
|
|
934
|
+
const out = {};
|
|
935
|
+
seen.set(value, out);
|
|
936
|
+
let keys;
|
|
937
|
+
try {
|
|
938
|
+
keys = Object.keys(value);
|
|
939
|
+
} catch {
|
|
940
|
+
return out;
|
|
941
|
+
}
|
|
942
|
+
for (const k of keys) {
|
|
943
|
+
if (k === "__proto__")
|
|
944
|
+
continue;
|
|
945
|
+
try {
|
|
946
|
+
const v = value[k];
|
|
947
|
+
if (typeof v === "function")
|
|
948
|
+
continue;
|
|
949
|
+
out[k] = hostSafeCopy(v, seen);
|
|
950
|
+
} catch (e) {
|
|
951
|
+
if (isCapError(e))
|
|
952
|
+
throw e;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return out;
|
|
956
|
+
};
|
|
957
|
+
var runScript = (vmScript, ctx, syncTimeoutMs = SYNC_RUN_TIMEOUT_MS) => vmScript.runInContext(ctx, { timeout: syncTimeoutMs });
|
|
958
|
+
var describeScriptError = (err, frames = 5) => {
|
|
959
|
+
if (!(err instanceof Error))
|
|
960
|
+
return String(err);
|
|
961
|
+
if (!err.stack)
|
|
962
|
+
return err.message;
|
|
963
|
+
const lines = err.stack.split(`
|
|
964
|
+
`);
|
|
965
|
+
const head = lines[0] ?? err.message;
|
|
966
|
+
const atFrames = lines.slice(1).filter((l) => l.trim().startsWith("at "));
|
|
967
|
+
if (atFrames.length <= frames)
|
|
968
|
+
return err.stack;
|
|
969
|
+
return [head, ...atFrames.slice(0, frames)].join(`
|
|
970
|
+
`);
|
|
971
|
+
};
|
|
972
|
+
var formatScriptError = (err) => {
|
|
973
|
+
let stack;
|
|
974
|
+
try {
|
|
975
|
+
const s = err?.stack;
|
|
976
|
+
stack = typeof s === "string" ? s : undefined;
|
|
977
|
+
} catch {
|
|
978
|
+
stack = undefined;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
if (stack !== undefined) {
|
|
982
|
+
const lines = stack.split(`
|
|
983
|
+
`);
|
|
984
|
+
const atFrames = lines.slice(1).filter((l) => l.trim().startsWith("at "));
|
|
985
|
+
return atFrames.length <= 5 ? stack : [lines[0] ?? "", ...atFrames.slice(0, 5)].join(`
|
|
986
|
+
`);
|
|
987
|
+
}
|
|
988
|
+
return describeScriptError(err);
|
|
989
|
+
} catch {
|
|
990
|
+
return "<unprintable error>";
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
// src/runtime/semaphore.ts
|
|
995
|
+
var makeSemaphore = (permits) => {
|
|
996
|
+
let free = Math.max(1, permits);
|
|
997
|
+
const queue = [];
|
|
998
|
+
const acquire = () => new Promise((resolve) => {
|
|
999
|
+
if (free > 0) {
|
|
1000
|
+
free -= 1;
|
|
1001
|
+
resolve();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
queue.push(() => {
|
|
1005
|
+
resolve();
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
const release = () => {
|
|
1009
|
+
const next = queue.shift();
|
|
1010
|
+
if (next !== undefined) {
|
|
1011
|
+
next();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
free += 1;
|
|
1015
|
+
};
|
|
1016
|
+
return {
|
|
1017
|
+
withPermit: async (fn) => {
|
|
1018
|
+
await acquire();
|
|
1019
|
+
try {
|
|
1020
|
+
return await fn();
|
|
1021
|
+
} finally {
|
|
1022
|
+
release();
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
// src/runtime/structuredSchema.ts
|
|
1029
|
+
import { Ajv } from "ajv";
|
|
1030
|
+
var compile = (schema) => {
|
|
1031
|
+
try {
|
|
1032
|
+
const ajv = new Ajv({ allErrors: true });
|
|
1033
|
+
if (!ajv.validateSchema(schema)) {
|
|
1034
|
+
return { error: ajv.errorsText(ajv.errors) };
|
|
1035
|
+
}
|
|
1036
|
+
const validateFn = ajv.compile(schema);
|
|
1037
|
+
return {
|
|
1038
|
+
validate: (input) => {
|
|
1039
|
+
if (validateFn(input))
|
|
1040
|
+
return { ok: true };
|
|
1041
|
+
const detail = validateFn.errors?.map((e) => `${e.instancePath || "root"}: ${e.message}`).join(", ");
|
|
1042
|
+
return {
|
|
1043
|
+
ok: false,
|
|
1044
|
+
message: `Output does not match required schema: ${detail}`
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
};
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
var memo = new WeakMap;
|
|
1053
|
+
var compileStructuredSchema = (schema) => {
|
|
1054
|
+
if (typeof schema !== "object" || schema === null)
|
|
1055
|
+
return compile(schema);
|
|
1056
|
+
const hit = memo.get(schema);
|
|
1057
|
+
if (hit !== undefined)
|
|
1058
|
+
return hit;
|
|
1059
|
+
const compiled = compile(schema);
|
|
1060
|
+
memo.set(schema, compiled);
|
|
1061
|
+
return compiled;
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// src/runtime/types.ts
|
|
1065
|
+
class WorkflowAgentCapError extends Error {
|
|
1066
|
+
name = "WorkflowAgentCapError";
|
|
1067
|
+
constructor() {
|
|
1068
|
+
super(AGENT_CAP_MESSAGE);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
class WorkflowBudgetExceededError extends Error {
|
|
1073
|
+
name = "WorkflowBudgetExceededError";
|
|
1074
|
+
spent;
|
|
1075
|
+
total;
|
|
1076
|
+
constructor(spent, total) {
|
|
1077
|
+
super(budgetExceededMessage(spent, total));
|
|
1078
|
+
this.spent = spent;
|
|
1079
|
+
this.total = total;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/runtime/worktree.ts
|
|
1084
|
+
import { execFile } from "node:child_process";
|
|
1085
|
+
import { join as join2 } from "node:path";
|
|
1086
|
+
import { promisify } from "node:util";
|
|
1087
|
+
var execFileAsync = promisify(execFile);
|
|
1088
|
+
var git = async (cwd, args) => {
|
|
1089
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
1090
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1091
|
+
});
|
|
1092
|
+
return stdout;
|
|
1093
|
+
};
|
|
1094
|
+
var worktreePromptSuffix = (path) => `
|
|
1095
|
+
|
|
1096
|
+
You are running in an isolated git worktree at ${path}. Do all of your work inside this directory — changes here do not affect the main checkout until merged.`;
|
|
1097
|
+
var createWorktree = async (repoCwd, runDir, agentIndex) => {
|
|
1098
|
+
const path = join2(runDir, "worktrees", `wf-${agentIndex}`);
|
|
1099
|
+
await git(repoCwd, ["worktree", "add", "--detach", path, "HEAD"]);
|
|
1100
|
+
return {
|
|
1101
|
+
path,
|
|
1102
|
+
cleanupIfClean: async () => {
|
|
1103
|
+
try {
|
|
1104
|
+
const status = await git(path, ["status", "--porcelain"]);
|
|
1105
|
+
if (status.trim().length > 0)
|
|
1106
|
+
return;
|
|
1107
|
+
await git(repoCwd, ["worktree", "remove", "--force", path]);
|
|
1108
|
+
} catch {}
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
// src/runtime/orchestrator.ts
|
|
1114
|
+
var applyEvent = (s, event) => {
|
|
1115
|
+
if (event.type === "workflow_log") {
|
|
1116
|
+
const logs = s.logs.length >= LOG_BUFFER_CAP ? [...s.logs.slice(1), event.message] : [...s.logs, event.message];
|
|
1117
|
+
return { ...s, logs };
|
|
1118
|
+
}
|
|
1119
|
+
if (event.type === "workflow_phase") {
|
|
1120
|
+
if (s.phases.includes(event.title))
|
|
1121
|
+
return s;
|
|
1122
|
+
return { ...s, phases: [...s.phases, event.title] };
|
|
1123
|
+
}
|
|
1124
|
+
const agents = [...s.agents];
|
|
1125
|
+
const at = agents.findIndex((a) => a.index === event.index);
|
|
1126
|
+
const prev = at >= 0 ? agents[at] : undefined;
|
|
1127
|
+
const next = {
|
|
1128
|
+
index: event.index,
|
|
1129
|
+
label: event.label,
|
|
1130
|
+
...event.phaseTitle !== undefined ? { phaseTitle: event.phaseTitle } : prev?.phaseTitle !== undefined ? { phaseTitle: prev.phaseTitle } : {},
|
|
1131
|
+
state: event.state === "start" ? "running" : event.state === "done" ? "done" : "error",
|
|
1132
|
+
...event.cached !== undefined ? { cached: event.cached } : {},
|
|
1133
|
+
...event.tokens !== undefined ? { tokens: event.tokens } : {},
|
|
1134
|
+
...event.toolCalls !== undefined ? { toolCalls: event.toolCalls } : {},
|
|
1135
|
+
...event.error !== undefined ? { error: event.error } : {}
|
|
1136
|
+
};
|
|
1137
|
+
if (at >= 0)
|
|
1138
|
+
agents[at] = next;
|
|
1139
|
+
else
|
|
1140
|
+
agents.push(next);
|
|
1141
|
+
return { ...s, agents };
|
|
1142
|
+
};
|
|
1143
|
+
var createWorkflowHost = (config) => {
|
|
1144
|
+
const { runner, registry, defaultModel } = config;
|
|
1145
|
+
const launch = (params) => {
|
|
1146
|
+
const { runId } = params;
|
|
1147
|
+
const runDir = runDirFor(runId);
|
|
1148
|
+
ensureRunDir(runDir);
|
|
1149
|
+
const journalPath = journalPathFor(runDir);
|
|
1150
|
+
const progressPath = progressPathFor(runDir);
|
|
1151
|
+
const replay = params.resumeFromRunId !== undefined ? buildReplayMap(readJournal(journalPath)) : null;
|
|
1152
|
+
const journal = makeJournal(journalPath);
|
|
1153
|
+
const taskId = "w" + runId.replace(/^wf_/, "").slice(0, 8);
|
|
1154
|
+
const initial = {
|
|
1155
|
+
taskId,
|
|
1156
|
+
runId,
|
|
1157
|
+
status: "running",
|
|
1158
|
+
name: params.parsed.meta.name,
|
|
1159
|
+
description: params.parsed.meta.description,
|
|
1160
|
+
runDir,
|
|
1161
|
+
scriptPath: params.scriptPath,
|
|
1162
|
+
cwd: params.cwd,
|
|
1163
|
+
startTime: Date.now(),
|
|
1164
|
+
agentCount: 0,
|
|
1165
|
+
tokensSpent: 0,
|
|
1166
|
+
phases: (params.parsed.meta.phases ?? []).map((p) => p.title),
|
|
1167
|
+
agents: [],
|
|
1168
|
+
logs: [],
|
|
1169
|
+
failures: []
|
|
1170
|
+
};
|
|
1171
|
+
const abort = registry.register(initial);
|
|
1172
|
+
runWorkflowBody({
|
|
1173
|
+
config,
|
|
1174
|
+
params,
|
|
1175
|
+
runDir,
|
|
1176
|
+
progressPath,
|
|
1177
|
+
journal,
|
|
1178
|
+
replay,
|
|
1179
|
+
abort,
|
|
1180
|
+
runner,
|
|
1181
|
+
defaultModel
|
|
1182
|
+
}).catch((err) => {
|
|
1183
|
+
registry.patch(runId, (s) => s.status === "running" ? {
|
|
1184
|
+
...s,
|
|
1185
|
+
status: "failed",
|
|
1186
|
+
endTime: Date.now(),
|
|
1187
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1188
|
+
} : s);
|
|
1189
|
+
});
|
|
1190
|
+
return { taskId, runId, runDir };
|
|
1191
|
+
};
|
|
1192
|
+
return { launch };
|
|
1193
|
+
};
|
|
1194
|
+
var runWorkflowBody = async (a) => {
|
|
1195
|
+
const { config, params, runDir, progressPath, journal, abort, runner } = a;
|
|
1196
|
+
const { registry } = config;
|
|
1197
|
+
const runId = params.runId;
|
|
1198
|
+
const compiled = compileScript(params.parsed.scriptBody);
|
|
1199
|
+
if (!compiled.ok) {
|
|
1200
|
+
registry.patch(runId, (s) => ({
|
|
1201
|
+
...s,
|
|
1202
|
+
status: "failed",
|
|
1203
|
+
endTime: Date.now(),
|
|
1204
|
+
error: compiled.error
|
|
1205
|
+
}));
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1208
|
+
let agentCount = 0;
|
|
1209
|
+
let prevKey = "";
|
|
1210
|
+
let journalDirty = a.replay === null;
|
|
1211
|
+
let tokensSpent = 0;
|
|
1212
|
+
const failures = [];
|
|
1213
|
+
const phaseIndexByTitle = new Map;
|
|
1214
|
+
let nextPhaseIndex = 0;
|
|
1215
|
+
let currentPhase;
|
|
1216
|
+
let settle = async (v) => ({ v: await v });
|
|
1217
|
+
let callInContext = (fn, ...args) => fn(...args);
|
|
1218
|
+
let cloneIntoVm = (v) => structuredClone(v);
|
|
1219
|
+
const sleep0 = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
1220
|
+
const sleepAbortable = (ms) => new Promise((resolve) => {
|
|
1221
|
+
const t = setTimeout(() => {
|
|
1222
|
+
abort.signal.removeEventListener("abort", onAbort);
|
|
1223
|
+
resolve();
|
|
1224
|
+
}, ms);
|
|
1225
|
+
const onAbort = () => {
|
|
1226
|
+
clearTimeout(t);
|
|
1227
|
+
resolve();
|
|
1228
|
+
};
|
|
1229
|
+
abort.signal.addEventListener("abort", onAbort, { once: true });
|
|
1230
|
+
});
|
|
1231
|
+
for (const p of params.parsed.meta.phases ?? []) {
|
|
1232
|
+
if (!phaseIndexByTitle.has(p.title)) {
|
|
1233
|
+
phaseIndexByTitle.set(p.title, nextPhaseIndex++);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
const emit = (event) => {
|
|
1237
|
+
appendJsonl(progressPath, { ts: Date.now(), ...event });
|
|
1238
|
+
registry.patch(runId, (s) => applyEvent(s, event));
|
|
1239
|
+
};
|
|
1240
|
+
const resolvePhase = (title) => {
|
|
1241
|
+
let idx = phaseIndexByTitle.get(title);
|
|
1242
|
+
if (idx === undefined) {
|
|
1243
|
+
idx = nextPhaseIndex++;
|
|
1244
|
+
phaseIndexByTitle.set(title, idx);
|
|
1245
|
+
}
|
|
1246
|
+
emit({ type: "workflow_phase", index: idx, title });
|
|
1247
|
+
return idx;
|
|
1248
|
+
};
|
|
1249
|
+
const phase = (title) => {
|
|
1250
|
+
currentPhase = String(title);
|
|
1251
|
+
resolvePhase(currentPhase);
|
|
1252
|
+
};
|
|
1253
|
+
const log = (message) => {
|
|
1254
|
+
emit({ type: "workflow_log", message: String(message) });
|
|
1255
|
+
};
|
|
1256
|
+
const recordFailure = (message) => {
|
|
1257
|
+
failures.push(message);
|
|
1258
|
+
registry.patch(runId, (s) => ({
|
|
1259
|
+
...s,
|
|
1260
|
+
failures: [...s.failures, message]
|
|
1261
|
+
}));
|
|
1262
|
+
};
|
|
1263
|
+
const checkCap = () => {
|
|
1264
|
+
if (agentCount >= AGENT_CAP)
|
|
1265
|
+
throw new WorkflowAgentCapError;
|
|
1266
|
+
};
|
|
1267
|
+
const checkBudget = () => {
|
|
1268
|
+
const total = params.tokenBudgetTotal;
|
|
1269
|
+
if (total !== null && total > 0 && tokensSpent >= total) {
|
|
1270
|
+
throw new WorkflowBudgetExceededError(tokensSpent, total);
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
const onTokens = (delta) => {
|
|
1274
|
+
tokensSpent += delta;
|
|
1275
|
+
};
|
|
1276
|
+
const localSem = makeSemaphore(config.maxConcurrency ?? LOCAL_CONCURRENCY);
|
|
1277
|
+
const worktreeSem = makeSemaphore(1);
|
|
1278
|
+
const runOneAgent = async (index, prompt, opts, label, phaseTitle, phaseIndex, stallMs) => {
|
|
1279
|
+
if (opts.isolation === "remote")
|
|
1280
|
+
throw new Error(ERR_ISOLATION_REMOTE);
|
|
1281
|
+
if (opts.agentType !== undefined) {
|
|
1282
|
+
throw new Error(ERR_AGENT_TYPE_UNAVAILABLE);
|
|
1283
|
+
}
|
|
1284
|
+
const hasSchema = opts.schema !== undefined;
|
|
1285
|
+
const contract = hasSchema ? SUBAGENT_CONTRACT_SCHEMA : SUBAGENT_CONTRACT_TEXT;
|
|
1286
|
+
const model = opts.model ?? config.defaultModel;
|
|
1287
|
+
const agentId = `${runId}-a${index}`;
|
|
1288
|
+
let validate;
|
|
1289
|
+
let schemaDoc;
|
|
1290
|
+
if (hasSchema) {
|
|
1291
|
+
const compiledSchema = compileStructuredSchema(opts.schema);
|
|
1292
|
+
if ("error" in compiledSchema) {
|
|
1293
|
+
throw new TypeError(`agent({schema}) received an invalid JSON Schema: ${compiledSchema.error}`);
|
|
1294
|
+
}
|
|
1295
|
+
validate = compiledSchema.validate;
|
|
1296
|
+
schemaDoc = opts.schema !== null && typeof opts.schema === "object" ? opts.schema : undefined;
|
|
1297
|
+
}
|
|
1298
|
+
let effCwd = params.cwd;
|
|
1299
|
+
let effPrompt = prompt;
|
|
1300
|
+
let worktree;
|
|
1301
|
+
if (opts.isolation === "worktree") {
|
|
1302
|
+
try {
|
|
1303
|
+
worktree = await worktreeSem.withPermit(() => createWorktree(params.cwd, runDir, index));
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1306
|
+
throw new Error(`agent({isolation:'worktree'}): ${msg}`);
|
|
1307
|
+
}
|
|
1308
|
+
effCwd = worktree.path;
|
|
1309
|
+
effPrompt = prompt + worktreePromptSuffix(worktree.path);
|
|
1310
|
+
}
|
|
1311
|
+
const attempt = async () => {
|
|
1312
|
+
const attemptAbort = new AbortController;
|
|
1313
|
+
const onRunAbort = () => attemptAbort.abort();
|
|
1314
|
+
abort.signal.addEventListener("abort", onRunAbort, { once: true });
|
|
1315
|
+
let watchdog;
|
|
1316
|
+
const resetWatchdog = () => {
|
|
1317
|
+
if (watchdog !== undefined)
|
|
1318
|
+
clearTimeout(watchdog);
|
|
1319
|
+
if (stallMs > 0) {
|
|
1320
|
+
watchdog = setTimeout(() => attemptAbort.abort(), stallMs);
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
resetWatchdog();
|
|
1324
|
+
try {
|
|
1325
|
+
return await runner.run({
|
|
1326
|
+
prompt: effPrompt,
|
|
1327
|
+
contract,
|
|
1328
|
+
cwd: effCwd,
|
|
1329
|
+
...opts.model !== undefined ? { model: opts.model } : {},
|
|
1330
|
+
...schemaDoc !== undefined ? { schema: schemaDoc } : {},
|
|
1331
|
+
...validate !== undefined ? { validate } : {},
|
|
1332
|
+
signal: attemptAbort.signal,
|
|
1333
|
+
onActivity: resetWatchdog,
|
|
1334
|
+
onTokens
|
|
1335
|
+
});
|
|
1336
|
+
} finally {
|
|
1337
|
+
if (watchdog !== undefined)
|
|
1338
|
+
clearTimeout(watchdog);
|
|
1339
|
+
abort.signal.removeEventListener("abort", onRunAbort);
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
try {
|
|
1343
|
+
let result = await attempt();
|
|
1344
|
+
let attemptNo = 1;
|
|
1345
|
+
if (abort.signal.aborted)
|
|
1346
|
+
return new Promise(() => {});
|
|
1347
|
+
const degraded = !hasSchema && result.aborted !== true && result.apiError === undefined && result.outputTokens < 50 && result.text.trim().length === 0;
|
|
1348
|
+
if (degraded) {
|
|
1349
|
+
emit({
|
|
1350
|
+
type: "workflow_log",
|
|
1351
|
+
message: `[throttle] retrying agent ${index} after backoff`
|
|
1352
|
+
});
|
|
1353
|
+
await sleepAbortable(THROTTLE_BACKOFF_MS);
|
|
1354
|
+
if (abort.signal.aborted)
|
|
1355
|
+
return new Promise(() => {});
|
|
1356
|
+
attemptNo += 1;
|
|
1357
|
+
result = await attempt();
|
|
1358
|
+
}
|
|
1359
|
+
let stallRetries = 0;
|
|
1360
|
+
while (result.aborted === true && !abort.signal.aborted && stallRetries < MAX_STALL_RETRIES) {
|
|
1361
|
+
stallRetries += 1;
|
|
1362
|
+
attemptNo += 1;
|
|
1363
|
+
emit({
|
|
1364
|
+
type: "workflow_log",
|
|
1365
|
+
message: `[stall] retrying agent ${index} (attempt ${attemptNo})`
|
|
1366
|
+
});
|
|
1367
|
+
result = await attempt();
|
|
1368
|
+
}
|
|
1369
|
+
if (abort.signal.aborted)
|
|
1370
|
+
return new Promise(() => {});
|
|
1371
|
+
if (result.aborted === true) {
|
|
1372
|
+
throw new Error(`agent stalled on all ${stallRetries + 1} attempts (no progress for ${stallMs}ms each)`);
|
|
1373
|
+
}
|
|
1374
|
+
if (result.apiError !== undefined) {
|
|
1375
|
+
emit({
|
|
1376
|
+
type: "workflow_agent",
|
|
1377
|
+
index,
|
|
1378
|
+
label,
|
|
1379
|
+
phaseIndex,
|
|
1380
|
+
...phaseTitle !== undefined ? { phaseTitle } : {},
|
|
1381
|
+
agentId,
|
|
1382
|
+
model,
|
|
1383
|
+
state: "error",
|
|
1384
|
+
error: result.apiError,
|
|
1385
|
+
tokens: result.outputTokens,
|
|
1386
|
+
toolCalls: result.toolCalls
|
|
1387
|
+
});
|
|
1388
|
+
const failed = `[${label}] failed: ${result.apiError}`;
|
|
1389
|
+
recordFailure(failed);
|
|
1390
|
+
emit({ type: "workflow_log", message: failed });
|
|
1391
|
+
return null;
|
|
1392
|
+
}
|
|
1393
|
+
emit({
|
|
1394
|
+
type: "workflow_agent",
|
|
1395
|
+
index,
|
|
1396
|
+
label,
|
|
1397
|
+
phaseIndex,
|
|
1398
|
+
...phaseTitle !== undefined ? { phaseTitle } : {},
|
|
1399
|
+
agentId,
|
|
1400
|
+
model,
|
|
1401
|
+
state: "done",
|
|
1402
|
+
tokens: result.outputTokens,
|
|
1403
|
+
toolCalls: result.toolCalls,
|
|
1404
|
+
resultPreview: previewTruncate(hasSchema ? result.structured : result.text)
|
|
1405
|
+
});
|
|
1406
|
+
registry.patch(runId, (s) => ({ ...s, tokensSpent }));
|
|
1407
|
+
if (hasSchema) {
|
|
1408
|
+
if (result.structured === undefined) {
|
|
1409
|
+
throw new Error(ERR_NO_STRUCTURED_OUTPUT);
|
|
1410
|
+
}
|
|
1411
|
+
return cloneIntoVm(result.structured);
|
|
1412
|
+
}
|
|
1413
|
+
return result.text;
|
|
1414
|
+
} finally {
|
|
1415
|
+
if (worktree !== undefined)
|
|
1416
|
+
await worktree.cleanupIfClean();
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
const schemaCopies = new WeakMap;
|
|
1420
|
+
const agent = async (prompt, optsRaw) => {
|
|
1421
|
+
let schemaObj;
|
|
1422
|
+
try {
|
|
1423
|
+
const s = optsRaw?.schema;
|
|
1424
|
+
if (s !== null && typeof s === "object")
|
|
1425
|
+
schemaObj = s;
|
|
1426
|
+
} catch {}
|
|
1427
|
+
const opts = hostSafeCopy(optsRaw);
|
|
1428
|
+
if (opts !== undefined && schemaObj !== undefined) {
|
|
1429
|
+
let copied = schemaCopies.get(schemaObj);
|
|
1430
|
+
if (copied === undefined) {
|
|
1431
|
+
copied = hostSafeCopy(schemaObj);
|
|
1432
|
+
schemaCopies.set(schemaObj, copied);
|
|
1433
|
+
}
|
|
1434
|
+
opts.schema = copied;
|
|
1435
|
+
}
|
|
1436
|
+
if (abort.signal.aborted)
|
|
1437
|
+
return new Promise(() => {});
|
|
1438
|
+
try {
|
|
1439
|
+
checkCap();
|
|
1440
|
+
checkBudget();
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
await sleep0();
|
|
1443
|
+
throw err;
|
|
1444
|
+
}
|
|
1445
|
+
const index = ++agentCount;
|
|
1446
|
+
registry.patch(runId, (s) => ({ ...s, agentCount }));
|
|
1447
|
+
const promptStr = String(prompt);
|
|
1448
|
+
const label = opts?.label != null ? String(opts.label) : promptStr.slice(0, 60).replace(/\s+/g, " ").trim();
|
|
1449
|
+
const phaseTitle = opts?.phase != null ? String(opts.phase) : currentPhase;
|
|
1450
|
+
const phaseIndex = phaseTitle !== undefined ? resolvePhase(phaseTitle) : -1;
|
|
1451
|
+
const stallMs = opts?.stallMs != null ? Number(opts.stallMs) : DEFAULT_STALL_MS;
|
|
1452
|
+
const key = cacheKey(promptStr, opts ?? {}, prevKey);
|
|
1453
|
+
prevKey = key;
|
|
1454
|
+
if (!journalDirty && a.replay && a.replay.results.has(key)) {
|
|
1455
|
+
const cached = a.replay.results.get(key).result;
|
|
1456
|
+
emit({
|
|
1457
|
+
type: "workflow_agent",
|
|
1458
|
+
index,
|
|
1459
|
+
label,
|
|
1460
|
+
...phaseTitle !== undefined ? { phaseTitle } : {},
|
|
1461
|
+
phaseIndex,
|
|
1462
|
+
state: "done",
|
|
1463
|
+
cached: true,
|
|
1464
|
+
resultPreview: previewTruncate(cached)
|
|
1465
|
+
});
|
|
1466
|
+
return cloneIntoVm(cached);
|
|
1467
|
+
}
|
|
1468
|
+
journalDirty = true;
|
|
1469
|
+
emit({
|
|
1470
|
+
type: "workflow_agent",
|
|
1471
|
+
index,
|
|
1472
|
+
label,
|
|
1473
|
+
...phaseTitle !== undefined ? { phaseTitle } : {},
|
|
1474
|
+
phaseIndex,
|
|
1475
|
+
promptPreview: previewTruncate(promptStr),
|
|
1476
|
+
model: opts?.model ?? a.defaultModel,
|
|
1477
|
+
state: "start"
|
|
1478
|
+
});
|
|
1479
|
+
const agentId = `${runId}-a${index}`;
|
|
1480
|
+
return localSem.withPermit(async () => {
|
|
1481
|
+
journal.append({ type: "started", key, agentId });
|
|
1482
|
+
const value = await runOneAgent(index, promptStr, opts ?? {}, label, phaseTitle, phaseIndex, stallMs);
|
|
1483
|
+
if (value !== null) {
|
|
1484
|
+
journal.append({ type: "result", key, agentId, result: value });
|
|
1485
|
+
}
|
|
1486
|
+
return value;
|
|
1487
|
+
}).catch((err) => {
|
|
1488
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1489
|
+
emit({
|
|
1490
|
+
type: "workflow_agent",
|
|
1491
|
+
index,
|
|
1492
|
+
label,
|
|
1493
|
+
...phaseTitle !== undefined ? { phaseTitle } : {},
|
|
1494
|
+
phaseIndex,
|
|
1495
|
+
state: "error",
|
|
1496
|
+
error: msg
|
|
1497
|
+
});
|
|
1498
|
+
throw err;
|
|
1499
|
+
});
|
|
1500
|
+
};
|
|
1501
|
+
const mapSettledSlots = (hookName, settled) => {
|
|
1502
|
+
let dropped = 0;
|
|
1503
|
+
const out = settled.map((s, i) => {
|
|
1504
|
+
if (s.status === "fulfilled")
|
|
1505
|
+
return s.value.v;
|
|
1506
|
+
const { name, msg } = boundaryErrorInfo(s.reason);
|
|
1507
|
+
if (name === "WorkflowBudgetExceededError") {
|
|
1508
|
+
dropped += 1;
|
|
1509
|
+
return null;
|
|
1510
|
+
}
|
|
1511
|
+
const line = `${hookName}[${i}] failed: ${msg}`;
|
|
1512
|
+
recordFailure(line);
|
|
1513
|
+
emit({ type: "workflow_log", message: line });
|
|
1514
|
+
return null;
|
|
1515
|
+
});
|
|
1516
|
+
if (dropped > 0) {
|
|
1517
|
+
recordFailure(`${hookName}: ${dropped} ${dropped === 1 ? "slot" : "slots"} dropped — token budget exceeded`);
|
|
1518
|
+
}
|
|
1519
|
+
return out;
|
|
1520
|
+
};
|
|
1521
|
+
const parallel = async (thunksRaw) => {
|
|
1522
|
+
if (abort.signal.aborted)
|
|
1523
|
+
return new Promise(() => {});
|
|
1524
|
+
await sleep0();
|
|
1525
|
+
if (!Array.isArray(thunksRaw))
|
|
1526
|
+
throw new TypeError(ERR_PARALLEL_NOT_ARRAY);
|
|
1527
|
+
const thunks = extractBoundaryArray(thunksRaw);
|
|
1528
|
+
if (thunks.length === 0)
|
|
1529
|
+
return cloneIntoVm([]);
|
|
1530
|
+
checkCap();
|
|
1531
|
+
checkBudget();
|
|
1532
|
+
for (const t of thunks) {
|
|
1533
|
+
if (typeof t !== "function") {
|
|
1534
|
+
throw new TypeError(ERR_PARALLEL_NOT_FUNCTIONS);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const settled = await Promise.allSettled(thunks.map((t) => settle(callInContext(t))));
|
|
1538
|
+
return cloneIntoVm(mapSettledSlots("parallel", settled));
|
|
1539
|
+
};
|
|
1540
|
+
const pipeline = async (itemsRaw, ...stagesRaw) => {
|
|
1541
|
+
if (abort.signal.aborted)
|
|
1542
|
+
return new Promise(() => {});
|
|
1543
|
+
await sleep0();
|
|
1544
|
+
if (!Array.isArray(itemsRaw))
|
|
1545
|
+
throw new TypeError(ERR_PIPELINE_NOT_ARRAY);
|
|
1546
|
+
const items = extractBoundaryArray(itemsRaw);
|
|
1547
|
+
const stages = extractBoundaryArray(stagesRaw);
|
|
1548
|
+
if (items.length === 0)
|
|
1549
|
+
return cloneIntoVm([]);
|
|
1550
|
+
checkCap();
|
|
1551
|
+
checkBudget();
|
|
1552
|
+
for (const stage of stages) {
|
|
1553
|
+
if (typeof stage !== "function") {
|
|
1554
|
+
throw new TypeError(ERR_PIPELINE_STAGES);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const settled = await Promise.allSettled(items.map(async (item, index) => {
|
|
1558
|
+
let acc = await settle(item);
|
|
1559
|
+
for (const stage of stages) {
|
|
1560
|
+
if (acc.v === null)
|
|
1561
|
+
break;
|
|
1562
|
+
acc = await settle(callInContext(stage, acc.v, item, index));
|
|
1563
|
+
}
|
|
1564
|
+
return acc;
|
|
1565
|
+
}));
|
|
1566
|
+
return cloneIntoVm(mapSettledSlots("pipeline", settled));
|
|
1567
|
+
};
|
|
1568
|
+
const workflowHook = () => Promise.reject(new Error(ERR_WORKFLOW_HOOK_UNAVAILABLE));
|
|
1569
|
+
const budget = Object.freeze(Object.assign(Object.create(null), {
|
|
1570
|
+
total: params.tokenBudgetTotal,
|
|
1571
|
+
spent: sanitizeHostSyncFn(() => tokensSpent),
|
|
1572
|
+
remaining: sanitizeHostSyncFn(() => params.tokenBudgetTotal == null ? Infinity : Math.max(0, params.tokenBudgetTotal - tokensSpent))
|
|
1573
|
+
}));
|
|
1574
|
+
const consoleShim = makeConsoleShim(log);
|
|
1575
|
+
const timers = makeDeterministicTimers(abort.signal);
|
|
1576
|
+
const ctx = createSandboxContext(Object.assign(Object.create(null), {
|
|
1577
|
+
log: sanitizeHostSyncFn(log),
|
|
1578
|
+
phase: sanitizeHostSyncFn(phase),
|
|
1579
|
+
budget,
|
|
1580
|
+
console: consoleShim,
|
|
1581
|
+
setTimeout: timers.setTimeout,
|
|
1582
|
+
clearTimeout: timers.clearTimeout
|
|
1583
|
+
}));
|
|
1584
|
+
timers.bindVMInvoke(makeInContextInvoker(ctx));
|
|
1585
|
+
const wrapHook = makeInContextHookWrapper(ctx);
|
|
1586
|
+
for (const [name, hook] of [
|
|
1587
|
+
["agent", agent],
|
|
1588
|
+
["parallel", parallel],
|
|
1589
|
+
["pipeline", pipeline],
|
|
1590
|
+
["workflow", workflowHook]
|
|
1591
|
+
]) {
|
|
1592
|
+
Object.defineProperty(ctx, name, {
|
|
1593
|
+
value: wrapHook(sanitizeHostAsyncFn(nullProtoHostFn(hook))),
|
|
1594
|
+
writable: true,
|
|
1595
|
+
enumerable: true,
|
|
1596
|
+
configurable: true
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
{
|
|
1600
|
+
const json = params.args === undefined ? undefined : JSON.stringify(params.args);
|
|
1601
|
+
Object.defineProperty(ctx, "args", {
|
|
1602
|
+
value: json === undefined ? undefined : cloneJsonIntoContext(ctx, json),
|
|
1603
|
+
writable: true,
|
|
1604
|
+
enumerable: true,
|
|
1605
|
+
configurable: true
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
settle = makeBoxedSettler(ctx);
|
|
1609
|
+
callInContext = makeInContextApplier(ctx);
|
|
1610
|
+
cloneIntoVm = makeInContextCloner(ctx);
|
|
1611
|
+
const outcome = await (async () => {
|
|
1612
|
+
let removeAbortListener;
|
|
1613
|
+
try {
|
|
1614
|
+
const raw = runScript(compiled.vmScript, ctx);
|
|
1615
|
+
const boxed = settle(raw);
|
|
1616
|
+
boxed.catch(() => {});
|
|
1617
|
+
const abortPromise = new Promise((_, reject) => {
|
|
1618
|
+
const onAbort = () => reject(new Error(ERR_WORKFLOW_ABORTED));
|
|
1619
|
+
if (abort.signal.aborted) {
|
|
1620
|
+
onAbort();
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
abort.signal.addEventListener("abort", onAbort, { once: true });
|
|
1624
|
+
removeAbortListener = () => abort.signal.removeEventListener("abort", onAbort);
|
|
1625
|
+
});
|
|
1626
|
+
const value = (await Promise.race([boxed, abortPromise])).v;
|
|
1627
|
+
let cloned;
|
|
1628
|
+
try {
|
|
1629
|
+
cloned = structuredClone(value);
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
if (value === null || typeof value !== "object")
|
|
1632
|
+
throw err;
|
|
1633
|
+
cloned = JSON.parse(JSON.stringify(value, (_k, v) => typeof v === "function" ? undefined : v) ?? "null");
|
|
1634
|
+
}
|
|
1635
|
+
JSON.stringify(cloned);
|
|
1636
|
+
return { ok: true, result: cloned };
|
|
1637
|
+
} catch (err) {
|
|
1638
|
+
return { ok: false, error: formatScriptError(err) };
|
|
1639
|
+
} finally {
|
|
1640
|
+
removeAbortListener?.();
|
|
1641
|
+
}
|
|
1642
|
+
})();
|
|
1643
|
+
if (outcome.ok) {
|
|
1644
|
+
try {
|
|
1645
|
+
writeFileSync(resultPathFor(runDir), JSON.stringify(outcome.result ?? null, null, 2), "utf8");
|
|
1646
|
+
} catch {}
|
|
1647
|
+
}
|
|
1648
|
+
registry.patch(runId, (s) => {
|
|
1649
|
+
if (s.status !== "running")
|
|
1650
|
+
return { ...s, agentCount, tokensSpent };
|
|
1651
|
+
return {
|
|
1652
|
+
...s,
|
|
1653
|
+
status: abort.signal.aborted ? "stopped" : outcome.ok ? "completed" : "failed",
|
|
1654
|
+
endTime: Date.now(),
|
|
1655
|
+
agentCount,
|
|
1656
|
+
tokensSpent,
|
|
1657
|
+
...outcome.ok ? { result: outcome.result } : { error: outcome.error }
|
|
1658
|
+
};
|
|
1659
|
+
});
|
|
1660
|
+
};
|
|
1661
|
+
|
|
1662
|
+
// src/runtime/runs.ts
|
|
1663
|
+
import { readdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1664
|
+
import { join as join3 } from "node:path";
|
|
1665
|
+
var TERMINAL = new Set([
|
|
1666
|
+
"completed",
|
|
1667
|
+
"failed",
|
|
1668
|
+
"stopped"
|
|
1669
|
+
]);
|
|
1670
|
+
var isTerminal = (status) => TERMINAL.has(status);
|
|
1671
|
+
var persistSnapshot = (state) => {
|
|
1672
|
+
try {
|
|
1673
|
+
writeFileSync2(statePathFor(state.runDir), JSON.stringify(state, null, 2), "utf8");
|
|
1674
|
+
} catch {}
|
|
1675
|
+
};
|
|
1676
|
+
var rehydrate = (runs) => {
|
|
1677
|
+
let entries;
|
|
1678
|
+
try {
|
|
1679
|
+
entries = readdirSync(join3(stateHome(), "runs"));
|
|
1680
|
+
} catch {
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
for (const runId of entries) {
|
|
1684
|
+
try {
|
|
1685
|
+
const raw = readFileSync2(statePathFor(runDirFor(runId)), "utf8");
|
|
1686
|
+
const state = JSON.parse(raw);
|
|
1687
|
+
if (runs.has(state.runId))
|
|
1688
|
+
continue;
|
|
1689
|
+
const fixed = state.status === "running" ? {
|
|
1690
|
+
...state,
|
|
1691
|
+
status: "failed",
|
|
1692
|
+
error: "workflow process exited before completion — resume with workflow({scriptPath, resumeFromRunId})"
|
|
1693
|
+
} : state;
|
|
1694
|
+
if (fixed !== state)
|
|
1695
|
+
persistSnapshot(fixed);
|
|
1696
|
+
runs.set(fixed.runId, {
|
|
1697
|
+
state: fixed,
|
|
1698
|
+
abort: new AbortController,
|
|
1699
|
+
waiters: []
|
|
1700
|
+
});
|
|
1701
|
+
} catch {}
|
|
1702
|
+
}
|
|
1703
|
+
};
|
|
1704
|
+
var createRunRegistry = () => {
|
|
1705
|
+
const runs = new Map;
|
|
1706
|
+
rehydrate(runs);
|
|
1707
|
+
const get = (runId) => runs.get(runId)?.state;
|
|
1708
|
+
const patch = (runId, f) => {
|
|
1709
|
+
const rec = runs.get(runId);
|
|
1710
|
+
if (rec === undefined)
|
|
1711
|
+
return;
|
|
1712
|
+
const wasTerminal = isTerminal(rec.state.status);
|
|
1713
|
+
rec.state = f(rec.state);
|
|
1714
|
+
persistSnapshot(rec.state);
|
|
1715
|
+
if (!wasTerminal && isTerminal(rec.state.status)) {
|
|
1716
|
+
const waiters = rec.waiters.splice(0);
|
|
1717
|
+
for (const w of waiters)
|
|
1718
|
+
w();
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
return {
|
|
1722
|
+
register: (state) => {
|
|
1723
|
+
const abort = new AbortController;
|
|
1724
|
+
runs.set(state.runId, { state, abort, waiters: [] });
|
|
1725
|
+
persistSnapshot(state);
|
|
1726
|
+
return abort;
|
|
1727
|
+
},
|
|
1728
|
+
get,
|
|
1729
|
+
list: () => Array.from(runs.values()).map((r) => r.state),
|
|
1730
|
+
patch,
|
|
1731
|
+
stop: (runId) => {
|
|
1732
|
+
const rec = runs.get(runId);
|
|
1733
|
+
if (rec === undefined)
|
|
1734
|
+
return false;
|
|
1735
|
+
if (isTerminal(rec.state.status))
|
|
1736
|
+
return false;
|
|
1737
|
+
rec.abort.abort();
|
|
1738
|
+
patch(runId, (s) => ({ ...s, status: "stopped", endTime: Date.now() }));
|
|
1739
|
+
return true;
|
|
1740
|
+
},
|
|
1741
|
+
isRunning: (runId) => {
|
|
1742
|
+
const rec = runs.get(runId);
|
|
1743
|
+
return rec !== undefined && rec.state.status === "running";
|
|
1744
|
+
},
|
|
1745
|
+
waitForTerminal: (runId, timeoutMs) => {
|
|
1746
|
+
const rec = runs.get(runId);
|
|
1747
|
+
if (rec === undefined)
|
|
1748
|
+
return Promise.resolve(undefined);
|
|
1749
|
+
if (isTerminal(rec.state.status))
|
|
1750
|
+
return Promise.resolve(rec.state);
|
|
1751
|
+
return new Promise((resolve) => {
|
|
1752
|
+
let settled = false;
|
|
1753
|
+
const finish = () => {
|
|
1754
|
+
if (settled)
|
|
1755
|
+
return;
|
|
1756
|
+
settled = true;
|
|
1757
|
+
clearTimeout(timer);
|
|
1758
|
+
resolve(runs.get(runId)?.state);
|
|
1759
|
+
};
|
|
1760
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
1761
|
+
rec.waiters.push(finish);
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
// src/server.ts
|
|
1768
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1769
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1770
|
+
import { z } from "zod";
|
|
1771
|
+
|
|
1772
|
+
// src/runtime/meta.ts
|
|
1773
|
+
import * as acorn2 from "acorn";
|
|
1774
|
+
var TS_HINT = "Workflow scripts must be plain JavaScript — TypeScript syntax (type annotations, interfaces, generics) fails to parse.";
|
|
1775
|
+
var evalLiteral = (node) => {
|
|
1776
|
+
switch (node.type) {
|
|
1777
|
+
case "Literal":
|
|
1778
|
+
return node.value;
|
|
1779
|
+
case "TemplateLiteral": {
|
|
1780
|
+
if (node.expressions.length > 0) {
|
|
1781
|
+
throw new Error("non-literal node type in meta: TemplateLiteral");
|
|
1782
|
+
}
|
|
1783
|
+
return node.quasis.map((q) => q.value.cooked).join("");
|
|
1784
|
+
}
|
|
1785
|
+
case "ArrayExpression": {
|
|
1786
|
+
const out = [];
|
|
1787
|
+
for (const el of node.elements) {
|
|
1788
|
+
if (el === null) {
|
|
1789
|
+
throw new Error("non-literal node type in meta: SparseElement");
|
|
1790
|
+
}
|
|
1791
|
+
if (el.type === "SpreadElement") {
|
|
1792
|
+
throw new Error("non-literal node type in meta: SpreadElement");
|
|
1793
|
+
}
|
|
1794
|
+
out.push(evalLiteral(el));
|
|
1795
|
+
}
|
|
1796
|
+
return out;
|
|
1797
|
+
}
|
|
1798
|
+
case "ObjectExpression": {
|
|
1799
|
+
const out = {};
|
|
1800
|
+
for (const prop of node.properties) {
|
|
1801
|
+
if (prop.type !== "Property") {
|
|
1802
|
+
throw new Error("non-literal node type in meta: " + prop.type);
|
|
1803
|
+
}
|
|
1804
|
+
if (prop.computed || prop.method || prop.kind !== "init") {
|
|
1805
|
+
throw new Error("non-literal node type in meta: ComputedOrMethod");
|
|
1806
|
+
}
|
|
1807
|
+
const key = prop.key.type === "Identifier" ? prop.key.name : evalLiteral(prop.key);
|
|
1808
|
+
out[key] = evalLiteral(prop.value);
|
|
1809
|
+
}
|
|
1810
|
+
return out;
|
|
1811
|
+
}
|
|
1812
|
+
case "UnaryExpression": {
|
|
1813
|
+
if (node.operator === "-" && node.argument.type === "Literal" && typeof node.argument.value === "number") {
|
|
1814
|
+
return -node.argument.value;
|
|
1815
|
+
}
|
|
1816
|
+
throw new Error("non-literal node type in meta: UnaryExpression");
|
|
1817
|
+
}
|
|
1818
|
+
default:
|
|
1819
|
+
throw new Error("non-literal node type in meta: " + node.type);
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
var nonEmptyString = (v) => typeof v === "string" && v.length > 0;
|
|
1823
|
+
var shapePhases = (raw) => {
|
|
1824
|
+
if (!Array.isArray(raw))
|
|
1825
|
+
return;
|
|
1826
|
+
const out = [];
|
|
1827
|
+
for (const entry of raw) {
|
|
1828
|
+
if (entry && typeof entry === "object" && nonEmptyString(entry["title"])) {
|
|
1829
|
+
const e = entry;
|
|
1830
|
+
const phase = {
|
|
1831
|
+
title: e["title"],
|
|
1832
|
+
...typeof e["detail"] === "string" ? { detail: e["detail"] } : {},
|
|
1833
|
+
...typeof e["model"] === "string" ? { model: e["model"] } : {}
|
|
1834
|
+
};
|
|
1835
|
+
out.push(phase);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
return out.length > 0 ? out : undefined;
|
|
1839
|
+
};
|
|
1840
|
+
var shapeMeta = (raw) => {
|
|
1841
|
+
const obj = raw ?? {};
|
|
1842
|
+
if (!nonEmptyString(obj["name"])) {
|
|
1843
|
+
throw new Error("meta.name must be a non-empty string");
|
|
1844
|
+
}
|
|
1845
|
+
if (!nonEmptyString(obj["description"])) {
|
|
1846
|
+
throw new Error("meta.description must be a non-empty string");
|
|
1847
|
+
}
|
|
1848
|
+
const title = obj["title"];
|
|
1849
|
+
if (title !== undefined && !nonEmptyString(title)) {
|
|
1850
|
+
throw new Error("meta.title must be a non-empty string");
|
|
1851
|
+
}
|
|
1852
|
+
const phases = shapePhases(obj["phases"]);
|
|
1853
|
+
return {
|
|
1854
|
+
name: obj["name"],
|
|
1855
|
+
description: obj["description"],
|
|
1856
|
+
...typeof title === "string" ? { title } : {},
|
|
1857
|
+
...typeof obj["whenToUse"] === "string" ? { whenToUse: obj["whenToUse"] } : {},
|
|
1858
|
+
...phases ? { phases } : {}
|
|
1859
|
+
};
|
|
1860
|
+
};
|
|
1861
|
+
var parseWorkflowScript = (script) => {
|
|
1862
|
+
if (script.length > MAX_SCRIPT_BYTES) {
|
|
1863
|
+
throw new Error(`Script exceeds ${MAX_SCRIPT_BYTES} bytes`);
|
|
1864
|
+
}
|
|
1865
|
+
let program;
|
|
1866
|
+
try {
|
|
1867
|
+
program = acorn2.parse(script, {
|
|
1868
|
+
ecmaVersion: "latest",
|
|
1869
|
+
sourceType: "module",
|
|
1870
|
+
allowAwaitOutsideFunction: true,
|
|
1871
|
+
allowReturnOutsideFunction: true
|
|
1872
|
+
});
|
|
1873
|
+
} catch (err) {
|
|
1874
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1875
|
+
throw new Error(`${msg}. ${TS_HINT}`);
|
|
1876
|
+
}
|
|
1877
|
+
const first = program.body[0];
|
|
1878
|
+
const META_FIRST_ERR = "`export const meta = { name, description, phases }` must be the FIRST statement in the script";
|
|
1879
|
+
if (!first || first.type !== "ExportNamedDeclaration") {
|
|
1880
|
+
throw new Error(META_FIRST_ERR);
|
|
1881
|
+
}
|
|
1882
|
+
const decl = first.declaration;
|
|
1883
|
+
if (!decl || decl.type !== "VariableDeclaration" || decl.kind !== "const") {
|
|
1884
|
+
throw new Error(META_FIRST_ERR);
|
|
1885
|
+
}
|
|
1886
|
+
const declarator = decl.declarations?.[0];
|
|
1887
|
+
if (!declarator || declarator.id?.type !== "Identifier" || declarator.id.name !== "meta" || !declarator.init || declarator.init.type !== "ObjectExpression") {
|
|
1888
|
+
throw new Error(META_FIRST_ERR);
|
|
1889
|
+
}
|
|
1890
|
+
let rawMeta;
|
|
1891
|
+
try {
|
|
1892
|
+
rawMeta = evalLiteral(declarator.init);
|
|
1893
|
+
} catch (err) {
|
|
1894
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1895
|
+
throw new Error("meta must be a pure literal: " + msg);
|
|
1896
|
+
}
|
|
1897
|
+
const meta = shapeMeta(rawMeta);
|
|
1898
|
+
const metaEnd = first.end;
|
|
1899
|
+
const scriptBody = script.slice(metaEnd).replace(/^[\s;]+/, "");
|
|
1900
|
+
return { meta, scriptBody };
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
// src/server.ts
|
|
1904
|
+
var DETERMINISM_MESSAGE = "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable (breaks resume). Stamp results after the workflow returns, or pass timestamps via args.";
|
|
1905
|
+
var textResult = (text, isError = false) => ({
|
|
1906
|
+
content: [{ type: "text", text }],
|
|
1907
|
+
...isError ? { isError: true } : {}
|
|
1908
|
+
});
|
|
1909
|
+
var elapsedSeconds = (s) => Math.round(((s.endTime ?? Date.now()) - s.startTime) / 1000);
|
|
1910
|
+
var renderAgentLine = (a) => {
|
|
1911
|
+
const marks = {
|
|
1912
|
+
running: "[run ]",
|
|
1913
|
+
done: "[done]",
|
|
1914
|
+
error: "[fail]"
|
|
1915
|
+
};
|
|
1916
|
+
const cached = a.cached === true ? " (cached)" : "";
|
|
1917
|
+
const tokens = a.tokens !== undefined && a.tokens > 0 ? ` ${(a.tokens / 1000).toFixed(1)}k tok` : "";
|
|
1918
|
+
const tools = a.toolCalls !== undefined && a.toolCalls > 0 ? ` ${a.toolCalls} tools` : "";
|
|
1919
|
+
const error = a.error !== undefined ? ` — ${a.error}` : "";
|
|
1920
|
+
return ` ${marks[a.state]} #${a.index} ${a.label}${cached}${tokens}${tools}${error}`;
|
|
1921
|
+
};
|
|
1922
|
+
var renderRunStatus = (s) => {
|
|
1923
|
+
const lines = [];
|
|
1924
|
+
lines.push(`${s.name} (${s.runId}) — ${s.status}, ${s.agentCount} agent${s.agentCount === 1 ? "" : "s"}, ${elapsedSeconds(s)}s${s.tokensSpent > 0 ? `, ${Math.round(s.tokensSpent / 1000)}k output tokens` : ""}`);
|
|
1925
|
+
lines.push(s.description);
|
|
1926
|
+
const byPhase = new Map;
|
|
1927
|
+
for (const agent of s.agents) {
|
|
1928
|
+
const list = byPhase.get(agent.phaseTitle) ?? [];
|
|
1929
|
+
byPhase.set(agent.phaseTitle, [...list, agent]);
|
|
1930
|
+
}
|
|
1931
|
+
for (const title of [...s.phases, undefined]) {
|
|
1932
|
+
const agents = byPhase.get(title);
|
|
1933
|
+
if (agents === undefined || agents.length === 0)
|
|
1934
|
+
continue;
|
|
1935
|
+
byPhase.delete(title);
|
|
1936
|
+
lines.push(title !== undefined ? `
|
|
1937
|
+
${title}:` : `
|
|
1938
|
+
(no phase):`);
|
|
1939
|
+
for (const agent of agents)
|
|
1940
|
+
lines.push(renderAgentLine(agent));
|
|
1941
|
+
}
|
|
1942
|
+
for (const [title, agents] of byPhase) {
|
|
1943
|
+
lines.push(title !== undefined ? `
|
|
1944
|
+
${title}:` : `
|
|
1945
|
+
(no phase):`);
|
|
1946
|
+
for (const agent of agents)
|
|
1947
|
+
lines.push(renderAgentLine(agent));
|
|
1948
|
+
}
|
|
1949
|
+
if (s.logs.length > 0) {
|
|
1950
|
+
lines.push(`
|
|
1951
|
+
Recent log:`);
|
|
1952
|
+
for (const message of s.logs.slice(-10))
|
|
1953
|
+
lines.push(` ${message}`);
|
|
1954
|
+
}
|
|
1955
|
+
if (s.failures.length > 0) {
|
|
1956
|
+
lines.push(`
|
|
1957
|
+
Failures:`);
|
|
1958
|
+
for (const failure of s.failures)
|
|
1959
|
+
lines.push(` ${failure}`);
|
|
1960
|
+
}
|
|
1961
|
+
if (s.status === "completed") {
|
|
1962
|
+
const json = JSON.stringify(s.result ?? null, null, 2);
|
|
1963
|
+
const capped = json.length > RESULT_TEXT_CAP ? json.slice(0, RESULT_TEXT_CAP) + `
|
|
1964
|
+
… [truncated — full result at ${resultPathFor(s.runDir)}]` : json;
|
|
1965
|
+
lines.push(`
|
|
1966
|
+
Result:`);
|
|
1967
|
+
lines.push(capped);
|
|
1968
|
+
}
|
|
1969
|
+
if (s.status === "failed" && s.error !== undefined) {
|
|
1970
|
+
lines.push(`
|
|
1971
|
+
Error: ${s.error}`);
|
|
1972
|
+
}
|
|
1973
|
+
if (s.status === "failed" || s.status === "stopped") {
|
|
1974
|
+
lines.push(`
|
|
1975
|
+
Resume with workflow({scriptPath: "${s.scriptPath}", resumeFromRunId: "${s.runId}"}) — completed agent() calls replay from cache.`);
|
|
1976
|
+
}
|
|
1977
|
+
if (s.status === "running") {
|
|
1978
|
+
lines.push(`
|
|
1979
|
+
Still running. Poll workflow_status({runId: "${s.runId}"}) or workflow_wait({runId: "${s.runId}"}).`);
|
|
1980
|
+
}
|
|
1981
|
+
return lines.join(`
|
|
1982
|
+
`);
|
|
1983
|
+
};
|
|
1984
|
+
var createWorkflowMcpServer = (config) => {
|
|
1985
|
+
const { host, registry } = config;
|
|
1986
|
+
const server = new McpServer({
|
|
1987
|
+
name: "openhorizon-workflows",
|
|
1988
|
+
version: config.version
|
|
1989
|
+
});
|
|
1990
|
+
server.registerTool("workflow", {
|
|
1991
|
+
title: "Launch workflow",
|
|
1992
|
+
description: WORKFLOW_TOOL_DESCRIPTION,
|
|
1993
|
+
inputSchema: {
|
|
1994
|
+
script: z.string().max(MAX_SCRIPT_BYTES).optional().describe("Self-contained workflow script. Must begin with `export const meta = { name, description, phases }` (pure literal, no computed values) followed by the script body using agent()/parallel()/pipeline()/phase()."),
|
|
1995
|
+
scriptPath: z.string().optional().describe("Path to a workflow script file on disk. Every invocation persists its script and returns the path in the tool result. To iterate, edit that file and re-invoke with the same `scriptPath` instead of re-sending the full script. Takes precedence over `script`."),
|
|
1996
|
+
args: z.unknown().optional().describe("Optional input value exposed to the script as the global `args`, verbatim. Pass arrays/objects as actual JSON values, NOT as a JSON-encoded string — a stringified list breaks `args.filter`/`args.map` in the script."),
|
|
1997
|
+
resumeFromRunId: z.string().regex(RUN_ID_RE).optional().describe("Run ID of a prior workflow invocation to resume from. Completed agent() calls with unchanged (prompt, opts) return their cached results instantly; only edited or new calls re-run. Stop a still-running run first (workflow_stop)."),
|
|
1998
|
+
cwd: z.string().optional().describe("Working directory the subagents operate in. Defaults to the server's working directory."),
|
|
1999
|
+
tokenBudget: z.number().int().positive().optional().describe("Output-token budget for this run (the script's `budget` global). Once spent() reaches this total, further agent() calls throw. Omit for no budget.")
|
|
2000
|
+
}
|
|
2001
|
+
}, (input) => {
|
|
2002
|
+
let script;
|
|
2003
|
+
if (input.scriptPath !== undefined) {
|
|
2004
|
+
try {
|
|
2005
|
+
script = readFileSync3(input.scriptPath, "utf8");
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2008
|
+
return textResult(`Could not read workflow scriptPath: ${msg}`, true);
|
|
2009
|
+
}
|
|
2010
|
+
} else if (input.script !== undefined) {
|
|
2011
|
+
script = input.script;
|
|
2012
|
+
} else {
|
|
2013
|
+
return textResult("Must provide script or scriptPath", true);
|
|
2014
|
+
}
|
|
2015
|
+
if (input.script !== undefined && DETERMINISM_RE.test(input.script)) {
|
|
2016
|
+
return textResult(DETERMINISM_MESSAGE, true);
|
|
2017
|
+
}
|
|
2018
|
+
let parsed;
|
|
2019
|
+
try {
|
|
2020
|
+
parsed = parseWorkflowScript(script);
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2023
|
+
return textResult(`Invalid workflow script: ${msg}`, true);
|
|
2024
|
+
}
|
|
2025
|
+
const compiled = compileScript(parsed.scriptBody);
|
|
2026
|
+
if (!compiled.ok) {
|
|
2027
|
+
return textResult(`Workflow script has a syntax error and was not launched:
|
|
2028
|
+
${compiled.error}`, true);
|
|
2029
|
+
}
|
|
2030
|
+
if (input.resumeFromRunId !== undefined && registry.isRunning(input.resumeFromRunId)) {
|
|
2031
|
+
const taskId = registry.get(input.resumeFromRunId)?.taskId ?? "?";
|
|
2032
|
+
return textResult(`Workflow ${input.resumeFromRunId} is still running (task ${taskId}). Stop it first with workflow_stop({runId: "${input.resumeFromRunId}"}) before resuming.`, true);
|
|
2033
|
+
}
|
|
2034
|
+
const runId = mintRunId(input.resumeFromRunId);
|
|
2035
|
+
const runDir = runDirFor(runId);
|
|
2036
|
+
ensureRunDir(runDir);
|
|
2037
|
+
const persistedPath = scriptPathFor(runDir);
|
|
2038
|
+
try {
|
|
2039
|
+
writeFileSync3(persistedPath, script, "utf8");
|
|
2040
|
+
} catch {}
|
|
2041
|
+
const scriptPath = input.scriptPath ?? persistedPath;
|
|
2042
|
+
const launchParams = {
|
|
2043
|
+
parsed,
|
|
2044
|
+
args: input.args,
|
|
2045
|
+
cwd: input.cwd ?? config.cwd,
|
|
2046
|
+
runId,
|
|
2047
|
+
scriptPath,
|
|
2048
|
+
...input.resumeFromRunId !== undefined ? { resumeFromRunId: input.resumeFromRunId } : {},
|
|
2049
|
+
tokenBudgetTotal: input.tokenBudget ?? null
|
|
2050
|
+
};
|
|
2051
|
+
const launched = host.launch(launchParams);
|
|
2052
|
+
return textResult([
|
|
2053
|
+
`Workflow launched in background. Task ID: ${launched.taskId}`,
|
|
2054
|
+
`Summary: ${parsed.meta.description}`,
|
|
2055
|
+
`Run dir: ${launched.runDir}`,
|
|
2056
|
+
`Script file: ${scriptPath} (edit this file and re-invoke workflow with {scriptPath} to iterate)`,
|
|
2057
|
+
`Run ID: ${launched.runId} (after a stop/failure/edit, resume with workflow({scriptPath, resumeFromRunId: "${launched.runId}"}) — unchanged agent() calls replay from cache)`,
|
|
2058
|
+
``,
|
|
2059
|
+
`Poll workflow_status({runId: "${launched.runId}"}) for live progress, or workflow_wait({runId: "${launched.runId}"}) to block until it finishes.`
|
|
2060
|
+
].join(`
|
|
2061
|
+
`));
|
|
2062
|
+
});
|
|
2063
|
+
server.registerTool("workflow_status", {
|
|
2064
|
+
title: "Workflow status",
|
|
2065
|
+
description: "Progress snapshot of a workflow run: phases, per-agent states, recent log lines, failures, and (when finished) the script's return value. Without runId, lists all known runs.",
|
|
2066
|
+
annotations: { readOnlyHint: true },
|
|
2067
|
+
inputSchema: {
|
|
2068
|
+
runId: z.string().optional().describe("The run to inspect. Omit to list all runs.")
|
|
2069
|
+
}
|
|
2070
|
+
}, (input) => {
|
|
2071
|
+
if (input.runId === undefined) {
|
|
2072
|
+
const runs = registry.list();
|
|
2073
|
+
if (runs.length === 0)
|
|
2074
|
+
return textResult("No workflow runs.");
|
|
2075
|
+
const lines = [...runs].sort((a, b) => b.startTime - a.startTime).map((s) => `${s.runId} ${s.status.padEnd(9)} ${s.name} — ${s.agentCount} agent${s.agentCount === 1 ? "" : "s"}, ${elapsedSeconds(s)}s`);
|
|
2076
|
+
return textResult(lines.join(`
|
|
2077
|
+
`));
|
|
2078
|
+
}
|
|
2079
|
+
const state = registry.get(input.runId);
|
|
2080
|
+
if (state === undefined) {
|
|
2081
|
+
return textResult(`No workflow run with run ID ${input.runId}.`, true);
|
|
2082
|
+
}
|
|
2083
|
+
return textResult(renderRunStatus(state));
|
|
2084
|
+
});
|
|
2085
|
+
server.registerTool("workflow_wait", {
|
|
2086
|
+
title: "Wait for workflow",
|
|
2087
|
+
description: "Block until the workflow reaches a terminal status (completed/failed/stopped) or the timeout elapses, then return the status snapshot. Capped per call to stay inside MCP client timeouts — if it returns while the run is still going, call it again.",
|
|
2088
|
+
annotations: { readOnlyHint: true },
|
|
2089
|
+
inputSchema: {
|
|
2090
|
+
runId: z.string().describe("The run to wait for."),
|
|
2091
|
+
timeoutSeconds: z.number().int().min(1).max(55).optional().describe("Max seconds to block (default 30, max 55).")
|
|
2092
|
+
}
|
|
2093
|
+
}, async (input) => {
|
|
2094
|
+
const state = await registry.waitForTerminal(input.runId, (input.timeoutSeconds ?? 30) * 1000);
|
|
2095
|
+
if (state === undefined) {
|
|
2096
|
+
return textResult(`No workflow run with run ID ${input.runId}.`, true);
|
|
2097
|
+
}
|
|
2098
|
+
if (!isTerminal(state.status)) {
|
|
2099
|
+
return textResult(`Workflow ${input.runId} is still running (${state.agentCount} agents so far, ${elapsedSeconds(state)}s elapsed). Call workflow_wait again.
|
|
2100
|
+
|
|
2101
|
+
${renderRunStatus(state)}`);
|
|
2102
|
+
}
|
|
2103
|
+
return textResult(renderRunStatus(state));
|
|
2104
|
+
});
|
|
2105
|
+
server.registerTool("workflow_stop", {
|
|
2106
|
+
title: "Stop workflow",
|
|
2107
|
+
description: "Abort a running workflow. In-flight subagents are cancelled; completed agent() results stay in the journal, so the run can be resumed with workflow({scriptPath, resumeFromRunId}).",
|
|
2108
|
+
annotations: { destructiveHint: true },
|
|
2109
|
+
inputSchema: {
|
|
2110
|
+
runId: z.string().describe("The run to stop.")
|
|
2111
|
+
}
|
|
2112
|
+
}, (input) => {
|
|
2113
|
+
const stopped = registry.stop(input.runId);
|
|
2114
|
+
return stopped ? textResult(`Workflow ${input.runId} stopped. Resume later with workflow({scriptPath, resumeFromRunId: "${input.runId}"}).`) : textResult(`No running workflow with run ID ${input.runId}.`, true);
|
|
2115
|
+
});
|
|
2116
|
+
return server;
|
|
2117
|
+
};
|
|
2118
|
+
|
|
2119
|
+
// src/cli.ts
|
|
2120
|
+
var VERSION = "0.1.0";
|
|
2121
|
+
console.log = console.error.bind(console);
|
|
2122
|
+
console.info = console.error.bind(console);
|
|
2123
|
+
console.debug = console.error.bind(console);
|
|
2124
|
+
process.on("uncaughtException", (err) => {
|
|
2125
|
+
console.error("[openhorizon-workflows] uncaught exception:", err);
|
|
2126
|
+
});
|
|
2127
|
+
process.on("unhandledRejection", (reason) => {
|
|
2128
|
+
console.error("[openhorizon-workflows] unhandled rejection:", reason);
|
|
2129
|
+
});
|
|
2130
|
+
var main = async () => {
|
|
2131
|
+
if (process.env["CURSOR_API_KEY"] === undefined) {
|
|
2132
|
+
console.error("[openhorizon-workflows] CURSOR_API_KEY is not set — agent() calls will fail until it is provided.");
|
|
2133
|
+
}
|
|
2134
|
+
const registry = createRunRegistry();
|
|
2135
|
+
const runner = makeCursorRunner({
|
|
2136
|
+
...process.env["CURSOR_API_KEY"] !== undefined ? { apiKey: process.env["CURSOR_API_KEY"] } : {},
|
|
2137
|
+
defaultModel: DEFAULT_MODEL
|
|
2138
|
+
});
|
|
2139
|
+
const host = createWorkflowHost({
|
|
2140
|
+
runner,
|
|
2141
|
+
registry,
|
|
2142
|
+
defaultModel: DEFAULT_MODEL
|
|
2143
|
+
});
|
|
2144
|
+
const server = createWorkflowMcpServer({
|
|
2145
|
+
host,
|
|
2146
|
+
registry,
|
|
2147
|
+
cwd: process.cwd(),
|
|
2148
|
+
version: VERSION
|
|
2149
|
+
});
|
|
2150
|
+
const shutdown = () => {
|
|
2151
|
+
process.exit(0);
|
|
2152
|
+
};
|
|
2153
|
+
process.once("SIGINT", shutdown);
|
|
2154
|
+
process.once("SIGTERM", shutdown);
|
|
2155
|
+
const transport = new StdioServerTransport;
|
|
2156
|
+
await server.connect(transport);
|
|
2157
|
+
console.error(`[openhorizon-workflows] MCP server ready (model: ${DEFAULT_MODEL}, cwd: ${process.cwd()})`);
|
|
2158
|
+
};
|
|
2159
|
+
main().catch((err) => {
|
|
2160
|
+
console.error("[openhorizon-workflows] fatal:", err);
|
|
2161
|
+
process.exit(1);
|
|
2162
|
+
});
|