@krapht/ork 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD_PARTY_NOTICES.md +7 -0
- package/dist/__build_info__-CtSVVuZE.mjs +691 -0
- package/dist/bin.mjs +1654 -3532
- package/dist/clean-Dcr807be.mjs +88 -0
- package/dist/context-C_bksb0C.mjs +351 -0
- package/dist/find-DptmbhGd.mjs +47 -0
- package/dist/format-J-a21Yvn.mjs +393 -0
- package/dist/in-a-run-D1Qc5Mit.mjs +22 -0
- package/dist/limits-BDZCmGEu.mjs +44 -0
- package/dist/list-ZvY2OcLq.mjs +110 -0
- package/dist/mcp-CHVKKg3B.mjs +91 -0
- package/dist/output-CXSJO00X.mjs +900 -0
- package/dist/pause-BPSmg4yz.mjs +65 -0
- package/dist/relay-Do-aNbSD.mjs +122 -0
- package/dist/remove-AwaMMaHf.mjs +194 -0
- package/dist/report-DK4LGqwF.mjs +129 -0
- package/dist/send-DhGBkowy.mjs +163 -0
- package/dist/{src-DAsOYtoR.mjs → src-BHnxpJyH.mjs} +496 -193
- package/dist/src-CGqMati3.mjs +12505 -0
- package/dist/src-CpDKIulI.mjs +3118 -0
- package/dist/src-UWkk1sl9.mjs +1899 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +631 -0
- package/dist/ui/assets/app-BgVDC_5l.js +67 -0
- package/dist/ui/assets/app-DVf68fj5.css +1 -0
- package/dist/ui/assets/geist-mono-cyrillic-400-normal-C51Di1Mf.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-400-normal-CPJFfJgk.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-500-normal-D2cXvDHF.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-500-normal-DfuvdVgn.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-CkxHOdCb.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-XB2BPmDK.woff2 +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-BY1KTvO5.woff +0 -0
- package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-C7aYOB1D.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-400-normal-B2yvC1Cq.woff +0 -0
- package/dist/ui/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-500-normal-D-GG86Jb.woff +0 -0
- package/dist/ui/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-400-normal-CfzLURNc.woff +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-400-normal-DI-rJ0UV.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-500-normal-Buglb9-a.woff2 +0 -0
- package/dist/ui/assets/geist-mono-latin-ext-500-normal-CrWEgvU_.woff +0 -0
- package/dist/ui/assets/geist-mono-symbols2-400-normal-BS9BGVxz.woff2 +0 -0
- package/dist/ui/assets/geist-mono-symbols2-400-normal-DjbiTEEA.woff +0 -0
- package/dist/ui/assets/geist-mono-symbols2-500-normal-CmnCPGa0.woff +0 -0
- package/dist/ui/assets/geist-mono-symbols2-500-normal-Q_ksOahU.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-400-normal-CKEaXIlx.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-400-normal-CZEPLOgu.woff +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-500-normal-Bs29RrUZ.woff2 +0 -0
- package/dist/ui/assets/geist-mono-vietnamese-500-normal-Dx9-epDo.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-400-normal-BOaIZNA2.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-400-normal-gapTbOY8.woff2 +0 -0
- package/dist/ui/assets/geist-sans-latin-500-normal-CN2lyvyL.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-500-normal-uokXdC-Q.woff2 +0 -0
- package/dist/ui/assets/geist-sans-latin-600-normal-CA1yjETN.woff +0 -0
- package/dist/ui/assets/geist-sans-latin-600-normal-DFOURf8L.woff2 +0 -0
- package/dist/ui/assets/index-BiO54L90.css +1 -0
- package/dist/ui/assets/index-MQNNgsyT.js +10 -0
- package/dist/ui/assets/theme-3xH5jxKs.js +1 -0
- package/dist/ui/favicon.ico +0 -0
- package/dist/ui/favicon.svg +4 -0
- package/dist/ui/index.html +30 -0
- package/dist/ui/ork-wordmark-small-on-light.svg +4 -0
- package/dist/ui/ork-wordmark-small.svg +4 -0
- package/dist/ui-DhutUMgQ.mjs +472 -0
- package/dist/usage-CT7Z-yjr.mjs +419 -0
- package/dist/watch-0Qr2UfCQ.mjs +394 -0
- package/dist/worktrees-DHl80k96.mjs +212 -0
- package/package.json +6 -4
- package/dist/src-CJ_zF0Zj.mjs +0 -5433
|
@@ -0,0 +1,1899 @@
|
|
|
1
|
+
import { Ct as runScope, Et as envReadsOf, Gn as TOOL_PREFIX, Gt as parseTemplate, Hn as programsRunBy, Kn as integrationFor, Kt as renderTemplate, M as declaredProblem, Nn as markValue, Ot as declaredOutputOf, St as heldTimeout, Tt as calledWorkflows, Un as timeoutMs, Vn as isToolNode, Wn as INTEGRATIONS, Wt as evaluate, s as planReason, st as planNodeSession, zn as beforeOf } from "./src-CGqMati3.mjs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
6
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
7
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
8
|
+
// ../nodes/src/call.ts
|
|
9
|
+
/**
|
|
10
|
+
* @file The `workflow.call` node type (RFC-004): renders what the node asks for
|
|
11
|
+
* and hands it to the run's call port, which starts the workflow as its own
|
|
12
|
+
* run and waits as the node says.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* A call a limit stopped is cut off, as a session is: the called run ended
|
|
16
|
+
* `quota_exhausted`, or was refused at its start, which the caller's own limits
|
|
17
|
+
* can't see when its project has its own.
|
|
18
|
+
*/
|
|
19
|
+
const limitedCall = (result) => {
|
|
20
|
+
if (result.ok) return result;
|
|
21
|
+
return (result.error ?? "").includes("Not started: ") || result.output["runStatus"] === "quota_exhausted" ? {
|
|
22
|
+
...result,
|
|
23
|
+
limited: result.error ?? "the called run reached a limit"
|
|
24
|
+
} : result;
|
|
25
|
+
};
|
|
26
|
+
const callExecutor = (context) => {
|
|
27
|
+
const { node, calls } = context;
|
|
28
|
+
if (node.type !== "workflow.call") return Promise.resolve({
|
|
29
|
+
ok: false,
|
|
30
|
+
output: {},
|
|
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);
|
|
39
|
+
const render = (text) => {
|
|
40
|
+
const template = parseTemplate(text);
|
|
41
|
+
return template.ok ? renderTemplate(template.parts, scope) : text;
|
|
42
|
+
};
|
|
43
|
+
return calls.invoke({
|
|
44
|
+
callId: context.id,
|
|
45
|
+
workflow: render(node.workflow),
|
|
46
|
+
project: node.project === void 0 ? void 0 : render(node.project),
|
|
47
|
+
node: node.node,
|
|
48
|
+
inputs: Object.fromEntries(Object.entries(node.inputs ?? {}).map(([name, value]) => [name, render(value)])),
|
|
49
|
+
wait: node.wait ?? (node.node === void 0 ? "run" : "node"),
|
|
50
|
+
ref: node.ref === void 0 ? void 0 : render(node.ref),
|
|
51
|
+
maxMessages: node.maxMessages,
|
|
52
|
+
input: context.input,
|
|
53
|
+
budgetLeftUsd: context.budgetLeftNow?.() ?? context.budgetLeftUsd,
|
|
54
|
+
channels: (context.workflow.messages ?? []).flatMap(({ between }) => {
|
|
55
|
+
const [a, b] = between;
|
|
56
|
+
if (a === context.id) return [b];
|
|
57
|
+
return b === context.id ? [a] : [];
|
|
58
|
+
}),
|
|
59
|
+
linked: (address) => {
|
|
60
|
+
context.mailbox?.alias(address);
|
|
61
|
+
}
|
|
62
|
+
}, context.signal).then(limitedCall);
|
|
63
|
+
};
|
|
64
|
+
// ../nodes/src/claude-result.ts
|
|
65
|
+
/**
|
|
66
|
+
* @file A finished session as a node's result: its report is the output, held
|
|
67
|
+
* to what the node declares (RFC-010).
|
|
68
|
+
*/
|
|
69
|
+
/** A finished session as a node result: its report is the node's output. */
|
|
70
|
+
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
|
+
};
|
|
79
|
+
const { report, usage } = outcome;
|
|
80
|
+
if (report === null) {
|
|
81
|
+
const reason = outcome.errors[0] ?? "no valid report";
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
output: {
|
|
85
|
+
status: "fail",
|
|
86
|
+
summary: reason,
|
|
87
|
+
usage
|
|
88
|
+
},
|
|
89
|
+
usage,
|
|
90
|
+
error: `ended without a report (${outcome.subtype}): ${reason}`,
|
|
91
|
+
sessionId: outcome.sessionId
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const ran = { sessionId: outcome.sessionId };
|
|
95
|
+
const output = {
|
|
96
|
+
...report.fields,
|
|
97
|
+
status: report.status,
|
|
98
|
+
summary: report.summary,
|
|
99
|
+
artifacts: report.artifacts,
|
|
100
|
+
usage
|
|
101
|
+
};
|
|
102
|
+
if (report.branch !== void 0) output["branch"] = report.branch;
|
|
103
|
+
const problem = declaredProblem(declared, output);
|
|
104
|
+
if (problem !== null) return {
|
|
105
|
+
ok: false,
|
|
106
|
+
output: {
|
|
107
|
+
status: "fail",
|
|
108
|
+
summary: problem,
|
|
109
|
+
artifacts: report.artifacts,
|
|
110
|
+
usage,
|
|
111
|
+
...report.branch === void 0 ? {} : { branch: report.branch }
|
|
112
|
+
},
|
|
113
|
+
usage,
|
|
114
|
+
error: problem,
|
|
115
|
+
...ran
|
|
116
|
+
};
|
|
117
|
+
const result = {
|
|
118
|
+
ok: report.status !== "fail",
|
|
119
|
+
output,
|
|
120
|
+
usage,
|
|
121
|
+
...ran
|
|
122
|
+
};
|
|
123
|
+
if (!result.ok) result.error = report.summary;
|
|
124
|
+
return result;
|
|
125
|
+
};
|
|
126
|
+
// ../nodes/src/session-turns.ts
|
|
127
|
+
/**
|
|
128
|
+
* @file One session over several processes: the outcome after another turn, and
|
|
129
|
+
* what that turn may spend. Late messages and failed checks both resume a
|
|
130
|
+
* session that has finished, and neither is free.
|
|
131
|
+
*/
|
|
132
|
+
/**
|
|
133
|
+
* The later outcome, with the tokens of both processes. Its cost is already the
|
|
134
|
+
* session's total: Claude Code carries it across a resume (ADR-046).
|
|
135
|
+
*/
|
|
136
|
+
const withUsageOf = (first, later) => {
|
|
137
|
+
const sum = (key) => first.usage[key] + later.usage[key];
|
|
138
|
+
const usage = {
|
|
139
|
+
costUsd: Math.max(first.usage.costUsd, later.usage.costUsd),
|
|
140
|
+
inputTokens: sum("inputTokens"),
|
|
141
|
+
outputTokens: sum("outputTokens"),
|
|
142
|
+
cacheReadTokens: sum("cacheReadTokens"),
|
|
143
|
+
cacheWriteTokens: sum("cacheWriteTokens"),
|
|
144
|
+
turns: sum("turns"),
|
|
145
|
+
durationMs: sum("durationMs")
|
|
146
|
+
};
|
|
147
|
+
const sessionId = later.sessionId === "" ? first.sessionId : later.sessionId;
|
|
148
|
+
return {
|
|
149
|
+
...later,
|
|
150
|
+
sessionId,
|
|
151
|
+
usage
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* The session after a late turn. A turn that gave no report, as when it ran out
|
|
156
|
+
* of budget or was stopped, does not undo the report the node already gave.
|
|
157
|
+
*/
|
|
158
|
+
const afterLateTurn = (first, later) => {
|
|
159
|
+
const merged = withUsageOf(first, later);
|
|
160
|
+
return later.report === null ? {
|
|
161
|
+
...merged,
|
|
162
|
+
report: first.report,
|
|
163
|
+
subtype: first.subtype,
|
|
164
|
+
text: first.text,
|
|
165
|
+
errors: first.errors
|
|
166
|
+
} : merged;
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* What another turn of a session may use. `spent` and `turns` are this
|
|
170
|
+
* activation's, which neither the node's cap nor the run's budget has counted.
|
|
171
|
+
* The quota has: the ledger took it as the session finished, so what it says is
|
|
172
|
+
* left is left. `null` when nothing is, and the turn does not start.
|
|
173
|
+
*/
|
|
174
|
+
const lateLimits = (spec, { spent, turns }, runLeft, quotaLeft) => {
|
|
175
|
+
const budgets = [...[spec.budgetUsd, runLeft].flatMap((limit) => limit === void 0 ? [] : [limit - spent]), ...quotaLeft === void 0 ? [] : [quotaLeft]];
|
|
176
|
+
const budgetUsd = budgets.length === 0 ? void 0 : Math.min(...budgets);
|
|
177
|
+
if (budgetUsd !== void 0 && budgetUsd <= 0) return null;
|
|
178
|
+
const maxTurns = spec.maxTurns === void 0 ? void 0 : spec.maxTurns - turns;
|
|
179
|
+
if (maxTurns !== void 0 && maxTurns <= 0) return null;
|
|
180
|
+
return {
|
|
181
|
+
...budgetUsd === void 0 ? {} : { budgetUsd },
|
|
182
|
+
...maxTurns === void 0 ? {} : { maxTurns }
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
/** What a session may spend as it starts: the least of the budget and any quota. */
|
|
186
|
+
const budgetNow = (context) => {
|
|
187
|
+
if (context.budgetLeftNow === void 0) return context.budgetLeftUsd;
|
|
188
|
+
const known = [context.budgetLeftNow(), context.quota?.().leftUsd].flatMap((left) => left === void 0 ? [] : [left]);
|
|
189
|
+
return known.length === 0 ? void 0 : Math.min(...known);
|
|
190
|
+
};
|
|
191
|
+
/**
|
|
192
|
+
* Whether a limit across runs, rather than the node's work, ended a node: a
|
|
193
|
+
* session whose cap was a quota's and that ran into it, or one its plan refused
|
|
194
|
+
* and that gave no report. A node that still succeeded is left alone.
|
|
195
|
+
*/
|
|
196
|
+
const limitWatch = (context) => {
|
|
197
|
+
let why;
|
|
198
|
+
return {
|
|
199
|
+
stoppedBy: (reason) => {
|
|
200
|
+
why = reason;
|
|
201
|
+
},
|
|
202
|
+
session: (asked) => {
|
|
203
|
+
const left = context.quota?.().leftUsd;
|
|
204
|
+
let refused;
|
|
205
|
+
return {
|
|
206
|
+
saw: (event) => {
|
|
207
|
+
if (event.type === "plan.limit" && event.status === "rejected") refused = planReason(event);
|
|
208
|
+
},
|
|
209
|
+
ended: (outcome) => {
|
|
210
|
+
if (refused !== void 0 && (outcome?.report ?? null) === null) why = refused;
|
|
211
|
+
if (left !== void 0 && asked.budgetUsd !== void 0 && asked.budgetUsd >= left && outcome?.subtype === "error_max_budget_usd") why = context.quota?.().blocked ?? "a session reached what was left of a quota";
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
result: (result) => result.ok || why === void 0 ? result : {
|
|
216
|
+
...result,
|
|
217
|
+
limited: why
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
};
|
|
221
|
+
/** No session started: nothing was left for one. */
|
|
222
|
+
const notStarted = (reason, limited) => ({
|
|
223
|
+
ok: false,
|
|
224
|
+
error: `not started: ${reason}`,
|
|
225
|
+
output: {
|
|
226
|
+
status: "fail",
|
|
227
|
+
summary: `not started: ${reason}`
|
|
228
|
+
},
|
|
229
|
+
...limited === void 0 ? {} : { limited }
|
|
230
|
+
});
|
|
231
|
+
// ../nodes/src/tool-output.ts
|
|
232
|
+
/**
|
|
233
|
+
* @file A tool node's structured result (`output: { format: json }`): the file
|
|
234
|
+
* at `$ORK_OUTPUT` when the program wrote one, otherwise its whole stdout. A
|
|
235
|
+
* result that cannot be read fails the node rather than turning into an empty
|
|
236
|
+
* value, and one too large to keep in an event is never cut. What a program
|
|
237
|
+
* wrote is not yet scrubbed, so these files live in a private folder that a
|
|
238
|
+
* run clears when it starts and ends.
|
|
239
|
+
*/
|
|
240
|
+
/** The run's private folder for results not yet read. */
|
|
241
|
+
const OUTPUT_FOLDER = ".output";
|
|
242
|
+
/**
|
|
243
|
+
* Removes a result, or whatever else the program left in its place. What cannot
|
|
244
|
+
* go here is reported when the run clears its folder.
|
|
245
|
+
*/
|
|
246
|
+
const removeOutputFile = (file) => {
|
|
247
|
+
try {
|
|
248
|
+
rmSync(file, {
|
|
249
|
+
force: true,
|
|
250
|
+
recursive: true
|
|
251
|
+
});
|
|
252
|
+
} catch {}
|
|
253
|
+
};
|
|
254
|
+
/**
|
|
255
|
+
* Removes what a run left before it could read its results. A folder that will
|
|
256
|
+
* not go is worth a warning, not worth failing a run over.
|
|
257
|
+
*/
|
|
258
|
+
const clearOutputFiles = (runDir, warn) => {
|
|
259
|
+
const folder = path.join(runDir, OUTPUT_FOLDER);
|
|
260
|
+
try {
|
|
261
|
+
rmSync(folder, {
|
|
262
|
+
force: true,
|
|
263
|
+
recursive: true
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
warn(`Could not remove ${folder}, which may hold a result that was not scrubbed.`);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
/** A clean place for the program to write to, in the run's files. */
|
|
270
|
+
const prepareOutputFile = (runDir, id, round) => {
|
|
271
|
+
const folder = path.join(runDir, OUTPUT_FOLDER);
|
|
272
|
+
mkdirSync(runDir, { recursive: true });
|
|
273
|
+
mkdirSync(folder, {
|
|
274
|
+
mode: 448,
|
|
275
|
+
recursive: true
|
|
276
|
+
});
|
|
277
|
+
const file = path.join(folder, `${id}.${String(round)}.output.json`);
|
|
278
|
+
removeOutputFile(file);
|
|
279
|
+
return file;
|
|
280
|
+
};
|
|
281
|
+
/** Where a long field of a node's output is kept. */
|
|
282
|
+
const keptFile = (context, field) => path.join(context.runDir, `${context.id}.${String(context.round)}.${field}.txt`);
|
|
283
|
+
/** A way to write a field of a node's output to the run's files, when it's long. */
|
|
284
|
+
const keepFile = (context, field, text) => () => {
|
|
285
|
+
mkdirSync(context.runDir, { recursive: true });
|
|
286
|
+
const file = keptFile(context, field);
|
|
287
|
+
writeFileSync(file, text);
|
|
288
|
+
return file;
|
|
289
|
+
};
|
|
290
|
+
/** Writes a node's `script:` where the run can start it, for this go only. */
|
|
291
|
+
const writeScriptFile = (runDir, id, round, text) => {
|
|
292
|
+
const folder = path.join(runDir, OUTPUT_FOLDER);
|
|
293
|
+
mkdirSync(folder, {
|
|
294
|
+
mode: 448,
|
|
295
|
+
recursive: true
|
|
296
|
+
});
|
|
297
|
+
const file = path.join(folder, `${id}.${String(round)}.script`);
|
|
298
|
+
writeFileSync(file, text, { mode: 384 });
|
|
299
|
+
return file;
|
|
300
|
+
};
|
|
301
|
+
/** Two keys can mask to the same text; neither value is lost. */
|
|
302
|
+
const scrubbedKeys = (value, scrub) => {
|
|
303
|
+
const entries = Object.entries(value).map(([key, item]) => [scrub(key), item]);
|
|
304
|
+
const named = new Set(entries.map(([name]) => name));
|
|
305
|
+
const used = /* @__PURE__ */ new Set();
|
|
306
|
+
return entries.map(([name, item]) => {
|
|
307
|
+
let unique = name;
|
|
308
|
+
for (let count = 2; used.has(unique); count += 1) {
|
|
309
|
+
const next = `${name} ${String(count)}`;
|
|
310
|
+
if (!named.has(next)) unique = next;
|
|
311
|
+
}
|
|
312
|
+
used.add(unique);
|
|
313
|
+
return [unique, item];
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
/** A parsed value with its keys and strings scrubbed; numbers are not touched. */
|
|
317
|
+
const scrubbed = (value, scrub) => {
|
|
318
|
+
if (typeof value === "string") return scrub(value);
|
|
319
|
+
if (Array.isArray(value)) return value.map((item) => scrubbed(item, scrub));
|
|
320
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(scrubbedKeys(value, scrub).map(([key, item]) => [key, scrubbed(item, scrub)]));
|
|
321
|
+
return value;
|
|
322
|
+
};
|
|
323
|
+
/**
|
|
324
|
+
* Parses, comparing each number by its text as written as well as its value:
|
|
325
|
+
* `12345678901234567890` reads back as a different number, which would no
|
|
326
|
+
* longer match the secret.
|
|
327
|
+
*/
|
|
328
|
+
const parseScrubbingNumbers = (text, scrub) => {
|
|
329
|
+
return JSON.parse(text, (_key, value, seen) => {
|
|
330
|
+
const source = seen?.source;
|
|
331
|
+
if (typeof value !== "number" || source === void 0) return value;
|
|
332
|
+
const masked = scrub(source);
|
|
333
|
+
if (masked !== source) return masked;
|
|
334
|
+
const read = String(value);
|
|
335
|
+
const maskedRead = scrub(read);
|
|
336
|
+
return maskedRead === read ? value : maskedRead;
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
/**
|
|
340
|
+
* Reads JSON with everything in it scrubbed, numbers too. Throws as a parser
|
|
341
|
+
* does.
|
|
342
|
+
*/
|
|
343
|
+
const parseScrubbed = (text, scrub) => scrubbed(parseScrubbingNumbers(text, scrub), scrub);
|
|
344
|
+
const parsed = (text, source, scrub) => {
|
|
345
|
+
if (text.trim() === "") return {
|
|
346
|
+
ok: false,
|
|
347
|
+
error: `${source} is empty`
|
|
348
|
+
};
|
|
349
|
+
try {
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
json: parseScrubbed(text, scrub)
|
|
353
|
+
};
|
|
354
|
+
} catch (error) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: error instanceof RangeError ? `${source} is nested too deeply to read` : `${source} is not valid JSON`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
/** What the program wrote to `$ORK_OUTPUT`; a link or a folder is refused. */
|
|
362
|
+
const readWritten = (file, scrub) => {
|
|
363
|
+
try {
|
|
364
|
+
return lstatSync(file).isFile() ? parsed(readFileSync(file, "utf8"), "$ORK_OUTPUT", scrub) : {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: "$ORK_OUTPUT is not a file"
|
|
367
|
+
};
|
|
368
|
+
} catch {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: "$ORK_OUTPUT could not be read"
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
/** Whether something is there; a folder the program broke counts, and fails. */
|
|
376
|
+
const wasWritten = (file) => {
|
|
377
|
+
try {
|
|
378
|
+
return lstatSync(file, { throwIfNoEntry: false }) !== void 0;
|
|
379
|
+
} catch {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
/**
|
|
384
|
+
* Reads the program's result and removes its file: what is kept is the scrubbed
|
|
385
|
+
* value. `limit` is how long it may be once serialized; `keep` saves one that
|
|
386
|
+
* is longer and says where.
|
|
387
|
+
*/
|
|
388
|
+
const readJsonOutput = (input) => {
|
|
389
|
+
const { file, stdout, scrub, limit, keep } = input;
|
|
390
|
+
const result = wasWritten(file) ? readWritten(file, scrub) : parsed(stdout, "stdout", scrub);
|
|
391
|
+
removeOutputFile(file);
|
|
392
|
+
if (!result.ok) return result;
|
|
393
|
+
const text = JSON.stringify(result.json) ?? "";
|
|
394
|
+
return text.length <= limit ? result : {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: `the JSON result is ${String(text.length)} characters, over the limit of ${String(limit)}; it is in ${keep(text)}`
|
|
397
|
+
};
|
|
398
|
+
};
|
|
399
|
+
/**
|
|
400
|
+
* The command line a tool node declares: its `command`, or what runs its
|
|
401
|
+
* script.
|
|
402
|
+
*/
|
|
403
|
+
const commandLineOf = (node) => node.script === void 0 ? node.command : node.interpreter ?? "sh -eu";
|
|
404
|
+
/**
|
|
405
|
+
* Splits a command into arguments the way a shell would, without running one:
|
|
406
|
+
* whitespace separates, `'…'` is literal, `"…"` and `\` escape, and `{{ … }}`
|
|
407
|
+
* stays whole so a template is rendered inside its own argument.
|
|
408
|
+
*/
|
|
409
|
+
const tokenize = (command) => {
|
|
410
|
+
const args = [];
|
|
411
|
+
let current = null;
|
|
412
|
+
let index = 0;
|
|
413
|
+
const add = (text) => {
|
|
414
|
+
current = (current ?? "") + text;
|
|
415
|
+
};
|
|
416
|
+
while (index < command.length) {
|
|
417
|
+
const char = command.charAt(index);
|
|
418
|
+
if (command.startsWith("{{", index)) {
|
|
419
|
+
const end = command.indexOf("}}", index + 2);
|
|
420
|
+
if (end === -1) return {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: "unclosed {{ in command"
|
|
423
|
+
};
|
|
424
|
+
add(command.slice(index, end + 2));
|
|
425
|
+
index = end + 2;
|
|
426
|
+
} else if (/\s/u.test(char)) {
|
|
427
|
+
if (current !== null) args.push(current);
|
|
428
|
+
current = null;
|
|
429
|
+
index += 1;
|
|
430
|
+
} else if (char === "'") {
|
|
431
|
+
const end = command.indexOf("'", index + 1);
|
|
432
|
+
if (end === -1) return {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: "unclosed ' in command"
|
|
435
|
+
};
|
|
436
|
+
add(command.slice(index + 1, end));
|
|
437
|
+
index = end + 1;
|
|
438
|
+
} else if (char === "\"") {
|
|
439
|
+
let text = "";
|
|
440
|
+
index += 1;
|
|
441
|
+
while (index < command.length && command.charAt(index) !== "\"") {
|
|
442
|
+
if (command.charAt(index) === "\\") index += 1;
|
|
443
|
+
text += command.charAt(index);
|
|
444
|
+
index += 1;
|
|
445
|
+
}
|
|
446
|
+
if (index >= command.length) return {
|
|
447
|
+
ok: false,
|
|
448
|
+
error: "unclosed \" in command"
|
|
449
|
+
};
|
|
450
|
+
add(text);
|
|
451
|
+
index += 1;
|
|
452
|
+
} else if (char === "\\") {
|
|
453
|
+
add(command.charAt(index + 1));
|
|
454
|
+
index += 2;
|
|
455
|
+
} else {
|
|
456
|
+
add(char);
|
|
457
|
+
index += 1;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (current !== null) args.push(current);
|
|
461
|
+
return {
|
|
462
|
+
ok: true,
|
|
463
|
+
args
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
// ../nodes/src/environ.ts
|
|
467
|
+
/** A variable of the user's. */
|
|
468
|
+
const readEnv = (environ, name) => Object.hasOwn(environ, name) ? environ[name] : void 0;
|
|
469
|
+
// ../nodes/src/mcp-servers.ts
|
|
470
|
+
/**
|
|
471
|
+
* @file Reading an MCP server's definition from the project's `.mcp.json`, the
|
|
472
|
+
* file Claude Code reads too, so a server is defined in one place (RFC-006).
|
|
473
|
+
* `${VAR}` and `${VAR:-default}` read the user's environment, which is where
|
|
474
|
+
* secrets stay: Ork stores none, and a run masks each value read this way. A
|
|
475
|
+
* server that needs an OAuth sign-in holds Claude Code's tokens, not Ork's,
|
|
476
|
+
* so it is refused.
|
|
477
|
+
*/
|
|
478
|
+
const MCP_FILE = ".mcp.json";
|
|
479
|
+
const VARIABLE = /\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}/gu;
|
|
480
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
481
|
+
/** The file's `mcpServers`; `{}` when there is no file. */
|
|
482
|
+
const serversIn = (root) => {
|
|
483
|
+
const file = path.join(root, MCP_FILE);
|
|
484
|
+
if (!existsSync(file)) return {
|
|
485
|
+
ok: true,
|
|
486
|
+
value: {}
|
|
487
|
+
};
|
|
488
|
+
try {
|
|
489
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
490
|
+
const servers = isRecord(parsed) ? parsed["mcpServers"] : void 0;
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
value: isRecord(servers) ? servers : {}
|
|
494
|
+
};
|
|
495
|
+
} catch (error) {
|
|
496
|
+
const reason = error instanceof SyntaxError ? "it is not valid JSON" : error instanceof Error ? error.message : String(error);
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
error: `${MCP_FILE} can't be read: ${reason}`
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
const stringsOf = (value) => isRecord(value) ? Object.fromEntries(Object.entries(value).flatMap(([key, item]) => typeof item === "string" ? [[key, item]] : [])) : {};
|
|
504
|
+
const listOf = (value) => Array.isArray(value) ? value.flatMap((item) => typeof item === "string" ? [item] : []) : [];
|
|
505
|
+
/** A server's field that holds something other than text: it would be dropped. */
|
|
506
|
+
const notTextIn = (server) => [
|
|
507
|
+
"args",
|
|
508
|
+
"env",
|
|
509
|
+
"headers"
|
|
510
|
+
].find((field) => {
|
|
511
|
+
const value = server[field];
|
|
512
|
+
return (Array.isArray(value) ? value : isRecord(value) ? Object.values(value) : []).some((item) => typeof item !== "string");
|
|
513
|
+
});
|
|
514
|
+
/** The server as written, or why Ork can't call it. */
|
|
515
|
+
const readMcpServer = (root, name) => {
|
|
516
|
+
const servers = serversIn(root);
|
|
517
|
+
if (!servers.ok) return servers;
|
|
518
|
+
const known = Object.keys(servers.value);
|
|
519
|
+
const server = Object.hasOwn(servers.value, name) ? servers.value[name] : void 0;
|
|
520
|
+
if (!isRecord(server)) return {
|
|
521
|
+
ok: false,
|
|
522
|
+
error: `no MCP server \`${name}\` in ${MCP_FILE}${known.length === 0 ? "" : `, which has ${known.join(", ")}`}`
|
|
523
|
+
};
|
|
524
|
+
if ("oauth" in server) return {
|
|
525
|
+
ok: false,
|
|
526
|
+
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`
|
|
527
|
+
};
|
|
528
|
+
const notText = notTextIn(server);
|
|
529
|
+
if (notText !== void 0) return {
|
|
530
|
+
ok: false,
|
|
531
|
+
error: `MCP server \`${name}\` has a value in ${notText} that isn't a string: write it in quotes`
|
|
532
|
+
};
|
|
533
|
+
const { type, url, command } = server;
|
|
534
|
+
if (type === "http" && typeof url === "string") return {
|
|
535
|
+
ok: true,
|
|
536
|
+
value: {
|
|
537
|
+
kind: "http",
|
|
538
|
+
url,
|
|
539
|
+
headers: stringsOf(server["headers"])
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
if ((type === void 0 || type === "stdio") && typeof command === "string") return {
|
|
543
|
+
ok: true,
|
|
544
|
+
value: {
|
|
545
|
+
kind: "stdio",
|
|
546
|
+
command,
|
|
547
|
+
args: listOf(server["args"]),
|
|
548
|
+
env: stringsOf(server["env"])
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
return {
|
|
552
|
+
ok: false,
|
|
553
|
+
error: `MCP server \`${name}\` is not one Ork can call: it takes stdio and http servers`
|
|
554
|
+
};
|
|
555
|
+
};
|
|
556
|
+
/** Each variable a server reads, and whether it has a default. */
|
|
557
|
+
const readsOf = (server) => {
|
|
558
|
+
return (server.kind === "stdio" ? [
|
|
559
|
+
server.command,
|
|
560
|
+
...server.args,
|
|
561
|
+
...Object.values(server.env)
|
|
562
|
+
] : [server.url, ...Object.values(server.headers)]).flatMap((text) => [...text.matchAll(VARIABLE)].map((match) => ({
|
|
563
|
+
name: String(match[1]),
|
|
564
|
+
fallback: match[2] !== void 0
|
|
565
|
+
})));
|
|
566
|
+
};
|
|
567
|
+
/**
|
|
568
|
+
* The server with its variables filled in, and the values to mask: all it read
|
|
569
|
+
* but those `plain` names, which aren't secret.
|
|
570
|
+
*/
|
|
571
|
+
const expandServer = (server, environ, plain = []) => {
|
|
572
|
+
const unset = readsOf(server).find(({ name, fallback }) => !fallback && readEnv(environ, name) === void 0);
|
|
573
|
+
if (unset !== void 0) return {
|
|
574
|
+
ok: false,
|
|
575
|
+
error: `${MCP_FILE} reads ${unset.name}, which is not set`
|
|
576
|
+
};
|
|
577
|
+
const values = [];
|
|
578
|
+
const fill = (text) => text.replaceAll(VARIABLE, (_all, name, fallback) => {
|
|
579
|
+
const value = readEnv(environ, name);
|
|
580
|
+
if (value !== void 0 && !plain.includes(name)) values.push(value);
|
|
581
|
+
return value ?? fallback ?? "";
|
|
582
|
+
});
|
|
583
|
+
const fillAll = (record) => Object.fromEntries(Object.entries(record).map(([key, text]) => [key, fill(text)]));
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
value: {
|
|
587
|
+
server: server.kind === "stdio" ? {
|
|
588
|
+
kind: "stdio",
|
|
589
|
+
command: fill(server.command),
|
|
590
|
+
args: server.args.map((arg) => fill(arg)),
|
|
591
|
+
env: fillAll(server.env)
|
|
592
|
+
} : {
|
|
593
|
+
kind: "http",
|
|
594
|
+
url: fill(server.url),
|
|
595
|
+
headers: fillAll(server.headers)
|
|
596
|
+
},
|
|
597
|
+
values
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
};
|
|
601
|
+
/** What a workflow's MCP servers read of the user's, unless called not secret. */
|
|
602
|
+
const mcpVariablesOf = (workflow, root) => Object.values(workflow.nodes).flatMap((node) => {
|
|
603
|
+
if (node.type !== "mcp.call") return [];
|
|
604
|
+
const read = readMcpServer(root, node.server);
|
|
605
|
+
return read.ok ? readsOf(read.value).map(({ name }) => name).filter((name) => node.plainEnv?.includes(name) !== true) : [];
|
|
606
|
+
});
|
|
607
|
+
/** A stdio server's program, when its name can be known before it is filled in. */
|
|
608
|
+
const programOf = (server) => server.kind === "stdio" && !server.command.includes("$") ? server.command : void 0;
|
|
609
|
+
/**
|
|
610
|
+
* What stops a workflow's `mcp.call` nodes, found before a run spends anything:
|
|
611
|
+
* a server that isn't there or can't be called, a program that isn't installed,
|
|
612
|
+
* or a variable that is not set.
|
|
613
|
+
*/
|
|
614
|
+
const mcpProblems = (workflow, root, environ = process.env, finished = /* @__PURE__ */ new Set(), installed = () => true) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
615
|
+
if (node.type !== "mcp.call" || finished.has(id)) return [];
|
|
616
|
+
const read = readMcpServer(root, node.server);
|
|
617
|
+
if (!read.ok) return [`${id}: ${read.error}`];
|
|
618
|
+
const given = new Set(Object.keys(node.env ?? {}));
|
|
619
|
+
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`]);
|
|
620
|
+
const program = programOf(read.value);
|
|
621
|
+
return program === void 0 || installed(program) ? unset : [`\`${id}\`: MCP server \`${node.server}\` runs \`${program}\`, which is not installed`].concat(unset);
|
|
622
|
+
});
|
|
623
|
+
// ../nodes/src/tool-scrub.ts
|
|
624
|
+
/**
|
|
625
|
+
* @file Masking a secret in what a program prints. A plain search, so a value
|
|
626
|
+
* of any size or shape takes time in step with the text: it looks in the text
|
|
627
|
+
* as it is, and with JSON's escapes decoded once and twice, so that it does
|
|
628
|
+
* not matter which characters an encoder chose to escape. It masks the value,
|
|
629
|
+
* and the pieces programs print on their own: each line, and each value of a
|
|
630
|
+
* JSON document. Other encodings, such as base64, are out of its reach
|
|
631
|
+
* (L81).
|
|
632
|
+
*/
|
|
633
|
+
/** Shorter values than this would mask ordinary text: `1`, `true`, a word. */
|
|
634
|
+
const MIN_SECRET_LENGTH = 8;
|
|
635
|
+
const SHORT_ESCAPES = {
|
|
636
|
+
"\"": "\"",
|
|
637
|
+
"\\": "\\",
|
|
638
|
+
"/": "/",
|
|
639
|
+
b: "\b",
|
|
640
|
+
f: "\f",
|
|
641
|
+
n: "\n",
|
|
642
|
+
r: "\r",
|
|
643
|
+
t: " "
|
|
644
|
+
};
|
|
645
|
+
/** The unit an escape at `at` stands for, and how long it is written. */
|
|
646
|
+
const escapeAt = (text, at) => {
|
|
647
|
+
const next = text.charAt(at + 1);
|
|
648
|
+
const short = SHORT_ESCAPES[next];
|
|
649
|
+
if (short !== void 0) return {
|
|
650
|
+
unit: short,
|
|
651
|
+
width: 2
|
|
652
|
+
};
|
|
653
|
+
if (next !== "u") return null;
|
|
654
|
+
const hex = text.slice(at + 2, at + 6);
|
|
655
|
+
return /^[\da-f]{4}$/iu.test(hex) ? {
|
|
656
|
+
unit: String.fromCodePoint(Number(`0x${hex}`)),
|
|
657
|
+
width: 6
|
|
658
|
+
} : null;
|
|
659
|
+
};
|
|
660
|
+
/** The last mark at or before `index`, by a binary search. */
|
|
661
|
+
const lastMark = (marks, index) => {
|
|
662
|
+
let low = 0;
|
|
663
|
+
let high = marks.length - 1;
|
|
664
|
+
while (low < high) {
|
|
665
|
+
const middle = Math.ceil((low + high) / 2);
|
|
666
|
+
if ((marks[middle] ?? 0) <= index) low = middle;
|
|
667
|
+
else high = middle - 1;
|
|
668
|
+
}
|
|
669
|
+
return low;
|
|
670
|
+
};
|
|
671
|
+
/** The view with its escapes decoded; `null` when it has none. */
|
|
672
|
+
const decoded = (view) => {
|
|
673
|
+
const { text } = view;
|
|
674
|
+
const parts = [];
|
|
675
|
+
const decodedAt = [0];
|
|
676
|
+
const sourceAt = [0];
|
|
677
|
+
let from = 0;
|
|
678
|
+
let length = 0;
|
|
679
|
+
let at = text.indexOf("\\");
|
|
680
|
+
while (at !== -1) {
|
|
681
|
+
const escape = escapeAt(text, at);
|
|
682
|
+
if (escape === null) at = text.indexOf("\\", at + 1);
|
|
683
|
+
else {
|
|
684
|
+
parts.push(text.slice(from, at), escape.unit);
|
|
685
|
+
length += at - from;
|
|
686
|
+
decodedAt.push(length);
|
|
687
|
+
sourceAt.push(at);
|
|
688
|
+
length += 1;
|
|
689
|
+
from = at + escape.width;
|
|
690
|
+
decodedAt.push(length);
|
|
691
|
+
sourceAt.push(from);
|
|
692
|
+
at = text.indexOf("\\", from);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (from === 0) return null;
|
|
696
|
+
parts.push(text.slice(from));
|
|
697
|
+
return {
|
|
698
|
+
text: parts.join(""),
|
|
699
|
+
toSource: (index) => {
|
|
700
|
+
const mark = lastMark(decodedAt, index);
|
|
701
|
+
return view.toSource((sourceAt[mark] ?? 0) + (index - (decodedAt[mark] ?? 0)));
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
};
|
|
705
|
+
/** The text as it is, then decoded as far as JSON inside JSON goes. */
|
|
706
|
+
const viewsOf = (text) => {
|
|
707
|
+
const raw = {
|
|
708
|
+
text,
|
|
709
|
+
toSource: (index) => index
|
|
710
|
+
};
|
|
711
|
+
const once = decoded(raw);
|
|
712
|
+
const twice = once === null ? null : decoded(once);
|
|
713
|
+
return [
|
|
714
|
+
raw,
|
|
715
|
+
...once === null ? [] : [once],
|
|
716
|
+
...twice === null ? [] : [twice]
|
|
717
|
+
];
|
|
718
|
+
};
|
|
719
|
+
/** Where each secret is in the text, overlapping ones joined. */
|
|
720
|
+
const rangesOf = (text, secrets) => {
|
|
721
|
+
const found = [];
|
|
722
|
+
for (const view of viewsOf(text)) for (const secret of secrets) {
|
|
723
|
+
let at = view.text.indexOf(secret);
|
|
724
|
+
while (at !== -1) {
|
|
725
|
+
found.push([view.toSource(at), view.toSource(at + secret.length)]);
|
|
726
|
+
at = view.text.indexOf(secret, at + secret.length);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const joined = [];
|
|
730
|
+
for (const [start, end] of found.toSorted((a, b) => a[0] - b[0])) {
|
|
731
|
+
const last = joined.at(-1);
|
|
732
|
+
if (last !== void 0 && start < last[1]) last[1] = Math.max(last[1], end);
|
|
733
|
+
else joined.push([start, end]);
|
|
734
|
+
}
|
|
735
|
+
return joined;
|
|
736
|
+
};
|
|
737
|
+
/** The strings and numbers inside a parsed JSON document. */
|
|
738
|
+
const leavesOf = (value) => {
|
|
739
|
+
if (typeof value === "string") return [value];
|
|
740
|
+
if (typeof value === "number") return [String(value)];
|
|
741
|
+
if (typeof value === "object" && value !== null) return Object.values(value).flatMap((item) => leavesOf(item));
|
|
742
|
+
return [];
|
|
743
|
+
};
|
|
744
|
+
/** A value's leaves, when it is a JSON object or array. */
|
|
745
|
+
const jsonLeavesOf = (value) => {
|
|
746
|
+
if (!/^\s*[[{]/u.test(value)) return [];
|
|
747
|
+
try {
|
|
748
|
+
return leavesOf(JSON.parse(value));
|
|
749
|
+
} catch {
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
};
|
|
753
|
+
/** A value, and the pieces of it a program may well print on their own. */
|
|
754
|
+
const formsOf = (value) => [
|
|
755
|
+
value,
|
|
756
|
+
value.trim(),
|
|
757
|
+
...value.split(/\r?\n|\r/u).map((line) => line.trim()),
|
|
758
|
+
...jsonLeavesOf(value)
|
|
759
|
+
];
|
|
760
|
+
/** Replaces the values, in any form JSON writes them, with `[masked]`. */
|
|
761
|
+
const scrubberOf = (values) => {
|
|
762
|
+
const secrets = [...new Set(values.flatMap((value) => formsOf(value)))].filter((secret) => secret.trim().length >= MIN_SECRET_LENGTH);
|
|
763
|
+
return (text) => {
|
|
764
|
+
if (secrets.length === 0) return text;
|
|
765
|
+
const parts = [];
|
|
766
|
+
let from = 0;
|
|
767
|
+
for (const [start, end] of rangesOf(text, secrets)) {
|
|
768
|
+
parts.push(text.slice(from, start), "[masked]");
|
|
769
|
+
from = end;
|
|
770
|
+
}
|
|
771
|
+
parts.push(text.slice(from));
|
|
772
|
+
return parts.join("");
|
|
773
|
+
};
|
|
774
|
+
};
|
|
775
|
+
// ../nodes/src/tool-env.ts
|
|
776
|
+
/**
|
|
777
|
+
* @file What a tool node's program sees besides what it inherits: who it is
|
|
778
|
+
* (`ORK_*`), and the node's `env:`, rendered as templates. Only `env:` may
|
|
779
|
+
* read the user's own variables, so their values never reach a recorded
|
|
780
|
+
* command, and those values are scrubbed from what the program prints.
|
|
781
|
+
*/
|
|
782
|
+
/** The file a node with `output: { format: json }` may write its result to. */
|
|
783
|
+
const OUTPUT_ENV = "ORK_OUTPUT";
|
|
784
|
+
/** A file a `watch:` node's program keeps between looks (RFC-011). */
|
|
785
|
+
const STATE_ENV = "ORK_STATE";
|
|
786
|
+
/** Set for a program in a run. */
|
|
787
|
+
const RUN_ENV = "ORK_RUN";
|
|
788
|
+
/** Set for a Claude session. It says no more than that: no run, no node. */
|
|
789
|
+
const SESSION_ENV = "ORK_SESSION";
|
|
790
|
+
/** The variables a caller's run reads, for a run it calls to mask too. */
|
|
791
|
+
const MASKED_ENV = "ORK_MASKED";
|
|
792
|
+
/** Every variable Ork sets itself; a workflow's `env:` cannot set them. */
|
|
793
|
+
const IDENTITY_KEYS = /* @__PURE__ */ new Set([
|
|
794
|
+
RUN_ENV,
|
|
795
|
+
SESSION_ENV,
|
|
796
|
+
MASKED_ENV,
|
|
797
|
+
"ORK_NODE",
|
|
798
|
+
"ORK_ROUND",
|
|
799
|
+
"ORK_RUN_DIR",
|
|
800
|
+
"ORK_PROJECT",
|
|
801
|
+
"ORK_WORKFLOW",
|
|
802
|
+
"ORK_BIN",
|
|
803
|
+
OUTPUT_ENV,
|
|
804
|
+
STATE_ENV
|
|
805
|
+
]);
|
|
806
|
+
/**
|
|
807
|
+
* `env` without Ork's identity variables. A run started by a program in another
|
|
808
|
+
* run must not pass that run's identity on to its own sessions and programs.
|
|
809
|
+
*/
|
|
810
|
+
const withoutIdentity = (env) => Object.fromEntries(Object.entries(env).flatMap(([key, value]) => value === void 0 || IDENTITY_KEYS.has(key) ? [] : [[key, value]]));
|
|
811
|
+
/**
|
|
812
|
+
* What a Claude session runs with: no identity, only the marker that stops its
|
|
813
|
+
* Bash from speaking as the user through `ork chat`.
|
|
814
|
+
*/
|
|
815
|
+
const sessionEnv = (env) => ({
|
|
816
|
+
...withoutIdentity(env),
|
|
817
|
+
[SESSION_ENV]: "1"
|
|
818
|
+
});
|
|
819
|
+
/**
|
|
820
|
+
* The variable that says a program in a run or a session started this process,
|
|
821
|
+
* or `null` when none does.
|
|
822
|
+
*/
|
|
823
|
+
const runMarkerIn = (env) => ["ORK_RUN", SESSION_ENV].find((name) => env[name] !== void 0) ?? null;
|
|
824
|
+
/** Who a node's program is. It is attribution, not a security boundary. */
|
|
825
|
+
const identityOf = (context, orkBin) => ({
|
|
826
|
+
[RUN_ENV]: context.runId,
|
|
827
|
+
ORK_NODE: context.id,
|
|
828
|
+
ORK_ROUND: String(context.round),
|
|
829
|
+
ORK_RUN_DIR: context.runDir,
|
|
830
|
+
ORK_PROJECT: context.projectRoot,
|
|
831
|
+
ORK_WORKFLOW: context.workflow.name,
|
|
832
|
+
...orkBin === void 0 ? {} : { ORK_BIN: orkBin }
|
|
833
|
+
});
|
|
834
|
+
/** The variables a workflow's nodes read that no `plainEnv` excuses. */
|
|
835
|
+
const secretNamesOf = (workflow) => Object.values(workflow.nodes).flatMap((node) => programsRunBy(node, workflow.defaults)).flatMap(({ program }) => {
|
|
836
|
+
const plain = new Set(program.plainEnv);
|
|
837
|
+
return Object.values(program.env ?? {}).flatMap((value) => envReadsOf(value)).filter((name) => !plain.has(name));
|
|
838
|
+
});
|
|
839
|
+
/** What the run that called this one told it to mask. */
|
|
840
|
+
const inheritedNames = (environ) => (environ[MASKED_ENV] ?? "").split(",").filter((name) => name !== "");
|
|
841
|
+
/**
|
|
842
|
+
* The variables a run must mask: what its workflow and its MCP servers read,
|
|
843
|
+
* what the workflows it calls by name read, and what its caller reads. Any of
|
|
844
|
+
* them may print any of these, since a called run inherits the same
|
|
845
|
+
* environment.
|
|
846
|
+
*/
|
|
847
|
+
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)))];
|
|
848
|
+
/**
|
|
849
|
+
* A program learns the names too, never their values: `ork send` masks with
|
|
850
|
+
* them before it writes to the store, and a run the program starts passes them
|
|
851
|
+
* on.
|
|
852
|
+
*/
|
|
853
|
+
const namesToMask = (workflow, environ, also = []) => [.../* @__PURE__ */ new Set([
|
|
854
|
+
...secretNamesOf(workflow),
|
|
855
|
+
...also,
|
|
856
|
+
...inheritedNames(environ)
|
|
857
|
+
])];
|
|
858
|
+
const valuesOf = (names, environ) => names.flatMap((name) => {
|
|
859
|
+
const value = readEnv(environ, name);
|
|
860
|
+
return value === void 0 ? [] : [value];
|
|
861
|
+
});
|
|
862
|
+
/** What a process in a program masks: what its run told it to. */
|
|
863
|
+
const inheritedScrubber = (environ) => scrubberOf(valuesOf(inheritedNames(environ), environ));
|
|
864
|
+
/** What a run masks in a message of its own: the same names' values. */
|
|
865
|
+
const runScrubber = (names, environ) => scrubberOf(valuesOf(names, environ));
|
|
866
|
+
/** What a run tells a run it calls: names, never values. */
|
|
867
|
+
const maskedEnv = (names) => names.length === 0 ? {} : { [MASKED_ENV]: names.join(",") };
|
|
868
|
+
/** The values to mask: what the workflow reads, and the names given. */
|
|
869
|
+
const secretsOf = (workflow, environ, also = []) => valuesOf(namesToMask(workflow, environ, also), environ);
|
|
870
|
+
/** A node's `env:`, rendered. */
|
|
871
|
+
const renderEnv = (node, context, environ) => {
|
|
872
|
+
const values = {};
|
|
873
|
+
const scope = {
|
|
874
|
+
...runScope(context.inputs, context.outputs, context.runId),
|
|
875
|
+
output: { env: environ }
|
|
876
|
+
};
|
|
877
|
+
for (const [key, text] of Object.entries(node.env ?? {})) {
|
|
878
|
+
const template = parseTemplate(text);
|
|
879
|
+
if (!template.ok) return {
|
|
880
|
+
ok: false,
|
|
881
|
+
error: template.message
|
|
882
|
+
};
|
|
883
|
+
for (const name of envReadsOf(text)) if (readEnv(environ, name) === void 0) return {
|
|
884
|
+
ok: false,
|
|
885
|
+
error: `env.${key} reads ${name}, which is not set`
|
|
886
|
+
};
|
|
887
|
+
const value = renderTemplate(template.parts, scope);
|
|
888
|
+
if (value.includes("\0")) return {
|
|
889
|
+
ok: false,
|
|
890
|
+
error: `env.${key} holds a null byte`
|
|
891
|
+
};
|
|
892
|
+
values[key] = value;
|
|
893
|
+
}
|
|
894
|
+
return {
|
|
895
|
+
ok: true,
|
|
896
|
+
values
|
|
897
|
+
};
|
|
898
|
+
};
|
|
899
|
+
/** A runner whose refusal to start is a failed run, with nothing secret in it. */
|
|
900
|
+
const safeRunner = (runner, scrub) => async (program, args, options) => {
|
|
901
|
+
try {
|
|
902
|
+
return await runner(program, args, options);
|
|
903
|
+
} catch (error) {
|
|
904
|
+
return {
|
|
905
|
+
exitCode: -1,
|
|
906
|
+
stdout: "",
|
|
907
|
+
stderr: scrub(error instanceof Error ? error.message : String(error))
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
// ../nodes/src/tool-state.ts
|
|
912
|
+
/**
|
|
913
|
+
* @file `$ORK_STATE` (RFC-011): a file a `watch:` node's program keeps between
|
|
914
|
+
* looks, rounds and a resume, so a script can tell what's new without
|
|
915
|
+
* inventing a place to keep it. It's the program's to create, and holds what
|
|
916
|
+
* the program wrote, so it's private, and goes when the run has succeeded.
|
|
917
|
+
*/
|
|
918
|
+
const STATE_FOLDER = ".state";
|
|
919
|
+
/** The file `$ORK_STATE` names, for one node in one run. */
|
|
920
|
+
const prepareStateFile = (runDir, id) => {
|
|
921
|
+
const folder = path.join(runDir, STATE_FOLDER);
|
|
922
|
+
mkdirSync(folder, {
|
|
923
|
+
mode: 448,
|
|
924
|
+
recursive: true
|
|
925
|
+
});
|
|
926
|
+
return path.join(folder, id);
|
|
927
|
+
};
|
|
928
|
+
/** Removes what programs kept, once the run can't go on from it. */
|
|
929
|
+
const clearStateFiles = (runDir, warn) => {
|
|
930
|
+
const folder = path.join(runDir, STATE_FOLDER);
|
|
931
|
+
try {
|
|
932
|
+
rmSync(folder, {
|
|
933
|
+
force: true,
|
|
934
|
+
recursive: true
|
|
935
|
+
});
|
|
936
|
+
} catch {
|
|
937
|
+
warn(`Could not remove ${folder}, which holds what a program kept.`);
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
// ../nodes/src/tools.ts
|
|
941
|
+
/**
|
|
942
|
+
* @file The tool node executor, for every registered integration (`tool.gh`,
|
|
943
|
+
* `tool.aws`, `tool.git`, `tool.shell`, …): the user's own program, run
|
|
944
|
+
* without a shell, checked against `allow` (or the integration's default),
|
|
945
|
+
* its arguments and observed output fields from the integration's adapter,
|
|
946
|
+
* long output kept in run files. It sees who it is and the node's `env:`
|
|
947
|
+
* (`tool-env.ts`), and may return JSON (`tool-output.ts`).
|
|
948
|
+
*/
|
|
949
|
+
/** How much of stdout and stderr a node's output keeps. */
|
|
950
|
+
const OUTPUT_LIMIT = 65536;
|
|
951
|
+
const ERROR_LIMIT = 1024;
|
|
952
|
+
/** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
|
|
953
|
+
const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
|
|
954
|
+
return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
|
|
955
|
+
});
|
|
956
|
+
const render = (text, context) => {
|
|
957
|
+
const template = parseTemplate(text);
|
|
958
|
+
if (!template.ok) return {
|
|
959
|
+
ok: false,
|
|
960
|
+
error: template.message
|
|
961
|
+
};
|
|
962
|
+
return {
|
|
963
|
+
ok: true,
|
|
964
|
+
value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId))
|
|
965
|
+
};
|
|
966
|
+
};
|
|
967
|
+
/** The program and arguments a tool node runs, before the allow check. */
|
|
968
|
+
const commandOf = (node, context) => {
|
|
969
|
+
const line = commandLineOf(node);
|
|
970
|
+
if (line === void 0) return {
|
|
971
|
+
ok: false,
|
|
972
|
+
error: "the node has no command or script"
|
|
973
|
+
};
|
|
974
|
+
const tokens = tokenize(line);
|
|
975
|
+
if (!tokens.ok) return tokens;
|
|
976
|
+
const args = [];
|
|
977
|
+
for (const token of tokens.args) {
|
|
978
|
+
if (node.script !== void 0) {
|
|
979
|
+
args.push(token);
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
const rendered = render(token, context);
|
|
983
|
+
if (!rendered.ok) return rendered;
|
|
984
|
+
args.push(rendered.value);
|
|
985
|
+
}
|
|
986
|
+
const integration = integrationFor(node.type);
|
|
987
|
+
if (integration === void 0) return {
|
|
988
|
+
ok: false,
|
|
989
|
+
error: `no integration for ${node.type}`
|
|
990
|
+
};
|
|
991
|
+
const extra = integration.args(node);
|
|
992
|
+
if (integration.program !== null) return {
|
|
993
|
+
ok: true,
|
|
994
|
+
program: integration.program,
|
|
995
|
+
args: [...args, ...extra]
|
|
996
|
+
};
|
|
997
|
+
const [program, ...rest] = [...args, ...extra];
|
|
998
|
+
if (program === void 0 || program === "") return {
|
|
999
|
+
ok: false,
|
|
1000
|
+
error: "the command names no program"
|
|
1001
|
+
};
|
|
1002
|
+
return {
|
|
1003
|
+
ok: true,
|
|
1004
|
+
program,
|
|
1005
|
+
args: rest
|
|
1006
|
+
};
|
|
1007
|
+
};
|
|
1008
|
+
/** Keeps the first `OUTPUT_LIMIT` characters; the rest goes to a file. */
|
|
1009
|
+
const capped = (text, file) => {
|
|
1010
|
+
if (text.length <= 65536) return text;
|
|
1011
|
+
return `${text.slice(0, OUTPUT_LIMIT)}\n… (truncated; the full output is in ${file()})`;
|
|
1012
|
+
};
|
|
1013
|
+
/**
|
|
1014
|
+
* Text for a node's `error`, which reaches its events, its `notify:` rules and
|
|
1015
|
+
* what a session is told. All of it is kept in the output's fields and files.
|
|
1016
|
+
*/
|
|
1017
|
+
const clipError = (text) => text.length <= ERROR_LIMIT ? text : `${text.slice(0, ERROR_LIMIT)}…`;
|
|
1018
|
+
const failed$1 = (error) => ({
|
|
1019
|
+
ok: false,
|
|
1020
|
+
output: {
|
|
1021
|
+
exitCode: -1,
|
|
1022
|
+
stdout: "",
|
|
1023
|
+
stderr: error
|
|
1024
|
+
},
|
|
1025
|
+
error
|
|
1026
|
+
});
|
|
1027
|
+
/** Runs a user's CLI through its integration's adapter, with its own login. */
|
|
1028
|
+
const toolExecutor = (given, options = {}) => async (context) => {
|
|
1029
|
+
const { node } = context;
|
|
1030
|
+
if (!isToolNode(node)) return failed$1(`${context.id} is not a tool node`);
|
|
1031
|
+
const environ = options.environ ?? process.env;
|
|
1032
|
+
const scrub = scrubberOf(secretsOf(context.workflow, environ, options.maskedNames));
|
|
1033
|
+
const command = commandOf(node, context);
|
|
1034
|
+
if (!command.ok) return failed$1(scrub(command.error));
|
|
1035
|
+
const integration = integrationFor(node.type);
|
|
1036
|
+
const checked = integration?.program === null ? [command.program, ...command.args] : command.args;
|
|
1037
|
+
const allow = node.allow ?? integration?.allow;
|
|
1038
|
+
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(", ")})`));
|
|
1039
|
+
let cwd = context.cwd;
|
|
1040
|
+
if (node.cwd !== void 0) {
|
|
1041
|
+
const rendered = render(node.cwd, context);
|
|
1042
|
+
if (!rendered.ok) return failed$1(rendered.error);
|
|
1043
|
+
cwd = path.resolve(context.cwd, rendered.value);
|
|
1044
|
+
}
|
|
1045
|
+
const nodeEnv = renderEnv(node, context, environ);
|
|
1046
|
+
if (!nodeEnv.ok) return failed$1(nodeEnv.error);
|
|
1047
|
+
const runner = safeRunner(given, scrub);
|
|
1048
|
+
const outputFile = node.output?.format === "json" ? prepareOutputFile(context.runDir, context.id, context.round) : void 0;
|
|
1049
|
+
const env = {
|
|
1050
|
+
...nodeEnv.values,
|
|
1051
|
+
...identityOf(context, options.orkBin),
|
|
1052
|
+
...maskedEnv(namesToMask(context.workflow, environ, options.maskedNames)),
|
|
1053
|
+
...outputFile === void 0 ? {} : { [OUTPUT_ENV]: outputFile },
|
|
1054
|
+
...node.watch === void 0 ? {} : { [STATE_ENV]: prepareStateFile(context.runDir, context.id) }
|
|
1055
|
+
};
|
|
1056
|
+
const names = Object.keys(nodeEnv.values);
|
|
1057
|
+
context.emit({
|
|
1058
|
+
type: "tool.called",
|
|
1059
|
+
id: context.id,
|
|
1060
|
+
name: scrub(command.program),
|
|
1061
|
+
input: {
|
|
1062
|
+
command: scrub([
|
|
1063
|
+
command.program,
|
|
1064
|
+
...command.args,
|
|
1065
|
+
...node.script === void 0 ? [] : ["<script>"]
|
|
1066
|
+
].join(" ")),
|
|
1067
|
+
...names.length === 0 ? {} : { env: names }
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
const scriptFile = node.script === void 0 ? void 0 : writeScriptFile(context.runDir, context.id, context.round, node.script);
|
|
1071
|
+
const raw = await runner(command.program, scriptFile === void 0 ? command.args : [...command.args, scriptFile], {
|
|
1072
|
+
cwd,
|
|
1073
|
+
signal: context.signal,
|
|
1074
|
+
env
|
|
1075
|
+
});
|
|
1076
|
+
if (scriptFile !== void 0) removeOutputFile(scriptFile);
|
|
1077
|
+
const run = {
|
|
1078
|
+
exitCode: raw.exitCode,
|
|
1079
|
+
stdout: scrub(raw.stdout),
|
|
1080
|
+
stderr: scrub(raw.stderr)
|
|
1081
|
+
};
|
|
1082
|
+
const observed = await integration?.observe?.read(async (args) => {
|
|
1083
|
+
const seen = await runner(command.program, args, {
|
|
1084
|
+
cwd,
|
|
1085
|
+
signal: context.signal,
|
|
1086
|
+
env
|
|
1087
|
+
});
|
|
1088
|
+
return {
|
|
1089
|
+
ok: seen.exitCode === 0,
|
|
1090
|
+
stdout: seen.stdout
|
|
1091
|
+
};
|
|
1092
|
+
}) ?? {};
|
|
1093
|
+
const keep = (field, text) => keepFile(context, field, text);
|
|
1094
|
+
const output = {
|
|
1095
|
+
exitCode: run.exitCode,
|
|
1096
|
+
stdout: capped(run.stdout, keep("stdout", run.stdout)),
|
|
1097
|
+
stderr: capped(run.stderr, keep("stderr", run.stderr))
|
|
1098
|
+
};
|
|
1099
|
+
for (const [field, text] of Object.entries(observed)) output[field] = capped(scrub(text), keep(field, scrub(text)));
|
|
1100
|
+
if (outputFile !== void 0 && run.exitCode !== 0) removeOutputFile(outputFile);
|
|
1101
|
+
const json = outputFile === void 0 || run.exitCode !== 0 ? null : readJsonOutput({
|
|
1102
|
+
file: outputFile,
|
|
1103
|
+
stdout: raw.stdout,
|
|
1104
|
+
scrub,
|
|
1105
|
+
limit: OUTPUT_LIMIT,
|
|
1106
|
+
keep: (text) => keep("json", text)()
|
|
1107
|
+
});
|
|
1108
|
+
const declared = json?.ok === true ? declaredProblem(node.output?.schema, json.json) : null;
|
|
1109
|
+
context.emit({
|
|
1110
|
+
type: "tool.result",
|
|
1111
|
+
id: context.id,
|
|
1112
|
+
isError: run.exitCode !== 0 || json?.ok === false || declared !== null
|
|
1113
|
+
});
|
|
1114
|
+
if (json?.ok === false) return {
|
|
1115
|
+
ok: false,
|
|
1116
|
+
output,
|
|
1117
|
+
error: `${context.id}: ${json.error}`
|
|
1118
|
+
};
|
|
1119
|
+
if (declared !== null) return {
|
|
1120
|
+
ok: false,
|
|
1121
|
+
output,
|
|
1122
|
+
error: declared
|
|
1123
|
+
};
|
|
1124
|
+
if (json !== null) output["json"] = json.json;
|
|
1125
|
+
if (run.exitCode === 0) return {
|
|
1126
|
+
ok: true,
|
|
1127
|
+
output
|
|
1128
|
+
};
|
|
1129
|
+
const last = run.stderr.trim().split("\n").at(-1) ?? "";
|
|
1130
|
+
return {
|
|
1131
|
+
ok: false,
|
|
1132
|
+
transient: true,
|
|
1133
|
+
output,
|
|
1134
|
+
error: `${scrub(command.program)} exited with ${String(run.exitCode)}${last === "" ? "" : `: ${clipError(last)}`}`
|
|
1135
|
+
};
|
|
1136
|
+
};
|
|
1137
|
+
// ../nodes/src/verify.ts
|
|
1138
|
+
/**
|
|
1139
|
+
* @file `verify:` (RFC-006): a program that must pass before a session's report
|
|
1140
|
+
* is accepted. On a failure, the same session hears what failed and gets to
|
|
1141
|
+
* fix it, a set number of times, and then the node fails. What goes back to
|
|
1142
|
+
* the session stays small, as a session's cost grows with what it holds.
|
|
1143
|
+
*/
|
|
1144
|
+
/** A setup that failed: no session started, so nothing was spent on it. */
|
|
1145
|
+
const beforeFailed = (ran) => {
|
|
1146
|
+
const error = `its before step failed: ${ran.error ?? "no reason given"}`;
|
|
1147
|
+
return {
|
|
1148
|
+
ok: false,
|
|
1149
|
+
error,
|
|
1150
|
+
output: {
|
|
1151
|
+
status: "fail",
|
|
1152
|
+
summary: error,
|
|
1153
|
+
before: ran.output
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
};
|
|
1157
|
+
/** With nothing to run a step, a report cannot be accepted. */
|
|
1158
|
+
const noSteps = () => Promise.resolve({
|
|
1159
|
+
ok: false,
|
|
1160
|
+
output: {},
|
|
1161
|
+
error: "this run has no way to run a check"
|
|
1162
|
+
});
|
|
1163
|
+
/** How much of a failed check's output a session is sent. */
|
|
1164
|
+
const TAIL_LIMIT = 8192;
|
|
1165
|
+
/**
|
|
1166
|
+
* A report a check stands between: `fail` and `changes_requested` accept
|
|
1167
|
+
* nothing.
|
|
1168
|
+
*/
|
|
1169
|
+
const isAcceptance = (outcome) => outcome.report !== null && outcome.report.status !== "fail" && outcome.report.status !== "changes_requested";
|
|
1170
|
+
/** Runs a step as a one-off tool node, in the node's checkout, as the node. */
|
|
1171
|
+
const runStep = async (tool, context, { timeout, ...fields }) => {
|
|
1172
|
+
const node = {
|
|
1173
|
+
type: "tool.shell",
|
|
1174
|
+
...fields
|
|
1175
|
+
};
|
|
1176
|
+
const ms = timeoutMs(timeout);
|
|
1177
|
+
if (ms === void 0) return tool({
|
|
1178
|
+
...context,
|
|
1179
|
+
node
|
|
1180
|
+
});
|
|
1181
|
+
const limit = new AbortController();
|
|
1182
|
+
const finished = new AbortController();
|
|
1183
|
+
let expired = false;
|
|
1184
|
+
const watch = async () => {
|
|
1185
|
+
if (await heldTimeout(context.hold, ms, AbortSignal.any([context.signal, finished.signal]))) {
|
|
1186
|
+
expired = true;
|
|
1187
|
+
limit.abort();
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
watch();
|
|
1191
|
+
const signal = AbortSignal.any([context.signal, limit.signal]);
|
|
1192
|
+
try {
|
|
1193
|
+
const result = await tool({
|
|
1194
|
+
...context,
|
|
1195
|
+
node,
|
|
1196
|
+
signal
|
|
1197
|
+
});
|
|
1198
|
+
return expired ? {
|
|
1199
|
+
...result,
|
|
1200
|
+
ok: false,
|
|
1201
|
+
error: `timed out after ${String(timeout)}`
|
|
1202
|
+
} : result;
|
|
1203
|
+
} finally {
|
|
1204
|
+
finished.abort();
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
const textOf$1 = (value) => typeof value === "string" ? value.trim() : "";
|
|
1208
|
+
/**
|
|
1209
|
+
* All a check printed on one stream. A node's output holds only the first 64
|
|
1210
|
+
* KiB, and what matters in a failure is usually at the end, so a longer one is
|
|
1211
|
+
* read back from the file it was kept in.
|
|
1212
|
+
*/
|
|
1213
|
+
const printed = (check, field, where) => {
|
|
1214
|
+
const shown = textOf$1(check.output[field]);
|
|
1215
|
+
if (shown.length <= 65536) return shown;
|
|
1216
|
+
try {
|
|
1217
|
+
return readFileSync(keptFile(where, field), "utf8").trim();
|
|
1218
|
+
} catch {
|
|
1219
|
+
return shown;
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
/** The end of what a check printed, and a file with the rest when there is more. */
|
|
1223
|
+
const tailOf = (check, context) => {
|
|
1224
|
+
const said = [printed(check, "stdout", context), printed(check, "stderr", context)].filter((text) => text !== "").join("\n");
|
|
1225
|
+
if (said === "") return "It printed nothing.";
|
|
1226
|
+
if (said.length <= TAIL_LIMIT) return `\`\`\`\n${said}\n\`\`\``;
|
|
1227
|
+
mkdirSync(context.runDir, { recursive: true });
|
|
1228
|
+
const file = path.join(context.runDir, `${context.id}.${String(context.round)}.verify.txt`);
|
|
1229
|
+
writeFileSync(file, said);
|
|
1230
|
+
return `The end of what it printed, with all of it in ${file}:\n\n\`\`\`\n${said.slice(-8192)}\n\`\`\``;
|
|
1231
|
+
};
|
|
1232
|
+
/** What a session is told when the check on its report failed. */
|
|
1233
|
+
const checkFailedMessage = (check, context) => `[ork] Your report was not accepted: the workflow's check failed (${check.error ?? "no reason given"}).\n\n${tailOf(check, context)}\n\nFix what it found, and end with a new structured report.`;
|
|
1234
|
+
/**
|
|
1235
|
+
* Checks a report and, while a check fails and attempts and budget remain,
|
|
1236
|
+
* resumes the session. A resume that ends with a report that accepts nothing
|
|
1237
|
+
* ends it there, with the check still failed.
|
|
1238
|
+
*/
|
|
1239
|
+
const checkReport = (given) => {
|
|
1240
|
+
const attempts = given.verify.attempts ?? 1;
|
|
1241
|
+
const go = async (outcome, resumes, readLate) => {
|
|
1242
|
+
const check = given.signal.aborted ? {
|
|
1243
|
+
ok: false,
|
|
1244
|
+
output: {},
|
|
1245
|
+
error: "the node was stopped before its check finished"
|
|
1246
|
+
} : await given.check();
|
|
1247
|
+
if (check.ok && !readLate && !given.signal.aborted) {
|
|
1248
|
+
const later = await given.late?.(outcome) ?? null;
|
|
1249
|
+
if (later !== null) {
|
|
1250
|
+
const merged = afterLateTurn(outcome, later);
|
|
1251
|
+
return isAcceptance(merged) ? go(merged, resumes, true) : {
|
|
1252
|
+
outcome: merged,
|
|
1253
|
+
check,
|
|
1254
|
+
resumes
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (check.ok || given.signal.aborted || resumes >= attempts) return {
|
|
1259
|
+
outcome,
|
|
1260
|
+
check,
|
|
1261
|
+
resumes
|
|
1262
|
+
};
|
|
1263
|
+
const later = await given.resume(outcome, check);
|
|
1264
|
+
if (later === null) return {
|
|
1265
|
+
outcome,
|
|
1266
|
+
check,
|
|
1267
|
+
resumes
|
|
1268
|
+
};
|
|
1269
|
+
const merged = withUsageOf(outcome, later);
|
|
1270
|
+
return isAcceptance(merged) ? go(merged, resumes + 1, readLate) : {
|
|
1271
|
+
outcome: merged,
|
|
1272
|
+
check,
|
|
1273
|
+
resumes: resumes + 1
|
|
1274
|
+
};
|
|
1275
|
+
};
|
|
1276
|
+
return go(given.first, 0, false);
|
|
1277
|
+
};
|
|
1278
|
+
/**
|
|
1279
|
+
* A node's result once its report has been checked. A later report that fails
|
|
1280
|
+
* says why.
|
|
1281
|
+
*/
|
|
1282
|
+
const checkedResult = (result, { check, resumes }) => {
|
|
1283
|
+
const verify = {
|
|
1284
|
+
exitCode: check.output["exitCode"],
|
|
1285
|
+
resumes
|
|
1286
|
+
};
|
|
1287
|
+
if (check.ok) return {
|
|
1288
|
+
...result,
|
|
1289
|
+
output: {
|
|
1290
|
+
...result.output,
|
|
1291
|
+
verify
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
const reason = `its check failed: ${check.error ?? "no reason given"}`;
|
|
1295
|
+
const error = result.ok ? reason : `${reason} (then: ${result.error ?? "no report"})`;
|
|
1296
|
+
return {
|
|
1297
|
+
...result,
|
|
1298
|
+
ok: false,
|
|
1299
|
+
error,
|
|
1300
|
+
output: {
|
|
1301
|
+
...result.output,
|
|
1302
|
+
status: "fail",
|
|
1303
|
+
summary: error,
|
|
1304
|
+
verify
|
|
1305
|
+
}
|
|
1306
|
+
};
|
|
1307
|
+
};
|
|
1308
|
+
// ../nodes/src/claude.ts
|
|
1309
|
+
/**
|
|
1310
|
+
* @file The Claude node type: runs a `claude.leader` or `claude.worker` as one
|
|
1311
|
+
* session, planned with the run's outputs (and the brief its owner gave it),
|
|
1312
|
+
* and turns its report into the node's output. A leader gets its children
|
|
1313
|
+
* through the session's ports, and resumes its session on re-entry
|
|
1314
|
+
* (ADR-019).
|
|
1315
|
+
*/
|
|
1316
|
+
const MAX_INPUT = 8192;
|
|
1317
|
+
/** On a loop re-entry, the brief carries what the workflow sent back. */
|
|
1318
|
+
const reentryNote = (round, input) => {
|
|
1319
|
+
const json = JSON.stringify(input ?? {}, void 0, 2);
|
|
1320
|
+
const body = json.length > MAX_INPUT ? `${json.slice(0, MAX_INPUT)}\n…` : json;
|
|
1321
|
+
return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\``;
|
|
1322
|
+
};
|
|
1323
|
+
/**
|
|
1324
|
+
* What a resumed leader is told: it already has its brief and its earlier work,
|
|
1325
|
+
* so only the new round, and the new task when its owner sent one.
|
|
1326
|
+
*/
|
|
1327
|
+
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");
|
|
1328
|
+
/** A first-round resume is a session the run was cut off in (`ork resume`). */
|
|
1329
|
+
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)}`;
|
|
1330
|
+
/** What a session is told when messages came in as it finished. */
|
|
1331
|
+
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.`;
|
|
1332
|
+
/** A re-entered leader resumes; anyone else starts with the full brief. */
|
|
1333
|
+
const specFor = (spec, context) => {
|
|
1334
|
+
if (context.resume !== void 0) return {
|
|
1335
|
+
...spec,
|
|
1336
|
+
resume: context.resume,
|
|
1337
|
+
prompt: resumeMessage(context.round, context.input, context.brief)
|
|
1338
|
+
};
|
|
1339
|
+
if (context.round > 1 && context.brief === void 0) return {
|
|
1340
|
+
...spec,
|
|
1341
|
+
prompt: spec.prompt + reentryNote(context.round, context.input)
|
|
1342
|
+
};
|
|
1343
|
+
return spec;
|
|
1344
|
+
};
|
|
1345
|
+
/** Runs a `claude.leader` or `claude.worker` as one session. */
|
|
1346
|
+
const claudeExecutor = (options) => async (context) => {
|
|
1347
|
+
const planOptions = {
|
|
1348
|
+
cwd: context.cwd,
|
|
1349
|
+
inputs: context.inputs,
|
|
1350
|
+
outputs: context.outputs,
|
|
1351
|
+
inPlace: options.inPlace,
|
|
1352
|
+
workflowDir: context.workflowDir,
|
|
1353
|
+
projectRoot: context.projectRoot,
|
|
1354
|
+
...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
|
|
1355
|
+
pluginDir: path.join(context.runDir, "plugins", context.id)
|
|
1356
|
+
};
|
|
1357
|
+
if (context.checkout !== void 0) planOptions.checkout = context.checkout;
|
|
1358
|
+
if (context.brief !== void 0) planOptions.brief = context.brief;
|
|
1359
|
+
if (context.template !== void 0) planOptions.template = context.template;
|
|
1360
|
+
if (context.ownership !== void 0) planOptions.ownership = context.ownership;
|
|
1361
|
+
if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
|
|
1362
|
+
if (context.claims !== void 0) planOptions.claims = context.claims;
|
|
1363
|
+
const before = beforeOf(context.node, context.workflow.defaults);
|
|
1364
|
+
if (before !== void 0) {
|
|
1365
|
+
const ran = await runStep(options.steps ?? noSteps, context, before);
|
|
1366
|
+
if (!ran.ok) return beforeFailed(ran);
|
|
1367
|
+
await context.hold?.wait("start", context.signal);
|
|
1368
|
+
if (context.signal.aborted) return beforeFailed({
|
|
1369
|
+
...ran,
|
|
1370
|
+
error: "the node was stopped"
|
|
1371
|
+
});
|
|
1372
|
+
planOptions.before = ran.output;
|
|
1373
|
+
}
|
|
1374
|
+
const blocked = context.quota?.().blocked;
|
|
1375
|
+
if (blocked !== void 0) return notStarted(blocked, blocked);
|
|
1376
|
+
const budget = budgetNow(context);
|
|
1377
|
+
if (budget !== void 0 && budget <= 0) return notStarted("nothing is left of the run's budget");
|
|
1378
|
+
if (budget !== void 0) planOptions.budgetLeftUsd = budget;
|
|
1379
|
+
const plan = planNodeSession(context.workflow, context.id, planOptions);
|
|
1380
|
+
if (!plan.ok) return {
|
|
1381
|
+
ok: false,
|
|
1382
|
+
output: {
|
|
1383
|
+
status: "fail",
|
|
1384
|
+
summary: plan.error
|
|
1385
|
+
},
|
|
1386
|
+
error: plan.error
|
|
1387
|
+
};
|
|
1388
|
+
const spec = specFor(plan.spec, context);
|
|
1389
|
+
const runtime = await options.runtimeFor(options.driver ?? plan.driver);
|
|
1390
|
+
const ports = { human: context.human };
|
|
1391
|
+
if (context.hold !== void 0) ports.hold = context.hold;
|
|
1392
|
+
if (context.ownership !== void 0) ports.ownership = context.ownership;
|
|
1393
|
+
if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
|
|
1394
|
+
if (context.claims !== void 0) ports.claims = context.claims;
|
|
1395
|
+
if (context.feedback !== void 0) ports.feedback = context.feedback;
|
|
1396
|
+
const watch = limitWatch(context);
|
|
1397
|
+
const run = async (asked) => {
|
|
1398
|
+
const watching = watch.session(asked);
|
|
1399
|
+
const session = runtime.start(asked, ports);
|
|
1400
|
+
const stop = () => {
|
|
1401
|
+
session.interrupt();
|
|
1402
|
+
};
|
|
1403
|
+
context.signal.addEventListener("abort", stop, { once: true });
|
|
1404
|
+
if (context.signal.aborted) stop();
|
|
1405
|
+
const disconnect = context.mailbox?.connect((notice) => {
|
|
1406
|
+
session.send(notice);
|
|
1407
|
+
});
|
|
1408
|
+
let outcome = null;
|
|
1409
|
+
try {
|
|
1410
|
+
for await (const event of session.events) {
|
|
1411
|
+
if (event.type === "session.finished") outcome = event.outcome;
|
|
1412
|
+
watching.saw(event);
|
|
1413
|
+
context.emit(event);
|
|
1414
|
+
}
|
|
1415
|
+
} finally {
|
|
1416
|
+
disconnect?.();
|
|
1417
|
+
context.signal.removeEventListener("abort", stop);
|
|
1418
|
+
}
|
|
1419
|
+
watching.ended(outcome);
|
|
1420
|
+
return outcome;
|
|
1421
|
+
};
|
|
1422
|
+
const first = await run(spec);
|
|
1423
|
+
const limitsAfter = ({ sessionId, usage }) => {
|
|
1424
|
+
const quota = context.quota?.();
|
|
1425
|
+
if (quota?.blocked !== void 0) {
|
|
1426
|
+
watch.stoppedBy(quota.blocked);
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
return lateLimits(spec, {
|
|
1430
|
+
spent: context.ownSpend?.(sessionId, usage.costUsd) ?? usage.costUsd,
|
|
1431
|
+
turns: usage.turns
|
|
1432
|
+
}, context.budgetLeftNow?.(), quota?.leftUsd);
|
|
1433
|
+
};
|
|
1434
|
+
const resumeWith = (outcome, prompt, limits) => run({
|
|
1435
|
+
...spec,
|
|
1436
|
+
...limits,
|
|
1437
|
+
resume: outcome.sessionId,
|
|
1438
|
+
prompt
|
|
1439
|
+
});
|
|
1440
|
+
const lateTurn = (current) => {
|
|
1441
|
+
const late = context.mailbox?.pending() ?? 0;
|
|
1442
|
+
const limits = limitsAfter(current);
|
|
1443
|
+
return late === 0 || context.signal.aborted || limits === null ? Promise.resolve(null) : resumeWith(current, lateMessage(late), limits);
|
|
1444
|
+
};
|
|
1445
|
+
const again = first === null || first.report === null ? null : await lateTurn(first);
|
|
1446
|
+
const outcome = first === null || again === null ? first : afterLateTurn(first, again);
|
|
1447
|
+
const { node } = context;
|
|
1448
|
+
const verify = "verify" in node ? node.verify : void 0;
|
|
1449
|
+
if (outcome === null || verify === void 0 || !isAcceptance(outcome)) return watch.result(resultOf(outcome, declaredOutputOf(node)));
|
|
1450
|
+
const checked = await checkReport({
|
|
1451
|
+
verify,
|
|
1452
|
+
first: outcome,
|
|
1453
|
+
signal: context.signal,
|
|
1454
|
+
check: () => runStep(options.steps ?? noSteps, context, verify),
|
|
1455
|
+
late: lateTurn,
|
|
1456
|
+
resume: async (current, check) => {
|
|
1457
|
+
const limits = limitsAfter(current);
|
|
1458
|
+
if (limits === null) return null;
|
|
1459
|
+
await context.hold?.wait("start", context.signal);
|
|
1460
|
+
return context.signal.aborted ? null : resumeWith(current, checkFailedMessage(check, context), limits);
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
return watch.result(checkedResult(resultOf(checked.outcome, declaredOutputOf(node)), checked));
|
|
1464
|
+
};
|
|
1465
|
+
// ../nodes/src/gate.ts
|
|
1466
|
+
/**
|
|
1467
|
+
* @file The `human.gate` node type: asks the human the rendered question, and
|
|
1468
|
+
* answers with the `default` when the `timeout` passes. The timeout counts
|
|
1469
|
+
* only while the run and the gate are not paused (ADR-041).
|
|
1470
|
+
*/
|
|
1471
|
+
/** A promise that never settles: the losing side of a race. */
|
|
1472
|
+
const NEVER = new Promise(() => {});
|
|
1473
|
+
const choiceOf = (option) => {
|
|
1474
|
+
if (typeof option === "string") return { id: option };
|
|
1475
|
+
const choice = { id: option.id };
|
|
1476
|
+
if (option.label !== void 0) choice.label = option.label;
|
|
1477
|
+
if (option.description !== void 0) choice.description = option.description;
|
|
1478
|
+
return choice;
|
|
1479
|
+
};
|
|
1480
|
+
/**
|
|
1481
|
+
* Asks the human and outputs `choice` and `answeredBy`. With `timeout`, the
|
|
1482
|
+
* `default` answers when no one has.
|
|
1483
|
+
*/
|
|
1484
|
+
const gateExecutor = async (context) => {
|
|
1485
|
+
const { node } = context;
|
|
1486
|
+
if (node.type !== "human.gate") return {
|
|
1487
|
+
ok: false,
|
|
1488
|
+
output: {},
|
|
1489
|
+
error: `${context.id} is not a gate`
|
|
1490
|
+
};
|
|
1491
|
+
const template = parseTemplate(node.question);
|
|
1492
|
+
const question = template.ok ? renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId)) : node.question;
|
|
1493
|
+
const options = node.options.map((o) => choiceOf(o));
|
|
1494
|
+
const withdraw = new AbortController();
|
|
1495
|
+
context.signal.addEventListener("abort", () => {
|
|
1496
|
+
withdraw.abort();
|
|
1497
|
+
});
|
|
1498
|
+
const asked = {
|
|
1499
|
+
nodeId: context.id,
|
|
1500
|
+
question,
|
|
1501
|
+
options,
|
|
1502
|
+
signal: withdraw.signal
|
|
1503
|
+
};
|
|
1504
|
+
if (node.recommended !== void 0) asked.recommended = node.recommended;
|
|
1505
|
+
const answer = context.human.ask(asked).then((choice) => ({
|
|
1506
|
+
choice,
|
|
1507
|
+
answeredBy: "user"
|
|
1508
|
+
}));
|
|
1509
|
+
const ms = timeoutMs(node.timeout);
|
|
1510
|
+
const answered = new AbortController();
|
|
1511
|
+
const expired = ms === void 0 || node.default === void 0 ? [] : [heldTimeout(context.hold, ms, answered.signal).then((elapsed) => {
|
|
1512
|
+
if (!elapsed) return NEVER;
|
|
1513
|
+
withdraw.abort();
|
|
1514
|
+
return {
|
|
1515
|
+
choice: node.default ?? "",
|
|
1516
|
+
answeredBy: "timeout"
|
|
1517
|
+
};
|
|
1518
|
+
})];
|
|
1519
|
+
try {
|
|
1520
|
+
const output = await Promise.race([answer, ...expired]);
|
|
1521
|
+
if (!options.some((o) => o.id === output.choice)) return {
|
|
1522
|
+
ok: false,
|
|
1523
|
+
output,
|
|
1524
|
+
error: "no one answered the gate"
|
|
1525
|
+
};
|
|
1526
|
+
return {
|
|
1527
|
+
ok: true,
|
|
1528
|
+
output
|
|
1529
|
+
};
|
|
1530
|
+
} catch {
|
|
1531
|
+
return {
|
|
1532
|
+
ok: false,
|
|
1533
|
+
output: {},
|
|
1534
|
+
error: "the gate was withdrawn"
|
|
1535
|
+
};
|
|
1536
|
+
} finally {
|
|
1537
|
+
answered.abort();
|
|
1538
|
+
}
|
|
1539
|
+
};
|
|
1540
|
+
// ../nodes/src/mcp-call.ts
|
|
1541
|
+
/**
|
|
1542
|
+
* @file The `mcp.call` node type (RFC-006): calls one named tool on one MCP
|
|
1543
|
+
* server, with no session and so no model turn. The server comes from the
|
|
1544
|
+
* project's `.mcp.json` (`mcp-servers.ts`), its secrets from the user's
|
|
1545
|
+
* environment. The workflow's author named the tool, so this asks for no
|
|
1546
|
+
* permission, as a tool node does not.
|
|
1547
|
+
*/
|
|
1548
|
+
/** A node's `timeout:` is what limits a call; this only lifts the SDK's 60 s. */
|
|
1549
|
+
const NO_LIMIT_MS = 864e5;
|
|
1550
|
+
const failed = (error, output = {}) => ({
|
|
1551
|
+
ok: false,
|
|
1552
|
+
output,
|
|
1553
|
+
error
|
|
1554
|
+
});
|
|
1555
|
+
/** A failure another try might not have, so a `watch:` looks again. */
|
|
1556
|
+
const missed = (error, output = {}) => ({
|
|
1557
|
+
ok: false,
|
|
1558
|
+
transient: true,
|
|
1559
|
+
output,
|
|
1560
|
+
error
|
|
1561
|
+
});
|
|
1562
|
+
/**
|
|
1563
|
+
* A string's templates rendered. A string that is one `{{ … }}` and nothing
|
|
1564
|
+
* else keeps its value's type, so a tool that asks for a number gets one.
|
|
1565
|
+
*/
|
|
1566
|
+
const renderValue = (value, scope) => {
|
|
1567
|
+
if (typeof value === "string") {
|
|
1568
|
+
const template = parseTemplate(value);
|
|
1569
|
+
if (!template.ok) return value;
|
|
1570
|
+
const [only, ...rest] = template.parts;
|
|
1571
|
+
if (only !== void 0 && typeof only !== "string" && rest.length === 0) return evaluate(only.expr, scope);
|
|
1572
|
+
return renderTemplate(template.parts, scope);
|
|
1573
|
+
}
|
|
1574
|
+
if (Array.isArray(value)) return value.map((item) => renderValue(item, scope));
|
|
1575
|
+
if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderValue(item, scope)]));
|
|
1576
|
+
return value;
|
|
1577
|
+
};
|
|
1578
|
+
const transportFor = (server, context, environ, env) => server.kind === "stdio" ? new StdioClientTransport({
|
|
1579
|
+
command: server.command,
|
|
1580
|
+
args: server.args,
|
|
1581
|
+
env: {
|
|
1582
|
+
...withoutIdentity(environ),
|
|
1583
|
+
...env,
|
|
1584
|
+
...server.env
|
|
1585
|
+
},
|
|
1586
|
+
cwd: context.projectRoot,
|
|
1587
|
+
stderr: "ignore"
|
|
1588
|
+
}) : new StreamableHTTPClientTransport(new URL(server.url), { requestInit: { headers: server.headers } });
|
|
1589
|
+
/** The text of a tool's result; what is not text is named, not dropped silently. */
|
|
1590
|
+
const textOf = (content) => (Array.isArray(content) ? content : []).map((part) => part.type === "text" && typeof part.text === "string" ? part.text : `[${String(part.type)} content]`).join("\n");
|
|
1591
|
+
/** The result as JSON, read before masking so a masked number stays valid. */
|
|
1592
|
+
const jsonOf = (structured, text, scrub) => {
|
|
1593
|
+
try {
|
|
1594
|
+
return parseScrubbed(structured === void 0 ? text : JSON.stringify(structured), scrub);
|
|
1595
|
+
} catch {
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
const reasonOf = (error, server) => {
|
|
1600
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1601
|
+
return (typeof error === "object" && error !== null && "code" in error ? error.code : void 0) === 401 || error?.constructor.name === "UnauthorizedError" ? `MCP server \`${server}\` asks for a sign-in: give it a token through headers and \`env:\`, or call it from a session` : message;
|
|
1602
|
+
};
|
|
1603
|
+
const mcpCallExecutor = (options = {}) => async (context) => {
|
|
1604
|
+
const { node } = context;
|
|
1605
|
+
if (node.type !== "mcp.call") return failed(`${context.id} is not an MCP call`);
|
|
1606
|
+
const environ = options.environ ?? process.env;
|
|
1607
|
+
const declared = readMcpServer(context.projectRoot, node.server);
|
|
1608
|
+
if (!declared.ok) return failed(declared.error);
|
|
1609
|
+
const nodeEnv = renderEnv(node, context, environ);
|
|
1610
|
+
if (!nodeEnv.ok) return failed(nodeEnv.error);
|
|
1611
|
+
const expanded = expandServer(declared.value, {
|
|
1612
|
+
...environ,
|
|
1613
|
+
...nodeEnv.values
|
|
1614
|
+
}, node.plainEnv);
|
|
1615
|
+
if (!expanded.ok) return failed(expanded.error);
|
|
1616
|
+
const scrub = scrubberOf([...secretsOf(context.workflow, environ, options.maskedNames), ...expanded.value.values]);
|
|
1617
|
+
const scope = runScope(context.inputs, context.outputs, context.runId);
|
|
1618
|
+
const args = Object.fromEntries(Object.entries(node.arguments ?? {}).map(([key, value]) => [key, renderValue(value, scope)]));
|
|
1619
|
+
const name = `${node.server}.${node.tool}`;
|
|
1620
|
+
context.emit({
|
|
1621
|
+
type: "tool.called",
|
|
1622
|
+
id: context.id,
|
|
1623
|
+
name,
|
|
1624
|
+
input: { arguments: scrubbed(args, scrub) }
|
|
1625
|
+
});
|
|
1626
|
+
const client = new Client({
|
|
1627
|
+
name: "ork",
|
|
1628
|
+
version: "1"
|
|
1629
|
+
});
|
|
1630
|
+
try {
|
|
1631
|
+
await client.connect(transportFor(expanded.value.server, context, environ, nodeEnv.values), {
|
|
1632
|
+
signal: context.signal,
|
|
1633
|
+
timeout: NO_LIMIT_MS
|
|
1634
|
+
});
|
|
1635
|
+
const result = await client.callTool({
|
|
1636
|
+
name: node.tool,
|
|
1637
|
+
arguments: args
|
|
1638
|
+
}, void 0, {
|
|
1639
|
+
signal: context.signal,
|
|
1640
|
+
timeout: NO_LIMIT_MS
|
|
1641
|
+
});
|
|
1642
|
+
const raw = textOf(result.content);
|
|
1643
|
+
const said = scrub(raw);
|
|
1644
|
+
const text = capped(said, keepFile(context, "text", said));
|
|
1645
|
+
const json = jsonOf(result.structuredContent, raw, scrub);
|
|
1646
|
+
const tooLong = JSON.stringify(json ?? null).length > OUTPUT_LIMIT;
|
|
1647
|
+
const reported = result.isError === true;
|
|
1648
|
+
const problem = declaredProblem(node.output?.schema, json);
|
|
1649
|
+
const output = {
|
|
1650
|
+
text,
|
|
1651
|
+
...json === void 0 || tooLong || problem !== null ? {} : { json }
|
|
1652
|
+
};
|
|
1653
|
+
context.emit({
|
|
1654
|
+
type: "tool.result",
|
|
1655
|
+
id: context.id,
|
|
1656
|
+
isError: reported || tooLong || problem !== null
|
|
1657
|
+
});
|
|
1658
|
+
if (reported) return missed(`${name} failed: ${clipError(said)}`, output);
|
|
1659
|
+
if (tooLong) return failed(`${name}'s result is over 64 KiB`, output);
|
|
1660
|
+
return problem === null ? {
|
|
1661
|
+
ok: true,
|
|
1662
|
+
output
|
|
1663
|
+
} : failed(problem, output);
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
context.emit({
|
|
1666
|
+
type: "tool.result",
|
|
1667
|
+
id: context.id,
|
|
1668
|
+
isError: true
|
|
1669
|
+
});
|
|
1670
|
+
return missed(clipError(scrub(reasonOf(error, node.server))));
|
|
1671
|
+
} finally {
|
|
1672
|
+
await client.close().catch(() => {});
|
|
1673
|
+
}
|
|
1674
|
+
};
|
|
1675
|
+
// ../nodes/src/spawn.ts
|
|
1676
|
+
/**
|
|
1677
|
+
* @file The real tool runner: `spawn` without a shell, output collected as
|
|
1678
|
+
* text. A program runs in its own process group, so aborting it reaches
|
|
1679
|
+
* whatever it started: SIGTERM, then SIGKILL for what ignores that. A stop or
|
|
1680
|
+
* a timeout therefore leaves nothing running, and an escaped process that
|
|
1681
|
+
* keeps the output pipes open cannot hold the node.
|
|
1682
|
+
*/
|
|
1683
|
+
/** How long a program may take to end after SIGTERM. */
|
|
1684
|
+
const KILL_AFTER_MS = 2e3;
|
|
1685
|
+
/** How long to wait for the pipes to close after SIGKILL. */
|
|
1686
|
+
const CLOSE_AFTER_MS = 500;
|
|
1687
|
+
const spawnTool = (program, args, { cwd, signal, env }) => {
|
|
1688
|
+
if (signal.aborted) return Promise.resolve({
|
|
1689
|
+
exitCode: -1,
|
|
1690
|
+
stdout: "",
|
|
1691
|
+
stderr: `${program} was not started: the node was stopped`
|
|
1692
|
+
});
|
|
1693
|
+
return new Promise((resolve) => {
|
|
1694
|
+
const stdout = [];
|
|
1695
|
+
const stderr = [];
|
|
1696
|
+
const timers = [];
|
|
1697
|
+
const child = spawn(program, args, {
|
|
1698
|
+
cwd,
|
|
1699
|
+
detached: true,
|
|
1700
|
+
env: {
|
|
1701
|
+
...withoutIdentity(process.env),
|
|
1702
|
+
...env
|
|
1703
|
+
},
|
|
1704
|
+
stdio: [
|
|
1705
|
+
"ignore",
|
|
1706
|
+
"pipe",
|
|
1707
|
+
"pipe"
|
|
1708
|
+
]
|
|
1709
|
+
});
|
|
1710
|
+
const killGroup = (kill) => {
|
|
1711
|
+
if (child.pid === void 0) return;
|
|
1712
|
+
try {
|
|
1713
|
+
process.kill(-child.pid, kill);
|
|
1714
|
+
} catch {}
|
|
1715
|
+
};
|
|
1716
|
+
const finish = (run) => {
|
|
1717
|
+
for (const timer of timers) clearTimeout(timer);
|
|
1718
|
+
signal.removeEventListener("abort", abort);
|
|
1719
|
+
resolve(run);
|
|
1720
|
+
};
|
|
1721
|
+
const collected = (exitCode) => ({
|
|
1722
|
+
exitCode,
|
|
1723
|
+
stdout: stdout.join(""),
|
|
1724
|
+
stderr: stderr.join("")
|
|
1725
|
+
});
|
|
1726
|
+
const abort = () => {
|
|
1727
|
+
killGroup("SIGTERM");
|
|
1728
|
+
timers.push(setTimeout(() => {
|
|
1729
|
+
killGroup("SIGKILL");
|
|
1730
|
+
timers.push(setTimeout(() => {
|
|
1731
|
+
finish(collected(-1));
|
|
1732
|
+
}, CLOSE_AFTER_MS));
|
|
1733
|
+
}, KILL_AFTER_MS));
|
|
1734
|
+
};
|
|
1735
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1736
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
1737
|
+
stdout.push(chunk);
|
|
1738
|
+
});
|
|
1739
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
1740
|
+
stderr.push(chunk);
|
|
1741
|
+
});
|
|
1742
|
+
child.on("error", (error) => {
|
|
1743
|
+
finish({
|
|
1744
|
+
exitCode: -1,
|
|
1745
|
+
stdout: stdout.join(""),
|
|
1746
|
+
stderr: `could not run ${program}: ${error.message}`
|
|
1747
|
+
});
|
|
1748
|
+
});
|
|
1749
|
+
child.on("close", (code) => {
|
|
1750
|
+
finish(collected(code ?? -1));
|
|
1751
|
+
});
|
|
1752
|
+
});
|
|
1753
|
+
};
|
|
1754
|
+
// ../nodes/src/builtins.ts
|
|
1755
|
+
/** Every built-in node type that does work; control nodes are the scheduler's. */
|
|
1756
|
+
const builtinExecutors = (options) => {
|
|
1757
|
+
const tool = toolExecutor(options.tools ?? spawnTool, {
|
|
1758
|
+
...options.orkBin === void 0 ? {} : { orkBin: options.orkBin },
|
|
1759
|
+
...options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames }
|
|
1760
|
+
});
|
|
1761
|
+
const claude = claudeExecutor({
|
|
1762
|
+
...options,
|
|
1763
|
+
steps: tool
|
|
1764
|
+
});
|
|
1765
|
+
const executors = {
|
|
1766
|
+
"claude.leader": claude,
|
|
1767
|
+
"claude.worker": claude,
|
|
1768
|
+
"human.gate": gateExecutor,
|
|
1769
|
+
"workflow.call": callExecutor,
|
|
1770
|
+
"mcp.call": mcpCallExecutor(options.maskedNames === void 0 ? {} : { maskedNames: options.maskedNames })
|
|
1771
|
+
};
|
|
1772
|
+
for (const integration of INTEGRATIONS) executors[`${TOOL_PREFIX}${integration.id}`] = tool;
|
|
1773
|
+
return executors;
|
|
1774
|
+
};
|
|
1775
|
+
// ../nodes/src/programs.ts
|
|
1776
|
+
/**
|
|
1777
|
+
* @file Which programs a workflow's tool nodes run, and which of them are not
|
|
1778
|
+
* installed. Integrations are optional: `ork run` checks only the programs
|
|
1779
|
+
* the workflow uses, before anything starts.
|
|
1780
|
+
*/
|
|
1781
|
+
const isRelativePath = (program) => program.includes(path.sep) && !path.isAbsolute(program);
|
|
1782
|
+
/**
|
|
1783
|
+
* The programs the workflow's tool nodes run: an integration's own (`gh`,
|
|
1784
|
+
* `aws`), or the first word of a `tool.shell` command or interpreter, when it
|
|
1785
|
+
* is not a template. A relative path is not asked about: it means a node's
|
|
1786
|
+
* checkout, where a `before:` or an earlier node may yet make it.
|
|
1787
|
+
*/
|
|
1788
|
+
const neededPrograms = (workflow) => Object.entries(workflow.nodes).flatMap(([id, node]) => programsRunBy(node, workflow.defaults).flatMap(({ program }) => {
|
|
1789
|
+
const type = isToolNode(node) ? node.type : "tool.shell";
|
|
1790
|
+
const integration = integrationFor(type);
|
|
1791
|
+
if (integration === void 0) return [];
|
|
1792
|
+
if (integration.program !== null) return [{
|
|
1793
|
+
node: id,
|
|
1794
|
+
type,
|
|
1795
|
+
program: integration.program
|
|
1796
|
+
}];
|
|
1797
|
+
const tokens = tokenize(commandLineOf(program) ?? "");
|
|
1798
|
+
const first = tokens.ok ? tokens.args[0] : void 0;
|
|
1799
|
+
return first === void 0 || first.includes("{{") || isRelativePath(first) ? [] : [{
|
|
1800
|
+
node: id,
|
|
1801
|
+
type,
|
|
1802
|
+
program: first
|
|
1803
|
+
}];
|
|
1804
|
+
}));
|
|
1805
|
+
/** The `{{env.NAME}}` a workflow reads that are not set, with their nodes. */
|
|
1806
|
+
const missingEnv = (workflow, environ = process.env, finished = /* @__PURE__ */ new Set()) => {
|
|
1807
|
+
const missing = /* @__PURE__ */ new Map();
|
|
1808
|
+
for (const [id, node] of Object.entries(workflow.nodes)) {
|
|
1809
|
+
if (finished.has(id)) continue;
|
|
1810
|
+
const values = programsRunBy(node, workflow.defaults).flatMap(({ program }) => Object.values(program.env ?? {}));
|
|
1811
|
+
for (const value of values) for (const name of envReadsOf(value)) {
|
|
1812
|
+
if (readEnv(environ, name) !== void 0) continue;
|
|
1813
|
+
missing.set(name, (missing.get(name) ?? /* @__PURE__ */ new Set()).add(id));
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
return [...missing].map(([variable, nodes]) => ({
|
|
1817
|
+
variable,
|
|
1818
|
+
nodes: [...nodes]
|
|
1819
|
+
}));
|
|
1820
|
+
};
|
|
1821
|
+
/** The nodes a resumed run hands back rather than runs: their last go worked. */
|
|
1822
|
+
const finishedOf = (history) => new Set([...history].filter(([, results]) => results.at(-1)?.ok === true).map(([id]) => id));
|
|
1823
|
+
/**
|
|
1824
|
+
* What a workflow this run may call reads and is not set. Whether a call runs
|
|
1825
|
+
* is often only known at run time, so this warns; the call fails if reached. A
|
|
1826
|
+
* call a resumed run already finished is not asked about.
|
|
1827
|
+
*/
|
|
1828
|
+
const calledEnvWarning = (workflow, own, registered, history = /* @__PURE__ */ new Map(), environ = process.env) => {
|
|
1829
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1830
|
+
const unreadable = [];
|
|
1831
|
+
for (const called of calledWorkflows(workflow, own, registered, {
|
|
1832
|
+
finished: finishedOf(history),
|
|
1833
|
+
unreadable: (name) => {
|
|
1834
|
+
unreadable.push(name);
|
|
1835
|
+
}
|
|
1836
|
+
})) for (const { variable, nodes } of missingEnv(called, environ)) {
|
|
1837
|
+
const seen = merged.get(variable) ?? /* @__PURE__ */ new Set();
|
|
1838
|
+
for (const node of nodes) seen.add(`${called.name}/${node}`);
|
|
1839
|
+
merged.set(variable, seen);
|
|
1840
|
+
}
|
|
1841
|
+
return [...merged.size === 0 ? [] : [
|
|
1842
|
+
"A workflow this run may call reads variables that are not set:",
|
|
1843
|
+
...[...merged].map(([variable, nodes]) => ` ${variable}, for ${[...nodes].join(", ")}`),
|
|
1844
|
+
"That call fails if the run reaches it."
|
|
1845
|
+
], ...unreadable.length === 0 ? [] : [`Could not read ${unreadable.join(", ")}, which this run may call, so what ${unreadable.length === 1 ? "it reads" : "they read"} is neither checked nor masked.`]];
|
|
1846
|
+
};
|
|
1847
|
+
/** Why a run refuses to start when a variable its nodes read is not set. */
|
|
1848
|
+
const missingEnvMessage = (missing) => [
|
|
1849
|
+
"This workflow reads environment variables that are not set:",
|
|
1850
|
+
...missing.map((m) => ` ${m.variable}, for ${m.nodes.join(", ")}`),
|
|
1851
|
+
"Set them, and run it again."
|
|
1852
|
+
];
|
|
1853
|
+
/** Why a run refuses to start when a program its tool nodes need is missing. */
|
|
1854
|
+
const missingProgramMessage = (missing) => [
|
|
1855
|
+
"This workflow runs programs that are not installed:",
|
|
1856
|
+
...missing.map((m) => ` ${m.program}, for ${m.nodes.join(", ")} (${m.type})`),
|
|
1857
|
+
"Install them, or take those nodes out. Integrations are optional: only the ones a workflow uses must be installed (`ork doctor` shows what you have)."
|
|
1858
|
+
];
|
|
1859
|
+
/**
|
|
1860
|
+
* What stops this run for want of a variable, as lines to print; what only
|
|
1861
|
+
* might stop it goes to `warn`.
|
|
1862
|
+
*/
|
|
1863
|
+
const envProblems = (workflow, own, registered, history, warn) => {
|
|
1864
|
+
const unset = missingEnv(workflow, process.env, finishedOf(history ?? /* @__PURE__ */ new Map()));
|
|
1865
|
+
if (unset.length > 0) return missingEnvMessage(unset);
|
|
1866
|
+
const servers = mcpProblems(workflow, own.root, process.env, finishedOf(history ?? /* @__PURE__ */ new Map()), (program) => isRelativePath(program) || onPath(program));
|
|
1867
|
+
if (servers.length > 0) return servers;
|
|
1868
|
+
for (const line of calledEnvWarning(workflow, own, registered, history)) warn(line);
|
|
1869
|
+
return [];
|
|
1870
|
+
};
|
|
1871
|
+
const runnable = (file) => {
|
|
1872
|
+
try {
|
|
1873
|
+
accessSync(file, constants.X_OK);
|
|
1874
|
+
return true;
|
|
1875
|
+
} catch {
|
|
1876
|
+
return false;
|
|
1877
|
+
}
|
|
1878
|
+
};
|
|
1879
|
+
/** Whether `program` runs: a path that is executable, or a name on `PATH`. */
|
|
1880
|
+
const onPath = (program, env = process.env) => {
|
|
1881
|
+
if (program.includes(path.sep)) return runnable(program);
|
|
1882
|
+
return (env["PATH"] ?? "").split(path.delimiter).filter((dir) => dir !== "").some((dir) => runnable(path.join(dir, program)));
|
|
1883
|
+
};
|
|
1884
|
+
/** The needed programs that are not installed, one entry per program. */
|
|
1885
|
+
const missingPrograms = (workflow, installed = (program) => onPath(program)) => {
|
|
1886
|
+
const missing = /* @__PURE__ */ new Map();
|
|
1887
|
+
for (const need of neededPrograms(workflow)) {
|
|
1888
|
+
if (installed(need.program)) continue;
|
|
1889
|
+
const entry = missing.get(need.program) ?? {
|
|
1890
|
+
program: need.program,
|
|
1891
|
+
nodes: [],
|
|
1892
|
+
type: need.type
|
|
1893
|
+
};
|
|
1894
|
+
entry.nodes.push(need.node);
|
|
1895
|
+
missing.set(need.program, entry);
|
|
1896
|
+
}
|
|
1897
|
+
return [...missing.values()];
|
|
1898
|
+
};
|
|
1899
|
+
export { resultOf as _, builtinExecutors as a, toolExecutor as c, maskedEnv as d, maskedNamesOfRun as f, clearOutputFiles as g, sessionEnv as h, onPath as i, clearStateFiles as l, runScrubber as m, missingProgramMessage as n, spawnTool as o, runMarkerIn as p, missingPrograms as r, mcpCallExecutor as s, envProblems as t, inheritedScrubber as u };
|