@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.
Files changed (64) hide show
  1. package/THIRD_PARTY_NOTICES.md +7 -0
  2. package/dist/__build_info__-CXXMLvjt.mjs +104 -0
  3. package/dist/bin.mjs +859 -3037
  4. package/dist/context-BFGC_C02.mjs +435 -0
  5. package/dist/find-BcvhT6UN.mjs +47 -0
  6. package/dist/format-DqpObyMl.mjs +553 -0
  7. package/dist/in-a-run-BbLhZOFW.mjs +22 -0
  8. package/dist/limits-BKYAmqCR.mjs +44 -0
  9. package/dist/mcp-BWsdQyri.mjs +91 -0
  10. package/dist/output-Q2EdeVUt.mjs +856 -0
  11. package/dist/pause-DKACDfV1.mjs +65 -0
  12. package/dist/relay-CbK-Wme9.mjs +122 -0
  13. package/dist/report-DK4LGqwF.mjs +129 -0
  14. package/dist/send-C6yr04Fe.mjs +163 -0
  15. package/dist/{src-DAsOYtoR.mjs → src-CZOVF2yU.mjs} +496 -193
  16. package/dist/src-Cz1yOUTg.mjs +11841 -0
  17. package/dist/src-D5ruZFgD.mjs +1973 -0
  18. package/dist/src-DtN1k-B3.mjs +1899 -0
  19. package/dist/ui/THIRD_PARTY_LICENSES.txt +631 -0
  20. package/dist/ui/assets/app-BlDdP396.js +61 -0
  21. package/dist/ui/assets/app-C722xNZ3.css +1 -0
  22. package/dist/ui/assets/geist-mono-cyrillic-400-normal-C51Di1Mf.woff2 +0 -0
  23. package/dist/ui/assets/geist-mono-cyrillic-400-normal-CPJFfJgk.woff +0 -0
  24. package/dist/ui/assets/geist-mono-cyrillic-500-normal-D2cXvDHF.woff2 +0 -0
  25. package/dist/ui/assets/geist-mono-cyrillic-500-normal-DfuvdVgn.woff +0 -0
  26. package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-CkxHOdCb.woff +0 -0
  27. package/dist/ui/assets/geist-mono-cyrillic-ext-400-normal-XB2BPmDK.woff2 +0 -0
  28. package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-BY1KTvO5.woff +0 -0
  29. package/dist/ui/assets/geist-mono-cyrillic-ext-500-normal-C7aYOB1D.woff2 +0 -0
  30. package/dist/ui/assets/geist-mono-latin-400-normal-B2yvC1Cq.woff +0 -0
  31. package/dist/ui/assets/geist-mono-latin-400-normal-DTRLJnHl.woff2 +0 -0
  32. package/dist/ui/assets/geist-mono-latin-500-normal-D-GG86Jb.woff +0 -0
  33. package/dist/ui/assets/geist-mono-latin-500-normal-YINYabwD.woff2 +0 -0
  34. package/dist/ui/assets/geist-mono-latin-ext-400-normal-CfzLURNc.woff +0 -0
  35. package/dist/ui/assets/geist-mono-latin-ext-400-normal-DI-rJ0UV.woff2 +0 -0
  36. package/dist/ui/assets/geist-mono-latin-ext-500-normal-Buglb9-a.woff2 +0 -0
  37. package/dist/ui/assets/geist-mono-latin-ext-500-normal-CrWEgvU_.woff +0 -0
  38. package/dist/ui/assets/geist-mono-symbols2-400-normal-BS9BGVxz.woff2 +0 -0
  39. package/dist/ui/assets/geist-mono-symbols2-400-normal-DjbiTEEA.woff +0 -0
  40. package/dist/ui/assets/geist-mono-symbols2-500-normal-CmnCPGa0.woff +0 -0
  41. package/dist/ui/assets/geist-mono-symbols2-500-normal-Q_ksOahU.woff2 +0 -0
  42. package/dist/ui/assets/geist-mono-vietnamese-400-normal-CKEaXIlx.woff2 +0 -0
  43. package/dist/ui/assets/geist-mono-vietnamese-400-normal-CZEPLOgu.woff +0 -0
  44. package/dist/ui/assets/geist-mono-vietnamese-500-normal-Bs29RrUZ.woff2 +0 -0
  45. package/dist/ui/assets/geist-mono-vietnamese-500-normal-Dx9-epDo.woff +0 -0
  46. package/dist/ui/assets/geist-sans-latin-400-normal-BOaIZNA2.woff +0 -0
  47. package/dist/ui/assets/geist-sans-latin-400-normal-gapTbOY8.woff2 +0 -0
  48. package/dist/ui/assets/geist-sans-latin-500-normal-CN2lyvyL.woff +0 -0
  49. package/dist/ui/assets/geist-sans-latin-500-normal-uokXdC-Q.woff2 +0 -0
  50. package/dist/ui/assets/geist-sans-latin-600-normal-CA1yjETN.woff +0 -0
  51. package/dist/ui/assets/geist-sans-latin-600-normal-DFOURf8L.woff2 +0 -0
  52. package/dist/ui/assets/index-BiO54L90.css +1 -0
  53. package/dist/ui/assets/index-BoG6O-Lp.js +10 -0
  54. package/dist/ui/assets/theme-3xH5jxKs.js +1 -0
  55. package/dist/ui/favicon.ico +0 -0
  56. package/dist/ui/favicon.svg +4 -0
  57. package/dist/ui/index.html +30 -0
  58. package/dist/ui/ork-wordmark-small-on-light.svg +4 -0
  59. package/dist/ui/ork-wordmark-small.svg +4 -0
  60. package/dist/ui-CZS1XKla.mjs +160 -0
  61. package/dist/usage-BIjf9Xdt.mjs +418 -0
  62. package/dist/watch-B8o4Szd_.mjs +394 -0
  63. package/package.json +4 -2
  64. package/dist/src-CJ_zF0Zj.mjs +0 -5433
