@krapht/ork 1.0.0-alpha.2 → 1.0.0-alpha.20
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__-D6mXC4cD.mjs +228 -0
- package/dist/apply-Mb0fNpCX.mjs +17 -0
- package/dist/argument-error-CihH46Le.mjs +27 -0
- package/dist/attach-C1OnxyMh.mjs +92 -0
- package/dist/attach-CEGbRg1D.mjs +1230 -0
- package/dist/bin.mjs +1312 -2390
- package/dist/brief-DH66xmj8.mjs +115 -0
- package/dist/{decide-CGyx9rv7.mjs → builtin-files-Cf-NZDqM.mjs} +3 -51
- package/dist/chips-BqcT58qr.mjs +43 -0
- package/dist/command-tree-CEkzWNy6.mjs +63 -0
- package/dist/{completion-CSU5vkEt.mjs → completion-DS0gCZRm.mjs} +200 -30
- package/dist/context-CGPh12HV.mjs +106 -0
- package/dist/detached-Dakw5RZj.mjs +38 -0
- package/dist/extra-arguments-BxQMXARO.mjs +13 -0
- package/dist/{find-D6Gj1sxg.mjs → find-BTikxc54.mjs} +2 -2
- package/dist/flags-C8yCq0j5.mjs +145 -0
- package/dist/follow-ups-CtlhHbwe.mjs +563 -0
- package/dist/format-BB4E-T6x.mjs +249 -0
- package/dist/{help-groups-BwYoAWsh.mjs → help-groups-BpRgBSTh.mjs} +42 -76
- package/dist/in-a-run-CbnKjO7t.mjs +27 -0
- package/dist/install-CWcpX5g4.mjs +211 -0
- package/dist/{limits-B1-DYc3d.mjs → limits-VQqsBoQk.mjs} +2 -2
- package/dist/{mcp-DE_EmyVm.mjs → mcp-wGfkMCVF.mjs} +6 -6
- package/dist/once-BHxSdZz_.mjs +112 -0
- package/dist/orphan-guard-BFKg8VSd.mjs +58 -0
- package/dist/output-B1kwo0YU.mjs +439 -0
- package/dist/outputs-text-CNTJ7Qq_.mjs +19 -0
- package/dist/{pause-CL6N_Xrj.mjs → pause-C_Wxh-KM.mjs} +7 -4
- package/dist/print-DNYzq4Rr.mjs +19 -0
- package/dist/project-records-XY3ku95x.mjs +233 -0
- package/dist/refusals-DQQX3AIb.mjs +116 -0
- package/dist/{relay-0SD0erwf.mjs → relay-BA6tsnpc.mjs} +2 -2
- package/dist/run-Bvvg-fiF.mjs +234 -0
- package/dist/run-DBUAq-Uu.mjs +1452 -0
- package/dist/send-nrKtak5F.mjs +105 -0
- package/dist/{src-Dujmvv4s.mjs → src-8cOPOgrT.mjs} +12523 -5507
- package/dist/{src-BbyEoMn4.mjs → src-CmEvHdju.mjs} +797 -233
- package/dist/src-D4JSymhK.mjs +3104 -0
- package/dist/{src-DOcK8Jfp.mjs → src-I_RyKu6_.mjs} +1022 -590
- package/dist/store-CDpk8Wtz.mjs +16 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +612 -0
- package/dist/ui/assets/app--FijEXiJ.css +1 -0
- package/dist/ui/assets/app-BowhjEAe.js +112 -0
- package/dist/ui/assets/{index-9dFha9Qn.js → index-CrMa2oHB.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-DY3Dnpho.mjs} +54 -25
- package/dist/usage-1VwSg_Qt.mjs +257 -0
- package/dist/validate-BxayjjkV.mjs +632 -0
- package/dist/verdict-CMvPCpWC.mjs +84 -0
- package/dist/{watch-CtbgDAcz.mjs → watch-CT0EtLPZ.mjs} +45 -17
- package/dist/{workflows-D1_WVukk.mjs → workflows-DHHi-APg.mjs} +30 -5
- package/dist/worktrees-CZvbHzbN.mjs +302 -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 { At as heldTimeout, Bt as lowerChild, Ct as reportSchemaFor, Di as INTEGRATIONS, Ei as timeoutMs, Fi as AGENT_DRIVERS, Ft as failedWith, Gr as placeFor, Ht as startAt, It as stoppedEnd, Lt as declaredProblem, Mt as calledWorkflows, Oi as TOOL_PREFIX, Pt as endedWith, Q as sessionRefusals, Rt as slotClock, Si as isToolNode, Sr as renderTemplate, Wn as countedTokens, ar as endFor, br as evaluate, dn as declaredOutputOf, fi as parseWorkflow, fn as MCP_FILE, jt as runScope, ki as integrationFor, li as beforeOf, ln as sessionDriver, mi as markValue, mn as serversIn, nr as outcomeEnd, on as envReadsOf, pn as mcpNotText, rr as END_DETAIL_LIMIT, tr as endOf, ui as programsRunBy, vt as planReason, xi as isClaudeNode, xr as parseTemplate, xt as planNodeSession, z as storePath } from "./src-8cOPOgrT.mjs";
|
|
2
|
+
import { t as runOnce } from "./run-Bvvg-fiF.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,9 @@ 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"
|
|
1002
1379
|
]);
|
|
1003
1380
|
/**
|
|
1004
1381
|
* `env` without Ork's identity variables. A run started by a program in another
|
|
@@ -1006,27 +1383,32 @@ const IDENTITY_KEYS = /* @__PURE__ */ new Set([
|
|
|
1006
1383
|
*/
|
|
1007
1384
|
const withoutIdentity = (env) => Object.fromEntries(Object.entries(env).flatMap(([key, value]) => value === void 0 || IDENTITY_KEYS.has(key) ? [] : [[key, value]]));
|
|
1008
1385
|
/**
|
|
1009
|
-
* What a Claude session
|
|
1010
|
-
* Bash from speaking as the user
|
|
1386
|
+
* What a Claude session of a run in `store` (default: the user's) runs with: no
|
|
1387
|
+
* identity, only the marker that stops its Bash from speaking as the user
|
|
1388
|
+
* through `ork chat`, and the stores it must not do that in.
|
|
1011
1389
|
*/
|
|
1012
|
-
const sessionEnv = (env) => ({
|
|
1390
|
+
const sessionEnv = (env, store) => ({
|
|
1013
1391
|
...withoutIdentity(env),
|
|
1014
|
-
[SESSION_ENV]: "1"
|
|
1392
|
+
[SESSION_ENV]: "1",
|
|
1393
|
+
...guardOf(env, store)
|
|
1015
1394
|
});
|
|
1016
1395
|
/**
|
|
1017
|
-
*
|
|
1018
|
-
*
|
|
1396
|
+
* Who a node's program is, and the stores its run guards (none listed without
|
|
1397
|
+
* `store`, which guards every one). It is attribution, not a boundary.
|
|
1019
1398
|
*/
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
const identityOf = (context, orkBin) => ({
|
|
1399
|
+
const identityOf = (context, orkBin, environ = {}, store) => ({
|
|
1400
|
+
...store === void 0 ? {} : guardOf(environ, store),
|
|
1023
1401
|
[RUN_ENV]: context.runId,
|
|
1024
1402
|
ORK_NODE: context.id,
|
|
1025
1403
|
ORK_ROUND: String(context.round),
|
|
1026
1404
|
ORK_RUN_DIR: context.runDir,
|
|
1027
1405
|
ORK_PROJECT: context.projectRoot,
|
|
1028
1406
|
ORK_WORKFLOW: context.workflow.name,
|
|
1029
|
-
...orkBin === void 0 ? {} : { ORK_BIN: orkBin }
|
|
1407
|
+
...orkBin === void 0 ? {} : { ORK_BIN: orkBin },
|
|
1408
|
+
...context.item === void 0 ? {} : {
|
|
1409
|
+
ORK_ITEM: typeof context.item.value === "string" ? context.item.value : JSON.stringify(context.item.value),
|
|
1410
|
+
ORK_INDEX: String(context.item.index)
|
|
1411
|
+
}
|
|
1030
1412
|
});
|
|
1031
1413
|
/** The variables a workflow's nodes read that no `plainEnv` excuses. */
|
|
1032
1414
|
const secretNamesOf = (workflow) => Object.values(workflow.nodes).flatMap((node) => programsRunBy(node, workflow.defaults)).flatMap(({ program }) => {
|
|
@@ -1043,6 +1425,23 @@ const inheritedNames = (environ) => (environ[MASKED_ENV] ?? "").split(",").filte
|
|
|
1043
1425
|
*/
|
|
1044
1426
|
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
1427
|
/**
|
|
1428
|
+
* The env-value scrubber of a recorded run, from its workflow file as it is
|
|
1429
|
+
* now, for what is written to it from outside the run (a person's note);
|
|
1430
|
+
* `undefined` when the file is gone or no longer parses.
|
|
1431
|
+
*/
|
|
1432
|
+
const scrubberOfRun = (run, registered, environ = process.env) => {
|
|
1433
|
+
let text;
|
|
1434
|
+
try {
|
|
1435
|
+
text = readFileSync(run.file, "utf8");
|
|
1436
|
+
} catch {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
const parsed = parseWorkflow(text);
|
|
1440
|
+
if (!parsed.ok) return void 0;
|
|
1441
|
+
const own = placeFor(path.dirname(run.file), registered);
|
|
1442
|
+
return runScrubber(maskedNamesOfRun(parsed.workflow, own, registered, environ), environ);
|
|
1443
|
+
};
|
|
1444
|
+
/**
|
|
1046
1445
|
* A program learns the names too, never their values: `ork send` masks with
|
|
1047
1446
|
* them before it writes to the store, and a run the program starts passes them
|
|
1048
1447
|
* on.
|
|
@@ -1052,23 +1451,17 @@ const namesToMask = (workflow, environ, also = []) => [.../* @__PURE__ */ new Se
|
|
|
1052
1451
|
...also,
|
|
1053
1452
|
...inheritedNames(environ)
|
|
1054
1453
|
])];
|
|
1055
|
-
const valuesOf = (names, environ) => names.flatMap((name) => {
|
|
1056
|
-
const value = readEnv(environ, name);
|
|
1057
|
-
return value === void 0 ? [] : [value];
|
|
1058
|
-
});
|
|
1059
1454
|
/** 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));
|
|
1455
|
+
const inheritedScrubber = (environ) => scrubberOf(envValuesOf(inheritedNames(environ), environ));
|
|
1063
1456
|
/** What a run tells a run it calls: names, never values. */
|
|
1064
1457
|
const maskedEnv = (names) => names.length === 0 ? {} : { [MASKED_ENV]: names.join(",") };
|
|
1065
1458
|
/** The values to mask: what the workflow reads, and the names given. */
|
|
1066
|
-
const secretsOf = (workflow, environ, also = []) =>
|
|
1459
|
+
const secretsOf = (workflow, environ, also = []) => envValuesOf(namesToMask(workflow, environ, also), environ);
|
|
1067
1460
|
/** A node's `env:`, rendered. */
|
|
1068
1461
|
const renderEnv = (node, context, environ) => {
|
|
1069
1462
|
const values = {};
|
|
1070
1463
|
const scope = {
|
|
1071
|
-
...runScope(context.inputs, context.outputs, context.runId),
|
|
1464
|
+
...runScope(context.inputs, context.outputs, context.runId, context.item),
|
|
1072
1465
|
output: { env: environ }
|
|
1073
1466
|
};
|
|
1074
1467
|
for (const [key, text] of Object.entries(node.env ?? {})) {
|
|
@@ -1141,26 +1534,22 @@ const clearStateFiles = (runDir, warn) => {
|
|
|
1141
1534
|
* without a shell, checked against `allow` (or the integration's default),
|
|
1142
1535
|
* its arguments and observed output fields from the integration's adapter,
|
|
1143
1536
|
* long output kept in run files. It sees who it is and the node's `env:`
|
|
1144
|
-
* (`tool-env.ts`),
|
|
1537
|
+
* (`tool-env.ts`), may return JSON (`tool-output.ts`), and may run once per
|
|
1538
|
+
* set of files (`runUnlessPassed` in `tool-command.ts`).
|
|
1145
1539
|
*/
|
|
1146
1540
|
/** How much of stdout and stderr a node's output keeps. */
|
|
1147
1541
|
const OUTPUT_LIMIT = 65536;
|
|
1148
1542
|
const ERROR_LIMIT = 1024;
|
|
1543
|
+
/** The interpreters whose script's commands a heavy check reads. */
|
|
1544
|
+
const SHELLS = /* @__PURE__ */ new Set([
|
|
1545
|
+
"sh",
|
|
1546
|
+
"bash",
|
|
1547
|
+
"zsh"
|
|
1548
|
+
]);
|
|
1149
1549
|
/** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
|
|
1150
1550
|
const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
|
|
1151
1551
|
return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
|
|
1152
1552
|
});
|
|
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
1553
|
/** The program and arguments a tool node runs, before the allow check. */
|
|
1165
1554
|
const commandOf = (node, context) => {
|
|
1166
1555
|
const line = commandLineOf(node);
|
|
@@ -1171,12 +1560,14 @@ const commandOf = (node, context) => {
|
|
|
1171
1560
|
const tokens = tokenize(line);
|
|
1172
1561
|
if (!tokens.ok) return tokens;
|
|
1173
1562
|
const args = [];
|
|
1563
|
+
let afterSeparator = false;
|
|
1174
1564
|
for (const token of tokens.args) {
|
|
1175
1565
|
if (node.script !== void 0) {
|
|
1176
1566
|
args.push(token);
|
|
1177
1567
|
continue;
|
|
1178
1568
|
}
|
|
1179
|
-
const rendered =
|
|
1569
|
+
const rendered = renderArg(token, context, !afterSeparator);
|
|
1570
|
+
afterSeparator ||= token === "--";
|
|
1180
1571
|
if (!rendered.ok) return rendered;
|
|
1181
1572
|
args.push(rendered.value);
|
|
1182
1573
|
}
|
|
@@ -1212,19 +1603,10 @@ const capped = (text, file) => {
|
|
|
1212
1603
|
* what a session is told. All of it is kept in the output's fields and files.
|
|
1213
1604
|
*/
|
|
1214
1605
|
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
1606
|
/** Runs a user's CLI through its integration's adapter, with its own login. */
|
|
1225
1607
|
const toolExecutor = (given, options = {}) => async (context) => {
|
|
1226
1608
|
const { node } = context;
|
|
1227
|
-
if (!isToolNode(node)) return failed$1(`${context.id} is not a tool
|
|
1609
|
+
if (!isToolNode(node)) return failed$1(`${context.id} is not a tool`, "unknown");
|
|
1228
1610
|
const environ = options.environ ?? process.env;
|
|
1229
1611
|
const scrub = scrubberOf(secretsOf(context.workflow, environ, options.maskedNames));
|
|
1230
1612
|
const command = commandOf(node, context);
|
|
@@ -1235,7 +1617,7 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1235
1617
|
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
1618
|
let cwd = context.cwd;
|
|
1237
1619
|
if (node.cwd !== void 0) {
|
|
1238
|
-
const rendered =
|
|
1620
|
+
const rendered = renderArg(node.cwd, context);
|
|
1239
1621
|
if (!rendered.ok) return failed$1(rendered.error);
|
|
1240
1622
|
cwd = path.resolve(context.cwd, rendered.value);
|
|
1241
1623
|
}
|
|
@@ -1245,32 +1627,49 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1245
1627
|
const outputFile = node.output?.format === "json" ? prepareOutputFile(context.runDir, context.id, context.round) : void 0;
|
|
1246
1628
|
const env = {
|
|
1247
1629
|
...nodeEnv.values,
|
|
1248
|
-
...identityOf(context, options.orkBin),
|
|
1630
|
+
...identityOf(context, options.orkBin, environ, options.store),
|
|
1249
1631
|
...maskedEnv(namesToMask(context.workflow, environ, options.maskedNames)),
|
|
1250
1632
|
...outputFile === void 0 ? {} : { [OUTPUT_ENV]: outputFile },
|
|
1251
1633
|
...node.watch === void 0 ? {} : { [STATE_ENV]: prepareStateFile(context.runDir, context.id) }
|
|
1252
1634
|
};
|
|
1253
1635
|
const names = Object.keys(nodeEnv.values);
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1636
|
+
const shown = scrub([
|
|
1637
|
+
command.program,
|
|
1638
|
+
...command.args,
|
|
1639
|
+
...node.script === void 0 ? [] : ["<script>"]
|
|
1640
|
+
].join(" "));
|
|
1641
|
+
const execute = async () => {
|
|
1642
|
+
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 } : {});
|
|
1643
|
+
context.emit({
|
|
1644
|
+
type: "tool.called",
|
|
1645
|
+
id: context.id,
|
|
1646
|
+
name: scrub(command.program),
|
|
1647
|
+
input: {
|
|
1648
|
+
command: shown,
|
|
1649
|
+
...names.length === 0 ? {} : { env: names }
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
|
|
1653
|
+
const priority = context.machine?.priority;
|
|
1654
|
+
return runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
|
|
1655
|
+
cwd,
|
|
1656
|
+
signal: context.signal,
|
|
1657
|
+
env,
|
|
1658
|
+
...priority === void 0 ? {} : { priority }
|
|
1659
|
+
}).finally(() => {
|
|
1660
|
+
release?.();
|
|
1661
|
+
if (scriptFile !== void 0) removeOutputFile(scriptFile);
|
|
1662
|
+
});
|
|
1663
|
+
};
|
|
1664
|
+
const raw = await runUnlessPassed({
|
|
1665
|
+
node,
|
|
1666
|
+
context,
|
|
1269
1667
|
cwd,
|
|
1270
|
-
|
|
1271
|
-
|
|
1668
|
+
shown,
|
|
1669
|
+
environ,
|
|
1670
|
+
execute
|
|
1272
1671
|
});
|
|
1273
|
-
if (
|
|
1672
|
+
if (raw === null) return skippedCheck(outputFile);
|
|
1274
1673
|
const run = {
|
|
1275
1674
|
exitCode: raw.exitCode,
|
|
1276
1675
|
stdout: scrub(raw.stdout),
|
|
@@ -1306,30 +1705,14 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1306
1705
|
context.emit({
|
|
1307
1706
|
type: "tool.result",
|
|
1308
1707
|
id: context.id,
|
|
1309
|
-
isError: run.exitCode !== 0 || json?.ok === false || declared !== null
|
|
1708
|
+
isError: run.exitCode !== 0 || json?.ok === false || declared !== null,
|
|
1709
|
+
...raw.cpu === void 0 ? {} : { cpu: raw.cpu }
|
|
1310
1710
|
});
|
|
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
|
-
};
|
|
1711
|
+
if (json?.ok === false) return invalidOutput(output, json.error);
|
|
1712
|
+
if (declared !== null) return invalidOutput(output, declared);
|
|
1321
1713
|
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
|
-
};
|
|
1714
|
+
const signedOut = integration?.signedOut;
|
|
1715
|
+
return exitEnd(output, run, context, scrub(command.program), signedOut);
|
|
1333
1716
|
};
|
|
1334
1717
|
// ../nodes/src/verify.ts
|
|
1335
1718
|
/**
|
|
@@ -1341,15 +1724,17 @@ const toolExecutor = (given, options = {}) => async (context) => {
|
|
|
1341
1724
|
/** A setup that failed: no session started, so nothing was spent on it. */
|
|
1342
1725
|
const beforeFailed = (ran) => {
|
|
1343
1726
|
const error = `its before step failed: ${ran.error ?? "no reason given"}`;
|
|
1344
|
-
return {
|
|
1727
|
+
return endedWith({
|
|
1345
1728
|
ok: false,
|
|
1346
|
-
error,
|
|
1347
1729
|
output: {
|
|
1348
1730
|
status: "fail",
|
|
1349
1731
|
summary: error,
|
|
1350
1732
|
before: ran.output
|
|
1351
1733
|
}
|
|
1352
|
-
}
|
|
1734
|
+
}, endOf({
|
|
1735
|
+
...ran,
|
|
1736
|
+
ok: false
|
|
1737
|
+
}));
|
|
1353
1738
|
};
|
|
1354
1739
|
/** With nothing to run a step, a report cannot be accepted. */
|
|
1355
1740
|
const noSteps = () => Promise.resolve({
|
|
@@ -1370,20 +1755,23 @@ const runStep = async (tool, given, { timeout, ...fields }, step) => {
|
|
|
1370
1755
|
type: "tool.shell",
|
|
1371
1756
|
...fields
|
|
1372
1757
|
};
|
|
1373
|
-
const context = {
|
|
1374
|
-
...given,
|
|
1375
|
-
step
|
|
1376
|
-
};
|
|
1377
1758
|
const ms = timeoutMs(timeout);
|
|
1378
1759
|
if (ms === void 0) return tool({
|
|
1379
|
-
...
|
|
1760
|
+
...given,
|
|
1761
|
+
step,
|
|
1380
1762
|
node
|
|
1381
1763
|
});
|
|
1764
|
+
const { clock, machine } = slotClock(given.hold, given.machine);
|
|
1765
|
+
const context = {
|
|
1766
|
+
...given,
|
|
1767
|
+
step,
|
|
1768
|
+
...machine === void 0 ? {} : { machine }
|
|
1769
|
+
};
|
|
1382
1770
|
const limit = new AbortController();
|
|
1383
1771
|
const finished = new AbortController();
|
|
1384
1772
|
let expired = false;
|
|
1385
1773
|
const watch = async () => {
|
|
1386
|
-
if (await heldTimeout(
|
|
1774
|
+
if (await heldTimeout(clock, ms, AbortSignal.any([context.signal, finished.signal]))) {
|
|
1387
1775
|
expired = true;
|
|
1388
1776
|
limit.abort();
|
|
1389
1777
|
}
|
|
@@ -1494,42 +1882,28 @@ const checkedResult = (result, { check, resumes }) => {
|
|
|
1494
1882
|
};
|
|
1495
1883
|
const reason = `its check failed: ${check.error ?? "no reason given"}`;
|
|
1496
1884
|
const error = result.ok ? reason : `${reason} (then: ${result.error ?? "no report"})`;
|
|
1497
|
-
return {
|
|
1885
|
+
return endedWith({
|
|
1498
1886
|
...result,
|
|
1499
1887
|
ok: false,
|
|
1500
|
-
error,
|
|
1501
1888
|
output: {
|
|
1502
1889
|
...result.output,
|
|
1503
1890
|
status: "fail",
|
|
1504
1891
|
summary: error,
|
|
1505
1892
|
verify
|
|
1506
1893
|
}
|
|
1507
|
-
}
|
|
1894
|
+
}, endOf(result.ok ? {
|
|
1895
|
+
...check,
|
|
1896
|
+
ok: false
|
|
1897
|
+
} : result));
|
|
1508
1898
|
};
|
|
1509
1899
|
// ../nodes/src/claude.ts
|
|
1510
1900
|
/**
|
|
1511
|
-
* @file The Claude node type: runs
|
|
1901
|
+
* @file The Claude node type: runs an `agent.leader` or `agent.worker` as one
|
|
1512
1902
|
* session, planned with the run's outputs (and the brief its owner gave it),
|
|
1513
1903
|
* and turns its report into the node's output. A leader gets its children
|
|
1514
1904
|
* through the session's ports, and resumes its session on re-entry
|
|
1515
1905
|
* (ADR-019).
|
|
1516
1906
|
*/
|
|
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
1907
|
/** A re-entered leader resumes; anyone else starts with the full brief. */
|
|
1534
1908
|
const specFor = (spec, context) => {
|
|
1535
1909
|
if (context.resume !== void 0) return {
|
|
@@ -1543,24 +1917,9 @@ const specFor = (spec, context) => {
|
|
|
1543
1917
|
};
|
|
1544
1918
|
return spec;
|
|
1545
1919
|
};
|
|
1546
|
-
/** Runs
|
|
1920
|
+
/** Runs an `agent.leader` or `agent.worker` as one session. */
|
|
1547
1921
|
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;
|
|
1922
|
+
const planOptions = planOptionsOf(context, options.inPlace);
|
|
1564
1923
|
const before = beforeOf(context.node, context.workflow.defaults);
|
|
1565
1924
|
if (before !== void 0) {
|
|
1566
1925
|
const ran = await runStep(options.steps ?? noSteps, context, before, {
|
|
@@ -1569,49 +1928,53 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1569
1928
|
});
|
|
1570
1929
|
if (!ran.ok) return beforeFailed(ran);
|
|
1571
1930
|
await context.hold?.wait("start", context.signal);
|
|
1572
|
-
if (context.signal.aborted)
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1931
|
+
if (context.signal.aborted) {
|
|
1932
|
+
const end = stoppedEnd(context.signal);
|
|
1933
|
+
return beforeFailed({
|
|
1934
|
+
...ran,
|
|
1935
|
+
ok: false,
|
|
1936
|
+
error: end.message,
|
|
1937
|
+
end
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1576
1940
|
planOptions.before = ran.output;
|
|
1577
1941
|
}
|
|
1578
1942
|
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;
|
|
1943
|
+
if (blocked !== void 0) return notStarted("plan_limit", blocked, blocked);
|
|
1944
|
+
const refused = await startBudgets(context, planOptions);
|
|
1945
|
+
if (refused !== null) return refused;
|
|
1583
1946
|
const plan = planNodeSession(context.workflow, context.id, planOptions);
|
|
1584
|
-
if (!plan.ok) return
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
error: plan.error
|
|
1947
|
+
if (!plan.ok) return notStarted("invalid_node", plan.error);
|
|
1948
|
+
const servers = namedMcpEntries(context.projectRoot, plan.mcp);
|
|
1949
|
+
if (!servers.ok) return notStarted("invalid_node", servers.error);
|
|
1950
|
+
const spec = {
|
|
1951
|
+
...specFor(plan.spec, context),
|
|
1952
|
+
...servers.value
|
|
1591
1953
|
};
|
|
1592
|
-
const spec = specFor(plan.spec, context);
|
|
1593
1954
|
if (options.machineClaude === true) spec.machineClaude = true;
|
|
1594
|
-
const driver =
|
|
1595
|
-
|
|
1596
|
-
named: plan.driver,
|
|
1597
|
-
ready: options.readyDriver
|
|
1598
|
-
});
|
|
1599
|
-
if (driver === void 0) return notStarted("no Claude driver is ready; see `ork doctor`");
|
|
1955
|
+
const driver = sessionDriverOf(context, plan, options);
|
|
1956
|
+
if (typeof driver !== "string") return driver;
|
|
1600
1957
|
const runtime = await options.runtimeFor(driver);
|
|
1601
|
-
const ports = { human: context.owner === void 0 ? context
|
|
1958
|
+
const ports = { human: context.owner === void 0 ? withdrawnOnStop(context) : ownerFirst(context, context.owner, {
|
|
1602
1959
|
runtimeFor: options.runtimeFor,
|
|
1603
1960
|
driver: options.driver,
|
|
1604
1961
|
readyDriver: options.readyDriver,
|
|
1605
1962
|
inPlace: options.inPlace
|
|
1606
1963
|
}) };
|
|
1607
1964
|
if (context.hold !== void 0) ports.hold = context.hold;
|
|
1965
|
+
if (context.machine !== void 0) ports.machine = context.machine;
|
|
1608
1966
|
if (context.ownership !== void 0) ports.ownership = context.ownership;
|
|
1609
1967
|
if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
|
|
1610
1968
|
if (context.claims !== void 0) ports.claims = context.claims;
|
|
1611
1969
|
if (context.feedback !== void 0) ports.feedback = context.feedback;
|
|
1612
|
-
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1970
|
+
if (context.followUps !== void 0) ports.followUps = context.followUps;
|
|
1971
|
+
const whole = context.round > 1 ? wholeInput(context.input) : void 0;
|
|
1972
|
+
if (whole !== void 0) ports.input = whole;
|
|
1973
|
+
const watch = limitWatch(context, runCapsOf(context, planOptions));
|
|
1974
|
+
const run = async (asked, prior = 0) => {
|
|
1975
|
+
const watching = watch.session(asked, prior);
|
|
1976
|
+
context.reserveBudget?.(asked.budgetUsd);
|
|
1977
|
+
context.reserveTokens?.(asked.budgetTokens);
|
|
1615
1978
|
const session = runtime.start(asked, ports);
|
|
1616
1979
|
const stop = () => {
|
|
1617
1980
|
session.interrupt();
|
|
@@ -1629,6 +1992,8 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1629
1992
|
context.emit(event);
|
|
1630
1993
|
}
|
|
1631
1994
|
} finally {
|
|
1995
|
+
context.reserveBudget?.(0);
|
|
1996
|
+
context.reserveTokens?.(0);
|
|
1632
1997
|
disconnect?.();
|
|
1633
1998
|
context.signal.removeEventListener("abort", stop);
|
|
1634
1999
|
}
|
|
@@ -1636,7 +2001,7 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1636
2001
|
return outcome;
|
|
1637
2002
|
};
|
|
1638
2003
|
const first = await run(spec);
|
|
1639
|
-
const limitsAfter = ({ sessionId, usage }) => {
|
|
2004
|
+
const limitsAfter = ({ sessionId, usage }, turnsAtLeast = 0) => {
|
|
1640
2005
|
const quota = context.quota?.();
|
|
1641
2006
|
if (quota?.blocked !== void 0) {
|
|
1642
2007
|
watch.stoppedBy(quota.blocked);
|
|
@@ -1644,15 +2009,19 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1644
2009
|
}
|
|
1645
2010
|
return lateLimits(spec, {
|
|
1646
2011
|
spent: context.ownSpend?.(sessionId, usage.costUsd) ?? usage.costUsd,
|
|
1647
|
-
turns: usage.turns
|
|
1648
|
-
|
|
2012
|
+
turns: usage.turns,
|
|
2013
|
+
tokens: countedTokens(usage)
|
|
2014
|
+
}, context.budgetLeftNow?.(), quota?.leftUsd, {
|
|
2015
|
+
run: context.tokensLeftNow?.(),
|
|
2016
|
+
quota: quota?.leftTokens
|
|
2017
|
+
}, turnsAtLeast);
|
|
1649
2018
|
};
|
|
1650
2019
|
const resumeWith = (outcome, prompt, limits) => run({
|
|
1651
2020
|
...spec,
|
|
1652
2021
|
...limits,
|
|
1653
2022
|
resume: outcome.sessionId,
|
|
1654
2023
|
prompt
|
|
1655
|
-
});
|
|
2024
|
+
}, outcome.usage.costUsd);
|
|
1656
2025
|
const lateTurn = (current) => {
|
|
1657
2026
|
const late = context.mailbox?.pending() ?? 0;
|
|
1658
2027
|
const limits = limitsAfter(current);
|
|
@@ -1678,7 +2047,7 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1678
2047
|
},
|
|
1679
2048
|
late: lateTurn,
|
|
1680
2049
|
resume: async (current, check) => {
|
|
1681
|
-
const limits = limitsAfter(current);
|
|
2050
|
+
const limits = limitsAfter(current, 20);
|
|
1682
2051
|
if (limits === null) return null;
|
|
1683
2052
|
await context.hold?.wait("start", context.signal);
|
|
1684
2053
|
return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, {
|
|
@@ -1691,10 +2060,11 @@ const claudeExecutor = (options) => async (context) => {
|
|
|
1691
2060
|
};
|
|
1692
2061
|
// ../nodes/src/gate.ts
|
|
1693
2062
|
/**
|
|
1694
|
-
* @file The `human.gate` node type: asks the human the rendered question,
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
2063
|
+
* @file The `human.gate` node type: asks the human the rendered question, with
|
|
2064
|
+
* its document if it has one, both in readable text (no raw JSON), outputs
|
|
2065
|
+
* their choice with the note they gave, and answers with the `default` when
|
|
2066
|
+
* the `timeout` passes. The timeout counts only while the run and the gate
|
|
2067
|
+
* are not paused (ADR-041).
|
|
1698
2068
|
*/
|
|
1699
2069
|
/** A promise that never settles: the losing side of a race. */
|
|
1700
2070
|
const NEVER = new Promise(() => {});
|
|
@@ -1716,8 +2086,13 @@ const gateExecutor = async (context) => {
|
|
|
1716
2086
|
output: {},
|
|
1717
2087
|
error: `${context.id} is not a gate`
|
|
1718
2088
|
};
|
|
1719
|
-
const
|
|
1720
|
-
const
|
|
2089
|
+
const scope = runScope(context.inputs, context.outputs, context.runId);
|
|
2090
|
+
const render = (text) => {
|
|
2091
|
+
const template = parseTemplate(text);
|
|
2092
|
+
return template.ok ? renderTemplate(template.parts, scope, { readable: true }) : text;
|
|
2093
|
+
};
|
|
2094
|
+
const question = render(node.question);
|
|
2095
|
+
const document = render(node.document ?? "");
|
|
1721
2096
|
const options = node.options.map((o) => choiceOf(o));
|
|
1722
2097
|
const withdraw = new AbortController();
|
|
1723
2098
|
context.signal.addEventListener("abort", () => {
|
|
@@ -1728,12 +2103,14 @@ const gateExecutor = async (context) => {
|
|
|
1728
2103
|
nodeId: context.id,
|
|
1729
2104
|
question,
|
|
1730
2105
|
options,
|
|
2106
|
+
waits: true,
|
|
1731
2107
|
signal: withdraw.signal,
|
|
1732
2108
|
onNote: (given) => {
|
|
1733
2109
|
note = given;
|
|
1734
2110
|
}
|
|
1735
2111
|
};
|
|
1736
2112
|
if (node.recommended !== void 0) asked.recommended = node.recommended;
|
|
2113
|
+
if (document.trim() !== "") asked.document = document;
|
|
1737
2114
|
const answer = context.human.ask(asked).then((choice) => ({
|
|
1738
2115
|
choice,
|
|
1739
2116
|
answeredBy: "user",
|
|
@@ -1752,21 +2129,22 @@ const gateExecutor = async (context) => {
|
|
|
1752
2129
|
})];
|
|
1753
2130
|
try {
|
|
1754
2131
|
const output = await Promise.race([answer, ...expired]);
|
|
1755
|
-
if (!options.some((o) => o.id === output.choice))
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
2132
|
+
if (!options.some((o) => o.id === output.choice)) {
|
|
2133
|
+
const facts = node.timeout === void 0 ? {} : { timeout: node.timeout };
|
|
2134
|
+
return endedWith({
|
|
2135
|
+
ok: false,
|
|
2136
|
+
output
|
|
2137
|
+
}, endFor("timed_out", facts, "no one answered the gate"));
|
|
2138
|
+
}
|
|
1760
2139
|
return {
|
|
1761
2140
|
ok: true,
|
|
1762
2141
|
output
|
|
1763
2142
|
};
|
|
1764
2143
|
} catch {
|
|
1765
|
-
return {
|
|
2144
|
+
return endedWith({
|
|
1766
2145
|
ok: false,
|
|
1767
|
-
output: {}
|
|
1768
|
-
|
|
1769
|
-
};
|
|
2146
|
+
output: {}
|
|
2147
|
+
}, endFor("interrupted", {}, "the gate was withdrawn"));
|
|
1770
2148
|
} finally {
|
|
1771
2149
|
answered.abort();
|
|
1772
2150
|
}
|
|
@@ -1781,18 +2159,16 @@ const gateExecutor = async (context) => {
|
|
|
1781
2159
|
*/
|
|
1782
2160
|
/** A node's `timeout:` is what limits a call; this only lifts the SDK's 60 s. */
|
|
1783
2161
|
const NO_LIMIT_MS = 864e5;
|
|
1784
|
-
const failed = (
|
|
2162
|
+
const failed = (kind, facts, detail, output = {}) => endedWith({
|
|
1785
2163
|
ok: false,
|
|
1786
|
-
output
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
const
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
error
|
|
1795
|
-
});
|
|
2164
|
+
output
|
|
2165
|
+
}, endFor(kind, facts, detail));
|
|
2166
|
+
/** How a call that threw ended: a sign-in, a server that won't start, or else. */
|
|
2167
|
+
const thrownKind = (error) => {
|
|
2168
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
2169
|
+
if (code === 401 || error?.constructor.name === "UnauthorizedError") return "auth";
|
|
2170
|
+
return code === "ENOENT" ? "tool_missing" : "command_failed";
|
|
2171
|
+
};
|
|
1796
2172
|
/**
|
|
1797
2173
|
* A string's templates rendered. A string that is one `{{ … }}` and nothing
|
|
1798
2174
|
* else keeps its value's type, so a tool that asks for a number gets one.
|
|
@@ -1836,19 +2212,22 @@ const reasonOf = (error, server) => {
|
|
|
1836
2212
|
};
|
|
1837
2213
|
const mcpCallExecutor = (options = {}) => async (context) => {
|
|
1838
2214
|
const { node } = context;
|
|
1839
|
-
if (node.type !== "mcp.call")
|
|
2215
|
+
if (node.type !== "mcp.call") {
|
|
2216
|
+
const said = `${context.id} is not an MCP call`;
|
|
2217
|
+
return failed("unknown", {}, said);
|
|
2218
|
+
}
|
|
1840
2219
|
const environ = options.environ ?? process.env;
|
|
1841
2220
|
const declared = readMcpServer(context.projectRoot, node.server);
|
|
1842
|
-
if (!declared.ok) return failed(declared.error);
|
|
2221
|
+
if (!declared.ok) return failed("invalid_node", { reason: declared.error });
|
|
1843
2222
|
const nodeEnv = renderEnv(node, context, environ);
|
|
1844
|
-
if (!nodeEnv.ok) return failed(nodeEnv.error);
|
|
2223
|
+
if (!nodeEnv.ok) return failed("invalid_node", { reason: nodeEnv.error });
|
|
1845
2224
|
const expanded = expandServer(declared.value, {
|
|
1846
2225
|
...environ,
|
|
1847
2226
|
...nodeEnv.values
|
|
1848
2227
|
}, node.plainEnv);
|
|
1849
|
-
if (!expanded.ok) return failed(expanded.error);
|
|
2228
|
+
if (!expanded.ok) return failed("invalid_node", { reason: expanded.error });
|
|
1850
2229
|
const scrub = scrubberOf([...secretsOf(context.workflow, environ, options.maskedNames), ...expanded.value.values]);
|
|
1851
|
-
const scope = runScope(context.inputs, context.outputs, context.runId);
|
|
2230
|
+
const scope = runScope(context.inputs, context.outputs, context.runId, context.item);
|
|
1852
2231
|
const args = Object.fromEntries(Object.entries(node.arguments ?? {}).map(([key, value]) => [key, renderValue(value, scope)]));
|
|
1853
2232
|
const name = `${node.server}.${node.tool}`;
|
|
1854
2233
|
context.emit({
|
|
@@ -1889,19 +2268,22 @@ const mcpCallExecutor = (options = {}) => async (context) => {
|
|
|
1889
2268
|
id: context.id,
|
|
1890
2269
|
isError: reported || tooLong || problem !== null
|
|
1891
2270
|
});
|
|
1892
|
-
if (reported) return
|
|
1893
|
-
if (tooLong)
|
|
1894
|
-
|
|
2271
|
+
if (reported) return failed("command_failed", { program: name }, said, output);
|
|
2272
|
+
if (tooLong) {
|
|
2273
|
+
const reason = `${name}'s result is over 64 KiB`;
|
|
2274
|
+
return failed("invalid_output", { reason }, void 0, output);
|
|
2275
|
+
}
|
|
2276
|
+
return problem === null ? endedWith({
|
|
1895
2277
|
ok: true,
|
|
1896
2278
|
output
|
|
1897
|
-
} : failed(problem, output);
|
|
2279
|
+
}, endFor("done")) : failed("invalid_output", { reason: problem }, void 0, output);
|
|
1898
2280
|
} catch (error) {
|
|
1899
2281
|
context.emit({
|
|
1900
2282
|
type: "tool.result",
|
|
1901
2283
|
id: context.id,
|
|
1902
2284
|
isError: true
|
|
1903
2285
|
});
|
|
1904
|
-
return
|
|
2286
|
+
return failed(thrownKind(error), { program: `MCP server \`${node.server}\`` }, clipError(scrub(reasonOf(error, node.server))));
|
|
1905
2287
|
} finally {
|
|
1906
2288
|
await client.close().catch(() => {});
|
|
1907
2289
|
}
|
|
@@ -1912,13 +2294,42 @@ const mcpCallExecutor = (options = {}) => async (context) => {
|
|
|
1912
2294
|
* text. A program runs in its own process group, so aborting it reaches
|
|
1913
2295
|
* whatever it started: SIGTERM, then SIGKILL for what ignores that. A stop or
|
|
1914
2296
|
* a timeout therefore leaves nothing running, and an escaped process that
|
|
1915
|
-
* keeps the output pipes open cannot hold the node.
|
|
2297
|
+
* keeps the output pipes open cannot hold the node. Off Windows it runs under
|
|
2298
|
+
* `sh`, whose `times` says the CPU the program and its children used, beside
|
|
2299
|
+
* the orphan guard, which ends the group when `ork` dies, and at the
|
|
2300
|
+
* project's priority: `low` starts it below Ork's own (nice, and on macOS
|
|
2301
|
+
* utility QoS).
|
|
1916
2302
|
*/
|
|
1917
|
-
/** How long a program may take to end after SIGTERM. */
|
|
1918
|
-
const KILL_AFTER_MS = 2e3;
|
|
1919
2303
|
/** How long to wait for the pipes to close after SIGKILL. */
|
|
1920
2304
|
const CLOSE_AFTER_MS = 500;
|
|
1921
|
-
|
|
2305
|
+
/** The fd of the orphan guard's pipe from `ork`. */
|
|
2306
|
+
const WATCH_FD = 4;
|
|
2307
|
+
/**
|
|
2308
|
+
* Runs `$0 "$@"` beside the orphan guard, which ends the group if `ork` dies,
|
|
2309
|
+
* and stops the guard once the program ends, so its leftovers live on as
|
|
2310
|
+
* before. Then writes the shell's `times` to fd 3: its children's user and
|
|
2311
|
+
* system time, the program's whole tree that it waited for. A program that
|
|
2312
|
+
* isn't there says so on fd 3, as spawn's ENOENT would.
|
|
2313
|
+
*/
|
|
2314
|
+
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`;
|
|
2315
|
+
const MISSING = "ork-missing";
|
|
2316
|
+
/** The children's line of `times` (`0m1.250s 0m0.300s`), in milliseconds. */
|
|
2317
|
+
const cpuOfTimes = (text) => {
|
|
2318
|
+
const [, , user, system] = [...text.matchAll(/(\d+)m([\d.]+)s/gu)].map(([, m = "0", s = "0"]) => Number(m) * 60 + Number(s));
|
|
2319
|
+
return user === void 0 || system === void 0 ? void 0 : {
|
|
2320
|
+
userMs: Math.round(user * 1e3),
|
|
2321
|
+
systemMs: Math.round(system * 1e3)
|
|
2322
|
+
};
|
|
2323
|
+
};
|
|
2324
|
+
const timed = process.platform !== "win32";
|
|
2325
|
+
/** What to start: the program, under `sh` to time it, at the priority. */
|
|
2326
|
+
const startOf = (program, args, priority) => timed ? startAt(priority, "sh", [
|
|
2327
|
+
"-c",
|
|
2328
|
+
TIMED,
|
|
2329
|
+
program,
|
|
2330
|
+
...args
|
|
2331
|
+
]) : [program, [...args]];
|
|
2332
|
+
const spawnTool = (program, args, { cwd, signal, env, priority }) => {
|
|
1922
2333
|
if (signal.aborted) return Promise.resolve({
|
|
1923
2334
|
exitCode: -1,
|
|
1924
2335
|
stdout: "",
|
|
@@ -1928,7 +2339,9 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1928
2339
|
const stdout = [];
|
|
1929
2340
|
const stderr = [];
|
|
1930
2341
|
const timers = [];
|
|
1931
|
-
const
|
|
2342
|
+
const times = [];
|
|
2343
|
+
const [bin, argv] = startOf(program, args, priority);
|
|
2344
|
+
const child = spawn(bin, argv, {
|
|
1932
2345
|
cwd,
|
|
1933
2346
|
detached: true,
|
|
1934
2347
|
env: {
|
|
@@ -1938,9 +2351,17 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1938
2351
|
stdio: [
|
|
1939
2352
|
"ignore",
|
|
1940
2353
|
"pipe",
|
|
2354
|
+
"pipe",
|
|
2355
|
+
"pipe",
|
|
1941
2356
|
"pipe"
|
|
1942
2357
|
]
|
|
1943
2358
|
});
|
|
2359
|
+
lowerChild(priority, child.pid);
|
|
2360
|
+
holdGuard(child.stdio[WATCH_FD]);
|
|
2361
|
+
const fd3 = child.stdio[3];
|
|
2362
|
+
if (fd3 instanceof Readable) fd3.setEncoding("utf8").on("data", (chunk) => {
|
|
2363
|
+
times.push(chunk);
|
|
2364
|
+
});
|
|
1944
2365
|
const killGroup = (kill) => {
|
|
1945
2366
|
if (child.pid === void 0) return;
|
|
1946
2367
|
try {
|
|
@@ -1952,11 +2373,21 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1952
2373
|
signal.removeEventListener("abort", abort);
|
|
1953
2374
|
resolve(run);
|
|
1954
2375
|
};
|
|
1955
|
-
const collected = (exitCode) =>
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2376
|
+
const collected = (exitCode) => {
|
|
2377
|
+
const said = times.join("");
|
|
2378
|
+
if (said.includes(MISSING)) return {
|
|
2379
|
+
exitCode: -1,
|
|
2380
|
+
stdout: stdout.join(""),
|
|
2381
|
+
stderr: `could not run ${program}: spawn ${program} ENOENT`
|
|
2382
|
+
};
|
|
2383
|
+
const cpu = cpuOfTimes(said);
|
|
2384
|
+
return {
|
|
2385
|
+
exitCode,
|
|
2386
|
+
stdout: stdout.join(""),
|
|
2387
|
+
stderr: stderr.join(""),
|
|
2388
|
+
...cpu === void 0 ? {} : { cpu }
|
|
2389
|
+
};
|
|
2390
|
+
};
|
|
1960
2391
|
const abort = () => {
|
|
1961
2392
|
killGroup("SIGTERM");
|
|
1962
2393
|
timers.push(setTimeout(() => {
|
|
@@ -1967,10 +2398,10 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1967
2398
|
}, KILL_AFTER_MS));
|
|
1968
2399
|
};
|
|
1969
2400
|
signal.addEventListener("abort", abort, { once: true });
|
|
1970
|
-
child.stdout
|
|
2401
|
+
child.stdout?.setEncoding("utf8").on("data", (chunk) => {
|
|
1971
2402
|
stdout.push(chunk);
|
|
1972
2403
|
});
|
|
1973
|
-
child.stderr
|
|
2404
|
+
child.stderr?.setEncoding("utf8").on("data", (chunk) => {
|
|
1974
2405
|
stderr.push(chunk);
|
|
1975
2406
|
});
|
|
1976
2407
|
child.on("error", (error) => {
|
|
@@ -1990,6 +2421,7 @@ const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
|
1990
2421
|
const builtinExecutors = (options) => {
|
|
1991
2422
|
const tool = toolExecutor(options.tools ?? spawnTool, {
|
|
1992
2423
|
...options.orkBin === void 0 ? {} : { orkBin: options.orkBin },
|
|
2424
|
+
...options.store === void 0 ? {} : { store: options.store },
|
|
1993
2425
|
...options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames }
|
|
1994
2426
|
});
|
|
1995
2427
|
const claude = claudeExecutor({
|
|
@@ -1997,8 +2429,8 @@ const builtinExecutors = (options) => {
|
|
|
1997
2429
|
steps: tool
|
|
1998
2430
|
});
|
|
1999
2431
|
const executors = {
|
|
2000
|
-
"
|
|
2001
|
-
"
|
|
2432
|
+
"agent.leader": claude,
|
|
2433
|
+
"agent.worker": claude,
|
|
2002
2434
|
"human.gate": gateExecutor,
|
|
2003
2435
|
"workflow.call": callExecutor,
|
|
2004
2436
|
"mcp.call": mcpCallExecutor(options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames })
|
|
@@ -2130,4 +2562,4 @@ const missingPrograms = (workflow, installed = (program) => onPath(program)) =>
|
|
|
2130
2562
|
}
|
|
2131
2563
|
return [...missing.values()];
|
|
2132
2564
|
};
|
|
2133
|
-
export {
|
|
2565
|
+
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 };
|