@krapht/ork 0.2.0 → 0.3.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__-CXXMLvjt.mjs +104 -0
- package/dist/bin.mjs +859 -3037
- package/dist/context-BFGC_C02.mjs +435 -0
- package/dist/find-BcvhT6UN.mjs +47 -0
- package/dist/format-DqpObyMl.mjs +553 -0
- package/dist/in-a-run-BbLhZOFW.mjs +22 -0
- package/dist/limits-BKYAmqCR.mjs +44 -0
- package/dist/mcp-BWsdQyri.mjs +91 -0
- package/dist/output-Q2EdeVUt.mjs +856 -0
- package/dist/pause-DKACDfV1.mjs +65 -0
- package/dist/relay-CbK-Wme9.mjs +122 -0
- package/dist/report-DK4LGqwF.mjs +129 -0
- package/dist/send-C6yr04Fe.mjs +163 -0
- package/dist/{src-DAsOYtoR.mjs → src-CZOVF2yU.mjs} +496 -193
- package/dist/src-Cz1yOUTg.mjs +11841 -0
- package/dist/src-D5ruZFgD.mjs +1973 -0
- package/dist/src-DtN1k-B3.mjs +1899 -0
- package/dist/ui/THIRD_PARTY_LICENSES.txt +631 -0
- package/dist/ui/assets/app-BlDdP396.js +61 -0
- package/dist/ui/assets/app-C722xNZ3.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-BoG6O-Lp.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-CZS1XKla.mjs +160 -0
- package/dist/usage-BIjf9Xdt.mjs +418 -0
- package/dist/watch-B8o4Szd_.mjs +394 -0
- package/package.json +4 -2
- package/dist/src-CJ_zF0Zj.mjs +0 -5433
package/dist/bin.mjs
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { $ as replayLoopAnswers, $t as projectsHome, Bt as linkTools, Cn as beforeOf, Ct as FEEDBACK_SEVERITIES, Dt as targetOf, E as planNodeSession, Et as fingerprint, Ft as socketPath, Gt as catalogAgent, H as requestStop, Ht as toolsInUse, I as peerOf, It as wakeRun, Jt as hasInRepoFiles, Kt as loadAgents, L as tellNode, Lt as planAgentMigration, M as addressOf, Mn as ORK_LAYOUT, N as endLink, Nn as CLAUDE_DRIVERS, O as parseInputPairs, On as INTEGRATIONS, Ot as maskReplacer, P as endLinksOf, Pn as firstLine, Pt as serveRun, Q as createReplay, Qt as placeFor, Rt as applySteps, St as FEEDBACK_KINDS, Tt as createFeedback, U as tellLiveRun, Ut as createAgent, Vt as toolLinks, Wt as deleteAgent, X as detachResume, Xt as newProjectId, Y as adoptedCalls, Yt as inRepoPlace, Z as resumable, Zt as placeFiles, b as inspectProject, d as triggersReport, dt as startRun, en as runDirOf, hn as serializeWorkflow, i as NOT_STARTED, in as projectRootOf, j as createCalls, jn as AGENT_TOOL_LAYOUTS, k as resolveInputs, kt as maskSecrets, l as detectInstaller, n as resolveBase, nt as followEvents, ot as runStatus, pt as userMessageText, rn as inside, rt as liveStatus, st as runTree, t as createWorkspace, tn as shownPath, tt as detachRun, u as lateTriggers, ut as walkSummary, wn as isClaudeNode, xn as settingsOf, xt as gitRulesOf, yn as WorkflowSchema, z as flattenTree, zt as planMoveInto } from "./src-Cz1yOUTg.mjs";
|
|
3
|
+
import { a as pendingDecisions, i as answerDecision, n as ORK_VERSION, r as nodeSession, t as ORK_NAME } from "./__build_info__-CXXMLvjt.mjs";
|
|
4
|
+
import { n as newRunId, t as findRun } from "./find-BcvhT6UN.mjs";
|
|
5
|
+
import { a as write, c as openStore, i as style, l as storePath, n as cost, o as writeErr, s as nodeOf, t as confirm } from "./output-Q2EdeVUt.mjs";
|
|
6
|
+
import { a as formatRunEvent, c as createdWorktrees, i as formatEvent, l as listOrkWorktrees, n as formatRuns, o as createTerminalHuman, r as formatTree, s as applyClean, t as ago, u as planClean } from "./format-DqpObyMl.mjs";
|
|
7
|
+
import { a as validate, c as warningLines, d as workflowArg, f as projectAt, i as loadWorkflow, l as WORKFLOW_DIR, o as validateFile, p as isNewer, r as formatReport, s as walkLoaded, t as context, u as WORKFLOW_SUFFIX } from "./context-BFGC_C02.mjs";
|
|
8
|
+
import { i as runLimits, r as limitsRefusal, t as chatNotice } from "./limits-BKYAmqCR.mjs";
|
|
9
|
+
import { _ as resultOf, a as builtinExecutors, d as maskedEnv, f as maskedNamesOfRun, g as clearOutputFiles, h as sessionEnv, i as onPath, l as clearStateFiles, m as runScrubber, n as missingProgramMessage, r as missingPrograms, t as envProblems } from "./src-DtN1k-B3.mjs";
|
|
10
|
+
import { i as decideRefusal, n as chatRefusal } from "./in-a-run-BbLhZOFW.mjs";
|
|
3
11
|
import { defineCommand, runMain } from "citty";
|
|
4
12
|
import { createInterface } from "node:readline/promises";
|
|
5
13
|
import path from "node:path";
|
|
6
14
|
import { stringify } from "yaml";
|
|
7
|
-
import {
|
|
8
|
-
import { createHash
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
9
17
|
import { homedir } from "node:os";
|
|
10
|
-
import {
|
|
11
|
-
import { createInterface as createInterface$1 } from "node:readline";
|
|
12
|
-
import { DatabaseSync } from "node:sqlite";
|
|
18
|
+
import { setTimeout } from "node:timers/promises";
|
|
13
19
|
import { execFile, spawn } from "node:child_process";
|
|
14
20
|
import { styleText } from "node:util";
|
|
15
|
-
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
16
21
|
// ../core/src/control/queue.ts
|
|
17
22
|
const PERMISSION_OPTIONS = [{
|
|
18
23
|
id: "allow",
|
|
@@ -134,123 +139,6 @@ const createDecisionQueue = (options) => {
|
|
|
134
139
|
}
|
|
135
140
|
};
|
|
136
141
|
};
|
|
137
|
-
// ../core/src/control/socket.ts
|
|
138
|
-
/**
|
|
139
|
-
* @file The per-run local socket (`~/.ork/sockets/<run>.sock`): the fast path
|
|
140
|
-
* beside the store (ADR-024). Other `ork` processes `wake` a run when they
|
|
141
|
-
* have written an answer or a command, and `follow` a run to get each event
|
|
142
|
-
* as it is stored. One JSON message per line; nothing is lost when the socket
|
|
143
|
-
* is gone, since the store has it all.
|
|
144
|
-
*/
|
|
145
|
-
/** Where a run's socket lives; `home` is `~` (tests pass a folder). */
|
|
146
|
-
const socketPath = (runId, home = homedir()) => path.join(home, ".ork", "sockets", `${runId}.sock`);
|
|
147
|
-
/** For errors another handler already settles. */
|
|
148
|
-
const ignore = () => {};
|
|
149
|
-
const messageOf = (line) => {
|
|
150
|
-
try {
|
|
151
|
-
const value = JSON.parse(line);
|
|
152
|
-
if (value === null || typeof value !== "object" || !("type" in value)) return;
|
|
153
|
-
if (value.type === "wake") return { type: "wake" };
|
|
154
|
-
if (value.type === "follow") {
|
|
155
|
-
const after = "after" in value ? Number(value.after) : 0;
|
|
156
|
-
return {
|
|
157
|
-
type: "follow",
|
|
158
|
-
after: Number.isFinite(after) ? after : 0
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
} catch {}
|
|
162
|
-
};
|
|
163
|
-
/** Listens on the run's socket; resolves once it is listening. */
|
|
164
|
-
const serveRun = async (options) => {
|
|
165
|
-
mkdirSync(path.dirname(options.path), { recursive: true });
|
|
166
|
-
rmSync(options.path, { force: true });
|
|
167
|
-
const followers = /* @__PURE__ */ new Set();
|
|
168
|
-
const server = createServer((socket) => {
|
|
169
|
-
socket.on("error", () => {
|
|
170
|
-
followers.delete(socket);
|
|
171
|
-
});
|
|
172
|
-
socket.on("close", () => {
|
|
173
|
-
followers.delete(socket);
|
|
174
|
-
});
|
|
175
|
-
const lines = createInterface$1({ input: socket });
|
|
176
|
-
lines.on("error", ignore);
|
|
177
|
-
lines.on("line", (line) => {
|
|
178
|
-
const message = messageOf(line);
|
|
179
|
-
if (message?.type === "wake") options.onWake();
|
|
180
|
-
if (message?.type === "follow") {
|
|
181
|
-
for (const stored of options.backlog(message.after)) socket.write(`${JSON.stringify(stored)}\n`);
|
|
182
|
-
followers.add(socket);
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
});
|
|
186
|
-
const { promise, resolve, reject } = Promise.withResolvers();
|
|
187
|
-
server.once("error", reject);
|
|
188
|
-
server.listen(options.path, () => {
|
|
189
|
-
resolve();
|
|
190
|
-
});
|
|
191
|
-
await promise;
|
|
192
|
-
return {
|
|
193
|
-
publish: (stored) => {
|
|
194
|
-
const line = `${JSON.stringify(stored)}\n`;
|
|
195
|
-
for (const socket of followers) socket.write(line);
|
|
196
|
-
},
|
|
197
|
-
close: () => {
|
|
198
|
-
for (const socket of followers) socket.end();
|
|
199
|
-
const closed = Promise.withResolvers();
|
|
200
|
-
server.close(() => {
|
|
201
|
-
closed.resolve();
|
|
202
|
-
});
|
|
203
|
-
rmSync(options.path, { force: true });
|
|
204
|
-
return closed.promise;
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
};
|
|
208
|
-
/** Tells a live run to look at the store now; false when it is not listening. */
|
|
209
|
-
const wakeRun = (file) => {
|
|
210
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
211
|
-
const socket = createConnection(file, () => {
|
|
212
|
-
socket.end(`${JSON.stringify({ type: "wake" })}\n`, () => {
|
|
213
|
-
resolve(true);
|
|
214
|
-
});
|
|
215
|
-
});
|
|
216
|
-
socket.on("error", () => {
|
|
217
|
-
resolve(false);
|
|
218
|
-
});
|
|
219
|
-
return promise;
|
|
220
|
-
};
|
|
221
|
-
/**
|
|
222
|
-
* Follows a live run over its socket: `onEvent` gets each event stored after
|
|
223
|
-
* `after`. Resolves `"ended"` when the run closes the socket, or
|
|
224
|
-
* `"unavailable"` when no run is listening.
|
|
225
|
-
*/
|
|
226
|
-
const followSocket = (file, after, onEvent, signal) => {
|
|
227
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
228
|
-
let connected = false;
|
|
229
|
-
const socket = createConnection(file, () => {
|
|
230
|
-
connected = true;
|
|
231
|
-
socket.write(`${JSON.stringify({
|
|
232
|
-
type: "follow",
|
|
233
|
-
after
|
|
234
|
-
})}\n`);
|
|
235
|
-
});
|
|
236
|
-
const lines = createInterface$1({ input: socket });
|
|
237
|
-
lines.on("error", ignore);
|
|
238
|
-
lines.on("line", (line) => {
|
|
239
|
-
try {
|
|
240
|
-
onEvent(JSON.parse(line));
|
|
241
|
-
} catch {}
|
|
242
|
-
});
|
|
243
|
-
socket.on("error", () => {
|
|
244
|
-
resolve(connected ? "ended" : "unavailable");
|
|
245
|
-
});
|
|
246
|
-
socket.on("close", () => {
|
|
247
|
-
resolve(connected ? "ended" : "unavailable");
|
|
248
|
-
});
|
|
249
|
-
signal?.addEventListener("abort", () => {
|
|
250
|
-
socket.destroy();
|
|
251
|
-
});
|
|
252
|
-
return promise;
|
|
253
|
-
};
|
|
254
142
|
// ../core/src/control/control.ts
|
|
255
143
|
const startControl = async (options) => {
|
|
256
144
|
const { store, runId } = options;
|
|
@@ -265,13 +153,23 @@ const startControl = async (options) => {
|
|
|
265
153
|
}
|
|
266
154
|
}
|
|
267
155
|
for (const command of store.takeCommands(runId)) if (command.kind === "message") options.onMessage(command.node, command.body);
|
|
268
|
-
else if (
|
|
156
|
+
else if (command.kind === "relay") options.onRelay?.(command.node, command.from, command.body);
|
|
157
|
+
else if (command.kind === "script") options.onScript?.(command.node, command.from, command.body);
|
|
158
|
+
else if (command.kind === "notice") options.onNotice?.(command.node, command.body);
|
|
159
|
+
else if (command.kind === "stop" && command.node === void 0) {
|
|
160
|
+
if (stopped) continue;
|
|
269
161
|
stopped = true;
|
|
270
162
|
options.emit({
|
|
271
163
|
type: "run.command",
|
|
272
164
|
command: "stop"
|
|
273
165
|
});
|
|
274
166
|
options.onStop();
|
|
167
|
+
} else if (!stopped) {
|
|
168
|
+
if (options.onControl?.(command.kind, command.node) ?? false) options.emit({
|
|
169
|
+
type: "run.command",
|
|
170
|
+
command: command.kind,
|
|
171
|
+
...command.node === void 0 ? {} : { node: command.node }
|
|
172
|
+
});
|
|
275
173
|
}
|
|
276
174
|
};
|
|
277
175
|
const timer = setInterval(check, options.intervalMs ?? 1e3);
|
|
@@ -504,267 +402,102 @@ const createSignals = (options = {}) => {
|
|
|
504
402
|
return signal === null ? [event] : [event, ...record(...signal)];
|
|
505
403
|
};
|
|
506
404
|
};
|
|
507
|
-
// ../core/src/
|
|
508
|
-
const coerce = (name, type, raw) => {
|
|
509
|
-
if (type === "string") return { value: raw };
|
|
510
|
-
if (type === "number") {
|
|
511
|
-
const value = Number(raw);
|
|
512
|
-
return raw.trim() === "" || Number.isNaN(value) ? { error: `input "${name}" must be a number, got "${raw}"` } : { value };
|
|
513
|
-
}
|
|
514
|
-
if (raw === "true" || raw === "false") return { value: raw === "true" };
|
|
515
|
-
return { error: `input "${name}" must be true or false, got "${raw}"` };
|
|
516
|
-
};
|
|
405
|
+
// ../core/src/remote/relay.ts
|
|
517
406
|
/**
|
|
518
|
-
*
|
|
519
|
-
*
|
|
407
|
+
* @file A run's side of the relay (RFC-003): its nodes' links, read from the
|
|
408
|
+
* store, and sending to a node of another run. A message is a row in the
|
|
409
|
+
* store and a wake on that run's socket, so no process has to stay up for it,
|
|
410
|
+
* and the store has it if the socket is gone. Reaching a link's limit asks
|
|
411
|
+
* the user whether to go on, as a loop out of rounds does.
|
|
520
412
|
*/
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
413
|
+
/** For a refused or abandoned question: a stopped run has nothing to do. */
|
|
414
|
+
const ignore = () => {};
|
|
415
|
+
const createRelay = (options) => {
|
|
416
|
+
const { store, runId, home, human } = options;
|
|
417
|
+
/** Links with the question about their limit open. */
|
|
418
|
+
const asking = /* @__PURE__ */ new Set();
|
|
419
|
+
const askToGoOn = async (link, node) => {
|
|
420
|
+
if (asking.has(link.id)) return;
|
|
421
|
+
asking.add(link.id);
|
|
422
|
+
try {
|
|
423
|
+
if (await human.ask({
|
|
424
|
+
nodeId: node,
|
|
425
|
+
question: `${addressOf(link.a)} and ${addressOf(link.b)} have used all ${String(link.maxMessages)} messages of their link. Let them go on?`,
|
|
426
|
+
options: [{
|
|
427
|
+
id: "extend",
|
|
428
|
+
description: `${String(link.maxMessages)} more`
|
|
429
|
+
}, {
|
|
430
|
+
id: "end",
|
|
431
|
+
description: "End the link"
|
|
432
|
+
}],
|
|
433
|
+
recommended: "end"
|
|
434
|
+
}) === "extend") {
|
|
435
|
+
store.extendLink(link.id, link.maxMessages);
|
|
436
|
+
await Promise.all([link.a, link.b].map((end) => tellNode(store, end, "[ork] The user let this link go on; you can send again.", home)));
|
|
437
|
+
} else await endLink(store, link, "the user ended it", home);
|
|
438
|
+
} finally {
|
|
439
|
+
asking.delete(link.id);
|
|
532
440
|
}
|
|
533
|
-
const result = coerce(name, def.type ?? "string", value);
|
|
534
|
-
if ("error" in result) errors.push(result.error);
|
|
535
|
-
else inputs[name] = result.value;
|
|
536
|
-
}
|
|
537
|
-
return errors.length > 0 ? {
|
|
538
|
-
ok: false,
|
|
539
|
-
errors
|
|
540
|
-
} : {
|
|
541
|
-
ok: true,
|
|
542
|
-
inputs
|
|
543
441
|
};
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
if (at <= 0) return {
|
|
551
|
-
ok: false,
|
|
552
|
-
error: `expected name=value, got "${pair}"`
|
|
553
|
-
};
|
|
554
|
-
raw[pair.slice(0, at)] = pair.slice(at + 1);
|
|
555
|
-
}
|
|
556
|
-
return {
|
|
557
|
-
ok: true,
|
|
558
|
-
raw
|
|
442
|
+
/** Why `peer`'s run cannot take a message now, or null. */
|
|
443
|
+
const closed = (peer) => {
|
|
444
|
+
const [run] = store.lookup(peer.run);
|
|
445
|
+
if (run === void 0) return `run ${peer.run} is gone`;
|
|
446
|
+
const status = liveStatus(run);
|
|
447
|
+
return status === "running" ? null : `its run has finished (${status}). A finished run's session is not resumed by another agent; the user can resume it with \`ork chat\``;
|
|
559
448
|
};
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
/** Nodes whose last activation did not finish well, and its session. */
|
|
580
|
-
const unfinished = /* @__PURE__ */ new Map();
|
|
581
|
-
const inboxes = /* @__PURE__ */ new Map();
|
|
582
|
-
const waiting = /* @__PURE__ */ new Map();
|
|
583
|
-
let last = 0;
|
|
584
|
-
for (const event of events) {
|
|
585
|
-
if (event.type === "run.started") {
|
|
586
|
-
inputs = event.inputs ?? inputs;
|
|
587
|
-
settings = event.settings ?? settings;
|
|
588
|
-
continue;
|
|
589
|
-
}
|
|
590
|
-
if (event.type === "message.sent") {
|
|
591
|
-
last = Math.max(last, event.message);
|
|
592
|
-
const unread = inboxes.get(event.to) ?? [];
|
|
593
|
-
unread.push({
|
|
594
|
-
id: event.message,
|
|
595
|
-
from: event.node,
|
|
596
|
-
body: event.body
|
|
449
|
+
const isRunning = (peer) => {
|
|
450
|
+
const events = store.events(peer.run).map((stored) => stored.event);
|
|
451
|
+
return flattenTree(runTree(events)).some((n) => n.id === peer.node && n.state === "running");
|
|
452
|
+
};
|
|
453
|
+
return {
|
|
454
|
+
peers: (node) => store.links({
|
|
455
|
+
run: runId,
|
|
456
|
+
live: true
|
|
457
|
+
}).flatMap((link) => {
|
|
458
|
+
const peer = peerOf(link, runId, node);
|
|
459
|
+
return peer === void 0 ? [] : [addressOf(peer)];
|
|
460
|
+
}),
|
|
461
|
+
send: (node, to, body) => {
|
|
462
|
+
const link = store.links({
|
|
463
|
+
run: runId,
|
|
464
|
+
live: true
|
|
465
|
+
}).find((candidate) => {
|
|
466
|
+
const peer = peerOf(candidate, runId, node);
|
|
467
|
+
return peer !== void 0 && addressOf(peer) === to;
|
|
597
468
|
});
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
if (event.type === "node.started") {
|
|
611
|
-
waiting.delete(event.id);
|
|
612
|
-
if (event.owner === void 0) open.set(event.id, {});
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
if (event.type === "node.event") {
|
|
616
|
-
const inner = event.event;
|
|
617
|
-
const current = open.get(event.id);
|
|
618
|
-
if (current === void 0) continue;
|
|
619
|
-
if (inner.type === "session.started") current.sessionId = inner.sessionId;
|
|
620
|
-
if (inner.type === "session.finished" && inner.outcome.sessionId) current.sessionId = inner.outcome.sessionId;
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
if (event.type === "node.finished") {
|
|
624
|
-
const current = open.get(event.id);
|
|
625
|
-
open.delete(event.id);
|
|
626
|
-
if (current === void 0 || closed.has(event.id)) continue;
|
|
627
|
-
if (!event.ok) {
|
|
628
|
-
closed.add(event.id);
|
|
629
|
-
if (current.sessionId !== void 0) unfinished.set(event.id, current.sessionId);
|
|
630
|
-
continue;
|
|
469
|
+
const peer = link && peerOf(link, runId, node);
|
|
470
|
+
if (link === void 0 || peer === void 0) return {
|
|
471
|
+
ok: false,
|
|
472
|
+
error: `you have no live link to ${to}`
|
|
473
|
+
};
|
|
474
|
+
const why = closed(peer);
|
|
475
|
+
if (why !== null) {
|
|
476
|
+
endLink(store, link, why, home);
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
error: `${to} cannot take it: ${why}`
|
|
480
|
+
};
|
|
631
481
|
}
|
|
632
|
-
|
|
482
|
+
if (link.messages >= link.maxMessages) {
|
|
483
|
+
askToGoOn(link, node).catch(ignore);
|
|
484
|
+
return {
|
|
485
|
+
ok: false,
|
|
486
|
+
error: `this link has used all ${String(link.maxMessages)} of its messages, and the user has been asked whether to go on`
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
store.countMessage(link.id);
|
|
490
|
+
store.command(peer.run, {
|
|
491
|
+
kind: "relay",
|
|
492
|
+
node: peer.node,
|
|
493
|
+
from: `${runId}/${node}`,
|
|
494
|
+
body
|
|
495
|
+
});
|
|
496
|
+
wakeRun(socketPath(peer.run, home));
|
|
497
|
+
return {
|
|
633
498
|
ok: true,
|
|
634
|
-
|
|
499
|
+
delivered: isRunning(peer) ? "now" : "queued"
|
|
635
500
|
};
|
|
636
|
-
if (event.usage !== void 0) result.usage = event.usage;
|
|
637
|
-
if (current.sessionId !== void 0) result.sessionId = current.sessionId;
|
|
638
|
-
history.set(event.id, [...history.get(event.id) ?? [], result]);
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
if (event.type === "worktree") {
|
|
642
|
-
const known = worktrees.get(event.id);
|
|
643
|
-
if (event.action === "created" || known === void 0) worktrees.set(event.id, {
|
|
644
|
-
id: event.id,
|
|
645
|
-
path: event.path,
|
|
646
|
-
...event.branch === void 0 ? {} : { branch: event.branch },
|
|
647
|
-
merged: false,
|
|
648
|
-
removed: false
|
|
649
|
-
});
|
|
650
|
-
const entry = worktrees.get(event.id);
|
|
651
|
-
if (entry === void 0) continue;
|
|
652
|
-
if (event.branch !== void 0) branches.add(event.branch);
|
|
653
|
-
if (event.snapshot !== void 0) entry.snapshot = event.snapshot;
|
|
654
|
-
if (event.action === "merged") entry.merged = true;
|
|
655
|
-
if (event.action === "committed" || event.action === "conflict") entry.merged = false;
|
|
656
|
-
if (event.action === "removed") entry.removed = true;
|
|
657
|
-
continue;
|
|
658
|
-
}
|
|
659
|
-
if (event.type === "loop.exhausted") {
|
|
660
|
-
loopPending = true;
|
|
661
|
-
continue;
|
|
662
|
-
}
|
|
663
|
-
if (event.type === "decision.opened") {
|
|
664
|
-
if (loopPending) loopQuestions.set(event.decision, event.question);
|
|
665
|
-
loopPending = false;
|
|
666
|
-
continue;
|
|
667
|
-
}
|
|
668
|
-
if (event.type === "owner.asked") {
|
|
669
|
-
if (loopPending) loopQuestions.set(event.question, event.text);
|
|
670
|
-
loopPending = false;
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
if (event.type === "owner.unanswered") {
|
|
674
|
-
loopPending = true;
|
|
675
|
-
continue;
|
|
676
|
-
}
|
|
677
|
-
if (event.type === "decision.answered" || event.type === "owner.answered") {
|
|
678
|
-
const key = event.type === "owner.answered" ? event.question : event.decision;
|
|
679
|
-
const question = loopQuestions.get(key);
|
|
680
|
-
if (question !== void 0) loopAnswers.push({
|
|
681
|
-
question,
|
|
682
|
-
answer: event.answer
|
|
683
|
-
});
|
|
684
|
-
continue;
|
|
685
|
-
}
|
|
686
|
-
if (event.type === "run.finished") {
|
|
687
|
-
succeeded = event.result.status === "succeeded";
|
|
688
|
-
ended = event.result.status;
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
for (const [id, current] of open) {
|
|
693
|
-
closed.add(id);
|
|
694
|
-
if (current.sessionId !== void 0) unfinished.set(id, current.sessionId);
|
|
695
|
-
}
|
|
696
|
-
const cut = ended === void 0 || CUTTING.has(ended);
|
|
697
|
-
return {
|
|
698
|
-
history,
|
|
699
|
-
inputs,
|
|
700
|
-
settings,
|
|
701
|
-
worktrees: [...worktrees.values()].filter((w) => !w.removed).map(({ removed: _removed, ...w }) => w),
|
|
702
|
-
branches: [...branches],
|
|
703
|
-
loopAnswers,
|
|
704
|
-
succeeded,
|
|
705
|
-
cutOff: cut ? unfinished : /* @__PURE__ */ new Map(),
|
|
706
|
-
mail: {
|
|
707
|
-
inboxes,
|
|
708
|
-
waiting,
|
|
709
|
-
last
|
|
710
|
-
}
|
|
711
|
-
};
|
|
712
|
-
};
|
|
713
|
-
/** Whether an activation's input carries a result this run made anew. */
|
|
714
|
-
const carries = (fresh, input) => {
|
|
715
|
-
if (input === void 0) return false;
|
|
716
|
-
if (fresh.has(input)) return true;
|
|
717
|
-
const results = input["results"];
|
|
718
|
-
if (results === null || typeof results !== "object") return false;
|
|
719
|
-
return Object.values(results).some((value) => value !== null && typeof value === "object" && fresh.has(value));
|
|
720
|
-
};
|
|
721
|
-
/**
|
|
722
|
-
* Hands each node's recorded results back in order, until the node runs anew
|
|
723
|
-
* once (it failed or never finished last time, or its input is new); from then
|
|
724
|
-
* on it always runs. A node cut off mid-session continues that session, once.
|
|
725
|
-
*/
|
|
726
|
-
const createReplay = (history, cutOff = /* @__PURE__ */ new Map(), mail) => {
|
|
727
|
-
const sessions = new Map(cutOff);
|
|
728
|
-
const used = /* @__PURE__ */ new Map();
|
|
729
|
-
const anew = /* @__PURE__ */ new Set();
|
|
730
|
-
const fresh = /* @__PURE__ */ new WeakSet();
|
|
731
|
-
return {
|
|
732
|
-
take: (activation) => {
|
|
733
|
-
const { id } = activation;
|
|
734
|
-
if (activation.brief !== void 0 || anew.has(id)) return null;
|
|
735
|
-
const index = used.get(id) ?? 0;
|
|
736
|
-
const recorded = history.get(id)?.[index];
|
|
737
|
-
if (recorded === void 0 || carries(fresh, activation.input)) {
|
|
738
|
-
anew.add(id);
|
|
739
|
-
return null;
|
|
740
|
-
}
|
|
741
|
-
used.set(id, index + 1);
|
|
742
|
-
return recorded;
|
|
743
|
-
},
|
|
744
|
-
ran: (result) => {
|
|
745
|
-
fresh.add(result.output);
|
|
746
|
-
},
|
|
747
|
-
cutOff: (activation) => {
|
|
748
|
-
const session = activation.brief === void 0 ? sessions.get(activation.id) : void 0;
|
|
749
|
-
sessions.delete(activation.id);
|
|
750
|
-
return session;
|
|
751
|
-
},
|
|
752
|
-
kept: () => [...history.keys()],
|
|
753
|
-
...mail === void 0 ? {} : { mail }
|
|
754
|
-
};
|
|
755
|
-
};
|
|
756
|
-
/**
|
|
757
|
-
* The run's `HumanChannel`, answering questions about loops out of rounds as
|
|
758
|
-
* they were answered last time, in order; anything else goes to `human`.
|
|
759
|
-
*/
|
|
760
|
-
const replayLoopAnswers = (human, answers) => {
|
|
761
|
-
const left = [...answers];
|
|
762
|
-
return {
|
|
763
|
-
permit: (request) => human.permit(request),
|
|
764
|
-
ask: (question) => {
|
|
765
|
-
const index = left.findIndex((a) => a.question === question.question);
|
|
766
|
-
const [given] = index === -1 ? [] : left.splice(index, 1);
|
|
767
|
-
return given === void 0 ? human.ask(question) : Promise.resolve(given.answer);
|
|
768
501
|
}
|
|
769
502
|
};
|
|
770
503
|
};
|
|
@@ -793,606 +526,6 @@ const createRecorder = (store, run, runDir) => {
|
|
|
793
526
|
store.begin(run);
|
|
794
527
|
return appendRecorder(store, run.id, runDir);
|
|
795
528
|
};
|
|
796
|
-
// ../core/src/store/schema.ts
|
|
797
|
-
const MIGRATIONS = [
|
|
798
|
-
`CREATE TABLE runs (
|
|
799
|
-
id TEXT PRIMARY KEY,
|
|
800
|
-
workflow TEXT NOT NULL,
|
|
801
|
-
file TEXT NOT NULL,
|
|
802
|
-
project TEXT NOT NULL,
|
|
803
|
-
status TEXT NOT NULL,
|
|
804
|
-
pid INTEGER NOT NULL,
|
|
805
|
-
started_at TEXT NOT NULL,
|
|
806
|
-
finished_at TEXT,
|
|
807
|
-
cost_usd REAL NOT NULL DEFAULT 0
|
|
808
|
-
);
|
|
809
|
-
CREATE INDEX runs_by_project ON runs (project, started_at);
|
|
810
|
-
CREATE TABLE events (
|
|
811
|
-
run_id TEXT NOT NULL REFERENCES runs (id),
|
|
812
|
-
seq INTEGER NOT NULL,
|
|
813
|
-
at TEXT NOT NULL,
|
|
814
|
-
type TEXT NOT NULL,
|
|
815
|
-
node TEXT,
|
|
816
|
-
data TEXT NOT NULL,
|
|
817
|
-
PRIMARY KEY (run_id, seq)
|
|
818
|
-
) WITHOUT ROWID;`,
|
|
819
|
-
`CREATE TABLE decisions (
|
|
820
|
-
id TEXT PRIMARY KEY,
|
|
821
|
-
run_id TEXT NOT NULL REFERENCES runs (id),
|
|
822
|
-
node TEXT NOT NULL,
|
|
823
|
-
kind TEXT NOT NULL,
|
|
824
|
-
question TEXT NOT NULL,
|
|
825
|
-
options TEXT NOT NULL,
|
|
826
|
-
recommended TEXT,
|
|
827
|
-
context TEXT,
|
|
828
|
-
opened_at TEXT NOT NULL,
|
|
829
|
-
status TEXT NOT NULL,
|
|
830
|
-
answer TEXT,
|
|
831
|
-
answered_by TEXT,
|
|
832
|
-
answered_at TEXT
|
|
833
|
-
);
|
|
834
|
-
CREATE INDEX decisions_by_status ON decisions (status, run_id);
|
|
835
|
-
CREATE TABLE commands (
|
|
836
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
837
|
-
run_id TEXT NOT NULL REFERENCES runs (id),
|
|
838
|
-
kind TEXT NOT NULL,
|
|
839
|
-
at TEXT NOT NULL,
|
|
840
|
-
handled_at TEXT
|
|
841
|
-
);`,
|
|
842
|
-
`ALTER TABLE commands ADD COLUMN node TEXT;
|
|
843
|
-
ALTER TABLE commands ADD COLUMN body TEXT;`,
|
|
844
|
-
`CREATE TABLE feedback (
|
|
845
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
846
|
-
run_id TEXT NOT NULL REFERENCES runs (id),
|
|
847
|
-
seq INTEGER NOT NULL,
|
|
848
|
-
at TEXT NOT NULL,
|
|
849
|
-
node TEXT,
|
|
850
|
-
source TEXT NOT NULL,
|
|
851
|
-
kind TEXT NOT NULL,
|
|
852
|
-
target TEXT NOT NULL,
|
|
853
|
-
severity TEXT NOT NULL,
|
|
854
|
-
title TEXT NOT NULL,
|
|
855
|
-
body TEXT NOT NULL,
|
|
856
|
-
evidence TEXT NOT NULL,
|
|
857
|
-
code TEXT,
|
|
858
|
-
fingerprint TEXT NOT NULL,
|
|
859
|
-
workflow TEXT,
|
|
860
|
-
workflow_hash TEXT,
|
|
861
|
-
node_type TEXT,
|
|
862
|
-
model TEXT,
|
|
863
|
-
driver TEXT,
|
|
864
|
-
version TEXT,
|
|
865
|
-
feedback_version INTEGER NOT NULL
|
|
866
|
-
);
|
|
867
|
-
CREATE INDEX feedback_by_fingerprint ON feedback (fingerprint, at);
|
|
868
|
-
CREATE TABLE feedback_items (
|
|
869
|
-
fingerprint TEXT PRIMARY KEY,
|
|
870
|
-
status TEXT NOT NULL,
|
|
871
|
-
updated_at TEXT NOT NULL
|
|
872
|
-
);`,
|
|
873
|
-
`CREATE TABLE projects (
|
|
874
|
-
id TEXT PRIMARY KEY,
|
|
875
|
-
root TEXT NOT NULL UNIQUE,
|
|
876
|
-
mode TEXT NOT NULL,
|
|
877
|
-
dir TEXT NOT NULL,
|
|
878
|
-
registered_at TEXT NOT NULL
|
|
879
|
-
);`
|
|
880
|
-
];
|
|
881
|
-
/** A text column, or `""` when it is null. */
|
|
882
|
-
const text$1 = (row, key) => {
|
|
883
|
-
const value = row?.[key];
|
|
884
|
-
return typeof value === "string" ? value : "";
|
|
885
|
-
};
|
|
886
|
-
/** Applies the migrations the database has not had yet. */
|
|
887
|
-
const migrate$1 = (db) => {
|
|
888
|
-
const version = Number(db.prepare("PRAGMA user_version").get()?.["user_version"] ?? 0);
|
|
889
|
-
for (const [index, sql] of MIGRATIONS.entries()) {
|
|
890
|
-
if (index < version) continue;
|
|
891
|
-
db.exec("BEGIN");
|
|
892
|
-
db.exec(sql);
|
|
893
|
-
db.exec(`PRAGMA user_version = ${String(index + 1)}`);
|
|
894
|
-
db.exec("COMMIT");
|
|
895
|
-
}
|
|
896
|
-
};
|
|
897
|
-
// ../core/src/store/decisions.ts
|
|
898
|
-
const choicesOf = (json) => {
|
|
899
|
-
const parsed = JSON.parse(json);
|
|
900
|
-
if (!Array.isArray(parsed)) return [];
|
|
901
|
-
return parsed.flatMap((item) => {
|
|
902
|
-
if (item === null || typeof item !== "object" || !("id" in item)) return [];
|
|
903
|
-
const { id } = item;
|
|
904
|
-
if (typeof id !== "string") return [];
|
|
905
|
-
const choice = { id };
|
|
906
|
-
if ("label" in item && typeof item.label === "string") choice.label = item.label;
|
|
907
|
-
if ("description" in item && typeof item.description === "string") choice.description = item.description;
|
|
908
|
-
return [choice];
|
|
909
|
-
});
|
|
910
|
-
};
|
|
911
|
-
const kindOf = (value) => value === "permission" ? "permission" : "question";
|
|
912
|
-
const statusOf$1 = (value) => value === "answered" || value === "withdrawn" ? value : "open";
|
|
913
|
-
const decisionOf = (row) => {
|
|
914
|
-
const record = {
|
|
915
|
-
id: text$1(row, "id"),
|
|
916
|
-
runId: text$1(row, "run_id"),
|
|
917
|
-
node: text$1(row, "node"),
|
|
918
|
-
kind: kindOf(text$1(row, "kind")),
|
|
919
|
-
question: text$1(row, "question"),
|
|
920
|
-
options: choicesOf(text$1(row, "options") || "[]"),
|
|
921
|
-
openedAt: text$1(row, "opened_at"),
|
|
922
|
-
status: statusOf$1(text$1(row, "status"))
|
|
923
|
-
};
|
|
924
|
-
const optional = {
|
|
925
|
-
recommended: text$1(row, "recommended"),
|
|
926
|
-
context: text$1(row, "context"),
|
|
927
|
-
answer: text$1(row, "answer"),
|
|
928
|
-
answeredAt: text$1(row, "answered_at")
|
|
929
|
-
};
|
|
930
|
-
for (const [key, value] of Object.entries(optional)) if (value !== "") Object.assign(record, { [key]: value });
|
|
931
|
-
const by = text$1(row, "answered_by");
|
|
932
|
-
if (by === "terminal" || by === "decide") record.answeredBy = by;
|
|
933
|
-
return record;
|
|
934
|
-
};
|
|
935
|
-
const decisionQueries = (db) => {
|
|
936
|
-
const insert = db.prepare(`INSERT INTO decisions (id, run_id, node, kind, question, options, recommended, context, opened_at, status)
|
|
937
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open')`);
|
|
938
|
-
const answer = db.prepare(`UPDATE decisions SET status = 'answered', answer = ?, answered_by = ?, answered_at = ?
|
|
939
|
-
WHERE id = ? AND status = 'open'`);
|
|
940
|
-
const withdraw = db.prepare("UPDATE decisions SET status = 'withdrawn', answered_at = ? WHERE id = ? AND status = 'open'");
|
|
941
|
-
const byId = db.prepare("SELECT * FROM decisions WHERE id = ?");
|
|
942
|
-
const filtered = db.prepare(`SELECT d.* FROM decisions d JOIN runs r ON r.id = d.run_id
|
|
943
|
-
WHERE (?1 IS NULL OR d.run_id = ?1)
|
|
944
|
-
AND (?2 IS NULL OR r.project = ?2)
|
|
945
|
-
AND (?3 IS NULL OR d.status = ?3)
|
|
946
|
-
ORDER BY d.opened_at, d.rowid`);
|
|
947
|
-
const addCommand = db.prepare("INSERT INTO commands (run_id, kind, node, body, at) VALUES (?, ?, ?, ?, ?)");
|
|
948
|
-
const pending = db.prepare("SELECT id, kind, node, body FROM commands WHERE run_id = ? AND handled_at IS NULL ORDER BY id");
|
|
949
|
-
const handled = db.prepare("UPDATE commands SET handled_at = ? WHERE id = ?");
|
|
950
|
-
return {
|
|
951
|
-
openDecision: (decision) => {
|
|
952
|
-
const openedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
953
|
-
const masked = {
|
|
954
|
-
...decision,
|
|
955
|
-
question: maskSecrets(decision.question),
|
|
956
|
-
...decision.context === void 0 ? {} : { context: maskSecrets(decision.context) }
|
|
957
|
-
};
|
|
958
|
-
insert.run(masked.id, masked.runId, masked.node, masked.kind, masked.question, JSON.stringify(masked.options), masked.recommended ?? null, masked.context ?? null, openedAt);
|
|
959
|
-
return {
|
|
960
|
-
...masked,
|
|
961
|
-
openedAt,
|
|
962
|
-
status: "open"
|
|
963
|
-
};
|
|
964
|
-
},
|
|
965
|
-
answerDecision: (id, choice, by) => Number(answer.run(choice, by, (/* @__PURE__ */ new Date()).toISOString(), id).changes) === 1,
|
|
966
|
-
withdrawDecision: (id) => {
|
|
967
|
-
withdraw.run((/* @__PURE__ */ new Date()).toISOString(), id);
|
|
968
|
-
},
|
|
969
|
-
decision: (id) => {
|
|
970
|
-
const row = byId.get(id);
|
|
971
|
-
return row === void 0 ? void 0 : decisionOf(row);
|
|
972
|
-
},
|
|
973
|
-
decisions: (filter) => filtered.all(filter.runId ?? null, filter.project ?? null, filter.status ?? null).map((row) => decisionOf(row)),
|
|
974
|
-
command: (runId, command) => {
|
|
975
|
-
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
976
|
-
if (command.kind === "stop") addCommand.run(runId, "stop", null, null, at);
|
|
977
|
-
else addCommand.run(runId, "message", command.node, maskSecrets(command.body), at);
|
|
978
|
-
},
|
|
979
|
-
takeCommands: (runId) => {
|
|
980
|
-
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
981
|
-
return pending.all(runId).flatMap((row) => {
|
|
982
|
-
handled.run(at, Number(row["id"]));
|
|
983
|
-
const kind = text$1(row, "kind");
|
|
984
|
-
if (kind === "stop") return [{ kind }];
|
|
985
|
-
if (kind === "message") return [{
|
|
986
|
-
kind,
|
|
987
|
-
node: text$1(row, "node"),
|
|
988
|
-
body: text$1(row, "body")
|
|
989
|
-
}];
|
|
990
|
-
return [];
|
|
991
|
-
});
|
|
992
|
-
}
|
|
993
|
-
};
|
|
994
|
-
};
|
|
995
|
-
// ../core/src/store/feedback.ts
|
|
996
|
-
const STATUSES$1 = [
|
|
997
|
-
"new",
|
|
998
|
-
"triaged",
|
|
999
|
-
"accepted",
|
|
1000
|
-
"dismissed",
|
|
1001
|
-
"published",
|
|
1002
|
-
"resolved"
|
|
1003
|
-
];
|
|
1004
|
-
const SOURCES = [
|
|
1005
|
-
"system",
|
|
1006
|
-
"agent",
|
|
1007
|
-
"human"
|
|
1008
|
-
];
|
|
1009
|
-
/** `value` if it is one of `values`, else `fallback`. */
|
|
1010
|
-
const oneOf$1 = (values, value, fallback) => values.find((v) => v === value) ?? fallback;
|
|
1011
|
-
/** A JSON array column of strings, without nulls. */
|
|
1012
|
-
const strings = (row, key) => {
|
|
1013
|
-
const parsed = JSON.parse(text$1(row, key) || "[]");
|
|
1014
|
-
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string" && v !== "") : [];
|
|
1015
|
-
};
|
|
1016
|
-
const ORIGIN_COLUMNS = [
|
|
1017
|
-
["workflow", "workflow"],
|
|
1018
|
-
["workflowHash", "workflow_hash"],
|
|
1019
|
-
["nodeType", "node_type"],
|
|
1020
|
-
["model", "model"],
|
|
1021
|
-
["driver", "driver"],
|
|
1022
|
-
["version", "version"]
|
|
1023
|
-
];
|
|
1024
|
-
const recordOf$2 = (row) => {
|
|
1025
|
-
const origin = {};
|
|
1026
|
-
for (const [key, column] of ORIGIN_COLUMNS) {
|
|
1027
|
-
const value = text$1(row, column);
|
|
1028
|
-
if (value !== "") origin[key] = value;
|
|
1029
|
-
}
|
|
1030
|
-
const feedback = {
|
|
1031
|
-
source: oneOf$1(SOURCES, text$1(row, "source"), "system"),
|
|
1032
|
-
kind: oneOf$1(FEEDBACK_KINDS, text$1(row, "kind"), "idea"),
|
|
1033
|
-
target: oneOf$1(FEEDBACK_TARGETS, text$1(row, "target"), "workflow"),
|
|
1034
|
-
severity: oneOf$1(FEEDBACK_SEVERITIES, text$1(row, "severity"), "medium"),
|
|
1035
|
-
title: text$1(row, "title"),
|
|
1036
|
-
body: text$1(row, "body"),
|
|
1037
|
-
evidence: strings(row, "evidence"),
|
|
1038
|
-
fingerprint: text$1(row, "fingerprint"),
|
|
1039
|
-
origin
|
|
1040
|
-
};
|
|
1041
|
-
const code = text$1(row, "code");
|
|
1042
|
-
if (code !== "") feedback.code = code;
|
|
1043
|
-
const record = {
|
|
1044
|
-
id: Number(row?.["id"]),
|
|
1045
|
-
runId: text$1(row, "run_id"),
|
|
1046
|
-
seq: Number(row?.["seq"]),
|
|
1047
|
-
at: text$1(row, "at"),
|
|
1048
|
-
feedback
|
|
1049
|
-
};
|
|
1050
|
-
const node = text$1(row, "node");
|
|
1051
|
-
if (node !== "") record.node = node;
|
|
1052
|
-
return record;
|
|
1053
|
-
};
|
|
1054
|
-
const itemOf = (row) => {
|
|
1055
|
-
const severities = strings(row, "severities");
|
|
1056
|
-
return {
|
|
1057
|
-
id: text$1(row, "fingerprint"),
|
|
1058
|
-
status: oneOf$1(STATUSES$1, text$1(row, "status"), "new"),
|
|
1059
|
-
title: text$1(row, "title"),
|
|
1060
|
-
target: oneOf$1(FEEDBACK_TARGETS, text$1(row, "target"), "workflow"),
|
|
1061
|
-
kind: oneOf$1(FEEDBACK_KINDS, text$1(row, "kind"), "idea"),
|
|
1062
|
-
severity: FEEDBACK_SEVERITIES.toReversed().find((s) => severities.includes(s)) ?? "medium",
|
|
1063
|
-
sources: strings(row, "sources").flatMap((s) => SOURCES.filter((known) => known === s)),
|
|
1064
|
-
count: Number(row?.["count"]),
|
|
1065
|
-
firstSeen: text$1(row, "first_seen"),
|
|
1066
|
-
lastSeen: text$1(row, "last_seen"),
|
|
1067
|
-
runs: strings(row, "runs"),
|
|
1068
|
-
workflows: strings(row, "workflows"),
|
|
1069
|
-
models: strings(row, "models")
|
|
1070
|
-
};
|
|
1071
|
-
};
|
|
1072
|
-
const ITEMS = `SELECT f.fingerprint, i.status,
|
|
1073
|
-
(SELECT title FROM feedback WHERE fingerprint = f.fingerprint ORDER BY id LIMIT 1) AS title,
|
|
1074
|
-
MIN(f.target) AS target, MIN(f.kind) AS kind,
|
|
1075
|
-
json_group_array(DISTINCT f.severity) AS severities,
|
|
1076
|
-
json_group_array(DISTINCT f.source) AS sources,
|
|
1077
|
-
COUNT(*) AS count, MIN(f.at) AS first_seen, MAX(f.at) AS last_seen,
|
|
1078
|
-
json_group_array(DISTINCT f.run_id) AS runs,
|
|
1079
|
-
json_group_array(DISTINCT f.workflow) AS workflows,
|
|
1080
|
-
json_group_array(DISTINCT f.model) AS models
|
|
1081
|
-
FROM feedback f JOIN feedback_items i ON i.fingerprint = f.fingerprint`;
|
|
1082
|
-
const feedbackQueries = (db) => {
|
|
1083
|
-
const insert = db.prepare(`INSERT INTO feedback (run_id, seq, at, node, source, kind, target, severity,
|
|
1084
|
-
title, body, evidence, code, fingerprint, workflow, workflow_hash,
|
|
1085
|
-
node_type, model, driver, version, feedback_version)
|
|
1086
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
1087
|
-
const insertItem = db.prepare(`INSERT INTO feedback_items (fingerprint, status, updated_at)
|
|
1088
|
-
VALUES (?, 'new', ?) ON CONFLICT (fingerprint) DO NOTHING`);
|
|
1089
|
-
const shown = db.prepare(`${ITEMS} WHERE i.status != 'dismissed' GROUP BY f.fingerprint ORDER BY last_seen DESC`);
|
|
1090
|
-
const every = db.prepare(`${ITEMS} GROUP BY f.fingerprint ORDER BY last_seen DESC`);
|
|
1091
|
-
const byPrefix = db.prepare(`${ITEMS} WHERE substr(f.fingerprint, 1, ?) = ? GROUP BY f.fingerprint ORDER BY last_seen DESC`);
|
|
1092
|
-
const records = db.prepare("SELECT * FROM feedback WHERE fingerprint = ? ORDER BY id");
|
|
1093
|
-
const setStatus = db.prepare("UPDATE feedback_items SET status = ?, updated_at = ? WHERE fingerprint = ?");
|
|
1094
|
-
return {
|
|
1095
|
-
insertFeedback: (runId, seq, at, node, feedback) => {
|
|
1096
|
-
const { origin } = feedback;
|
|
1097
|
-
insert.run(runId, seq, at, node ?? null, feedback.source, feedback.kind, feedback.target, feedback.severity, maskSecrets(feedback.title), maskSecrets(feedback.body), JSON.stringify(feedback.evidence.map((e) => maskSecrets(e))), feedback.code ?? null, feedback.fingerprint, origin.workflow ?? null, origin.workflowHash ?? null, origin.nodeType ?? null, origin.model ?? null, origin.driver ?? null, origin.version ?? null, 1);
|
|
1098
|
-
insertItem.run(feedback.fingerprint, at);
|
|
1099
|
-
},
|
|
1100
|
-
feedbackItems: (filter = {}) => (filter.all === true ? every : shown).all().map((r) => itemOf(r)),
|
|
1101
|
-
findFeedback: (prefix) => byPrefix.all(prefix.length, prefix).map((r) => itemOf(r)),
|
|
1102
|
-
feedbackRecords: (fingerprint) => records.all(fingerprint).map((r) => recordOf$2(r)),
|
|
1103
|
-
setFeedbackStatus: (fingerprint, status) => Number(setStatus.run(status, (/* @__PURE__ */ new Date()).toISOString(), fingerprint).changes) > 0
|
|
1104
|
-
};
|
|
1105
|
-
};
|
|
1106
|
-
// ../core/src/store/projects.ts
|
|
1107
|
-
/**
|
|
1108
|
-
* @file The store's registered projects (ADR-035): which repositories Ork
|
|
1109
|
-
* knows, and where it keeps each one's files (in the repository, or in
|
|
1110
|
-
* `~/.ork/projects/<id>/` when attached), and moving it from one to the other
|
|
1111
|
-
* (`ork migrate`) with its runs' workflow files.
|
|
1112
|
-
*/
|
|
1113
|
-
const recordOf$1 = (row) => ({
|
|
1114
|
-
id: text$1(row, "id"),
|
|
1115
|
-
root: text$1(row, "root"),
|
|
1116
|
-
mode: PROJECT_MODES.find((m) => m === text$1(row, "mode")) ?? "in-repo",
|
|
1117
|
-
dir: text$1(row, "dir"),
|
|
1118
|
-
registeredAt: text$1(row, "registered_at")
|
|
1119
|
-
});
|
|
1120
|
-
const projectQueries = (db) => {
|
|
1121
|
-
const all = db.prepare("SELECT * FROM projects ORDER BY id");
|
|
1122
|
-
const insert = db.prepare(`INSERT INTO projects (id, root, mode, dir, registered_at)
|
|
1123
|
-
VALUES (?, ?, ?, ?, ?)`);
|
|
1124
|
-
const remove = db.prepare("DELETE FROM projects WHERE id = ?");
|
|
1125
|
-
const move = db.prepare("UPDATE projects SET root = ?, mode = ?, dir = ? WHERE id = ?");
|
|
1126
|
-
const rehome = db.prepare("UPDATE runs SET project = ? WHERE project = ?");
|
|
1127
|
-
const refile = db.prepare(`UPDATE runs SET file = ? || substr(file, ?)
|
|
1128
|
-
WHERE project = ? AND substr(file, 1, ?) = ?`);
|
|
1129
|
-
return {
|
|
1130
|
-
projects: () => all.all().map((r) => recordOf$1(r)),
|
|
1131
|
-
addProject: (project) => {
|
|
1132
|
-
const record = {
|
|
1133
|
-
...project,
|
|
1134
|
-
registeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1135
|
-
};
|
|
1136
|
-
insert.run(record.id, record.root, record.mode, record.dir, record.registeredAt);
|
|
1137
|
-
return record;
|
|
1138
|
-
},
|
|
1139
|
-
forgetProject: (id) => Number(remove.run(id).changes) > 0,
|
|
1140
|
-
moveProject: (id, place) => {
|
|
1141
|
-
const before = all.all().map((r) => recordOf$1(r)).find((p) => p.id === id);
|
|
1142
|
-
if (before === void 0) return false;
|
|
1143
|
-
const from = `${before.dir}${path.sep}`;
|
|
1144
|
-
const root = place.root ?? before.root;
|
|
1145
|
-
db.exec("BEGIN");
|
|
1146
|
-
try {
|
|
1147
|
-
move.run(root, place.mode, place.dir, id);
|
|
1148
|
-
rehome.run(root, before.root);
|
|
1149
|
-
refile.run(`${place.dir}${path.sep}`, from.length + 1, root, from.length, from);
|
|
1150
|
-
db.exec("COMMIT");
|
|
1151
|
-
} catch (error) {
|
|
1152
|
-
db.exec("ROLLBACK");
|
|
1153
|
-
throw error;
|
|
1154
|
-
}
|
|
1155
|
-
return true;
|
|
1156
|
-
}
|
|
1157
|
-
};
|
|
1158
|
-
};
|
|
1159
|
-
// ../core/src/store/queries.ts
|
|
1160
|
-
/** Every query set on `db`; `insertFeedback` is the store's own. */
|
|
1161
|
-
const storeQueries = (db) => ({
|
|
1162
|
-
...decisionQueries(db),
|
|
1163
|
-
...feedbackQueries(db),
|
|
1164
|
-
...projectQueries(db)
|
|
1165
|
-
});
|
|
1166
|
-
// ../core/src/store/sqlite.ts
|
|
1167
|
-
/**
|
|
1168
|
-
* @file The event store on `node:sqlite` (ADR-024): one database for every
|
|
1169
|
-
* project, `~/.ork/state.db`, in WAL mode so other `ork` processes read while
|
|
1170
|
-
* a run writes. Events are stored with secrets masked, feedback records
|
|
1171
|
-
* beside them, and the registered projects; the schema is versioned with
|
|
1172
|
-
* `user_version`.
|
|
1173
|
-
*/
|
|
1174
|
-
/** `~/.ork/state.db`; `home` is `~` (tests pass a folder). */
|
|
1175
|
-
const storePath = (home = homedir()) => path.join(home, ".ork", "state.db");
|
|
1176
|
-
const STATUSES = [
|
|
1177
|
-
"running",
|
|
1178
|
-
"succeeded",
|
|
1179
|
-
"failed",
|
|
1180
|
-
"budget_exhausted",
|
|
1181
|
-
"interrupted"
|
|
1182
|
-
];
|
|
1183
|
-
const statusOf = (value) => STATUSES.find((s) => s === value) ?? "failed";
|
|
1184
|
-
const recordOf = (row) => {
|
|
1185
|
-
const record = {
|
|
1186
|
-
id: text$1(row, "id"),
|
|
1187
|
-
workflow: text$1(row, "workflow"),
|
|
1188
|
-
file: text$1(row, "file"),
|
|
1189
|
-
project: text$1(row, "project"),
|
|
1190
|
-
status: statusOf(text$1(row, "status")),
|
|
1191
|
-
pid: Number(row?.["pid"]),
|
|
1192
|
-
startedAt: text$1(row, "started_at"),
|
|
1193
|
-
costUsd: Number(row?.["cost_usd"])
|
|
1194
|
-
};
|
|
1195
|
-
const finished = text$1(row, "finished_at");
|
|
1196
|
-
if (finished !== "") record.finishedAt = finished;
|
|
1197
|
-
return record;
|
|
1198
|
-
};
|
|
1199
|
-
/** An event as the store wrote it. */
|
|
1200
|
-
const eventOf = (data) => JSON.parse(data);
|
|
1201
|
-
/** The node an event is about, for filtering. */
|
|
1202
|
-
const nodeOf = (event) => {
|
|
1203
|
-
if ("id" in event && typeof event.id === "string") return event.id;
|
|
1204
|
-
if ("node" in event && typeof event.node === "string") return event.node;
|
|
1205
|
-
return null;
|
|
1206
|
-
};
|
|
1207
|
-
/** Opens (and creates) the store at `file`; `:memory:` for tests. */
|
|
1208
|
-
const openStore = (file = storePath()) => {
|
|
1209
|
-
if (file !== ":memory:") mkdirSync(path.dirname(file), { recursive: true });
|
|
1210
|
-
const db = new DatabaseSync(file);
|
|
1211
|
-
db.exec("PRAGMA journal_mode = WAL");
|
|
1212
|
-
db.exec("PRAGMA synchronous = NORMAL");
|
|
1213
|
-
db.exec("PRAGMA busy_timeout = 5000");
|
|
1214
|
-
db.exec("PRAGMA foreign_keys = ON");
|
|
1215
|
-
migrate$1(db);
|
|
1216
|
-
const insertRun = db.prepare(`INSERT INTO runs (id, workflow, file, project, status, pid, started_at)
|
|
1217
|
-
VALUES (?, ?, ?, ?, 'running', ?, ?)`);
|
|
1218
|
-
const lastSeq = db.prepare("SELECT COALESCE(MAX(seq), 0) AS seq FROM events WHERE run_id = ?");
|
|
1219
|
-
const insertEvent = db.prepare("INSERT INTO events (run_id, seq, at, type, node, data) VALUES (?, ?, ?, ?, ?, ?)");
|
|
1220
|
-
const finishRun = db.prepare("UPDATE runs SET status = ?, finished_at = ?, cost_usd = ? WHERE id = ?");
|
|
1221
|
-
const reopenRun = db.prepare("UPDATE runs SET status = 'running', pid = ?, finished_at = NULL WHERE id = ?");
|
|
1222
|
-
const addCost = db.prepare("UPDATE runs SET cost_usd = cost_usd + ? WHERE id = ?");
|
|
1223
|
-
const byId = db.prepare("SELECT * FROM runs WHERE id = ?");
|
|
1224
|
-
const byPrefix = db.prepare("SELECT * FROM runs WHERE substr(id, 1, ?) = ? ORDER BY started_at DESC, rowid DESC");
|
|
1225
|
-
const allRuns = db.prepare("SELECT * FROM runs ORDER BY started_at DESC, rowid DESC LIMIT ?");
|
|
1226
|
-
const projectRuns = db.prepare("SELECT * FROM runs WHERE project = ? ORDER BY started_at DESC, rowid DESC LIMIT ?");
|
|
1227
|
-
const eventsAfter = db.prepare("SELECT seq, at, data FROM events WHERE run_id = ? AND seq > ? ORDER BY seq");
|
|
1228
|
-
const { insertFeedback, ...queries } = storeQueries(db);
|
|
1229
|
-
return {
|
|
1230
|
-
...queries,
|
|
1231
|
-
begin: (run) => {
|
|
1232
|
-
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1233
|
-
insertRun.run(run.id, run.workflow, run.file, run.project, process.pid, startedAt);
|
|
1234
|
-
return {
|
|
1235
|
-
...run,
|
|
1236
|
-
status: "running",
|
|
1237
|
-
pid: process.pid,
|
|
1238
|
-
startedAt,
|
|
1239
|
-
costUsd: 0
|
|
1240
|
-
};
|
|
1241
|
-
},
|
|
1242
|
-
reopen: (runId) => {
|
|
1243
|
-
reopenRun.run(process.pid, runId);
|
|
1244
|
-
},
|
|
1245
|
-
append: (runId, event) => {
|
|
1246
|
-
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1247
|
-
const seq = Number(lastSeq.get(runId)?.["seq"] ?? 0) + 1;
|
|
1248
|
-
insertEvent.run(runId, seq, at, event.type, nodeOf(event), JSON.stringify(event, maskReplacer));
|
|
1249
|
-
if (event.type === "node.finished" && event.usage !== void 0) addCost.run(event.usage.costUsd, runId);
|
|
1250
|
-
if (event.type === "feedback.recorded") insertFeedback(runId, seq, at, event.node, event.feedback);
|
|
1251
|
-
if (event.type === "run.finished") finishRun.run(event.result.status, at, event.result.costUsd, runId);
|
|
1252
|
-
return {
|
|
1253
|
-
runId,
|
|
1254
|
-
seq,
|
|
1255
|
-
at,
|
|
1256
|
-
event
|
|
1257
|
-
};
|
|
1258
|
-
},
|
|
1259
|
-
runs: (filter = {}) => {
|
|
1260
|
-
const limit = filter.limit ?? -1;
|
|
1261
|
-
return (filter.project === void 0 ? allRuns.all(limit) : projectRuns.all(filter.project, limit)).map((r) => recordOf(r));
|
|
1262
|
-
},
|
|
1263
|
-
lookup: (prefix) => {
|
|
1264
|
-
const exact = byId.get(prefix);
|
|
1265
|
-
if (exact !== void 0) return [recordOf(exact)];
|
|
1266
|
-
return byPrefix.all(prefix.length, prefix).map((r) => recordOf(r));
|
|
1267
|
-
},
|
|
1268
|
-
events: (runId, after = 0) => eventsAfter.all(runId, after).map((r) => ({
|
|
1269
|
-
runId,
|
|
1270
|
-
seq: Number(r["seq"]),
|
|
1271
|
-
at: text$1(r, "at"),
|
|
1272
|
-
event: eventOf(text$1(r, "data"))
|
|
1273
|
-
})),
|
|
1274
|
-
close: () => {
|
|
1275
|
-
db.close();
|
|
1276
|
-
}
|
|
1277
|
-
};
|
|
1278
|
-
};
|
|
1279
|
-
// ../core/src/store/view.ts
|
|
1280
|
-
const blank = (id, type, parent) => {
|
|
1281
|
-
const node = {
|
|
1282
|
-
id,
|
|
1283
|
-
type,
|
|
1284
|
-
state: "waiting",
|
|
1285
|
-
runs: 0,
|
|
1286
|
-
costUsd: 0,
|
|
1287
|
-
inputTokens: 0,
|
|
1288
|
-
outputTokens: 0,
|
|
1289
|
-
children: []
|
|
1290
|
-
};
|
|
1291
|
-
if (parent !== void 0) node.parent = parent;
|
|
1292
|
-
return node;
|
|
1293
|
-
};
|
|
1294
|
-
/** A spawned worker's template: `<template>-<n>` of a declared node. */
|
|
1295
|
-
const templateOf$1 = (id, declared) => {
|
|
1296
|
-
const base = /^(?<base>.+)-\d+$/u.exec(id)?.groups?.["base"];
|
|
1297
|
-
return base !== void 0 && declared.has(base) ? base : void 0;
|
|
1298
|
-
};
|
|
1299
|
-
/** Folds a run's events into its ownership tree; roots in declared order. */
|
|
1300
|
-
const runTree = (events) => {
|
|
1301
|
-
const nodes = /* @__PURE__ */ new Map();
|
|
1302
|
-
const declared = /* @__PURE__ */ new Map();
|
|
1303
|
-
for (const event of events) if (event.type === "run.started") for (const info of event.nodes) {
|
|
1304
|
-
if (nodes.has(info.id)) continue;
|
|
1305
|
-
const node = blank(info.id, info.type, info.parent);
|
|
1306
|
-
nodes.set(info.id, node);
|
|
1307
|
-
declared.set(info.id, node);
|
|
1308
|
-
}
|
|
1309
|
-
else if (event.type === "node.started") {
|
|
1310
|
-
let node = nodes.get(event.id);
|
|
1311
|
-
if (node === void 0) {
|
|
1312
|
-
node = blank(event.id, event.nodeType, event.owner);
|
|
1313
|
-
const template = templateOf$1(event.id, declared);
|
|
1314
|
-
if (template !== void 0) node.template = template;
|
|
1315
|
-
nodes.set(event.id, node);
|
|
1316
|
-
}
|
|
1317
|
-
node.state = "running";
|
|
1318
|
-
node.runs += 1;
|
|
1319
|
-
} else if (event.type === "node.finished") {
|
|
1320
|
-
const node = nodes.get(event.id);
|
|
1321
|
-
if (node === void 0) continue;
|
|
1322
|
-
node.state = event.ok ? "ok" : "failed";
|
|
1323
|
-
node.output = event.output;
|
|
1324
|
-
node.costUsd += event.usage?.costUsd ?? 0;
|
|
1325
|
-
node.inputTokens += event.usage?.inputTokens ?? 0;
|
|
1326
|
-
node.outputTokens += event.usage?.outputTokens ?? 0;
|
|
1327
|
-
} else if (event.type === "node.skipped") {
|
|
1328
|
-
const node = nodes.get(event.id);
|
|
1329
|
-
if (node !== void 0) node.state = "skipped";
|
|
1330
|
-
}
|
|
1331
|
-
const roots = [];
|
|
1332
|
-
for (const node of nodes.values()) {
|
|
1333
|
-
const owner = node.parent === void 0 ? void 0 : nodes.get(node.parent);
|
|
1334
|
-
if (owner === void 0) roots.push(node);
|
|
1335
|
-
else owner.children.push(node);
|
|
1336
|
-
}
|
|
1337
|
-
return roots;
|
|
1338
|
-
};
|
|
1339
|
-
const alive = (pid) => {
|
|
1340
|
-
try {
|
|
1341
|
-
process.kill(pid, 0);
|
|
1342
|
-
return true;
|
|
1343
|
-
} catch (error) {
|
|
1344
|
-
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
1345
|
-
}
|
|
1346
|
-
};
|
|
1347
|
-
/** A run's status, with `crashed` for a `running` run whose process is gone. */
|
|
1348
|
-
const liveStatus = (run) => run.status === "running" && !alive(run.pid) ? "crashed" : run.status;
|
|
1349
|
-
// ../core/src/update/version.ts
|
|
1350
|
-
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
1351
|
-
/** `1.2.3`, `v1.2.3-beta.1` and the like; `null` for anything else. */
|
|
1352
|
-
const parseVersion = (text) => {
|
|
1353
|
-
const match = VERSION.exec(text.trim());
|
|
1354
|
-
if (match === null) return null;
|
|
1355
|
-
const [, major = "0", minor = "0", patch = "0", pre] = match;
|
|
1356
|
-
return {
|
|
1357
|
-
core: [
|
|
1358
|
-
Number(major),
|
|
1359
|
-
Number(minor),
|
|
1360
|
-
Number(patch)
|
|
1361
|
-
],
|
|
1362
|
-
pre: pre === void 0 ? [] : pre.split(".")
|
|
1363
|
-
};
|
|
1364
|
-
};
|
|
1365
|
-
const NUMERIC = /^\d+$/u;
|
|
1366
|
-
/** One pre-release identifier against another: numbers sort below words. */
|
|
1367
|
-
const compareIdentifier = (a, b) => {
|
|
1368
|
-
const an = NUMERIC.test(a);
|
|
1369
|
-
const bn = NUMERIC.test(b);
|
|
1370
|
-
if (an && bn) return Number(a) - Number(b);
|
|
1371
|
-
if (an !== bn) return an ? -1 : 1;
|
|
1372
|
-
return a < b ? -1 : Number(a > b);
|
|
1373
|
-
};
|
|
1374
|
-
const comparePre = (a, b) => {
|
|
1375
|
-
if (a.length === 0 || b.length === 0) return b.length - a.length;
|
|
1376
|
-
for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
|
|
1377
|
-
const order = compareIdentifier(a[i] ?? "", b[i] ?? "");
|
|
1378
|
-
if (order !== 0) return order;
|
|
1379
|
-
}
|
|
1380
|
-
return a.length - b.length;
|
|
1381
|
-
};
|
|
1382
|
-
/** Semver precedence: negative when `a` is older, 0 when equal. */
|
|
1383
|
-
const compareVersions = (a, b) => {
|
|
1384
|
-
for (let i = 0; i < 3; i += 1) {
|
|
1385
|
-
const order = (a.core[i] ?? 0) - (b.core[i] ?? 0);
|
|
1386
|
-
if (order !== 0) return order;
|
|
1387
|
-
}
|
|
1388
|
-
return comparePre(a.pre, b.pre);
|
|
1389
|
-
};
|
|
1390
|
-
/** Whether `candidate` is newer than `current`; false when either is invalid. */
|
|
1391
|
-
const isNewer = (candidate, current) => {
|
|
1392
|
-
const a = parseVersion(candidate);
|
|
1393
|
-
const b = parseVersion(current);
|
|
1394
|
-
return a !== null && b !== null && compareVersions(a, b) > 0;
|
|
1395
|
-
};
|
|
1396
529
|
const updateCachePath = (home = homedir()) => path.join(home, ".ork", "update-check.json");
|
|
1397
530
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1398
531
|
const text = (record, key) => {
|
|
@@ -1484,799 +617,80 @@ const pendingNotice = (cache, current, now) => {
|
|
|
1484
617
|
const told = cache?.notified;
|
|
1485
618
|
return told?.version === newer && age(told.at, now) < 864e5 ? null : newer;
|
|
1486
619
|
};
|
|
1487
|
-
//
|
|
620
|
+
// src/runs/chat.ts
|
|
1488
621
|
/**
|
|
1489
|
-
* @file
|
|
1490
|
-
*
|
|
1491
|
-
*
|
|
622
|
+
* @file `ork chat`'s two paths. A node of a live run gets the message through
|
|
623
|
+
* the store and the run's socket, and the run's bus pushes it into the node's
|
|
624
|
+
* session (specs/messaging.md#chat; `tellLiveRun` and `nodeSession` are in
|
|
625
|
+
* core). A node of a finished run gets it by resuming its last Claude session
|
|
626
|
+
* where it ran, and the exchange is added to the run's record.
|
|
1492
627
|
*/
|
|
628
|
+
/** The declared node a spawned worker (`<template>-<n>`) is an instance of. */
|
|
629
|
+
const templateOf = (nodes, id) => {
|
|
630
|
+
if (id in nodes) return void 0;
|
|
631
|
+
const template = id.replace(/-\d+$/u, "");
|
|
632
|
+
return template in nodes ? template : void 0;
|
|
633
|
+
};
|
|
1493
634
|
/**
|
|
1494
|
-
*
|
|
1495
|
-
*
|
|
635
|
+
* Resumes a finished node's last session with the user's message, prints the
|
|
636
|
+
* session, and adds it to the run's record. Returns why it could not.
|
|
1496
637
|
*/
|
|
1497
|
-
const
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
638
|
+
const resumeChat = async (options) => {
|
|
639
|
+
const { store, run, node } = options;
|
|
640
|
+
const { session, outputs } = nodeSession(store.events(run.id, 0), node);
|
|
641
|
+
if (!session.known) return `${node} is not a node of run ${run.id}.`;
|
|
642
|
+
if (!session.chattable) return `${node} is not a leader or worker; only they have sessions to talk to.`;
|
|
643
|
+
if (session.sessionId === void 0) return `${node} has no Claude session to resume in run ${run.id}.`;
|
|
644
|
+
const cwd = session.cwd ?? session.checkout?.path;
|
|
645
|
+
if (cwd === void 0 || !existsSync(cwd)) return `${node}'s session ran in ${cwd ?? "a folder Ork did not record"}, which is gone (a merged worktree is removed), so it cannot be resumed.`;
|
|
646
|
+
const { workflow, place, workflowDir } = loadWorkflow(run.file, path.dirname(run.file), void 0, store.projects());
|
|
647
|
+
if (workflow === null) return `The workflow ${run.file} is gone or no longer valid.`;
|
|
648
|
+
const notice = chatNotice(store, place, options.home);
|
|
649
|
+
if (notice !== null) options.out(notice);
|
|
650
|
+
const runDir = runDirOf(place, run.id);
|
|
651
|
+
const template = templateOf(workflow.nodes, node);
|
|
652
|
+
const plan = planNodeSession(workflow, node, {
|
|
653
|
+
cwd,
|
|
654
|
+
inputs: {},
|
|
655
|
+
outputs,
|
|
656
|
+
inPlace: session.checkout === void 0,
|
|
657
|
+
workflowDir,
|
|
658
|
+
projectRoot: run.project,
|
|
659
|
+
orkDir: place.dir,
|
|
660
|
+
pluginDir: path.join(runDir, "plugins", node),
|
|
661
|
+
...options.budgetLeftUsd === void 0 ? {} : { budgetLeftUsd: options.budgetLeftUsd },
|
|
662
|
+
...session.checkout === void 0 ? {} : { checkout: session.checkout },
|
|
663
|
+
...template === void 0 ? {} : { template }
|
|
664
|
+
});
|
|
665
|
+
if (!plan.ok) return plan.error;
|
|
666
|
+
const record = appendRecorder(store, run.id, runDir);
|
|
667
|
+
record({
|
|
668
|
+
type: "user.message",
|
|
669
|
+
node,
|
|
670
|
+
body: options.body,
|
|
671
|
+
delivered: "resumed"
|
|
672
|
+
});
|
|
673
|
+
const started = (await options.runtime(options.driver ?? session.driver ?? plan.driver)).start({
|
|
674
|
+
...plan.spec,
|
|
675
|
+
resume: session.sessionId,
|
|
676
|
+
prompt: options.body
|
|
677
|
+
}, { human: options.human });
|
|
678
|
+
if (options.signal?.aborted === true) started.interrupt();
|
|
679
|
+
options.signal?.addEventListener("abort", () => {
|
|
680
|
+
started.interrupt();
|
|
681
|
+
}, { once: true });
|
|
682
|
+
const tools = /* @__PURE__ */ new Map();
|
|
683
|
+
let failure = null;
|
|
684
|
+
for await (const event of started.events) {
|
|
685
|
+
record({
|
|
686
|
+
type: "node.event",
|
|
687
|
+
id: node,
|
|
688
|
+
event
|
|
1506
689
|
});
|
|
690
|
+
for (const line of formatEvent(node, event, tools)) options.out(line);
|
|
691
|
+
if (event.type === "session.finished" && event.outcome.subtype === "error_driver") failure = event.outcome.errors.join("; ");
|
|
1507
692
|
}
|
|
1508
|
-
return
|
|
1509
|
-
};
|
|
1510
|
-
const parsePorcelain = (text) => text.split("\n\n").map((block) => lines(block)).map((fields) => {
|
|
1511
|
-
const value = (key) => fields.find((f) => f.startsWith(`${key} `))?.slice(key.length + 1);
|
|
1512
|
-
return {
|
|
1513
|
-
path: value("worktree") ?? "",
|
|
1514
|
-
branch: (value("branch") ?? "").replace(/^refs\/heads\//u, ""),
|
|
1515
|
-
prunable: fields.some((f) => f.startsWith("prunable"))
|
|
1516
|
-
};
|
|
1517
|
-
}).filter((entry) => entry.path !== "");
|
|
1518
|
-
/** The `ork/<run>/<node>` branches of runs that recorded nothing. */
|
|
1519
|
-
const prefixed = (branch) => {
|
|
1520
|
-
const [, runId = "", ...rest] = branch.split("/");
|
|
1521
|
-
return {
|
|
1522
|
-
runId,
|
|
1523
|
-
nodeId: rest.join("/"),
|
|
1524
|
-
path: "",
|
|
1525
|
-
branch
|
|
1526
|
-
};
|
|
1527
|
-
};
|
|
1528
|
-
/** How many commits a detached worktree holds that no branch, tag or remote has. */
|
|
1529
|
-
const onNoBranch = async (entry) => entry === void 0 ? "0" : (await git(entry.path, [
|
|
1530
|
-
"rev-list",
|
|
1531
|
-
"--count",
|
|
1532
|
-
"HEAD",
|
|
1533
|
-
"--not",
|
|
1534
|
-
"--branches",
|
|
1535
|
-
"--tags",
|
|
1536
|
-
"--remotes"
|
|
1537
|
-
])).stdout;
|
|
1538
|
-
/** One created worktree or branch, with its state read from git. */
|
|
1539
|
-
const stateOf = async (root, entries, item) => {
|
|
1540
|
-
const { branch } = item;
|
|
1541
|
-
const entry = branch === void 0 ? entries.find((e) => e.path === item.path) : entries.find((e) => e.branch === branch);
|
|
1542
|
-
const missing = entry !== void 0 && (entry.prunable || !existsSync(entry.path));
|
|
1543
|
-
const status = entry === void 0 || missing ? "" : (await git(entry.path, ["status", "--porcelain"])).stdout;
|
|
1544
|
-
const checkedOut = branch === void 0 && entry !== void 0 && entry.branch !== "" ? entry.branch : void 0;
|
|
1545
|
-
const on = branch ?? checkedOut;
|
|
1546
|
-
const counted = on === void 0 ? await onNoBranch(entry === void 0 || missing ? void 0 : entry) : (await git(root, [
|
|
1547
|
-
"rev-list",
|
|
1548
|
-
"--count",
|
|
1549
|
-
`HEAD..${on}`
|
|
1550
|
-
])).stdout;
|
|
1551
|
-
const ahead = Number(counted);
|
|
1552
|
-
return {
|
|
1553
|
-
...branch === void 0 ? {} : { branch },
|
|
1554
|
-
...checkedOut === void 0 ? {} : { checkedOut },
|
|
1555
|
-
runId: item.runId,
|
|
1556
|
-
nodeId: item.nodeId,
|
|
1557
|
-
path: entry?.path ?? null,
|
|
1558
|
-
missing,
|
|
1559
|
-
dirty: lines(status).length > 0,
|
|
1560
|
-
ahead: Number.isNaN(ahead) ? 0 : ahead,
|
|
1561
|
-
merged: on !== void 0 && ahead === 0,
|
|
1562
|
-
inMainCheckout: entry?.path === root
|
|
1563
|
-
};
|
|
1564
|
-
};
|
|
1565
|
-
/**
|
|
1566
|
-
* Every worktree and branch Ork's runs created in the repository at `root`,
|
|
1567
|
-
* with its state: those the runs recorded (`created`, whatever the project's
|
|
1568
|
-
* git rules named them), and any `ork/` branch besides. A recorded one that git
|
|
1569
|
-
* no longer has is left out.
|
|
1570
|
-
*/
|
|
1571
|
-
const listOrkWorktrees = async (root, created = []) => {
|
|
1572
|
-
const entries = parsePorcelain(await gitOk(root, [
|
|
1573
|
-
"worktree",
|
|
1574
|
-
"list",
|
|
1575
|
-
"--porcelain"
|
|
1576
|
-
]));
|
|
1577
|
-
const branches = lines(await gitOk(root, [
|
|
1578
|
-
"for-each-ref",
|
|
1579
|
-
"--format=%(refname:short)",
|
|
1580
|
-
"refs/heads/"
|
|
1581
|
-
]));
|
|
1582
|
-
const recordedBranches = new Set(created.flatMap((c) => c.branch ?? []));
|
|
1583
|
-
const items = [...created.filter((c) => c.branch === void 0 ? entries.some((e) => e.path === c.path) : branches.includes(c.branch)), ...branches.filter((b) => b.startsWith("ork/") && !recordedBranches.has(b)).map((b) => prefixed(b))];
|
|
1584
|
-
return Promise.all(items.map((item) => stateOf(root, entries, item)));
|
|
1585
|
-
};
|
|
1586
|
-
const cleanStep = (item, options) => {
|
|
1587
|
-
if (item.inMainCheckout) return {
|
|
1588
|
-
item,
|
|
1589
|
-
removeWorktree: false,
|
|
1590
|
-
deleteBranch: false,
|
|
1591
|
-
skip: "checked out in the main checkout"
|
|
1592
|
-
};
|
|
1593
|
-
if (item.dirty && !options.force) return {
|
|
1594
|
-
item,
|
|
1595
|
-
removeWorktree: false,
|
|
1596
|
-
deleteBranch: false,
|
|
1597
|
-
skip: "uncommitted changes"
|
|
1598
|
-
};
|
|
1599
|
-
if (item.branch === void 0 && item.checkedOut === void 0 && item.ahead > 0 && !options.force) return {
|
|
1600
|
-
item,
|
|
1601
|
-
removeWorktree: false,
|
|
1602
|
-
deleteBranch: false,
|
|
1603
|
-
skip: "commits on no branch"
|
|
1604
|
-
};
|
|
1605
|
-
const removable = item.path !== null && !item.missing;
|
|
1606
|
-
if (options.branches && item.branch !== void 0 && !item.merged && !options.force) return {
|
|
1607
|
-
item,
|
|
1608
|
-
removeWorktree: removable,
|
|
1609
|
-
deleteBranch: false,
|
|
1610
|
-
skip: "branch not merged"
|
|
1611
|
-
};
|
|
1612
|
-
return {
|
|
1613
|
-
item,
|
|
1614
|
-
removeWorktree: removable,
|
|
1615
|
-
deleteBranch: options.branches && item.branch !== void 0
|
|
1616
|
-
};
|
|
1617
|
-
};
|
|
1618
|
-
/**
|
|
1619
|
-
* What `ork worktrees clean` would do. A worktree with uncommitted changes, an
|
|
1620
|
-
* unmerged branch, or a detached worktree holding commits no branch has, is
|
|
1621
|
-
* kept unless `force`.
|
|
1622
|
-
*/
|
|
1623
|
-
const planClean = (items, filter, options) => items.filter((item) => filter.runId === void 0 || item.runId === filter.runId).filter((item) => !filter.merged || item.merged).map((item) => cleanStep(item, options));
|
|
1624
|
-
/** Carries out a clean plan, then prunes stale worktree entries. */
|
|
1625
|
-
const applyClean = async (root, steps, force) => {
|
|
1626
|
-
const done = [];
|
|
1627
|
-
await gitOk(root, ["worktree", "prune"]);
|
|
1628
|
-
for (const step of steps) {
|
|
1629
|
-
const { item } = step;
|
|
1630
|
-
if (step.removeWorktree && item.path !== null) {
|
|
1631
|
-
await removeWorktree(root, item.path, force);
|
|
1632
|
-
done.push(`removed ${item.path}`);
|
|
1633
|
-
}
|
|
1634
|
-
if (step.deleteBranch && item.branch !== void 0) {
|
|
1635
|
-
await gitOk(root, [
|
|
1636
|
-
"branch",
|
|
1637
|
-
force ? "-D" : "-d",
|
|
1638
|
-
item.branch
|
|
1639
|
-
]);
|
|
1640
|
-
done.push(`deleted ${item.branch}`);
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
await gitOk(root, ["worktree", "prune"]);
|
|
1644
|
-
return done;
|
|
1645
|
-
};
|
|
1646
|
-
// src/output.ts
|
|
1647
|
-
/**
|
|
1648
|
-
* @file Terminal output helpers: styled text that turns plain when stdout is
|
|
1649
|
-
* not a TTY or `NO_COLOR` is set, and a yes-or-no question on the terminal.
|
|
1650
|
-
*/
|
|
1651
|
-
/** Styles text for stdout; plain when stdout is not a TTY or NO_COLOR is set. */
|
|
1652
|
-
const style = (format, text) => styleText(format, text, { stream: process.stdout });
|
|
1653
|
-
const write = (text) => {
|
|
1654
|
-
process.stdout.write(`${text}\n`);
|
|
1655
|
-
};
|
|
1656
|
-
const writeErr = (text) => {
|
|
1657
|
-
process.stderr.write(`${text}\n`);
|
|
1658
|
-
};
|
|
1659
|
-
/** Asks a yes-or-no question on the terminal; anything but yes is no. */
|
|
1660
|
-
const confirm = async (question) => {
|
|
1661
|
-
const rl = createInterface({
|
|
1662
|
-
input: process.stdin,
|
|
1663
|
-
output: process.stderr
|
|
1664
|
-
});
|
|
1665
|
-
try {
|
|
1666
|
-
const answer = await rl.question(question);
|
|
1667
|
-
return ["y", "yes"].includes(answer.trim().toLowerCase());
|
|
1668
|
-
} finally {
|
|
1669
|
-
rl.close();
|
|
1670
|
-
}
|
|
1671
|
-
};
|
|
1672
|
-
// src/human.ts
|
|
1673
|
-
/**
|
|
1674
|
-
* @file The terminal `HumanChannel`: answers `ask_user` questions, gates and
|
|
1675
|
-
* permission prompts on the terminal, one at a time, and denies or defers
|
|
1676
|
-
* when no one is there. In a run it is the decision queue's local answerer,
|
|
1677
|
-
* racing `ork decide`.
|
|
1678
|
-
*/
|
|
1679
|
-
const PREVIEW = 160;
|
|
1680
|
-
const NO_ONE_TO_PERMIT = "No one is at the terminal to approve this tool, so it is not allowed in this run.";
|
|
1681
|
-
const NO_ONE_TO_ASK = "no one (nobody is at the terminal; use your best judgment and say so in your report)";
|
|
1682
|
-
/** One line that shows what a tool is about to do. */
|
|
1683
|
-
const previewInput = (toolName, input) => {
|
|
1684
|
-
const key = [
|
|
1685
|
-
"command",
|
|
1686
|
-
"file_path",
|
|
1687
|
-
"path",
|
|
1688
|
-
"url",
|
|
1689
|
-
"pattern"
|
|
1690
|
-
].find((k) => typeof input[k] === "string");
|
|
1691
|
-
const line = (key === void 0 ? JSON.stringify(input) : `${key}: ${String(input[key])}`).replaceAll(/\s+/gu, " ").trim();
|
|
1692
|
-
return line.length > PREVIEW ? `${toolName} ${line.slice(0, 159)}…` : `${toolName} ${line}`;
|
|
1693
|
-
};
|
|
1694
|
-
/** The option an answer names: its number, or its id. */
|
|
1695
|
-
const pickOption = (answer, options, recommended) => {
|
|
1696
|
-
const value = answer.trim();
|
|
1697
|
-
if (value === "") return recommended;
|
|
1698
|
-
const index = Number(value);
|
|
1699
|
-
if (Number.isInteger(index) && index >= 1 && index <= options.length) return options[index - 1]?.id;
|
|
1700
|
-
return options.find((o) => o.id === value)?.id;
|
|
1701
|
-
};
|
|
1702
|
-
const optionLine = (option, index, recommended) => {
|
|
1703
|
-
const label = option.label === void 0 ? "" : ` ${option.label}`;
|
|
1704
|
-
const description = option.description === void 0 ? "" : style("dim", ` - ${option.description}`);
|
|
1705
|
-
const mark = option.id === recommended ? style("green", " (recommended)") : "";
|
|
1706
|
-
return ` ${String(index + 1)}) ${style("bold", option.id)}${label}${description}${mark}`;
|
|
1707
|
-
};
|
|
1708
|
-
/**
|
|
1709
|
-
* Answers permission prompts and `ask_user` questions on the terminal, one at a
|
|
1710
|
-
* time. Without a terminal it denies tools and leaves questions to the node. A
|
|
1711
|
-
* withdrawn prompt (answered elsewhere) rejects, which frees the turn.
|
|
1712
|
-
*/
|
|
1713
|
-
const createTerminalHuman = (io) => {
|
|
1714
|
-
let rl;
|
|
1715
|
-
let turn = Promise.resolve();
|
|
1716
|
-
const say = (text) => {
|
|
1717
|
-
io.output.write(`${text}\n`);
|
|
1718
|
-
};
|
|
1719
|
-
const prompt = (text, signal) => {
|
|
1720
|
-
rl ??= createInterface({
|
|
1721
|
-
input: io.input,
|
|
1722
|
-
output: io.output
|
|
1723
|
-
});
|
|
1724
|
-
return signal === void 0 ? rl.question(text) : rl.question(text, { signal });
|
|
1725
|
-
};
|
|
1726
|
-
const choose = async (hint, options, recommended, signal) => {
|
|
1727
|
-
const picked = pickOption(await prompt(` Choose${hint}: `, signal), options, recommended);
|
|
1728
|
-
if (picked !== void 0) return picked;
|
|
1729
|
-
say(style("dim", " Type an option's number or id."));
|
|
1730
|
-
return choose(hint, options, recommended, signal);
|
|
1731
|
-
};
|
|
1732
|
-
const oneAtATime = (work) => {
|
|
1733
|
-
const result = turn.then(work);
|
|
1734
|
-
turn = result.catch(() => null);
|
|
1735
|
-
return result;
|
|
1736
|
-
};
|
|
1737
|
-
return {
|
|
1738
|
-
permit: ({ nodeId, toolName, input, signal }) => oneAtATime(async () => {
|
|
1739
|
-
if (signal?.aborted === true) throw new Error("the prompt was withdrawn");
|
|
1740
|
-
say(`${style(["yellow", "bold"], "?")} ${style("bold", nodeId)} wants to run ${previewInput(toolName, input)}`);
|
|
1741
|
-
if (!io.interactive) {
|
|
1742
|
-
say(style("dim", ` denied: ${NO_ONE_TO_PERMIT}`));
|
|
1743
|
-
return {
|
|
1744
|
-
allow: false,
|
|
1745
|
-
message: NO_ONE_TO_PERMIT
|
|
1746
|
-
};
|
|
1747
|
-
}
|
|
1748
|
-
const answer = (await prompt(" Allow? [y/N] ", signal)).trim().toLowerCase();
|
|
1749
|
-
return answer === "y" || answer === "yes" ? { allow: true } : {
|
|
1750
|
-
allow: false,
|
|
1751
|
-
message: "The user did not allow this tool."
|
|
1752
|
-
};
|
|
1753
|
-
}),
|
|
1754
|
-
ask: ({ nodeId, question, options, recommended, context, signal }) => oneAtATime(() => {
|
|
1755
|
-
if (signal?.aborted === true) return Promise.reject(/* @__PURE__ */ new Error("the question was withdrawn"));
|
|
1756
|
-
say(`${style(["yellow", "bold"], "?")} ${style("bold", nodeId)} asks: ${question}`);
|
|
1757
|
-
if (context !== void 0) say(style("dim", ` ${context}`));
|
|
1758
|
-
for (const [index, option] of options.entries()) say(optionLine(option, index, recommended));
|
|
1759
|
-
if (!io.interactive) {
|
|
1760
|
-
const answer = recommended ?? NO_ONE_TO_ASK;
|
|
1761
|
-
say(style("dim", ` no terminal; answered ${answer}`));
|
|
1762
|
-
return Promise.resolve(answer);
|
|
1763
|
-
}
|
|
1764
|
-
const fallback = options.findIndex((o) => o.id === recommended);
|
|
1765
|
-
const hint = fallback === -1 ? "" : ` [${String(fallback + 1)}]`;
|
|
1766
|
-
return choose(hint, options, recommended, signal);
|
|
1767
|
-
}),
|
|
1768
|
-
close: () => {
|
|
1769
|
-
rl?.close();
|
|
1770
|
-
}
|
|
1771
|
-
};
|
|
1772
|
-
};
|
|
1773
|
-
// src/project/place.ts
|
|
1774
|
-
/**
|
|
1775
|
-
* @file Which project a folder belongs to, from the projects the store has
|
|
1776
|
-
* registered (ADR-035): an attached project's files are in
|
|
1777
|
-
* `~/.ork/projects/<id>/`, an in-repo one's in its `.ork/`.
|
|
1778
|
-
*/
|
|
1779
|
-
/** The registered projects; none before Ork has a store (never creates one). */
|
|
1780
|
-
const registeredProjects = (home) => {
|
|
1781
|
-
const file = storePath(home);
|
|
1782
|
-
if (!existsSync(file)) return [];
|
|
1783
|
-
const store = openStore(file);
|
|
1784
|
-
try {
|
|
1785
|
-
return store.projects();
|
|
1786
|
-
} finally {
|
|
1787
|
-
store.close();
|
|
1788
|
-
}
|
|
1789
|
-
};
|
|
1790
|
-
/** The project `dir` is in: registered, or the in-repo one at its git root. */
|
|
1791
|
-
const projectAt = (dir, home) => placeFor(dir, registeredProjects(home));
|
|
1792
|
-
/** A path to show: relative to `cwd` when inside it, else as it is. */
|
|
1793
|
-
const fromCwd = (target, cwd) => inside(cwd, target) ? path.relative(cwd, target) || "." : target;
|
|
1794
|
-
// src/workflows.ts
|
|
1795
|
-
/**
|
|
1796
|
-
* @file Finding workflow files: the project's workflows folder by default
|
|
1797
|
-
* (`.ork/workflows/`, or an attached project's), directories expanded, and a
|
|
1798
|
-
* workflow given by name.
|
|
1799
|
-
*/
|
|
1800
|
-
const WORKFLOW_DIR = path.join(".ork", "workflows");
|
|
1801
|
-
const WORKFLOW_SUFFIX = ".ork.yaml";
|
|
1802
|
-
/**
|
|
1803
|
-
* Expands the given paths to workflow files: a directory contributes its
|
|
1804
|
-
* `*.ork.yaml` files, and a name one in the project's workflows folder
|
|
1805
|
-
* (`workflows`, default `.ork/workflows/` under `cwd`). With no paths, uses
|
|
1806
|
-
* that folder.
|
|
1807
|
-
*/
|
|
1808
|
-
const findWorkflowFiles = (paths, cwd, workflows = path.join(cwd, WORKFLOW_DIR)) => {
|
|
1809
|
-
return (paths.length > 0 ? paths : [fromCwd(workflows, cwd)]).flatMap((target) => {
|
|
1810
|
-
const absolute = path.resolve(cwd, target);
|
|
1811
|
-
if (existsSync(absolute) && statSync(absolute).isDirectory()) return readdirSync(absolute).filter((name) => name.endsWith(WORKFLOW_SUFFIX)).toSorted().map((name) => path.join(target, name));
|
|
1812
|
-
return [resolveWorkflowArg(target, cwd, workflows)];
|
|
1813
|
-
});
|
|
1814
|
-
};
|
|
1815
|
-
/**
|
|
1816
|
-
* A workflow given by path, or by name for one in the project's workflows
|
|
1817
|
-
* folder: `hello` is `.ork/workflows/hello.ork.yaml` when no file `hello`
|
|
1818
|
-
* exists.
|
|
1819
|
-
*/
|
|
1820
|
-
const resolveWorkflowArg = (arg, cwd, workflows = path.join(cwd, WORKFLOW_DIR)) => {
|
|
1821
|
-
if (existsSync(path.resolve(cwd, arg)) || arg.endsWith(".ork.yaml")) return arg;
|
|
1822
|
-
const named = path.join(workflows, `${arg}${WORKFLOW_SUFFIX}`);
|
|
1823
|
-
return existsSync(named) ? fromCwd(named, cwd) : arg;
|
|
1824
|
-
};
|
|
1825
|
-
/**
|
|
1826
|
-
* A command's workflow argument, by path or by name in the project `cwd` is in,
|
|
1827
|
-
* with the project the file belongs to and a run's folder there.
|
|
1828
|
-
*/
|
|
1829
|
-
const workflowArg = (arg, cwd) => {
|
|
1830
|
-
const file = resolveWorkflowArg(arg, cwd, placeFiles(projectAt(cwd)).workflows);
|
|
1831
|
-
const place = projectAt(path.dirname(path.resolve(cwd, file)));
|
|
1832
|
-
return {
|
|
1833
|
-
file,
|
|
1834
|
-
place,
|
|
1835
|
-
runDir: (runId) => runDirOf(place, runId)
|
|
1836
|
-
};
|
|
1837
|
-
};
|
|
1838
|
-
// src/commands/validate.ts
|
|
1839
|
-
/**
|
|
1840
|
-
* @file `ork validate`: loads, parses and compiles workflow files with the
|
|
1841
|
-
* project's rules (`.ork/project.yaml`) and the agents they name, checks
|
|
1842
|
-
* their skill and context references, and reports diagnostics compiler-style,
|
|
1843
|
-
* with file, line and column. `loadWorkflow` is shared with `ork run`.
|
|
1844
|
-
*/
|
|
1845
|
-
const hasErrors = (diagnostics) => diagnostics.some((d) => d.severity === "error");
|
|
1846
|
-
/**
|
|
1847
|
-
* Parses and compiles one workflow file, with the agents the user and its
|
|
1848
|
-
* project offer. `home` is the user's `~` (tests pass a folder); `registered`
|
|
1849
|
-
* the projects a caller's store has, else those of the store in `home`.
|
|
1850
|
-
*/
|
|
1851
|
-
const loadWorkflow = (file, cwd, home, registered = registeredProjects(home)) => {
|
|
1852
|
-
const absolute = path.resolve(cwd, file);
|
|
1853
|
-
const place = placeFor(path.dirname(absolute), registered);
|
|
1854
|
-
const workflowDir = workflowDirOf(place, absolute);
|
|
1855
|
-
const where = {
|
|
1856
|
-
place,
|
|
1857
|
-
workflowDir
|
|
1858
|
-
};
|
|
1859
|
-
let text;
|
|
1860
|
-
try {
|
|
1861
|
-
text = readFileSync(path.resolve(cwd, file), "utf8");
|
|
1862
|
-
} catch (error) {
|
|
1863
|
-
return {
|
|
1864
|
-
report: {
|
|
1865
|
-
file,
|
|
1866
|
-
diagnostics: [{
|
|
1867
|
-
severity: "error",
|
|
1868
|
-
code: "file.read",
|
|
1869
|
-
message: error instanceof Error && "code" in error && error.code === "ENOENT" ? "file not found" : "cannot read file",
|
|
1870
|
-
path: []
|
|
1871
|
-
}]
|
|
1872
|
-
},
|
|
1873
|
-
workflow: null,
|
|
1874
|
-
graph: null,
|
|
1875
|
-
...where
|
|
1876
|
-
};
|
|
1877
|
-
}
|
|
1878
|
-
const parsed = parseWorkflow(text);
|
|
1879
|
-
if (!parsed.ok) return {
|
|
1880
|
-
report: {
|
|
1881
|
-
file,
|
|
1882
|
-
diagnostics: parsed.diagnostics
|
|
1883
|
-
},
|
|
1884
|
-
workflow: null,
|
|
1885
|
-
graph: null,
|
|
1886
|
-
...where
|
|
1887
|
-
};
|
|
1888
|
-
const projectRoot = place.root;
|
|
1889
|
-
const catalog = loadAgents({
|
|
1890
|
-
projectRoot,
|
|
1891
|
-
orkDir: place.dir,
|
|
1892
|
-
...home === void 0 ? {} : { home }
|
|
1893
|
-
});
|
|
1894
|
-
const loaded = loadProject(projectRoot, placeFiles(place).rules);
|
|
1895
|
-
const compiled = compileWorkflow(parsed.workflow, {
|
|
1896
|
-
catalog,
|
|
1897
|
-
workflowDir,
|
|
1898
|
-
projectRoot,
|
|
1899
|
-
...loaded.project === void 0 ? {} : { project: loaded.project }
|
|
1900
|
-
});
|
|
1901
|
-
const { graph } = compiled;
|
|
1902
|
-
const diagnostics = [
|
|
1903
|
-
...loaded.diagnostics,
|
|
1904
|
-
...compiled.diagnostics,
|
|
1905
|
-
...graph === null ? [] : contextDiagnostics(graph.workflow, {
|
|
1906
|
-
workflowDir,
|
|
1907
|
-
projectRoot
|
|
1908
|
-
})
|
|
1909
|
-
];
|
|
1910
|
-
const located = [];
|
|
1911
|
-
for (const diagnostic of diagnostics) {
|
|
1912
|
-
const position = parsed.locate(diagnostic.path);
|
|
1913
|
-
located.push(position === null ? diagnostic : {
|
|
1914
|
-
...diagnostic,
|
|
1915
|
-
...position
|
|
1916
|
-
});
|
|
1917
|
-
}
|
|
1918
|
-
if (graph === null) return {
|
|
1919
|
-
report: {
|
|
1920
|
-
file,
|
|
1921
|
-
diagnostics: located
|
|
1922
|
-
},
|
|
1923
|
-
workflow: null,
|
|
1924
|
-
graph: null,
|
|
1925
|
-
...where
|
|
1926
|
-
};
|
|
1927
|
-
return {
|
|
1928
|
-
report: {
|
|
1929
|
-
file,
|
|
1930
|
-
diagnostics: located,
|
|
1931
|
-
summary: {
|
|
1932
|
-
nodes: graph.nodes.size,
|
|
1933
|
-
edges: graph.edges.length,
|
|
1934
|
-
entries: graph.entries
|
|
1935
|
-
}
|
|
1936
|
-
},
|
|
1937
|
-
workflow: hasErrors(located) ? null : graph.workflow,
|
|
1938
|
-
graph: hasErrors(located) ? null : graph,
|
|
1939
|
-
...where
|
|
1940
|
-
};
|
|
1941
|
-
};
|
|
1942
|
-
const validateFile = (file, cwd) => loadWorkflow(file, cwd).report;
|
|
1943
|
-
const plural$1 = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
1944
|
-
/** Human-readable report, one line per diagnostic, compiler style. */
|
|
1945
|
-
const formatReport = (report) => {
|
|
1946
|
-
const lines = report.diagnostics.map((d) => {
|
|
1947
|
-
const where = d.line === void 0 ? report.file : `${report.file}:${d.line}:${d.column ?? 1}`;
|
|
1948
|
-
const severity = d.severity === "error" ? style(["red", "bold"], "error") : style(["yellow", "bold"], "warning");
|
|
1949
|
-
return `${style("dim", where)} ${severity} ${d.message} ${style("dim", `(${d.code})`)}`;
|
|
1950
|
-
});
|
|
1951
|
-
const errors = report.diagnostics.filter((d) => d.severity === "error").length;
|
|
1952
|
-
const warnings = report.diagnostics.length - errors;
|
|
1953
|
-
if (report.summary !== void 0 && errors === 0) {
|
|
1954
|
-
const { nodes, edges, entries } = report.summary;
|
|
1955
|
-
const extra = warnings > 0 ? `, ${plural$1(warnings, "warning")}` : "";
|
|
1956
|
-
lines.push(`${style("green", "✓")} ${report.file} ${style("dim", `(${plural$1(nodes, "node")}, ${plural$1(edges, "edge")}, starts at ${entries.join(", ")}${extra})`)}`);
|
|
1957
|
-
} else lines.push(`${style("red", "✗")} ${report.file} ${style("dim", `(${plural$1(errors, "error")}, ${plural$1(warnings, "warning")})`)}`);
|
|
1958
|
-
return lines;
|
|
1959
|
-
};
|
|
1960
|
-
const validate = defineCommand({
|
|
1961
|
-
meta: {
|
|
1962
|
-
name: "validate",
|
|
1963
|
-
description: "Check workflow files: schema, ownership tree, edges, loops, and references"
|
|
1964
|
-
},
|
|
1965
|
-
args: { json: {
|
|
1966
|
-
type: "boolean",
|
|
1967
|
-
description: "Print the reports as JSON",
|
|
1968
|
-
default: false
|
|
1969
|
-
} },
|
|
1970
|
-
run: ({ args }) => {
|
|
1971
|
-
const cwd = process.cwd();
|
|
1972
|
-
const files = findWorkflowFiles(args._, cwd, placeFiles(projectAt(cwd)).workflows);
|
|
1973
|
-
if (files.length === 0) {
|
|
1974
|
-
write("No workflow files found. Run `ork init`, or pass a path.");
|
|
1975
|
-
process.exitCode = 1;
|
|
1976
|
-
return;
|
|
1977
|
-
}
|
|
1978
|
-
const reports = files.map((file) => validateFile(file, cwd));
|
|
1979
|
-
if (args.json) write(JSON.stringify(reports, void 0, 2));
|
|
1980
|
-
else for (const report of reports) for (const line of formatReport(report)) write(line);
|
|
1981
|
-
if (reports.some((r) => hasErrors(r.diagnostics))) process.exitCode = 1;
|
|
1982
|
-
}
|
|
1983
|
-
});
|
|
1984
|
-
// src/run/format.ts
|
|
1985
|
-
const STATUS_MARK = {
|
|
1986
|
-
pass: style("green", "✓"),
|
|
1987
|
-
done: style("green", "✓"),
|
|
1988
|
-
changes_requested: style("yellow", "!"),
|
|
1989
|
-
fail: style("red", "✗")
|
|
1990
|
-
};
|
|
1991
|
-
const seconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
1992
|
-
const indent = (text) => text.trimEnd().split("\n").map((line) => ` ${line}`);
|
|
1993
|
-
/** The usage line: cost, tokens, turns and time. */
|
|
1994
|
-
const formatUsage = ({ usage }) => style("dim", [
|
|
1995
|
-
`$${usage.costUsd.toFixed(4)}`,
|
|
1996
|
-
`${String(usage.inputTokens)} in / ${String(usage.outputTokens)} out (${String(usage.cacheReadTokens)} cached)`,
|
|
1997
|
-
`${String(usage.turns)} turns`,
|
|
1998
|
-
seconds(usage.durationMs)
|
|
1999
|
-
].join(" · "));
|
|
2000
|
-
/** How a finished session reads on the terminal. */
|
|
2001
|
-
const formatOutcome = (nodeId, outcome) => {
|
|
2002
|
-
const { report } = outcome;
|
|
2003
|
-
if (report === null) return [
|
|
2004
|
-
`${style("red", "✗")} ${style("bold", nodeId)} ended without a report (${outcome.subtype})`,
|
|
2005
|
-
...outcome.errors.map((e) => ` ${style("red", e)}`),
|
|
2006
|
-
` ${formatUsage(outcome)}`
|
|
2007
|
-
];
|
|
2008
|
-
const lines = [`${STATUS_MARK[report.status] ?? "-"} ${style("bold", nodeId)} ${report.status}: ${report.summary}`];
|
|
2009
|
-
if (report.branch !== void 0) lines.push(` branch: ${report.branch}`);
|
|
2010
|
-
for (const [name, file] of Object.entries(report.artifacts)) lines.push(` ${name}: ${file}`);
|
|
2011
|
-
for (const [name, value] of Object.entries(report.fields)) lines.push(` ${name}: ${JSON.stringify(value)}`);
|
|
2012
|
-
lines.push(` ${formatUsage(outcome)}`);
|
|
2013
|
-
return lines;
|
|
2014
|
-
};
|
|
2015
|
-
/** The transcript lines for one event; tool names come from `tools`. */
|
|
2016
|
-
const formatEvent = (nodeId, event, tools) => {
|
|
2017
|
-
if (event.type === "session.started") return [style("dim", `▸ ${nodeId} (${event.model}, session ${event.sessionId})`)];
|
|
2018
|
-
if (event.type === "assistant.text") return indent(event.text);
|
|
2019
|
-
if (event.type === "tool.called") {
|
|
2020
|
-
tools.set(event.id, event.name);
|
|
2021
|
-
return [style("dim", ` · ${previewInput(event.name, event.input)}`)];
|
|
2022
|
-
}
|
|
2023
|
-
if (event.type === "tool.result") return event.isError ? [style("red", ` ✗ ${tools.get(event.id) ?? "tool"} failed`)] : [];
|
|
2024
|
-
return formatOutcome(nodeId, event.outcome);
|
|
2025
|
-
};
|
|
2026
|
-
/** A one-line verdict for a finished node, by what its type outputs. */
|
|
2027
|
-
const nodeVerdict = (output) => {
|
|
2028
|
-
const { status, summary, exitCode, choice, answeredBy, count } = output;
|
|
2029
|
-
if (typeof status === "string") return typeof summary === "string" && summary !== "" ? `${status}: ${summary}` : status;
|
|
2030
|
-
if (typeof exitCode === "number") return `exit ${String(exitCode)}`;
|
|
2031
|
-
if (typeof choice === "string") return `${choice} (${String(answeredBy)})`;
|
|
2032
|
-
if (typeof count === "number") return `${String(count)} ran`;
|
|
2033
|
-
return "done";
|
|
2034
|
-
};
|
|
2035
|
-
/** Transcript lines for one run event, prefixed with the node they are from. */
|
|
2036
|
-
const formatRunEvent = (event, width, tools) => {
|
|
2037
|
-
const tag = (id) => style("cyan", `${id.padEnd(width)} │`);
|
|
2038
|
-
if (event.type === "run.started") return [style("dim", `▸ run ${event.runId} of ${event.workflow} (starts at ${event.entries.join(", ")})`)];
|
|
2039
|
-
if (event.type === "node.started") {
|
|
2040
|
-
const round = event.round > 1 ? ` (round ${String(event.round)})` : "";
|
|
2041
|
-
const owner = event.owner === void 0 ? "" : `, for ${event.owner}`;
|
|
2042
|
-
return [`${tag(event.id)} ${style("dim", `${event.nodeType}${round}${owner}`)}`];
|
|
2043
|
-
}
|
|
2044
|
-
if (event.type === "node.event") {
|
|
2045
|
-
const inner = event.event;
|
|
2046
|
-
if (inner.type === "session.finished") return [];
|
|
2047
|
-
if (inner.type === "session.started") return [`${tag(event.id)} ${style("dim", `${inner.model}, session ${inner.sessionId}`)}`];
|
|
2048
|
-
return formatEvent(event.id, event.event, tools).map((line) => `${tag(event.id)} ${line.replace(/^ {2}/u, "")}`);
|
|
2049
|
-
}
|
|
2050
|
-
if (event.type === "node.finished") {
|
|
2051
|
-
const mark = event.ok ? style("green", "✓") : style("red", "✗");
|
|
2052
|
-
const cost = event.usage === void 0 ? "" : style("dim", ` · $${event.usage.costUsd.toFixed(4)}`);
|
|
2053
|
-
return [`${tag(event.id)} ${mark} ${nodeVerdict(event.output)}${cost}`];
|
|
2054
|
-
}
|
|
2055
|
-
if (event.type === "node.skipped") return [`${tag(event.id)} ${style("dim", "- skipped")}`];
|
|
2056
|
-
if (event.type === "loop.exhausted") return [`${style(["yellow", "bold"], "!")} the loop ${event.from} -> ${event.to} used all ${String(event.maxRounds)} rounds`];
|
|
2057
|
-
if (event.type === "edge.taken") return [];
|
|
2058
|
-
if (event.type === "worktree") {
|
|
2059
|
-
const detail = event.detail === void 0 ? "" : ` (${event.detail})`;
|
|
2060
|
-
const line = `${event.action} ${event.branch ?? event.path}${detail}`;
|
|
2061
|
-
return [`${tag(event.id)} ${event.action === "conflict" ? style("yellow", `! ${line}`) : style("dim", `⎇ ${line}`)}`];
|
|
2062
|
-
}
|
|
2063
|
-
if (event.type === "decision.opened") {
|
|
2064
|
-
const what = event.kind === "permission" ? "asks to" : "asks:";
|
|
2065
|
-
const question = event.kind === "permission" ? event.question.replace(/^Allow /u, "run ").replace(/\?$/u, "") : event.question;
|
|
2066
|
-
return [`${tag(event.node)} ${style(["yellow", "bold"], "?")} ${what} ${question} ${style("dim", `(decision ${event.decision}; ork decide)`)}`];
|
|
2067
|
-
}
|
|
2068
|
-
if (event.type === "decision.answered") {
|
|
2069
|
-
const by = event.by === "decide" ? "ork decide" : "terminal";
|
|
2070
|
-
return [`${tag(event.node)} ${style("dim", `→ ${event.answer} (${by})`)}`];
|
|
2071
|
-
}
|
|
2072
|
-
if (event.type === "decision.withdrawn") return [`${tag(event.node)} ${style("dim", "→ question withdrawn")}`];
|
|
2073
|
-
if (event.type === "run.command") return [style(["yellow", "bold"], `! ork ${event.command} received`)];
|
|
2074
|
-
if (event.type === "run.finished") return formatRunResult(event.result);
|
|
2075
|
-
if (event.type === "run.resumed") {
|
|
2076
|
-
const [one] = event.kept;
|
|
2077
|
-
const kept = one === void 0 ? "every node runs again" : event.kept.length === 1 ? `${one} keeps its result` : `${event.kept.join(", ")} keep their results`;
|
|
2078
|
-
return ["", style("dim", `▸ resumed: ${kept}`)];
|
|
2079
|
-
}
|
|
2080
|
-
if (event.type === "feedback.recorded") {
|
|
2081
|
-
const { target, kind, title } = event.feedback;
|
|
2082
|
-
const line = style("dim", `✎ feedback (${target}, ${kind}): ${title}`);
|
|
2083
|
-
return [event.node === void 0 ? line : `${tag(event.node)} ${line}`];
|
|
2084
|
-
}
|
|
2085
|
-
if (event.type === "user.message") {
|
|
2086
|
-
const note = event.delivered === "queued" ? " (for its next session)" : "";
|
|
2087
|
-
return [`${tag(event.node)} ${style(["magenta", "bold"], "you:")} ${firstLine(event.body)}${style("dim", note)}`];
|
|
2088
|
-
}
|
|
2089
|
-
return formatBusEvent(event).map((line) => `${tag(event.node)} ${line}`);
|
|
2090
|
-
};
|
|
2091
|
-
/** The first line of a message body, cut to fit one terminal line. */
|
|
2092
|
-
const firstLine = (body) => {
|
|
2093
|
-
const [line = ""] = body.trim().split("\n");
|
|
2094
|
-
return line.length > 100 ? `${line.slice(0, 99)}…` : line;
|
|
2095
|
-
};
|
|
2096
|
-
/** Messages, owner questions and claims, without the node prefix. */
|
|
2097
|
-
const formatBusEvent = (event) => {
|
|
2098
|
-
if (event.type === "claim.taken") return [style("dim", `⚑ claimed ${event.resource}`)];
|
|
2099
|
-
if (event.type === "claim.refused") {
|
|
2100
|
-
const held = event.held === event.resource ? "" : ` (it holds ${event.held})`;
|
|
2101
|
-
return [style("yellow", `⚑ ${event.resource} is ${event.holder}'s${held}`)];
|
|
2102
|
-
}
|
|
2103
|
-
if (event.type === "claim.released") return [style("dim", `⚑ released ${event.resource}`)];
|
|
2104
|
-
if (event.type === "message.sent") return [style("dim", `✉ to ${event.to}: ${firstLine(event.body)}`)];
|
|
2105
|
-
if (event.type === "message.read") return [];
|
|
2106
|
-
if (event.type === "owner.asked") return [`${style(["yellow", "bold"], "?")} asked as the owner of ${event.about}: ${event.text} ${style("dim", `(${event.question})`)}`];
|
|
2107
|
-
if (event.type === "owner.answered") return [style("dim", `→ ${event.answer} (owner)`)];
|
|
2108
|
-
return [style("dim", "→ its session ended first; asking you")];
|
|
2109
|
-
};
|
|
2110
|
-
const RUN_MARK = {
|
|
2111
|
-
succeeded: style("green", "✓"),
|
|
2112
|
-
failed: style("red", "✗"),
|
|
2113
|
-
budget_exhausted: style("yellow", "!"),
|
|
2114
|
-
interrupted: style("yellow", "!")
|
|
2115
|
-
};
|
|
2116
|
-
/** The run summary: status, cost, what ran, branches to look at, and why. */
|
|
2117
|
-
const formatRunResult = (result) => {
|
|
2118
|
-
const ran = result.nodes.filter((n) => n.runs > 0).length;
|
|
2119
|
-
const skipped = result.nodes.length - ran;
|
|
2120
|
-
const lines = ["", `${RUN_MARK[result.status]} run ${result.runId} ${result.status.replace("_", " ")} ${style("dim", `· $${result.costUsd.toFixed(4)} · ${String(ran)} ran, ${String(skipped)} skipped`)}`];
|
|
2121
|
-
for (const kept of result.worktrees) lines.push(` ${kept.id}: ${kept.branch ?? "detached"} ${style("dim", `at ${kept.path} (${kept.reason})`)}`);
|
|
2122
|
-
for (const reason of result.reasons) lines.push(` ${style("red", reason)}`);
|
|
2123
|
-
return lines;
|
|
2124
|
-
};
|
|
2125
|
-
// src/runs/chat.ts
|
|
2126
|
-
/**
|
|
2127
|
-
* @file `ork chat`'s two paths. A node of a live run gets the message through
|
|
2128
|
-
* the store and the run's socket, and the run's bus pushes it into the node's
|
|
2129
|
-
* session (specs/messaging.md#chat). A node of a finished run gets it by
|
|
2130
|
-
* resuming its last Claude session where it ran, and the exchange is added to
|
|
2131
|
-
* the run's record.
|
|
2132
|
-
*/
|
|
2133
|
-
const chattable = (type) => type === "claude.leader" || type === "claude.worker";
|
|
2134
|
-
/** Reads `node`'s session from a run's events, and every node's last output. */
|
|
2135
|
-
const nodeSession = (events, node) => {
|
|
2136
|
-
const session = {
|
|
2137
|
-
known: false,
|
|
2138
|
-
chattable: false,
|
|
2139
|
-
running: false
|
|
2140
|
-
};
|
|
2141
|
-
const outputs = {};
|
|
2142
|
-
for (const { event } of events) {
|
|
2143
|
-
if (event.type === "run.started") {
|
|
2144
|
-
const declared = event.nodes.find((n) => n.id === node);
|
|
2145
|
-
if (declared !== void 0) {
|
|
2146
|
-
session.known = true;
|
|
2147
|
-
session.chattable = chattable(declared.type);
|
|
2148
|
-
}
|
|
2149
|
-
}
|
|
2150
|
-
if (event.type === "node.finished") outputs[event.id] = event.output;
|
|
2151
|
-
if (!("id" in event) || event.id !== node) continue;
|
|
2152
|
-
if (event.type === "node.started") {
|
|
2153
|
-
session.known = true;
|
|
2154
|
-
session.chattable = chattable(event.nodeType);
|
|
2155
|
-
session.running = true;
|
|
2156
|
-
}
|
|
2157
|
-
if (event.type === "node.finished") session.running = false;
|
|
2158
|
-
if (event.type === "worktree" && event.action === "created") session.checkout = {
|
|
2159
|
-
path: event.path,
|
|
2160
|
-
...event.branch === void 0 ? {} : { branch: event.branch },
|
|
2161
|
-
own: true,
|
|
2162
|
-
owner: node
|
|
2163
|
-
};
|
|
2164
|
-
if (event.type !== "node.event") continue;
|
|
2165
|
-
const inner = event.event;
|
|
2166
|
-
if (inner.type === "session.started") {
|
|
2167
|
-
session.sessionId = inner.sessionId;
|
|
2168
|
-
if (inner.cwd !== void 0) session.cwd = inner.cwd;
|
|
2169
|
-
if (inner.driver !== void 0) session.driver = inner.driver;
|
|
2170
|
-
}
|
|
2171
|
-
if (inner.type === "session.finished" && inner.outcome.sessionId !== "") session.sessionId = inner.outcome.sessionId;
|
|
2172
|
-
}
|
|
2173
|
-
return {
|
|
2174
|
-
session,
|
|
2175
|
-
outputs
|
|
2176
|
-
};
|
|
2177
|
-
};
|
|
2178
|
-
/** Sends a live run's node a message; false when the run did not wake. */
|
|
2179
|
-
const tellLiveRun = (store, run, node, body, home) => {
|
|
2180
|
-
store.command(run.id, {
|
|
2181
|
-
kind: "message",
|
|
2182
|
-
node,
|
|
2183
|
-
body
|
|
2184
|
-
});
|
|
2185
|
-
return wakeRun(socketPath(run.id, home));
|
|
2186
|
-
};
|
|
2187
|
-
/** The declared node a spawned worker (`<template>-<n>`) is an instance of. */
|
|
2188
|
-
const templateOf = (nodes, id) => {
|
|
2189
|
-
if (id in nodes) return void 0;
|
|
2190
|
-
const template = id.replace(/-\d+$/u, "");
|
|
2191
|
-
return template in nodes ? template : void 0;
|
|
2192
|
-
};
|
|
2193
|
-
/**
|
|
2194
|
-
* Resumes a finished node's last session with the user's message, prints the
|
|
2195
|
-
* session, and adds it to the run's record. Returns why it could not.
|
|
2196
|
-
*/
|
|
2197
|
-
const resumeChat = async (options) => {
|
|
2198
|
-
const { store, run, node } = options;
|
|
2199
|
-
const { session, outputs } = nodeSession(store.events(run.id, 0), node);
|
|
2200
|
-
if (!session.known) return `${node} is not a node of run ${run.id}.`;
|
|
2201
|
-
if (!session.chattable) return `${node} is not a leader or worker; only they have sessions to talk to.`;
|
|
2202
|
-
if (session.sessionId === void 0) return `${node} has no Claude session to resume in run ${run.id}.`;
|
|
2203
|
-
const cwd = session.cwd ?? session.checkout?.path;
|
|
2204
|
-
if (cwd === void 0 || !existsSync(cwd)) return `${node}'s session ran in ${cwd ?? "a folder Ork did not record"}, which is gone (a merged worktree is removed), so it cannot be resumed.`;
|
|
2205
|
-
const { workflow, place, workflowDir } = loadWorkflow(run.file, path.dirname(run.file), void 0, store.projects());
|
|
2206
|
-
if (workflow === null) return `The workflow ${run.file} is gone or no longer valid.`;
|
|
2207
|
-
const runDir = runDirOf(place, run.id);
|
|
2208
|
-
const template = templateOf(workflow.nodes, node);
|
|
2209
|
-
const plan = planNodeSession(workflow, node, {
|
|
2210
|
-
cwd,
|
|
2211
|
-
inputs: {},
|
|
2212
|
-
outputs,
|
|
2213
|
-
inPlace: session.checkout === void 0,
|
|
2214
|
-
workflowDir,
|
|
2215
|
-
projectRoot: run.project,
|
|
2216
|
-
orkDir: place.dir,
|
|
2217
|
-
pluginDir: path.join(runDir, "plugins", node),
|
|
2218
|
-
...session.checkout === void 0 ? {} : { checkout: session.checkout },
|
|
2219
|
-
...template === void 0 ? {} : { template }
|
|
2220
|
-
});
|
|
2221
|
-
if (!plan.ok) return plan.error;
|
|
2222
|
-
const record = appendRecorder(store, run.id, runDir);
|
|
2223
|
-
record({
|
|
2224
|
-
type: "user.message",
|
|
2225
|
-
node,
|
|
2226
|
-
body: options.body,
|
|
2227
|
-
delivered: "resumed"
|
|
2228
|
-
});
|
|
2229
|
-
const started = (await options.runtime(options.driver ?? session.driver ?? plan.driver)).start({
|
|
2230
|
-
...plan.spec,
|
|
2231
|
-
resume: session.sessionId,
|
|
2232
|
-
prompt: options.body
|
|
2233
|
-
}, { human: options.human });
|
|
2234
|
-
const tools = /* @__PURE__ */ new Map();
|
|
2235
|
-
let failure = null;
|
|
2236
|
-
for await (const event of started.events) {
|
|
2237
|
-
record({
|
|
2238
|
-
type: "node.event",
|
|
2239
|
-
id: node,
|
|
2240
|
-
event
|
|
2241
|
-
});
|
|
2242
|
-
for (const line of formatEvent(node, event, tools)) options.out(line);
|
|
2243
|
-
if (event.type === "session.finished" && event.outcome.subtype === "error_driver") failure = event.outcome.errors.join("; ");
|
|
2244
|
-
}
|
|
2245
|
-
return failure;
|
|
2246
|
-
};
|
|
2247
|
-
// src/runs/find.ts
|
|
2248
|
-
/**
|
|
2249
|
-
* @file Finding a stored run from a command's argument: a run id or a unique
|
|
2250
|
-
* prefix of one, or, with none, the newest run of this project.
|
|
2251
|
-
*/
|
|
2252
|
-
const findRun = (store, arg, cwd) => {
|
|
2253
|
-
if (arg === void 0 || arg === "") {
|
|
2254
|
-
const [latest] = store.runs({
|
|
2255
|
-
project: placeFor(cwd, store.projects()).root,
|
|
2256
|
-
limit: 1
|
|
2257
|
-
});
|
|
2258
|
-
return latest === void 0 ? {
|
|
2259
|
-
ok: false,
|
|
2260
|
-
error: "No runs in this project yet; see `ork ps --all`."
|
|
2261
|
-
} : {
|
|
2262
|
-
ok: true,
|
|
2263
|
-
run: latest
|
|
2264
|
-
};
|
|
2265
|
-
}
|
|
2266
|
-
const found = store.lookup(arg);
|
|
2267
|
-
const [only] = found;
|
|
2268
|
-
if (only === void 0) return {
|
|
2269
|
-
ok: false,
|
|
2270
|
-
error: `No run "${arg}".`
|
|
2271
|
-
};
|
|
2272
|
-
if (found.length > 1) return {
|
|
2273
|
-
ok: false,
|
|
2274
|
-
error: `"${arg}" matches ${String(found.length)} runs: ${found.map((r) => r.id).join(", ")}`
|
|
2275
|
-
};
|
|
2276
|
-
return {
|
|
2277
|
-
ok: true,
|
|
2278
|
-
run: only
|
|
2279
|
-
};
|
|
693
|
+
return failure;
|
|
2280
694
|
};
|
|
2281
695
|
// src/runs/logs.ts
|
|
2282
696
|
/**
|
|
@@ -2284,887 +698,50 @@ const findRun = (store, arg, cwd) => {
|
|
|
2284
698
|
* printed, for the whole run or one node, and tails a live run until it
|
|
2285
699
|
* ends.
|
|
2286
700
|
*/
|
|
2287
|
-
/** Turns stored events into lines, keeping the state a transcript needs. */
|
|
2288
|
-
const createLogPrinter = (options) => {
|
|
2289
|
-
const tools = /* @__PURE__ */ new Map();
|
|
2290
|
-
let width = 8;
|
|
2291
|
-
return (stored) => {
|
|
2292
|
-
const { event } = stored;
|
|
2293
|
-
if (event.type === "run.started") width = Math.max(...event.nodes.map((n) => n.id.length), 1);
|
|
2294
|
-
if (options.node !== void 0 && nodeOf(event) !== options.node) return [];
|
|
2295
|
-
if (options.json) return [JSON.stringify(stored)];
|
|
2296
|
-
return formatRunEvent(event, width, tools);
|
|
2297
|
-
};
|
|
2298
|
-
};
|
|
2299
|
-
/**
|
|
2300
|
-
* Prints a run's events; with `follow`, then each new one as it is stored:
|
|
2301
|
-
* pushed over the run's socket, or polled from the store when there is none.
|
|
2302
|
-
* Returns when `run.finished` is stored, the run's process is gone, or `signal`
|
|
2303
|
-
* aborts.
|
|
2304
|
-
*/
|
|
2305
|
-
const followRun = async (store, run, out, options) => {
|
|
2306
|
-
const print = createLogPrinter(options);
|
|
2307
|
-
/** Prints what the run's socket pushes after `after`, until it closes. */
|
|
2308
|
-
const viaSocket = async (after) => {
|
|
2309
|
-
let last = after;
|
|
2310
|
-
let finished = false;
|
|
2311
|
-
return {
|
|
2312
|
-
via: await followSocket(socketPath(run.id, options.home), after, (stored) => {
|
|
2313
|
-
if (stored.seq <= last) return;
|
|
2314
|
-
last = stored.seq;
|
|
2315
|
-
for (const line of print(stored)) out(line);
|
|
2316
|
-
if (stored.event.type === "run.finished") finished = true;
|
|
2317
|
-
}, options.signal),
|
|
2318
|
-
seq: last,
|
|
2319
|
-
finished
|
|
2320
|
-
};
|
|
2321
|
-
};
|
|
2322
|
-
let seq = options.after ?? 0;
|
|
2323
|
-
for (;;) {
|
|
2324
|
-
let finished = false;
|
|
2325
|
-
for (const stored of store.events(run.id, seq)) {
|
|
2326
|
-
seq = stored.seq;
|
|
2327
|
-
for (const line of print(stored)) out(line);
|
|
2328
|
-
finished ||= stored.event.type === "run.finished";
|
|
2329
|
-
}
|
|
2330
|
-
if (finished || !options.follow) return;
|
|
2331
|
-
const pushed = await viaSocket(seq);
|
|
2332
|
-
seq = pushed.seq;
|
|
2333
|
-
if (pushed.finished || options.signal?.aborted === true) return;
|
|
2334
|
-
const { via } = pushed;
|
|
2335
|
-
if (via === "ended") continue;
|
|
2336
|
-
const [now] = store.lookup(run.id);
|
|
2337
|
-
if (now === void 0 || liveStatus(now) !== "running") {
|
|
2338
|
-
if (now !== void 0 && liveStatus(now) === "crashed") out(style("red", `run ${run.id} stopped without finishing (its process is gone)`));
|
|
2339
|
-
return;
|
|
2340
|
-
}
|
|
2341
|
-
try {
|
|
2342
|
-
await setTimeout$1(options.intervalMs ?? 250, void 0, { signal: options.signal });
|
|
2343
|
-
} catch {
|
|
2344
|
-
return;
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
};
|
|
2348
|
-
// src/run/detach.ts
|
|
2349
|
-
/**
|
|
2350
|
-
* @file `ork run --detach` and `ork resume --detach`: starts the same command
|
|
2351
|
-
* as a background process with no terminal, its output in the run's folder
|
|
2352
|
-
* (`.ork/runs/<run>/`, or the attached project's) as `output.log`, and
|
|
2353
|
-
* returns once the run is running. Its questions wait for `ork decide`.
|
|
2354
|
-
*/
|
|
2355
|
-
/** Set in a detached run's environment: no terminal answers its questions. */
|
|
2356
|
-
const DETACHED_ENV = "ORK_DETACHED";
|
|
2357
|
-
const detachRun = async (options) => {
|
|
2358
|
-
const { runId } = options;
|
|
2359
|
-
const dir = options.runDir;
|
|
2360
|
-
mkdirSync(dir, { recursive: true });
|
|
2361
|
-
const log = path.join(dir, "output.log");
|
|
2362
|
-
const fd = openSync(log, "a");
|
|
2363
|
-
let exited = null;
|
|
2364
|
-
const script = process.argv[1] ?? "";
|
|
2365
|
-
const child = spawn(process.execPath, [
|
|
2366
|
-
...process.execArgv,
|
|
2367
|
-
script,
|
|
2368
|
-
...options.args
|
|
2369
|
-
], {
|
|
2370
|
-
cwd: options.cwd,
|
|
2371
|
-
detached: true,
|
|
2372
|
-
stdio: [
|
|
2373
|
-
"ignore",
|
|
2374
|
-
fd,
|
|
2375
|
-
fd
|
|
2376
|
-
],
|
|
2377
|
-
env: {
|
|
2378
|
-
...process.env,
|
|
2379
|
-
[DETACHED_ENV]: "1",
|
|
2380
|
-
NO_COLOR: "1"
|
|
2381
|
-
}
|
|
2382
|
-
});
|
|
2383
|
-
child.on("exit", (code) => {
|
|
2384
|
-
exited = code ?? 1;
|
|
2385
|
-
});
|
|
2386
|
-
child.unref();
|
|
2387
|
-
const deadline = Date.now() + (options.timeoutMs ?? 15e3);
|
|
2388
|
-
while (Date.now() < deadline) {
|
|
2389
|
-
if (options.started?.(child.pid) ?? options.store.lookup(runId).length > 0) return {
|
|
2390
|
-
ok: true,
|
|
2391
|
-
runId,
|
|
2392
|
-
log
|
|
2393
|
-
};
|
|
2394
|
-
if (exited !== null) return {
|
|
2395
|
-
ok: false,
|
|
2396
|
-
error: readFileSync(log, "utf8").trim(),
|
|
2397
|
-
log
|
|
2398
|
-
};
|
|
2399
|
-
await setTimeout$1(100);
|
|
2400
|
-
}
|
|
2401
|
-
return {
|
|
2402
|
-
ok: false,
|
|
2403
|
-
error: "the run did not start in time",
|
|
2404
|
-
log
|
|
2405
|
-
};
|
|
2406
|
-
};
|
|
2407
|
-
// ../nodes/src/claude.ts
|
|
2408
|
-
/**
|
|
2409
|
-
* @file The Claude node type: runs a `claude.leader` or `claude.worker` as one
|
|
2410
|
-
* session, planned with the run's outputs (and the brief its owner gave it),
|
|
2411
|
-
* and turns its report into the node's output. A leader gets its children
|
|
2412
|
-
* through the session's ports, and resumes its session on re-entry
|
|
2413
|
-
* (ADR-019).
|
|
2414
|
-
*/
|
|
2415
|
-
const MAX_INPUT = 8192;
|
|
2416
|
-
/** On a loop re-entry, the brief carries what the workflow sent back. */
|
|
2417
|
-
const reentryNote = (round, input) => {
|
|
2418
|
-
const json = JSON.stringify(input ?? {}, void 0, 2);
|
|
2419
|
-
const body = json.length > MAX_INPUT ? `${json.slice(0, MAX_INPUT)}\n…` : json;
|
|
2420
|
-
return `\n\n## Round ${String(round)}\n\nThe workflow sent this task back to you with:\n\n\`\`\`json\n${body}\n\`\`\``;
|
|
2421
|
-
};
|
|
2422
|
-
/**
|
|
2423
|
-
* What a resumed leader is told: it already has its brief and its earlier work,
|
|
2424
|
-
* so only the new round, and the new task when its owner sent one.
|
|
2425
|
-
*/
|
|
2426
|
-
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");
|
|
2427
|
-
/** A first-round resume is a session the run was cut off in (`ork resume`). */
|
|
2428
|
-
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)}`;
|
|
2429
|
-
/** What a session is told when messages came in as it finished. */
|
|
2430
|
-
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.`;
|
|
2431
|
-
/** The later outcome, with the cost and tokens of both sessions. */
|
|
2432
|
-
const withUsageOf = (first, later) => {
|
|
2433
|
-
const sum = (key) => first.usage[key] + later.usage[key];
|
|
2434
|
-
const usage = {
|
|
2435
|
-
costUsd: sum("costUsd"),
|
|
2436
|
-
inputTokens: sum("inputTokens"),
|
|
2437
|
-
outputTokens: sum("outputTokens"),
|
|
2438
|
-
cacheReadTokens: sum("cacheReadTokens"),
|
|
2439
|
-
cacheWriteTokens: sum("cacheWriteTokens"),
|
|
2440
|
-
turns: sum("turns"),
|
|
2441
|
-
durationMs: sum("durationMs")
|
|
2442
|
-
};
|
|
2443
|
-
return {
|
|
2444
|
-
...later,
|
|
2445
|
-
usage
|
|
2446
|
-
};
|
|
2447
|
-
};
|
|
2448
|
-
/** A finished session as a node result: its report is the node's output. */
|
|
2449
|
-
const resultOf = (outcome) => {
|
|
2450
|
-
if (outcome === null) return {
|
|
2451
|
-
ok: false,
|
|
2452
|
-
output: {
|
|
2453
|
-
status: "fail",
|
|
2454
|
-
summary: "the session ended without a result"
|
|
2455
|
-
},
|
|
2456
|
-
error: "the session ended without a result"
|
|
2457
|
-
};
|
|
2458
|
-
const { report, usage } = outcome;
|
|
2459
|
-
if (report === null) {
|
|
2460
|
-
const reason = outcome.errors[0] ?? "no valid report";
|
|
2461
|
-
return {
|
|
2462
|
-
ok: false,
|
|
2463
|
-
output: {
|
|
2464
|
-
status: "fail",
|
|
2465
|
-
summary: reason,
|
|
2466
|
-
usage
|
|
2467
|
-
},
|
|
2468
|
-
usage,
|
|
2469
|
-
error: `ended without a report (${outcome.subtype}): ${reason}`,
|
|
2470
|
-
sessionId: outcome.sessionId
|
|
2471
|
-
};
|
|
2472
|
-
}
|
|
2473
|
-
const ran = { sessionId: outcome.sessionId };
|
|
2474
|
-
const output = {
|
|
2475
|
-
...report.fields,
|
|
2476
|
-
status: report.status,
|
|
2477
|
-
summary: report.summary,
|
|
2478
|
-
artifacts: report.artifacts,
|
|
2479
|
-
usage
|
|
2480
|
-
};
|
|
2481
|
-
if (report.branch !== void 0) output["branch"] = report.branch;
|
|
2482
|
-
const result = {
|
|
2483
|
-
ok: report.status !== "fail",
|
|
2484
|
-
output,
|
|
2485
|
-
usage,
|
|
2486
|
-
...ran
|
|
2487
|
-
};
|
|
2488
|
-
if (!result.ok) result.error = report.summary;
|
|
2489
|
-
return result;
|
|
2490
|
-
};
|
|
2491
|
-
/** A re-entered leader resumes; anyone else starts with the full brief. */
|
|
2492
|
-
const specFor = (spec, context) => {
|
|
2493
|
-
if (context.resume !== void 0) return {
|
|
2494
|
-
...spec,
|
|
2495
|
-
resume: context.resume,
|
|
2496
|
-
prompt: resumeMessage(context.round, context.input, context.brief)
|
|
2497
|
-
};
|
|
2498
|
-
if (context.round > 1 && context.brief === void 0) return {
|
|
2499
|
-
...spec,
|
|
2500
|
-
prompt: spec.prompt + reentryNote(context.round, context.input)
|
|
2501
|
-
};
|
|
2502
|
-
return spec;
|
|
2503
|
-
};
|
|
2504
|
-
/** Runs a `claude.leader` or `claude.worker` as one session. */
|
|
2505
|
-
const claudeExecutor = (options) => async (context) => {
|
|
2506
|
-
const planOptions = {
|
|
2507
|
-
cwd: context.cwd,
|
|
2508
|
-
inputs: context.inputs,
|
|
2509
|
-
outputs: context.outputs,
|
|
2510
|
-
inPlace: options.inPlace,
|
|
2511
|
-
workflowDir: context.workflowDir,
|
|
2512
|
-
projectRoot: context.projectRoot,
|
|
2513
|
-
...context.orkDir === void 0 ? {} : { orkDir: context.orkDir },
|
|
2514
|
-
pluginDir: path.join(context.runDir, "plugins", context.id)
|
|
2515
|
-
};
|
|
2516
|
-
if (context.budgetLeftUsd !== void 0) planOptions.budgetLeftUsd = context.budgetLeftUsd;
|
|
2517
|
-
if (context.checkout !== void 0) planOptions.checkout = context.checkout;
|
|
2518
|
-
if (context.brief !== void 0) planOptions.brief = context.brief;
|
|
2519
|
-
if (context.template !== void 0) planOptions.template = context.template;
|
|
2520
|
-
if (context.ownership !== void 0) planOptions.ownership = context.ownership;
|
|
2521
|
-
if (context.mailbox !== void 0) planOptions.mailbox = context.mailbox;
|
|
2522
|
-
if (context.claims !== void 0) planOptions.claims = context.claims;
|
|
2523
|
-
const plan = planNodeSession(context.workflow, context.id, planOptions);
|
|
2524
|
-
if (!plan.ok) return {
|
|
2525
|
-
ok: false,
|
|
2526
|
-
output: {
|
|
2527
|
-
status: "fail",
|
|
2528
|
-
summary: plan.error
|
|
2529
|
-
},
|
|
2530
|
-
error: plan.error
|
|
2531
|
-
};
|
|
2532
|
-
const spec = specFor(plan.spec, context);
|
|
2533
|
-
const runtime = await options.runtimeFor(options.driver ?? plan.driver);
|
|
2534
|
-
const ports = { human: context.human };
|
|
2535
|
-
if (context.ownership !== void 0) ports.ownership = context.ownership;
|
|
2536
|
-
if (context.mailbox !== void 0) ports.mailbox = context.mailbox;
|
|
2537
|
-
if (context.claims !== void 0) ports.claims = context.claims;
|
|
2538
|
-
if (context.feedback !== void 0) ports.feedback = context.feedback;
|
|
2539
|
-
const run = async (asked) => {
|
|
2540
|
-
const session = runtime.start(asked, ports);
|
|
2541
|
-
const stop = () => {
|
|
2542
|
-
session.interrupt();
|
|
2543
|
-
};
|
|
2544
|
-
context.signal.addEventListener("abort", stop, { once: true });
|
|
2545
|
-
const disconnect = context.mailbox?.connect((notice) => {
|
|
2546
|
-
session.send(notice);
|
|
2547
|
-
});
|
|
2548
|
-
let outcome = null;
|
|
2549
|
-
try {
|
|
2550
|
-
for await (const event of session.events) {
|
|
2551
|
-
if (event.type === "session.finished") outcome = event.outcome;
|
|
2552
|
-
context.emit(event);
|
|
2553
|
-
}
|
|
2554
|
-
} finally {
|
|
2555
|
-
disconnect?.();
|
|
2556
|
-
context.signal.removeEventListener("abort", stop);
|
|
2557
|
-
}
|
|
2558
|
-
return outcome;
|
|
2559
|
-
};
|
|
2560
|
-
const first = await run(spec);
|
|
2561
|
-
const late = context.mailbox?.pending() ?? 0;
|
|
2562
|
-
const stopped = context.signal.aborted;
|
|
2563
|
-
if (first === null || first.report === null || late === 0 || stopped) return resultOf(first);
|
|
2564
|
-
const again = await run({
|
|
2565
|
-
...spec,
|
|
2566
|
-
resume: first.sessionId,
|
|
2567
|
-
prompt: lateMessage(late)
|
|
2568
|
-
});
|
|
2569
|
-
return resultOf(again === null ? first : withUsageOf(first, again));
|
|
2570
|
-
};
|
|
2571
|
-
// ../nodes/src/gate.ts
|
|
2572
|
-
/**
|
|
2573
|
-
* @file The `human.gate` node type: asks the human the rendered question, and
|
|
2574
|
-
* answers with the `default` when the `timeout` passes.
|
|
2575
|
-
*/
|
|
2576
|
-
const UNITS = {
|
|
2577
|
-
s: 1e3,
|
|
2578
|
-
m: 6e4,
|
|
2579
|
-
h: 36e5
|
|
2580
|
-
};
|
|
2581
|
-
/** `30s`, `15m`, `2h` in milliseconds; `undefined` without a timeout. */
|
|
2582
|
-
const timeoutMs = (timeout) => {
|
|
2583
|
-
const match = /^(\d+)([smh])$/u.exec(timeout ?? "");
|
|
2584
|
-
if (match === null) return void 0;
|
|
2585
|
-
const [, amount = "0", unit = "s"] = match;
|
|
2586
|
-
return Number(amount) * (UNITS[unit] ?? 1e3);
|
|
2587
|
-
};
|
|
2588
|
-
const choiceOf = (option) => {
|
|
2589
|
-
if (typeof option === "string") return { id: option };
|
|
2590
|
-
const choice = { id: option.id };
|
|
2591
|
-
if (option.label !== void 0) choice.label = option.label;
|
|
2592
|
-
if (option.description !== void 0) choice.description = option.description;
|
|
2593
|
-
return choice;
|
|
2594
|
-
};
|
|
2595
|
-
/**
|
|
2596
|
-
* Asks the human and outputs `choice` and `answeredBy`. With `timeout`, the
|
|
2597
|
-
* `default` answers when no one has.
|
|
2598
|
-
*/
|
|
2599
|
-
const gateExecutor = async (context) => {
|
|
2600
|
-
const { node } = context;
|
|
2601
|
-
if (node.type !== "human.gate") return {
|
|
2602
|
-
ok: false,
|
|
2603
|
-
output: {},
|
|
2604
|
-
error: `${context.id} is not a gate`
|
|
2605
|
-
};
|
|
2606
|
-
const template = parseTemplate(node.question);
|
|
2607
|
-
const question = template.ok ? renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId)) : node.question;
|
|
2608
|
-
const options = node.options.map((o) => choiceOf(o));
|
|
2609
|
-
const withdraw = new AbortController();
|
|
2610
|
-
context.signal.addEventListener("abort", () => {
|
|
2611
|
-
withdraw.abort();
|
|
2612
|
-
});
|
|
2613
|
-
const asked = {
|
|
2614
|
-
nodeId: context.id,
|
|
2615
|
-
question,
|
|
2616
|
-
options,
|
|
2617
|
-
signal: withdraw.signal
|
|
2618
|
-
};
|
|
2619
|
-
if (node.recommended !== void 0) asked.recommended = node.recommended;
|
|
2620
|
-
const answer = context.human.ask(asked).then((choice) => ({
|
|
2621
|
-
choice,
|
|
2622
|
-
answeredBy: "user"
|
|
2623
|
-
}));
|
|
2624
|
-
const ms = timeoutMs(node.timeout);
|
|
2625
|
-
let timer;
|
|
2626
|
-
const expired = ms === void 0 || node.default === void 0 ? [] : [new Promise((resolve) => {
|
|
2627
|
-
timer = setTimeout(() => {
|
|
2628
|
-
withdraw.abort();
|
|
2629
|
-
resolve({
|
|
2630
|
-
choice: node.default ?? "",
|
|
2631
|
-
answeredBy: "timeout"
|
|
2632
|
-
});
|
|
2633
|
-
}, ms);
|
|
2634
|
-
})];
|
|
2635
|
-
try {
|
|
2636
|
-
const output = await Promise.race([answer, ...expired]);
|
|
2637
|
-
if (!options.some((o) => o.id === output.choice)) return {
|
|
2638
|
-
ok: false,
|
|
2639
|
-
output,
|
|
2640
|
-
error: "no one answered the gate"
|
|
2641
|
-
};
|
|
2642
|
-
return {
|
|
2643
|
-
ok: true,
|
|
2644
|
-
output
|
|
2645
|
-
};
|
|
2646
|
-
} catch {
|
|
2647
|
-
return {
|
|
2648
|
-
ok: false,
|
|
2649
|
-
output: {},
|
|
2650
|
-
error: "the gate was withdrawn"
|
|
2651
|
-
};
|
|
2652
|
-
} finally {
|
|
2653
|
-
clearTimeout(timer);
|
|
2654
|
-
}
|
|
2655
|
-
};
|
|
2656
|
-
// ../nodes/src/tools.ts
|
|
2657
|
-
/**
|
|
2658
|
-
* @file The tool node executor, for every registered integration (`tool.gh`,
|
|
2659
|
-
* `tool.aws`, `tool.git`, `tool.shell`, …): the user's own program, run
|
|
2660
|
-
* without a shell, checked against `allow` (or the integration's default),
|
|
2661
|
-
* its arguments and observed output fields from the integration's adapter,
|
|
2662
|
-
* long output kept in run files.
|
|
2663
|
-
*/
|
|
2664
|
-
/** How much of stdout and stderr a node's output keeps. */
|
|
2665
|
-
const OUTPUT_LIMIT = 65536;
|
|
2666
|
-
/**
|
|
2667
|
-
* Splits a command into arguments the way a shell would, without running one:
|
|
2668
|
-
* whitespace separates, `'…'` is literal, `"…"` and `\` escape, and `{{ … }}`
|
|
2669
|
-
* stays whole so a template is rendered inside its own argument.
|
|
2670
|
-
*/
|
|
2671
|
-
const tokenize = (command) => {
|
|
2672
|
-
const args = [];
|
|
2673
|
-
let current = null;
|
|
2674
|
-
let index = 0;
|
|
2675
|
-
const add = (text) => {
|
|
2676
|
-
current = (current ?? "") + text;
|
|
2677
|
-
};
|
|
2678
|
-
while (index < command.length) {
|
|
2679
|
-
const char = command.charAt(index);
|
|
2680
|
-
if (command.startsWith("{{", index)) {
|
|
2681
|
-
const end = command.indexOf("}}", index + 2);
|
|
2682
|
-
if (end === -1) return {
|
|
2683
|
-
ok: false,
|
|
2684
|
-
error: "unclosed {{ in command"
|
|
2685
|
-
};
|
|
2686
|
-
add(command.slice(index, end + 2));
|
|
2687
|
-
index = end + 2;
|
|
2688
|
-
} else if (/\s/u.test(char)) {
|
|
2689
|
-
if (current !== null) args.push(current);
|
|
2690
|
-
current = null;
|
|
2691
|
-
index += 1;
|
|
2692
|
-
} else if (char === "'") {
|
|
2693
|
-
const end = command.indexOf("'", index + 1);
|
|
2694
|
-
if (end === -1) return {
|
|
2695
|
-
ok: false,
|
|
2696
|
-
error: "unclosed ' in command"
|
|
2697
|
-
};
|
|
2698
|
-
add(command.slice(index + 1, end));
|
|
2699
|
-
index = end + 1;
|
|
2700
|
-
} else if (char === "\"") {
|
|
2701
|
-
let text = "";
|
|
2702
|
-
index += 1;
|
|
2703
|
-
while (index < command.length && command.charAt(index) !== "\"") {
|
|
2704
|
-
if (command.charAt(index) === "\\") index += 1;
|
|
2705
|
-
text += command.charAt(index);
|
|
2706
|
-
index += 1;
|
|
2707
|
-
}
|
|
2708
|
-
if (index >= command.length) return {
|
|
2709
|
-
ok: false,
|
|
2710
|
-
error: "unclosed \" in command"
|
|
2711
|
-
};
|
|
2712
|
-
add(text);
|
|
2713
|
-
index += 1;
|
|
2714
|
-
} else if (char === "\\") {
|
|
2715
|
-
add(command.charAt(index + 1));
|
|
2716
|
-
index += 2;
|
|
2717
|
-
} else {
|
|
2718
|
-
add(char);
|
|
2719
|
-
index += 1;
|
|
2720
|
-
}
|
|
2721
|
-
}
|
|
2722
|
-
if (current !== null) args.push(current);
|
|
2723
|
-
return {
|
|
2724
|
-
ok: true,
|
|
2725
|
-
args
|
|
2726
|
-
};
|
|
2727
|
-
};
|
|
2728
|
-
/** Whether `args` starts with one of the allowed prefixes (`"pr merge"`). */
|
|
2729
|
-
const isAllowed = (args, allow) => allow === void 0 || allow.some((prefix) => {
|
|
2730
|
-
return prefix.trim().split(/\s+/u).every((word, i) => args[i] === word);
|
|
2731
|
-
});
|
|
2732
|
-
const render = (text, context) => {
|
|
2733
|
-
const template = parseTemplate(text);
|
|
2734
|
-
if (!template.ok) return {
|
|
2735
|
-
ok: false,
|
|
2736
|
-
error: template.message
|
|
2737
|
-
};
|
|
2738
|
-
return {
|
|
2739
|
-
ok: true,
|
|
2740
|
-
value: renderTemplate(template.parts, runScope(context.inputs, context.outputs, context.runId))
|
|
2741
|
-
};
|
|
2742
|
-
};
|
|
2743
|
-
/** The program and arguments a tool node runs, before the allow check. */
|
|
2744
|
-
const commandOf = (node, context) => {
|
|
2745
|
-
const tokens = tokenize(node.command);
|
|
2746
|
-
if (!tokens.ok) return tokens;
|
|
2747
|
-
const args = [];
|
|
2748
|
-
for (const token of tokens.args) {
|
|
2749
|
-
const rendered = render(token, context);
|
|
2750
|
-
if (!rendered.ok) return rendered;
|
|
2751
|
-
args.push(rendered.value);
|
|
2752
|
-
}
|
|
2753
|
-
const integration = integrationFor(node.type);
|
|
2754
|
-
if (integration === void 0) return {
|
|
2755
|
-
ok: false,
|
|
2756
|
-
error: `no integration for ${node.type}`
|
|
2757
|
-
};
|
|
2758
|
-
const extra = integration.args(node);
|
|
2759
|
-
if (integration.program !== null) return {
|
|
2760
|
-
ok: true,
|
|
2761
|
-
program: integration.program,
|
|
2762
|
-
args: [...args, ...extra]
|
|
2763
|
-
};
|
|
2764
|
-
const [program, ...rest] = [...args, ...extra];
|
|
2765
|
-
if (program === void 0 || program === "") return {
|
|
2766
|
-
ok: false,
|
|
2767
|
-
error: "the command names no program"
|
|
2768
|
-
};
|
|
2769
|
-
return {
|
|
2770
|
-
ok: true,
|
|
2771
|
-
program,
|
|
2772
|
-
args: rest
|
|
2773
|
-
};
|
|
2774
|
-
};
|
|
2775
|
-
/** Keeps the first `OUTPUT_LIMIT` characters; the rest goes to a file. */
|
|
2776
|
-
const capped = (text, file) => {
|
|
2777
|
-
if (text.length <= 65536) return text;
|
|
2778
|
-
return `${text.slice(0, OUTPUT_LIMIT)}\n… (truncated; the full output is in ${file()})`;
|
|
2779
|
-
};
|
|
2780
|
-
const failed$1 = (error) => ({
|
|
2781
|
-
ok: false,
|
|
2782
|
-
output: {
|
|
2783
|
-
exitCode: -1,
|
|
2784
|
-
stdout: "",
|
|
2785
|
-
stderr: error
|
|
2786
|
-
},
|
|
2787
|
-
error
|
|
2788
|
-
});
|
|
2789
|
-
/** Runs a user's CLI through its integration's adapter, with its own login. */
|
|
2790
|
-
const toolExecutor = (runner) => async (context) => {
|
|
2791
|
-
const { node } = context;
|
|
2792
|
-
if (!isToolNode(node)) return failed$1(`${context.id} is not a tool node`);
|
|
2793
|
-
const command = commandOf(node, context);
|
|
2794
|
-
if (!command.ok) return failed$1(command.error);
|
|
2795
|
-
const integration = integrationFor(node.type);
|
|
2796
|
-
const checked = integration?.program === null ? [command.program, ...command.args] : command.args;
|
|
2797
|
-
const allow = node.allow ?? integration?.allow;
|
|
2798
|
-
if (!isAllowed(checked, allow)) return failed$1(`"${checked.join(" ")}" is not in the node's allow list (${(allow ?? []).join(", ")})`);
|
|
2799
|
-
let cwd = context.cwd;
|
|
2800
|
-
if (node.cwd !== void 0) {
|
|
2801
|
-
const rendered = render(node.cwd, context);
|
|
2802
|
-
if (!rendered.ok) return failed$1(rendered.error);
|
|
2803
|
-
cwd = path.resolve(context.cwd, rendered.value);
|
|
2804
|
-
}
|
|
2805
|
-
context.emit({
|
|
2806
|
-
type: "tool.called",
|
|
2807
|
-
id: context.id,
|
|
2808
|
-
name: command.program,
|
|
2809
|
-
input: { command: [command.program, ...command.args].join(" ") }
|
|
2810
|
-
});
|
|
2811
|
-
const run = await runner(command.program, command.args, {
|
|
2812
|
-
cwd,
|
|
2813
|
-
signal: context.signal
|
|
2814
|
-
});
|
|
2815
|
-
const observed = await integration?.observe?.read(async (args) => {
|
|
2816
|
-
const seen = await runner(command.program, args, {
|
|
2817
|
-
cwd,
|
|
2818
|
-
signal: context.signal
|
|
2819
|
-
});
|
|
2820
|
-
return {
|
|
2821
|
-
ok: seen.exitCode === 0,
|
|
2822
|
-
stdout: seen.stdout
|
|
2823
|
-
};
|
|
2824
|
-
}) ?? {};
|
|
2825
|
-
const keep = (field, text) => () => {
|
|
2826
|
-
const dir = context.runDir;
|
|
2827
|
-
mkdirSync(dir, { recursive: true });
|
|
2828
|
-
const file = path.join(dir, `${context.id}.${String(context.round)}.${field}.txt`);
|
|
2829
|
-
writeFileSync(file, text);
|
|
2830
|
-
return file;
|
|
2831
|
-
};
|
|
2832
|
-
const output = {
|
|
2833
|
-
exitCode: run.exitCode,
|
|
2834
|
-
stdout: capped(run.stdout, keep("stdout", run.stdout)),
|
|
2835
|
-
stderr: capped(run.stderr, keep("stderr", run.stderr))
|
|
2836
|
-
};
|
|
2837
|
-
for (const [field, text] of Object.entries(observed)) output[field] = capped(text, keep(field, text));
|
|
2838
|
-
context.emit({
|
|
2839
|
-
type: "tool.result",
|
|
2840
|
-
id: context.id,
|
|
2841
|
-
isError: run.exitCode !== 0
|
|
2842
|
-
});
|
|
2843
|
-
if (run.exitCode === 0) return {
|
|
2844
|
-
ok: true,
|
|
2845
|
-
output
|
|
2846
|
-
};
|
|
2847
|
-
const last = run.stderr.trim().split("\n").at(-1) ?? "";
|
|
2848
|
-
return {
|
|
2849
|
-
ok: false,
|
|
2850
|
-
output,
|
|
2851
|
-
error: `${command.program} exited with ${String(run.exitCode)}${last === "" ? "" : `: ${last}`}`
|
|
2852
|
-
};
|
|
2853
|
-
};
|
|
2854
|
-
/** The real runner: `spawn` without a shell, output collected as text. */
|
|
2855
|
-
const spawnTool = (program, args, { cwd, signal }) => new Promise((resolve) => {
|
|
2856
|
-
const stdout = [];
|
|
2857
|
-
const stderr = [];
|
|
2858
|
-
const child = spawn(program, args, {
|
|
2859
|
-
cwd,
|
|
2860
|
-
signal,
|
|
2861
|
-
stdio: [
|
|
2862
|
-
"ignore",
|
|
2863
|
-
"pipe",
|
|
2864
|
-
"pipe"
|
|
2865
|
-
]
|
|
2866
|
-
});
|
|
2867
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => {
|
|
2868
|
-
stdout.push(chunk);
|
|
2869
|
-
});
|
|
2870
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => {
|
|
2871
|
-
stderr.push(chunk);
|
|
2872
|
-
});
|
|
2873
|
-
child.on("error", (error) => {
|
|
2874
|
-
resolve({
|
|
2875
|
-
exitCode: -1,
|
|
2876
|
-
stdout: stdout.join(""),
|
|
2877
|
-
stderr: `could not run ${program}: ${error.message}`
|
|
2878
|
-
});
|
|
2879
|
-
});
|
|
2880
|
-
child.on("close", (code) => {
|
|
2881
|
-
resolve({
|
|
2882
|
-
exitCode: code ?? -1,
|
|
2883
|
-
stdout: stdout.join(""),
|
|
2884
|
-
stderr: stderr.join("")
|
|
2885
|
-
});
|
|
2886
|
-
});
|
|
2887
|
-
});
|
|
2888
|
-
// ../nodes/src/builtins.ts
|
|
2889
|
-
/** Every built-in node type that does work; control nodes are the scheduler's. */
|
|
2890
|
-
const builtinExecutors = (options) => {
|
|
2891
|
-
const claude = claudeExecutor(options);
|
|
2892
|
-
const tool = toolExecutor(options.tools ?? spawnTool);
|
|
2893
|
-
const executors = {
|
|
2894
|
-
"claude.leader": claude,
|
|
2895
|
-
"claude.worker": claude,
|
|
2896
|
-
"human.gate": gateExecutor
|
|
2897
|
-
};
|
|
2898
|
-
for (const integration of INTEGRATIONS) executors[`${TOOL_PREFIX}${integration.id}`] = tool;
|
|
2899
|
-
return executors;
|
|
2900
|
-
};
|
|
2901
|
-
// ../nodes/src/programs.ts
|
|
2902
|
-
/**
|
|
2903
|
-
* @file Which programs a workflow's tool nodes run, and which of them are not
|
|
2904
|
-
* installed. Integrations are optional: `ork run` checks only the programs
|
|
2905
|
-
* the workflow uses, before anything starts.
|
|
2906
|
-
*/
|
|
2907
|
-
/**
|
|
2908
|
-
* The programs the workflow's tool nodes run: an integration's own (`gh`,
|
|
2909
|
-
* `aws`), or a `tool.shell` command's first word when it is not a template.
|
|
2910
|
-
*/
|
|
2911
|
-
const neededPrograms = (workflow) => Object.entries(workflow.nodes).flatMap(([id, node]) => {
|
|
2912
|
-
if (!isToolNode(node)) return [];
|
|
2913
|
-
const integration = integrationFor(node.type);
|
|
2914
|
-
if (integration === void 0) return [];
|
|
2915
|
-
if (integration.program !== null) return [{
|
|
2916
|
-
node: id,
|
|
2917
|
-
type: node.type,
|
|
2918
|
-
program: integration.program
|
|
2919
|
-
}];
|
|
2920
|
-
const tokens = tokenize(node.command);
|
|
2921
|
-
const first = tokens.ok ? tokens.args[0] : void 0;
|
|
2922
|
-
return first === void 0 || first.includes("{{") ? [] : [{
|
|
2923
|
-
node: id,
|
|
2924
|
-
type: node.type,
|
|
2925
|
-
program: first
|
|
2926
|
-
}];
|
|
2927
|
-
});
|
|
2928
|
-
const runnable = (file) => {
|
|
2929
|
-
try {
|
|
2930
|
-
accessSync(file, constants.X_OK);
|
|
2931
|
-
return true;
|
|
2932
|
-
} catch {
|
|
2933
|
-
return false;
|
|
2934
|
-
}
|
|
2935
|
-
};
|
|
2936
|
-
/** Whether `program` runs: a path that is executable, or a name on `PATH`. */
|
|
2937
|
-
const onPath = (program, env = process.env) => {
|
|
2938
|
-
if (program.includes(path.sep)) return runnable(program);
|
|
2939
|
-
return (env["PATH"] ?? "").split(path.delimiter).filter((dir) => dir !== "").some((dir) => runnable(path.join(dir, program)));
|
|
2940
|
-
};
|
|
2941
|
-
/** The needed programs that are not installed, one entry per program. */
|
|
2942
|
-
const missingPrograms = (workflow, installed = (program) => onPath(program)) => {
|
|
2943
|
-
const missing = /* @__PURE__ */ new Map();
|
|
2944
|
-
for (const need of neededPrograms(workflow)) {
|
|
2945
|
-
if (installed(need.program)) continue;
|
|
2946
|
-
const entry = missing.get(need.program) ?? {
|
|
2947
|
-
program: need.program,
|
|
2948
|
-
nodes: [],
|
|
2949
|
-
type: need.type
|
|
2950
|
-
};
|
|
2951
|
-
entry.nodes.push(need.node);
|
|
2952
|
-
missing.set(need.program, entry);
|
|
2953
|
-
}
|
|
2954
|
-
return [...missing.values()];
|
|
2955
|
-
};
|
|
2956
|
-
// src/__build_info__.ts
|
|
2957
|
-
/**
|
|
2958
|
-
* @file The published package's name and version, bound to
|
|
2959
|
-
* `packages/cli/package.json`. Do not edit by hand: `pnpm changesets:version`
|
|
2960
|
-
* and `pnpm build` rewrite it (`tools/release/build-info.ts`), and a test
|
|
2961
|
-
* fails when it is stale.
|
|
2962
|
-
*/
|
|
2963
|
-
const ORK_NAME = "@krapht/ork";
|
|
2964
|
-
const ORK_VERSION = "0.2.0";
|
|
2965
|
-
// src/run/origin.ts
|
|
2966
|
-
/**
|
|
2967
|
-
* @file Where a run's feedback comes from, beyond what its events say: Ork's
|
|
2968
|
-
* version (bound from the CLI's `package.json`) and the workflow file's
|
|
2969
|
-
* content hash.
|
|
2970
|
-
*/
|
|
2971
|
-
/** Ork's version and the workflow's hash, for the run's feedback records. */
|
|
2972
|
-
const feedbackOrigin = (workflowFile) => {
|
|
2973
|
-
const origin = { version: ORK_VERSION };
|
|
2974
|
-
try {
|
|
2975
|
-
origin.workflowHash = createHash("sha256").update(readFileSync(workflowFile)).digest("hex").slice(0, 12);
|
|
2976
|
-
} catch {}
|
|
2977
|
-
return origin;
|
|
2978
|
-
};
|
|
2979
|
-
// src/run/checkout.ts
|
|
2980
|
-
/**
|
|
2981
|
-
* @file Before a run with worktree nodes: the project must be a git repository
|
|
2982
|
-
* with a commit, its instructions, skills and agents must be committed
|
|
2983
|
-
* (worktrees get only committed files, unless `--allow-dirty`), and the
|
|
2984
|
-
* project's `git.base` must name a commit, which root worktrees start from.
|
|
2985
|
-
*/
|
|
2986
|
-
/** Why a run refuses to start with uncommitted agent files. */
|
|
2987
|
-
const dirtyAgentMessage = (files) => [
|
|
2988
|
-
"These instructions, skills or agents have uncommitted changes, and the worktrees only get committed files:",
|
|
2989
|
-
...files.slice(0, 20).map((f) => ` ${f}`),
|
|
2990
|
-
...files.length > 20 ? [` … and ${String(files.length - 20)} more`] : [],
|
|
2991
|
-
"Commit or stash them, or pass --allow-dirty to run with the committed versions."
|
|
2992
|
-
];
|
|
2993
|
-
/** Claude nodes that get their own worktree. */
|
|
2994
|
-
const worktreeNodes = (workflow) => Object.entries(workflow.nodes).filter(([, node]) => isClaudeNode(node) && node.type !== "claude.subagent" && (node.isolation ?? workflow.defaults?.isolation ?? "worktree") === "worktree").map(([id]) => id);
|
|
2995
|
-
/** Checks the project for a run's worktrees, and resolves `git.base`. */
|
|
2996
|
-
const prepareCheckout = async (workflow, options, inputs) => {
|
|
2997
|
-
const isolated = worktreeNodes(workflow);
|
|
2998
|
-
if (isolated.length === 0 || options.inPlace) return {
|
|
2999
|
-
ok: true,
|
|
3000
|
-
base: ""
|
|
3001
|
-
};
|
|
3002
|
-
const project = await inspectProject(options.cwd);
|
|
3003
|
-
if (project === null) return {
|
|
3004
|
-
ok: false,
|
|
3005
|
-
lines: [`${isolated.join(", ")} need a git worktree, but ${options.cwd} is not in a git repository with a commit; pass --in-place to run them here`]
|
|
3006
|
-
};
|
|
3007
|
-
if (project.dirtyAgentFiles.length > 0 && options.allowDirty !== true) return {
|
|
3008
|
-
ok: false,
|
|
3009
|
-
lines: dirtyAgentMessage(project.dirtyAgentFiles)
|
|
3010
|
-
};
|
|
3011
|
-
if (project.dirtyOther > 0) options.err(`${style(["yellow", "bold"], "warning")} ${String(project.dirtyOther)} uncommitted change(s) in this checkout are not in the worktrees`);
|
|
3012
|
-
try {
|
|
3013
|
-
const base = await resolveBase(project.root, gitRulesOf(workflow), inputs);
|
|
3014
|
-
return {
|
|
3015
|
-
ok: true,
|
|
3016
|
-
root: project.root,
|
|
3017
|
-
base
|
|
3018
|
-
};
|
|
3019
|
-
} catch (error) {
|
|
3020
|
-
return {
|
|
3021
|
-
ok: false,
|
|
3022
|
-
lines: [error instanceof Error ? error.message : String(error)]
|
|
3023
|
-
};
|
|
3024
|
-
}
|
|
3025
|
-
};
|
|
3026
|
-
// src/run/workflow.ts
|
|
3027
|
-
/**
|
|
3028
|
-
* @file `ork run` for a whole workflow: checks the workflow, its inputs and the
|
|
3029
|
-
* project (git, uncommitted agent files), sets up the worktree workspace, and
|
|
3030
|
-
* streams the scheduler's run. `ork resume` comes through here too, with the
|
|
3031
|
-
* run's recorded state.
|
|
3032
|
-
*/
|
|
3033
|
-
/** Why a run refuses to start when a program its tool nodes need is missing. */
|
|
3034
|
-
const missingProgramMessage = (missing) => [
|
|
3035
|
-
"This workflow runs programs that are not installed:",
|
|
3036
|
-
...missing.map((m) => ` ${m.program}, for ${m.nodes.join(", ")} (${m.type})`),
|
|
3037
|
-
"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)."
|
|
3038
|
-
];
|
|
3039
|
-
/** A run id no stored run has yet; `node-…` for a run of one node. */
|
|
3040
|
-
const newRunId = (store, prefix = "") => {
|
|
3041
|
-
for (;;) {
|
|
3042
|
-
const id = `${prefix}${randomUUID().slice(0, 8)}`;
|
|
3043
|
-
if (store.lookup(id).length === 0) return id;
|
|
3044
|
-
}
|
|
3045
|
-
};
|
|
3046
|
-
/**
|
|
3047
|
-
* Runs a whole workflow: checks it and its inputs, then hands the compiled
|
|
3048
|
-
* graph to the scheduler with the built-in node types, and streams the run.
|
|
3049
|
-
*/
|
|
3050
|
-
const runWorkflowFile = async (options) => {
|
|
3051
|
-
const failed = (lines) => {
|
|
3052
|
-
for (const line of lines) options.err(line);
|
|
3053
|
-
return {
|
|
3054
|
-
exitCode: 1,
|
|
3055
|
-
result: null
|
|
3056
|
-
};
|
|
3057
|
-
};
|
|
3058
|
-
const { report, workflow, graph, place, workflowDir } = loadWorkflow(options.file, options.cwd, options.home, options.store.projects());
|
|
3059
|
-
if (workflow === null || graph === null) return failed(formatReport(report));
|
|
3060
|
-
const pairs = parseInputPairs(options.inputs);
|
|
3061
|
-
if (!pairs.ok) return failed([`--input: ${pairs.error}`]);
|
|
3062
|
-
const recorded = Object.fromEntries(Object.entries(options.resume?.inputs ?? {}).map(([k, v]) => [k, String(v)]));
|
|
3063
|
-
const inputs = resolveInputs(workflow, {
|
|
3064
|
-
...recorded,
|
|
3065
|
-
...pairs.raw
|
|
3066
|
-
});
|
|
3067
|
-
if (!inputs.ok) return failed(inputs.errors);
|
|
3068
|
-
const installed = options.installed ?? (options.tools === void 0 ? (p) => onPath(p) : () => true);
|
|
3069
|
-
const missing = missingPrograms(workflow, installed);
|
|
3070
|
-
if (missing.length > 0) return failed(missingProgramMessage(missing));
|
|
3071
|
-
const checkout = await prepareCheckout(workflow, {
|
|
3072
|
-
...options,
|
|
3073
|
-
cwd: place.root
|
|
3074
|
-
}, inputs.inputs);
|
|
3075
|
-
if (!checkout.ok) return failed(checkout.lines);
|
|
3076
|
-
const { root, base } = checkout;
|
|
3077
|
-
const cwd = root ?? (inside(place.root, options.cwd) ? options.cwd : place.root);
|
|
3078
|
-
const runId = options.runId ?? newRunId(options.store);
|
|
3079
|
-
const runDir = runDirOf(place, runId);
|
|
3080
|
-
if (options.resume !== void 0) options.store.reopen(runId);
|
|
3081
|
-
const record = options.resume === void 0 ? createRecorder(options.store, {
|
|
3082
|
-
id: runId,
|
|
3083
|
-
workflow: workflow.name,
|
|
3084
|
-
file: path.resolve(options.cwd, options.file),
|
|
3085
|
-
project: place.root
|
|
3086
|
-
}, runDir) : appendRecorder(options.store, runId, runDir);
|
|
3087
|
-
const width = Math.max(...Object.keys(workflow.nodes).map((id) => id.length));
|
|
701
|
+
/** Turns stored events into lines, keeping the state a transcript needs. */
|
|
702
|
+
const createLogPrinter = (options) => {
|
|
3088
703
|
const tools = /* @__PURE__ */ new Map();
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
onStop: () => {
|
|
3097
|
-
(async () => {
|
|
3098
|
-
(await started.promise).interrupt();
|
|
3099
|
-
})();
|
|
3100
|
-
},
|
|
3101
|
-
onMessage: (node, body) => {
|
|
3102
|
-
(async () => {
|
|
3103
|
-
(await started.promise).tell(node, body);
|
|
3104
|
-
})();
|
|
3105
|
-
},
|
|
3106
|
-
...options.human === void 0 ? {} : { local: options.human },
|
|
3107
|
-
...options.home === void 0 ? {} : { home: options.home },
|
|
3108
|
-
...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs }
|
|
3109
|
-
});
|
|
3110
|
-
const signals = createSignals(feedbackOrigin(path.resolve(options.cwd, options.file)));
|
|
3111
|
-
const onEvent = (raw) => {
|
|
3112
|
-
for (const event of signals(raw)) {
|
|
3113
|
-
control.publish(record(event));
|
|
3114
|
-
if (options.json) options.out(JSON.stringify(event));
|
|
3115
|
-
else for (const line of formatRunEvent(event, width, tools)) options.out(line);
|
|
3116
|
-
}
|
|
3117
|
-
};
|
|
3118
|
-
const workspace = root === void 0 ? void 0 : createWorkspace({
|
|
3119
|
-
graph,
|
|
3120
|
-
runId,
|
|
3121
|
-
root,
|
|
3122
|
-
head: base,
|
|
3123
|
-
inputs: inputs.inputs,
|
|
3124
|
-
onEvent,
|
|
3125
|
-
...options.resume === void 0 ? {} : {
|
|
3126
|
-
adopt: options.resume.worktrees,
|
|
3127
|
-
known: options.resume.branches
|
|
3128
|
-
}
|
|
3129
|
-
});
|
|
3130
|
-
const executorOptions = {
|
|
3131
|
-
runtimeFor: options.runtime,
|
|
3132
|
-
inPlace: options.inPlace
|
|
3133
|
-
};
|
|
3134
|
-
if (options.driver !== void 0) executorOptions.driver = options.driver;
|
|
3135
|
-
if (options.tools !== void 0) executorOptions.tools = options.tools;
|
|
3136
|
-
const replay = options.resume === void 0 ? void 0 : createReplay(options.resume.history, options.resume.cutOff, options.resume.mail);
|
|
3137
|
-
if (replay !== void 0) onEvent({
|
|
3138
|
-
type: "run.resumed",
|
|
3139
|
-
kept: replay.kept()
|
|
3140
|
-
});
|
|
3141
|
-
const handle = startRun({
|
|
3142
|
-
graph,
|
|
3143
|
-
runId,
|
|
3144
|
-
inputs: inputs.inputs,
|
|
3145
|
-
cwd,
|
|
3146
|
-
executors: builtinExecutors(executorOptions),
|
|
3147
|
-
human: options.resume === void 0 ? control.human : replayLoopAnswers(control.human, options.resume.loopAnswers),
|
|
3148
|
-
onEvent,
|
|
3149
|
-
settings: {
|
|
3150
|
-
inPlace: options.inPlace,
|
|
3151
|
-
...options.driver === void 0 ? {} : { driver: options.driver }
|
|
3152
|
-
},
|
|
3153
|
-
...replay === void 0 ? {} : { replay },
|
|
3154
|
-
workflowDir,
|
|
3155
|
-
projectRoot: root ?? place.root,
|
|
3156
|
-
orkDir: place.dir,
|
|
3157
|
-
...workspace === void 0 ? {} : { workspace }
|
|
3158
|
-
});
|
|
3159
|
-
started.resolve(handle);
|
|
3160
|
-
options.onRun?.(handle);
|
|
3161
|
-
const result = await handle.done;
|
|
3162
|
-
await control.close();
|
|
3163
|
-
return {
|
|
3164
|
-
exitCode: result.status === "succeeded" ? 0 : 1,
|
|
3165
|
-
result
|
|
704
|
+
let width = 8;
|
|
705
|
+
return (stored) => {
|
|
706
|
+
const { event } = stored;
|
|
707
|
+
if (event.type === "run.started") width = Math.max(...event.nodes.map((n) => n.id.length), 1);
|
|
708
|
+
if (options.node !== void 0 && nodeOf(event) !== options.node) return [];
|
|
709
|
+
if (options.json) return [JSON.stringify(stored)];
|
|
710
|
+
return formatRunEvent(event, width, tools);
|
|
3166
711
|
};
|
|
3167
712
|
};
|
|
713
|
+
/**
|
|
714
|
+
* Prints a run's events; with `follow`, then each new one as it is stored:
|
|
715
|
+
* pushed over the run's socket, or polled from the store when there is none.
|
|
716
|
+
* Returns when `run.finished` is stored, the run's process is gone, or `signal`
|
|
717
|
+
* aborts.
|
|
718
|
+
*/
|
|
719
|
+
const followRun = async (store, run, out, options) => {
|
|
720
|
+
const print = createLogPrinter(options);
|
|
721
|
+
if (await followEvents(store, run.id, (stored) => {
|
|
722
|
+
for (const line of print(stored)) out(line);
|
|
723
|
+
}, {
|
|
724
|
+
follow: options.follow,
|
|
725
|
+
...options.after === void 0 ? {} : { after: options.after },
|
|
726
|
+
...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs },
|
|
727
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
728
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
729
|
+
}) === "crashed") out(style("red", `run ${run.id} stopped without finishing (its process is gone)`));
|
|
730
|
+
};
|
|
731
|
+
// src/run/origin.ts
|
|
732
|
+
/**
|
|
733
|
+
* @file Where a run's feedback comes from, beyond what its events say: Ork's
|
|
734
|
+
* version (bound from the CLI's `package.json`) and the workflow file's
|
|
735
|
+
* content hash.
|
|
736
|
+
*/
|
|
737
|
+
/** Ork's version and the workflow's hash, for the run's feedback records. */
|
|
738
|
+
const feedbackOrigin = (workflowFile) => {
|
|
739
|
+
const origin = { version: ORK_VERSION };
|
|
740
|
+
try {
|
|
741
|
+
origin.workflowHash = createHash("sha256").update(readFileSync(workflowFile)).digest("hex").slice(0, 12);
|
|
742
|
+
} catch {}
|
|
743
|
+
return origin;
|
|
744
|
+
};
|
|
3168
745
|
// src/run/node.ts
|
|
3169
746
|
/**
|
|
3170
747
|
* @file `ork run --node`: checks a workflow and its inputs, plans one Claude
|
|
@@ -3186,15 +763,21 @@ const runNode = async (options) => {
|
|
|
3186
763
|
};
|
|
3187
764
|
const { report, workflow, graph, place, workflowDir } = loadWorkflow(options.file, options.cwd, options.home, options.store.projects());
|
|
3188
765
|
if (workflow === null) return failed(formatReport(report));
|
|
766
|
+
for (const line of warningLines(report)) options.err(line);
|
|
3189
767
|
const pairs = parseInputPairs(options.inputs);
|
|
3190
768
|
if (!pairs.ok) return failed([`--input: ${pairs.error}`]);
|
|
3191
769
|
const inputs = resolveInputs(workflow, pairs.raw);
|
|
3192
770
|
if (!inputs.ok) return failed(inputs.errors);
|
|
771
|
+
const limits = runLimits(options.store, place, options.home);
|
|
772
|
+
const refused = limitsRefusal(limits, workflow);
|
|
773
|
+
if (refused.length > 0) return failed(refused);
|
|
774
|
+
const left = limits.now().leftUsd;
|
|
3193
775
|
const projectRoot = place.root;
|
|
3194
776
|
const runId = options.runId ?? newRunId(options.store, "node-");
|
|
3195
777
|
const runDir = runDirOf(place, runId);
|
|
3196
778
|
const cwd = inside(projectRoot, options.cwd) ? options.cwd : projectRoot;
|
|
3197
779
|
const plan = planNodeSession(workflow, options.nodeId, {
|
|
780
|
+
...left === void 0 ? {} : { budgetLeftUsd: left },
|
|
3198
781
|
cwd,
|
|
3199
782
|
inputs: inputs.inputs,
|
|
3200
783
|
inPlace: options.inPlace,
|
|
@@ -3204,6 +787,10 @@ const runNode = async (options) => {
|
|
|
3204
787
|
pluginDir: path.join(runDir, "plugins", options.nodeId)
|
|
3205
788
|
});
|
|
3206
789
|
if (!plan.ok) return failed([plan.error]);
|
|
790
|
+
const planned = workflow.nodes[options.nodeId];
|
|
791
|
+
if (planned !== void 0 && "notify" in planned && planned.notify) options.err(`${style(["yellow", "bold"], "warning")} ${options.nodeId}'s notify: does nothing here: a run of one node has no message bus.`);
|
|
792
|
+
const skipped = planned === void 0 ? [] : [...beforeOf(planned, workflow.defaults) === void 0 ? [] : ["before"], ..."verify" in planned && planned.verify !== void 0 ? ["verify"] : []];
|
|
793
|
+
for (const step of skipped) options.err(`${style(["yellow", "bold"], "warning")} ${options.nodeId}'s ${step}: does not run in a run of one node.`);
|
|
3207
794
|
for (const warning of plan.warnings) options.err(`${style(["yellow", "bold"], "warning")} ${warning}`);
|
|
3208
795
|
const id = options.nodeId;
|
|
3209
796
|
const recorder = createRecorder(options.store, {
|
|
@@ -3313,11 +900,362 @@ const runNode = async (options) => {
|
|
|
3313
900
|
});
|
|
3314
901
|
await control.close();
|
|
3315
902
|
return {
|
|
3316
|
-
exitCode: succeeded(outcome) ? 0 : 1,
|
|
3317
|
-
outcome
|
|
903
|
+
exitCode: succeeded(outcome) ? 0 : 1,
|
|
904
|
+
outcome
|
|
905
|
+
};
|
|
906
|
+
};
|
|
907
|
+
/** Every value of a repeatable flag: `--input a=1 --input=b=2`. */
|
|
908
|
+
// src/run/calls.ts
|
|
909
|
+
/**
|
|
910
|
+
* @file What a run needs to reach other runs: its port for `workflow.call`
|
|
911
|
+
* nodes (RFC-004), its relay when links are on (RFC-003), and the budget a
|
|
912
|
+
* caller has capped it to. A called run being resumed keeps its caller and
|
|
913
|
+
* its cap, which the run recorded.
|
|
914
|
+
*/
|
|
915
|
+
const callPorts = (options) => {
|
|
916
|
+
const { graph, store, runId, home, human, runtime, out } = options;
|
|
917
|
+
const linksOff = graph.workflow.rules?.links === "off";
|
|
918
|
+
const cap = options.budgetCap ?? options.resume?.settings.budgetCap;
|
|
919
|
+
const calledBy = options.calledBy ?? options.resume?.settings.calledBy;
|
|
920
|
+
const baseRef = options.baseRef ?? options.resume?.settings.baseRef;
|
|
921
|
+
const trigger = options.trigger ?? options.resume?.settings.trigger;
|
|
922
|
+
const untrusted = options.untrusted ?? options.resume?.settings.untrusted;
|
|
923
|
+
const own = graph.workflow.limits?.budgetUsd;
|
|
924
|
+
const budgetUsd = cap === void 0 ? own : Math.min(cap, own ?? Number.POSITIVE_INFINITY);
|
|
925
|
+
return {
|
|
926
|
+
graph: {
|
|
927
|
+
...graph,
|
|
928
|
+
workflow: {
|
|
929
|
+
...graph.workflow,
|
|
930
|
+
limits: {
|
|
931
|
+
...graph.workflow.limits,
|
|
932
|
+
...budgetUsd === void 0 ? {} : { budgetUsd }
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
calls: createCalls({
|
|
937
|
+
store,
|
|
938
|
+
runId,
|
|
939
|
+
place: options.place,
|
|
940
|
+
emit: options.emit,
|
|
941
|
+
home,
|
|
942
|
+
adopt: adoptedCalls(store, runId),
|
|
943
|
+
newId: () => newRunId(store),
|
|
944
|
+
env: maskedEnv(maskedNamesOfRun(graph.workflow, options.place, store.projects())),
|
|
945
|
+
resumeSession: (run, node, body, signal) => {
|
|
946
|
+
const { blocked, leftUsd } = runLimits(store, options.place, home).now();
|
|
947
|
+
return blocked === void 0 ? resumeChat({
|
|
948
|
+
store,
|
|
949
|
+
run,
|
|
950
|
+
node,
|
|
951
|
+
body,
|
|
952
|
+
runtime,
|
|
953
|
+
human,
|
|
954
|
+
out,
|
|
955
|
+
signal,
|
|
956
|
+
...home === void 0 ? {} : { home },
|
|
957
|
+
...leftUsd === void 0 ? {} : { budgetLeftUsd: leftUsd }
|
|
958
|
+
}) : Promise.resolve(`${NOT_STARTED}${blocked}.`);
|
|
959
|
+
}
|
|
960
|
+
}),
|
|
961
|
+
...linksOff ? {} : { relay: createRelay({
|
|
962
|
+
store,
|
|
963
|
+
runId,
|
|
964
|
+
home,
|
|
965
|
+
human
|
|
966
|
+
}) },
|
|
967
|
+
settings: {
|
|
968
|
+
...linksOff ? { links: "off" } : {},
|
|
969
|
+
...cap === void 0 ? {} : { budgetCap: cap },
|
|
970
|
+
...calledBy === void 0 ? {} : { calledBy },
|
|
971
|
+
...baseRef === void 0 ? {} : { baseRef },
|
|
972
|
+
...trigger === void 0 ? {} : { trigger },
|
|
973
|
+
...untrusted === void 0 ? {} : { untrusted }
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
};
|
|
977
|
+
// src/run/live-handlers.ts
|
|
978
|
+
/**
|
|
979
|
+
* Takes what is on its way before the run ends: with no socket, a program's
|
|
980
|
+
* message may not have been seen yet. Each command waits on `started`, so
|
|
981
|
+
* waiting on it too lets them all land first.
|
|
982
|
+
*/
|
|
983
|
+
const takeLastCommands = (control, started) => async () => {
|
|
984
|
+
control.check();
|
|
985
|
+
await started;
|
|
986
|
+
};
|
|
987
|
+
/** `ork pause`, `ork continue` and `ork stop --node`, on the run as it is. */
|
|
988
|
+
const liveControl = (live) => (command, node) => {
|
|
989
|
+
const { handle } = live;
|
|
990
|
+
if (handle === void 0) return false;
|
|
991
|
+
if (command === "pause") return handle.pause(node);
|
|
992
|
+
if (command === "continue") return handle.proceed(node);
|
|
993
|
+
return node !== void 0 && handle.stopNode(node);
|
|
994
|
+
};
|
|
995
|
+
const liveHandlers = (started) => {
|
|
996
|
+
const when = (act) => {
|
|
997
|
+
started.then(act);
|
|
998
|
+
};
|
|
999
|
+
return {
|
|
1000
|
+
onStop: () => {
|
|
1001
|
+
when((run) => {
|
|
1002
|
+
run.interrupt();
|
|
1003
|
+
});
|
|
1004
|
+
},
|
|
1005
|
+
onMessage: (node, body) => {
|
|
1006
|
+
when((run) => {
|
|
1007
|
+
run.tell(node, body);
|
|
1008
|
+
});
|
|
1009
|
+
},
|
|
1010
|
+
onRelay: (node, from, body) => {
|
|
1011
|
+
when((run) => {
|
|
1012
|
+
run.receive(node, from, body);
|
|
1013
|
+
});
|
|
1014
|
+
},
|
|
1015
|
+
onScript: (node, from, body) => {
|
|
1016
|
+
when((run) => {
|
|
1017
|
+
run.receive(node, from, body, "script");
|
|
1018
|
+
});
|
|
1019
|
+
},
|
|
1020
|
+
onNotice: (node, text) => {
|
|
1021
|
+
when((run) => {
|
|
1022
|
+
run.notice(node, text);
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
};
|
|
1027
|
+
// src/run/checkout.ts
|
|
1028
|
+
/**
|
|
1029
|
+
* @file Before a run with worktree nodes: the project must be a git repository
|
|
1030
|
+
* with a commit, its instructions, skills and agents must be committed
|
|
1031
|
+
* (worktrees get only committed files, unless `--allow-dirty`), and the
|
|
1032
|
+
* project's `git.base` must name a commit, which root worktrees start from.
|
|
1033
|
+
*/
|
|
1034
|
+
/** Why a run refuses to start with uncommitted agent files. */
|
|
1035
|
+
const dirtyAgentMessage = (files) => [
|
|
1036
|
+
"These instructions, skills or agents have uncommitted changes, and the worktrees only get committed files:",
|
|
1037
|
+
...files.slice(0, 20).map((f) => ` ${f}`),
|
|
1038
|
+
...files.length > 20 ? [` … and ${String(files.length - 20)} more`] : [],
|
|
1039
|
+
"Commit or stash them, or pass --allow-dirty to run with the committed versions."
|
|
1040
|
+
];
|
|
1041
|
+
/** Claude nodes that get their own worktree. */
|
|
1042
|
+
const worktreeNodes = (workflow) => Object.entries(workflow.nodes).filter(([, node]) => isClaudeNode(node) && node.type !== "claude.subagent" && (node.isolation ?? workflow.defaults?.isolation ?? "worktree") === "worktree").map(([id]) => id);
|
|
1043
|
+
/** Checks the project for a run's worktrees, and resolves `git.base`. */
|
|
1044
|
+
const prepareCheckout = async (workflow, options, inputs) => {
|
|
1045
|
+
const isolated = worktreeNodes(workflow);
|
|
1046
|
+
if (isolated.length === 0 || options.inPlace) return {
|
|
1047
|
+
ok: true,
|
|
1048
|
+
base: ""
|
|
1049
|
+
};
|
|
1050
|
+
const project = await inspectProject(options.cwd);
|
|
1051
|
+
if (project === null) return {
|
|
1052
|
+
ok: false,
|
|
1053
|
+
lines: [`${isolated.join(", ")} need a git worktree, but ${options.cwd} is not in a git repository with a commit; pass --in-place to run them here`]
|
|
1054
|
+
};
|
|
1055
|
+
if (project.dirtyAgentFiles.length > 0 && options.allowDirty !== true) return {
|
|
1056
|
+
ok: false,
|
|
1057
|
+
lines: dirtyAgentMessage(project.dirtyAgentFiles)
|
|
1058
|
+
};
|
|
1059
|
+
if (project.dirtyOther > 0) options.err(`${style(["yellow", "bold"], "warning")} ${String(project.dirtyOther)} uncommitted change(s) in this checkout are not in the worktrees`);
|
|
1060
|
+
try {
|
|
1061
|
+
const base = await resolveBase(project.root, gitRulesOf(workflow), inputs);
|
|
1062
|
+
return {
|
|
1063
|
+
ok: true,
|
|
1064
|
+
root: project.root,
|
|
1065
|
+
base
|
|
1066
|
+
};
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
lines: [error instanceof Error ? error.message : String(error)]
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
// src/run/preflight.ts
|
|
1075
|
+
const preflight = async (options, given) => {
|
|
1076
|
+
const { workflow, place, projects } = given;
|
|
1077
|
+
const installed = options.installed ?? (options.tools === void 0 ? (p) => onPath(p) : () => true);
|
|
1078
|
+
const missing = missingPrograms(workflow, installed);
|
|
1079
|
+
if (missing.length > 0) return {
|
|
1080
|
+
ok: false,
|
|
1081
|
+
lines: missingProgramMessage(missing)
|
|
1082
|
+
};
|
|
1083
|
+
const unset = envProblems(workflow, place, projects, options.resume?.history, options.err);
|
|
1084
|
+
if (unset.length > 0) return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
lines: unset
|
|
1087
|
+
};
|
|
1088
|
+
const limits = runLimits(options.store, place, options.home);
|
|
1089
|
+
const refused = limitsRefusal(limits, workflow, options.resume?.history);
|
|
1090
|
+
if (refused.length > 0) return {
|
|
1091
|
+
ok: false,
|
|
1092
|
+
lines: refused
|
|
1093
|
+
};
|
|
1094
|
+
const checkout = await prepareCheckout(workflow, {
|
|
1095
|
+
...options,
|
|
1096
|
+
cwd: place.root
|
|
1097
|
+
}, given.inputs);
|
|
1098
|
+
if (!checkout.ok) return {
|
|
1099
|
+
ok: false,
|
|
1100
|
+
lines: checkout.lines
|
|
1101
|
+
};
|
|
1102
|
+
return {
|
|
1103
|
+
...checkout,
|
|
1104
|
+
limits: limits.now
|
|
1105
|
+
};
|
|
1106
|
+
};
|
|
1107
|
+
// src/run/workflow.ts
|
|
1108
|
+
/**
|
|
1109
|
+
* @file `ork run` for a whole workflow: checks the workflow, its inputs and the
|
|
1110
|
+
* project (git, uncommitted agent files), sets up the worktree workspace, and
|
|
1111
|
+
* streams the scheduler's run. `ork resume` comes through here too, with the
|
|
1112
|
+
* run's recorded state.
|
|
1113
|
+
*/
|
|
1114
|
+
/**
|
|
1115
|
+
* Runs a whole workflow: checks it and its inputs, then hands the compiled
|
|
1116
|
+
* graph to the scheduler with the built-in node types, and streams the run.
|
|
1117
|
+
*/
|
|
1118
|
+
const runWorkflowFile = async (options) => {
|
|
1119
|
+
const failed = (lines) => {
|
|
1120
|
+
for (const line of lines) options.err(line);
|
|
1121
|
+
return {
|
|
1122
|
+
exitCode: 1,
|
|
1123
|
+
result: null
|
|
1124
|
+
};
|
|
1125
|
+
};
|
|
1126
|
+
const loaded = loadWorkflow(options.file, options.cwd, options.home, options.store.projects());
|
|
1127
|
+
const { workflow, graph, place, workflowDir, textHash } = loaded;
|
|
1128
|
+
if (options.expectWorkflow !== void 0 && options.expectWorkflow !== textHash) return failed([`${options.file} isn't what was accepted: it changed after ork watch read it. Accept it again with \`ork watch --accept\`.`]);
|
|
1129
|
+
if (workflow === null || graph === null) return failed(formatReport(loaded.report));
|
|
1130
|
+
const { report } = options.resume === void 0 ? await walkLoaded(loaded) : loaded;
|
|
1131
|
+
for (const line of warningLines(report)) options.err(line);
|
|
1132
|
+
if (report.walk?.complete === false) options.err(walkSummary(report.walk));
|
|
1133
|
+
const pairs = parseInputPairs(options.inputs);
|
|
1134
|
+
if (!pairs.ok) return failed([`--input: ${pairs.error}`]);
|
|
1135
|
+
const recorded = Object.fromEntries(Object.entries(options.resume?.inputs ?? {}).map(([k, v]) => [k, String(v)]));
|
|
1136
|
+
const inputs = resolveInputs(workflow, {
|
|
1137
|
+
...recorded,
|
|
1138
|
+
...pairs.raw
|
|
1139
|
+
});
|
|
1140
|
+
if (!inputs.ok) return failed(inputs.errors);
|
|
1141
|
+
const projects = options.store.projects();
|
|
1142
|
+
const ready = await preflight(options, {
|
|
1143
|
+
workflow,
|
|
1144
|
+
place,
|
|
1145
|
+
projects,
|
|
1146
|
+
inputs: inputs.inputs
|
|
1147
|
+
});
|
|
1148
|
+
if (!ready.ok) return failed(ready.lines);
|
|
1149
|
+
const { root, base } = ready;
|
|
1150
|
+
const cwd = root ?? (inside(place.root, options.cwd) ? options.cwd : place.root);
|
|
1151
|
+
const runId = options.runId ?? newRunId(options.store);
|
|
1152
|
+
const runDir = runDirOf(place, runId);
|
|
1153
|
+
if (options.resume !== void 0) options.store.reopen(runId);
|
|
1154
|
+
const record = options.resume === void 0 ? createRecorder(options.store, {
|
|
1155
|
+
id: runId,
|
|
1156
|
+
workflow: workflow.name,
|
|
1157
|
+
file: path.resolve(options.cwd, options.file),
|
|
1158
|
+
project: place.root,
|
|
1159
|
+
...options.calledBy === void 0 ? {} : { parent: options.calledBy.split("/")[0] ?? "" }
|
|
1160
|
+
}, runDir) : appendRecorder(options.store, runId, runDir);
|
|
1161
|
+
const width = Math.max(...Object.keys(workflow.nodes).map((id) => id.length));
|
|
1162
|
+
const tools = /* @__PURE__ */ new Map();
|
|
1163
|
+
const started = Promise.withResolvers();
|
|
1164
|
+
const live = {};
|
|
1165
|
+
const control = await startControl({
|
|
1166
|
+
store: options.store,
|
|
1167
|
+
runId,
|
|
1168
|
+
emit: (event) => {
|
|
1169
|
+
onEvent(event);
|
|
1170
|
+
},
|
|
1171
|
+
...liveHandlers(started.promise),
|
|
1172
|
+
onControl: liveControl(live),
|
|
1173
|
+
...options.human === void 0 ? {} : { local: options.human },
|
|
1174
|
+
...options.home === void 0 ? {} : { home: options.home },
|
|
1175
|
+
...options.intervalMs === void 0 ? {} : { intervalMs: options.intervalMs }
|
|
1176
|
+
});
|
|
1177
|
+
const signals = createSignals(feedbackOrigin(path.resolve(options.cwd, options.file)));
|
|
1178
|
+
const onEvent = (raw) => {
|
|
1179
|
+
for (const event of signals(raw)) {
|
|
1180
|
+
control.publish(record(event));
|
|
1181
|
+
if (options.json) options.out(JSON.stringify(event));
|
|
1182
|
+
else for (const line of formatRunEvent(event, width, tools)) options.out(line);
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
const workspace = root === void 0 ? void 0 : createWorkspace({
|
|
1186
|
+
graph,
|
|
1187
|
+
runId,
|
|
1188
|
+
root,
|
|
1189
|
+
head: options.baseRef ?? options.resume?.settings.baseRef ?? base,
|
|
1190
|
+
inputs: inputs.inputs,
|
|
1191
|
+
onEvent,
|
|
1192
|
+
...options.resume === void 0 ? {} : {
|
|
1193
|
+
adopt: options.resume.worktrees,
|
|
1194
|
+
known: options.resume.branches
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
const maskedNames = maskedNamesOfRun(workflow, place, projects);
|
|
1198
|
+
const executorOptions = {
|
|
1199
|
+
runtimeFor: options.runtime,
|
|
1200
|
+
inPlace: options.inPlace,
|
|
1201
|
+
maskedNames
|
|
1202
|
+
};
|
|
1203
|
+
if (options.driver !== void 0) executorOptions.driver = options.driver;
|
|
1204
|
+
if (options.tools !== void 0) executorOptions.tools = options.tools;
|
|
1205
|
+
const script = process.argv[1];
|
|
1206
|
+
if (script !== void 0 && onPath(script)) executorOptions.orkBin = script;
|
|
1207
|
+
const replay = options.resume === void 0 ? void 0 : createReplay(options.resume.history, options.resume.cutOff, options.resume.mail, options.resume.earlier);
|
|
1208
|
+
if (replay !== void 0) onEvent({
|
|
1209
|
+
type: "run.resumed",
|
|
1210
|
+
kept: replay.kept()
|
|
1211
|
+
});
|
|
1212
|
+
const ports = callPorts({
|
|
1213
|
+
...options,
|
|
1214
|
+
graph,
|
|
1215
|
+
runId,
|
|
1216
|
+
place,
|
|
1217
|
+
human: control.human,
|
|
1218
|
+
emit: onEvent
|
|
1219
|
+
});
|
|
1220
|
+
clearOutputFiles(runDir, options.err);
|
|
1221
|
+
const handle = startRun({
|
|
1222
|
+
graph: ports.graph,
|
|
1223
|
+
calls: ports.calls,
|
|
1224
|
+
runId,
|
|
1225
|
+
runDir,
|
|
1226
|
+
inputs: inputs.inputs,
|
|
1227
|
+
cwd,
|
|
1228
|
+
executors: builtinExecutors(executorOptions),
|
|
1229
|
+
human: options.resume === void 0 ? control.human : replayLoopAnswers(control.human, options.resume.loopAnswers),
|
|
1230
|
+
onEvent,
|
|
1231
|
+
settings: {
|
|
1232
|
+
inPlace: options.inPlace,
|
|
1233
|
+
...options.driver === void 0 ? {} : { driver: options.driver },
|
|
1234
|
+
...ports.settings
|
|
1235
|
+
},
|
|
1236
|
+
...ports.relay === void 0 ? {} : { relay: ports.relay },
|
|
1237
|
+
...replay === void 0 ? {} : { replay },
|
|
1238
|
+
workflowDir,
|
|
1239
|
+
projectRoot: root ?? place.root,
|
|
1240
|
+
orkDir: place.dir,
|
|
1241
|
+
...workspace === void 0 ? {} : { workspace },
|
|
1242
|
+
scrub: runScrubber(maskedNames, process.env),
|
|
1243
|
+
limits: ready.limits,
|
|
1244
|
+
beforeFinish: takeLastCommands(control, started.promise)
|
|
1245
|
+
});
|
|
1246
|
+
live.handle = handle;
|
|
1247
|
+
started.resolve(handle);
|
|
1248
|
+
options.onRun?.(handle);
|
|
1249
|
+
const result = await handle.done;
|
|
1250
|
+
clearOutputFiles(runDir, options.err);
|
|
1251
|
+
if (result.status === "succeeded") clearStateFiles(runDir, options.err);
|
|
1252
|
+
await endLinksOf(options.store, runId, result.status, options.home);
|
|
1253
|
+
await control.close();
|
|
1254
|
+
return {
|
|
1255
|
+
exitCode: result.status === "succeeded" ? 0 : 1,
|
|
1256
|
+
result
|
|
3318
1257
|
};
|
|
3319
1258
|
};
|
|
3320
|
-
/** Every value of a repeatable flag: `--input a=1 --input=b=2`. */
|
|
3321
1259
|
// src/commands/run.ts
|
|
3322
1260
|
/**
|
|
3323
1261
|
* @file `ork run`: the command and its flags. It runs a whole workflow, or one
|
|
@@ -3335,13 +1273,22 @@ const flagValues = (rawArgs, flag) => {
|
|
|
3335
1273
|
};
|
|
3336
1274
|
const isDriver = (value) => CLAUDE_DRIVERS.some((d) => d.id === value);
|
|
3337
1275
|
/** Loads the drivers only when a run starts, so other commands stay fast. */
|
|
3338
|
-
const loadRuntime = async (driver) => (await import("./src-
|
|
3339
|
-
/**
|
|
1276
|
+
const loadRuntime = async (driver) => (await import("./src-CZOVF2yU.mjs")).createRuntime(driver, sessionEnv(process.env));
|
|
1277
|
+
/**
|
|
1278
|
+
* Ctrl-C asks the run to stop, and so does a terminal closing or a polite kill,
|
|
1279
|
+
* so that the run ends tidily and leaves no result unscrubbed.
|
|
1280
|
+
*/
|
|
3340
1281
|
const onInterrupt = (stop) => {
|
|
3341
|
-
|
|
1282
|
+
let asked = false;
|
|
1283
|
+
const interrupt = () => {
|
|
1284
|
+
if (asked) return;
|
|
1285
|
+
asked = true;
|
|
3342
1286
|
writeErr(style("dim", "\ninterrupting…"));
|
|
3343
1287
|
stop();
|
|
3344
|
-
}
|
|
1288
|
+
};
|
|
1289
|
+
process.once("SIGINT", interrupt);
|
|
1290
|
+
process.once("SIGTERM", interrupt);
|
|
1291
|
+
process.once("SIGHUP", interrupt);
|
|
3345
1292
|
};
|
|
3346
1293
|
/** The terminal that answers a run's questions; none in a detached run. */
|
|
3347
1294
|
const terminalHuman = () => process.env["ORK_DETACHED"] === "1" ? void 0 : createTerminalHuman({
|
|
@@ -3395,11 +1342,35 @@ const run = defineCommand({
|
|
|
3395
1342
|
"run-id": {
|
|
3396
1343
|
type: "string",
|
|
3397
1344
|
description: "Internal: the id `--detach` picked for the run"
|
|
1345
|
+
},
|
|
1346
|
+
"called-by": {
|
|
1347
|
+
type: "string",
|
|
1348
|
+
description: "Internal: the workflow.call that started this run"
|
|
1349
|
+
},
|
|
1350
|
+
"budget-cap": {
|
|
1351
|
+
type: "string",
|
|
1352
|
+
description: "Internal: what the caller has left of its budget"
|
|
1353
|
+
},
|
|
1354
|
+
"base-ref": {
|
|
1355
|
+
type: "string",
|
|
1356
|
+
description: "Internal: what this run's worktrees start from"
|
|
1357
|
+
},
|
|
1358
|
+
trigger: {
|
|
1359
|
+
type: "string",
|
|
1360
|
+
description: "Internal: the trigger that started this run"
|
|
1361
|
+
},
|
|
1362
|
+
"expect-workflow": {
|
|
1363
|
+
type: "string",
|
|
1364
|
+
description: "Internal: the hash the workflow's text must have"
|
|
1365
|
+
},
|
|
1366
|
+
untrusted: {
|
|
1367
|
+
type: "string",
|
|
1368
|
+
description: "Internal: an input from what a trigger's check reported"
|
|
3398
1369
|
}
|
|
3399
1370
|
},
|
|
3400
1371
|
run: async ({ args, rawArgs }) => {
|
|
3401
1372
|
if (args.driver !== void 0 && !isDriver(args.driver)) {
|
|
3402
|
-
writeErr(`--driver must be ${CLAUDE_DRIVERS.map((d) => d.id).join(" or ")}, got
|
|
1373
|
+
writeErr(`--driver must be ${CLAUDE_DRIVERS.map((d) => d.id).join(" or ")}, got \`${args.driver}\``);
|
|
3403
1374
|
process.exitCode = 1;
|
|
3404
1375
|
return;
|
|
3405
1376
|
}
|
|
@@ -3454,6 +1425,14 @@ const run = defineCommand({
|
|
|
3454
1425
|
const { exitCode } = args.node === void 0 || args.node === "" ? await runWorkflowFile({
|
|
3455
1426
|
...common,
|
|
3456
1427
|
allowDirty: args["allow-dirty"],
|
|
1428
|
+
...args["called-by"] === void 0 ? {} : { calledBy: args["called-by"] },
|
|
1429
|
+
...args["budget-cap"] === void 0 ? {} : { budgetCap: Number(args["budget-cap"]) },
|
|
1430
|
+
...args["base-ref"] === void 0 ? {} : { baseRef: args["base-ref"] },
|
|
1431
|
+
...args["expect-workflow"] === void 0 ? {} : { expectWorkflow: args["expect-workflow"] },
|
|
1432
|
+
...args.trigger === void 0 ? {} : {
|
|
1433
|
+
trigger: args.trigger,
|
|
1434
|
+
untrusted: flagValues(rawArgs, "--untrusted")
|
|
1435
|
+
},
|
|
3457
1436
|
onRun: (handle) => {
|
|
3458
1437
|
onInterrupt(handle.interrupt);
|
|
3459
1438
|
}
|
|
@@ -3551,6 +1530,12 @@ const chat = defineCommand({
|
|
|
3551
1530
|
}
|
|
3552
1531
|
},
|
|
3553
1532
|
run: async ({ args }) => {
|
|
1533
|
+
const refusal = chatRefusal();
|
|
1534
|
+
if (refusal !== null) {
|
|
1535
|
+
writeErr(refusal);
|
|
1536
|
+
process.exitCode = 1;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
3554
1539
|
const store = openStore();
|
|
3555
1540
|
try {
|
|
3556
1541
|
const found = findRun(store, args.run, process.cwd());
|
|
@@ -3567,7 +1552,7 @@ const chat = defineCommand({
|
|
|
3567
1552
|
return;
|
|
3568
1553
|
}
|
|
3569
1554
|
if (args.driver !== void 0 && !isDriver(args.driver)) {
|
|
3570
|
-
writeErr(`--driver must be sdk or cli, not
|
|
1555
|
+
writeErr(`--driver must be sdk or cli, not \`${args.driver}\`.`);
|
|
3571
1556
|
process.exitCode = 1;
|
|
3572
1557
|
return;
|
|
3573
1558
|
}
|
|
@@ -3606,257 +1591,13 @@ const chat = defineCommand({
|
|
|
3606
1591
|
}
|
|
3607
1592
|
}
|
|
3608
1593
|
});
|
|
3609
|
-
// src/commands/context.ts
|
|
3610
|
-
/**
|
|
3611
|
-
* @file `ork context explain <wf> <node>`: what one node's session would load,
|
|
3612
|
-
* layer by layer. Its project instructions, every skill with the layer it
|
|
3613
|
-
* came from and whether it is live, shadowed or switched off, its context
|
|
3614
|
-
* files, its practices, and estimated tokens. Plans the session without
|
|
3615
|
-
* writing anything.
|
|
3616
|
-
*/
|
|
3617
|
-
/** A rough count, for comparing sizes: about four characters a token. */
|
|
3618
|
-
const tokens = (text) => Math.ceil(text.length / 4);
|
|
3619
|
-
/** Which project instruction file Claude Code reads from the checkout. */
|
|
3620
|
-
const instructionsOf = (stack, checkout) => {
|
|
3621
|
-
if (stack.instructions === "ignore") return "left out (project.instructions: ignore)";
|
|
3622
|
-
const has = (file) => existsSync(path.join(checkout, file));
|
|
3623
|
-
if ([
|
|
3624
|
-
"CLAUDE.md",
|
|
3625
|
-
".claude/CLAUDE.md",
|
|
3626
|
-
"CLAUDE.local.md"
|
|
3627
|
-
].some((file) => has(file))) return "CLAUDE.md, loaded by Claude Code";
|
|
3628
|
-
if (has("AGENTS.md")) return "AGENTS.md, loaded by Claude Code";
|
|
3629
|
-
return "none in the project";
|
|
3630
|
-
};
|
|
3631
|
-
const skillLines = (stack, root) => stack.skills.map((skill) => {
|
|
3632
|
-
const source = skill.text === void 0 ? path.relative(root, skill.dir) || "." : "built-in";
|
|
3633
|
-
const line = {
|
|
3634
|
-
name: skill.layer !== "project" ? `ork:${skill.name}` : skill.name,
|
|
3635
|
-
layer: skill.layer,
|
|
3636
|
-
source: source.startsWith("..") ? skill.dir : source,
|
|
3637
|
-
state: skill.shadowedBy === void 0 && skill.ignored === void 0 ? "live" : "off",
|
|
3638
|
-
tokens: tokens(skill.text ?? readFileSync(path.join(skill.dir, "SKILL.md"), "utf8"))
|
|
3639
|
-
};
|
|
3640
|
-
if (skill.shadowedBy !== void 0) line.why = `shadowed by the ${skill.shadowedBy} layer`;
|
|
3641
|
-
else if (skill.ignored !== void 0) line.why = skill.ignored === "ignore" ? "project.skills: ignore" : "not in project.skills.allow";
|
|
3642
|
-
return line;
|
|
3643
|
-
});
|
|
3644
|
-
const explainNode = (file, nodeId, cwd, home) => {
|
|
3645
|
-
const { report, workflow, graph, place, workflowDir } = loadWorkflow(file, cwd, home);
|
|
3646
|
-
if (workflow === null) return {
|
|
3647
|
-
ok: false,
|
|
3648
|
-
lines: formatReport(report)
|
|
3649
|
-
};
|
|
3650
|
-
const { root } = place;
|
|
3651
|
-
const plan = planNodeSession(workflow, nodeId, {
|
|
3652
|
-
cwd: root,
|
|
3653
|
-
inputs: {},
|
|
3654
|
-
inPlace: true,
|
|
3655
|
-
workflowDir,
|
|
3656
|
-
projectRoot: root,
|
|
3657
|
-
orkDir: place.dir,
|
|
3658
|
-
...home === void 0 ? {} : { home }
|
|
3659
|
-
});
|
|
3660
|
-
if (!plan.ok) return {
|
|
3661
|
-
ok: false,
|
|
3662
|
-
lines: [plan.error]
|
|
3663
|
-
};
|
|
3664
|
-
const { context } = plan;
|
|
3665
|
-
const practices = practiceInstructions(resolvePractices(workflow, nodeId), root);
|
|
3666
|
-
const names = ["caveman", "ponytail"].filter((n) => !practices.skipped.includes(n));
|
|
3667
|
-
const ref = graph?.agents.get(nodeId);
|
|
3668
|
-
const role = ref === void 0 ? void 0 : workflow.agents?.[ref.name]?.instructions;
|
|
3669
|
-
return {
|
|
3670
|
-
ok: true,
|
|
3671
|
-
explanation: {
|
|
3672
|
-
node: nodeId,
|
|
3673
|
-
type: workflow.nodes[nodeId]?.type ?? "",
|
|
3674
|
-
workflow: workflow.name,
|
|
3675
|
-
...ref === void 0 ? {} : { agent: {
|
|
3676
|
-
name: ref.name,
|
|
3677
|
-
where: ref.where,
|
|
3678
|
-
tokens: tokens(role ?? "")
|
|
3679
|
-
} },
|
|
3680
|
-
instructions: instructionsOf(context, root),
|
|
3681
|
-
skills: skillLines(context, root),
|
|
3682
|
-
files: context.files.map((f) => ({
|
|
3683
|
-
layer: f.layer,
|
|
3684
|
-
name: f.name,
|
|
3685
|
-
tokens: tokens(f.text)
|
|
3686
|
-
})),
|
|
3687
|
-
practices: practices.text.map((text, index) => ({
|
|
3688
|
-
name: names[index] ?? "practice",
|
|
3689
|
-
tokens: tokens(text)
|
|
3690
|
-
})),
|
|
3691
|
-
skippedPractices: practices.skipped,
|
|
3692
|
-
appendedTokens: tokens(plan.spec.systemAppend)
|
|
3693
|
-
}
|
|
3694
|
-
};
|
|
3695
|
-
};
|
|
3696
|
-
const approx = (count) => style("dim", `≈${count.toLocaleString("en")} tokens`);
|
|
3697
|
-
const formatExplanation = (e) => {
|
|
3698
|
-
const width = Math.max(8, ...e.skills.map((s) => s.name.length));
|
|
3699
|
-
const sourceWidth = Math.max(0, ...e.skills.map((s) => s.source.length));
|
|
3700
|
-
const lines = [
|
|
3701
|
-
`${style("bold", e.node)} ${style("dim", `(${e.type}) in ${e.workflow}`)}`,
|
|
3702
|
-
"",
|
|
3703
|
-
...e.agent === void 0 ? [] : [`${style("bold", "Agent")} ${e.agent.name} ${style("dim", `(${e.agent.where})`)} ${approx(e.agent.tokens)}`],
|
|
3704
|
-
`${style("bold", "Instructions")} ${e.instructions}`,
|
|
3705
|
-
"",
|
|
3706
|
-
style("bold", "Skills")
|
|
3707
|
-
];
|
|
3708
|
-
if (e.skills.length === 0) lines.push(style("dim", " none"));
|
|
3709
|
-
for (const s of e.skills) {
|
|
3710
|
-
const mark = s.state === "live" ? style("green", "✓") : style("dim", "−");
|
|
3711
|
-
const state = s.why === void 0 ? approx(s.tokens).replace("tokens", "tokens when used") : style("yellow", `off: ${s.why}`);
|
|
3712
|
-
lines.push(` ${mark} ${s.name.padEnd(width)} ${s.layer.padEnd(8)} ${style("dim", s.source.padEnd(sourceWidth))} ${state}`);
|
|
3713
|
-
}
|
|
3714
|
-
lines.push("", style("bold", "Context files"));
|
|
3715
|
-
if (e.files.length === 0) lines.push(style("dim", " none"));
|
|
3716
|
-
for (const f of e.files) lines.push(` ${f.layer.padEnd(8)} ${f.name} ${approx(f.tokens)}`);
|
|
3717
|
-
lines.push("", style("bold", "Practices"));
|
|
3718
|
-
for (const p of e.practices) lines.push(` ${p.name.padEnd(8)} ${approx(p.tokens)}`);
|
|
3719
|
-
for (const name of e.skippedPractices) lines.push(` ${name.padEnd(8)} ${style("dim", "the project's own skill wins")}`);
|
|
3720
|
-
if (e.practices.length === 0 && e.skippedPractices.length === 0) lines.push(style("dim", " off"));
|
|
3721
|
-
lines.push("", `Appended to Claude Code's system prompt: ${approx(e.appendedTokens)}`);
|
|
3722
|
-
return lines;
|
|
3723
|
-
};
|
|
3724
|
-
const explain = defineCommand({
|
|
3725
|
-
meta: {
|
|
3726
|
-
name: "explain",
|
|
3727
|
-
description: "Show what one node's session loads, layer by layer"
|
|
3728
|
-
},
|
|
3729
|
-
args: {
|
|
3730
|
-
workflow: {
|
|
3731
|
-
type: "positional",
|
|
3732
|
-
description: "A workflow file, or the name of one in the project's workflows (.ork/workflows/)",
|
|
3733
|
-
required: true
|
|
3734
|
-
},
|
|
3735
|
-
node: {
|
|
3736
|
-
type: "positional",
|
|
3737
|
-
description: "Node id",
|
|
3738
|
-
required: true
|
|
3739
|
-
},
|
|
3740
|
-
json: {
|
|
3741
|
-
type: "boolean",
|
|
3742
|
-
description: "Print JSON",
|
|
3743
|
-
default: false
|
|
3744
|
-
}
|
|
3745
|
-
},
|
|
3746
|
-
run: ({ args }) => {
|
|
3747
|
-
const cwd = process.cwd();
|
|
3748
|
-
const result = explainNode(workflowArg(args.workflow, cwd).file, args.node, cwd);
|
|
3749
|
-
if (!result.ok) {
|
|
3750
|
-
for (const line of result.lines) process.stderr.write(`${line}\n`);
|
|
3751
|
-
process.exitCode = 1;
|
|
3752
|
-
return;
|
|
3753
|
-
}
|
|
3754
|
-
if (args.json) write(JSON.stringify(result.explanation, void 0, 2));
|
|
3755
|
-
else for (const line of formatExplanation(result.explanation)) write(line);
|
|
3756
|
-
}
|
|
3757
|
-
});
|
|
3758
|
-
const context = defineCommand({
|
|
3759
|
-
meta: {
|
|
3760
|
-
name: "context",
|
|
3761
|
-
description: "Inspect the context Ork compiles for each node"
|
|
3762
|
-
},
|
|
3763
|
-
subCommands: { explain }
|
|
3764
|
-
});
|
|
3765
|
-
// src/runs/decide.ts
|
|
3766
|
-
/**
|
|
3767
|
-
* @file `ork decide`: the open decisions of live runs, from any terminal, and
|
|
3768
|
-
* answering them. An answer goes to the store (the first one wins), then the
|
|
3769
|
-
* run is woken on its socket so it picks the answer up at once.
|
|
3770
|
-
*/
|
|
3771
|
-
/** Open decisions whose run is still live, oldest first. */
|
|
3772
|
-
const pendingDecisions = (store, filter) => {
|
|
3773
|
-
const live = /* @__PURE__ */ new Map();
|
|
3774
|
-
return store.decisions({
|
|
3775
|
-
...filter,
|
|
3776
|
-
status: "open"
|
|
3777
|
-
}).filter((decision) => {
|
|
3778
|
-
if (!live.has(decision.runId)) {
|
|
3779
|
-
const [run] = store.lookup(decision.runId);
|
|
3780
|
-
live.set(decision.runId, run !== void 0 && liveStatus(run) === "running");
|
|
3781
|
-
}
|
|
3782
|
-
return live.get(decision.runId) === true;
|
|
3783
|
-
});
|
|
3784
|
-
};
|
|
3785
|
-
/** Answers an open decision with one of its options, and wakes its run. */
|
|
3786
|
-
const answerDecision = async (store, id, choice, home) => {
|
|
3787
|
-
const decision = store.decision(id);
|
|
3788
|
-
if (decision === void 0) return {
|
|
3789
|
-
ok: false,
|
|
3790
|
-
error: `No decision "${id}".`
|
|
3791
|
-
};
|
|
3792
|
-
if (!decision.options.some((o) => o.id === choice)) return {
|
|
3793
|
-
ok: false,
|
|
3794
|
-
error: `"${choice}" is not an option of ${id}: ${decision.options.map((o) => o.id).join(", ")}`
|
|
3795
|
-
};
|
|
3796
|
-
if (!store.answerDecision(id, choice, "decide")) {
|
|
3797
|
-
const now = store.decision(id);
|
|
3798
|
-
return {
|
|
3799
|
-
ok: false,
|
|
3800
|
-
error: now?.status === "answered" ? `${id} was already answered: ${now.answer ?? ""} (${now.answeredBy ?? ""})` : `${id} was withdrawn`
|
|
3801
|
-
};
|
|
3802
|
-
}
|
|
3803
|
-
return {
|
|
3804
|
-
ok: true,
|
|
3805
|
-
woke: await wakeRun(socketPath(decision.runId, home))
|
|
3806
|
-
};
|
|
3807
|
-
};
|
|
3808
|
-
// src/runs/resume.ts
|
|
3809
|
-
/**
|
|
3810
|
-
* @file Whether a stored run can be resumed, and with what: its recorded state,
|
|
3811
|
-
* and the flags it was started with unless the user changes them; and `ork
|
|
3812
|
-
* resume --detach`, which resumes it in the background.
|
|
3813
|
-
*/
|
|
3814
|
-
/** A run `ork resume` can continue: not running, not done, of a workflow. */
|
|
3815
|
-
const resumable = (store, run, flags) => {
|
|
3816
|
-
if (liveStatus(run) === "running") return {
|
|
3817
|
-
ok: false,
|
|
3818
|
-
error: `Run ${run.id} is still running.`
|
|
3819
|
-
};
|
|
3820
|
-
const events = store.events(run.id, 0).map((stored) => stored.event);
|
|
3821
|
-
const state = resumeState(events);
|
|
3822
|
-
if (state.succeeded) return {
|
|
3823
|
-
ok: false,
|
|
3824
|
-
error: `Run ${run.id} succeeded; there is nothing to resume.`
|
|
3825
|
-
};
|
|
3826
|
-
if (run.id.startsWith("node-")) return {
|
|
3827
|
-
ok: false,
|
|
3828
|
-
error: `Run ${run.id} ran one node; run it again with ork run --node.`
|
|
3829
|
-
};
|
|
3830
|
-
const driver = flags.driver ?? state.settings.driver;
|
|
3831
|
-
return {
|
|
3832
|
-
ok: true,
|
|
3833
|
-
state,
|
|
3834
|
-
inPlace: flags.inPlace ?? state.settings.inPlace ?? false,
|
|
3835
|
-
...driver === void 0 ? {} : { driver }
|
|
3836
|
-
};
|
|
3837
|
-
};
|
|
3838
|
-
/**
|
|
3839
|
-
* `ork resume --detach`: resumes `run` in a background process with the same
|
|
3840
|
-
* flags, and returns once it is running again.
|
|
3841
|
-
*/
|
|
3842
|
-
const detachResume = (store, run, flags) => detachRun({
|
|
3843
|
-
store,
|
|
3844
|
-
runId: run.id,
|
|
3845
|
-
args: [
|
|
3846
|
-
"resume",
|
|
3847
|
-
run.id,
|
|
3848
|
-
...flags
|
|
3849
|
-
],
|
|
3850
|
-
cwd: run.project,
|
|
3851
|
-
runDir: workflowArg(run.file, run.project).runDir(run.id),
|
|
3852
|
-
started: (pid) => store.lookup(run.id)[0]?.pid === pid
|
|
3853
|
-
});
|
|
3854
1594
|
// src/commands/control.ts
|
|
3855
1595
|
/**
|
|
3856
1596
|
* @file `ork decide`, `ork stop` and `ork resume`: answer a live run's open
|
|
3857
|
-
* decisions and stop a live run
|
|
3858
|
-
* then wake the run on its socket;
|
|
3859
|
-
* stopped or crashed run with its
|
|
1597
|
+
* decisions and stop a live run, or one of its nodes (ADR-041), from any
|
|
1598
|
+
* terminal (both write to the store, then wake the run on its socket;
|
|
1599
|
+
* specs/messaging.md), and continue a stopped or crashed run with its
|
|
1600
|
+
* recorded state (ADR-026).
|
|
3860
1601
|
*/
|
|
3861
1602
|
const failed = (message) => {
|
|
3862
1603
|
writeErr(message);
|
|
@@ -3900,6 +1641,11 @@ const decide = defineCommand({
|
|
|
3900
1641
|
}
|
|
3901
1642
|
},
|
|
3902
1643
|
run: async ({ args }) => {
|
|
1644
|
+
const refusal = args.id !== void 0 || args.choose !== void 0 || process.stdin.isTTY && !args.list && !args.json ? decideRefusal() : null;
|
|
1645
|
+
if (refusal !== null) {
|
|
1646
|
+
failed(refusal);
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
3903
1649
|
const store = openStore();
|
|
3904
1650
|
const human = createTerminalHuman({
|
|
3905
1651
|
input: process.stdin,
|
|
@@ -3957,13 +1703,19 @@ const decide = defineCommand({
|
|
|
3957
1703
|
const stop = defineCommand({
|
|
3958
1704
|
meta: {
|
|
3959
1705
|
name: "stop",
|
|
3960
|
-
description: "Stop a live run"
|
|
1706
|
+
description: "Stop a live run, or one of its nodes"
|
|
1707
|
+
},
|
|
1708
|
+
args: {
|
|
1709
|
+
run: {
|
|
1710
|
+
type: "positional",
|
|
1711
|
+
description: "A run id or a prefix (default: this project's newest)",
|
|
1712
|
+
required: false
|
|
1713
|
+
},
|
|
1714
|
+
node: {
|
|
1715
|
+
type: "string",
|
|
1716
|
+
description: "Stop only this running node: it fails, and the run goes on"
|
|
1717
|
+
}
|
|
3961
1718
|
},
|
|
3962
|
-
args: { run: {
|
|
3963
|
-
type: "positional",
|
|
3964
|
-
description: "A run id or a prefix (default: this project's newest)",
|
|
3965
|
-
required: false
|
|
3966
|
-
} },
|
|
3967
1719
|
run: async ({ args }) => {
|
|
3968
1720
|
const store = openStore();
|
|
3969
1721
|
try {
|
|
@@ -3973,20 +1725,22 @@ const stop = defineCommand({
|
|
|
3973
1725
|
return;
|
|
3974
1726
|
}
|
|
3975
1727
|
const { run } = found;
|
|
3976
|
-
const
|
|
3977
|
-
if (
|
|
3978
|
-
failed(
|
|
1728
|
+
const asked = await requestStop(store, run, void 0, args.node);
|
|
1729
|
+
if (!asked.ok) {
|
|
1730
|
+
failed(asked.error);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (args.node !== void 0) {
|
|
1734
|
+
write(`${style("green", "✓")} ${args.node} in run ${run.id} stops: its session is interrupted and it fails, "stopped by the user"; the run goes on.`);
|
|
3979
1735
|
return;
|
|
3980
1736
|
}
|
|
3981
|
-
store.command(run.id, { kind: "stop" });
|
|
3982
|
-
await wakeRun(socketPath(run.id));
|
|
3983
1737
|
for (let i = 0; i < 150; i += 1) {
|
|
3984
1738
|
const [now] = store.lookup(run.id);
|
|
3985
1739
|
if (now !== void 0 && liveStatus(now) !== "running") {
|
|
3986
1740
|
write(`${style("green", "✓")} run ${run.id} ${liveStatus(now)}`);
|
|
3987
1741
|
return;
|
|
3988
1742
|
}
|
|
3989
|
-
await setTimeout
|
|
1743
|
+
await setTimeout(100);
|
|
3990
1744
|
}
|
|
3991
1745
|
write(`Asked run ${run.id} to stop; it is still finishing its running nodes (see ork logs ${run.id} -f).`);
|
|
3992
1746
|
} finally {
|
|
@@ -4036,7 +1790,7 @@ const resume = defineCommand({
|
|
|
4036
1790
|
},
|
|
4037
1791
|
run: async ({ args, rawArgs }) => {
|
|
4038
1792
|
if (args.driver !== void 0 && !isDriver(args.driver)) {
|
|
4039
|
-
failed(`--driver must be sdk or cli, got
|
|
1793
|
+
failed(`--driver must be sdk or cli, got \`${args.driver}\``);
|
|
4040
1794
|
return;
|
|
4041
1795
|
}
|
|
4042
1796
|
const store = openStore();
|
|
@@ -4275,7 +2029,7 @@ const checkGit = async (run) => {
|
|
|
4275
2029
|
return [{
|
|
4276
2030
|
name: "git",
|
|
4277
2031
|
status: "ok",
|
|
4278
|
-
detail: firstLine
|
|
2032
|
+
detail: firstLine(version.stdout)
|
|
4279
2033
|
}, inside.ok ? {
|
|
4280
2034
|
name: "git repo",
|
|
4281
2035
|
status: "ok",
|
|
@@ -4309,6 +2063,134 @@ const runChecks = async (run, env, nodeVersion) => {
|
|
|
4309
2063
|
ok: drivers.some((d) => d.status === "ok") && !results.some((r) => r.status === "fail")
|
|
4310
2064
|
};
|
|
4311
2065
|
};
|
|
2066
|
+
/**
|
|
2067
|
+
* What it says of triggers (RFC-011): a long-lived process can die quietly, so
|
|
2068
|
+
* a live trigger that hasn't looked when it should have is worth saying. `home`
|
|
2069
|
+
* is the user's `~` (tests pass a folder).
|
|
2070
|
+
*/
|
|
2071
|
+
const triggerChecks = (home) => {
|
|
2072
|
+
const store = openStore(storePath(home));
|
|
2073
|
+
try {
|
|
2074
|
+
const report = triggersReport(store, store.projects(), home);
|
|
2075
|
+
const broken = report.broken.map(({ file, message }) => ({
|
|
2076
|
+
name: "triggers",
|
|
2077
|
+
status: "warn",
|
|
2078
|
+
detail: `${file}: ${message}`,
|
|
2079
|
+
hint: "Its triggers are off until it can be read."
|
|
2080
|
+
}));
|
|
2081
|
+
const live = report.triggers.filter(({ standing }) => standing === "live");
|
|
2082
|
+
if (live.length === 0) return broken;
|
|
2083
|
+
const late = lateTriggers(report, /* @__PURE__ */ new Date());
|
|
2084
|
+
if (late.length === 0) return [...broken, {
|
|
2085
|
+
name: "ork watch",
|
|
2086
|
+
status: "ok",
|
|
2087
|
+
detail: `${String(live.length)} trigger(s) looking`
|
|
2088
|
+
}];
|
|
2089
|
+
return [...broken, {
|
|
2090
|
+
name: "ork watch",
|
|
2091
|
+
status: "warn",
|
|
2092
|
+
detail: `not looking: ${late.map(({ trigger }) => trigger).join(", ")}`,
|
|
2093
|
+
hint: report.pid === void 0 ? "Start it with `ork watch --detach`." : "See `ork watch --status`."
|
|
2094
|
+
}];
|
|
2095
|
+
} finally {
|
|
2096
|
+
store.close();
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
// src/project/ork-skills.ts
|
|
2100
|
+
/**
|
|
2101
|
+
* @file The Claude Code plugin's skills (`plugins/ork/skills/`) as text, by
|
|
2102
|
+
* name, for `ork init --skills` (RFC-012). Do not edit by hand:
|
|
2103
|
+
* `node tools/release/plugin-skills.ts` and `pnpm build` rewrite it, and a
|
|
2104
|
+
* test fails when it is stale.
|
|
2105
|
+
*/
|
|
2106
|
+
const ORK_SKILLS = {
|
|
2107
|
+
principles: "---\nname: principles\ndescription: Use when deciding how to split work into an Ork workflow - which steps are programs, which are Claude sessions, where a person decides - so it costs less than doing it by hand and can't go wrong quietly.\n---\n\n# How Ork splits work\n\nOrk exists to do long jobs for less than doing them by hand, and safely. Four\nrules decide the shape of a workflow.\n\n## 1. A program for what a program can do\n\nA model turn is for judgment. Anything a program can decide, it should: run the\ntests, read a pull request's state, count what changed, wait for CI.\n\n- **A tool node** (`tool.shell`, `tool.git`, `tool.gh`, `tool.aws`) or an\n **`mcp.call`** does it with no session. With `output: { format: json }`, its\n answer routes the run: `when: \"json.state == 'OPEN'\"`.\n- **`verify:`** on a session runs a check before its report counts, so a session\n can't claim success past failing tests. **`before:`** hands it what a program\n found, so it doesn't spend turns finding it.\n- **`watch:`** waits on CI or a review with a program that looks every few\n seconds; hours of waiting cost nothing.\n- **`notify:`** lets a program tell a running session what it found, with no\n model turn to carry the news.\n\n## 2. Spend where it pays\n\n- A small model and low effort for a narrow task (`model: haiku`,\n `effort: low`); the large ones where thinking pays.\n- `budgetUsd` on each session and on the run (`limits.budgetUsd`). A loop of\n sessions without a budget or `limits.timeout` gets a warning, for good reason.\n- Give a session what it needs in its `prompt` and `before:`, rather than\n letting it search; and `isolation: readonly` for a reviewer that only reads.\n\n## 3. Make a mistake impossible before checking for it\n\n- Declare what a node says: `output.schema` lists on tool JSON and Claude\n reports (`verdict: [approve, escalate]`). Conditions then compare against a\n closed list, and a run fails a value off it rather than matching nothing.\n- A verdict is a declared field, never words to search a summary for.\n- Give each condition its opposite, and each failure a handler (`when: \"!ok\"`).\n- Check every draft with Ork's `check` tool until it's clean.\n\n## 4. A person decides what can't be undone\n\nMerging, deploying, publishing, spending a lot: put a `human.gate` before it,\nwith the safe option `recommended`. For a run nobody watches, give it a\n`timeout` and the safe `default`. You never answer a gate yourself.\n\n## An example\n\nA review that a program checks first, and a person signs off:\n\n```yaml\nschemaVersion: 1\nname: careful-review\ndescription: Lint with a program, review with a small model, and ask before merging.\ninputs:\n pr:\n type: string\n required: true\nlimits:\n budgetUsd: 3\nnodes:\n lint:\n type: tool.shell\n command: pnpm lint\n allow: [pnpm lint]\n review:\n type: claude.worker\n prompt: \"Review pull request {{inputs.pr}}. The linter passed.\"\n model: haiku\n effort: low\n isolation: readonly\n budgetUsd: 1\n output:\n schema:\n verdict: [approve, escalate]\n ask:\n type: human.gate\n question: \"Merge pull request {{inputs.pr}}? The review says {{nodes.review.output.verdict}}.\"\n options: [merge, wait]\n recommended: wait\n merge:\n type: tool.gh\n command: pr merge {{inputs.pr}} --squash\n allow: [pr merge]\nedges:\n - lint -> review\n - review -> ask\n - from: ask\n to: merge\n when: \"choice == 'merge'\"\n```\n\n`lint` costs nothing, `review` is a small model with a cap, and nothing merges\nwithout a person.\n",
|
|
2108
|
+
run: "---\nname: run\ndescription: Use when starting, following or explaining an Ork run - what it's doing, why it failed, what it cost, and what waits on the person - and when the person asks about their Ork runs.\n---\n\n# Running and following Ork workflows\n\nOrk runs workflows on the person's machine with their accounts, so a run spends\ntheir money and can push, merge or deploy. Start one only when they ask, and\nnever answer a decision for them.\n\n## Starting\n\n1. Find it: Ork's `workflows` tool gives each workflow's name and inputs.\n2. Check it first with `check` (`workflow: <name>`). Don't start one with an\n error.\n3. Start it with `start_run`, giving every required input. Claude Code asks the\n person first, unless they've allowed it. It answers with the run's id; the run goes on in the\n background, whatever happens to this conversation.\n\nWithout Ork's tools: `ork run <workflow> --detach --input name=value`.\n\n## Following\n\n- `run` gives a run's status, what it waits on, its cost so far, and each\n node's state. `runs` lists the newest runs.\n- `run_events` gives its newest events; `node` narrows them, and `last` or\n `after` widens them. A `node.finished` event with `ok: false` says why a node\n failed.\n- `explain_context` says what a node's session was given: its agent,\n instructions, skills and files. Use it when a node didn't know something.\n- `usage` says what runs spent, by workflow and model, and how close the limits\n are.\n\nSay what you found plainly: which node, what it said, and what it cost. Quote a\nnode's own words rather than retelling them.\n\n## What only the person does\n\n- **Decisions.** `decisions` lists the questions waiting on them: a gate, a loop\n that ran out of rounds, a permission. Tell them what each asks, the options,\n and the recommended one, and that they answer in `ork ui` or with\n `ork decide` in their own terminal. Don't answer one: never run\n `ork decide --id … --choose …`; with Ork's plugin, a hook stops it.\n- **Stopping, pausing, resuming.** Tell them the command: `ork stop <run>`,\n `ork pause <run>`, `ork continue <run>`, `ork resume <run>`. A node can be\n stopped alone with `--node <id>`.\n\n## Without Ork's tools\n\n| Want | Command |\n| ---------------------- | ------------------------------ |\n| Runs, newest first | `ork ps` |\n| A run's transcript | `ork logs <run>` (`--follow`) |\n| Its nodes and cost | `ork tree <run>` |\n| What runs spent | `ork usage` |\n| What waits on a person | `ork decide --list` |\n| A node's context | `ork context explain <wf> <n>` |\n",
|
|
2109
|
+
write: "---\nname: write\ndescription: Use when writing or changing an Ork workflow (a `.ork/workflows/*.ork.yaml` file) - its nodes, edges, conditions and loops - and checking each draft until Ork accepts it.\n---\n\n# Writing an Ork workflow\n\nAn Ork workflow is one YAML file in the project, `.ork/workflows/<name>.ork.yaml`.\nIt names the nodes (Claude sessions, programs, a person's decisions) and the\nedges that carry work between them. Ork runs it on the person's machine with\ntheir own accounts.\n\n## How to work\n\n1. **Look first.** Ork's `workflows` tool lists the project's workflows; read\n one that's close. `agents` lists the agents a node can use. Before you split\n the work, read `/ork:principles`: a program where a program will do, a\n session only for judgment.\n2. **Don't guess a field.** `workflow_format` gives the format; ask it for one\n node type (`nodeType: \"human.gate\"`) to see that type's fields.\n3. **Check every draft** with Ork's `check` tool (`text`, or `workflow` for a\n file), and fix what it says until it has no error. Read its warnings: they\n mean something. Without Ork's tools, run `ork validate <file>`.\n4. **Then save the file**, beginning with the schema line below, so editors can\n check it too.\n\n## The shape of a file\n\n```yaml\n# yaml-language-server: $schema=https://raw.githubusercontent.com/mimshins/ork/main/packages/schema/workflow.schema.json\nschemaVersion: 1\nname: fix-and-ship\ndescription: Fix a failing test, check it with the test suite, and ask before opening a pull request.\ninputs:\n issue:\n type: string\n required: true\n description: What fails.\ndefaults:\n model: sonnet\n budgetUsd: 2\nlimits:\n budgetUsd: 10\nnodes:\n fix:\n type: claude.worker\n prompt: \"Fix this, and keep the change small: {{inputs.issue}}\"\n tests:\n type: tool.shell\n command: pnpm test\n allow: [pnpm test]\n ask:\n type: human.gate\n question: \"Open a pull request for {{nodes.fix.output.branch}}?\"\n options: [open, hold]\n recommended: hold\n pr:\n type: tool.gh\n command: pr create --fill --head {{nodes.fix.output.branch}}\n allow: [pr create]\nedges:\n - fix -> tests\n - from: tests\n to: fix\n when: \"!ok\"\n maxRounds: 2\n - from: tests\n to: ask\n when: ok\n - from: ask\n to: pr\n when: \"choice == 'open'\"\n```\n\nNode ids are lowercase: letters, digits, `-` and `_`. Secrets never go in the\nfile: a tool reads one with `env:` and `{{env.NAME}}`.\n\n## Nodes\n\n| Type | Does |\n| --------------------------------- | ------------------------------------------------------------------- |\n| `claude.worker` | A Claude session that does one task and reports |\n| `claude.leader` | A session that plans and delegates to the nodes it owns (`parent:`) |\n| `claude.subagent` | A subagent inside its parent's session; it takes no edges |\n| `tool.shell` | Any program, run without a shell; `allow:` lists command prefixes |\n| `tool.git`, `tool.gh`, `tool.aws` | The person's own `git`, `gh`, `aws`, with an allow list |\n| `mcp.call` | One tool call on an MCP server, with no session |\n| `human.gate` | A question with options and a recommended one |\n| `control.join` | Waits for its inputs: `all` (default), `any`, or `n` of them |\n| `control.router`, `control.split` | Pass their input on; a router takes its first matching edge |\n| `workflow.call` | Starts another workflow as its own run |\n\n- **A Claude node reports** `status` (`pass`, `changes_requested`, `done` or\n `fail`), `summary`, `artifacts`, `branch`, and any field its `output.schema`\n declares, such as `verdict: [approve, escalate]`. For another verdict, declare\n a field: `status` can't take other values.\n- **A tool node reports** `exitCode`, `stdout`, `stderr`, and with\n `output: { format: json, schema: { state: [OPEN, CLOSED] } }` its JSON as\n `json`. Declare the fields you read: a run then checks them, and a typo can't\n pass.\n- **A gate reports** `choice` and `answeredBy`. A `timeout` needs a `default`.\n\n## Edges and conditions\n\n- `a -> b`, `a -> b, c`, `b, c -> j` are plain edges, taken when the source\n succeeded. A failed node takes no plain edge: handle a failure with\n `when: \"!ok\"`, or `status == 'fail'` on a Claude node.\n- `{ from: a, to: b, when: \"...\" }` is taken when the condition holds. A bare\n name reads the source's output: `when: \"verdict == 'approve'\"`. Quote any\n condition that starts with `!`, or YAML reads it as a tag.\n- Conditions have `==`, `!=`, `<`, `>`, `&&`, `||`, `!`, and `any`, `all`,\n `count`, `len`, `contains`. A join's `statuses` suits `any(statuses, 'fail')`.\n- A node runs when any edge into it is taken; only a join waits. A node with no\n way in taken is skipped, and so is what follows it.\n- Templates, `{{inputs.x}}`, `{{nodes.a.output.field}}`, `{{run.id}}`, go in\n `prompt`, `instructions`, `command`, `cwd` and `question`. Each value stays\n one argument of a command, so it can't inject one.\n\n## Loops\n\nEvery cycle needs an edge with `maxRounds`. Give the way back a condition and\nthe way out its opposite, as `!ok` and `ok` above; a plain edge beside a loop\nfires every round. When a loop runs out, Ork asks a person what to do.\n\n## What `check` refuses, and why\n\n- **A value the source can't have** (`when.value`): `status == 'failed'` (it's\n `fail`), a gate's `choice == 'aprove'` for `approve`, a value outside a\n declared list. The message names the values it can have.\n- **A loop no condition ends**, a plain edge beside a loop, a loop of sessions\n with no `limits.budgetUsd` or `limits.timeout`: warnings, each worth fixing.\n- **`no way the walk tried reaches x`**: Ork tried every way through, and none\n got to `x`. A join waiting on a branch a first-match router never takes is\n the usual cause.\n"
|
|
2110
|
+
};
|
|
2111
|
+
// src/project/init-skills.ts
|
|
2112
|
+
/**
|
|
2113
|
+
* @file Ork's skills for Claude Code in a project (RFC-012): `ork init
|
|
2114
|
+
* --skills` writes the plugin's skills into `.ork/skills/ork-<name>/`, each
|
|
2115
|
+
* naming the Ork that wrote it, and links Claude Code's skills folder there,
|
|
2116
|
+
* so teammates get them through git. It rewrites Ork's own copies, but never
|
|
2117
|
+
* one a newer Ork wrote or a skill someone else wrote; `ork doctor` says when
|
|
2118
|
+
* a copy is older than Ork.
|
|
2119
|
+
*/
|
|
2120
|
+
/** A project copy's folder name: `ork-write` for the plugin's `write`. */
|
|
2121
|
+
const SKILL_PREFIX = "ork-";
|
|
2122
|
+
const STAMP = /^<!-- Ork (\S+) wrote this with `ork init --skills`/mu;
|
|
2123
|
+
/** The Ork version a copy names, or `null` for a skill Ork didn't write. */
|
|
2124
|
+
const stampOf = (text) => STAMP.exec(text)?.[1] ?? null;
|
|
2125
|
+
/**
|
|
2126
|
+
* A plugin skill as a project skill: named `ork-<name>`, its references to
|
|
2127
|
+
* `/ork:<name>` as `/ork-<name>`, and the Ork that wrote it after its
|
|
2128
|
+
* frontmatter.
|
|
2129
|
+
*/
|
|
2130
|
+
const projectSkill = (name, text, version) => text.replace(/^name: .*$/mu, `name: ${SKILL_PREFIX}${name}`).replace(/^(---\n[\s\S]*?\n---\n)/u, `$1\n<!-- Ork ${version} wrote this with \`ork init --skills\`, which rewrites it. To keep your own version, rename its folder. -->\n`).replaceAll("/ork:", `/${SKILL_PREFIX}`);
|
|
2131
|
+
const fileOf = (root, name) => path.join(root, ORK_LAYOUT.skills, `${SKILL_PREFIX}${name}`, "SKILL.md");
|
|
2132
|
+
const sameFolder = (a, b) => {
|
|
2133
|
+
try {
|
|
2134
|
+
return realpathSync(a) === realpathSync(b);
|
|
2135
|
+
} catch {
|
|
2136
|
+
return false;
|
|
2137
|
+
}
|
|
2138
|
+
};
|
|
2139
|
+
/**
|
|
2140
|
+
* Writes Ork's skills into the project at `root`, and links Claude Code's
|
|
2141
|
+
* folder.
|
|
2142
|
+
*/
|
|
2143
|
+
const writeSkills = (root, version, options) => {
|
|
2144
|
+
const result = {
|
|
2145
|
+
created: [],
|
|
2146
|
+
updated: [],
|
|
2147
|
+
skipped: [],
|
|
2148
|
+
newer: [],
|
|
2149
|
+
links: []
|
|
2150
|
+
};
|
|
2151
|
+
for (const [name, text] of Object.entries(ORK_SKILLS)) {
|
|
2152
|
+
const file = fileOf(root, name);
|
|
2153
|
+
const shown = path.relative(root, file);
|
|
2154
|
+
const wanted = projectSkill(name, text, version);
|
|
2155
|
+
const current = existsSync(file) ? readFileSync(file, "utf8") : null;
|
|
2156
|
+
const stamp = current === null ? null : stampOf(current);
|
|
2157
|
+
if (current !== null && (current === wanted || stamp === null)) result.skipped.push(shown);
|
|
2158
|
+
else if (stamp !== null && isNewer(stamp, version)) result.newer.push(`${shown} (Ork ${stamp})`);
|
|
2159
|
+
else {
|
|
2160
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
2161
|
+
writeFileSync(file, wanted);
|
|
2162
|
+
(current === null ? result.created : result.updated).push(shown);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
const claude = path.join(root, ".claude", "skills");
|
|
2166
|
+
const ork = path.join(root, ORK_LAYOUT.skills);
|
|
2167
|
+
if (options.link && !sameFolder(claude, ork)) {
|
|
2168
|
+
const linked = linkTools(root, ["claude"]);
|
|
2169
|
+
if (linked.ok) {
|
|
2170
|
+
result.created.push(...linked.created);
|
|
2171
|
+
result.links = linked.links;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return result;
|
|
2175
|
+
};
|
|
2176
|
+
/** Whether the project has Ork's skills, current or not. */
|
|
2177
|
+
const hasSkills = (root) => Object.keys(ORK_SKILLS).some((name) => existsSync(fileOf(root, name)));
|
|
2178
|
+
/** What `ork doctor` says of the project's copies of Ork's skills. */
|
|
2179
|
+
const skillChecks = (root, version) => {
|
|
2180
|
+
const folder = path.join(root, ORK_LAYOUT.skills);
|
|
2181
|
+
if (!existsSync(folder)) return [];
|
|
2182
|
+
const old = readdirSync(folder).filter((name) => name.startsWith(SKILL_PREFIX)).flatMap((name) => {
|
|
2183
|
+
const file = path.join(folder, name, "SKILL.md");
|
|
2184
|
+
const stamp = existsSync(file) ? stampOf(readFileSync(file, "utf8")) : null;
|
|
2185
|
+
return stamp !== null && isNewer(version, stamp) ? [`${name} (${stamp})`] : [];
|
|
2186
|
+
});
|
|
2187
|
+
return old.length === 0 ? [] : [{
|
|
2188
|
+
name: "skills",
|
|
2189
|
+
status: "warn",
|
|
2190
|
+
detail: `older than Ork ${version}: ${old.join(", ")}`,
|
|
2191
|
+
hint: "Rewrite them with `ork init --skills`."
|
|
2192
|
+
}];
|
|
2193
|
+
};
|
|
4312
2194
|
// src/project/link.ts
|
|
4313
2195
|
/**
|
|
4314
2196
|
* @file What `ork link` and `ork doctor` say about a project's tool folders:
|
|
@@ -4365,6 +2247,8 @@ const linkChecks = (projectRoot) => {
|
|
|
4365
2247
|
const links = toolLinks(projectRoot, toolsInUse(projectRoot));
|
|
4366
2248
|
return Object.entries(Object.groupBy(links, (link) => link.tool)).map(([tool, group = []]) => checkOf(tool, group));
|
|
4367
2249
|
};
|
|
2250
|
+
// src/doctor/project-checks.ts
|
|
2251
|
+
const projectChecks = (root) => [...linkChecks(root), ...skillChecks(root, ORK_VERSION)];
|
|
4368
2252
|
// src/commands/install.ts
|
|
4369
2253
|
/**
|
|
4370
2254
|
* @file This installation of Ork: its name and version for the root command;
|
|
@@ -4404,7 +2288,8 @@ const doctor = defineCommand({
|
|
|
4404
2288
|
for (const result of [
|
|
4405
2289
|
orkCheck(),
|
|
4406
2290
|
...results,
|
|
4407
|
-
...place.mode === "attached" ? [] :
|
|
2291
|
+
...place.mode === "attached" ? [] : projectChecks(place.root),
|
|
2292
|
+
...triggerChecks()
|
|
4408
2293
|
]) for (const line of formatCheck(result)) write(line);
|
|
4409
2294
|
if (!ok) {
|
|
4410
2295
|
write(`\n${style("red", "Not ready:")} Ork needs Node, git, and at least one Claude driver.`);
|
|
@@ -4741,7 +2626,7 @@ const registerProject = (store, target, options = {}) => {
|
|
|
4741
2626
|
if (options.id !== void 0) {
|
|
4742
2627
|
if (!ID.test(options.id)) return {
|
|
4743
2628
|
ok: false,
|
|
4744
|
-
error: `--id
|
|
2629
|
+
error: `--id \`${options.id}\` must be lowercase letters, digits, ".", "_" or "-"`
|
|
4745
2630
|
};
|
|
4746
2631
|
if (taken.includes(options.id)) return {
|
|
4747
2632
|
ok: false,
|
|
@@ -5335,119 +3220,6 @@ const migrate = defineCommand({
|
|
|
5335
3220
|
}, (file) => formatReport(validateFile(file, process.cwd())));
|
|
5336
3221
|
}
|
|
5337
3222
|
});
|
|
5338
|
-
// src/runs/format.ts
|
|
5339
|
-
/**
|
|
5340
|
-
* @file How stored runs read on the terminal: the `ork ps` table and the `ork
|
|
5341
|
-
* tree` ownership tree with each node's state, tokens and cost.
|
|
5342
|
-
*/
|
|
5343
|
-
const STATUS_STYLE = {
|
|
5344
|
-
running: "cyan",
|
|
5345
|
-
succeeded: "green",
|
|
5346
|
-
failed: "red",
|
|
5347
|
-
crashed: "red",
|
|
5348
|
-
budget_exhausted: "yellow",
|
|
5349
|
-
interrupted: "yellow"
|
|
5350
|
-
};
|
|
5351
|
-
/** `just now`, `5m ago`, `3h ago`, `2d ago`, then the date. */
|
|
5352
|
-
const ago = (iso, now = Date.now()) => {
|
|
5353
|
-
const minutes = Math.floor((now - Date.parse(iso)) / 6e4);
|
|
5354
|
-
if (minutes < 1) return "just now";
|
|
5355
|
-
if (minutes < 60) return `${String(minutes)}m ago`;
|
|
5356
|
-
if (minutes < 1440) return `${String(Math.floor(minutes / 60))}h ago`;
|
|
5357
|
-
if (minutes < 10080) return `${String(Math.floor(minutes / 1440))}d ago`;
|
|
5358
|
-
return iso.slice(0, 10);
|
|
5359
|
-
};
|
|
5360
|
-
/** `42s`, `3m12s`, `1h05m`. */
|
|
5361
|
-
const duration = (ms) => {
|
|
5362
|
-
const s = Math.max(0, Math.round(ms / 1e3));
|
|
5363
|
-
if (s < 60) return `${String(s)}s`;
|
|
5364
|
-
if (s < 3600) return `${String(Math.floor(s / 60))}m${String(s % 60).padStart(2, "0")}s`;
|
|
5365
|
-
return `${String(Math.floor(s / 3600))}h${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}m`;
|
|
5366
|
-
};
|
|
5367
|
-
const cost = (usd) => `$${usd.toFixed(4)}`;
|
|
5368
|
-
/** The `ork ps` table; `withProject` adds each run's project. */
|
|
5369
|
-
const formatRuns = (runs, withProject, now = Date.now()) => {
|
|
5370
|
-
if (runs.length === 0) return ["No runs."];
|
|
5371
|
-
const rows = runs.map((run) => {
|
|
5372
|
-
const status = liveStatus(run);
|
|
5373
|
-
const end = run.finishedAt === void 0 ? now : Date.parse(run.finishedAt);
|
|
5374
|
-
return {
|
|
5375
|
-
id: run.id,
|
|
5376
|
-
workflow: run.workflow,
|
|
5377
|
-
status,
|
|
5378
|
-
when: `${ago(run.startedAt, now)} · ${duration(end - Date.parse(run.startedAt))}`,
|
|
5379
|
-
cost: cost(run.costUsd),
|
|
5380
|
-
project: run.project
|
|
5381
|
-
};
|
|
5382
|
-
});
|
|
5383
|
-
const width = (pick, title) => Math.max(title.length, ...rows.map((r) => pick(r).length));
|
|
5384
|
-
const w = {
|
|
5385
|
-
id: width((r) => r.id, "RUN"),
|
|
5386
|
-
workflow: width((r) => r.workflow, "WORKFLOW"),
|
|
5387
|
-
status: width((r) => r.status, "STATUS"),
|
|
5388
|
-
when: width((r) => r.when, "STARTED"),
|
|
5389
|
-
cost: width((r) => r.cost, "COST")
|
|
5390
|
-
};
|
|
5391
|
-
const header = [
|
|
5392
|
-
"RUN".padEnd(w.id),
|
|
5393
|
-
"WORKFLOW".padEnd(w.workflow),
|
|
5394
|
-
"STATUS".padEnd(w.status),
|
|
5395
|
-
"STARTED".padEnd(w.when),
|
|
5396
|
-
"COST".padStart(w.cost),
|
|
5397
|
-
...withProject ? ["PROJECT"] : []
|
|
5398
|
-
].join(" ");
|
|
5399
|
-
return [style("dim", header), ...rows.map((r) => [
|
|
5400
|
-
style("bold", r.id.padEnd(w.id)),
|
|
5401
|
-
r.workflow.padEnd(w.workflow),
|
|
5402
|
-
style(STATUS_STYLE[r.status] ?? "reset", r.status.padEnd(w.status)),
|
|
5403
|
-
style("dim", r.when.padEnd(w.when)),
|
|
5404
|
-
r.cost.padStart(w.cost),
|
|
5405
|
-
...withProject ? [style("dim", r.project)] : []
|
|
5406
|
-
].join(" "))];
|
|
5407
|
-
};
|
|
5408
|
-
const STATE_MARK = {
|
|
5409
|
-
waiting: style("dim", "·"),
|
|
5410
|
-
running: style("cyan", "●"),
|
|
5411
|
-
ok: style("green", "✓"),
|
|
5412
|
-
failed: style("red", "✗"),
|
|
5413
|
-
skipped: style("dim", "-")
|
|
5414
|
-
};
|
|
5415
|
-
/** `950`, `12.3k`, `1.2M`. */
|
|
5416
|
-
const count = (n) => {
|
|
5417
|
-
if (n < 1e3) return String(n);
|
|
5418
|
-
if (n < 1e6) return `${(n / 1e3).toFixed(1)}k`;
|
|
5419
|
-
return `${(n / 1e6).toFixed(1)}M`;
|
|
5420
|
-
};
|
|
5421
|
-
const stateText = (node) => {
|
|
5422
|
-
if (node.state === "running") return node.runs > 1 ? `running (round ${String(node.runs)})` : "running";
|
|
5423
|
-
if (node.state === "ok" || node.state === "failed") {
|
|
5424
|
-
const verdict = nodeVerdict(node.output ?? {});
|
|
5425
|
-
const line = verdict.length > 60 ? `${verdict.slice(0, 59)}…` : verdict;
|
|
5426
|
-
return node.runs > 1 ? `${line} (${String(node.runs)} runs)` : line;
|
|
5427
|
-
}
|
|
5428
|
-
return node.state;
|
|
5429
|
-
};
|
|
5430
|
-
/** The ownership tree, one line per node, children under their owner. */
|
|
5431
|
-
const formatTree = (roots) => {
|
|
5432
|
-
const lines = [];
|
|
5433
|
-
const walk = (node, lead, branch) => {
|
|
5434
|
-
lines.push({
|
|
5435
|
-
label: `${lead}${branch}${node.id}`,
|
|
5436
|
-
node
|
|
5437
|
-
});
|
|
5438
|
-
const next = lead + (branch === "├─ " ? "│ " : branch === "" ? "" : " ");
|
|
5439
|
-
node.children.forEach((child, index) => {
|
|
5440
|
-
walk(child, next, index === node.children.length - 1 ? "└─ " : "├─ ");
|
|
5441
|
-
});
|
|
5442
|
-
};
|
|
5443
|
-
for (const root of roots) walk(root, "", "");
|
|
5444
|
-
const label = Math.max(...lines.map((l) => l.label.length));
|
|
5445
|
-
const type = Math.max(...lines.map((l) => l.node.type.length));
|
|
5446
|
-
return lines.map(({ label: text, node }) => {
|
|
5447
|
-
const usage = node.inputTokens + node.outputTokens === 0 ? "" : style("dim", ` ${count(node.inputTokens)} in / ${count(node.outputTokens)} out · ${cost(node.costUsd)}`);
|
|
5448
|
-
return `${text.padEnd(label)} ${style("dim", node.type.padEnd(type))} ${STATE_MARK[node.state]} ${stateText(node)}${usage}`;
|
|
5449
|
-
});
|
|
5450
|
-
};
|
|
5451
3223
|
// src/runs/feedback.ts
|
|
5452
3224
|
/**
|
|
5453
3225
|
* @file `ork feedback`'s helpers (specs/feedback.md): finding an item by a
|
|
@@ -5599,8 +3371,9 @@ const ps = defineCommand({
|
|
|
5599
3371
|
limit: Number.isInteger(limit) && limit > 0 ? limit : 20,
|
|
5600
3372
|
...args.all ? {} : { project: placeFor(process.cwd(), store.projects()).root }
|
|
5601
3373
|
});
|
|
3374
|
+
const statusOf = (run) => runStatus(run, run.status === "running" ? store.events(run.id).map((s) => s.event) : []);
|
|
5602
3375
|
if (args.json) write(JSON.stringify(runs, void 0, 2));
|
|
5603
|
-
else for (const line of formatRuns(runs, args.all)) write(line);
|
|
3376
|
+
else for (const line of formatRuns(runs, args.all, Date.now(), statusOf)) write(line);
|
|
5604
3377
|
})
|
|
5605
3378
|
});
|
|
5606
3379
|
const runArg = {
|
|
@@ -5671,12 +3444,13 @@ const tree = defineCommand({
|
|
|
5671
3444
|
return;
|
|
5672
3445
|
}
|
|
5673
3446
|
const { run } = found;
|
|
5674
|
-
const
|
|
3447
|
+
const events = store.events(run.id).map((s) => s.event);
|
|
3448
|
+
const roots = runTree(events);
|
|
5675
3449
|
if (args.json) {
|
|
5676
3450
|
write(JSON.stringify(roots, void 0, 2));
|
|
5677
3451
|
return;
|
|
5678
3452
|
}
|
|
5679
|
-
write(`${style("bold", `run ${run.id}`)} ${style("dim", `of ${run.workflow} · ${
|
|
3453
|
+
write(`${style("bold", `run ${run.id}`)} ${style("dim", `of ${run.workflow} · ${runStatus(run, events)} · ${cost(run.costUsd)}`)}`);
|
|
5680
3454
|
for (const line of formatTree(roots)) write(line);
|
|
5681
3455
|
})
|
|
5682
3456
|
});
|
|
@@ -5868,6 +3642,35 @@ const addAgent = (options) => {
|
|
|
5868
3642
|
if (options.model !== void 0) agent.model = options.model;
|
|
5869
3643
|
return createAgent(options.name, agent, options);
|
|
5870
3644
|
};
|
|
3645
|
+
// src/project/skills-step.ts
|
|
3646
|
+
/**
|
|
3647
|
+
* @file The skills step of `ork init` (RFC-012): with `--skills` it writes
|
|
3648
|
+
* Ork's skills for Claude Code into the project, with `--no-skills` it
|
|
3649
|
+
* doesn't, and otherwise it asks on a terminal, once, saying the plugin has
|
|
3650
|
+
* them too, or says how to add them later.
|
|
3651
|
+
*/
|
|
3652
|
+
const QUESTION = "Add Ork's skills for Claude Code to .ork/skills/, so it writes workflows Ork accepts? Ork's Claude Code plugin has them too. [y/N] ";
|
|
3653
|
+
const HINT = "Ork's skills for Claude Code: add them here with `ork init --skills`, or install Ork's plugin in Claude Code (/plugin marketplace add mimshins/ork, then /plugin install ork@ork).";
|
|
3654
|
+
/** Whether to write the skills: as asked, or the person's answer. */
|
|
3655
|
+
const wanted = (root, asked) => {
|
|
3656
|
+
if (asked !== void 0) return Promise.resolve(asked);
|
|
3657
|
+
if (hasSkills(root)) return Promise.resolve(false);
|
|
3658
|
+
return process.stdin.isTTY && process.stdout.isTTY ? confirm(QUESTION) : Promise.resolve(null);
|
|
3659
|
+
};
|
|
3660
|
+
const skillsStep = async (root, options) => {
|
|
3661
|
+
const choice = await wanted(root, options.skills);
|
|
3662
|
+
if (choice === null) {
|
|
3663
|
+
write(style("dim", HINT));
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
if (!choice) return;
|
|
3667
|
+
const result = writeSkills(root, ORK_VERSION, { link: options.link });
|
|
3668
|
+
for (const item of result.created) write(`${style("green", "created")} ${item}`);
|
|
3669
|
+
for (const item of result.updated) write(`${style("green", "updated")} ${item}`);
|
|
3670
|
+
for (const item of result.skipped) write(`${style("dim", "exists")} ${item}`);
|
|
3671
|
+
for (const item of result.newer) write(`${style("yellow", "kept")} ${item}, from a newer Ork than ${ORK_VERSION}`);
|
|
3672
|
+
for (const line of formatLinks(result.links.filter((l) => l.state !== "linked"))) write(line);
|
|
3673
|
+
};
|
|
5871
3674
|
// src/commands/setup.ts
|
|
5872
3675
|
/**
|
|
5873
3676
|
* @file Setting up: `ork init` scaffolds `.ork/` in a project, records it, and
|
|
@@ -5881,12 +3684,18 @@ const init = defineCommand({
|
|
|
5881
3684
|
name: "init",
|
|
5882
3685
|
description: "Set up .ork/ in this project with a starter workflow, and link the tool folders it uses"
|
|
5883
3686
|
},
|
|
5884
|
-
args: {
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
3687
|
+
args: {
|
|
3688
|
+
link: {
|
|
3689
|
+
type: "boolean",
|
|
3690
|
+
description: "Link .claude/agents and the like to .ork/ (--no-link: don't)",
|
|
3691
|
+
default: true
|
|
3692
|
+
},
|
|
3693
|
+
skills: {
|
|
3694
|
+
type: "boolean",
|
|
3695
|
+
description: "Write Ork's skills for Claude Code into .ork/skills/ (--no-skills: don't; asked otherwise)"
|
|
3696
|
+
}
|
|
3697
|
+
},
|
|
3698
|
+
run: async ({ args }) => {
|
|
5890
3699
|
const store = openStore();
|
|
5891
3700
|
let result;
|
|
5892
3701
|
try {
|
|
@@ -5903,6 +3712,10 @@ const init = defineCommand({
|
|
|
5903
3712
|
for (const item of created) write(`${style("green", "created")} ${item}`);
|
|
5904
3713
|
for (const item of skipped) write(`${style("dim", "exists")} ${item}`);
|
|
5905
3714
|
for (const line of formatLinks(links.filter((l) => l.state !== "linked"))) write(line);
|
|
3715
|
+
await skillsStep(process.cwd(), {
|
|
3716
|
+
skills: args.skills,
|
|
3717
|
+
link: args.link
|
|
3718
|
+
});
|
|
5906
3719
|
write(`\nNext: edit ${WORKFLOW_DIR}/, then run ${style("bold", "ork validate")}.`);
|
|
5907
3720
|
}
|
|
5908
3721
|
});
|
|
@@ -6091,14 +3904,23 @@ const main = defineCommand({
|
|
|
6091
3904
|
ps,
|
|
6092
3905
|
logs,
|
|
6093
3906
|
tree,
|
|
3907
|
+
usage: () => import("./usage-BIjf9Xdt.mjs").then((module) => module.usage),
|
|
3908
|
+
prune: () => import("./usage-BIjf9Xdt.mjs").then((module) => module.prune),
|
|
6094
3909
|
decide,
|
|
3910
|
+
relay: () => import("./relay-CbK-Wme9.mjs").then((module) => module.relay),
|
|
3911
|
+
pause: () => import("./pause-DKACDfV1.mjs").then((module) => module.pause),
|
|
3912
|
+
continue: () => import("./pause-DKACDfV1.mjs").then((module) => module.continueRun),
|
|
6095
3913
|
stop,
|
|
6096
3914
|
chat,
|
|
3915
|
+
send: () => import("./send-C6yr04Fe.mjs").then((module) => module.send),
|
|
6097
3916
|
resume,
|
|
6098
3917
|
feedback,
|
|
6099
3918
|
context,
|
|
6100
3919
|
worktrees,
|
|
6101
3920
|
wt: worktrees,
|
|
3921
|
+
watch: () => import("./watch-B8o4Szd_.mjs").then((module) => module.watch),
|
|
3922
|
+
mcp: () => import("./mcp-BWsdQyri.mjs").then((module) => module.mcp),
|
|
3923
|
+
ui: () => import("./ui-CZS1XKla.mjs").then((module) => module.ui),
|
|
6102
3924
|
doctor,
|
|
6103
3925
|
upgrade
|
|
6104
3926
|
}
|