@@ -0,0 +1,1973 @@
1
+ import { B as requestContinue, F as linkNodes, H as requestStop, Kt as loadAgents, Nt as readCondition, Qt as placeFor, R as unlinkNodes, Sn as NodeSchema, U as tellLiveRun, V as requestPause, X as detachResume, Z as resumable, Zt as placeFiles, at as processAlive, bn as expandEdge, cn as skillsIn, ct as checkWorkflowText, d as triggersReport, dn as newWorkflowText, en as runDirOf, fn as readDocument, it as pausedOf, ln as WorkflowEditSchema, lt as walkChecks, mn as parseWorkflow, nn as workflowDirOf, nt as followEvents, o as limitsReport, ot as runStatus, pn as renderJsonSchema, r as capsHere, rn as inside, rt as liveStatus, st as runTree, tt as detachRun, un as applyEdits, ut as walkSummary, vn as EdgeInputSchema, yt as nodeSays } from "./src-Cz1yOUTg.mjs";
2
+ import { a as pendingDecisions, i as answerDecision, r as nodeSession } from "./__build_info__-CXXMLvjt.mjs";
3
+ import { n as newRunId, t as findRun } from "./find-BcvhT6UN.mjs";
4
+ import { c as openStore, l as storePath, s as nodeOf } from "./output-Q2EdeVUt.mjs";
5
+ import { n as usageReport } from "./report-DK4LGqwF.mjs";
6
+ import path from "node:path";
7
+ import { z } from "zod";
8
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
10
+ import { homedir } from "node:os";
11
+ import { createServer } from "node:http";
12
+ // ../core/src/store/activity.ts
13
+ const LONGEST_LINE = 120;
14
+ /**
15
+ * The fields of a tool's input that say what it's working on, best first. Only
16
+ * these are shown, so what else a tool was given stays in the logs.
17
+ */
18
+ const TARGETS = [
19
+ "file_path",
20
+ "notebook_path",
21
+ "path",
22
+ "url",
23
+ "pattern",
24
+ "query",
25
+ "command"
26
+ ];
27
+ const oneLine = (text) => {
28
+ const line = text.replaceAll(/\s+/gu, " ").trim();
29
+ return line.length > LONGEST_LINE ? `${line.slice(0, 119)}…` : line;
30
+ };
31
+ const toolLine = ({ name, input }) => {
32
+ const target = TARGETS.map((field) => input[field]).find((value) => typeof value === "string" && value.trim() !== "");
33
+ if (typeof target !== "string") return name;
34
+ return oneLine(target.startsWith(`${name} `) ? target : `${name} ${target}`);
35
+ };
36
+ /** What a finished node said, or why it failed. */
37
+ const finishedNow = (ok, error, summary) => {
38
+ if (!ok) return error === void 0 ? void 0 : oneLine(error);
39
+ return typeof summary === "string" && summary.trim() !== "" ? oneLine(summary) : void 0;
40
+ };
41
+ /** Each node's activity, by id; a node that never started has none. */
42
+ const runActivity = (stored) => {
43
+ const nodes = /* @__PURE__ */ new Map();
44
+ const of = (id) => {
45
+ const found = nodes.get(id);
46
+ if (found !== void 0) return found;
47
+ const fresh = {};
48
+ nodes.set(id, fresh);
49
+ return fresh;
50
+ };
51
+ for (const { at, event } of stored) if (event.type === "node.started") {
52
+ const node = of(event.id);
53
+ node.startedAt ??= at;
54
+ delete node.finishedAt;
55
+ delete node.now;
56
+ } else if (event.type === "node.event") {
57
+ if (event.event.type === "tool.called") of(event.id).now = toolLine(event.event);
58
+ } else if (event.type === "decision.opened") of(event.node).now = oneLine(`Asked: ${event.question}`);
59
+ else if (event.type === "decision.answered") of(event.node).now = oneLine(`Answered: ${event.answer}`);
60
+ else if (event.type === "decision.withdrawn") delete of(event.node).now;
61
+ else if (event.type === "node.finished") {
62
+ const node = of(event.id);
63
+ node.finishedAt = at;
64
+ const now = finishedNow(event.ok, event.error, event.output["summary"]);
65
+ if (now === void 0) delete node.now;
66
+ else node.now = now;
67
+ }
68
+ return nodes;
69
+ };
70
+ /** The ownership tree, with what each node is doing and when it ran. */
71
+ const activeTree = (stored) => {
72
+ const activity = runActivity(stored);
73
+ const dress = (node) => {
74
+ Object.assign(node, activity.get(node.id));
75
+ for (const child of node.children) dress(child);
76
+ };
77
+ const roots = runTree(stored.map(({ event }) => event));
78
+ for (const root of roots) dress(root);
79
+ return roots;
80
+ };
81
+ // ../server/src/contract-builder.ts
82
+ /** A new workflow's name, as its file and its `name:` take it. */
83
+ const NewWorkflowSchema = z.strictObject({ name: z.string().min(1) });
84
+ /** The server checks each edit; this file imports only types of the schema. */
85
+ const WorkflowEditsSchema = z.strictObject({ edits: z.array(z.custom()).min(1) });
86
+ /** Earlier text, and the file it was based on, to ask before replacing it. */
87
+ const DraftTextSchema = z.strictObject({
88
+ text: z.string(),
89
+ baseHash: z.string().nullable()
90
+ });
91
+ const SaveWorkflowSchema = z.strictObject({
92
+ /** Write over a file that changed since this draft began. */
93
+ force: z.boolean().optional() });
94
+ const BUILDER_API = {
95
+ builderSchema: {
96
+ method: "GET",
97
+ path: "/api/workflow-schema"
98
+ },
99
+ builderCatalog: {
100
+ method: "GET",
101
+ path: "/api/projects/:id/catalog"
102
+ },
103
+ builderDrafts: {
104
+ method: "GET",
105
+ path: "/api/projects/:id/builder"
106
+ },
107
+ builderView: {
108
+ method: "GET",
109
+ path: "/api/projects/:id/builder/:name"
110
+ },
111
+ builderCreate: {
112
+ method: "POST",
113
+ path: "/api/projects/:id/builder",
114
+ body: NewWorkflowSchema
115
+ },
116
+ builderEdit: {
117
+ method: "POST",
118
+ path: "/api/projects/:id/builder/:name/edits",
119
+ body: WorkflowEditsSchema
120
+ },
121
+ builderRestore: {
122
+ method: "POST",
123
+ path: "/api/projects/:id/builder/:name/text",
124
+ body: DraftTextSchema
125
+ },
126
+ builderDiscard: {
127
+ method: "POST",
128
+ path: "/api/projects/:id/builder/:name/discard"
129
+ },
130
+ builderSave: {
131
+ method: "POST",
132
+ path: "/api/projects/:id/builder/:name/save",
133
+ body: SaveWorkflowSchema
134
+ }
135
+ };
136
+ // ../server/src/contract.ts
137
+ const Count = z.coerce.number().int().positive();
138
+ const Seq = z.coerce.number().int().nonnegative();
139
+ const Driver = z.enum(["sdk", "cli"]);
140
+ const RunsQuerySchema = z.strictObject({
141
+ project: z.string().min(1).optional(),
142
+ limit: Count.optional()
143
+ });
144
+ const UsageQuerySchema = z.strictObject({
145
+ project: z.string().min(1).optional(),
146
+ workflow: z.string().min(1).optional(),
147
+ /** ISO times: `since` included, `until` not. */
148
+ since: z.iso.datetime().optional(),
149
+ until: z.iso.datetime().optional()
150
+ });
151
+ const EventsQuerySchema = z.strictObject({
152
+ after: Seq.optional(),
153
+ node: z.string().min(1).optional(),
154
+ /** Only the first this many. */
155
+ limit: Count.optional(),
156
+ /** Only the newest this many, still oldest first. */
157
+ last: Count.optional()
158
+ });
159
+ const DecisionsQuerySchema = z.strictObject({
160
+ run: z.string().min(1).optional(),
161
+ project: z.string().min(1).optional()
162
+ });
163
+ const AnswerSchema = z.strictObject({ choose: z.string().min(1) });
164
+ const RunFlags = {
165
+ inputs: z.record(z.string(), z.string()).optional(),
166
+ driver: Driver.optional(),
167
+ inPlace: z.boolean().optional(),
168
+ allowDirty: z.boolean().optional()
169
+ };
170
+ const StartRunSchema = z.strictObject({
171
+ /** A registered project's id or root. */
172
+ project: z.string().min(1),
173
+ /** A workflow's name in the project, or a path inside the project. */
174
+ workflow: z.string().min(1),
175
+ ...RunFlags
176
+ });
177
+ const ResumeRunSchema = z.strictObject(RunFlags);
178
+ const MessageSchema = z.strictObject({
179
+ node: z.string().min(1),
180
+ body: z.string().min(1)
181
+ });
182
+ /** Two nodes of live runs, each `<run>/<node>` (RFC-003). */
183
+ const LinkSchema = z.strictObject({
184
+ a: z.string().min(3),
185
+ b: z.string().min(3),
186
+ maxMessages: z.number().int().positive().optional()
187
+ });
188
+ const UnlinkSchema = z.strictObject({
189
+ a: z.string().min(3),
190
+ b: z.string().min(3)
191
+ });
192
+ const LinksQuerySchema = z.strictObject({ run: z.string().min(1).optional() });
193
+ /** Pause, continue or stop: the whole run, or `node` alone (ADR-041). */
194
+ const ControlSchema = z.strictObject({ node: z.string().min(1).optional() });
195
+ /** Every JSON route; the two event streams are in {@link STREAMS}. */
196
+ const API = {
197
+ health: {
198
+ method: "GET",
199
+ path: "/api/health"
200
+ },
201
+ ...BUILDER_API,
202
+ projects: {
203
+ method: "GET",
204
+ path: "/api/projects"
205
+ },
206
+ workflows: {
207
+ method: "GET",
208
+ path: "/api/projects/:id/workflows"
209
+ },
210
+ runs: {
211
+ method: "GET",
212
+ path: "/api/runs",
213
+ query: RunsQuerySchema
214
+ },
215
+ run: {
216
+ method: "GET",
217
+ path: "/api/runs/:id"
218
+ },
219
+ tree: {
220
+ method: "GET",
221
+ path: "/api/runs/:id/tree"
222
+ },
223
+ graph: {
224
+ method: "GET",
225
+ path: "/api/runs/:id/graph"
226
+ },
227
+ events: {
228
+ method: "GET",
229
+ path: "/api/runs/:id/events",
230
+ query: EventsQuerySchema
231
+ },
232
+ decisions: {
233
+ method: "GET",
234
+ path: "/api/decisions",
235
+ query: DecisionsQuerySchema
236
+ },
237
+ decision: {
238
+ method: "GET",
239
+ path: "/api/decisions/:id"
240
+ },
241
+ answer: {
242
+ method: "POST",
243
+ path: "/api/decisions/:id",
244
+ body: AnswerSchema
245
+ },
246
+ start: {
247
+ method: "POST",
248
+ path: "/api/runs",
249
+ body: StartRunSchema
250
+ },
251
+ resume: {
252
+ method: "POST",
253
+ path: "/api/runs/:id/resume",
254
+ body: ResumeRunSchema
255
+ },
256
+ stop: {
257
+ method: "POST",
258
+ path: "/api/runs/:id/stop",
259
+ body: ControlSchema
260
+ },
261
+ pause: {
262
+ method: "POST",
263
+ path: "/api/runs/:id/pause",
264
+ body: ControlSchema
265
+ },
266
+ continue: {
267
+ method: "POST",
268
+ path: "/api/runs/:id/continue",
269
+ body: ControlSchema
270
+ },
271
+ message: {
272
+ method: "POST",
273
+ path: "/api/runs/:id/messages",
274
+ body: MessageSchema
275
+ },
276
+ usage: {
277
+ method: "GET",
278
+ path: "/api/usage",
279
+ query: UsageQuerySchema
280
+ },
281
+ triggers: {
282
+ method: "GET",
283
+ path: "/api/triggers"
284
+ },
285
+ links: {
286
+ method: "GET",
287
+ path: "/api/links",
288
+ query: LinksQuerySchema
289
+ },
290
+ link: {
291
+ method: "POST",
292
+ path: "/api/links",
293
+ body: LinkSchema
294
+ },
295
+ unlink: {
296
+ method: "POST",
297
+ path: "/api/links/end",
298
+ body: UnlinkSchema
299
+ }
300
+ };
301
+ /** The Server-Sent Event streams, and the events each sends. */
302
+ const STREAMS = {
303
+ /**
304
+ * `run-event` per stored event (`id:` is its seq), then `end`. Starts after
305
+ * `Last-Event-ID`, or `?after=`.
306
+ */
307
+ run: "/api/runs/:id/stream",
308
+ /** `runs` and `decisions`, each when it changes; `?project=` keeps one. */
309
+ overview: "/api/stream"
310
+ };
311
+ // ../server/src/graph.ts
312
+ /**
313
+ * @file A workflow or a run as the graph the UI draws: its nodes, its edges
314
+ * with their shorthand expanded, and its channels. Where they sit follows
315
+ * from those, so nothing about that is stored (RFC-008).
316
+ */
317
+ const workflowGraph = (workflow) => ({
318
+ nodes: Object.entries(workflow.nodes).map(([id, node]) => ({
319
+ id,
320
+ type: node.type,
321
+ parent: node.parent
322
+ })),
323
+ edges: (workflow.edges ?? []).flatMap((input, index) => {
324
+ const expanded = expandEdge(input, index);
325
+ return expanded.ok ? expanded.edges.map(({ from, to, when, maxRounds }) => ({
326
+ from,
327
+ to,
328
+ when,
329
+ maxRounds
330
+ })) : [];
331
+ }),
332
+ channels: (workflow.messages ?? []).map(({ between: [a, b] }) => [a, b])
333
+ });
334
+ /** The graph of a workflow file, or `null` if it is gone or does not parse. */
335
+ const readWorkflowGraph = (file) => {
336
+ if (!existsSync(file)) return null;
337
+ const parsed = parseWorkflow(readFileSync(file, "utf8"));
338
+ if (!parsed.ok) return null;
339
+ return workflowGraph(parsed.workflow);
340
+ };
341
+ /**
342
+ * The graph of a run: what it recorded it ran, so editing the file later does
343
+ * not redraw it. A run from before edges were kept has only its file to go by.
344
+ */
345
+ const runGraph = (events, file) => {
346
+ const started = events.findLast((event) => event.type === "run.started");
347
+ const onFile = readWorkflowGraph(file);
348
+ const nodes = started?.type === "run.started" ? started.nodes : [];
349
+ const edges = started?.type === "run.started" ? started.edges : void 0;
350
+ const channels = started?.type === "run.started" ? started.channels : void 0;
351
+ return {
352
+ nodes: nodes.length === 0 ? onFile?.nodes ?? [] : nodes.map(({ id, type, parent }) => ({
353
+ id,
354
+ type,
355
+ parent
356
+ })),
357
+ edges: edges ?? onFile?.edges ?? [],
358
+ channels: channels ?? onFile?.channels ?? [],
359
+ ...started?.type === "run.started" && started.budgetUsd !== void 0 ? { budgetUsd: started.budgetUsd } : {}
360
+ };
361
+ };
362
+ // ../server/src/guards.ts
363
+ /**
364
+ * @file What the server lets in (ADR-039, specs/server.md#security): only its
365
+ * own loopback `Host` (against DNS rebinding); only with its token, from the
366
+ * session cookie or an `Authorization: Bearer` header; and, for a write sent
367
+ * with the cookie, only from its own `Origin` (against CSRF). The token in
368
+ * `?token=` is swapped once for the cookie, with a redirect that takes it out
369
+ * of the address bar. Pure: it reads the request's parts and decides.
370
+ */
371
+ const WRITES = /* @__PURE__ */ new Set([
372
+ "POST",
373
+ "PUT",
374
+ "PATCH",
375
+ "DELETE"
376
+ ]);
377
+ /** A new token: 32 random bytes. */
378
+ const newToken = () => randomBytes(32).toString("base64url");
379
+ /** The session cookie's name; one per port, as cookies ignore ports. */
380
+ const cookieName = (port) => `ork_ui_${String(port)}`;
381
+ /** Whether `given` is the token, compared in constant time. */
382
+ const sameToken = (given, token) => {
383
+ const a = Buffer.from(given);
384
+ const b = Buffer.from(token);
385
+ return a.length === b.length && timingSafeEqual(a, b);
386
+ };
387
+ const header = (request, name) => {
388
+ const value = request.headers[name];
389
+ return Array.isArray(value) ? value[0] : value;
390
+ };
391
+ /** The server's own origins: `127.0.0.1` and `localhost` on its port. */
392
+ const ownOrigins = (port) => [`http://127.0.0.1:${String(port)}`, `http://localhost:${String(port)}`];
393
+ const cookieValue = (request, name) => (header(request, "cookie") ?? "").split(";").map((part) => part.trim().split("=")).find(([key]) => key === name)?.slice(1).join("=");
394
+ const bearer = (request) => {
395
+ return /^Bearer\s+(\S+)$/iu.exec(header(request, "authorization") ?? "")?.[1];
396
+ };
397
+ /** The session cookie that holds `token`. */
398
+ const sessionCookie = (port, token) => `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/`;
399
+ const exchange = (request, context) => {
400
+ const url = new URL(request.url, "http://127.0.0.1");
401
+ const given = url.searchParams.get("token");
402
+ if (given === null || request.method !== "GET") return null;
403
+ if (!sameToken(given, context.token)) return {
404
+ kind: "refuse",
405
+ status: 401,
406
+ error: "That link's token is not this server's; open it again with `ork ui`."
407
+ };
408
+ url.searchParams.delete("token");
409
+ return {
410
+ kind: "exchange",
411
+ cookie: sessionCookie(context.port, context.token),
412
+ location: `${url.pathname}${url.search}`
413
+ };
414
+ };
415
+ /** Decides whether a request may go on. */
416
+ const guard = (request, context) => {
417
+ const host = header(request, "host") ?? "";
418
+ if (!ownOrigins(context.port).includes(`http://${host}`)) return {
419
+ kind: "refuse",
420
+ status: 403,
421
+ error: `Host ${host} is not this server.`
422
+ };
423
+ const exchanged = exchange(request, context);
424
+ if (exchanged !== null) return exchanged;
425
+ const fromBearer = bearer(request);
426
+ const fromCookie = cookieValue(request, cookieName(context.port));
427
+ const given = fromBearer ?? fromCookie;
428
+ if (given === void 0 || !sameToken(given, context.token)) return {
429
+ kind: "refuse",
430
+ status: 401,
431
+ error: "Not signed in; open the UI with `ork ui`."
432
+ };
433
+ if (!WRITES.has(request.method)) return { kind: "pass" };
434
+ const origin = header(request, "origin");
435
+ if ((fromBearer === void 0 || origin !== void 0) && (origin === void 0 || !ownOrigins(context.port).includes(origin))) return {
436
+ kind: "refuse",
437
+ status: 403,
438
+ error: `Origin ${origin ?? "(none)"} may not write here.`
439
+ };
440
+ const type = header(request, "content-type") ?? "";
441
+ if (!/^application\/json\s*(;|$)/iu.test(type)) return {
442
+ kind: "refuse",
443
+ status: 415,
444
+ error: "Send JSON (Content-Type: application/json)."
445
+ };
446
+ return { kind: "pass" };
447
+ };
448
+ /** How often an open event stream gets a keep-alive comment. */
449
+ const KEEP_ALIVE_MS = 15e3;
450
+ /** Sent with every answer: no sniffing, no framing, no referrer, no cache. */
451
+ const BASE_HEADERS = {
452
+ "x-content-type-options": "nosniff",
453
+ "x-frame-options": "DENY",
454
+ "referrer-policy": "no-referrer",
455
+ "cache-control": "no-store"
456
+ };
457
+ const sendJson = (res, status, body, headers = {}) => {
458
+ res.writeHead(status, {
459
+ ...BASE_HEADERS,
460
+ ...headers,
461
+ "content-type": "application/json; charset=utf-8"
462
+ }).end(JSON.stringify(body));
463
+ };
464
+ const sendError = (res, status, error) => {
465
+ sendJson(res, status, { error });
466
+ };
467
+ /** Reads a JSON body; an empty body reads as `{}`. */
468
+ const readJson = async (req) => {
469
+ const chunks = [];
470
+ let size = 0;
471
+ for await (const chunk of req) {
472
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
473
+ size += buffer.length;
474
+ if (size > 1048576) return {
475
+ ok: false,
476
+ status: 413,
477
+ error: "The body is too large."
478
+ };
479
+ chunks.push(buffer);
480
+ }
481
+ const text = Buffer.concat(chunks).toString("utf8").trim();
482
+ if (text === "") return {
483
+ ok: true,
484
+ value: {}
485
+ };
486
+ try {
487
+ return {
488
+ ok: true,
489
+ value: JSON.parse(text)
490
+ };
491
+ } catch {
492
+ return {
493
+ ok: false,
494
+ status: 400,
495
+ error: "The body is not valid JSON."
496
+ };
497
+ }
498
+ };
499
+ /** Opens a Server-Sent Event stream on `res`. */
500
+ const openEventStream = (res, keepAliveMs = KEEP_ALIVE_MS) => {
501
+ const abort = new AbortController();
502
+ res.writeHead(200, {
503
+ ...BASE_HEADERS,
504
+ "content-type": "text/event-stream; charset=utf-8",
505
+ connection: "keep-alive"
506
+ });
507
+ res.write("retry: 1000\n\n");
508
+ const timer = setInterval(() => {
509
+ res.write(": keep-alive\n\n");
510
+ }, keepAliveMs);
511
+ const close = () => {
512
+ if (abort.signal.aborted) return;
513
+ clearInterval(timer);
514
+ abort.abort();
515
+ res.end();
516
+ };
517
+ res.on("close", close);
518
+ return {
519
+ send: (event, data, id) => {
520
+ if (abort.signal.aborted) return;
521
+ const head = id === void 0 ? "" : `id: ${String(id)}\n`;
522
+ res.write(`${head}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
523
+ },
524
+ close,
525
+ signal: abort.signal
526
+ };
527
+ };
528
+ // ../server/src/router.ts
529
+ const ok = (body) => ({
530
+ status: 200,
531
+ body
532
+ });
533
+ const accepted = (body) => ({
534
+ status: 202,
535
+ body
536
+ });
537
+ const fail = (status, error) => ({
538
+ status,
539
+ error
540
+ });
541
+ /** A path such as `/api/runs/:id` as a pattern with its param names. */
542
+ const compilePath = (path) => {
543
+ const keys = [];
544
+ const source = path.replaceAll(/:(\w+)/gu, (_, key) => {
545
+ keys.push(key);
546
+ return "([^/]+)";
547
+ });
548
+ return {
549
+ pattern: new RegExp(`^${source}$`, "u"),
550
+ keys
551
+ };
552
+ };
553
+ const isRouteName = (name) => name in API;
554
+ const compile = (name) => {
555
+ const spec = API[name];
556
+ const { pattern, keys } = compilePath(spec.path);
557
+ const compiled = {
558
+ name,
559
+ method: spec.method,
560
+ pattern,
561
+ keys
562
+ };
563
+ if (spec.query !== void 0) compiled.query = spec.query;
564
+ if (spec.body !== void 0) compiled.body = spec.body;
565
+ return compiled;
566
+ };
567
+ const ROUTES = Object.keys(API).filter((name) => isRouteName(name)).map((name) => compile(name));
568
+ const issues = (error) => error.issues.map((i) => i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message).join("; ");
569
+ const parse = (schema, value) => {
570
+ if (schema === void 0) return {
571
+ ok: true,
572
+ value: void 0
573
+ };
574
+ const parsed = schema.safeParse(value);
575
+ return parsed.success ? {
576
+ ok: true,
577
+ value: parsed.data
578
+ } : {
579
+ ok: false,
580
+ error: issues(parsed.error)
581
+ };
582
+ };
583
+ const decode = (value) => {
584
+ try {
585
+ return decodeURIComponent(value);
586
+ } catch {
587
+ return null;
588
+ }
589
+ };
590
+ /**
591
+ * Answers the request if a JSON route has its path, and returns whether one
592
+ * did; a path with no route for the method gets 405.
593
+ */
594
+ const route = async (handlers, req, res, url) => {
595
+ const found = ROUTES.map((r) => ({
596
+ r,
597
+ m: r.pattern.exec(url.pathname)
598
+ })).filter(({ m }) => m !== null);
599
+ if (found.length === 0) return false;
600
+ const hit = found.find(({ r }) => r.method === req.method);
601
+ if (hit === void 0 || hit.m === null) {
602
+ sendError(res, 405, `${req.method ?? ""} is not allowed here.`);
603
+ return true;
604
+ }
605
+ const { r, m } = hit;
606
+ const params = {};
607
+ for (const [i, key] of r.keys.entries()) {
608
+ const value = decode(m[i + 1] ?? "");
609
+ if (value === null) {
610
+ sendError(res, 400, `The ${key} in the path is not valid.`);
611
+ return true;
612
+ }
613
+ params[key] = value;
614
+ }
615
+ const query = parse(r.query, Object.fromEntries(url.searchParams));
616
+ if (!query.ok) {
617
+ sendError(res, 400, query.error);
618
+ return true;
619
+ }
620
+ const read = req.method === "GET" ? {
621
+ ok: true,
622
+ value: void 0
623
+ } : await readJson(req);
624
+ if (!read.ok) {
625
+ sendError(res, read.status, read.error);
626
+ return true;
627
+ }
628
+ const body = parse(r.body, read.value);
629
+ if (!body.ok) {
630
+ sendError(res, 400, body.error);
631
+ return true;
632
+ }
633
+ const handler = handlers[r.name];
634
+ const reply = await handler({
635
+ params,
636
+ query: query.value,
637
+ body: body.value
638
+ });
639
+ if ("error" in reply) sendError(res, reply.status, reply.error);
640
+ else sendJson(res, reply.status, reply.body);
641
+ return true;
642
+ };
643
+ // ../server/src/context.ts
644
+ /**
645
+ * @file What the server's handlers share: the store it reads and writes, where
646
+ * the sockets are, the `ork` script that detached runs start from, and how a
647
+ * request names a run or a project.
648
+ */
649
+ /** A run by id or unique prefix: 404 when there is none, 400 when several. */
650
+ const runOf = (context, id) => {
651
+ const found = findRun(context.store, id, "");
652
+ if (found.ok) return found;
653
+ const status = context.store.lookup(id).length === 0 ? 404 : 400;
654
+ return {
655
+ ok: false,
656
+ refusal: fail(status, found.error)
657
+ };
658
+ };
659
+ /** A registered project by id or root. */
660
+ const projectOf = (context, key) => context.store.projects().find((p) => p.id === key || p.root === key);
661
+ /** A run as the API shows it: with its live status and what is paused. */
662
+ const viewOf$2 = (context, run) => {
663
+ const events = run.status === "running" ? context.store.events(run.id).map((stored) => stored.event) : [];
664
+ return {
665
+ ...run,
666
+ live: runStatus(run, events),
667
+ paused: pausedOf(events)
668
+ };
669
+ };
670
+ // ../server/src/mcp-answers.ts
671
+ /**
672
+ * @file What `ork mcp`'s tools answer with (RFC-012): a route's reply as text,
673
+ * with long strings cut so one answer can't fill a model's context, and the
674
+ * workflow format a part at a time, as the whole is about 50 KB.
675
+ */
676
+ /** How long a string can be before an answer cuts it. */
677
+ const LONGEST = 400;
678
+ /** A value with every long string cut, saying how much was left out. */
679
+ const shrink = (value, longest = LONGEST) => {
680
+ if (typeof value === "string") return value.length <= longest ? value : `${value.slice(0, longest)}… (${String(value.length - longest)} more characters)`;
681
+ if (Array.isArray(value)) return value.map((item) => shrink(item, longest));
682
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, shrink(item, longest)]));
683
+ return value;
684
+ };
685
+ const answer = (value) => ({ text: JSON.stringify(shrink(value)) });
686
+ const refused = (error) => ({
687
+ text: error,
688
+ isError: true
689
+ });
690
+ /** A route's reply as an answer, `then` shaping what it gave. */
691
+ const fromReply = async (reply, then = (body) => body) => {
692
+ const settled = await reply;
693
+ return "error" in settled ? refused(settled.error) : answer(then(settled.body));
694
+ };
695
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
696
+ /** A field of an object, or `undefined`. */
697
+ const at = (value, key) => isRecord$1(value) ? value[key] : void 0;
698
+ /** A node type's name, from its branch of the schema. */
699
+ const typeOf = (branch) => {
700
+ const type = at(at(at(branch, "properties"), "type"), "const");
701
+ return typeof type === "string" ? type : void 0;
702
+ };
703
+ /**
704
+ * The format's JSON Schema: with no node type, the workflow's own fields and
705
+ * the node types there are; with one, that type's full schema.
706
+ */
707
+ const formatPart = (nodeType) => {
708
+ const schema = JSON.parse(renderJsonSchema());
709
+ const oneOf = at(at(at(at(schema, "properties"), "nodes"), "additionalProperties"), "oneOf");
710
+ const branches = Array.isArray(oneOf) ? oneOf : [];
711
+ const types = branches.flatMap((branch) => typeOf(branch) ?? []);
712
+ const properties = at(schema, "properties");
713
+ if (nodeType === void 0) return answer({
714
+ ...isRecord$1(schema) ? schema : {},
715
+ properties: {
716
+ ...isRecord$1(properties) ? properties : {},
717
+ nodes: {
718
+ description: "A map of node ids to nodes. Ask for one node type to see its fields.",
719
+ nodeTypes: types
720
+ }
721
+ }
722
+ });
723
+ const branch = branches.find((item) => typeOf(item) === nodeType);
724
+ return branch === void 0 ? refused(`No node type \`${nodeType}\`. The types are: ${types.map((type) => `\`${type}\``).join(", ")}.`) : { text: JSON.stringify(branch) };
725
+ };
726
+ // ../server/src/mcp-tool.ts
727
+ /**
728
+ * @file One of `ork mcp`'s tools (RFC-012): its name, what it says it does, its
729
+ * arguments as a Zod shape, whether it only reads, and its call, which checks
730
+ * the arguments before it runs.
731
+ */
732
+ const tool = (name, description, shape, run, options = {}) => {
733
+ const schema = z.strictObject(shape);
734
+ return {
735
+ name,
736
+ description,
737
+ shape,
738
+ readOnly: options.readOnly ?? true,
739
+ call: (args) => {
740
+ const parsed = schema.safeParse(args ?? {});
741
+ return Promise.resolve(parsed.success ? run(parsed.data) : refused(z.prettifyError(parsed.error)));
742
+ }
743
+ };
744
+ };
745
+ // ../server/src/write-routes.ts
746
+ /**
747
+ * @file The API's write routes (specs/server.md#api): answer a decision; stop,
748
+ * pause or continue a run or one of its nodes (ADR-041); message a live run's
749
+ * node; and start or resume a run in the background. Each does what its CLI
750
+ * command does (`ork decide --id`, `stop`, `pause`, `continue`, `chat -m`,
751
+ * `run --detach`, `resume --detach`), through the same functions in core:
752
+ * rows in the store, a wake on the run's socket, or a detached process. The
753
+ * server itself runs nothing.
754
+ */
755
+ /** A refused pause, continue or stop: an unknown node is 404, the rest 409. */
756
+ const commandAnswer = (runId, asked) => {
757
+ if (asked.ok) return accepted({ runId });
758
+ return fail(asked.error.includes("is not a node of") ? 404 : 409, asked.error);
759
+ };
760
+ /** The `ork run` / `ork resume` flags a request's options stand for. */
761
+ const flagsOf = (options) => [
762
+ ...Object.entries(options.inputs ?? {}).flatMap(([name, value]) => ["--input", `${name}=${value}`]),
763
+ ...options.driver === void 0 ? [] : ["--driver", options.driver],
764
+ ...options.inPlace === void 0 ? [] : [options.inPlace ? "--in-place" : "--no-in-place"],
765
+ ...options.allowDirty === true ? ["--allow-dirty"] : []
766
+ ];
767
+ const WORKFLOW_SUFFIX = ".ork.yaml";
768
+ /**
769
+ * A workflow file of `project`: a name in its workflows folder, or a path
770
+ * inside the project's root or its Ork folder.
771
+ */
772
+ const workflowFile = (project, workflow) => {
773
+ const workflows = placeFiles(project).workflows;
774
+ return [path.join(workflows, `${workflow}${WORKFLOW_SUFFIX}`), path.resolve(project.root, workflow)].find((file) => (inside(project.root, file) || inside(project.dir, file)) && existsSync(file) && statSync(file).isFile()) ?? null;
775
+ };
776
+ const answerStatus = (error) => {
777
+ if (error.startsWith("No decision")) return 404;
778
+ return error.includes("is not an option") ? 400 : 409;
779
+ };
780
+ const writeRoutes = (context) => {
781
+ const script = context.script === void 0 ? {} : { script: context.script };
782
+ return {
783
+ answer: async ({ params, body }) => {
784
+ const answered = await answerDecision(context.store, params.id, body.choose, context.home, "ui");
785
+ return answered.ok ? ok({
786
+ decision: params.id,
787
+ answer: body.choose
788
+ }) : fail(answerStatus(answered.error), answered.error);
789
+ },
790
+ stop: async ({ params, body }) => {
791
+ const found = runOf(context, params.id);
792
+ if (!found.ok) return found.refusal;
793
+ const { run } = found;
794
+ const { store, home } = context;
795
+ return commandAnswer(run.id, await requestStop(store, run, home, body.node));
796
+ },
797
+ pause: async ({ params, body }) => {
798
+ const found = runOf(context, params.id);
799
+ if (!found.ok) return found.refusal;
800
+ const { run } = found;
801
+ const { store, home } = context;
802
+ return commandAnswer(run.id, await requestPause(store, run, home, body.node));
803
+ },
804
+ continue: async ({ params, body }) => {
805
+ const found = runOf(context, params.id);
806
+ if (!found.ok) return found.refusal;
807
+ const { run } = found;
808
+ const { store, home } = context;
809
+ return commandAnswer(run.id, await requestContinue(store, run, home, body.node));
810
+ },
811
+ message: async ({ params, body }) => {
812
+ const found = runOf(context, params.id);
813
+ if (!found.ok) return found.refusal;
814
+ const { run } = found;
815
+ const { session } = nodeSession(context.store.events(run.id, 0), body.node);
816
+ if (!session.known) return fail(404, `${body.node} is not a node of run ${run.id}.`);
817
+ if (!session.chattable) return fail(400, `${body.node} is not a leader or worker; only they have sessions to talk to.`);
818
+ if (liveStatus(run) !== "running") return fail(409, `Run \`${run.id}\` is not running; to talk to a finished run's node, use \`ork chat ${run.id} ${body.node}\`.`);
819
+ await tellLiveRun(context.store, run, body.node, body.body, context.home);
820
+ return accepted({ runId: run.id });
821
+ },
822
+ link: async ({ body }) => {
823
+ const linked = await linkNodes(context.store, body.a, body.b, {
824
+ maxMessages: body.maxMessages,
825
+ home: context.home
826
+ });
827
+ return linked.ok ? ok(linked.link) : fail(409, linked.error);
828
+ },
829
+ unlink: async ({ body }) => {
830
+ const ended = await unlinkNodes(context.store, body.a, body.b, context.home);
831
+ return ended.ok ? ok({ ended: true }) : fail(404, ended.error);
832
+ },
833
+ start: async ({ body }) => {
834
+ const request = body;
835
+ const project = projectOf(context, request.project);
836
+ if (project === void 0) return fail(404, `No registered project \`${request.project}\`; register it with \`ork init\` or \`ork register\`.`);
837
+ const file = workflowFile(project, request.workflow);
838
+ if (file === null) return fail(404, `No workflow \`${request.workflow}\` in \`${project.id}\`'s workflows or inside its root.`);
839
+ const runId = newRunId(context.store);
840
+ const detached = await detachRun({
841
+ store: context.store,
842
+ runId,
843
+ args: [
844
+ "run",
845
+ file,
846
+ ...flagsOf(request),
847
+ "--run-id",
848
+ runId
849
+ ],
850
+ cwd: project.root,
851
+ runDir: runDirOf(project, runId),
852
+ ...script
853
+ });
854
+ return detached.ok ? accepted({ runId }) : fail(400, detached.error || "The run did not start.");
855
+ },
856
+ resume: async ({ params, body }) => {
857
+ const found = runOf(context, params.id);
858
+ if (!found.ok) return found.refusal;
859
+ const ready = resumable(context.store, found.run, {
860
+ ...body.inPlace === void 0 ? {} : { inPlace: body.inPlace },
861
+ ...body.driver === void 0 ? {} : { driver: body.driver }
862
+ });
863
+ if (!ready.ok) return fail(409, ready.error);
864
+ const detached = await detachResume(context.store, found.run, flagsOf(body), context.script);
865
+ return detached.ok ? accepted({ runId: found.run.id }) : fail(400, detached.error || "The run did not resume.");
866
+ }
867
+ };
868
+ };
869
+ // ../server/src/mcp-act.ts
870
+ /**
871
+ * @file The tools `ork mcp` gives Claude Code beyond reading (RFC-012):
872
+ * `check`, which checks a draft or a file as `ork validate` does, walk and
873
+ * all, and `start_run`, which starts a run in the background as `ork run
874
+ * --detach` does. Neither answers a decision; starting is the one that
875
+ * spends, and Claude Code asks the person before each.
876
+ */
877
+ /** How many of a walk's endings `check` lists when asked for them. */
878
+ const MOST_ENDINGS = 20;
879
+ const project$1 = z.string().min(1).describe("A registered project's id or root");
880
+ /** The text to check, and the file whose folder its paths resolve from. */
881
+ const textOf = (context, key, args) => {
882
+ const place = projectOf(context, key);
883
+ if (place === void 0) return {
884
+ ok: false,
885
+ answer: refused(`No registered project \`${key}\`.`)
886
+ };
887
+ if (args.workflow === void 0 === (args.text === void 0)) return {
888
+ ok: false,
889
+ answer: refused("Give either `workflow` (a name or path) or `text`.")
890
+ };
891
+ if (args.text !== void 0) {
892
+ const file = path.join(placeFiles(place).workflows, "draft.ork.yaml");
893
+ return {
894
+ ok: true,
895
+ text: args.text,
896
+ file,
897
+ place
898
+ };
899
+ }
900
+ const file = workflowFile(place, args.workflow ?? "");
901
+ return file === null ? {
902
+ ok: false,
903
+ answer: refused(`No workflow \`${args.workflow ?? ""}\` in \`${place.id}\`'s workflows or inside its root.`)
904
+ } : {
905
+ ok: true,
906
+ text: readFileSync(file, "utf8"),
907
+ file,
908
+ place
909
+ };
910
+ };
911
+ const checkTool = (context) => tool("check", "Checks a workflow as `ork validate` does: a draft's `text`, or a `workflow` by name or path. It gives each problem with its line, then walks every way through without running anything. Ask for `paths` to see how a run can end.", {
912
+ project: project$1,
913
+ workflow: z.string().min(1).optional(),
914
+ text: z.string().min(1).optional(),
915
+ paths: z.boolean().optional()
916
+ }, async (args) => {
917
+ const found = textOf(context, args.project, args);
918
+ if (!found.ok) return found.answer;
919
+ const { diagnostics, walk } = await walkChecks(checkWorkflowText(found.text, {
920
+ place: found.place,
921
+ workflowDir: workflowDirOf(found.place, found.file),
922
+ registered: context.store.projects(),
923
+ ...context.home === void 0 ? {} : { home: context.home }
924
+ }));
925
+ const count = (severity) => diagnostics.filter((each) => each.severity === severity).length;
926
+ return answer({
927
+ errors: count("error"),
928
+ warnings: count("warning"),
929
+ diagnostics: diagnostics.map(({ severity, code, message, line, column }) => line === void 0 ? {
930
+ severity,
931
+ code,
932
+ message
933
+ } : {
934
+ severity,
935
+ code,
936
+ message,
937
+ line,
938
+ column
939
+ }),
940
+ walk: walk === void 0 ? null : walkSummary(walk),
941
+ ...args.paths === true && walk !== void 0 ? { endings: walk.endings.slice(0, MOST_ENDINGS) } : {}
942
+ });
943
+ });
944
+ const startTool = (context) => {
945
+ const { start } = writeRoutes(context);
946
+ return tool("start_run", "Starts a workflow in the background, as `ork run --detach` does, and gives its run id. It spends money, and its questions wait on a person, who answers in `ork ui` or with `ork decide`.", {
947
+ project: project$1,
948
+ workflow: z.string().min(1).describe("A workflow's name, or a path"),
949
+ inputs: z.record(z.string(), z.string()).optional()
950
+ }, (args) => fromReply(start({
951
+ params: {},
952
+ query: void 0,
953
+ body: args
954
+ }), (started) => ({
955
+ ...started,
956
+ next: "Follow it with `run` and `run_events`. A decision it opens waits on a person."
957
+ })), { readOnly: false });
958
+ };
959
+ /** `check`, and `start_run` where starting is allowed. */
960
+ const actingTools = (context, options) => [checkTool(context), ...options.start ? [startTool(context)] : []];
961
+ // ../server/src/builder-files.ts
962
+ /**
963
+ * @file A workflow's file for the builder (RFC-008): reading it only where it
964
+ * stays inside the project, and writing it, the one place the server writes
965
+ * into a project, whole or not at all and never through a link.
966
+ */
967
+ /** Most file systems stop at 255 bytes, and the suffix takes some of those. */
968
+ const LONGEST_NAME = 200;
969
+ /** A name that stays one file in the workflows folder. */
970
+ const safeName = (name) => name.length > 0 && Buffer.byteLength(name) <= LONGEST_NAME && path.basename(name) === name && !name.startsWith(".") && !name.includes("\\") && !name.includes("\0");
971
+ /** Call only once `unreadableReason` has passed the file. */
972
+ const readText = (file) => existsSync(file) ? readFileSync(file, "utf8") : void 0;
973
+ const hashOf = (text) => createHash("sha256").update(text).digest("hex");
974
+ /** A file by its contents, or `null` when there's none. */
975
+ const hashOfFile = (file) => {
976
+ const text = readText(file);
977
+ return text === void 0 ? null : hashOf(text);
978
+ };
979
+ /** The folder a project's own files must stay in, links resolved. */
980
+ const homeOf = (project) => realpathSync(inside(project.root, project.dir) ? project.root : project.dir);
981
+ /** Whether something is at `at`, even a link that leads nowhere. */
982
+ const isThere = (at) => lstatSync(at, { throwIfNoEntry: false }) !== void 0;
983
+ /** The nearest existing folder on the way to `file`. */
984
+ const nearestFolder = (file) => {
985
+ let folder = path.dirname(file);
986
+ while (!isThere(folder) && path.dirname(folder) !== folder) folder = path.dirname(folder);
987
+ return folder;
988
+ };
989
+ const folderOutside = (project, file) => {
990
+ const folder = nearestFolder(file);
991
+ return !existsSync(folder) || !existsSync(project.root) || !existsSync(project.dir) || !inside(homeOf(project), realpathSync(folder));
992
+ };
993
+ const LEADS_OUT = "The workflows folder leads out of the project, so Ork won't use it.";
994
+ /** Why this file may not be read; a link could lead out of the project. */
995
+ const unreadableReason = (project, file) => {
996
+ if (folderOutside(project, file)) return LEADS_OUT;
997
+ if (!isThere(file)) return void 0;
998
+ if (!existsSync(file) || !inside(homeOf(project), realpathSync(file))) return `${path.basename(file)} is a link that leads out of the project, so Ork won't read it.`;
999
+ return statSync(file).isFile() ? void 0 : `${path.basename(file)} isn't a file.`;
1000
+ };
1001
+ /** Why this file may not be written: it must not be a link at all. */
1002
+ const outsideReason = (project, file) => {
1003
+ if (folderOutside(project, file)) return LEADS_OUT;
1004
+ const found = lstatSync(file, { throwIfNoEntry: false });
1005
+ if (found?.isSymbolicLink() === true) return `${path.basename(file)} is a symbolic link, so Ork won't write through it.`;
1006
+ return found === void 0 || found.isFile() ? void 0 : `${path.basename(file)} isn't a file.`;
1007
+ };
1008
+ /** Writes `text` so that a reader sees the old file or the new, never part. */
1009
+ const writeWhole = (file, text) => {
1010
+ mkdirSync(path.dirname(file), { recursive: true });
1011
+ const temporary = `${file}.${randomBytes(6).toString("hex")}.tmp`;
1012
+ writeFileSync(temporary, text, { flag: "wx" });
1013
+ try {
1014
+ if (existsSync(file)) chmodSync(temporary, statSync(file).mode);
1015
+ renameSync(temporary, file);
1016
+ } catch (failed) {
1017
+ rmSync(temporary, { force: true });
1018
+ throw failed;
1019
+ }
1020
+ };
1021
+ // ../server/src/builder-graph.ts
1022
+ /**
1023
+ * @file The graph of a draft (RFC-008). A draft isn't valid until it's
1024
+ * finished, so this reads what the text says rather than the parsed workflow:
1025
+ * a node with no type yet still gets a card, and an edge that can't be read
1026
+ * is left for the checks to report.
1027
+ */
1028
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1029
+ const asArray = (value) => Array.isArray(value) ? value : [];
1030
+ const EMPTY = {
1031
+ nodes: [],
1032
+ edges: [],
1033
+ channels: []
1034
+ };
1035
+ /** The nodes finished enough to say what they'd report. */
1036
+ const validNodes = (document) => Object.fromEntries(Object.entries(isRecord(document["nodes"]) ? document["nodes"] : {}).flatMap(([id, node]) => {
1037
+ const parsed = NodeSchema.safeParse(node);
1038
+ return parsed.success ? [[id, parsed.data]] : [];
1039
+ }));
1040
+ /** What the text says, and the graph to draw from it. */
1041
+ const draftGraph = (text) => {
1042
+ const document = readDocument(text);
1043
+ if (!isRecord(document)) return {
1044
+ document: document ?? null,
1045
+ graph: EMPTY
1046
+ };
1047
+ const nodes = isRecord(document["nodes"]) ? Object.entries(document["nodes"]).map(([id, node]) => ({
1048
+ id,
1049
+ type: isRecord(node) && typeof node["type"] === "string" ? node["type"] : "",
1050
+ parent: isRecord(node) && typeof node["parent"] === "string" ? node["parent"] : void 0
1051
+ })) : [];
1052
+ const edges = asArray(document["edges"]).flatMap((input, index) => {
1053
+ const parsed = EdgeInputSchema.safeParse(input);
1054
+ if (!parsed.success) return [];
1055
+ const expanded = expandEdge(parsed.data, index);
1056
+ return expanded.ok ? expanded.edges.map(({ from, to, when, maxRounds }) => ({
1057
+ from,
1058
+ to,
1059
+ when,
1060
+ maxRounds,
1061
+ condition: readCondition(when)
1062
+ })) : [];
1063
+ });
1064
+ const channels = asArray(document["messages"]).flatMap((channel) => {
1065
+ const [a, b] = isRecord(channel) ? asArray(channel["between"]) : [];
1066
+ return typeof a === "string" && typeof b === "string" ? [[a, b]] : [];
1067
+ });
1068
+ const untils = isRecord(document["nodes"]) ? Object.entries(document["nodes"]).flatMap(([id, node]) => {
1069
+ const watch = isRecord(node) ? node["watch"] : void 0;
1070
+ const until = isRecord(watch) ? watch["until"] : void 0;
1071
+ return typeof until === "string" ? [[id, readCondition(until)]] : [];
1072
+ }) : [];
1073
+ return {
1074
+ document,
1075
+ graph: {
1076
+ nodes,
1077
+ edges,
1078
+ channels,
1079
+ says: nodeSays(validNodes(document), edges),
1080
+ untils: Object.fromEntries(untils)
1081
+ }
1082
+ };
1083
+ };
1084
+ // ../server/src/builder-view.ts
1085
+ /**
1086
+ * @file What the builder shows for a workflow's text (RFC-008): what it says,
1087
+ * its graph, what the checks say, and what walking it found (RFC-009), which
1088
+ * never stops a save.
1089
+ */
1090
+ const SUFFIX$1 = ".ork.yaml";
1091
+ const fileOf = (project, name) => path.join(placeFiles(project).workflows, `${name}${SUFFIX$1}`);
1092
+ /** The workflow as it stands: its draft, or else its file. */
1093
+ const viewOf$1 = async (context, project, name, text) => {
1094
+ const file = fileOf(project, name);
1095
+ const { diagnostics, workflow, walk } = await walkChecks(checkWorkflowText(text, {
1096
+ place: project,
1097
+ workflowDir: workflowDirOf(project, file),
1098
+ registered: context.store.projects(),
1099
+ home: context.home
1100
+ }), { maxMs: 200 });
1101
+ const draft = context.store.draft(project.id, name);
1102
+ const onDisk = hashOfFile(file);
1103
+ const graph = draftGraph(text);
1104
+ return {
1105
+ name,
1106
+ file,
1107
+ text,
1108
+ document: graph.document,
1109
+ graph: workflow === null ? graph.graph : {
1110
+ ...graph.graph,
1111
+ says: nodeSays(workflow.nodes, graph.graph.edges)
1112
+ },
1113
+ diagnostics,
1114
+ walk: walk === void 0 ? null : walkSummary(walk),
1115
+ draft: draft !== void 0,
1116
+ baseHash: draft === void 0 ? onDisk : draft.baseHash,
1117
+ changedOnDisk: draft !== void 0 && onDisk !== draft.baseHash
1118
+ };
1119
+ };
1120
+ // ../server/src/builder.ts
1121
+ /**
1122
+ * @file The workflow builder's files and drafts (RFC-008): what a workflow is
1123
+ * while it's edited, applying edits to its draft, and saving it. This is the
1124
+ * only place the server writes a project's files, and it writes only a valid
1125
+ * workflow, inside the project's workflows folder.
1126
+ */
1127
+ const notFound = (project, name) => ({
1128
+ refused: 404,
1129
+ error: `No workflow \`${name}\` in \`${project.id}\`'s workflows.`
1130
+ });
1131
+ const badName = (name) => ({
1132
+ refused: 400,
1133
+ error: `\`${name}\` can't be a workflow's file name.`
1134
+ });
1135
+ /** Why this workflow can't be touched at all, by its name or where it leads. */
1136
+ const problemWith = (project, name) => {
1137
+ if (!safeName(name)) return badName(name);
1138
+ const reason = unreadableReason(project, fileOf(project, name));
1139
+ return reason === void 0 ? void 0 : {
1140
+ refused: 409,
1141
+ error: reason
1142
+ };
1143
+ };
1144
+ /** What the builder shows for a workflow. */
1145
+ const builderView = async (context, project, name) => {
1146
+ const problem = problemWith(project, name);
1147
+ if (problem !== void 0) return problem;
1148
+ const text = context.store.draft(project.id, name)?.text ?? readText(fileOf(project, name));
1149
+ if (text === void 0) return notFound(project, name);
1150
+ return await viewOf$1(context, project, name, text);
1151
+ };
1152
+ const NAME = /^[a-z0-9][a-z0-9._-]*$/u;
1153
+ /** Starts a draft of a new workflow. */
1154
+ const createDraft = async (context, project, name) => {
1155
+ if (!NAME.test(name) || !safeName(name)) return {
1156
+ refused: 400,
1157
+ error: "A workflow's name is a lowercase slug, such as nightly-review."
1158
+ };
1159
+ const problem = problemWith(project, name);
1160
+ if (problem !== void 0) return problem;
1161
+ if (existsSync(fileOf(project, name)) || context.store.draft(project.id, name) !== void 0) return {
1162
+ refused: 409,
1163
+ error: `There is already a workflow \`${name}\`.`
1164
+ };
1165
+ const text = newWorkflowText(name);
1166
+ context.store.putDraft({
1167
+ project: project.id,
1168
+ workflow: name,
1169
+ text,
1170
+ baseHash: null,
1171
+ at: (/* @__PURE__ */ new Date()).toISOString()
1172
+ });
1173
+ return await viewOf$1(context, project, name, text);
1174
+ };
1175
+ /**
1176
+ * Stores new text as the draft, or drops it if it's back to the file's. A draft
1177
+ * remembers the file it began from; `baseHash` says so when there's no draft
1178
+ * yet to remember it, and otherwise it's the file as it is now.
1179
+ */
1180
+ const keep = (context, project, name, text, baseHash) => {
1181
+ const file = fileOf(project, name);
1182
+ const draft = context.store.draft(project.id, name);
1183
+ if (text === readText(file)) {
1184
+ context.store.dropDraft(project.id, name);
1185
+ return;
1186
+ }
1187
+ context.store.putDraft({
1188
+ project: project.id,
1189
+ workflow: name,
1190
+ text,
1191
+ baseHash: draft === void 0 ? baseHash === void 0 ? hashOfFile(file) : baseHash : draft.baseHash,
1192
+ at: (/* @__PURE__ */ new Date()).toISOString()
1193
+ });
1194
+ };
1195
+ /** Applies edits to the draft, starting one from the file if there's none. */
1196
+ const editDraft = async (context, project, name, edits) => {
1197
+ const problem = problemWith(project, name);
1198
+ if (problem !== void 0) return problem;
1199
+ const checked = WorkflowEditSchema.array().safeParse(edits);
1200
+ if (!checked.success) return {
1201
+ refused: 400,
1202
+ error: checked.error.issues[0]?.message ?? "That isn't an edit."
1203
+ };
1204
+ const draft = context.store.draft(project.id, name);
1205
+ const file = fileOf(project, name);
1206
+ const text = draft?.text ?? readText(file);
1207
+ if (text === void 0) return notFound(project, name);
1208
+ const result = applyEdits(text, checked.data);
1209
+ if (!result.ok) return {
1210
+ refused: 409,
1211
+ error: result.message
1212
+ };
1213
+ const previous = {
1214
+ text,
1215
+ baseHash: draft === void 0 ? hashOfFile(file) : draft.baseHash
1216
+ };
1217
+ keep(context, project, name, result.text);
1218
+ return {
1219
+ ...await viewOf$1(context, project, name, result.text),
1220
+ previous
1221
+ };
1222
+ };
1223
+ /** Puts a draft back to earlier text: the builder's undo. */
1224
+ const restoreDraft = async (context, project, name, text, baseHash) => {
1225
+ const problem = problemWith(project, name);
1226
+ if (problem !== void 0) return problem;
1227
+ if (!(context.store.draft(project.id, name) !== void 0 || existsSync(fileOf(project, name)))) return notFound(project, name);
1228
+ keep(context, project, name, text, baseHash);
1229
+ return await viewOf$1(context, project, name, text);
1230
+ };
1231
+ /** Throws away a draft; what's left is the file, if there is one. */
1232
+ const discardDraft = async (context, project, name) => {
1233
+ if (!safeName(name)) return badName(name);
1234
+ context.store.dropDraft(project.id, name);
1235
+ const problem = problemWith(project, name);
1236
+ if (problem !== void 0) return problem;
1237
+ const text = readText(fileOf(project, name));
1238
+ if (text === void 0) return { gone: true };
1239
+ return await viewOf$1(context, project, name, text);
1240
+ };
1241
+ /**
1242
+ * Saves a draft to the workflows folder. Errors block it, as a running
1243
+ * workflow's call could read the file, and so does a file that changed since
1244
+ * the draft began, unless the user chooses to replace it.
1245
+ */
1246
+ const saveDraft = (context, project, name, force) => {
1247
+ if (!safeName(name)) return badName(name);
1248
+ const file = fileOf(project, name);
1249
+ const outside = outsideReason(project, file);
1250
+ if (outside !== void 0) return {
1251
+ refused: 409,
1252
+ error: outside
1253
+ };
1254
+ const draft = context.store.draft(project.id, name);
1255
+ if (draft === void 0) return existsSync(file) ? {
1256
+ saved: true,
1257
+ file
1258
+ } : notFound(project, name);
1259
+ const { diagnostics } = checkWorkflowText(draft.text, {
1260
+ place: project,
1261
+ workflowDir: workflowDirOf(project, file),
1262
+ registered: context.store.projects(),
1263
+ home: context.home
1264
+ });
1265
+ const errors = diagnostics.filter((d) => d.severity === "error").length;
1266
+ if (errors > 0) return {
1267
+ saved: false,
1268
+ why: "errors",
1269
+ message: `Fix ${errors === 1 ? "the error" : `the ${String(errors)} errors`} first; nothing was saved.`
1270
+ };
1271
+ if (!force && hashOfFile(file) !== draft.baseHash) return {
1272
+ saved: false,
1273
+ why: "changed",
1274
+ message: `${path.basename(file)} changed since this draft began; nothing was saved.`
1275
+ };
1276
+ writeWhole(file, draft.text);
1277
+ context.store.dropDraft(project.id, name);
1278
+ return {
1279
+ saved: true,
1280
+ file
1281
+ };
1282
+ };
1283
+ // ../server/src/builder-routes.ts
1284
+ /**
1285
+ * @file The workflow builder's routes (RFC-008, specs/server.md#api). Each
1286
+ * finds the project, then does its work in `builder.ts`, and a refusal has
1287
+ * the words the user will see.
1288
+ */
1289
+ const isRefused = (answer) => "refused" in answer;
1290
+ const replyOf = (answer) => isRefused(answer) ? fail(answer.refused, answer.error) : ok(answer);
1291
+ const builderRoutes = (context) => {
1292
+ /** The answer for a registered project; a 404 for any other. */
1293
+ const inProject = (id, work) => {
1294
+ const project = projectOf(context, id);
1295
+ return project === void 0 ? fail(404, `No registered project \`${id}\`.`) : replyOf(work(project));
1296
+ };
1297
+ /** The same, for work the server walks a workflow for. */
1298
+ const walkedInProject = async (id, work) => {
1299
+ const project = projectOf(context, id);
1300
+ return project === void 0 ? fail(404, `No registered project \`${id}\`.`) : replyOf(await work(project));
1301
+ };
1302
+ return {
1303
+ builderSchema: () => ok(JSON.parse(renderJsonSchema())),
1304
+ builderCatalog: ({ params }) => inProject(params.id, (project) => {
1305
+ const home = context.home ?? homedir();
1306
+ return {
1307
+ agents: loadAgents({
1308
+ projectRoot: project.root,
1309
+ orkDir: project.dir,
1310
+ home
1311
+ }).agents.map(({ name, source, agent }) => ({
1312
+ name,
1313
+ description: agent.description ?? "",
1314
+ source
1315
+ })),
1316
+ skills: [...skillsIn(path.join(home, ".ork", "skills"), "user"), ...skillsIn(path.join(project.root, ".claude", "skills"), "project")].map(({ name, layer }) => ({
1317
+ name,
1318
+ layer
1319
+ }))
1320
+ };
1321
+ }),
1322
+ builderDrafts: ({ params }) => inProject(params.id, (project) => {
1323
+ return context.store.drafts(project.id).map(({ workflow }) => workflow);
1324
+ }),
1325
+ builderView: ({ params }) => walkedInProject(params.id, (project) => builderView(context, project, params.name)),
1326
+ builderCreate: ({ params, body }) => walkedInProject(params.id, (project) => createDraft(context, project, body.name)),
1327
+ builderEdit: ({ params, body }) => walkedInProject(params.id, (project) => editDraft(context, project, params.name, body.edits)),
1328
+ builderRestore: ({ params, body }) => walkedInProject(params.id, (project) => restoreDraft(context, project, params.name, body.text, body.baseHash)),
1329
+ builderDiscard: ({ params }) => walkedInProject(params.id, (project) => discardDraft(context, project, params.name)),
1330
+ builderSave: ({ params, body }) => inProject(params.id, (project) => saveDraft(context, project, params.name, body.force === true))
1331
+ };
1332
+ };
1333
+ // ../server/src/workflows.ts
1334
+ /**
1335
+ * @file A project's workflows, for the UI's Run workflow dialog: each
1336
+ * `*.ork.yaml` in its workflows folder, with its name, description and
1337
+ * declared inputs; one that does not parse is listed with why, since starting
1338
+ * it is how the user sees the whole error.
1339
+ */
1340
+ const SUFFIX = ".ork.yaml";
1341
+ const viewOf = (file) => {
1342
+ const name = path.basename(file).slice(0, -9);
1343
+ let text;
1344
+ try {
1345
+ text = readFileSync(file, "utf8");
1346
+ } catch (error) {
1347
+ return {
1348
+ name,
1349
+ file,
1350
+ inputs: {},
1351
+ error: error instanceof Error ? error.message : String(error)
1352
+ };
1353
+ }
1354
+ const parsed = parseWorkflow(text);
1355
+ if (!parsed.ok) {
1356
+ const [first] = parsed.diagnostics;
1357
+ return {
1358
+ name,
1359
+ file,
1360
+ inputs: {},
1361
+ error: first?.message ?? "It is not a valid workflow."
1362
+ };
1363
+ }
1364
+ const { workflow } = parsed;
1365
+ return {
1366
+ name,
1367
+ file,
1368
+ title: workflow.name,
1369
+ ...workflow.description === void 0 ? {} : { description: workflow.description },
1370
+ inputs: workflow.inputs ?? {}
1371
+ };
1372
+ };
1373
+ /** The workflows in `project`'s workflows folder, by name. */
1374
+ const projectWorkflows = (project) => {
1375
+ const folder = placeFiles(project).workflows;
1376
+ let names;
1377
+ try {
1378
+ names = readdirSync(folder);
1379
+ } catch {
1380
+ return [];
1381
+ }
1382
+ return names.filter((name) => name.endsWith(SUFFIX)).toSorted().map((name) => viewOf(path.join(folder, name)));
1383
+ };
1384
+ // ../server/src/read-routes.ts
1385
+ /**
1386
+ * @file The API's read routes (specs/server.md#api): health, projects and a
1387
+ * project's workflows, runs, a run, its tree and its events, the open
1388
+ * decisions, and usage. Each reads the store as the matching CLI command does
1389
+ * (`ork projects`, `ps`, `tree`, `logs`, `decide --list`, `usage`).
1390
+ */
1391
+ /** A project filter's root: a registered project's, or the value as a root. */
1392
+ /** A registered project's place; caps are only known for one. */
1393
+ const placeOf = (context, key) => {
1394
+ const known = key === void 0 ? void 0 : projectOf(context, key);
1395
+ return known === void 0 ? void 0 : placeFor(known.root, context.store.projects());
1396
+ };
1397
+ const projectRoot = (context, key) => projectOf(context, key)?.root ?? key;
1398
+ const readRoutes = (context) => ({
1399
+ health: () => ok({
1400
+ version: context.version,
1401
+ pid: context.pid,
1402
+ startedAt: context.startedAt
1403
+ }),
1404
+ projects: () => ok(context.store.projects()),
1405
+ triggers: () => ok(triggersReport(context.store, context.store.projects(), context.home)),
1406
+ links: ({ query }) => ok(context.store.links(query.run === void 0 ? {} : { run: query.run })),
1407
+ usage: ({ query }) => ok({
1408
+ ...usageReport(context.store.usage({
1409
+ ...query.project === void 0 ? {} : { project: projectRoot(context, query.project) },
1410
+ ...query.workflow === void 0 ? {} : { workflow: query.workflow },
1411
+ ...query.since === void 0 ? {} : { since: query.since },
1412
+ ...query.until === void 0 ? {} : { until: query.until }
1413
+ })),
1414
+ limits: limitsReport(context.store, capsHere(context.home, placeOf(context, query.project)), /* @__PURE__ */ new Date())
1415
+ }),
1416
+ workflows: ({ params }) => {
1417
+ const project = projectOf(context, params.id);
1418
+ return project === void 0 ? fail(404, `No registered project \`${params.id}\`.`) : ok(projectWorkflows(project));
1419
+ },
1420
+ runs: ({ query }) => ok(context.store.runs({
1421
+ limit: query.limit ?? 20,
1422
+ ...query.project === void 0 ? {} : { project: projectRoot(context, query.project) }
1423
+ }).map((run) => viewOf$2(context, run))),
1424
+ run: ({ params }) => {
1425
+ const found = runOf(context, params.id);
1426
+ return found.ok ? ok(viewOf$2(context, found.run)) : found.refusal;
1427
+ },
1428
+ tree: ({ params }) => {
1429
+ const found = runOf(context, params.id);
1430
+ if (!found.ok) return found.refusal;
1431
+ return ok(activeTree(context.store.events(found.run.id)));
1432
+ },
1433
+ graph: ({ params }) => {
1434
+ const found = runOf(context, params.id);
1435
+ if (!found.ok) return found.refusal;
1436
+ return ok(runGraph(context.store.events(found.run.id).map((s) => s.event), found.run.file));
1437
+ },
1438
+ events: ({ params, query }) => {
1439
+ const found = runOf(context, params.id);
1440
+ if (!found.ok) return found.refusal;
1441
+ const events = context.store.events(found.run.id, query.after ?? 0).filter((s) => query.node === void 0 || nodeOf(s.event) === query.node);
1442
+ const newest = query.last === void 0 ? events : events.slice(-query.last);
1443
+ return ok(query.limit === void 0 ? newest : newest.slice(0, query.limit));
1444
+ },
1445
+ decisions: ({ query }) => {
1446
+ if (query.run === void 0) return ok(pendingDecisions(context.store, query.project === void 0 ? {} : { project: projectRoot(context, query.project) }));
1447
+ const found = runOf(context, query.run);
1448
+ if (!found.ok) return found.refusal;
1449
+ return ok(pendingDecisions(context.store, { runId: found.run.id }));
1450
+ },
1451
+ decision: ({ params }) => {
1452
+ const found = context.store.decision(params.id);
1453
+ return found === void 0 ? fail(404, `No decision \`${params.id}\`.`) : ok(found);
1454
+ }
1455
+ });
1456
+ // ../server/src/mcp-tools.ts
1457
+ /**
1458
+ * @file The tools `ork mcp` serves Claude Code (RFC-012): what a project has,
1459
+ * the workflow format, runs, their events, what they spent, and what waits on
1460
+ * a person. Each answers from the same handler as `ork ui`'s API, and they
1461
+ * are built from its read routes and the builder's catalog alone, so none can
1462
+ * answer a decision, start, stop, pause or message a run.
1463
+ */
1464
+ const NO_QUERY = {
1465
+ params: {},
1466
+ query: void 0,
1467
+ body: void 0
1468
+ };
1469
+ /** How many events `run_events` gives when it's asked for no range. */
1470
+ const RECENT_EVENTS = 30;
1471
+ /** Said with the open decisions: this server can't answer them. */
1472
+ const ANSWERED_BY_A_PERSON = "Only a person answers these: in `ork ui`, or with `ork decide` in their own terminal. Tell them what waits, and where.";
1473
+ const project = z.string().min(1).describe("A registered project's id or root");
1474
+ const run = z.string().min(1).describe("A run's id, or a unique start of it");
1475
+ const mcpTools = (context) => {
1476
+ const read = readRoutes(context);
1477
+ const { builderCatalog } = builderRoutes(context);
1478
+ return [
1479
+ tool("projects", "The projects registered with Ork on this machine.", {}, () => fromReply(read.projects(NO_QUERY))),
1480
+ tool("workflows", "A project's workflows: each one's name to start it by, its description and inputs, or why it doesn't parse.", { project }, (args) => fromReply(read.workflows({
1481
+ ...NO_QUERY,
1482
+ params: { id: args.project }
1483
+ }))),
1484
+ tool("workflow_format", "The workflow file format's JSON Schema. With no node type: the workflow's own fields and the node types there are. With one: that node type's fields.", { nodeType: z.string().min(1).optional() }, (args) => formatPart(args.nodeType)),
1485
+ tool("agents", "The agents and skills a project's nodes can use, and where each comes from.", { project }, (args) => fromReply(builderCatalog({
1486
+ ...NO_QUERY,
1487
+ params: { id: args.project }
1488
+ }))),
1489
+ tool("runs", "Runs, newest first, with their live status and cost. 10 unless a limit is given.", RunsQuerySchema.shape, (args) => fromReply(read.runs({
1490
+ ...NO_QUERY,
1491
+ query: {
1492
+ limit: 10,
1493
+ ...args
1494
+ }
1495
+ }))),
1496
+ tool("run", "One run: its status, what it waits on, its cost, and each node's state.", { run }, async (args) => {
1497
+ const view = await read.run({
1498
+ ...NO_QUERY,
1499
+ params: { id: args.run }
1500
+ });
1501
+ if ("error" in view) return refused(view.error);
1502
+ const tree = read.tree({
1503
+ ...NO_QUERY,
1504
+ params: { id: view.body.id }
1505
+ });
1506
+ return fromReply(tree, (nodes) => ({
1507
+ run: view.body,
1508
+ nodes
1509
+ }));
1510
+ }),
1511
+ tool("run_events", `A run's events, oldest first, long values cut. With no range, the newest ${String(RECENT_EVENTS)}.`, {
1512
+ run,
1513
+ ...EventsQuerySchema.shape
1514
+ }, ({ run: id, ...query }) => {
1515
+ const range = query.after === void 0 && query.limit === void 0 && query.last === void 0 ? { last: RECENT_EVENTS } : {};
1516
+ return fromReply(read.events({
1517
+ ...NO_QUERY,
1518
+ params: { id },
1519
+ query: {
1520
+ ...range,
1521
+ ...query
1522
+ }
1523
+ }));
1524
+ }),
1525
+ tool("usage", "What runs spent, by workflow and model, and how close the limits are. `since` and `until` are ISO times.", UsageQuerySchema.shape, (args) => fromReply(read.usage({
1526
+ ...NO_QUERY,
1527
+ query: args
1528
+ }))),
1529
+ tool("decisions", "The decisions open in live runs, waiting on a person. This server can't answer them.", DecisionsQuerySchema.shape, (args) => fromReply(read.decisions({
1530
+ ...NO_QUERY,
1531
+ query: args
1532
+ }), (open) => ({
1533
+ open,
1534
+ answer: ANSWERED_BY_A_PERSON
1535
+ })))
1536
+ ];
1537
+ };
1538
+ // ../server/src/mcp.ts
1539
+ /**
1540
+ * @file What `ork mcp` opens (RFC-012): the store under a home, and the tools
1541
+ * on it, as one more client of the store beside the CLI and `ork ui`.
1542
+ * Starting a run is offered only where the caller allows it: not inside a
1543
+ * run, whose sessions mustn't start more.
1544
+ */
1545
+ const openMcp = (options) => {
1546
+ const store = openStore(storePath(options.home));
1547
+ const context = {
1548
+ store,
1549
+ version: options.version,
1550
+ pid: process.pid,
1551
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1552
+ ...options.home === void 0 ? {} : { home: options.home },
1553
+ ...options.script === void 0 ? {} : { script: options.script }
1554
+ };
1555
+ return {
1556
+ tools: [...mcpTools(context), ...actingTools(context, { start: options.start })],
1557
+ fileOf: (key, workflow) => {
1558
+ const project = projectOf(context, key);
1559
+ if (project === void 0) return { error: `No registered project \`${key}\`.` };
1560
+ const file = workflowFile(project, workflow);
1561
+ return file === null ? { error: `No workflow \`${workflow}\` in \`${project.id}\`'s workflows or inside its root.` } : {
1562
+ project,
1563
+ file
1564
+ };
1565
+ },
1566
+ close: () => {
1567
+ store.close();
1568
+ }
1569
+ };
1570
+ };
1571
+ // ../server/src/assets.ts
1572
+ /**
1573
+ * @file The built UI's files (`packages/ui/dist`, shipped as the CLI's
1574
+ * `dist/ui`): `/` is its `index.html`, and `/assets/…` are its hashed files,
1575
+ * cached for good. Only a file inside the folder, with a known type, is
1576
+ * served; anything else is none.
1577
+ */
1578
+ /** The types the UI's build writes; anything else is not served. */
1579
+ const TYPES = {
1580
+ ".html": "text/html; charset=utf-8",
1581
+ ".js": "text/javascript; charset=utf-8",
1582
+ ".css": "text/css; charset=utf-8",
1583
+ ".svg": "image/svg+xml",
1584
+ ".png": "image/png",
1585
+ ".ico": "image/x-icon",
1586
+ ".woff2": "font/woff2",
1587
+ ".woff": "font/woff",
1588
+ ".txt": "text/plain; charset=utf-8",
1589
+ ".webmanifest": "application/manifest+json"
1590
+ };
1591
+ /** Whether `dir` holds a built UI. */
1592
+ const hasUi = (dir) => {
1593
+ try {
1594
+ return statSync(path.join(dir, "index.html")).isFile();
1595
+ } catch {
1596
+ return false;
1597
+ }
1598
+ };
1599
+ /** The file `pathname` names in the built UI `dir`, or null. */
1600
+ const readAsset = (dir, pathname) => {
1601
+ const relative = pathname === "/" ? "index.html" : pathname.slice(1);
1602
+ let decoded;
1603
+ try {
1604
+ decoded = decodeURIComponent(relative);
1605
+ } catch {
1606
+ return null;
1607
+ }
1608
+ const root = path.resolve(dir);
1609
+ const file = path.resolve(root, decoded);
1610
+ const type = TYPES[path.extname(file)];
1611
+ if (type === void 0 || !file.startsWith(`${root}${path.sep}`)) return null;
1612
+ try {
1613
+ if (!statSync(file).isFile()) return null;
1614
+ return {
1615
+ body: readFileSync(file),
1616
+ type,
1617
+ immutable: decoded.startsWith("assets/")
1618
+ };
1619
+ } catch {
1620
+ return null;
1621
+ }
1622
+ };
1623
+ // ../server/src/api-routes.ts
1624
+ /** @file Every JSON route's handler, in one place. */
1625
+ const apiRoutes = (context) => ({
1626
+ ...readRoutes(context),
1627
+ ...writeRoutes(context),
1628
+ ...builderRoutes(context)
1629
+ });
1630
+ const PLACEHOLDER_HTML = `<!doctype html>
1631
+ <html lang="en">
1632
+ <head>
1633
+ <meta charset="utf-8">
1634
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1635
+ <title>Ork</title>
1636
+ <style>
1637
+ :root { color-scheme: light dark; --fg: #1d1f23; --muted: #6b7079; --bg: #fbfbfa; --line: #e3e3e0; }
1638
+ @media (prefers-color-scheme: dark) { :root { --fg: #e6e6e3; --muted: #9a9ea6; --bg: #16171a; --line: #2a2c31; } }
1639
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 ui-sans-serif, system-ui, sans-serif; }
1640
+ main { max-width: 760px; margin: 0 auto; padding: 40px 16px; }
1641
+ h1 { font-size: 22px; margin: 0 0 4px; }
1642
+ h2 { font-size: 15px; margin: 28px 0 8px; }
1643
+ p, .muted { color: var(--muted); }
1644
+ ul { list-style: none; padding: 0; margin: 0; border-top: 1px solid var(--line); }
1645
+ li { padding: 8px 0; border-bottom: 1px solid var(--line); font-family: ui-monospace, monospace; font-size: 13px; overflow-wrap: anywhere; }
1646
+ code { font-family: ui-monospace, monospace; }
1647
+ </style>
1648
+ </head>
1649
+ <body>
1650
+ <main>
1651
+ <h1>Ork</h1>
1652
+ <p id="status">The server is running. The UI's panels come in the next releases; until then, this page lists what the API sees.</p>
1653
+ <h2>Open decisions</h2>
1654
+ <ul id="decisions"><li class="muted">Loading…</li></ul>
1655
+ <h2>Runs</h2>
1656
+ <ul id="runs"><li class="muted">Loading…</li></ul>
1657
+ <p>Answer with <code>ork decide</code>; follow a run with <code>ork logs &lt;run&gt; -f</code>.</p>
1658
+ </main>
1659
+ <script src="/placeholder.js"><\/script>
1660
+ </body>
1661
+ </html>
1662
+ `;
1663
+ const PLACEHOLDER_JS = `"use strict";
1664
+ const fill = (id, items, line, empty) => {
1665
+ const list = document.getElementById(id);
1666
+ list.replaceChildren();
1667
+ if (items.length === 0) items = [null];
1668
+ for (const item of items) {
1669
+ const li = document.createElement("li");
1670
+ li.textContent = item === null ? empty : line(item);
1671
+ if (item === null) li.className = "muted";
1672
+ list.append(li);
1673
+ }
1674
+ };
1675
+ fetch("/api/health").then(r => r.json()).then(h => {
1676
+ document.title = "Ork " + h.version;
1677
+ });
1678
+ const events = new EventSource("/api/stream");
1679
+ events.addEventListener("runs", e => fill("runs", JSON.parse(e.data),
1680
+ r => r.id + " " + r.live.padEnd(9) + " " + r.workflow + " " + r.startedAt.slice(0, 16).replace("T", " "),
1681
+ "No runs yet."));
1682
+ events.addEventListener("decisions", e => fill("decisions", JSON.parse(e.data),
1683
+ d => d.id + " " + d.node + " " + d.question + " [" + d.options.map(o => o.id).join(", ") + "]",
1684
+ "None."));
1685
+ events.onerror = () => {
1686
+ document.getElementById("status").textContent = "Lost the server; it may have stopped (start it with ork ui).";
1687
+ };
1688
+ `;
1689
+ // ../server/src/page.ts
1690
+ /**
1691
+ * No inline script and no `eval`. Inline `style` attributes are allowed
1692
+ * (`style-src-attr`): the UI's panels, popups and graph position themselves
1693
+ * with them; style sheets still come only from the server.
1694
+ */
1695
+ const PAGE_POLICY = [
1696
+ "default-src 'self'",
1697
+ "script-src 'self'",
1698
+ "style-src 'self'",
1699
+ "style-src-attr 'unsafe-inline'",
1700
+ "font-src 'self'",
1701
+ "img-src 'self' data:",
1702
+ "connect-src 'self'",
1703
+ "frame-ancestors 'none'",
1704
+ "base-uri 'none'",
1705
+ "form-action 'self'",
1706
+ "object-src 'none'"
1707
+ ].join("; ");
1708
+ const send = (res, status, type, body, cache) => {
1709
+ res.writeHead(status, {
1710
+ ...BASE_HEADERS,
1711
+ ...cache === void 0 ? {} : { "cache-control": cache },
1712
+ "content-security-policy": PAGE_POLICY,
1713
+ "content-type": type
1714
+ }).end(body);
1715
+ };
1716
+ /** A refusal for a person at the browser, not for a script. */
1717
+ const sendNotice = (res, status, text) => {
1718
+ send(res, status, "text/plain; charset=utf-8", `${text}\n`);
1719
+ };
1720
+ const sendPlaceholder = (res, pathname) => {
1721
+ if (pathname === "/placeholder.js") send(res, 200, "text/javascript; charset=utf-8", PLACEHOLDER_JS);
1722
+ else if (pathname === "/") send(res, 200, "text/html; charset=utf-8", PLACEHOLDER_HTML);
1723
+ else sendNotice(res, 404, `Nothing at ${pathname}.`);
1724
+ };
1725
+ /**
1726
+ * Answers a page request from the built UI in `uiDir` (or the placeholder
1727
+ * without one): the file, or 404 / 405.
1728
+ */
1729
+ const sendPage = (res, method, pathname, uiDir) => {
1730
+ if (method !== "GET" && method !== "HEAD") {
1731
+ sendNotice(res, 405, `${method} is not allowed here.`);
1732
+ return;
1733
+ }
1734
+ if (uiDir === void 0) {
1735
+ sendPlaceholder(res, pathname);
1736
+ return;
1737
+ }
1738
+ const asset = readAsset(uiDir, pathname);
1739
+ if (asset === null) sendNotice(res, 404, `Nothing at ${pathname}.`);
1740
+ else send(res, 200, asset.type, asset.body, asset.immutable ? "public, max-age=31536000, immutable" : void 0);
1741
+ };
1742
+ // ../server/src/streams.ts
1743
+ /** How often the overview stream compares the store. */
1744
+ const OVERVIEW_INTERVAL_MS = 1e3;
1745
+ /** How many runs the overview stream sends. */
1746
+ const OVERVIEW_RUNS = 50;
1747
+ /** The seq to start after: `Last-Event-ID`, else `?after=`, else 0. */
1748
+ const startAfter = (req, url) => {
1749
+ const header = req.headers["last-event-id"];
1750
+ const raw = (Array.isArray(header) ? header[0] : header) ?? url.searchParams.get("after") ?? "0";
1751
+ const after = Number(raw);
1752
+ return Number.isInteger(after) && after >= 0 ? after : null;
1753
+ };
1754
+ const runStream = async (context, req, res, url, id, shutdown) => {
1755
+ const found = runOf(context, id);
1756
+ if (!found.ok) {
1757
+ sendError(res, found.refusal.status, found.refusal.error);
1758
+ return;
1759
+ }
1760
+ const after = startAfter(req, url);
1761
+ if (after === null) {
1762
+ sendError(res, 400, "Last-Event-ID or after must be a sequence number.");
1763
+ return;
1764
+ }
1765
+ const stream = openEventStream(res);
1766
+ const end = await followEvents(context.store, found.run.id, (stored) => {
1767
+ stream.send("run-event", stored, stored.seq);
1768
+ }, {
1769
+ follow: true,
1770
+ after,
1771
+ ...context.home === void 0 ? {} : { home: context.home },
1772
+ signal: AbortSignal.any([stream.signal, shutdown])
1773
+ });
1774
+ if (end !== "aborted" && end !== "stored") stream.send("end", { reason: end });
1775
+ stream.close();
1776
+ };
1777
+ const overviewStream = (context, res, url, shutdown) => {
1778
+ const key = url.searchParams.get("project");
1779
+ const root = key === null ? void 0 : projectOf(context, key)?.root ?? key;
1780
+ const stream = openEventStream(res);
1781
+ const last = {
1782
+ runs: "",
1783
+ decisions: ""
1784
+ };
1785
+ const tick = () => {
1786
+ const filter = root === void 0 ? {} : { project: root };
1787
+ const runs = context.store.runs({
1788
+ ...filter,
1789
+ limit: OVERVIEW_RUNS
1790
+ }).map((run) => viewOf$2(context, run));
1791
+ const decisions = pendingDecisions(context.store, filter);
1792
+ for (const [name, value] of [["runs", runs], ["decisions", decisions]]) {
1793
+ const text = JSON.stringify(value);
1794
+ if (text === last[name]) continue;
1795
+ last[name] = text;
1796
+ stream.send(name, value);
1797
+ }
1798
+ };
1799
+ tick();
1800
+ const timer = setInterval(tick, OVERVIEW_INTERVAL_MS);
1801
+ const stop = () => {
1802
+ clearInterval(timer);
1803
+ stream.close();
1804
+ };
1805
+ stream.signal.addEventListener("abort", stop);
1806
+ shutdown.addEventListener("abort", stop, { signal: stream.signal });
1807
+ };
1808
+ // ../server/src/dispatch.ts
1809
+ const RUN_STREAM = compilePath(STREAMS.run);
1810
+ const createDispatch = (context, access) => {
1811
+ const handlers = apiRoutes(context);
1812
+ return async (req, res) => {
1813
+ const method = req.method ?? "GET";
1814
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1815
+ const api = url.pathname.startsWith("/api/");
1816
+ const verdict = guard({
1817
+ method,
1818
+ url: req.url ?? "/",
1819
+ headers: req.headers
1820
+ }, {
1821
+ port: access.port(),
1822
+ token: access.token
1823
+ });
1824
+ if (verdict.kind === "refuse") {
1825
+ if (api) sendError(res, verdict.status, verdict.error);
1826
+ else sendNotice(res, verdict.status, verdict.error);
1827
+ return;
1828
+ }
1829
+ if (verdict.kind === "exchange") {
1830
+ res.writeHead(302, {
1831
+ ...BASE_HEADERS,
1832
+ location: verdict.location,
1833
+ "set-cookie": verdict.cookie
1834
+ }).end();
1835
+ return;
1836
+ }
1837
+ const streamed = RUN_STREAM.pattern.exec(url.pathname);
1838
+ if (method === "GET" && streamed !== null) {
1839
+ const id = decodeURIComponent(streamed[1] ?? "");
1840
+ await runStream(context, req, res, url, id, access.shutdown);
1841
+ return;
1842
+ }
1843
+ if (method === "GET" && url.pathname === STREAMS.overview) {
1844
+ overviewStream(context, res, url, access.shutdown);
1845
+ return;
1846
+ }
1847
+ if (await route(handlers, req, res, url)) return;
1848
+ if (api) sendError(res, 404, `No API route ${url.pathname}.`);
1849
+ else sendPage(res, method, url.pathname, context.uiDir);
1850
+ };
1851
+ };
1852
+ // ../server/src/ui-file.ts
1853
+ /**
1854
+ * @file `~/.ork/ui.json`: the running server's port, pid, token and start time
1855
+ * (ADR-039). It is written with mode 0600 when the server starts, so a second
1856
+ * `ork ui` finds and opens the running one, and removed when it stops. A
1857
+ * record whose process is gone counts as none.
1858
+ */
1859
+ const UiRecordSchema = z.object({
1860
+ port: z.int().min(1).max(65535),
1861
+ pid: z.int().positive(),
1862
+ token: z.string().min(1),
1863
+ startedAt: z.string()
1864
+ });
1865
+ /** Where the record lives; `home` is `~` (tests pass a folder). */
1866
+ const uiFilePath = (home = homedir()) => path.join(home, ".ork", "ui.json");
1867
+ /** The record, or `null` when there is none or it cannot be read. */
1868
+ const readUiFile = (file) => {
1869
+ try {
1870
+ const parsed = UiRecordSchema.safeParse(JSON.parse(readFileSync(file, "utf8")));
1871
+ return parsed.success ? parsed.data : null;
1872
+ } catch {
1873
+ return null;
1874
+ }
1875
+ };
1876
+ /** Writes the record readable by the user only, replacing any other. */
1877
+ const writeUiFile = (file, record) => {
1878
+ mkdirSync(path.dirname(file), { recursive: true });
1879
+ const temporary = `${file}.${String(process.pid)}.tmp`;
1880
+ writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, { mode: 384 });
1881
+ chmodSync(temporary, 384);
1882
+ renameSync(temporary, file);
1883
+ };
1884
+ /** Removes the record, if it is still the one of the server `pid`. */
1885
+ const removeUiFile = (file, pid) => {
1886
+ if (readUiFile(file)?.pid === pid) rmSync(file, { force: true });
1887
+ };
1888
+ /** The record of a server that is still running, or `null`. */
1889
+ const runningServer = (file) => {
1890
+ const record = readUiFile(file);
1891
+ return record !== null && processAlive(record.pid) ? record : null;
1892
+ };
1893
+ // ../server/src/server.ts
1894
+ /**
1895
+ * @file Starts the local server (ADR-039, specs/server.md): `node:http` on
1896
+ * loopback only, every request through the guards, then the event streams,
1897
+ * the JSON routes, and the page. It opens the store as one more client and
1898
+ * runs nothing itself; it records itself in `~/.ork/ui.json` while it runs.
1899
+ */
1900
+ /** The port `ork ui` serves on unless told otherwise. */
1901
+ const DEFAULT_PORT = 4417;
1902
+ const listen = (server, port) => new Promise((resolve, reject) => {
1903
+ server.once("error", (error) => {
1904
+ reject(error.code === "EADDRINUSE" ? /* @__PURE__ */ new Error(`Port ${String(port)} is in use; start Ork's UI on another with \`--port <n>\`.`) : error);
1905
+ });
1906
+ server.listen(port, "127.0.0.1", () => {
1907
+ resolve(server.address().port);
1908
+ });
1909
+ });
1910
+ const startServer = async (options) => {
1911
+ const store = openStore(storePath(options.home));
1912
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
1913
+ const uiDir = options.ui?.find((dir) => hasUi(dir));
1914
+ const context = {
1915
+ store,
1916
+ version: options.version,
1917
+ pid: process.pid,
1918
+ startedAt,
1919
+ ...options.home === void 0 ? {} : { home: options.home },
1920
+ ...options.script === void 0 ? {} : { script: options.script },
1921
+ ...uiDir === void 0 ? {} : { uiDir }
1922
+ };
1923
+ const token = newToken();
1924
+ const shutdown = new AbortController();
1925
+ const log = options.log ?? ((line) => {
1926
+ process.stderr.write(`${line}\n`);
1927
+ });
1928
+ let port = options.port ?? 4417;
1929
+ const handle = createDispatch(context, {
1930
+ port: () => port,
1931
+ token,
1932
+ shutdown: shutdown.signal
1933
+ });
1934
+ const server = createServer((req, res) => {
1935
+ handle(req, res).catch((error) => {
1936
+ log(`ork ui: ${req.method ?? ""} ${req.url?.split("?")[0] ?? ""} failed: ${error instanceof Error ? error.message : String(error)}`);
1937
+ if (res.headersSent) res.destroy();
1938
+ else sendError(res, 500, "The server failed; see its terminal.");
1939
+ });
1940
+ });
1941
+ try {
1942
+ port = await listen(server, port);
1943
+ } catch (error) {
1944
+ store.close();
1945
+ throw error;
1946
+ }
1947
+ const file = uiFilePath(options.home);
1948
+ if (options.record !== false) writeUiFile(file, {
1949
+ port,
1950
+ pid: process.pid,
1951
+ token,
1952
+ startedAt
1953
+ });
1954
+ let closed;
1955
+ return {
1956
+ port,
1957
+ url: `http://127.0.0.1:${String(port)}`,
1958
+ token,
1959
+ close: () => {
1960
+ closed ??= new Promise((resolve) => {
1961
+ shutdown.abort();
1962
+ server.close(() => {
1963
+ store.close();
1964
+ if (options.record !== false) removeUiFile(file, process.pid);
1965
+ resolve();
1966
+ });
1967
+ server.closeAllConnections();
1968
+ });
1969
+ return closed;
1970
+ }
1971
+ };
1972
+ };
1973
+ export { openMcp as a, refused as c, uiFilePath as i, startServer as n, tool as o, runningServer as r, answer as s, DEFAULT_PORT as t };