@krapht/ork 1.0.0-alpha.2 → 1.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -7
- package/dist/__build_info__-0wqQ-rtf.mjs +180 -0
- package/dist/apply-Mb0fNpCX.mjs +17 -0
- package/dist/argument-error-CihH46Le.mjs +27 -0
- package/dist/attach-C0-YyNoY.mjs +92 -0
- package/dist/attach-CAKNqnUK.mjs +1259 -0
- package/dist/bin.mjs +1885 -1609
- package/dist/brief-C2hew8cS.mjs +116 -0
- package/dist/{decide-CGyx9rv7.mjs → builtin-files-YYAnbZGD.mjs} +3 -51
- package/dist/chips-CP_pgKlj.mjs +48 -0
- package/dist/command-tree-CEkzWNy6.mjs +63 -0
- package/dist/{completion-CSU5vkEt.mjs → completion-K4rAVsrf.mjs} +200 -30
- package/dist/context-B-fjPJky.mjs +106 -0
- package/dist/context-DMK0U1w0.mjs +160 -0
- package/dist/detached-q0AYG0Bs.mjs +39 -0
- package/dist/extra-arguments-BxQMXARO.mjs +13 -0
- package/dist/{find-D6Gj1sxg.mjs → find-Ct5CNl1J.mjs} +2 -2
- package/dist/flags-C90N4J1i.mjs +145 -0
- package/dist/follow-ups-D3HPyNYM.mjs +568 -0
- package/dist/format-CiPhQqbX.mjs +260 -0
- package/dist/{help-groups-BwYoAWsh.mjs → help-groups-BQvcKQ9H.mjs} +49 -78
- package/dist/in-a-run-D_wg4iJi.mjs +27 -0
- package/dist/install-Hji6LIei.mjs +259 -0
- package/dist/{limits-B1-DYc3d.mjs → limits-BD6RZRCS.mjs} +2 -2
- package/dist/{mcp-DE_EmyVm.mjs → mcp-CpbDmBLG.mjs} +6 -6
- package/dist/once-J8Qn5fTG.mjs +112 -0
- package/dist/orphan-guard-BFKg8VSd.mjs +58 -0
- package/dist/output-DXZbnFt_.mjs +458 -0
- package/dist/outputs-text-CNTJ7Qq_.mjs +19 -0
- package/dist/{pause-CL6N_Xrj.mjs → pause-Cl4ZYA35.mjs} +7 -4
- package/dist/print-DzugGZQB.mjs +19 -0
- package/dist/project-records-CtkmlsHN.mjs +233 -0
- package/dist/refusals-DQQX3AIb.mjs +116 -0
- package/dist/{relay-0SD0erwf.mjs → relay-CiP77vga.mjs} +2 -2
- package/dist/run-CtuNY3mE.mjs +234 -0
- package/dist/send-DRYGks8K.mjs +104 -0
- package/dist/short-flags-DkJLWVqh.mjs +1 -0
- package/dist/short-flags-Qf21as0E.mjs +83 -0
- package/dist/src-BMFQKHz4.mjs +3122 -0
- package/dist/{src-BbyEoMn4.mjs → src-BrFfDKux.mjs} +800 -235
- package/dist/{src-Dujmvv4s.mjs → src-BuF9Lbow.mjs} +12580 -5505
- package/dist/{src-DOcK8Jfp.mjs → src-k0A_QpST.mjs} +1024 -590
- package/dist/store-rTgRu8xy.mjs +25 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +612 -0
- package/dist/ui/assets/app-BYxpKJRh.css +1 -0
- package/dist/ui/assets/app-SEFEJLkF.js +112 -0
- package/dist/ui/assets/{index-9dFha9Qn.js → index-Cb6Voq_W.js} +3 -3
- package/dist/ui/assets/theme-CdXvqTQ6.js +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/{ui-CTmO2Xzu.mjs → ui-C2f37TSb.mjs} +57 -25
- package/dist/usage-BuvGcQSk.mjs +256 -0
- package/dist/validate-ObP5lt29.mjs +1 -0
- package/dist/validate-_HjACtk9.mjs +632 -0
- package/dist/verdict-B592j1W3.mjs +84 -0
- package/dist/{watch-CtbgDAcz.mjs → watch-DV3ixCYj.mjs} +45 -17
- package/dist/{workflows-D1_WVukk.mjs → workflows-QLjBhGNS.mjs} +30 -5
- package/dist/worktrees-D3ubG_Qw.mjs +302 -0
- package/dist/yolo-BSH1E_kE.mjs +33 -0
- package/package.json +10 -8
- package/dist/__build_info__-fy6L1Mrd.mjs +0 -615
- package/dist/clean-DaHjs__2.mjs +0 -88
- package/dist/context-DT8MfEIR.mjs +0 -274
- package/dist/format-Dnfv8zMT.mjs +0 -427
- package/dist/in-a-run-pLAZeNys.mjs +0 -22
- package/dist/list-BrH8KRSJ.mjs +0 -110
- package/dist/output-BLQWD6MJ.mjs +0 -950
- package/dist/remove-BeGdqroy.mjs +0 -194
- package/dist/report-DK4LGqwF.mjs +0 -129
- package/dist/send-C8tunWhv.mjs +0 -163
- package/dist/src-CCUKRX51.mjs +0 -1204
- package/dist/ui/assets/app-BNT0SuIz.css +0 -1
- package/dist/ui/assets/app-T7wVAhgx.js +0 -67
- package/dist/ui/assets/theme-3xH5jxKs.js +0 -1
- package/dist/usage-DYUmP0i4.mjs +0 -419
- package/dist/worktrees-DdMYoNOC.mjs +0 -212
|
@@ -1,10 +1,176 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Ai as TOOL_PREFIX, At as heldTimeout, Bt as lowerChild, Ci as isClaudeNode, Cr as parseTemplate, Ct as reportSchemaFor, Ft as failedWith, Ht as startAt, It as stoppedEnd, Kn as countedTokens, Lt as declaredProblem, Mt as calledWorkflows, Oi as timeoutMs, Pt as endedWith, Q as sessionRefusals, Ri as AGENT_DRIVERS, Rt as slotClock, Sr as evaluate, ar as END_DETAIL_LIMIT, di as beforeOf, dn as declaredOutputOf, fi as programsRunBy, fn as MCP_FILE, ft as DETACHED_ENV, gi as markValue, ir as outcomeEnd, ji as integrationFor, jt as runScope, ki as INTEGRATIONS, ln as sessionDriver, mi as parseWorkflow, mn as serversIn, on as envReadsOf, pn as mcpNotText, qr as placeFor, rr as endOf, sr as endFor, vt as planReason, wi as isToolNode, wr as renderTemplate, xt as planNodeSession, z as storePath } from "./src-BuF9Lbow.mjs";
|
|
2
|
+
import { t as runOnce } from "./run-CtuNY3mE.mjs";
|
|
3
|
+
import { n as holdGuard, r as orphanGuard, t as KILL_AFTER_MS } from "./orphan-guard-BFKg8VSd.mjs";
|
|
2
4
|
import path from "node:path";
|
|
3
|
-
import { accessSync, constants,
|
|
5
|
+
import { accessSync, constants, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
5
8
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
9
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
10
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
11
|
+
// ../core/src/project/environ.ts
|
|
12
|
+
/** A variable of the user's. */
|
|
13
|
+
const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
|
|
14
|
+
// ../core/src/store/scrub.ts
|
|
15
|
+
/**
|
|
16
|
+
* @file Masking the values of the user's secret variables in text Ork stores:
|
|
17
|
+
* what a tool node's program prints, and a run's own messages. A plain
|
|
18
|
+
* search, so a value of any size or shape takes time in step with the text:
|
|
19
|
+
* it looks in the text as it is, and with JSON's escapes decoded once and
|
|
20
|
+
* twice, so that it does not matter which characters an encoder chose to
|
|
21
|
+
* escape. It masks the value, and the pieces programs print on their own:
|
|
22
|
+
* each line, and each value of a JSON document. Other encodings, such as
|
|
23
|
+
* base64, are out of its reach (L81).
|
|
24
|
+
*/
|
|
25
|
+
/** Shorter values than this would mask ordinary text: `1`, `true`, a word. */
|
|
26
|
+
const MIN_SECRET_LENGTH = 8;
|
|
27
|
+
const SHORT_ESCAPES = {
|
|
28
|
+
"\"": "\"",
|
|
29
|
+
"\\": "\\",
|
|
30
|
+
"/": "/",
|
|
31
|
+
b: "\b",
|
|
32
|
+
f: "\f",
|
|
33
|
+
n: "\n",
|
|
34
|
+
r: "\r",
|
|
35
|
+
t: " "
|
|
36
|
+
};
|
|
37
|
+
/** The unit an escape at `at` stands for, and how long it is written. */
|
|
38
|
+
const escapeAt = (text, at) => {
|
|
39
|
+
const next = text.charAt(at + 1);
|
|
40
|
+
const short = SHORT_ESCAPES[next];
|
|
41
|
+
if (short !== void 0) return {
|
|
42
|
+
unit: short,
|
|
43
|
+
width: 2
|
|
44
|
+
};
|
|
45
|
+
if (next !== "u") return null;
|
|
46
|
+
const hex = text.slice(at + 2, at + 6);
|
|
47
|
+
return /^[\da-f]{4}$/iu.test(hex) ? {
|
|
48
|
+
unit: String.fromCodePoint(Number(`0x${hex}`)),
|
|
49
|
+
width: 6
|
|
50
|
+
} : null;
|
|
51
|
+
};
|
|
52
|
+
/** The last mark at or before `index`, by a binary search. */
|
|
53
|
+
const lastMark = (marks, index) => {
|
|
54
|
+
let low = 0;
|
|
55
|
+
let high = marks.length - 1;
|
|
56
|
+
while (low < high) {
|
|
57
|
+
const middle = Math.ceil((low + high) / 2);
|
|
58
|
+
if ((marks[middle] ?? 0) <= index) low = middle;
|
|
59
|
+
else high = middle - 1;
|
|
60
|
+
}
|
|
61
|
+
return low;
|
|
62
|
+
};
|
|
63
|
+
/** The view with its escapes decoded; `null` when it has none. */
|
|
64
|
+
const decoded = (view) => {
|
|
65
|
+
const { text } = view;
|
|
66
|
+
const parts = [];
|
|
67
|
+
const decodedAt = [0];
|
|
68
|
+
const sourceAt = [0];
|
|
69
|
+
let from = 0;
|
|
70
|
+
let length = 0;
|
|
71
|
+
let at = text.indexOf("\\");
|
|
72
|
+
while (at !== -1) {
|
|
73
|
+
const escape = escapeAt(text, at);
|
|
74
|
+
if (escape === null) at = text.indexOf("\\", at + 1);
|
|
75
|
+
else {
|
|
76
|
+
parts.push(text.slice(from, at), escape.unit);
|
|
77
|
+
length += at - from;
|
|
78
|
+
decodedAt.push(length);
|
|
79
|
+
sourceAt.push(at);
|
|
80
|
+
length += 1;
|
|
81
|
+
from = at + escape.width;
|
|
82
|
+
decodedAt.push(length);
|
|
83
|
+
sourceAt.push(from);
|
|
84
|
+
at = text.indexOf("\\", from);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (from === 0) return null;
|
|
88
|
+
parts.push(text.slice(from));
|
|
89
|
+
return {
|
|
90
|
+
text: parts.join(""),
|
|
91
|
+
toSource: (index) => {
|
|
92
|
+
const mark = lastMark(decodedAt, index);
|
|
93
|
+
return view.toSource((sourceAt[mark] ?? 0) + (index - (decodedAt[mark] ?? 0)));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
/** The text as it is, then decoded as far as JSON inside JSON goes. */
|
|
98
|
+
const viewsOf = (text) => {
|
|
99
|
+
const raw = {
|
|
100
|
+
text,
|
|
101
|
+
toSource: (index) => index
|
|
102
|
+
};
|
|
103
|
+
const once = decoded(raw);
|
|
104
|
+
const twice = once === null ? null : decoded(once);
|
|
105
|
+
return [
|
|
106
|
+
raw,
|
|
107
|
+
...once === null ? [] : [once],
|
|
108
|
+
...twice === null ? [] : [twice]
|
|
109
|
+
];
|
|
110
|
+
};
|
|
111
|
+
/** Where each secret is in the text, overlapping ones joined. */
|
|
112
|
+
const rangesOf = (text, secrets) => {
|
|
113
|
+
const found = [];
|
|
114
|
+
for (const view of viewsOf(text)) for (const secret of secrets) {
|
|
115
|
+
let at = view.text.indexOf(secret);
|
|
116
|
+
while (at !== -1) {
|
|
117
|
+
found.push([view.toSource(at), view.toSource(at + secret.length)]);
|
|
118
|
+
at = view.text.indexOf(secret, at + secret.length);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const joined = [];
|
|
122
|
+
for (const [start, end] of found.toSorted((a, b) => a[0] - b[0])) {
|
|
123
|
+
const last = joined.at(-1);
|
|
124
|
+
if (last !== void 0 && start < last[1]) last[1] = Math.max(last[1], end);
|
|
125
|
+
else joined.push([start, end]);
|
|
126
|
+
}
|
|
127
|
+
return joined;
|
|
128
|
+
};
|
|
129
|
+
/** The strings and numbers inside a parsed JSON document. */
|
|
130
|
+
const leavesOf = (value) => {
|
|
131
|
+
if (typeof value === "string") return [value];
|
|
132
|
+
if (typeof value === "number") return [String(value)];
|
|
133
|
+
if (typeof value === "object" && value !== null) return Object.values(value).flatMap((item) => leavesOf(item));
|
|
134
|
+
return [];
|
|
135
|
+
};
|
|
136
|
+
/** A value's leaves, when it is a JSON object or array. */
|
|
137
|
+
const jsonLeavesOf = (value) => {
|
|
138
|
+
if (!/^\s*[[{]/u.test(value)) return [];
|
|
139
|
+
try {
|
|
140
|
+
return leavesOf(JSON.parse(value));
|
|
141
|
+
} catch {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/** A value, and the pieces of it a program may well print on their own. */
|
|
146
|
+
const formsOf = (value) => [
|
|
147
|
+
value,
|
|
148
|
+
value.trim(),
|
|
149
|
+
...value.split(/\r?\n|\r/u).map((line) => line.trim()),
|
|
150
|
+
...jsonLeavesOf(value)
|
|
151
|
+
];
|
|
152
|
+
/** Replaces the values, in any form JSON writes them, with `[masked]`. */
|
|
153
|
+
const scrubberOf = (values) => {
|
|
154
|
+
const secrets = [...new Set(values.flatMap((value) => formsOf(value)))].filter((secret) => secret.trim().length >= MIN_SECRET_LENGTH);
|
|
155
|
+
return (text) => {
|
|
156
|
+
if (secrets.length === 0) return text;
|
|
157
|
+
const parts = [];
|
|
158
|
+
let from = 0;
|
|
159
|
+
for (const [start, end] of rangesOf(text, secrets)) {
|
|
160
|
+
parts.push(text.slice(from, start), "[masked]");
|
|
161
|
+
from = end;
|
|
162
|
+
}
|
|
163
|
+
parts.push(text.slice(from));
|
|
164
|
+
return parts.join("");
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
/** The values of the variables named that are set. */
|
|
168
|
+
const envValuesOf = (names, environ) => names.flatMap((name) => {
|
|
169
|
+
const value = readEnv(environ, name);
|
|
170
|
+
return value === void 0 ? [] : [value];
|
|
171
|
+
});
|
|
172
|
+
/** What a run masks in a message of its own: the named variables' values. */
|
|
173
|
+
const runScrubber = (names, environ) => scrubberOf(envValuesOf(names, environ));
|
|
8
174
|
// ../nodes/src/call.ts
|
|
9
175
|
/**
|
|
10
176
|
* @file The `workflow.call` node type (RFC-004): renders what the node asks for
|
|
@@ -18,24 +184,17 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|
|
18
184
|
*/
|
|
19
185
|
const limitedCall = (result) => {
|
|
20
186
|
if (result.ok) return result;
|
|
21
|
-
|
|
187
|
+
const why = result.end?.detail ?? result.error;
|
|
188
|
+
return result.end?.kind === "plan_limit" || (why ?? "").includes("Not started: ") || result.output["runStatus"] === "quota_exhausted" ? {
|
|
22
189
|
...result,
|
|
23
|
-
limited:
|
|
190
|
+
limited: why ?? "the called run reached a limit"
|
|
24
191
|
} : result;
|
|
25
192
|
};
|
|
26
193
|
const callExecutor = (context) => {
|
|
27
194
|
const { node, calls } = context;
|
|
28
|
-
if (node.type !== "workflow.call") return Promise.resolve({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
error: `${context.id} is not a call`
|
|
32
|
-
});
|
|
33
|
-
if (calls === void 0) return Promise.resolve({
|
|
34
|
-
ok: false,
|
|
35
|
-
output: { status: "fail" },
|
|
36
|
-
error: "This run cannot call workflows."
|
|
37
|
-
});
|
|
38
|
-
const scope = runScope(context.inputs, context.outputs, context.runId);
|
|
195
|
+
if (node.type !== "workflow.call") return Promise.resolve(failedWith(endFor("unknown", {}, `${context.id} is not a call`), {}));
|
|
196
|
+
if (calls === void 0) return Promise.resolve(failedWith(endFor("invalid_node", { reason: "this run cannot call workflows" }), { status: "fail" }));
|
|
197
|
+
const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
|
|
39
198
|
const render = (text) => {
|
|
40
199
|
const template = parseTemplate(text);
|
|
41
200
|
return template.ok ? renderTemplate(template.parts, scope) : text;
|
|
@@ -61,33 +220,82 @@ const callExecutor = (context) => {
|
|
|
61
220
|
}
|
|
62
221
|
}, context.signal).then(limitedCall);
|
|
63
222
|
};
|
|
223
|
+
// ../nodes/src/claude-plan.ts
|
|
224
|
+
/**
|
|
225
|
+
* @file What a Claude node's session is planned with, read from the node's
|
|
226
|
+
* context, and what it is told beyond its plan: the note of a sent-back input
|
|
227
|
+
* on re-entry, a resumed leader's message, and the turn for messages that
|
|
228
|
+
* came in as it finished.
|
|
229
|
+
*/
|
|
230
|
+
/** The plan options every session of a node starts from. */
|
|
231
|
+
const planOptionsOf = (context, inPlace) => {
|
|
232
|
+
const planOptions = {
|
|
233
|
+
cwd: context.cwd,
|
|
234
|
+
inputs: context.inputs,
|
|
235
|
+
outputs: context.outputs,
|
|
236
|
+
inPlace,
|
|
237
|
+
workflowDir: context.workflowDir,
|
|
238
|
+
projectRoot: context.projectRoot,
|
|
239
|
+
...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
|
|
240
|
+
pluginDir: path.join(context.runDir, "plugins", context.id)
|
|
241
|
+
};
|
|
242
|
+
if (context.checkout !== void 0) planOptions.checkout = context.checkout;
|
|
243
|
+
if (context.brief !== void 0) planOptions.brief = context.brief;
|
|
244
|
+
if (context.template !== void 0) planOptions.template = context.template;
|
|
245
|
+
if (context.item !== void 0) planOptions.item = context.item;
|
|
246
|
+
if (context.ownership !== void 0) planOptions.ownership = context.ownership;
|
|
247
|
+
if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
|
|
248
|
+
if (context.claims !== void 0) planOptions.claims = context.claims;
|
|
249
|
+
return planOptions;
|
|
250
|
+
};
|
|
251
|
+
const MAX_INPUT = 8192;
|
|
252
|
+
const inputJson = (input) => JSON.stringify(input ?? {}, void 0, 2);
|
|
253
|
+
/** What a sent-back input is whole, for `read_input`, when its note cuts it. */
|
|
254
|
+
const wholeInput = (input) => {
|
|
255
|
+
const json = inputJson(input);
|
|
256
|
+
return json.length > MAX_INPUT ? json : void 0;
|
|
257
|
+
};
|
|
258
|
+
/** A re-entry's note of what was sent back; `read_input` has a cut rest. */
|
|
259
|
+
const reentryNote = (round, input) => {
|
|
260
|
+
const json = inputJson(input);
|
|
261
|
+
const cut = json.length > MAX_INPUT;
|
|
262
|
+
const body = cut ? `${json.slice(0, MAX_INPUT)}\n…` : json;
|
|
263
|
+
const rest = cut ? `\n\nThat is the first ${String(MAX_INPUT)} of its ${String(json.length)} characters. Call read_input for all of it before you act on it.` : "";
|
|
264
|
+
return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\`${rest}`;
|
|
265
|
+
};
|
|
266
|
+
/**
|
|
267
|
+
* What a resumed leader is told: it already has its brief and its earlier work,
|
|
268
|
+
* so only the new round, and the new task when its owner sent one.
|
|
269
|
+
*/
|
|
270
|
+
const resumeMessage = (round, input, brief) => [brief === void 0 ? sentBack(round, input) : `## New task from \`${brief.from}\`\n\n${brief.text}`, "End with a new structured report."].join("\n\n");
|
|
271
|
+
/** A first-round resume is a session the run was cut off in (`ork resume`). */
|
|
272
|
+
const sentBack = (round, input) => round === 1 ? "Ork was stopped while you worked on this task, and the run is resumed. Your earlier turns are above: check what you finished, and carry on from there." : `The workflow sent your task back to you.${reentryNote(round, input)}`;
|
|
273
|
+
/** What a session is told when messages came in as it finished. */
|
|
274
|
+
const lateMessage = (count) => `[ork] ${String(count)} message${count === 1 ? "" : "s"} came in while you finished. Call read_inbox, act on ${count === 1 ? "it" : "them"} if needed, and end with a new structured report.`;
|
|
64
275
|
// ../nodes/src/claude-result.ts
|
|
65
276
|
/**
|
|
66
277
|
* @file A finished session as a node's result: its report is the output, held
|
|
67
|
-
* to what the node declares (RFC-010)
|
|
278
|
+
* to what the node declares (RFC-010), and how it ended is its end kind
|
|
279
|
+
* (specs/runtime/session-ends.md).
|
|
68
280
|
*/
|
|
281
|
+
/** How a session with no report ended; a driver's `done` without one is not. */
|
|
282
|
+
const withoutReport = (outcome) => {
|
|
283
|
+
const end = outcomeEnd(outcome);
|
|
284
|
+
return end.kind === "done" || end.kind === "reported_failure" ? endFor("no_report", {}, end.detail) : end;
|
|
285
|
+
};
|
|
69
286
|
/** A finished session as a node result: its report is the node's output. */
|
|
70
287
|
const resultOf = (outcome, declared) => {
|
|
71
|
-
if (outcome === null) return {
|
|
72
|
-
ok: false,
|
|
73
|
-
output: {
|
|
74
|
-
status: "fail",
|
|
75
|
-
summary: "the session ended without a result"
|
|
76
|
-
},
|
|
77
|
-
error: "the session ended without a result"
|
|
78
|
-
};
|
|
288
|
+
if (outcome === null) return failedWith(endFor("driver_crashed", {}, "the session ended without a result"));
|
|
79
289
|
const { report, usage } = outcome;
|
|
80
290
|
if (report === null) {
|
|
81
|
-
const
|
|
291
|
+
const end = withoutReport(outcome);
|
|
82
292
|
return {
|
|
83
|
-
|
|
84
|
-
output: {
|
|
293
|
+
...failedWith(end, {
|
|
85
294
|
status: "fail",
|
|
86
|
-
summary:
|
|
295
|
+
summary: end.message,
|
|
87
296
|
usage
|
|
88
|
-
},
|
|
297
|
+
}),
|
|
89
298
|
usage,
|
|
90
|
-
error: `ended without a report (${outcome.subtype}): ${reason}`,
|
|
91
299
|
sessionId: outcome.sessionId
|
|
92
300
|
};
|
|
93
301
|
}
|
|
@@ -101,7 +309,7 @@ const resultOf = (outcome, declared) => {
|
|
|
101
309
|
};
|
|
102
310
|
if (report.branch !== void 0) output["branch"] = report.branch;
|
|
103
311
|
const problem = declaredProblem(declared, output);
|
|
104
|
-
if (problem !== null) return {
|
|
312
|
+
if (problem !== null) return endedWith({
|
|
105
313
|
ok: false,
|
|
106
314
|
output: {
|
|
107
315
|
status: "fail",
|
|
@@ -111,18 +319,163 @@ const resultOf = (outcome, declared) => {
|
|
|
111
319
|
...report.branch === void 0 ? {} : { branch: report.branch }
|
|
112
320
|
},
|
|
113
321
|
usage,
|
|
114
|
-
error: problem,
|
|
115
322
|
...ran
|
|
116
|
-
};
|
|
117
|
-
const
|
|
118
|
-
|
|
323
|
+
}, endFor("invalid_report", { reason: problem }));
|
|
324
|
+
const ok = report.status !== "fail";
|
|
325
|
+
return endedWith({
|
|
326
|
+
ok,
|
|
119
327
|
output,
|
|
120
328
|
usage,
|
|
121
329
|
...ran
|
|
330
|
+
}, endFor(ok ? "done" : "reported_failure"));
|
|
331
|
+
};
|
|
332
|
+
// ../nodes/src/mcp-servers.ts
|
|
333
|
+
/**
|
|
334
|
+
* @file Reading an MCP server's definition from the project's `.mcp.json`, the
|
|
335
|
+
* file Claude Code reads too, so a server is defined in one place (RFC-006).
|
|
336
|
+
* `${VAR}` and `${VAR:-default}` read the user's environment, which is where
|
|
337
|
+
* secrets stay: Ork stores none, and a run masks each value read this way. A
|
|
338
|
+
* server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
|
|
339
|
+
* so it is refused.
|
|
340
|
+
*/
|
|
341
|
+
const VARIABLE = /\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}/gu;
|
|
342
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
343
|
+
const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
|
|
344
|
+
const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
|
|
345
|
+
/** The server as written, or why Ork can't call it. */
|
|
346
|
+
const readMcpServer = (root, name) => {
|
|
347
|
+
const servers = serversIn(root);
|
|
348
|
+
if (!servers.ok) return servers;
|
|
349
|
+
const known = Object.keys(servers.value);
|
|
350
|
+
const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
|
|
351
|
+
if (!isRecord(server)) return {
|
|
352
|
+
ok: false,
|
|
353
|
+
error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
|
|
354
|
+
};
|
|
355
|
+
if ("oauth" in server) return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: `MCP server \`${name}\` signs in with OAuth, whose tokens are Claude Code's: give it a token through headers and \`env:\`, or call it from a session`
|
|
358
|
+
};
|
|
359
|
+
const notText = mcpNotText(server);
|
|
360
|
+
if (notText !== void 0) return {
|
|
361
|
+
ok: false,
|
|
362
|
+
error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
|
|
363
|
+
};
|
|
364
|
+
const { type, url, command } = server;
|
|
365
|
+
if (type === "http" && typeof url === "string") return {
|
|
366
|
+
ok: true,
|
|
367
|
+
value: {
|
|
368
|
+
kind: "http",
|
|
369
|
+
url,
|
|
370
|
+
headers: stringsOf(server["headers"])
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
if ((type === void 0 || type === "stdio") && typeof command === "string") return {
|
|
374
|
+
ok: true,
|
|
375
|
+
value: {
|
|
376
|
+
kind: "stdio",
|
|
377
|
+
command,
|
|
378
|
+
args: listOf(server["args"]),
|
|
379
|
+
env: stringsOf(server["env"])
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
|
|
385
|
+
};
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* The named servers' entries as written, for a session: `${VAR}` stays text for
|
|
389
|
+
* the `claude` that loads them to expand. The compiler has checked the names.
|
|
390
|
+
*/
|
|
391
|
+
const namedMcpEntries = (root, names) => {
|
|
392
|
+
if (names.length === 0) return {
|
|
393
|
+
ok: true,
|
|
394
|
+
value: {}
|
|
395
|
+
};
|
|
396
|
+
const servers = serversIn(root);
|
|
397
|
+
if (!servers.ok) return servers;
|
|
398
|
+
const mcpServers = {};
|
|
399
|
+
for (const name of names) {
|
|
400
|
+
const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
|
|
401
|
+
if (!isRecord(server)) return {
|
|
402
|
+
ok: false,
|
|
403
|
+
error: `no MCP server \`${name}\` in ${MCP_FILE}`
|
|
404
|
+
};
|
|
405
|
+
mcpServers[name] = server;
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
value: { mcpServers }
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
/** Each variable a server reads, and whether it has a default. */
|
|
413
|
+
const readsOf = (server) => {
|
|
414
|
+
return (server.kind === "stdio" ? [
|
|
415
|
+
server.command,
|
|
416
|
+
...server.args,
|
|
417
|
+
...Object.values(server.env)
|
|
418
|
+
] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
|
|
419
|
+
name: String(match[1]),
|
|
420
|
+
fallback: match[2] !== void 0
|
|
421
|
+
})));
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* The server with its variables filled in, and the values to mask: all it read
|
|
425
|
+
* but those `plain` names, which aren't secret.
|
|
426
|
+
*/
|
|
427
|
+
const expandServer = (server, environ, plain = []) => {
|
|
428
|
+
const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
|
|
429
|
+
if (unset !== void 0) return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: `${MCP_FILE} reads ${unset.name}, which is not set`
|
|
432
|
+
};
|
|
433
|
+
const values = [];
|
|
434
|
+
const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
|
|
435
|
+
const value = readEnv(environ, name);
|
|
436
|
+
if (value !== void 0 && !plain.includes(name)) values.push(value);
|
|
437
|
+
return value ?? fallback ?? "";
|
|
438
|
+
});
|
|
439
|
+
const fillAll = (record) => Object.fromEntries(Object.entries(record).map(([key, text]) => [key, fill(text)]));
|
|
440
|
+
return {
|
|
441
|
+
ok: true,
|
|
442
|
+
value: {
|
|
443
|
+
server: server.kind === "stdio" ? {
|
|
444
|
+
kind: "stdio",
|
|
445
|
+
command: fill(server.command),
|
|
446
|
+
args: server.args.map((arg) => fill(arg)),
|
|
447
|
+
env: fillAll(server.env)
|
|
448
|
+
} : {
|
|
449
|
+
kind: "http",
|
|
450
|
+
url: fill(server.url),
|
|
451
|
+
headers: fillAll(server.headers)
|
|
452
|
+
},
|
|
453
|
+
values
|
|
454
|
+
}
|
|
122
455
|
};
|
|
123
|
-
if (!result.ok) result.error = report.summary;
|
|
124
|
-
return result;
|
|
125
456
|
};
|
|
457
|
+
/** What a workflow's MCP servers read of the user's, unless called not secret. */
|
|
458
|
+
const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
|
|
459
|
+
if (node.type !== "mcp.call") return [];
|
|
460
|
+
const read = readMcpServer(root, node.server);
|
|
461
|
+
return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
|
|
462
|
+
});
|
|
463
|
+
/** A stdio server's program, when its name can be known before it is filled in. */
|
|
464
|
+
const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
|
|
465
|
+
/**
|
|
466
|
+
* What stops a workflow's `mcp.call` nodes, found before a run spends anything:
|
|
467
|
+
* a server that isn't there or can't be called, a program that isn't installed,
|
|
468
|
+
* or a variable that is not set.
|
|
469
|
+
*/
|
|
470
|
+
const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
471
|
+
if (node.type !== "mcp.call" || finished.has(id)) return [];
|
|
472
|
+
const read = readMcpServer(root, node.server);
|
|
473
|
+
if (!read.ok) return [`${id}: ${read.error}`];
|
|
474
|
+
const given = new Set(Object.keys(node.env ?? {}));
|
|
475
|
+
const unset = readsOf(read.value).flatMap(({ name, fallback }) => fallback || given.has(name) || readEnv(environ, name) !== void 0 ? [] : [`${id}: ${MCP_FILE} reads ${name}, which is not set`]);
|
|
476
|
+
const program = programOf(read.value);
|
|
477
|
+
return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
|
|
478
|
+
});
|
|
126
479
|
/** The only built-in tools a fork has: it reads, and reports. */
|
|
127
480
|
const FORK_TOOLS = [
|
|
128
481
|
"Read",
|
|
@@ -158,7 +511,7 @@ const forkPrompt = (question, passOn) => [
|
|
|
158
511
|
/** The fork's report: one of the options or the pass-on, and a note. */
|
|
159
512
|
const forkReportSchema = (ids) => {
|
|
160
513
|
const node = {
|
|
161
|
-
type: "
|
|
514
|
+
type: "agent.worker",
|
|
162
515
|
prompt: "",
|
|
163
516
|
output: { schema: {
|
|
164
517
|
choice: [...ids],
|
|
@@ -274,28 +627,42 @@ const askFork = async (context, owner, question, deps) => {
|
|
|
274
627
|
if (signal.aborted) return null;
|
|
275
628
|
return answerOf(outcome, [...question.options.map((option) => option.id), passOn]);
|
|
276
629
|
};
|
|
277
|
-
/** What the person sees of a question its asker's owner passed on. */
|
|
278
|
-
const passedOnContext = (context, owner, note) => [...context === void 0 ? [] : [context, ""], note === void 0 ? `${owner} passed this on.` : `${owner} passed this on: ${note}`].join("\n");
|
|
630
|
+
/** What the person sees of a question its asker's owner passed on. */
|
|
631
|
+
const passedOnContext = (context, owner, note) => [...context === void 0 ? [] : [context, ""], note === void 0 ? `${owner} passed this on.` : `${owner} passed this on: ${note}`].join("\n");
|
|
632
|
+
/**
|
|
633
|
+
* The node's channel to the person: what it asked is withdrawn when it stops,
|
|
634
|
+
* so a stopped node leaves no decision open.
|
|
635
|
+
*/
|
|
636
|
+
const withdrawnOnStop = (context) => {
|
|
637
|
+
const { human } = context;
|
|
638
|
+
const signalOf = (asked) => asked === void 0 ? context.signal : AbortSignal.any([asked, context.signal]);
|
|
639
|
+
return {
|
|
640
|
+
permit: (request) => human.permit({
|
|
641
|
+
...request,
|
|
642
|
+
signal: signalOf(request.signal)
|
|
643
|
+
}),
|
|
644
|
+
ask: (question) => human.ask({
|
|
645
|
+
...question,
|
|
646
|
+
signal: signalOf(question.signal)
|
|
647
|
+
}),
|
|
648
|
+
...human.settled === void 0 ? {} : { settled: human.settled }
|
|
649
|
+
};
|
|
650
|
+
};
|
|
279
651
|
/**
|
|
280
652
|
* The child's channel when its owner takes its questions first: a question goes
|
|
281
653
|
* to a fork of the owner's session, then to the person if the fork passes it on
|
|
282
654
|
* or can't answer. Permission prompts go to the person as before.
|
|
283
655
|
*/
|
|
284
656
|
const ownerFirst = (context, owner, deps) => {
|
|
285
|
-
const
|
|
657
|
+
const human = withdrawnOnStop(context);
|
|
286
658
|
return {
|
|
287
659
|
permit: (request) => human.permit(request),
|
|
288
660
|
ask: async (question) => {
|
|
289
661
|
const answered = await askFork(context, owner, question, deps);
|
|
290
662
|
if (context.signal.aborted) throw new Error(`${context.id} was stopped`);
|
|
291
|
-
|
|
292
|
-
if (answered === null) return human.ask({
|
|
293
|
-
...question,
|
|
294
|
-
signal
|
|
295
|
-
});
|
|
663
|
+
if (answered === null) return human.ask(question);
|
|
296
664
|
if (answered.choice === passOnId(question.options)) return human.ask({
|
|
297
665
|
...question,
|
|
298
|
-
signal,
|
|
299
666
|
owner: owner.id,
|
|
300
667
|
context: passedOnContext(question.context, owner.id, answered.note)
|
|
301
668
|
});
|
|
@@ -347,50 +714,79 @@ const withUsageOf = (first, later) => {
|
|
|
347
714
|
*/
|
|
348
715
|
const afterLateTurn = (first, later) => {
|
|
349
716
|
const merged = withUsageOf(first, later);
|
|
350
|
-
|
|
351
|
-
|
|
717
|
+
if (later.report !== null) return merged;
|
|
718
|
+
const { end: _later, ...rest } = merged;
|
|
719
|
+
return {
|
|
720
|
+
...rest,
|
|
352
721
|
report: first.report,
|
|
353
722
|
subtype: first.subtype,
|
|
723
|
+
...first.end === void 0 ? {} : { end: first.end },
|
|
354
724
|
text: first.text,
|
|
355
725
|
errors: first.errors
|
|
356
|
-
}
|
|
726
|
+
};
|
|
727
|
+
};
|
|
728
|
+
/** The least of the limits that are set; `undefined` with none. */
|
|
729
|
+
const least = (...limits) => {
|
|
730
|
+
const known = limits.filter((limit) => limit !== void 0);
|
|
731
|
+
return known.length === 0 ? void 0 : Math.min(...known);
|
|
357
732
|
};
|
|
733
|
+
/** A cap less what this activation used, beside what a quota says is left. */
|
|
734
|
+
const leftOf = (caps, used, quotaLeft) => least(...caps.map((cap) => cap === void 0 ? void 0 : cap - used), quotaLeft);
|
|
358
735
|
/**
|
|
359
|
-
* What another turn of a session may use. `spent
|
|
360
|
-
* activation's, which neither the node's cap nor the run's
|
|
361
|
-
* The quota has: the ledger took it as the session
|
|
362
|
-
*
|
|
736
|
+
* What another turn of a session may use. `spent`, `tokens` (counted, RFC-015)
|
|
737
|
+
* and `turns` are this activation's, which neither the node's cap nor the run's
|
|
738
|
+
* budget has counted. The quota has: the ledger took it as the session
|
|
739
|
+
* finished, so what it says is left is left. The run's tokens left have too:
|
|
740
|
+
* only the node's own `budgetTokens` takes `tokens` off. `null` when nothing
|
|
741
|
+
* is, and the turn does not start. `turnsAtLeast` lifts the turns left to that
|
|
742
|
+
* many (a failed check's resume: `CHECK_RETRY_TURNS`).
|
|
363
743
|
*/
|
|
364
|
-
const lateLimits = (spec,
|
|
365
|
-
const
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
744
|
+
const lateLimits = (spec, used, runLeft, quotaLeft, tokensLeft = {}, turnsAtLeast = 0) => {
|
|
745
|
+
const budgetUsd = leftOf([spec.budgetUsd, runLeft], used.spent, quotaLeft);
|
|
746
|
+
const budgetTokens = leftOf([spec.budgetTokens], used.tokens ?? 0, least(tokensLeft.run, tokensLeft.quota));
|
|
747
|
+
const maxTurns = spec.maxTurns === void 0 ? void 0 : Math.max(spec.maxTurns - used.turns, turnsAtLeast);
|
|
748
|
+
if ([
|
|
749
|
+
budgetUsd,
|
|
750
|
+
budgetTokens,
|
|
751
|
+
maxTurns
|
|
752
|
+
].some((left) => (left ?? 1) <= 0)) return null;
|
|
370
753
|
return {
|
|
371
754
|
...budgetUsd === void 0 ? {} : { budgetUsd },
|
|
755
|
+
...budgetTokens === void 0 ? {} : { budgetTokens },
|
|
372
756
|
...maxTurns === void 0 ? {} : { maxTurns }
|
|
373
757
|
};
|
|
374
758
|
};
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
759
|
+
const reached = (cap, used) => cap !== void 0 && used >= cap;
|
|
760
|
+
/**
|
|
761
|
+
* Why the run's budget stopped a session that reached its cap, if it did. A
|
|
762
|
+
* unit blames the run when the session reached it, or when it is the only cap
|
|
763
|
+
* the session had; otherwise the node's own cap in the other unit may have
|
|
764
|
+
* stopped it. A resumed turn's dollars are its own: its cost is the session's
|
|
765
|
+
* total (ADR-046) while its cap is what was left, so `priorCostUsd` comes off,
|
|
766
|
+
* read as `ownSpend` reads it (a lower total counted again from zero).
|
|
767
|
+
*/
|
|
768
|
+
const runSpent = (run, asked, outcome, priorCostUsd) => {
|
|
769
|
+
const total = outcome.usage.costUsd;
|
|
770
|
+
const turnCostUsd = total < priorCostUsd ? total : total - priorCostUsd;
|
|
771
|
+
if (run.tokens !== void 0 && (reached(asked.budgetTokens, countedTokens(outcome.usage)) || asked.budgetUsd === void 0)) return `the run's budget of ${String(run.tokens)} tokens is spent`;
|
|
772
|
+
return run.usd !== void 0 && (reached(asked.budgetUsd, turnCostUsd) || asked.budgetTokens === void 0) ? `the run's budget of $${String(run.usd)} is spent` : void 0;
|
|
380
773
|
};
|
|
381
774
|
/**
|
|
382
775
|
* Whether a limit across runs, rather than the node's work, ended a node: a
|
|
383
776
|
* session whose cap was a quota's and that ran into it, or one its plan refused
|
|
384
|
-
* and that gave no report. A
|
|
777
|
+
* and that gave no report. A session whose cap was the run's budget and that
|
|
778
|
+
* ran into it stops the run as `budget_exhausted` (`run`, from `runCapsOf`). A
|
|
779
|
+
* node that still succeeded is left alone.
|
|
385
780
|
*/
|
|
386
|
-
const limitWatch = (context) => {
|
|
781
|
+
const limitWatch = (context, run = {}) => {
|
|
387
782
|
let why;
|
|
783
|
+
let runWhy;
|
|
388
784
|
return {
|
|
389
785
|
stoppedBy: (reason) => {
|
|
390
786
|
why = reason;
|
|
391
787
|
},
|
|
392
|
-
session: (asked) => {
|
|
393
|
-
const left = context.quota?.()
|
|
788
|
+
session: (asked, priorCostUsd = 0) => {
|
|
789
|
+
const { leftUsd: left, leftTokens } = context.quota?.() ?? {};
|
|
394
790
|
let refused;
|
|
395
791
|
return {
|
|
396
792
|
saw: (event) => {
|
|
@@ -398,26 +794,151 @@ const limitWatch = (context) => {
|
|
|
398
794
|
},
|
|
399
795
|
ended: (outcome) => {
|
|
400
796
|
if (refused !== void 0 && (outcome?.report ?? null) === null) why = refused;
|
|
401
|
-
|
|
797
|
+
const capped = left !== void 0 && asked.budgetUsd !== void 0 && asked.budgetUsd >= left || leftTokens !== void 0 && asked.budgetTokens !== void 0 && asked.budgetTokens >= leftTokens;
|
|
798
|
+
const spent = outcome !== null && outcomeEnd(outcome).kind === "budget_limit";
|
|
799
|
+
if (capped && spent) why = context.quota?.().blocked ?? "a session reached what was left of a quota";
|
|
800
|
+
else if (spent) runWhy = runSpent(run, asked, outcome, priorCostUsd);
|
|
402
801
|
}
|
|
403
802
|
};
|
|
404
803
|
},
|
|
405
|
-
result: (result) =>
|
|
406
|
-
|
|
407
|
-
|
|
804
|
+
result: (result) => {
|
|
805
|
+
if (result.ok) return result;
|
|
806
|
+
if (why !== void 0) return {
|
|
807
|
+
...endedWith(result, endFor("plan_limit", {}, why)),
|
|
808
|
+
limited: why
|
|
809
|
+
};
|
|
810
|
+
if (runWhy !== void 0) return {
|
|
811
|
+
...result,
|
|
812
|
+
limited: runWhy,
|
|
813
|
+
stopsAs: "budget_exhausted"
|
|
814
|
+
};
|
|
815
|
+
const { end } = result;
|
|
816
|
+
return end?.kind === "plan_limit" ? {
|
|
817
|
+
...result,
|
|
818
|
+
limited: end.message
|
|
819
|
+
} : result;
|
|
408
820
|
}
|
|
409
821
|
};
|
|
410
822
|
};
|
|
411
|
-
/**
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
823
|
+
/**
|
|
824
|
+
* No session started: nothing was left for one, or nothing to start it. Its
|
|
825
|
+
* summary keeps Ork's reason, `end.detail` too; the message is the kind's.
|
|
826
|
+
*/
|
|
827
|
+
const notStarted = (kind, reason, limited) => ({
|
|
828
|
+
...failedWith(endFor(kind, {
|
|
829
|
+
reason,
|
|
830
|
+
program: "an agent driver"
|
|
831
|
+
}, reason), {
|
|
416
832
|
status: "fail",
|
|
417
833
|
summary: `not started: ${reason}`
|
|
418
|
-
},
|
|
834
|
+
}),
|
|
419
835
|
...limited === void 0 ? {} : { limited }
|
|
420
836
|
});
|
|
837
|
+
// ../nodes/src/session-driver.ts
|
|
838
|
+
/**
|
|
839
|
+
* @file The driver a Claude node's session starts on (core's `sessionDriver`),
|
|
840
|
+
* and, when its model names that driver (`command-code.deepseek/…`, RFC-015),
|
|
841
|
+
* the fit check on the value an item rendered: the run's own check before it
|
|
842
|
+
* started saw only the written model.
|
|
843
|
+
*/
|
|
844
|
+
/** What stops the node from running on `driver`, in one line, or nothing. */
|
|
845
|
+
const refusalOn = (context, driver, drivers) => {
|
|
846
|
+
const id = context.template ?? context.id;
|
|
847
|
+
const node = context.workflow.nodes[id];
|
|
848
|
+
const descriptor = drivers.find((d) => d.id === driver);
|
|
849
|
+
if (node === void 0 || descriptor === void 0) return void 0;
|
|
850
|
+
const refusals = sessionRefusals(id, node, context.workflow, descriptor);
|
|
851
|
+
return refusals.length === 0 ? void 0 : refusals.map((r) => r.message).join("; ");
|
|
852
|
+
};
|
|
853
|
+
/** The session's driver, or the node's result when none fits. */
|
|
854
|
+
const sessionDriverOf = (context, plan, options) => {
|
|
855
|
+
const driver = sessionDriver({
|
|
856
|
+
pinned: plan.pinned,
|
|
857
|
+
override: options.driver,
|
|
858
|
+
named: plan.driver,
|
|
859
|
+
ready: options.readyDriver
|
|
860
|
+
});
|
|
861
|
+
if (driver === void 0) return notStarted("tool_missing", "no agent driver is ready; see `ork doctor`");
|
|
862
|
+
const unfit = plan.pinned === void 0 ? void 0 : refusalOn(context, driver, options.drivers ?? AGENT_DRIVERS);
|
|
863
|
+
return unfit === void 0 ? driver : notStarted("invalid_node", unfit);
|
|
864
|
+
};
|
|
865
|
+
// ../nodes/src/start-budgets.ts
|
|
866
|
+
/** What a session may spend as it starts: the least of the budget and any quota. */
|
|
867
|
+
const budgetNow = (context) => context.budgetLeftNow === void 0 ? context.budgetLeftUsd : least(context.budgetLeftNow(), context.quota?.().leftUsd);
|
|
868
|
+
/** The counted tokens a session may use as it starts: the run's and a quota's. */
|
|
869
|
+
const tokensNow = (context) => least(context.tokensLeftNow?.(), context.quota?.().leftTokens);
|
|
870
|
+
/**
|
|
871
|
+
* What is `left` now, with `own` (the node's cap) set aside through `reserve`
|
|
872
|
+
* in the same step; with nothing left, it waits for a worker's reserve to
|
|
873
|
+
* free.
|
|
874
|
+
*/
|
|
875
|
+
const startWith = async (context, left, own, reserve) => {
|
|
876
|
+
const now = left();
|
|
877
|
+
if (now !== void 0 && now <= 0) {
|
|
878
|
+
context.reserveBudget?.(0);
|
|
879
|
+
context.reserveTokens?.(0);
|
|
880
|
+
return await context.budgetFreed?.(context.signal) === true ? startWith(context, left, own, reserve) : now;
|
|
881
|
+
}
|
|
882
|
+
reserve?.(own === void 0 || now === void 0 ? now : Math.min(own, now));
|
|
883
|
+
return now;
|
|
884
|
+
};
|
|
885
|
+
/**
|
|
886
|
+
* What a node's first session may spend, set aside from the run's budget in the
|
|
887
|
+
* same step that reads it (ADR-077), so a node starting beside it is never
|
|
888
|
+
* offered the same part: the least of what is left and the node's own
|
|
889
|
+
* `budgetUsd`. With nothing left, it waits for a worker running beside it to
|
|
890
|
+
* finish, as long as one may.
|
|
891
|
+
*/
|
|
892
|
+
const budgetToStart = (context) => startWith(context, () => budgetNow(context), ownCaps(context).usd, context.reserveBudget);
|
|
893
|
+
/** The node's own caps: its `budgetUsd` and `budgetTokens`, else the defaults'. */
|
|
894
|
+
const ownCaps = (context) => isClaudeNode(context.node) ? {
|
|
895
|
+
usd: context.node.budgetUsd ?? context.workflow.defaults?.budgetUsd,
|
|
896
|
+
tokens: context.node.budgetTokens ?? context.workflow.defaults?.budgetTokens
|
|
897
|
+
} : {};
|
|
898
|
+
/**
|
|
899
|
+
* Where the run's budget, not the node's own cap, caps the node's first
|
|
900
|
+
* session: the run has a budget in that unit, and what `startBudgets` left the
|
|
901
|
+
* session is below the node's own cap, or it has none. A later turn of the
|
|
902
|
+
* session keeps them: both its caps go down by what it used, and the run's by
|
|
903
|
+
* what others used too.
|
|
904
|
+
*/
|
|
905
|
+
const runCapsOf = (context, planOptions) => {
|
|
906
|
+
const own = ownCaps(context);
|
|
907
|
+
const limits = context.workflow.limits;
|
|
908
|
+
const caps = {};
|
|
909
|
+
const usd = planOptions.budgetLeftUsd;
|
|
910
|
+
if (context.budgetLeftNow?.() !== void 0 && limits?.budgetUsd !== void 0 && usd !== void 0 && usd < (own.usd ?? Infinity)) caps.usd = limits.budgetUsd;
|
|
911
|
+
const tokens = planOptions.budgetLeftTokens;
|
|
912
|
+
if (context.tokensLeftNow?.() !== void 0 && limits?.budgetTokens !== void 0 && tokens !== void 0 && tokens < (own.tokens ?? Infinity)) caps.tokens = limits.budgetTokens;
|
|
913
|
+
return caps;
|
|
914
|
+
};
|
|
915
|
+
/** `budgetToStart` in counted tokens: the run's `budgetTokens` and a quota's. */
|
|
916
|
+
const tokensToStart = (context) => startWith(context, () => tokensNow(context), ownCaps(context).tokens, context.reserveTokens);
|
|
917
|
+
/**
|
|
918
|
+
* What a node's first session may spend, in dollars and in counted tokens, set
|
|
919
|
+
* into `planOptions`; the node's result instead when nothing is left of
|
|
920
|
+
* either.
|
|
921
|
+
*/
|
|
922
|
+
const startBudgets = async (context, planOptions) => {
|
|
923
|
+
const usd = await budgetToStart(context);
|
|
924
|
+
if (usd !== void 0 && usd <= 0) return notStarted("budget_limit", "nothing is left of the run's budget");
|
|
925
|
+
if (usd !== void 0) planOptions.budgetLeftUsd = usd;
|
|
926
|
+
const waited = { done: false };
|
|
927
|
+
const tokens = await tokensToStart({
|
|
928
|
+
...context,
|
|
929
|
+
budgetFreed: async (signal) => {
|
|
930
|
+
waited.done = true;
|
|
931
|
+
return await context.budgetFreed?.(signal) ?? false;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
if (tokens !== void 0 && tokens <= 0) return notStarted("budget_limit", "nothing is left of the run's tokens");
|
|
935
|
+
if (waited.done) {
|
|
936
|
+
context.reserveTokens?.(0);
|
|
937
|
+
return startBudgets(context, planOptions);
|
|
938
|
+
}
|
|
939
|
+
if (tokens !== void 0) planOptions.budgetLeftTokens = tokens;
|
|
940
|
+
return null;
|
|
941
|
+
};
|
|
421
942
|
// ../nodes/src/tool-output.ts
|
|
422
943
|
/**
|
|
423
944
|
* @file A tool node's structured result (`output: { format: json }`): the file
|
|
@@ -660,315 +1181,172 @@ const tokenize = (command) => {
|
|
|
660
1181
|
args
|
|
661
1182
|
};
|
|
662
1183
|
};
|
|
663
|
-
// ../nodes/src/environ.ts
|
|
664
|
-
/** A variable of the user's. */
|
|
665
|
-
const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
|
|
666
|
-
// ../nodes/src/mcp-servers.ts
|
|
667
1184
|
/**
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
* secrets stay: Ork stores none, and a run masks each value read this way. A
|
|
672
|
-
* server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
|
|
673
|
-
* so it is refused.
|
|
1185
|
+
* Runs `execute`, or, with `once: true`, skips it after a pass on these files:
|
|
1186
|
+
* `null` then, and `verify.skipped` is recorded. `shown` is the scrubbed
|
|
1187
|
+
* command.
|
|
674
1188
|
*/
|
|
675
|
-
const
|
|
676
|
-
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
687
|
-
const servers = isRecord(parsed) ? parsed["mcpServers"] : void 0;
|
|
688
|
-
return {
|
|
689
|
-
ok: true,
|
|
690
|
-
value: isRecord(servers) ? servers : {}
|
|
691
|
-
};
|
|
692
|
-
} catch (error) {
|
|
693
|
-
const reason = error instanceof SyntaxError ? "it is not valid JSON" : error instanceof Error ? error.message : String(error);
|
|
694
|
-
return {
|
|
695
|
-
ok: false,
|
|
696
|
-
error: `${MCP_FILE} can't be read: ${reason}`
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
};
|
|
700
|
-
const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
|
|
701
|
-
const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
|
|
702
|
-
/** A server's field that holds something other than text: it would be dropped. */
|
|
703
|
-
const notTextIn = (server) => [
|
|
704
|
-
"args",
|
|
705
|
-
"env",
|
|
706
|
-
"headers"
|
|
707
|
-
].find((field) => {
|
|
708
|
-
const value = server[field];
|
|
709
|
-
return (Array.isArray(value) ? value : isRecord(value) ? Object.values(value) : []).some((item) => typeof item !== "string");
|
|
710
|
-
});
|
|
711
|
-
/** The server as written, or why Ork can't call it. */
|
|
712
|
-
const readMcpServer = (root, name) => {
|
|
713
|
-
const servers = serversIn(root);
|
|
714
|
-
if (!servers.ok) return servers;
|
|
715
|
-
const known = Object.keys(servers.value);
|
|
716
|
-
const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
|
|
717
|
-
if (!isRecord(server)) return {
|
|
718
|
-
ok: false,
|
|
719
|
-
error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
|
|
720
|
-
};
|
|
721
|
-
if ("oauth" in server) return {
|
|
722
|
-
ok: false,
|
|
723
|
-
error: `MCP server \`${name}\` signs in with OAuth, whose tokens are Claude Code's: give it a token through headers and \`env:\`, or call it from a session`
|
|
724
|
-
};
|
|
725
|
-
const notText = notTextIn(server);
|
|
726
|
-
if (notText !== void 0) return {
|
|
727
|
-
ok: false,
|
|
728
|
-
error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
|
|
729
|
-
};
|
|
730
|
-
const { type, url, command } = server;
|
|
731
|
-
if (type === "http" && typeof url === "string") return {
|
|
732
|
-
ok: true,
|
|
733
|
-
value: {
|
|
734
|
-
kind: "http",
|
|
735
|
-
url,
|
|
736
|
-
headers: stringsOf(server["headers"])
|
|
737
|
-
}
|
|
738
|
-
};
|
|
739
|
-
if ((type === void 0 || type === "stdio") && typeof command === "string") return {
|
|
740
|
-
ok: true,
|
|
741
|
-
value: {
|
|
742
|
-
kind: "stdio",
|
|
743
|
-
command,
|
|
744
|
-
args: listOf(server["args"]),
|
|
745
|
-
env: stringsOf(server["env"])
|
|
1189
|
+
const runUnlessPassed = async ({ node, context, cwd, shown, environ, execute }) => {
|
|
1190
|
+
if (node.once !== true) return execute();
|
|
1191
|
+
const done = {};
|
|
1192
|
+
const once = await runOnce({
|
|
1193
|
+
cwd,
|
|
1194
|
+
name: node.script === void 0 ? shown : `${shown}\n${node.script}`,
|
|
1195
|
+
who: `${context.runId}/${context.id}`,
|
|
1196
|
+
env: environ,
|
|
1197
|
+
run: async () => {
|
|
1198
|
+
done.run = await execute();
|
|
1199
|
+
return done.run.exitCode;
|
|
746
1200
|
}
|
|
747
|
-
};
|
|
748
|
-
return {
|
|
749
|
-
ok: false,
|
|
750
|
-
error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
|
|
751
|
-
};
|
|
752
|
-
};
|
|
753
|
-
/** Each variable a server reads, and whether it has a default. */
|
|
754
|
-
const readsOf = (server) => {
|
|
755
|
-
return (server.kind === "stdio" ? [
|
|
756
|
-
server.command,
|
|
757
|
-
...server.args,
|
|
758
|
-
...Object.values(server.env)
|
|
759
|
-
] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
|
|
760
|
-
name: String(match[1]),
|
|
761
|
-
fallback: match[2] !== void 0
|
|
762
|
-
})));
|
|
763
|
-
};
|
|
764
|
-
/**
|
|
765
|
-
* The server with its variables filled in, and the values to mask: all it read
|
|
766
|
-
* but those `plain` names, which aren't secret.
|
|
767
|
-
*/
|
|
768
|
-
const expandServer = (server, environ, plain = []) => {
|
|
769
|
-
const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
|
|
770
|
-
if (unset !== void 0) return {
|
|
771
|
-
ok: false,
|
|
772
|
-
error: `${MCP_FILE} reads ${unset.name}, which is not set`
|
|
773
|
-
};
|
|
774
|
-
const values = [];
|
|
775
|
-
const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
|
|
776
|
-
const value = readEnv(environ, name);
|
|
777
|
-
if (value !== void 0 && !plain.includes(name)) values.push(value);
|
|
778
|
-
return value ?? fallback ?? "";
|
|
779
1201
|
});
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
value: {
|
|
784
|
-
server: server.kind === "stdio" ? {
|
|
785
|
-
kind: "stdio",
|
|
786
|
-
command: fill(server.command),
|
|
787
|
-
args: server.args.map((arg) => fill(arg)),
|
|
788
|
-
env: fillAll(server.env)
|
|
789
|
-
} : {
|
|
790
|
-
kind: "http",
|
|
791
|
-
url: fill(server.url),
|
|
792
|
-
headers: fillAll(server.headers)
|
|
793
|
-
},
|
|
794
|
-
values
|
|
795
|
-
}
|
|
796
|
-
};
|
|
797
|
-
};
|
|
798
|
-
/** What a workflow's MCP servers read of the user's, unless called not secret. */
|
|
799
|
-
const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
|
|
800
|
-
if (node.type !== "mcp.call") return [];
|
|
801
|
-
const read = readMcpServer(root, node.server);
|
|
802
|
-
return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
|
|
803
|
-
});
|
|
804
|
-
/** A stdio server's program, when its name can be known before it is filled in. */
|
|
805
|
-
const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
|
|
806
|
-
/**
|
|
807
|
-
* What stops a workflow's `mcp.call` nodes, found before a run spends anything:
|
|
808
|
-
* a server that isn't there or can't be called, a program that isn't installed,
|
|
809
|
-
* or a variable that is not set.
|
|
810
|
-
*/
|
|
811
|
-
const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
812
|
-
if (node.type !== "mcp.call" || finished.has(id)) return [];
|
|
813
|
-
const read = readMcpServer(root, node.server);
|
|
814
|
-
if (!read.ok) return [`${id}: ${read.error}`];
|
|
815
|
-
const given = new Set(Object.keys(node.env ?? {}));
|
|
816
|
-
const unset = readsOf(read.value).flatMap(({ name, fallback }) => fallback || given.has(name) || readEnv(environ, name) !== void 0 ? [] : [`${id}: ${MCP_FILE} reads ${name}, which is not set`]);
|
|
817
|
-
const program = programOf(read.value);
|
|
818
|
-
return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
|
|
819
|
-
});
|
|
820
|
-
// ../nodes/src/tool-scrub.ts
|
|
821
|
-
/**
|
|
822
|
-
* @file Masking a secret in what a program prints. A plain search, so a value
|
|
823
|
-
* of any size or shape takes time in step with the text: it looks in the text
|
|
824
|
-
* as it is, and with JSON's escapes decoded once and twice, so that it does
|
|
825
|
-
* not matter which characters an encoder chose to escape. It masks the value,
|
|
826
|
-
* and the pieces programs print on their own: each line, and each value of a
|
|
827
|
-
* JSON document. Other encodings, such as base64, are out of its reach
|
|
828
|
-
* (L81).
|
|
829
|
-
*/
|
|
830
|
-
/** Shorter values than this would mask ordinary text: `1`, `true`, a word. */
|
|
831
|
-
const MIN_SECRET_LENGTH = 8;
|
|
832
|
-
const SHORT_ESCAPES = {
|
|
833
|
-
"\"": "\"",
|
|
834
|
-
"\\": "\\",
|
|
835
|
-
"/": "/",
|
|
836
|
-
b: "\b",
|
|
837
|
-
f: "\f",
|
|
838
|
-
n: "\n",
|
|
839
|
-
r: "\r",
|
|
840
|
-
t: " "
|
|
841
|
-
};
|
|
842
|
-
/** The unit an escape at `at` stands for, and how long it is written. */
|
|
843
|
-
const escapeAt = (text, at) => {
|
|
844
|
-
const next = text.charAt(at + 1);
|
|
845
|
-
const short = SHORT_ESCAPES[next];
|
|
846
|
-
if (short !== void 0) return {
|
|
847
|
-
unit: short,
|
|
848
|
-
width: 2
|
|
849
|
-
};
|
|
850
|
-
if (next !== "u") return null;
|
|
851
|
-
const hex = text.slice(at + 2, at + 6);
|
|
852
|
-
return /^[\da-f]{4}$/iu.test(hex) ? {
|
|
853
|
-
unit: String.fromCodePoint(Number(`0x${hex}`)),
|
|
854
|
-
width: 6
|
|
855
|
-
} : null;
|
|
856
|
-
};
|
|
857
|
-
/** The last mark at or before `index`, by a binary search. */
|
|
858
|
-
const lastMark = (marks, index) => {
|
|
859
|
-
let low = 0;
|
|
860
|
-
let high = marks.length - 1;
|
|
861
|
-
while (low < high) {
|
|
862
|
-
const middle = Math.ceil((low + high) / 2);
|
|
863
|
-
if ((marks[middle] ?? 0) <= index) low = middle;
|
|
864
|
-
else high = middle - 1;
|
|
865
|
-
}
|
|
866
|
-
return low;
|
|
867
|
-
};
|
|
868
|
-
/** The view with its escapes decoded; `null` when it has none. */
|
|
869
|
-
const decoded = (view) => {
|
|
870
|
-
const { text } = view;
|
|
871
|
-
const parts = [];
|
|
872
|
-
const decodedAt = [0];
|
|
873
|
-
const sourceAt = [0];
|
|
874
|
-
let from = 0;
|
|
875
|
-
let length = 0;
|
|
876
|
-
let at = text.indexOf("\\");
|
|
877
|
-
while (at !== -1) {
|
|
878
|
-
const escape = escapeAt(text, at);
|
|
879
|
-
if (escape === null) at = text.indexOf("\\", at + 1);
|
|
880
|
-
else {
|
|
881
|
-
parts.push(text.slice(from, at), escape.unit);
|
|
882
|
-
length += at - from;
|
|
883
|
-
decodedAt.push(length);
|
|
884
|
-
sourceAt.push(at);
|
|
885
|
-
length += 1;
|
|
886
|
-
from = at + escape.width;
|
|
887
|
-
decodedAt.push(length);
|
|
888
|
-
sourceAt.push(from);
|
|
889
|
-
at = text.indexOf("\\", from);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
if (from === 0) return null;
|
|
893
|
-
parts.push(text.slice(from));
|
|
894
|
-
return {
|
|
895
|
-
text: parts.join(""),
|
|
896
|
-
toSource: (index) => {
|
|
897
|
-
const mark = lastMark(decodedAt, index);
|
|
898
|
-
return view.toSource((sourceAt[mark] ?? 0) + (index - (decodedAt[mark] ?? 0)));
|
|
899
|
-
}
|
|
900
|
-
};
|
|
1202
|
+
if (!once.skipped) return done.run ?? execute();
|
|
1203
|
+
context.skipped?.(shown, once.pass);
|
|
1204
|
+
return null;
|
|
901
1205
|
};
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
1206
|
+
// ../nodes/src/tool-dash.ts
|
|
1207
|
+
/**
|
|
1208
|
+
* @file The leading-`-` guard on a tool node's command: an argument that starts
|
|
1209
|
+
* with a template reading `nodes.*.output` and renders to a leading `-` would
|
|
1210
|
+
* read as an option of the program, so it is refused unless a `--` comes
|
|
1211
|
+
* before it. A literal `-x` and `--flag={{…}}` are not affected. Also how a
|
|
1212
|
+
* tool node renders one argument (or its `cwd`) with that guard.
|
|
1213
|
+
*/
|
|
1214
|
+
/** The error for a rendered argument that reads as an option, or `undefined`. */
|
|
1215
|
+
const leadingDashError = (parts, value) => {
|
|
1216
|
+
const first = parts[0];
|
|
1217
|
+
if (!value.startsWith("-") || typeof first !== "object" || first.expr.kind !== "path" || first.expr.segments[0] !== "nodes" || first.expr.segments[2] !== "output") return;
|
|
1218
|
+
return `argument from nodes.${String(first.expr.segments[1])}.output starts with \`-\`; put \`--\` before it in the command`;
|
|
1219
|
+
};
|
|
1220
|
+
/** One templated argument, rendered; `guardDash` refuses a leading `-`. */
|
|
1221
|
+
const renderArg = (text, context, guardDash = false) => {
|
|
1222
|
+
const template = parseTemplate(text);
|
|
1223
|
+
if (!template.ok) return {
|
|
1224
|
+
ok: false,
|
|
1225
|
+
error: template.message
|
|
1226
|
+
};
|
|
1227
|
+
const value = renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId, context.item));
|
|
1228
|
+
const error = guardDash ? leadingDashError(template.parts, value) : void 0;
|
|
1229
|
+
return error === void 0 ? {
|
|
1230
|
+
ok: true,
|
|
1231
|
+
value
|
|
1232
|
+
} : {
|
|
1233
|
+
ok: false,
|
|
1234
|
+
error
|
|
907
1235
|
};
|
|
908
|
-
const once = decoded(raw);
|
|
909
|
-
const twice = once === null ? null : decoded(once);
|
|
910
|
-
return [
|
|
911
|
-
raw,
|
|
912
|
-
...once === null ? [] : [once],
|
|
913
|
-
...twice === null ? [] : [twice]
|
|
914
|
-
];
|
|
915
1236
|
};
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
else joined.push([start, end]);
|
|
1237
|
+
// ../nodes/src/tool-ends.ts
|
|
1238
|
+
/**
|
|
1239
|
+
* @file How a tool node ended (specs/runtime/session-ends.md): a node that
|
|
1240
|
+
* could not run its command, JSON that broke its declared output, or how a
|
|
1241
|
+
* command that ran exited: done, stopped, never started, signed out, or
|
|
1242
|
+
* failed. Ork's message names the program; its stderr is the detail.
|
|
1243
|
+
*/
|
|
1244
|
+
/** A node that could not run its command: its own definition is wrong. */
|
|
1245
|
+
const failed$1 = (error, kind = "invalid_node") => endedWith({
|
|
1246
|
+
ok: false,
|
|
1247
|
+
output: {
|
|
1248
|
+
exitCode: -1,
|
|
1249
|
+
stdout: "",
|
|
1250
|
+
stderr: error
|
|
931
1251
|
}
|
|
932
|
-
|
|
1252
|
+
}, endFor(kind, { reason: error }, error));
|
|
1253
|
+
/** JSON that broke what the node declares, or that it could not read. */
|
|
1254
|
+
const invalidOutput = (output, reason) => endedWith({
|
|
1255
|
+
ok: false,
|
|
1256
|
+
output
|
|
1257
|
+
}, endFor("invalid_output", { reason }));
|
|
1258
|
+
/**
|
|
1259
|
+
* How a command that ran and exited non-zero ended: stopped, never started, a
|
|
1260
|
+
* sign-in its program asks for (`signedOut`), or a plain failure.
|
|
1261
|
+
*/
|
|
1262
|
+
const exitKind = (run, context, signedOut) => {
|
|
1263
|
+
if (context.signal.aborted) return "interrupted";
|
|
1264
|
+
if (run.exitCode === -1 && /\bENOENT\b/u.test(run.stderr)) return "tool_missing";
|
|
1265
|
+
return signedOut?.(run) === true ? "auth" : "command_failed";
|
|
1266
|
+
};
|
|
1267
|
+
/** How a command that ran ended, by its exit code; `program` is scrubbed. */
|
|
1268
|
+
const exitEnd = (output, run, context, program, signedOut) => {
|
|
1269
|
+
if (run.exitCode === 0) return endedWith({
|
|
1270
|
+
ok: true,
|
|
1271
|
+
output
|
|
1272
|
+
}, endFor("done"));
|
|
1273
|
+
const end = endFor(exitKind(run, context, signedOut), {
|
|
1274
|
+
program,
|
|
1275
|
+
exitCode: run.exitCode
|
|
1276
|
+
}, run.stderr.trim().slice(-END_DETAIL_LIMIT));
|
|
1277
|
+
return endedWith({
|
|
1278
|
+
ok: false,
|
|
1279
|
+
output
|
|
1280
|
+
}, end);
|
|
933
1281
|
};
|
|
934
|
-
/** The
|
|
935
|
-
const
|
|
936
|
-
if (
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1282
|
+
/** The result of a skipped check: it passed, and printed nothing this time. */
|
|
1283
|
+
const skippedCheck = (outputFile) => {
|
|
1284
|
+
if (outputFile !== void 0) removeOutputFile(outputFile);
|
|
1285
|
+
return endedWith({
|
|
1286
|
+
ok: true,
|
|
1287
|
+
output: {
|
|
1288
|
+
exitCode: 0,
|
|
1289
|
+
stdout: "",
|
|
1290
|
+
stderr: ""
|
|
1291
|
+
}
|
|
1292
|
+
}, endFor("done"));
|
|
940
1293
|
};
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1294
|
+
// ../nodes/src/run-marker.ts
|
|
1295
|
+
/**
|
|
1296
|
+
* @file The marker a run leaves on its sessions and programs (ADR-047), and the
|
|
1297
|
+
* stores it guards: the commands that act as the user refuse in the stores of
|
|
1298
|
+
* the runs that started a process, and everywhere under a marker that lists
|
|
1299
|
+
* none, which an older `ork` sets.
|
|
1300
|
+
*/
|
|
1301
|
+
/** Set for a program in a run. */
|
|
1302
|
+
const RUN_ENV = "ORK_RUN";
|
|
1303
|
+
/** Set for a Claude session. It says no more than that: no run, no node. */
|
|
1304
|
+
const SESSION_ENV = "ORK_SESSION";
|
|
1305
|
+
/**
|
|
1306
|
+
* The stores whose runs started a session or program, as a JSON array of real
|
|
1307
|
+
* paths: the commands that act as the user refuse only in these.
|
|
1308
|
+
*/
|
|
1309
|
+
const GUARDED_STORES_ENV = "ORK_GUARDED_STORES";
|
|
1310
|
+
/**
|
|
1311
|
+
* The variable that says a program in a run or a session started this process,
|
|
1312
|
+
* or `null` when none does.
|
|
1313
|
+
*/
|
|
1314
|
+
const runMarkerIn = (env) => ["ORK_RUN", "ORK_SESSION"].find((name) => env[name] !== void 0) ?? null;
|
|
1315
|
+
/** The stores `env` lists, or `undefined` when it lists none or garbles it. */
|
|
1316
|
+
const listIn = (env) => {
|
|
1317
|
+
const text = env[GUARDED_STORES_ENV];
|
|
1318
|
+
if (text === void 0) return void 0;
|
|
944
1319
|
try {
|
|
945
|
-
|
|
1320
|
+
const value = JSON.parse(text);
|
|
1321
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : void 0;
|
|
946
1322
|
} catch {
|
|
947
|
-
return
|
|
1323
|
+
return;
|
|
948
1324
|
}
|
|
949
1325
|
};
|
|
950
|
-
/** A
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
/** Replaces the values, in any form JSON writes them, with `[masked]`. */
|
|
958
|
-
const scrubberOf = (values) => {
|
|
959
|
-
const secrets = [...new Set(values.flatMap((value) => formsOf(value)))].filter((secret) => secret.trim().length >= MIN_SECRET_LENGTH);
|
|
960
|
-
return (text) => {
|
|
961
|
-
if (secrets.length === 0) return text;
|
|
962
|
-
const parts = [];
|
|
963
|
-
let from = 0;
|
|
964
|
-
for (const [start, end] of rangesOf(text, secrets)) {
|
|
965
|
-
parts.push(text.slice(from, start), "[masked]");
|
|
966
|
-
from = end;
|
|
967
|
-
}
|
|
968
|
-
parts.push(text.slice(from));
|
|
969
|
-
return parts.join("");
|
|
970
|
-
};
|
|
1326
|
+
/** A store file's real path, so a symlinked `~/.ork` is the same store. */
|
|
1327
|
+
const realStore = (store) => {
|
|
1328
|
+
try {
|
|
1329
|
+
return realpathSync(store);
|
|
1330
|
+
} catch {
|
|
1331
|
+
return path.resolve(store);
|
|
1332
|
+
}
|
|
971
1333
|
};
|
|
1334
|
+
/**
|
|
1335
|
+
* The stores a run that started this process is in: `null` when no run did, and
|
|
1336
|
+
* `"all"` when one did but listed none (an older `ork`), which fails closed.
|
|
1337
|
+
*/
|
|
1338
|
+
const guardedStoresIn = (env) => runMarkerIn(env) === null ? null : listIn(env) ?? "all";
|
|
1339
|
+
/** Whether a run in `store` (a store file) started this process. */
|
|
1340
|
+
const storeGuarded = (env, store) => {
|
|
1341
|
+
const guarded = guardedStoresIn(env);
|
|
1342
|
+
return guarded === "all" || guarded !== null && guarded.includes(realStore(store));
|
|
1343
|
+
};
|
|
1344
|
+
/**
|
|
1345
|
+
* What a run in `store` (default: the user's) tells its sessions and programs:
|
|
1346
|
+
* the stores it inherited, and its own. Nothing when it inherited "all", so
|
|
1347
|
+
* that holds.
|
|
1348
|
+
*/
|
|
1349
|
+
const guardOf = (env, store = storePath()) => guardedStoresIn(env) === "all" ? {} : { [GUARDED_STORES_ENV]: JSON.stringify([.../* @__PURE__ */ new Set([...listIn(env) ?? [], realStore(store)])]) };
|
|
972
1350
|
// ../nodes/src/tool-env.ts
|
|
973
1351
|
/**
|
|
974
1352
|
* @file What a tool node's program sees besides what it inherits: who it is
|
|
@@ -980,16 +1358,13 @@ const scrubberOf = (values) => {
|
|
|
980
1358
|
const OUTPUT_ENV = "ORK_OUTPUT";
|
|
981
1359
|
/** A file a `watch:` node's program keeps between looks (RFC-011). */
|
|
982
1360
|
const STATE_ENV = "ORK_STATE";
|
|
983
|
-
/** Set for a program in a run. */
|
|
984
|
-
const RUN_ENV = "ORK_RUN";
|
|
985
|
-
/** Set for a Claude session. It says no more than that: no run, no node. */
|
|
986
|
-
const SESSION_ENV = "ORK_SESSION";
|
|
987
1361
|
/** The variables a caller's run reads, for a run it calls to mask too. */
|
|
988
1362
|
const MASKED_ENV = "ORK_MASKED";
|
|
989
1363
|
/** Every variable Ork sets itself; a workflow's `env:` cannot set them. */
|
|
990
1364
|
const IDENTITY_KEYS = /* @__PURE__ */ new Set([
|
|
991
1365
|
RUN_ENV,
|
|
992
1366
|
SESSION_ENV,
|
|
1367
|
+
GUARDED_STORES_ENV,
|
|
993
1368
|
MASKED_ENV,
|
|
994
1369
|
"ORK_NODE",
|
|
995
1370
|
"ORK_ROUND",
|
|
@@ -998,7 +1373,10 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
|
|
|
998
1373
|
"ORK_WORKFLOW",
|
|
999
1374
|
"ORK_BIN",
|
|
1000
1375
|
OUTPUT_ENV,
|
|
1001
|
-
STATE_ENV
|
|
1376
|
+
STATE_ENV,
|
|
1377
|
+
"ORK_ITEM",
|
|
1378
|
+
"ORK_INDEX",
|
|
1379
|
+
DETACHED_ENV
|
|
1002
1380
|
]);
|
|
1003
1381
|
/**
|
|
1004
1382
|
* `env` without Ork's identity variables. A run started by a program in another
|
|
@@ -1006,27 +1384,32 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
|
|
|
1006
1384
|
*/
|
|
1007
1385
|
const withoutIdentity = (env) => Object.fromEntries(Object.entries(env).flatMap(([key, value]) => value === void 0 || IDENTITY_KEYS.has(key) ? [] : [[key, value]]));
|
|
1008
1386
|
/**
|
|
1009
|
-
* What a Claude session
|
|
1010
|
-
* Bash from speaking as the user
|
|
1387
|
+
* What a Claude session of a run in `store` (default: the user's) runs with: no
|
|
1388
|
+
* identity, only the marker that stops its Bash from speaking as the user
|
|
1389
|
+
* through `ork chat`, and the stores it must not do that in.
|
|
1011
1390
|
*/
|
|
1012
|
-
const sessionEnv = (env) => ({
|
|
1391
|
+
const sessionEnv = (env, store) => ({
|
|
1013
1392
|
...withoutIdentity(env),
|
|
1014
|
-
[SESSION_ENV]: "1"
|
|
1393
|
+
[SESSION_ENV]: "1",
|
|
1394
|
+
...guardOf(env, store)
|
|
1015
1395
|
});
|
|
1016
1396
|
/**
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1397
|
+
* Who a node's program is, and the stores its run guards (none listed without
|
|
1398
|
+
* `store`, which guards every one). It is attribution, not a boundary.
|
|
1019
1399
|
*/
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
const identityOf = (context, orkBin) => ({
|
|
1400
|
+
const identityOf = (context, orkBin, environ = {}, store) => ({
|
|
1401
|
+
...store === void 0 ? {} : guardOf(environ, store),
|
|
1023
1402
|
[RUN_ENV]: context.runId,
|
|
1024
1403
|
ORK_NODE: context.id,
|
|
1025
1404
|
ORK_ROUND: String(context.round),
|
|
1026
1405
|
ORK_RUN_DIR: context.runDir,
|
|
1027
1406
|
ORK_PROJECT: context.projectRoot,
|
|
1028
1407
|
ORK_WORKFLOW: context.workflow.name,
|
|
1029
|
-
...orkBin === void 0 ? {} : { ORK_BIN: orkBin }
|
|
1408
|
+
...orkBin === void 0 ? {} : { ORK_BIN: orkBin },
|
|
1409
|
+
...context.item === void 0 ? {} : {
|
|
1410
|
+
ORK_ITEM: typeof context.item.value === "string" ? context.item.value : JSON.stringify(context.item.value),
|
|
1411
|
+
ORK_INDEX: String(context.item.index)
|
|
1412
|
+
}
|
|
1030
1413
|
});
|
|
1031
1414
|
/** The variables a workflow's nodes read that no `plainEnv` excuses. */
|
|
1032
1415
|
const secretNamesOf = (workflow) => Object.values(workflow.nodes).flatMap((node) => programsRunBy(node, workflow.defaults)).flatMap(({ program }) => {
|
|
@@ -1043,6 +1426,23 @@ const inheritedNames = (environ) => (environ[MASKED_ENV] ?? "").split(",").filte
|
|
|
1043
1426
|
*/
|
|
1044
1427
|
const maskedNamesOfRun = (workflow, own, registered, environ = process.env) => [...new Set([workflow, ...calledWorkflows(workflow, own, registered, { everyCall: true })].flatMap((one) => secretNamesOf(one)).concat(mcpVariablesOf(workflow, own.root), inheritedNames(environ)))];
|
|
1045
1428
|
/**
|
|
1429
|
+
* The env-value scrubber of a recorded run, from its workflow file as it is
|
|
1430
|
+
* now, for what is written to it from outside the run (a person's note);
|
|
1431
|
+
* `undefined` when the file is gone or no longer parses.
|
|
1432
|
+
*/
|
|
1433
|
+
const scrubberOfRun = (run, registered, environ = process.env) => {
|
|
1434
|
+
let text;
|
|
1435
|
+
try {
|
|
1436
|
+
text = readFileSync(run.file, "utf8");
|
|
1437
|
+
} catch {
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const parsed = parseWorkflow(text);
|
|
1441
|
+
if (!parsed.ok) return void 0;
|
|
1442
|
+
const own = placeFor(path.dirname(run.file), registered);
|
|
1443
|
+
return runScrubber(maskedNamesOfRun(parsed.workflow, own, registered, environ), environ);
|
|
1444
|
+
};
|
|
1445
|
+
/**
|
|
1046
1446
|
* A program learns the names too, never their values: `ork send` masks with
|
|
1047
1447
|
* them before it writes to the store, and a run the program starts passes them
|
|
1048
1448
|
* on.
|
|
@@ -1052,23 +1452,17 @@ const namesToMask = (workflow, environ, also = []) => [.../* @__PURE__ */ new Se
|
|
|
1052
1452
|
...also,
|
|
1053
1453
|
...inheritedNames(environ)
|
|
1054
1454
|
])];
|
|
1055
|
-
const valuesOf = (names, environ) => names.flatMap((name) => {
|
|
1056
|
-
const value = readEnv(environ, name);
|
|
1057
|
-
return value === void 0 ? [] : [value];
|
|
1058
|
-
});
|
|
1059
1455
|
/** What a process in a program masks: what its run told it to. */
|
|
1060
|
-
const inheritedScrubber = (environ) => scrubberOf(
|
|
1061
|
-
/** What a run masks in a message of its own: the same names' values. */
|
|
1062
|
-
const runScrubber = (names, environ) => scrubberOf(valuesOf(names, environ));
|
|
1456
|
+
const inheritedScrubber = (environ) => scrubberOf(envValuesOf(inheritedNames(environ), environ));
|
|
1063
1457
|
/** What a run tells a run it calls: names, never values. */
|
|
1064
1458
|
const maskedEnv = (names) => names.length === 0 ? {} : { [MASKED_ENV]: names.join(",") };
|
|
1065
1459
|
/** The values to mask: what the workflow reads, and the names given. */
|
|
1066
|
-
const secretsOf = (workflow, environ, also = []) =>
|
|
1460
|
+
const secretsOf = (workflow, environ, also = []) => envValuesOf(namesToMask(workflow, environ, also), environ);
|
|
1067
1461
|
/** A node's `env:`, rendered. */
|
|
1068
1462
|
const renderEnv = (node, context, environ) => {
|
|
1069
1463
|
const values = {};
|
|
1070
1464
|
const scope = {
|
|
1071
|
-
...runScope(context.inputs, context.outputs, context.runId),
|
|
1465
|
+
...runScope(context.inputs, context.outputs, context.runId, context.item),
|
|
1072
1466
|
output: { env: environ }
|
|
1073
1467
|
};
|
|
1074
1468
|
for (const [key, text] of Object.entries(node.env ?? {})) {
|
|
@@ -1141,26 +1535,22 @@ const clearStateFiles = (runDir, warn) => {
|
|
|
1141
1535
|
* without a shell, checked against `allow` (or the integration's default),
|
|
1142
1536
|
* its arguments and observed output fields from the integration's adapter,
|
|
1143
1537
|
* long output kept in run files. It sees who it is and the node's `env:`
|
|
1144
|
-
* (`tool-env.ts`),
|
|
1538
|
+
* (`tool-env.ts`), may return JSON (`tool-output.ts`), and may run once per
|
|
1539
|
+
* set of files (`runUnlessPassed` in `tool-command.ts`).
|
|
1145
1540
|
*/
|
|
1146
1541
|
/** How much of stdout and stderr a node's output keeps. */
|
|
1147
1542
|
const OUTPUT_LIMIT = 65536;
|
|
1148
1543
|
const ERROR_LIMIT = 1024;
|
|
1544
|
+
/** The interpreters whose script's commands a heavy check reads. */
|
|
1545
|
+
const SHELLS = /* @__PURE__ */ new Set([
|
|
1546
|
+
"sh",
|
|
1547
|
+
"bash",
|
|
1548
|
+
"zsh"
|
|
1549
|
+
]);
|
|
1149
1550
|
/** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
|
|
1150
1551
|
const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
|
|
1151
1552
|
return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
|
|
1152
1553
|
});
|
|
1153
|
-
const render = (text, context) => {
|
|
1154
|
-
const template = parseTemplate(text);
|
|
1155
|
-
if (!template.ok) return {
|
|
1156
|
-
ok: false,
|
|
1157
|
-
error: template.message
|
|
1158
|
-
};
|
|
1159
|
-
return {
|
|
1160
|
-
ok: true,
|
|
1161
|
-
value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId))
|
|
1162
|
-
};
|
|
1163
|
-
};
|
|
1164
1554
|
/** The program and arguments a tool node runs, before the allow check. */
|
|
1165
1555
|
const commandOf = (node, context) => {
|
|
1166
1556
|
const line = commandLineOf(node);
|
|
@@ -1171,12 +1561,14 @@ const commandOf = (node, context) => {
|
|
|
1171
1561
|
const tokens = tokenize(line);
|
|
1172
1562
|
if (!tokens.ok) return tokens;
|
|
1173
1563
|
const args = [];
|
|
1564
|
+
let afterSeparator = false;
|
|
1174
1565
|
for (const token of tokens.args) {
|
|
1175
1566
|
if (node.script !== void 0) {
|
|
1176
1567
|
args.push(token);
|
|
1177
1568
|
continue;
|
|
1178
1569
|
}
|
|
1179
|
-
const rendered =
|
|
1570
|
+
const rendered = renderArg(token, context, !afterSeparator);
|
|
1571
|
+
afterSeparator ||= token === "--";
|
|
1180
1572
|
if (!rendered.ok) return rendered;
|
|
1181
1573
|
args.push(rendered.value);
|
|
1182
1574
|
}
|
|
@@ -1212,19 +1604,10 @@ const capped = (text, file) => {
|
|
|
1212
1604
|
* what a session is told. All of it is kept in the output's fields and files.
|
|
1213
1605
|
*/
|
|
1214
1606
|
const clipError = (text) => text.length <= ERROR_LIMIT ? text : `${text.slice(0, ERROR_LIMIT)}…`;
|
|
1215
|
-
const failed$1 = (error) => ({
|
|
1216
|
-
ok: false,
|
|
1217
|
-
output: {
|
|
1218
|
-
exitCode: -1,
|
|
1219
|
-
stdout: "",
|
|
1220
|
-
stderr: error
|
|
1221
|
-
},
|
|
1222
|
-
error
|
|
1223
|
-
});
|
|
1224
1607
|
/** Runs a user's CLI through its integration's adapter, with its own login. */
|
|
1225
1608
|
const toolExecutor = (given, options = {}) => async (context) => {
|
|
1226
1609
|
const { node } = context;
|
|
1227
|
-
if (!isToolNode(node)) return failed$1(`${context.id} is not a tool
|
|
1610
|
+
if (!isToolNode(node)) return failed$1(`${context.id} is not a tool`, "unknown");
|
|
1228
1611
|
const environ = options.environ ?? process.env;
|
|
1229
1612
|
const scrub = scrubberOf(secretsOf(context.workflow, environ, options.maskedNames));
|
|
1230
1613
|
const command = commandOf(node, context);
|
|
@@ -1235,7 +1618,7 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1235
1618
|
if (!isAllowed(checked, allow)) return failed$1(scrub(`${markValue(checked.join(" "))} is not in the node's allow list (${(allow ?? []).map((entry) => markValue(entry)).join(", ")})`));
|
|
1236
1619
|
let cwd = context.cwd;
|
|
1237
1620
|
if (node.cwd !== void 0) {
|
|
1238
|
-
const rendered =
|
|
1621
|
+
const rendered = renderArg(node.cwd, context);
|
|
1239
1622
|
if (!rendered.ok) return failed$1(rendered.error);
|
|
1240
1623
|
cwd = path.resolve(context.cwd, rendered.value);
|
|
1241
1624
|
}
|
|
@@ -1245,32 +1628,49 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1245
1628
|
const outputFile = node.output?.format === "json" ? prepareOutputFile(context.runDir, context.id, context.round) : void 0;
|
|
1246
1629
|
const env = {
|
|
1247
1630
|
...nodeEnv.values,
|
|
1248
|
-
...identityOf(context, options.orkBin),
|
|
1631
|
+
...identityOf(context, options.orkBin, environ, options.store),
|
|
1249
1632
|
...maskedEnv(namesToMask(context.workflow, environ, options.maskedNames)),
|
|
1250
1633
|
...outputFile === void 0 ? {} : { [OUTPUT_ENV]: outputFile },
|
|
1251
1634
|
...node.watch === void 0 ? {} : { [STATE_ENV]: prepareStateFile(context.runDir, context.id) }
|
|
1252
1635
|
};
|
|
1253
1636
|
const names = Object.keys(nodeEnv.values);
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1637
|
+
const shown = scrub([
|
|
1638
|
+
command.program,
|
|
1639
|
+
...command.args,
|
|
1640
|
+
...node.script === void 0 ? [] : ["<script>"]
|
|
1641
|
+
].join(" "));
|
|
1642
|
+
const execute = async () => {
|
|
1643
|
+
const release = await context.machine?.slot([command.program, ...command.args].join(" "), context.signal, node.script !== void 0 && SHELLS.has(path.basename(command.program)) ? { script: node.script } : {});
|
|
1644
|
+
context.emit({
|
|
1645
|
+
type: "tool.called",
|
|
1646
|
+
id: context.id,
|
|
1647
|
+
name: scrub(command.program),
|
|
1648
|
+
input: {
|
|
1649
|
+
command: shown,
|
|
1650
|
+
...names.length === 0 ? {} : { env: names }
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
|
|
1654
|
+
const priority = context.machine?.priority;
|
|
1655
|
+
return runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
|
|
1656
|
+
cwd,
|
|
1657
|
+
signal: context.signal,
|
|
1658
|
+
env,
|
|
1659
|
+
...priority === void 0 ? {} : { priority }
|
|
1660
|
+
}).finally(() => {
|
|
1661
|
+
release?.();
|
|
1662
|
+
if (scriptFile !== void 0) removeOutputFile(scriptFile);
|
|
1663
|
+
});
|
|
1664
|
+
};
|
|
1665
|
+
const raw = await runUnlessPassed({
|
|
1666
|
+
node,
|
|
1667
|
+
context,
|
|
1269
1668
|
cwd,
|
|
1270
|
-
|
|
1271
|
-
|
|
1669
|
+
shown,
|
|
1670
|
+
environ,
|
|
1671
|
+
execute
|
|
1272
1672
|
});
|
|
1273
|
-
if (
|
|
1673
|
+
if (raw === null) return skippedCheck(outputFile);
|
|
1274
1674
|
const run = {
|
|
1275
1675
|
exitCode: raw.exitCode,
|
|
1276
1676
|
stdout: scrub(raw.stdout),
|
|
@@ -1306,30 +1706,14 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1306
1706
|
context.emit({
|
|
1307
1707
|
type: "tool.result",
|
|
1308
1708
|
id: context.id,
|
|
1309
|
-
isError: run.exitCode !== 0 || json?.ok === false || declared !== null
|
|
1709
|
+
isError: run.exitCode !== 0 || json?.ok === false || declared !== null,
|
|
1710
|
+
...raw.cpu === void 0 ? {} : { cpu: raw.cpu }
|
|
1310
1711
|
});
|
|
1311
|
-
if (json?.ok === false) return
|
|
1312
|
-
|
|
1313
|
-
output,
|
|
1314
|
-
error: `${context.id}: ${json.error}`
|
|
1315
|
-
};
|
|
1316
|
-
if (declared !== null) return {
|
|
1317
|
-
ok: false,
|
|
1318
|
-
output,
|
|
1319
|
-
error: declared
|
|
1320
|
-
};
|
|
1712
|
+
if (json?.ok === false) return invalidOutput(output, json.error);
|
|
1713
|
+
if (declared !== null) return invalidOutput(output, declared);
|
|
1321
1714
|
if (json !== null) output["json"] = json.json;
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
output
|
|
1325
|
-
};
|
|
1326
|
-
const last = run.stderr.trim().split("\n").at(-1) ?? "";
|
|
1327
|
-
return {
|
|
1328
|
-
ok: false,
|
|
1329
|
-
transient: true,
|
|
1330
|
-
output,
|
|
1331
|
-
error: `${scrub(command.program)} exited with ${String(run.exitCode)}${last === "" ? "" : `: ${clipError(last)}`}`
|
|
1332
|
-
};
|
|
1715
|
+
const signedOut = integration?.signedOut;
|
|
1716
|
+
return exitEnd(output, run, context, scrub(command.program), signedOut);
|
|
1333
1717
|
};
|
|
1334
1718
|
// ../nodes/src/verify.ts
|
|
1335
1719
|
/**
|
|
@@ -1341,15 +1725,17 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1341
1725
|
/** A setup that failed: no session started, so nothing was spent on it. */
|
|
1342
1726
|
const beforeFailed = (ran) => {
|
|
1343
1727
|
const error = `its before step failed: ${ran.error ?? "no reason given"}`;
|
|
1344
|
-
return {
|
|
1728
|
+
return endedWith({
|
|
1345
1729
|
ok: false,
|
|
1346
|
-
error,
|
|
1347
1730
|
output: {
|
|
1348
1731
|
status: "fail",
|
|
1349
1732
|
summary: error,
|
|
1350
1733
|
before: ran.output
|
|
1351
1734
|
}
|
|
1352
|
-
}
|
|
1735
|
+
}, endOf({
|
|
1736
|
+
...ran,
|
|
1737
|
+
ok: false
|
|
1738
|
+
}));
|
|
1353
1739
|
};
|
|
1354
1740
|
/** With nothing to run a step, a report cannot be accepted. */
|
|
1355
1741
|
const noSteps = () => Promise.resolve({
|
|
@@ -1370,20 +1756,23 @@ const runStep = async (tool, given, { timeout, ...fields }, step) => {
|
|
|
1370
1756
|
type: "tool.shell",
|
|
1371
1757
|
...fields
|
|
1372
1758
|
};
|
|
1373
|
-
const context = {
|
|
1374
|
-
...given,
|
|
1375
|
-
step
|
|
1376
|
-
};
|
|
1377
1759
|
const ms = timeoutMs(timeout);
|
|
1378
1760
|
if (ms === void 0) return tool({
|
|
1379
|
-
...
|
|
1761
|
+
...given,
|
|
1762
|
+
step,
|
|
1380
1763
|
node
|
|
1381
1764
|
});
|
|
1765
|
+
const { clock, machine } = slotClock(given.hold, given.machine);
|
|
1766
|
+
const context = {
|
|
1767
|
+
...given,
|
|
1768
|
+
step,
|
|
1769
|
+
...machine === void 0 ? {} : { machine }
|
|
1770
|
+
};
|
|
1382
1771
|
const limit = new AbortController();
|
|
1383
1772
|
const finished = new AbortController();
|
|
1384
1773
|
let expired = false;
|
|
1385
1774
|
const watch = async () => {
|
|
1386
|
-
if (await heldTimeout(
|
|
1775
|
+
if (await heldTimeout(clock, ms, AbortSignal.any([context.signal, finished.signal]))) {
|
|
1387
1776
|
expired = true;
|
|
1388
1777
|
limit.abort();
|
|
1389
1778
|
}
|
|
@@ -1494,42 +1883,28 @@ const checkedResult = (result, { check, resumes }) => {
|
|
|
1494
1883
|
};
|
|
1495
1884
|
const reason = `its check failed: ${check.error ?? "no reason given"}`;
|
|
1496
1885
|
const error = result.ok ? reason : `${reason} (then: ${result.error ?? "no report"})`;
|
|
1497
|
-
return {
|
|
1886
|
+
return endedWith({
|
|
1498
1887
|
...result,
|
|
1499
1888
|
ok: false,
|
|
1500
|
-
error,
|
|
1501
1889
|
output: {
|
|
1502
1890
|
...result.output,
|
|
1503
1891
|
status: "fail",
|
|
1504
1892
|
summary: error,
|
|
1505
1893
|
verify
|
|
1506
1894
|
}
|
|
1507
|
-
}
|
|
1895
|
+
}, endOf(result.ok ? {
|
|
1896
|
+
...check,
|
|
1897
|
+
ok: false
|
|
1898
|
+
} : result));
|
|
1508
1899
|
};
|
|
1509
1900
|
// ../nodes/src/claude.ts
|
|
1510
1901
|
/**
|
|
1511
|
-
* @file The Claude node type: runs
|
|
1902
|
+
* @file The Claude node type: runs an `agent.leader` or `agent.worker` as one
|
|
1512
1903
|
* session, planned with the run's outputs (and the brief its owner gave it),
|
|
1513
1904
|
* and turns its report into the node's output. A leader gets its children
|
|
1514
1905
|
* through the session's ports, and resumes its session on re-entry
|
|
1515
1906
|
* (ADR-019).
|
|
1516
1907
|
*/
|
|
1517
|
-
const MAX_INPUT = 8192;
|
|
1518
|
-
/** On a loop re-entry, the brief carries what the workflow sent back. */
|
|
1519
|
-
const reentryNote = (round, input) => {
|
|
1520
|
-
const json = JSON.stringify(input ?? {}, void 0, 2);
|
|
1521
|
-
const body = json.length > MAX_INPUT ? `${json.slice(0, MAX_INPUT)}\n…` : json;
|
|
1522
|
-
return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\``;
|
|
1523
|
-
};
|
|
1524
|
-
/**
|
|
1525
|
-
* What a resumed leader is told: it already has its brief and its earlier work,
|
|
1526
|
-
* so only the new round, and the new task when its owner sent one.
|
|
1527
|
-
*/
|
|
1528
|
-
const resumeMessage = (round, input, brief) => [brief === void 0 ? sentBack(round, input) : `## New task from \`${brief.from}\`\n\n${brief.text}`, "End with a new structured report."].join("\n\n");
|
|
1529
|
-
/** A first-round resume is a session the run was cut off in (`ork resume`). */
|
|
1530
|
-
const sentBack = (round, input) => round === 1 ? "Ork was stopped while you worked on this task, and the run is resumed. Your earlier turns are above: check what you finished, and carry on from there." : `The workflow sent your task back to you.${reentryNote(round, input)}`;
|
|
1531
|
-
/** What a session is told when messages came in as it finished. */
|
|
1532
|
-
const lateMessage = (count) => `[ork] ${String(count)} message${count === 1 ? "" : "s"} came in while you finished. Call read_inbox, act on ${count === 1 ? "it" : "them"} if needed, and end with a new structured report.`;
|
|
1533
1908
|
/** A re-entered leader resumes; anyone else starts with the full brief. */
|
|
1534
1909
|
const specFor = (spec, context) => {
|
|
1535
1910
|
if (context.resume !== void 0) return {
|
|
@@ -1543,24 +1918,9 @@ const specFor = (spec, context) => {
|
|
|
1543
1918
|
};
|
|
1544
1919
|
return spec;
|
|
1545
1920
|
};
|
|
1546
|
-
/** Runs
|
|
1921
|
+
/** Runs an `agent.leader` or `agent.worker` as one session. */
|
|
1547
1922
|
const claudeExecutor = (options) => async (context) => {
|
|
1548
|
-
const planOptions =
|
|
1549
|
-
cwd: context.cwd,
|
|
1550
|
-
inputs: context.inputs,
|
|
1551
|
-
outputs: context.outputs,
|
|
1552
|
-
inPlace: options.inPlace,
|
|
1553
|
-
workflowDir: context.workflowDir,
|
|
1554
|
-
projectRoot: context.projectRoot,
|
|
1555
|
-
...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
|
|
1556
|
-
pluginDir: path.join(context.runDir, "plugins", context.id)
|
|
1557
|
-
};
|
|
1558
|
-
if (context.checkout !== void 0) planOptions.checkout = context.checkout;
|
|
1559
|
-
if (context.brief !== void 0) planOptions.brief = context.brief;
|
|
1560
|
-
if (context.template !== void 0) planOptions.template = context.template;
|
|
1561
|
-
if (context.ownership !== void 0) planOptions.ownership = context.ownership;
|
|
1562
|
-
if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
|
|
1563
|
-
if (context.claims !== void 0) planOptions.claims = context.claims;
|
|
1923
|
+
const planOptions = planOptionsOf(context, options.inPlace);
|
|
1564
1924
|
const before = beforeOf(context.node, context.workflow.defaults);
|
|
1565
1925
|
if (before !== void 0) {
|
|
1566
1926
|
const ran = await runStep(options.steps ?? noSteps, context, before, {
|
|
@@ -1569,49 +1929,54 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1569
1929
|
});
|
|
1570
1930
|
if (!ran.ok) return beforeFailed(ran);
|
|
1571
1931
|
await context.hold?.wait("start", context.signal);
|
|
1572
|
-
if (context.signal.aborted)
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1932
|
+
if (context.signal.aborted) {
|
|
1933
|
+
const end = stoppedEnd(context.signal);
|
|
1934
|
+
return beforeFailed({
|
|
1935
|
+
...ran,
|
|
1936
|
+
ok: false,
|
|
1937
|
+
error: end.message,
|
|
1938
|
+
end
|
|
1939
|
+
});
|
|
1940
|
+
}
|
|
1576
1941
|
planOptions.before = ran.output;
|
|
1577
1942
|
}
|
|
1578
1943
|
const blocked = context.quota?.().blocked;
|
|
1579
|
-
if (blocked !== void 0) return notStarted(blocked, blocked);
|
|
1580
|
-
const
|
|
1581
|
-
if (
|
|
1582
|
-
if (budget !== void 0) planOptions.budgetLeftUsd = budget;
|
|
1944
|
+
if (blocked !== void 0) return notStarted("plan_limit", blocked, blocked);
|
|
1945
|
+
const refused = await startBudgets(context, planOptions);
|
|
1946
|
+
if (refused !== null) return refused;
|
|
1583
1947
|
const plan = planNodeSession(context.workflow, context.id, planOptions);
|
|
1584
|
-
if (!plan.ok) return
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
error: plan.error
|
|
1948
|
+
if (!plan.ok) return notStarted("invalid_node", plan.error);
|
|
1949
|
+
const servers = namedMcpEntries(context.projectRoot, plan.mcp);
|
|
1950
|
+
if (!servers.ok) return notStarted("invalid_node", servers.error);
|
|
1951
|
+
const spec = {
|
|
1952
|
+
...specFor(plan.spec, context),
|
|
1953
|
+
...servers.value
|
|
1591
1954
|
};
|
|
1592
|
-
const spec = specFor(plan.spec, context);
|
|
1593
1955
|
if (options.machineClaude === true) spec.machineClaude = true;
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
ready: options.readyDriver
|
|
1598
|
-
});
|
|
1599
|
-
if (driver === void 0) return notStarted("no Claude driver is ready; see `ork doctor`");
|
|
1956
|
+
if (options.yolo === true) spec.yolo = true;
|
|
1957
|
+
const driver = sessionDriverOf(context, plan, options);
|
|
1958
|
+
if (typeof driver !== "string") return driver;
|
|
1600
1959
|
const runtime = await options.runtimeFor(driver);
|
|
1601
|
-
const ports = { human: context.owner === void 0 ? context
|
|
1960
|
+
const ports = { human: context.owner === void 0 ? withdrawnOnStop(context) : ownerFirst(context, context.owner, {
|
|
1602
1961
|
runtimeFor: options.runtimeFor,
|
|
1603
1962
|
driver: options.driver,
|
|
1604
1963
|
readyDriver: options.readyDriver,
|
|
1605
1964
|
inPlace: options.inPlace
|
|
1606
1965
|
}) };
|
|
1607
1966
|
if (context.hold !== void 0) ports.hold = context.hold;
|
|
1967
|
+
if (context.machine !== void 0) ports.machine = context.machine;
|
|
1608
1968
|
if (context.ownership !== void 0) ports.ownership = context.ownership;
|
|
1609
1969
|
if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
|
|
1610
1970
|
if (context.claims !== void 0) ports.claims = context.claims;
|
|
1611
1971
|
if (context.feedback !== void 0) ports.feedback = context.feedback;
|
|
1612
|
-
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1972
|
+
if (context.followUps !== void 0) ports.followUps = context.followUps;
|
|
1973
|
+
const whole = context.round > 1 ? wholeInput(context.input) : void 0;
|
|
1974
|
+
if (whole !== void 0) ports.input = whole;
|
|
1975
|
+
const watch = limitWatch(context, runCapsOf(context, planOptions));
|
|
1976
|
+
const run = async (asked, prior = 0) => {
|
|
1977
|
+
const watching = watch.session(asked, prior);
|
|
1978
|
+
context.reserveBudget?.(asked.budgetUsd);
|
|
1979
|
+
context.reserveTokens?.(asked.budgetTokens);
|
|
1615
1980
|
const session = runtime.start(asked, ports);
|
|
1616
1981
|
const stop = () => {
|
|
1617
1982
|
session.interrupt();
|
|
@@ -1629,6 +1994,8 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1629
1994
|
context.emit(event);
|
|
1630
1995
|
}
|
|
1631
1996
|
} finally {
|
|
1997
|
+
context.reserveBudget?.(0);
|
|
1998
|
+
context.reserveTokens?.(0);
|
|
1632
1999
|
disconnect?.();
|
|
1633
2000
|
context.signal.removeEventListener("abort", stop);
|
|
1634
2001
|
}
|
|
@@ -1636,7 +2003,7 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1636
2003
|
return outcome;
|
|
1637
2004
|
};
|
|
1638
2005
|
const first = await run(spec);
|
|
1639
|
-
const limitsAfter = ({ sessionId, usage }) => {
|
|
2006
|
+
const limitsAfter = ({ sessionId, usage }, turnsAtLeast = 0) => {
|
|
1640
2007
|
const quota = context.quota?.();
|
|
1641
2008
|
if (quota?.blocked !== void 0) {
|
|
1642
2009
|
watch.stoppedBy(quota.blocked);
|
|
@@ -1644,15 +2011,19 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1644
2011
|
}
|
|
1645
2012
|
return lateLimits(spec, {
|
|
1646
2013
|
spent: context.ownSpend?.(sessionId, usage.costUsd) ?? usage.costUsd,
|
|
1647
|
-
turns: usage.turns
|
|
1648
|
-
|
|
2014
|
+
turns: usage.turns,
|
|
2015
|
+
tokens: countedTokens(usage)
|
|
2016
|
+
}, context.budgetLeftNow?.(), quota?.leftUsd, {
|
|
2017
|
+
run: context.tokensLeftNow?.(),
|
|
2018
|
+
quota: quota?.leftTokens
|
|
2019
|
+
}, turnsAtLeast);
|
|
1649
2020
|
};
|
|
1650
2021
|
const resumeWith = (outcome, prompt, limits) => run({
|
|
1651
2022
|
...spec,
|
|
1652
2023
|
...limits,
|
|
1653
2024
|
resume: outcome.sessionId,
|
|
1654
2025
|
prompt
|
|
1655
|
-
});
|
|
2026
|
+
}, outcome.usage.costUsd);
|
|
1656
2027
|
const lateTurn = (current) => {
|
|
1657
2028
|
const late = context.mailbox?.pending() ?? 0;
|
|
1658
2029
|
const limits = limitsAfter(current);
|
|
@@ -1678,7 +2049,7 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1678
2049
|
},
|
|
1679
2050
|
late: lateTurn,
|
|
1680
2051
|
resume: async (current, check) => {
|
|
1681
|
-
const limits = limitsAfter(current);
|
|
2052
|
+
const limits = limitsAfter(current, 20);
|
|
1682
2053
|
if (limits === null) return null;
|
|
1683
2054
|
await context.hold?.wait("start", context.signal);
|
|
1684
2055
|
return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, {
|
|
@@ -1691,10 +2062,11 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1691
2062
|
};
|
|
1692
2063
|
// ../nodes/src/gate.ts
|
|
1693
2064
|
/**
|
|
1694
|
-
* @file The `human.gate` node type: asks the human the rendered question,
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
2065
|
+
* @file The `human.gate` node type: asks the human the rendered question, with
|
|
2066
|
+
* its document if it has one, both in readable text (no raw JSON), outputs
|
|
2067
|
+
* their choice with the note they gave, and answers with the `default` when
|
|
2068
|
+
* the `timeout` passes. The timeout counts only while the run and the gate
|
|
2069
|
+
* are not paused (ADR-041).
|
|
1698
2070
|
*/
|
|
1699
2071
|
/** A promise that never settles: the losing side of a race. */
|
|
1700
2072
|
const NEVER = new Promise(() => {});
|
|
@@ -1716,8 +2088,13 @@ const gateExecutor = async (context) => {
|
|
|
1716
2088
|
output: {},
|
|
1717
2089
|
error: `${context.id} is not a gate`
|
|
1718
2090
|
};
|
|
1719
|
-
const
|
|
1720
|
-
const
|
|
2091
|
+
const scope = runScope(context.inputs, context.outputs, context.runId);
|
|
2092
|
+
const render = (text) => {
|
|
2093
|
+
const template = parseTemplate(text);
|
|
2094
|
+
return template.ok ? renderTemplate(template.parts, scope, { readable: true }) : text;
|
|
2095
|
+
};
|
|
2096
|
+
const question = render(node.question);
|
|
2097
|
+
const document = render(node.document ?? "");
|
|
1721
2098
|
const options = node.options.map((o) => choiceOf(o));
|
|
1722
2099
|
const withdraw = new AbortController();
|
|
1723
2100
|
context.signal.addEventListener("abort", () => {
|
|
@@ -1728,12 +2105,14 @@ const gateExecutor = async (context) => {
|
|
|
1728
2105
|
nodeId: context.id,
|
|
1729
2106
|
question,
|
|
1730
2107
|
options,
|
|
2108
|
+
waits: true,
|
|
1731
2109
|
signal: withdraw.signal,
|
|
1732
2110
|
onNote: (given) => {
|
|
1733
2111
|
note = given;
|
|
1734
2112
|
}
|
|
1735
2113
|
};
|
|
1736
2114
|
if (node.recommended !== void 0) asked.recommended = node.recommended;
|
|
2115
|
+
if (document.trim() !== "") asked.document = document;
|
|
1737
2116
|
const answer = context.human.ask(asked).then((choice) => ({
|
|
1738
2117
|
choice,
|
|
1739
2118
|
answeredBy: "user",
|
|
@@ -1752,21 +2131,22 @@ const gateExecutor = async (context) => {
|
|
|
1752
2131
|
})];
|
|
1753
2132
|
try {
|
|
1754
2133
|
const output = await Promise.race([answer, ...expired]);
|
|
1755
|
-
if (!options.some((o) => o.id === output.choice))
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
2134
|
+
if (!options.some((o) => o.id === output.choice)) {
|
|
2135
|
+
const facts = node.timeout === void 0 ? {} : { timeout: node.timeout };
|
|
2136
|
+
return endedWith({
|
|
2137
|
+
ok: false,
|
|
2138
|
+
output
|
|
2139
|
+
}, endFor("timed_out", facts, "no one answered the gate"));
|
|
2140
|
+
}
|
|
1760
2141
|
return {
|
|
1761
2142
|
ok: true,
|
|
1762
2143
|
output
|
|
1763
2144
|
};
|
|
1764
2145
|
} catch {
|
|
1765
|
-
return {
|
|
2146
|
+
return endedWith({
|
|
1766
2147
|
ok: false,
|
|
1767
|
-
output: {}
|
|
1768
|
-
|
|
1769
|
-
};
|
|
2148
|
+
output: {}
|
|
2149
|
+
}, endFor("interrupted", {}, "the gate was withdrawn"));
|
|
1770
2150
|
} finally {
|
|
1771
2151
|
answered.abort();
|
|
1772
2152
|
}
|
|
@@ -1781,18 +2161,16 @@ const gateExecutor = async (context) => {
|
|
|
1781
2161
|
*/
|
|
1782
2162
|
/** A node's `timeout:` is what limits a call; this only lifts the SDK's 60 s. */
|
|
1783
2163
|
const NO_LIMIT_MS = 864e5;
|
|
1784
|
-
const failed = (
|
|
2164
|
+
const failed = (kind, facts, detail, output = {}) => endedWith({
|
|
1785
2165
|
ok: false,
|
|
1786
|
-
output
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
const
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
error
|
|
1795
|
-
});
|
|
2166
|
+
output
|
|
2167
|
+
}, endFor(kind, facts, detail));
|
|
2168
|
+
/** How a call that threw ended: a sign-in, a server that won't start, or else. */
|
|
2169
|
+
const thrownKind = (error) => {
|
|
2170
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
2171
|
+
if (code === 401 || error?.constructor.name === "UnauthorizedError") return "auth";
|
|
2172
|
+
return code === "ENOENT" ? "tool_missing" : "command_failed";
|
|
2173
|
+
};
|
|
1796
2174
|
/**
|
|
1797
2175
|
* A string's templates rendered. A string that is one `{{ … }}` and nothing
|
|
1798
2176
|
* else keeps its value's type, so a tool that asks for a number gets one.
|
|
@@ -1836,19 +2214,22 @@ const reasonOf = (error, server) => {
|
|
|
1836
2214
|
};
|
|
1837
2215
|
const mcpCallExecutor = (options = {}) => async (context) => {
|
|
1838
2216
|
const { node } = context;
|
|
1839
|
-
if (node.type !== "mcp.call")
|
|
2217
|
+
if (node.type !== "mcp.call") {
|
|
2218
|
+
const said = `${context.id} is not an MCP call`;
|
|
2219
|
+
return failed("unknown", {}, said);
|
|
2220
|
+
}
|
|
1840
2221
|
const environ = options.environ ?? process.env;
|
|
1841
2222
|
const declared = readMcpServer(context.projectRoot, node.server);
|
|
1842
|
-
if (!declared.ok) return failed(declared.error);
|
|
2223
|
+
if (!declared.ok) return failed("invalid_node", { reason: declared.error });
|
|
1843
2224
|
const nodeEnv = renderEnv(node, context, environ);
|
|
1844
|
-
if (!nodeEnv.ok) return failed(nodeEnv.error);
|
|
2225
|
+
if (!nodeEnv.ok) return failed("invalid_node", { reason: nodeEnv.error });
|
|
1845
2226
|
const expanded = expandServer(declared.value, {
|
|
1846
2227
|
...environ,
|
|
1847
2228
|
...nodeEnv.values
|
|
1848
2229
|
}, node.plainEnv);
|
|
1849
|
-
if (!expanded.ok) return failed(expanded.error);
|
|
2230
|
+
if (!expanded.ok) return failed("invalid_node", { reason: expanded.error });
|
|
1850
2231
|
const scrub = scrubberOf([...secretsOf(context.workflow, environ, options.maskedNames), ...expanded.value.values]);
|
|
1851
|
-
const scope = runScope(context.inputs, context.outputs, context.runId);
|
|
2232
|
+
const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
|
|
1852
2233
|
const args = Object.fromEntries(Object.entries(node.arguments ?? {}).map(([key, value]) => [key, renderValue(value, scope)]));
|
|
1853
2234
|
const name = `${node.server}.${node.tool}`;
|
|
1854
2235
|
context.emit({
|
|
@@ -1889,19 +2270,22 @@ const mcpCallExecutor = (options = {}) => async (context) => {
|
|
|
1889
2270
|
id: context.id,
|
|
1890
2271
|
isError: reported || tooLong || problem !== null
|
|
1891
2272
|
});
|
|
1892
|
-
if (reported) return
|
|
1893
|
-
if (tooLong)
|
|
1894
|
-
|
|
2273
|
+
if (reported) return failed("command_failed", { program: name }, said, output);
|
|
2274
|
+
if (tooLong) {
|
|
2275
|
+
const reason = `${name}'s result is over 64 KiB`;
|
|
2276
|
+
return failed("invalid_output", { reason }, void 0, output);
|
|
2277
|
+
}
|
|
2278
|
+
return problem === null ? endedWith({
|
|
1895
2279
|
ok: true,
|
|
1896
2280
|
output
|
|
1897
|
-
} : failed(problem, output);
|
|
2281
|
+
}, endFor("done")) : failed("invalid_output", { reason: problem }, void 0, output);
|
|
1898
2282
|
} catch (error) {
|
|
1899
2283
|
context.emit({
|
|
1900
2284
|
type: "tool.result",
|
|
1901
2285
|
id: context.id,
|
|
1902
2286
|
isError: true
|
|
1903
2287
|
});
|
|
1904
|
-
return
|
|
2288
|
+
return failed(thrownKind(error), { program: `MCP server \`${node.server}\`` }, clipError(scrub(reasonOf(error, node.server))));
|
|
1905
2289
|
} finally {
|
|
1906
2290
|
await client.close().catch(() => {});
|
|
1907
2291
|
}
|
|
@@ -1912,13 +2296,42 @@ const mcpCallExecutor = (options = {}) => async (context) => {
|
|
|
1912
2296
|
* text. A program runs in its own process group, so aborting it reaches
|
|
1913
2297
|
* whatever it started: SIGTERM, then SIGKILL for what ignores that. A stop or
|
|
1914
2298
|
* a timeout therefore leaves nothing running, and an escaped process that
|
|
1915
|
-
* keeps the output pipes open cannot hold the node.
|
|
2299
|
+
* keeps the output pipes open cannot hold the node. Off Windows it runs under
|
|
2300
|
+
* `sh`, whose `times` says the CPU the program and its children used, beside
|
|
2301
|
+
* the orphan guard, which ends the group when `ork` dies, and at the
|
|
2302
|
+
* project's priority: `low` starts it below Ork's own (nice, and on macOS
|
|
2303
|
+
* utility QoS).
|
|
1916
2304
|
*/
|
|
1917
|
-
/** How long a program may take to end after SIGTERM. */
|
|
1918
|
-
const KILL_AFTER_MS = 2e3;
|
|
1919
2305
|
/** How long to wait for the pipes to close after SIGKILL. */
|
|
1920
2306
|
const CLOSE_AFTER_MS = 500;
|
|
1921
|
-
|
|
2307
|
+
/** The fd of the orphan guard's pipe from `ork`. */
|
|
2308
|
+
const WATCH_FD = 4;
|
|
2309
|
+
/**
|
|
2310
|
+
* Runs `$0 "$@"` beside the orphan guard, which ends the group if `ork` dies,
|
|
2311
|
+
* and stops the guard once the program ends, so its leftovers live on as
|
|
2312
|
+
* before. Then writes the shell's `times` to fd 3: its children's user and
|
|
2313
|
+
* system time, the program's whole tree that it waited for. A program that
|
|
2314
|
+
* isn't there says so on fd 3, as spawn's ENOENT would.
|
|
2315
|
+
*/
|
|
2316
|
+
const TIMED = String.raw`command -v "$0" >/dev/null 2>&1 || { echo ork-missing >&3; exit 127; }; ${orphanGuard(WATCH_FD)}w=$!; "$0" "$@" 3>&- ${String(WATCH_FD)}<&-; s=$?; kill -s KILL $w 2>/dev/null; times >&3; exit $s`;
|
|
2317
|
+
const MISSING = "ork-missing";
|
|
2318
|
+
/** The children's line of `times` (`0m1.250s 0m0.300s`), in milliseconds. */
|
|
2319
|
+
const cpuOfTimes = (text) => {
|
|
2320
|
+
const [, , user, system] = [...text.matchAll(/(\d+)m([\d.]+)s/gu)].map(([, m = "0", s = "0"]) => Number(m) * 60 + Number(s));
|
|
2321
|
+
return user === void 0 || system === void 0 ? void 0 : {
|
|
2322
|
+
userMs: Math.round(user * 1e3),
|
|
2323
|
+
systemMs: Math.round(system * 1e3)
|
|
2324
|
+
};
|
|
2325
|
+
};
|
|
2326
|
+
const timed = process.platform !== "win32";
|
|
2327
|
+
/** What to start: the program, under `sh` to time it, at the priority. */
|
|
2328
|
+
const startOf = (program, args, priority) => timed ? startAt(priority, "sh", [
|
|
2329
|
+
"-c",
|
|
2330
|
+
TIMED,
|
|
2331
|
+
program,
|
|
2332
|
+
...args
|
|
2333
|
+
]) : [program, [...args]];
|
|
2334
|
+
const spawnTool = (program, args, { cwd, signal, env, priority }) => {
|
|
1922
2335
|
if (signal.aborted) return Promise.resolve({
|
|
1923
2336
|
exitCode: -1,
|
|
1924
2337
|
stdout: "",
|
|
@@ -1928,7 +2341,9 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1928
2341
|
const stdout = [];
|
|
1929
2342
|
const stderr = [];
|
|
1930
2343
|
const timers = [];
|
|
1931
|
-
const
|
|
2344
|
+
const times = [];
|
|
2345
|
+
const [bin, argv] = startOf(program, args, priority);
|
|
2346
|
+
const child = spawn(bin, argv, {
|
|
1932
2347
|
cwd,
|
|
1933
2348
|
detached: true,
|
|
1934
2349
|
env: {
|
|
@@ -1938,9 +2353,17 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1938
2353
|
stdio: [
|
|
1939
2354
|
"ignore",
|
|
1940
2355
|
"pipe",
|
|
2356
|
+
"pipe",
|
|
2357
|
+
"pipe",
|
|
1941
2358
|
"pipe"
|
|
1942
2359
|
]
|
|
1943
2360
|
});
|
|
2361
|
+
lowerChild(priority, child.pid);
|
|
2362
|
+
holdGuard(child.stdio[WATCH_FD]);
|
|
2363
|
+
const fd3 = child.stdio[3];
|
|
2364
|
+
if (fd3 instanceof Readable) fd3.setEncoding("utf8").on("data", (chunk) => {
|
|
2365
|
+
times.push(chunk);
|
|
2366
|
+
});
|
|
1944
2367
|
const killGroup = (kill) => {
|
|
1945
2368
|
if (child.pid === void 0) return;
|
|
1946
2369
|
try {
|
|
@@ -1952,11 +2375,21 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1952
2375
|
signal.removeEventListener("abort", abort);
|
|
1953
2376
|
resolve(run);
|
|
1954
2377
|
};
|
|
1955
|
-
const collected = (exitCode) =>
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2378
|
+
const collected = (exitCode) => {
|
|
2379
|
+
const said = times.join("");
|
|
2380
|
+
if (said.includes(MISSING)) return {
|
|
2381
|
+
exitCode: -1,
|
|
2382
|
+
stdout: stdout.join(""),
|
|
2383
|
+
stderr: `could not run ${program}: spawn ${program} ENOENT`
|
|
2384
|
+
};
|
|
2385
|
+
const cpu = cpuOfTimes(said);
|
|
2386
|
+
return {
|
|
2387
|
+
exitCode,
|
|
2388
|
+
stdout: stdout.join(""),
|
|
2389
|
+
stderr: stderr.join(""),
|
|
2390
|
+
...cpu === void 0 ? {} : { cpu }
|
|
2391
|
+
};
|
|
2392
|
+
};
|
|
1960
2393
|
const abort = () => {
|
|
1961
2394
|
killGroup("SIGTERM");
|
|
1962
2395
|
timers.push(setTimeout(() => {
|
|
@@ -1967,10 +2400,10 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1967
2400
|
}, KILL_AFTER_MS));
|
|
1968
2401
|
};
|
|
1969
2402
|
signal.addEventListener("abort", abort, { once: true });
|
|
1970
|
-
child.stdout
|
|
2403
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => {
|
|
1971
2404
|
stdout.push(chunk);
|
|
1972
2405
|
});
|
|
1973
|
-
child.stderr
|
|
2406
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => {
|
|
1974
2407
|
stderr.push(chunk);
|
|
1975
2408
|
});
|
|
1976
2409
|
child.on("error", (error) => {
|
|
@@ -1990,6 +2423,7 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1990
2423
|
const builtinExecutors = (options) => {
|
|
1991
2424
|
const tool = toolExecutor(options.tools ?? spawnTool, {
|
|
1992
2425
|
...options.orkBin === void 0 ? {} : { orkBin: options.orkBin },
|
|
2426
|
+
...options.store === void 0 ? {} : { store: options.store },
|
|
1993
2427
|
...options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames }
|
|
1994
2428
|
});
|
|
1995
2429
|
const claude = claudeExecutor({
|
|
@@ -1997,8 +2431,8 @@ const builtinExecutors = (options) => {
|
|
|
1997
2431
|
steps: tool
|
|
1998
2432
|
});
|
|
1999
2433
|
const executors = {
|
|
2000
|
-
"
|
|
2001
|
-
"
|
|
2434
|
+
"agent.leader": claude,
|
|
2435
|
+
"agent.worker": claude,
|
|
2002
2436
|
"human.gate": gateExecutor,
|
|
2003
2437
|
"workflow.call": callExecutor,
|
|
2004
2438
|
"mcp.call": mcpCallExecutor(options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames })
|
|
@@ -2130,4 +2564,4 @@ const missingPrograms = (workflow, installed = (program) => onPath(program)) =>
|
|
|
2130
2564
|
}
|
|
2131
2565
|
return [...missing.values()];
|
|
2132
2566
|
};
|
|
2133
|
-
export {
|
|
2567
|
+
export { clearOutputFiles as _, builtinExecutors as a, runScrubber as b, toolExecutor as c, maskedEnv as d, maskedNamesOfRun as f, storeGuarded as g, runMarkerIn as h, onPath as i, clearStateFiles as l, sessionEnv as m, missingProgramMessage as n, spawnTool as o, scrubberOfRun as p, missingPrograms as r, mcpCallExecutor as s, envProblems as t, inheritedScrubber as u, namedMcpEntries as v, resultOf as y };
|