@hasna/workflows 0.1.4 → 0.2.1

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/dist/handlers.js CHANGED
@@ -16,56 +16,3251 @@ var __export = (target, all) => {
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
  var __require = import.meta.require;
18
18
 
19
- // src/handlers.ts
20
- import { createHash, timingSafeEqual } from "crypto";
21
- function jsonResponse(body, status = 200) {
22
- return new Response(JSON.stringify(body), {
23
- status,
24
- headers: { "content-type": "application/json" }
19
+ // src/expr.ts
20
+ function tokenize(input) {
21
+ const tokens = [];
22
+ let i = 0;
23
+ while (i < input.length) {
24
+ const ch = input[i];
25
+ if (ch === " " || ch === "\t" || ch === `
26
+ ` || ch === "\r") {
27
+ i++;
28
+ continue;
29
+ }
30
+ if (ch === "(") {
31
+ tokens.push({ kind: "lparen", value: "(" });
32
+ i++;
33
+ continue;
34
+ }
35
+ if (ch === ")") {
36
+ tokens.push({ kind: "rparen", value: ")" });
37
+ i++;
38
+ continue;
39
+ }
40
+ if (ch === '"' || ch === "'") {
41
+ const quote = ch;
42
+ let j = i + 1;
43
+ let out = "";
44
+ let closed = false;
45
+ while (j < input.length) {
46
+ if (input[j] === quote) {
47
+ closed = true;
48
+ break;
49
+ }
50
+ out += input[j];
51
+ j++;
52
+ }
53
+ if (!closed)
54
+ throw new ExprSyntaxError(`unterminated string literal at offset ${i}`);
55
+ tokens.push({ kind: "string", value: out });
56
+ i = j + 1;
57
+ continue;
58
+ }
59
+ const two = input.slice(i, i + 2);
60
+ if (COMPARISON_OPS.has(two)) {
61
+ tokens.push({ kind: "op", value: two });
62
+ i += 2;
63
+ continue;
64
+ }
65
+ if (ch === "<" || ch === ">" || ch === "=" || ch === "!") {
66
+ tokens.push({ kind: "op", value: ch });
67
+ i++;
68
+ continue;
69
+ }
70
+ if (/[0-9]/.test(ch)) {
71
+ let j = i;
72
+ while (j < input.length && /[0-9.]/.test(input[j]))
73
+ j++;
74
+ tokens.push({ kind: "number", value: input.slice(i, j) });
75
+ i = j;
76
+ continue;
77
+ }
78
+ if (/[A-Za-z_]/.test(ch)) {
79
+ let j = i;
80
+ while (j < input.length && /[A-Za-z0-9_.]/.test(input[j]))
81
+ j++;
82
+ tokens.push({ kind: "ident", value: input.slice(i, j) });
83
+ i = j;
84
+ continue;
85
+ }
86
+ throw new ExprSyntaxError(`unexpected character ${JSON.stringify(ch)} at offset ${i}`);
87
+ }
88
+ return tokens;
89
+ }
90
+
91
+ class Parser {
92
+ tokens;
93
+ pos = 0;
94
+ constructor(tokens) {
95
+ this.tokens = tokens;
96
+ }
97
+ peek() {
98
+ return this.tokens[this.pos];
99
+ }
100
+ next() {
101
+ return this.tokens[this.pos++];
102
+ }
103
+ expectOp(value) {
104
+ const t = this.next();
105
+ if (!t || t.kind !== "op" || t.value !== value) {
106
+ throw new ExprSyntaxError(`expected ${JSON.stringify(value)}`);
107
+ }
108
+ }
109
+ parse() {
110
+ if (this.tokens.length === 0)
111
+ throw new ExprSyntaxError("empty expression");
112
+ const node = this.parseOr();
113
+ if (this.pos < this.tokens.length) {
114
+ throw new ExprSyntaxError(`unexpected trailing token ${JSON.stringify(this.peek().value)}`);
115
+ }
116
+ return node;
117
+ }
118
+ parseOr() {
119
+ let left = this.parseAnd();
120
+ while (this.peek()?.kind === "ident" && this.peek().value === "or") {
121
+ this.next();
122
+ const right = this.parseAnd();
123
+ left = { kind: "or", left, right };
124
+ }
125
+ return left;
126
+ }
127
+ parseAnd() {
128
+ let left = this.parseNot();
129
+ while (this.peek()?.kind === "ident" && this.peek().value === "and") {
130
+ this.next();
131
+ const right = this.parseNot();
132
+ left = { kind: "and", left, right };
133
+ }
134
+ return left;
135
+ }
136
+ parseNot() {
137
+ if (this.peek()?.kind === "ident" && this.peek().value === "not") {
138
+ this.next();
139
+ return { kind: "not", operand: this.parseNot() };
140
+ }
141
+ return this.parseComparison();
142
+ }
143
+ parseComparison() {
144
+ const left = this.parsePrimary();
145
+ const t = this.peek();
146
+ if (t && t.kind === "op" && COMPARISON_OPS.has(t.value)) {
147
+ this.next();
148
+ const right = this.parsePrimary();
149
+ return { kind: "comparison", op: t.value, left, right };
150
+ }
151
+ return left;
152
+ }
153
+ parsePrimary() {
154
+ const t = this.next();
155
+ if (!t)
156
+ throw new ExprSyntaxError("unexpected end of expression");
157
+ if (t.kind === "lparen") {
158
+ const inner = this.parseOr();
159
+ this.expectCloseParen();
160
+ return inner;
161
+ }
162
+ if (t.kind === "number") {
163
+ const value = Number(t.value);
164
+ if (!Number.isFinite(value))
165
+ throw new ExprSyntaxError(`invalid number ${JSON.stringify(t.value)}`);
166
+ return { kind: "literal", value };
167
+ }
168
+ if (t.kind === "string")
169
+ return { kind: "literal", value: t.value };
170
+ if (t.kind === "ident") {
171
+ if (t.value === "true")
172
+ return { kind: "literal", value: true };
173
+ if (t.value === "false")
174
+ return { kind: "literal", value: false };
175
+ return { kind: "path", segments: t.value.split(".") };
176
+ }
177
+ throw new ExprSyntaxError(`unexpected token ${JSON.stringify(t.value)}`);
178
+ }
179
+ expectCloseParen() {
180
+ const t = this.next();
181
+ if (!t || t.kind !== "rparen")
182
+ throw new ExprSyntaxError("expected )");
183
+ }
184
+ }
185
+ function parseExpr(input) {
186
+ return new Parser(tokenize(input)).parse();
187
+ }
188
+ function resolvePath(segments, context) {
189
+ let current = context;
190
+ for (const segment of segments) {
191
+ if (current === null || typeof current !== "object")
192
+ return;
193
+ const record = current;
194
+ if (!(segment in record))
195
+ return;
196
+ current = record[segment];
197
+ }
198
+ if (typeof current === "number" || typeof current === "string" || typeof current === "boolean") {
199
+ return current;
200
+ }
201
+ return;
202
+ }
203
+ function evalNode(node, context) {
204
+ switch (node.kind) {
205
+ case "literal":
206
+ return node.value;
207
+ case "path":
208
+ return resolvePath(node.segments, context);
209
+ case "comparison": {
210
+ const left = evalNode(node.left, context);
211
+ const right = evalNode(node.right, context);
212
+ if (left === undefined || right === undefined) {
213
+ if (node.op === "==")
214
+ return left === undefined && right === undefined;
215
+ if (node.op === "!=")
216
+ return left !== right;
217
+ throw new ExprEvalError("cannot order-compare an unknown path");
218
+ }
219
+ if (typeof left !== typeof right) {
220
+ throw new ExprEvalError(`cannot compare ${JSON.stringify(left)} (${typeof left}) with ${JSON.stringify(right)} (${typeof right})`);
221
+ }
222
+ switch (node.op) {
223
+ case "==":
224
+ return left === right;
225
+ case "!=":
226
+ return left !== right;
227
+ case "<":
228
+ return left < right;
229
+ case "<=":
230
+ return left <= right;
231
+ case ">":
232
+ return left > right;
233
+ case ">=":
234
+ return left >= right;
235
+ default:
236
+ throw new ExprEvalError(`unknown operator ${node.op}`);
237
+ }
238
+ }
239
+ case "and": {
240
+ const left = evalNode(node.left, context);
241
+ const right = evalNode(node.right, context);
242
+ if (typeof left !== "boolean" || typeof right !== "boolean") {
243
+ throw new ExprEvalError("and requires boolean operands");
244
+ }
245
+ return left && right;
246
+ }
247
+ case "or": {
248
+ const left = evalNode(node.left, context);
249
+ const right = evalNode(node.right, context);
250
+ if (typeof left !== "boolean" || typeof right !== "boolean") {
251
+ throw new ExprEvalError("or requires boolean operands");
252
+ }
253
+ return left || right;
254
+ }
255
+ case "not": {
256
+ const operand = evalNode(node.operand, context);
257
+ if (typeof operand !== "boolean")
258
+ throw new ExprEvalError("not requires a boolean operand");
259
+ return !operand;
260
+ }
261
+ }
262
+ }
263
+ function evaluateExpr(input, context) {
264
+ const ast = parseExpr(input);
265
+ const value = evalNode(ast, context);
266
+ if (typeof value !== "boolean") {
267
+ throw new ExprEvalError(`condition must evaluate to a boolean, got ${typeof value}`);
268
+ }
269
+ return value;
270
+ }
271
+ var ExprSyntaxError, ExprEvalError, COMPARISON_OPS;
272
+ var init_expr = __esm(() => {
273
+ ExprSyntaxError = class ExprSyntaxError extends Error {
274
+ constructor(message) {
275
+ super(message);
276
+ this.name = "ExprSyntaxError";
277
+ }
278
+ };
279
+ ExprEvalError = class ExprEvalError extends Error {
280
+ constructor(message) {
281
+ super(message);
282
+ this.name = "ExprEvalError";
283
+ }
284
+ };
285
+ COMPARISON_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
286
+ });
287
+
288
+ // src/graph.ts
289
+ function issue(issues, path, message) {
290
+ issues.push({ path, message });
291
+ }
292
+ function findNode(graph, id) {
293
+ return graph.nodes.find((n) => n.id === id);
294
+ }
295
+ function collectEdges(graph) {
296
+ const edges = [];
297
+ for (const node of graph.nodes) {
298
+ switch (node.type) {
299
+ case "start":
300
+ case "step":
301
+ if (node.next)
302
+ edges.push({ from: node.id, to: node.next });
303
+ break;
304
+ case "decision":
305
+ if (node.then)
306
+ edges.push({ from: node.id, to: node.then });
307
+ if (node.else)
308
+ edges.push({ from: node.id, to: node.else });
309
+ break;
310
+ case "while":
311
+ if (node.next)
312
+ edges.push({ from: node.id, to: node.next });
313
+ break;
314
+ case "end":
315
+ break;
316
+ }
317
+ }
318
+ return edges;
319
+ }
320
+ function reachableNodes(graph) {
321
+ const start = graph.nodes.find((n) => n.type === "start");
322
+ if (!start)
323
+ return [];
324
+ const seen = new Set;
325
+ const stack = [start.id];
326
+ while (stack.length > 0) {
327
+ const id = stack.pop();
328
+ if (seen.has(id))
329
+ continue;
330
+ seen.add(id);
331
+ const node = findNode(graph, id);
332
+ if (!node)
333
+ continue;
334
+ if (node.type === "start" || node.type === "step") {
335
+ if (node.next)
336
+ stack.push(node.next);
337
+ } else if (node.type === "decision") {
338
+ if (node.then)
339
+ stack.push(node.then);
340
+ if (node.else)
341
+ stack.push(node.else);
342
+ } else if (node.type === "while") {
343
+ for (const bodyId of node.body)
344
+ stack.push(bodyId);
345
+ if (node.next)
346
+ stack.push(node.next);
347
+ }
348
+ }
349
+ return [...seen];
350
+ }
351
+ function findCycles(graph) {
352
+ const edges = collectEdges(graph);
353
+ const adj = new Map;
354
+ for (const e of edges) {
355
+ const list = adj.get(e.from) ?? [];
356
+ list.push(e.to);
357
+ adj.set(e.from, list);
358
+ }
359
+ const color = new Map;
360
+ const stack = [];
361
+ const cycles = [];
362
+ function visit(id) {
363
+ color.set(id, "grey");
364
+ stack.push(id);
365
+ for (const to of adj.get(id) ?? []) {
366
+ const c = color.get(to);
367
+ if (c === "grey") {
368
+ const idx = stack.indexOf(to);
369
+ cycles.push([...stack.slice(idx), to]);
370
+ } else if (c === undefined) {
371
+ visit(to);
372
+ }
373
+ }
374
+ stack.pop();
375
+ color.set(id, "black");
376
+ }
377
+ for (const node of graph.nodes) {
378
+ if (color.get(node.id) === undefined)
379
+ visit(node.id);
380
+ }
381
+ return cycles;
382
+ }
383
+ function validateGraph(graph) {
384
+ const issues = [];
385
+ if (graph === null || typeof graph !== "object" || Array.isArray(graph)) {
386
+ return { ok: false, issues: [{ path: "graph", message: "graph must be an object" }] };
387
+ }
388
+ if (typeof graph.name !== "string" || graph.name.trim() === "") {
389
+ issue(issues, "name", "graph requires a non-empty name");
390
+ }
391
+ if (typeof graph.version !== "string" || graph.version.trim() === "") {
392
+ issue(issues, "version", "graph requires a version string");
393
+ }
394
+ if (graph.memoWatch !== undefined) {
395
+ if (!Array.isArray(graph.memoWatch) || graph.memoWatch.length === 0) {
396
+ issue(issues, "memoWatch", "memoWatch must be a non-empty array of file paths or globs");
397
+ } else {
398
+ for (const entry of graph.memoWatch) {
399
+ if (typeof entry !== "string" || entry.trim() === "") {
400
+ issue(issues, "memoWatch", "each memoWatch entry must be a non-empty path or glob string");
401
+ break;
402
+ }
403
+ }
404
+ }
405
+ }
406
+ if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
407
+ issue(issues, "nodes", "graph requires at least one node");
408
+ return { ok: false, issues };
409
+ }
410
+ for (let index = 0;index < graph.nodes.length; index++) {
411
+ const node = graph.nodes[index];
412
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
413
+ issue(issues, `nodes.${index}`, "node must be an object");
414
+ continue;
415
+ }
416
+ if (!GRAPH_NODE_TYPES.includes(node.type))
417
+ issue(issues, `nodes.${index}.type`, "type must be start, step, decision, while, or end");
418
+ for (const key of ["next", "then", "else", "prompt", "command"]) {
419
+ const value = node[key];
420
+ if (value !== undefined && (typeof value !== "string" || value.trim() === ""))
421
+ issue(issues, `nodes.${index}.${key}`, `${key} must be a non-empty string`);
422
+ }
423
+ if (node.type === "while" && (!Array.isArray(node.body) || node.body.some((id) => typeof id !== "string")))
424
+ issue(issues, `nodes.${index}.body`, "body must be an array of node ids");
425
+ }
426
+ if (issues.length)
427
+ return { ok: false, issues };
428
+ const starts = graph.nodes.filter((n) => n.type === "start");
429
+ if (starts.length === 0)
430
+ issue(issues, "nodes", "graph requires exactly one start node (found none)");
431
+ if (starts.length > 1)
432
+ issue(issues, "nodes", "graph requires exactly one start node (found multiple)");
433
+ const ends = graph.nodes.filter((n) => n.type === "end");
434
+ if (ends.length === 0)
435
+ issue(issues, "nodes", "graph requires at least one end node");
436
+ const seenIds = new Map;
437
+ for (const node of graph.nodes) {
438
+ if (typeof node.id !== "string" || !NODE_ID_RE.test(node.id)) {
439
+ issue(issues, "nodes", `node id ${JSON.stringify(node.id)} must match ${String(NODE_ID_RE)}`);
440
+ continue;
441
+ }
442
+ if (seenIds.has(node.id)) {
443
+ issue(issues, `nodes.${node.id}`, `duplicate node id ${node.id}`);
444
+ continue;
445
+ }
446
+ seenIds.set(node.id, node);
447
+ if (!GRAPH_NODE_TYPES.includes(node.type)) {
448
+ issue(issues, `nodes.${node.id}`, `unknown node type ${JSON.stringify(node.type)}`);
449
+ continue;
450
+ }
451
+ validateNode(graph, node, issues);
452
+ }
453
+ for (const node of graph.nodes) {
454
+ const edges = nodeEdges(node);
455
+ for (const { kind, target } of edges) {
456
+ if (!seenIds.has(target)) {
457
+ issue(issues, `nodes.${node.id}`, `${kind} edge targets nonexistent node ${target}`);
458
+ }
459
+ }
460
+ }
461
+ if (starts.length === 1) {
462
+ const reachable = new Set(reachableNodes(graph));
463
+ for (const node of graph.nodes) {
464
+ if (!reachable.has(node.id)) {
465
+ issue(issues, `nodes.${node.id}`, `node ${node.id} is unreachable from start`);
466
+ }
467
+ }
468
+ }
469
+ const cycles = findCycles(graph);
470
+ for (const cycle of cycles) {
471
+ issue(issues, "nodes", `explicit cycle detected: ${cycle.join(" -> ")} (loop only via a while node)`);
472
+ }
473
+ return { ok: issues.length === 0, issues };
474
+ }
475
+ function nodeEdges(node) {
476
+ switch (node.type) {
477
+ case "start":
478
+ case "step":
479
+ return node.next ? [{ kind: "next", target: node.next }] : [];
480
+ case "decision":
481
+ return [
482
+ ...node.then ? [{ kind: "then", target: node.then }] : [],
483
+ ...node.else ? [{ kind: "else", target: node.else }] : []
484
+ ];
485
+ case "while":
486
+ return node.next ? [{ kind: "next", target: node.next }] : [];
487
+ case "end":
488
+ return [];
489
+ default:
490
+ return [];
491
+ }
492
+ }
493
+ function validateNode(graph, node, issues) {
494
+ switch (node.type) {
495
+ case "start":
496
+ if (!node.next)
497
+ issue(issues, `nodes.${node.id}`, "start node requires a next edge");
498
+ break;
499
+ case "step": {
500
+ if (!node.prompt && !node.command) {
501
+ issue(issues, `nodes.${node.id}`, "step node requires prompt or command");
502
+ }
503
+ if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
504
+ issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
505
+ }
506
+ if (node.cwd !== undefined && (typeof node.cwd !== "string" || !node.cwd.trim() || node.cwd.includes("\x00"))) {
507
+ issue(issues, `nodes.${node.id}.cwd`, "cwd must be a non-empty path string without NUL characters on the executor");
508
+ }
509
+ if (node.codexMode !== undefined) {
510
+ if (!CODEX_EXECUTION_MODES.includes(node.codexMode)) {
511
+ issue(issues, `nodes.${node.id}.codexMode`, "codexMode must be repository or read-only");
512
+ } else if (node.lane !== "codex" || !node.prompt || node.command !== undefined) {
513
+ issue(issues, `nodes.${node.id}.codexMode`, "codexMode is supported only for a codex prompt step without command");
514
+ }
515
+ }
516
+ if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
517
+ issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
518
+ }
519
+ if (node.timeoutMs !== undefined && (!Number.isInteger(node.timeoutMs) || node.timeoutMs < 1)) {
520
+ issue(issues, `nodes.${node.id}`, "timeoutMs must be a positive integer");
521
+ }
522
+ break;
523
+ }
524
+ case "decision": {
525
+ if (typeof node.condition !== "string" || node.condition.trim() === "") {
526
+ issue(issues, `nodes.${node.id}`, "decision node requires a condition expression");
527
+ } else {
528
+ try {
529
+ parseExpr(node.condition);
530
+ } catch (err) {
531
+ if (err instanceof ExprSyntaxError) {
532
+ issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
533
+ } else {
534
+ throw err;
535
+ }
536
+ }
537
+ }
538
+ if (!node.then && !node.else) {
539
+ issue(issues, `nodes.${node.id}`, "decision node requires then and/or else edge");
540
+ }
541
+ break;
542
+ }
543
+ case "while": {
544
+ if (typeof node.condition !== "string" || node.condition.trim() === "") {
545
+ issue(issues, `nodes.${node.id}`, "while node requires a condition expression");
546
+ } else {
547
+ try {
548
+ parseExpr(node.condition);
549
+ } catch (err) {
550
+ if (err instanceof ExprSyntaxError) {
551
+ issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
552
+ } else {
553
+ throw err;
554
+ }
555
+ }
556
+ }
557
+ if (node.maxIterations === undefined) {
558
+ issue(issues, `nodes.${node.id}`, "while node requires maxIterations (finite loop bound)");
559
+ } else if (!Number.isInteger(node.maxIterations) || node.maxIterations < 1) {
560
+ issue(issues, `nodes.${node.id}`, "maxIterations must be a positive integer");
561
+ }
562
+ if (!Array.isArray(node.body) || node.body.length === 0) {
563
+ issue(issues, `nodes.${node.id}`, "while node requires a non-empty body");
564
+ } else {
565
+ for (const bodyId of node.body) {
566
+ const bodyNode = findNode(graph, bodyId);
567
+ if (!bodyNode) {
568
+ issue(issues, `nodes.${node.id}`, `while body references nonexistent node ${bodyId}`);
569
+ continue;
570
+ }
571
+ if (bodyNode.type === "start" || bodyNode.type === "end" || bodyNode.type === "while") {
572
+ issue(issues, `nodes.${node.id}`, `while body may only contain step/decision nodes in v1 (found ${bodyNode.type})`);
573
+ }
574
+ }
575
+ const bodySet = new Set(node.body);
576
+ for (const bodyId of node.body) {
577
+ const bodyNode = findNode(graph, bodyId);
578
+ if (!bodyNode)
579
+ continue;
580
+ for (const { kind, target } of nodeEdges(bodyNode)) {
581
+ if (!bodySet.has(target)) {
582
+ issue(issues, `nodes.${node.id}`, `while body node ${bodyId} ${kind}-edge leaves the body (target ${target})`);
583
+ }
584
+ }
585
+ }
586
+ }
587
+ break;
588
+ }
589
+ case "end":
590
+ break;
591
+ }
592
+ }
593
+ var GRAPH_NODE_TYPES, LANE_KINDS, CODEX_EXECUTION_MODES, NODE_ID_RE;
594
+ var init_graph = __esm(() => {
595
+ init_expr();
596
+ GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
597
+ LANE_KINDS = ["claude", "codex", "cursor", "grok"];
598
+ CODEX_EXECUTION_MODES = ["repository", "read-only"];
599
+ NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
600
+ });
601
+
602
+ // src/lanes/types.ts
603
+ import { statSync as statSync2 } from "fs";
604
+ function binaryOnPath(binary) {
605
+ const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
606
+ for (const entry of pathEntries) {
607
+ try {
608
+ const stat = statSync2(`${entry}/${binary}`);
609
+ if (stat.isFile())
610
+ return true;
611
+ } catch {}
612
+ }
613
+ return false;
614
+ }
615
+ function subscriptionEnvironment(overrides = {}) {
616
+ const paid = /^(OPENAI_API_KEY|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CODEX_API_KEY|CURSOR_API_KEY)$/;
617
+ const sensitive = /(?:API[_-]?KEY|SECRET|PASSWORD|TOKEN|DATABASE_URL|CREDENTIAL)/i;
618
+ const env = {};
619
+ for (const [name, value] of Object.entries(process.env)) {
620
+ if (value !== undefined && !paid.test(name) && (!sensitive.test(name) || name === "CLAUDE_CODE_OAUTH_TOKEN"))
621
+ env[name] = value;
622
+ }
623
+ for (const [name, value] of Object.entries(overrides)) {
624
+ if (paid.test(name) || sensitive.test(name))
625
+ throw new Error(`lane environment field ${name} requires a supported scoped credential consumer`);
626
+ env[name] = value;
627
+ }
628
+ return env;
629
+ }
630
+ function subscriptionSdkEnvironment(overrides = {}) {
631
+ const safe = subscriptionEnvironment(overrides);
632
+ return { ...Object.fromEntries(Object.keys(process.env).map((name) => [name, undefined])), ...safe };
633
+ }
634
+ function withTimeout(promise, timeoutMs, what, cancel) {
635
+ return new Promise((resolve, reject) => {
636
+ const timer = setTimeout(() => {
637
+ cancel?.();
638
+ reject(new LaneTimeoutError(what, timeoutMs));
639
+ }, timeoutMs);
640
+ promise.then((value) => {
641
+ clearTimeout(timer);
642
+ resolve(value);
643
+ }, (err) => {
644
+ clearTimeout(timer);
645
+ reject(err);
646
+ });
647
+ });
648
+ }
649
+ async function spawnCli(cmd, opts) {
650
+ const started = Date.now();
651
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
652
+ let proc;
653
+ try {
654
+ proc = Bun.spawn({
655
+ cmd,
656
+ cwd: opts.cwd,
657
+ env: subscriptionEnvironment(opts.env),
658
+ stdin: "ignore",
659
+ stdout: "pipe",
660
+ stderr: "pipe",
661
+ timeout: timeoutMs
662
+ });
663
+ } catch (err) {
664
+ const message = String(err instanceof Error ? err.message : err);
665
+ if (message.includes("ENOENT") || /not found/i.test(message)) {
666
+ throw new LaneDependencyMissingError(`lane binary ${JSON.stringify(opts.binaryName)} not found on PATH; install it or provide ${opts.packageHint}`);
667
+ }
668
+ return { ok: false, exitCode: 1, output: "", durationMs: Date.now() - started, error: message };
669
+ }
670
+ const [stdout, stderr, exitCode] = await Promise.all([
671
+ new Response(proc.stdout).text(),
672
+ new Response(proc.stderr).text(),
673
+ proc.exited
674
+ ]);
675
+ const output = opts.stdoutOnly ? stdout.trim() : stderr ? `${stdout}
676
+ ${stderr}`.trim() : stdout.trim();
677
+ return {
678
+ ok: exitCode === 0,
679
+ exitCode,
680
+ output,
681
+ durationMs: Date.now() - started,
682
+ ...opts.stdoutOnly && exitCode !== 0 && stderr.trim() ? { error: stderr.trim() } : {}
683
+ };
684
+ }
685
+ var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000, LaneTimeoutError;
686
+ var init_types = __esm(() => {
687
+ LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
688
+ LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
689
+ constructor(message) {
690
+ super(message);
691
+ this.name = "LaneDependencyMissingError";
692
+ }
693
+ };
694
+ LaneAdapterShapeError = class LaneAdapterShapeError extends Error {
695
+ constructor(message) {
696
+ super(message);
697
+ this.name = "LaneAdapterShapeError";
698
+ }
699
+ };
700
+ LaneTimeoutError = class LaneTimeoutError extends Error {
701
+ constructor(what, timeoutMs) {
702
+ super(`${what} exceeded the ${timeoutMs}ms budget`);
703
+ this.name = "LaneTimeoutError";
704
+ }
705
+ };
706
+ });
707
+
708
+ // src/lanes/claude.ts
709
+ function createClaudeAdapter(deps = {}) {
710
+ return {
711
+ kind: "claude",
712
+ async probe() {
713
+ const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
714
+ try {
715
+ await loader();
716
+ return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "sdk" };
717
+ } catch {
718
+ if (binaryOnPath(deps.cliPath ?? "claude")) {
719
+ return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "cli" };
720
+ }
721
+ return {
722
+ kind: "claude",
723
+ sdk: "@anthropic-ai/claude-agent-sdk",
724
+ wired: false,
725
+ reason: "neither @anthropic-ai/claude-agent-sdk nor the claude CLI is available on this machine"
726
+ };
727
+ }
728
+ },
729
+ async run(job) {
730
+ if (!job.prompt) {
731
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "claude lane requires a prompt" };
732
+ }
733
+ const started = Date.now();
734
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
735
+ const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
736
+ let sdk;
737
+ try {
738
+ sdk = await loader();
739
+ } catch (err) {
740
+ return runClaudeCli(job, deps.cliPath ?? "claude", started, timeoutMs);
741
+ }
742
+ const query = sdk.query;
743
+ if (typeof query !== "function") {
744
+ throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
745
+ }
746
+ const prompt = job.prompt;
747
+ const abortController = new AbortController;
748
+ try {
749
+ const { output, isError } = await withTimeout((async () => {
750
+ const generator = await query({
751
+ prompt,
752
+ options: { model: job.model, cwd: job.cwd, maxTurns: 32, abortController, env: subscriptionSdkEnvironment(job.env) }
753
+ });
754
+ let output2 = "";
755
+ let isError2 = false;
756
+ for await (const message of generator) {
757
+ if (message.type === "assistant") {
758
+ const content = message.message?.content;
759
+ if (Array.isArray(content)) {
760
+ for (const block of content) {
761
+ if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
762
+ output2 += block.text;
763
+ }
764
+ }
765
+ }
766
+ }
767
+ if (message.type === "error")
768
+ isError2 = true;
769
+ }
770
+ return { output: output2, isError: isError2 };
771
+ })(), timeoutMs, "claude lane", () => abortController.abort());
772
+ const durationMs = Date.now() - started;
773
+ return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
774
+ } catch (err) {
775
+ const durationMs = Date.now() - started;
776
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
777
+ }
778
+ }
779
+ };
780
+ }
781
+ async function runClaudeCli(job, cliPath, started, timeoutMs) {
782
+ try {
783
+ const result = await spawnCli([cliPath, "-p", job.prompt], {
784
+ binaryName: cliPath,
785
+ packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
786
+ timeoutMs,
787
+ env: job.env,
788
+ cwd: job.cwd
789
+ });
790
+ return { ...result, durationMs: Date.now() - started };
791
+ } catch (err) {
792
+ if (err instanceof LaneDependencyMissingError) {
793
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
794
+ }
795
+ throw err;
796
+ }
797
+ }
798
+ var init_claude = __esm(() => {
799
+ init_types();
800
+ });
801
+
802
+ // src/lanes/codex.ts
803
+ function createCodexAdapter(deps = {}) {
804
+ return {
805
+ kind: "codex",
806
+ async probe() {
807
+ const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
808
+ try {
809
+ await loader();
810
+ return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "sdk" };
811
+ } catch {
812
+ if (binaryOnPath(deps.cliPath ?? "codex")) {
813
+ return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "cli" };
814
+ }
815
+ return {
816
+ kind: "codex",
817
+ sdk: "@openai/codex-sdk",
818
+ wired: false,
819
+ reason: "neither @openai/codex-sdk nor the codex CLI is available on this machine"
820
+ };
821
+ }
822
+ },
823
+ async run(job) {
824
+ if (!job.prompt) {
825
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
826
+ }
827
+ if (job.codexMode !== undefined && (!CODEX_EXECUTION_MODES.includes(job.codexMode) || job.lane !== "codex" || job.command !== undefined)) {
828
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codexMode must be repository or read-only on a codex prompt without command", retryable: false };
829
+ }
830
+ const started = Date.now();
831
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
832
+ const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
833
+ let sdk;
834
+ try {
835
+ sdk = await loader();
836
+ } catch {
837
+ return runCodexCli(job, deps.cliPath ?? "codex", started, timeoutMs);
838
+ }
839
+ const CodexClass = sdk.Codex;
840
+ if (typeof CodexClass !== "function") {
841
+ throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
842
+ }
843
+ const abortController = new AbortController;
844
+ try {
845
+ const client = await withTimeout(Promise.resolve(new CodexClass({
846
+ config: { model: job.model },
847
+ env: subscriptionEnvironment(job.env)
848
+ })), timeoutMs, "codex lane");
849
+ const thread = client.startThread?.({
850
+ model: job.model,
851
+ workingDirectory: job.cwd,
852
+ ...job.codexMode === "read-only" ? { sandboxMode: "read-only", skipGitRepoCheck: true, approvalPolicy: "never" } : {}
853
+ });
854
+ if (!thread || typeof thread.run !== "function") {
855
+ throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
856
+ }
857
+ const turn = await withTimeout(thread.run(job.prompt, { signal: abortController.signal }), timeoutMs, "codex lane", () => abortController.abort());
858
+ const durationMs = Date.now() - started;
859
+ const output = turn?.finalResponse;
860
+ if (typeof output !== "string" || !output.trim()) {
861
+ return missingResponse(durationMs);
862
+ }
863
+ return { ok: true, exitCode: 0, output, durationMs };
864
+ } catch (err) {
865
+ const durationMs = Date.now() - started;
866
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
867
+ }
868
+ }
869
+ };
870
+ }
871
+ async function runCodexCli(job, cliPath, started, timeoutMs) {
872
+ try {
873
+ const controls = job.codexMode === "read-only" ? ["--sandbox", "read-only", "--skip-git-repo-check", "--config", 'approval_policy="never"'] : [];
874
+ const result = await spawnCli([cliPath, "exec", ...controls, "--", job.prompt], {
875
+ binaryName: cliPath,
876
+ packageHint: "@openai/codex-sdk or the codex CLI",
877
+ timeoutMs,
878
+ env: job.env,
879
+ cwd: job.cwd,
880
+ stdoutOnly: true
881
+ });
882
+ const durationMs = Date.now() - started;
883
+ return result.ok && !result.output.trim() ? missingResponse(durationMs) : { ...result, durationMs };
884
+ } catch (err) {
885
+ if (err instanceof LaneDependencyMissingError) {
886
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
887
+ }
888
+ throw err;
889
+ }
890
+ }
891
+ function missingResponse(durationMs) {
892
+ return { ok: false, exitCode: 1, output: "", durationMs, error: "Codex completed without an agent response; verify the native subscription and model route", retryable: false };
893
+ }
894
+ var init_codex = __esm(() => {
895
+ init_graph();
896
+ init_types();
897
+ });
898
+
899
+ // src/lanes/cursor.ts
900
+ function createCursorAdapter(deps = {}) {
901
+ return {
902
+ kind: "cursor",
903
+ async probe() {
904
+ const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
905
+ try {
906
+ await loader();
907
+ return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "sdk (local mode)" };
908
+ } catch {
909
+ if (binaryOnPath(deps.cliPath ?? "cursor-agent")) {
910
+ return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "cli" };
911
+ }
912
+ return {
913
+ kind: "cursor",
914
+ sdk: "@cursor/sdk",
915
+ wired: false,
916
+ reason: "neither @cursor/sdk nor the cursor-agent CLI is available on this machine"
917
+ };
918
+ }
919
+ },
920
+ async run(job) {
921
+ if (!job.prompt) {
922
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
923
+ }
924
+ if (!deps.sdkLoader && binaryOnPath(deps.cliPath ?? "cursor-agent"))
925
+ return runCursorCli(job, deps.cliPath ?? "cursor-agent", Date.now(), job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS);
926
+ const started = Date.now();
927
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
928
+ const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
929
+ let sdk;
930
+ try {
931
+ sdk = await loader();
932
+ } catch {
933
+ return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
934
+ }
935
+ let cancel;
936
+ try {
937
+ subscriptionEnvironment(job.env);
938
+ if (!deps.sdkLoader && process.env.CURSOR_API_KEY)
939
+ throw new Error("cursor lane refuses ambient API billing; use the native subscription CLI");
940
+ const run2 = await withTimeout(runLocalAgent(sdk, job, (callback) => {
941
+ cancel = callback;
942
+ }), timeoutMs, "cursor lane", () => cancel?.());
943
+ const durationMs = Date.now() - started;
944
+ const status = run2.status;
945
+ const ok = status === "finished";
946
+ return { ok, exitCode: ok ? 0 : 1, output: run2.result, durationMs };
947
+ } catch (err) {
948
+ if (err instanceof LaneAdapterShapeError)
949
+ throw err;
950
+ const durationMs = Date.now() - started;
951
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err), retryable: !(err instanceof LaneTimeoutError) };
952
+ }
953
+ }
954
+ };
955
+ }
956
+ async function runLocalAgent(sdk, job, onCancel) {
957
+ const AgentClass = sdk.Agent;
958
+ const CursorClass = sdk.Cursor;
959
+ if (typeof AgentClass !== "function" && typeof CursorClass !== "function") {
960
+ throw new LaneAdapterShapeError("@cursor/sdk exports no Agent/Cursor class");
961
+ }
962
+ const create = AgentClass.create;
963
+ const agent = typeof create === "function" ? await create.call(AgentClass, { local: { cwd: job.cwd, enableAgentRetries: false } }) : typeof AgentClass === "function" ? new AgentClass : undefined;
964
+ if (!agent || typeof agent.send !== "function") {
965
+ throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
966
+ }
967
+ let run2 = await agent.send(job.prompt, { model: job.model });
968
+ if (run2 && typeof run2 === "object") {
969
+ const handle = run2;
970
+ if (typeof handle.cancel === "function")
971
+ onCancel(() => {
972
+ handle.cancel().catch(() => {});
973
+ });
974
+ if (typeof handle.wait === "function")
975
+ run2 = await handle.wait();
976
+ }
977
+ if (!run2 || typeof run2 !== "object") {
978
+ throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
979
+ }
980
+ const candidate = run2;
981
+ if (typeof candidate.status !== "string" || !["finished", "error", "cancelled"].includes(candidate.status)) {
982
+ throw new LaneAdapterShapeError(`@cursor/sdk Agent.send() returned a Run without a terminal status (got ${JSON.stringify(candidate.status)}; expected finished|error|cancelled)`);
983
+ }
984
+ if (typeof candidate.result !== "string") {
985
+ throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned a Run without a string result");
986
+ }
987
+ return { status: candidate.status, result: candidate.result };
988
+ }
989
+ async function runCursorCli(job, cliPath, started, timeoutMs) {
990
+ try {
991
+ const result = await spawnCli([cliPath, "--print", job.prompt, ...job.model ? ["--model", job.model] : []], {
992
+ binaryName: cliPath,
993
+ packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
994
+ timeoutMs,
995
+ env: job.env,
996
+ cwd: job.cwd
997
+ });
998
+ return { ...result, durationMs: Date.now() - started };
999
+ } catch (err) {
1000
+ if (err instanceof LaneDependencyMissingError) {
1001
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
1002
+ }
1003
+ throw err;
1004
+ }
1005
+ }
1006
+ var init_cursor = __esm(() => {
1007
+ init_types();
1008
+ });
1009
+
1010
+ // src/lanes/grok.ts
1011
+ function createGrokAdapter(deps = {}) {
1012
+ return {
1013
+ kind: "grok",
1014
+ async probe() {
1015
+ const cliPath = deps.cliPath ?? "grok";
1016
+ if (binaryOnPath(cliPath)) {
1017
+ return { kind: "grok", sdk: "no npm SDK (measured 2026-08-25)", wired: true, via: "cli" };
1018
+ }
1019
+ return {
1020
+ kind: "grok",
1021
+ sdk: "no npm SDK (measured 2026-08-25)",
1022
+ wired: false,
1023
+ reason: `no xAI Grok SDK exists on npm (measured 2026-08-25) and the grok CLI is not on PATH`
1024
+ };
1025
+ },
1026
+ async run(job) {
1027
+ if (!job.prompt) {
1028
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "grok lane requires a prompt" };
1029
+ }
1030
+ const started = Date.now();
1031
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
1032
+ const cliPath = deps.cliPath ?? "grok";
1033
+ try {
1034
+ const result = await spawnCli([cliPath, job.prompt], {
1035
+ binaryName: cliPath,
1036
+ packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
1037
+ timeoutMs,
1038
+ env: job.env,
1039
+ cwd: job.cwd
1040
+ });
1041
+ return { ...result, durationMs: Date.now() - started };
1042
+ } catch (err) {
1043
+ if (err instanceof LaneDependencyMissingError) {
1044
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
1045
+ }
1046
+ throw err;
1047
+ }
1048
+ }
1049
+ };
1050
+ }
1051
+ var init_grok = __esm(() => {
1052
+ init_types();
1053
+ });
1054
+
1055
+ // src/lanes/index.ts
1056
+ var exports_lanes = {};
1057
+ __export(exports_lanes, {
1058
+ runLaneJob: () => runLaneJob,
1059
+ resolveLane: () => resolveLane,
1060
+ probeLane: () => probeLane,
1061
+ laneInventory: () => laneInventory,
1062
+ createGrokAdapter: () => createGrokAdapter,
1063
+ createCursorAdapter: () => createCursorAdapter,
1064
+ createCodexAdapter: () => createCodexAdapter,
1065
+ createClaudeAdapter: () => createClaudeAdapter,
1066
+ binaryOnPath: () => binaryOnPath,
1067
+ LaneDependencyMissingError: () => LaneDependencyMissingError,
1068
+ LaneAdapterShapeError: () => LaneAdapterShapeError,
1069
+ LANE_KINDS: () => LANE_KINDS2
1070
+ });
1071
+ function resolveLane(kind) {
1072
+ const adapter = adapters.get(kind);
1073
+ if (!adapter)
1074
+ throw new Error(`unknown lane ${kind} (expected one of ${LANE_KINDS2.join(", ")})`);
1075
+ return adapter;
1076
+ }
1077
+ async function runLaneJob(job) {
1078
+ return resolveLane(job.lane).run(job);
1079
+ }
1080
+ async function probeLane(kind) {
1081
+ return resolveLane(kind).probe();
1082
+ }
1083
+ async function laneInventory() {
1084
+ return Promise.all(LANE_KINDS2.map((kind) => probeLane(kind)));
1085
+ }
1086
+ var adapters;
1087
+ var init_lanes = __esm(() => {
1088
+ init_claude();
1089
+ init_codex();
1090
+ init_cursor();
1091
+ init_grok();
1092
+ init_types();
1093
+ init_types();
1094
+ init_claude();
1095
+ init_codex();
1096
+ init_cursor();
1097
+ init_grok();
1098
+ adapters = new Map([
1099
+ ["claude", createClaudeAdapter()],
1100
+ ["codex", createCodexAdapter()],
1101
+ ["cursor", createCursorAdapter()],
1102
+ ["grok", createGrokAdapter()]
1103
+ ]);
1104
+ });
1105
+
1106
+ // src/service.ts
1107
+ import { createHash as createHash3 } from "crypto";
1108
+ // package.json
1109
+ var package_default = {
1110
+ name: "@hasna/workflows",
1111
+ version: "0.2.1",
1112
+ description: "Universal graph workflow app \u2014 graph language (start/step/decision/while/end), three-table store, session WAL, daemon with claims/leases, four lane adapters (claude/codex/cursor/grok), and a full CLI on four surfaces",
1113
+ type: "module",
1114
+ main: "./dist/index.js",
1115
+ types: "./types/index.d.ts",
1116
+ bin: {
1117
+ workflows: "dist/cli/index.js",
1118
+ "workflows-mcp": "dist/mcp/index.js",
1119
+ "workflows-serve": "dist/serve/index.js"
1120
+ },
1121
+ exports: {
1122
+ ".": {
1123
+ types: "./types/index.d.ts",
1124
+ import: "./dist/index.js"
1125
+ },
1126
+ "./sdk": {
1127
+ types: "./types/sdk.d.ts",
1128
+ import: "./dist/sdk.js"
1129
+ },
1130
+ "./cli": {
1131
+ types: "./types/cli/index.d.ts",
1132
+ import: "./dist/cli/index.js"
1133
+ }
1134
+ },
1135
+ files: [
1136
+ "dist",
1137
+ "types",
1138
+ "README.md",
1139
+ "LICENSE",
1140
+ "hasna.contract.json"
1141
+ ],
1142
+ scripts: {
1143
+ build: "bun run check:sdk && bun run build:runtime && bun run build:types",
1144
+ "generate:sdk": "bun scripts/generate-sdk.ts --write",
1145
+ "check:sdk": "bun scripts/generate-sdk.ts",
1146
+ "test:artifacts": "bun scripts/artifact-acceptance.ts",
1147
+ "build:runtime": "bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/mcp/index.ts --outdir dist/mcp --target bun && bun build src/serve/index.ts src/serve/admin.ts --outdir dist/serve --target bun --packages external && bun build src/index.ts src/service.ts src/handlers.ts src/agent-tools.ts src/types.ts --root src --outdir dist --target bun --packages external && bun build src/sdk.ts --outdir dist --target node",
1148
+ "build:types": "tsc -p tsconfig.build.json --emitDeclarationOnly --outDir types",
1149
+ "artifact-scan": "bun scripts/contracts-cli.mjs artifact-scan dist",
1150
+ "contract:check": "bun scripts/contracts-cli.mjs repo-conformance .",
1151
+ typecheck: "tsc --noEmit",
1152
+ test: "bun test",
1153
+ "test:pg": 'WORKFLOWS_TEST_DATABASE_URL="${WORKFLOWS_TEST_DATABASE_URL:?point it at a fresh test Postgres database}" WORKFLOWS_REQUIRE_POSTGRES=1 bun test src/db/postgres-store.test.ts src/db/postgres-http.test.ts src/db/postgres-fencing.test.ts',
1154
+ prepack: "bun run build && bun run artifact-scan",
1155
+ prepublishOnly: "bun run test && bun run build"
1156
+ },
1157
+ keywords: [
1158
+ "workflows",
1159
+ "graph",
1160
+ "automation",
1161
+ "cli",
1162
+ "typescript",
1163
+ "bun",
1164
+ "hasna"
1165
+ ],
1166
+ publishConfig: {
1167
+ registry: "https://registry.npmjs.org",
1168
+ access: "public"
1169
+ },
1170
+ repository: {
1171
+ type: "git",
1172
+ url: "https://github.com/hasna/apps.git",
1173
+ directory: "apps/workflows"
1174
+ },
1175
+ homepage: "https://github.com/hasna/apps/tree/main/apps/workflows",
1176
+ bugs: {
1177
+ url: "https://github.com/hasna/apps/issues"
1178
+ },
1179
+ engines: {
1180
+ bun: ">=1.0.0"
1181
+ },
1182
+ author: "Hasna",
1183
+ license: "Apache-2.0",
1184
+ dependencies: {
1185
+ "@anthropic-ai/claude-agent-sdk": "0.3.234",
1186
+ "@cursor/sdk": "^1.0.28",
1187
+ "@hasna/contracts": "1.3.3",
1188
+ "@openai/codex-sdk": "0.160.0",
1189
+ commander: "^13.1.0",
1190
+ pg: "^8.16.3"
1191
+ },
1192
+ devDependencies: {
1193
+ "@types/bun": "1.3.14",
1194
+ "@types/pg": "^8.15.6",
1195
+ typescript: "^5.7.3"
1196
+ }
1197
+ };
1198
+
1199
+ // src/version.ts
1200
+ function packageVersion() {
1201
+ return package_default.version;
1202
+ }
1203
+
1204
+ // src/daemon.ts
1205
+ init_expr();
1206
+ init_graph();
1207
+ import { randomUUID as randomUUID2 } from "crypto";
1208
+
1209
+ // src/secrets.ts
1210
+ class SecretsGateError extends Error {
1211
+ constructor(message) {
1212
+ super(message);
1213
+ this.name = "SecretsGateError";
1214
+ }
1215
+ }
1216
+ var p = (...parts) => parts.join("");
1217
+ var VALUE_PATTERNS = [
1218
+ { detector: "anthropic_api_key", re: new RegExp(p("sk-", "ant-") + "[A-Za-z0-9_-]{10,}") },
1219
+ { detector: "openai_api_key", re: new RegExp(p("sk-", "proj-") + "[A-Za-z0-9_-]{10,}") },
1220
+ { detector: "npm_token", re: /npm_[A-Za-z0-9]{20,}/ },
1221
+ { detector: "github_token", re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
1222
+ { detector: "aws_access_key", re: /AKIA[0-9A-Z]{16}/ },
1223
+ { detector: "google_api_key", re: /AIza[0-9A-Za-z_-]{20,}/ },
1224
+ { detector: "xai_api_key", re: new RegExp(p("xai-") + "[A-Za-z0-9]{20,}") },
1225
+ { detector: "codex_token", re: new RegExp(p("ctx7", "sk-") + "[A-Za-z0-9]{10,}") },
1226
+ { detector: "private_key_block", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
1227
+ { detector: "bearer_token", re: /Bearer [A-Za-z0-9._~+/=-]{20,}/ }
1228
+ ];
1229
+ var CREDENTIAL_KEY_RE = /^(token|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|refresh[_-]?token|session[_-]?token|bot[_-]?token)$/i;
1230
+ function scanString(value, path, findings) {
1231
+ for (const { detector, re } of VALUE_PATTERNS) {
1232
+ if (re.test(value)) {
1233
+ findings.push({ path, detector });
1234
+ return;
1235
+ }
1236
+ }
1237
+ }
1238
+ function isTrivial(value) {
1239
+ const v = value.trim();
1240
+ if (v.length === 0)
1241
+ return true;
1242
+ const upper = v.toUpperCase();
1243
+ if (upper.startsWith("REPLACE") || upper.startsWith("CHANGE") || upper.startsWith("YOUR_") || upper.startsWith("EXAMPLE") || upper.startsWith("PLACEHOLDER"))
1244
+ return true;
1245
+ if (v.length < 8)
1246
+ return true;
1247
+ return false;
1248
+ }
1249
+ function scanForSecrets(value, path = "$") {
1250
+ const findings = [];
1251
+ if (value === null || value === undefined)
1252
+ return findings;
1253
+ if (typeof value === "string") {
1254
+ scanString(value, path, findings);
1255
+ return findings;
1256
+ }
1257
+ if (Array.isArray(value)) {
1258
+ value.forEach((item, index) => {
1259
+ findings.push(...scanForSecrets(item, `${path}[${index}]`));
1260
+ });
1261
+ return findings;
1262
+ }
1263
+ if (typeof value === "object") {
1264
+ const record = value;
1265
+ for (const [key, child] of Object.entries(record)) {
1266
+ if (CREDENTIAL_KEY_RE.test(key) && typeof child === "string" && !isTrivial(child)) {
1267
+ findings.push({ path: `${path}.${key}`, detector: "credential_key_field" });
1268
+ } else {
1269
+ findings.push(...scanForSecrets(child, `${path}.${key}`));
1270
+ }
1271
+ }
1272
+ }
1273
+ return findings;
1274
+ }
1275
+ function assertNoSecrets(value, what) {
1276
+ const findings = scanForSecrets(value);
1277
+ if (findings.length === 0)
1278
+ return;
1279
+ const detail = findings.map((f) => `${f.path} (${f.detector})`).join(", ");
1280
+ throw new SecretsGateError(`secrets write-gate refused to persist ${what}: credential-shaped value at ${detail}`);
1281
+ }
1282
+
1283
+ // src/session.ts
1284
+ import { createHash } from "crypto";
1285
+ import { readFileSync, statSync } from "fs";
1286
+ import { isAbsolute, join } from "path";
1287
+ var DEFAULT_MAX_ATTEMPTS = 3;
1288
+ function repairTornRuns(store, wal, opts = {}) {
1289
+ const sequence = repairSteps(store, wal, opts);
1290
+ let step = sequence.next();
1291
+ while (!step.done) {
1292
+ if (step.value instanceof Promise) {
1293
+ return (async () => {
1294
+ while (!step.done)
1295
+ step = sequence.next(await step.value);
1296
+ return step.value;
1297
+ })();
1298
+ }
1299
+ step = sequence.next(step.value);
1300
+ }
1301
+ return step.value;
1302
+ }
1303
+ function* repairSteps(store, wal, opts) {
1304
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1305
+ const replay = wal.replay();
1306
+ const liveClaims = replay.liveClaims(opts.now);
1307
+ const report = { interrupted: 0, requeued: 0, failed: 0 };
1308
+ const running = yield store.listRuns({ status: "running", limit: 1000 });
1309
+ for (const run of running) {
1310
+ if (liveClaims.has(run.id))
1311
+ continue;
1312
+ report.interrupted++;
1313
+ const nodes = yield store.listRunNodes(run.id);
1314
+ if (run.attempts < maxAttempts) {
1315
+ yield store.bumpAttempts(run.id);
1316
+ yield store.setRunStatus(run.id, "interrupted", {
1317
+ error: `torn run interrupted after claim loss (attempt ${run.attempts + 1}/${maxAttempts})`
1318
+ });
1319
+ for (const node of nodes) {
1320
+ if (node.status === "running") {
1321
+ yield store.setRunNodeStatus(node.id, "pending", { error: undefined });
1322
+ }
1323
+ }
1324
+ report.requeued++;
1325
+ } else {
1326
+ yield store.setRunStatus(run.id, "failed", {
1327
+ error: `torn run failed after ${maxAttempts} interrupted attempts`
1328
+ });
1329
+ for (const node of nodes) {
1330
+ if (node.status === "running") {
1331
+ yield store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
1332
+ }
1333
+ }
1334
+ report.failed++;
1335
+ }
1336
+ }
1337
+ return report;
1338
+ }
1339
+ function inputHash(payload) {
1340
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1341
+ }
1342
+ function memoKey(graphName, nodeId, input) {
1343
+ return `${graphName}:${nodeId}:${inputHash(input)}`;
1344
+ }
1345
+ function tryMemoHit(store, graphName, nodeId, input) {
1346
+ const key = memoKey(graphName, nodeId, input);
1347
+ const value = store.memoGet(key);
1348
+ if (value instanceof Promise) {
1349
+ return value.then(async (row) => {
1350
+ if (row)
1351
+ await store.memoHit(key);
1352
+ return row;
1353
+ });
1354
+ }
1355
+ if (value)
1356
+ store.memoHit(key);
1357
+ return value;
1358
+ }
1359
+ function memoWatchFingerprint(graph, dataDir) {
1360
+ if (!graph.memoWatch || graph.memoWatch.length === 0)
1361
+ return;
1362
+ const entries = [];
1363
+ for (const pattern of graph.memoWatch) {
1364
+ if (hasGlobMagic(pattern)) {
1365
+ const matches = [...new Bun.Glob(pattern).scanSync({ cwd: dataDir, onlyFiles: true })].sort();
1366
+ for (const match of matches) {
1367
+ const path2 = isAbsolute(match) ? match : join(dataDir, match);
1368
+ entries.push(fingerprintFile(pattern, path2));
1369
+ }
1370
+ continue;
1371
+ }
1372
+ const path = isAbsolute(pattern) ? pattern : join(dataDir, pattern);
1373
+ entries.push(fingerprintFile(pattern, path));
1374
+ }
1375
+ return entries;
1376
+ }
1377
+ function hasGlobMagic(pattern) {
1378
+ return /[*?[\]{}]/.test(pattern);
1379
+ }
1380
+ function fingerprintFile(pattern, path) {
1381
+ try {
1382
+ const stat = statSync(path);
1383
+ if (!stat.isFile()) {
1384
+ return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
1385
+ }
1386
+ const content = readFileSync(path);
1387
+ return {
1388
+ pattern,
1389
+ path,
1390
+ missing: false,
1391
+ mtimeMs: stat.mtimeMs,
1392
+ size: content.byteLength,
1393
+ sha256: createHash("sha256").update(content).digest("hex")
1394
+ };
1395
+ } catch {
1396
+ return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
1397
+ }
1398
+ }
1399
+
1400
+ // src/db/postgres-store.ts
1401
+ import { randomUUID } from "crypto";
1402
+ import { Pool } from "pg";
1403
+ var run = (r) => ({
1404
+ id: String(r.id),
1405
+ graphName: String(r.graph_name),
1406
+ graphVersion: String(r.graph_version),
1407
+ status: r.status,
1408
+ attempts: Number(r.attempts),
1409
+ contextJson: String(r.context_json),
1410
+ resultJson: r.result_json == null ? null : String(r.result_json),
1411
+ error: r.error == null ? null : String(r.error),
1412
+ startedAt: r.started_at == null ? null : String(r.started_at),
1413
+ finishedAt: r.finished_at == null ? null : String(r.finished_at),
1414
+ createdAt: String(r.created_at),
1415
+ updatedAt: String(r.updated_at)
1416
+ });
1417
+ var node = (r) => ({
1418
+ id: String(r.id),
1419
+ runId: String(r.run_id),
1420
+ nodeId: String(r.node_id),
1421
+ status: r.status,
1422
+ attempts: Number(r.attempts),
1423
+ lane: r.lane == null ? null : String(r.lane),
1424
+ exitCode: r.exit_code == null ? null : Number(r.exit_code),
1425
+ outputJson: r.output_json == null ? null : String(r.output_json),
1426
+ error: r.error == null ? null : String(r.error),
1427
+ startedAt: r.started_at == null ? null : String(r.started_at),
1428
+ finishedAt: r.finished_at == null ? null : String(r.finished_at)
1429
+ });
1430
+ var memo = (r) => ({
1431
+ key: String(r.key),
1432
+ graphName: String(r.graph_name),
1433
+ nodeId: String(r.node_id),
1434
+ inputHash: String(r.input_hash),
1435
+ outputJson: String(r.output_json),
1436
+ hitCount: Number(r.hit_count),
1437
+ createdAt: String(r.created_at)
1438
+ });
1439
+
1440
+ class WorkflowStorageError extends Error {
1441
+ constructor() {
1442
+ super("PostgreSQL workflow storage is unavailable or its schema is invalid");
1443
+ this.name = "WorkflowStorageError";
1444
+ }
1445
+ }
1446
+
1447
+ class PostgresWorkflowsStore {
1448
+ pool;
1449
+ prefix;
1450
+ dataDir;
1451
+ execution;
1452
+ constructor(options) {
1453
+ const schema = options.schema ?? "workflows";
1454
+ if (!/^[a-z][a-z0-9_]{0,62}$/.test(schema))
1455
+ throw new Error("PostgreSQL schema must be a lowercase SQL identifier");
1456
+ this.prefix = `"${schema}"`;
1457
+ this.dataDir = options.dataDir;
1458
+ this.pool = new Pool({
1459
+ connectionString: options.databaseUrl,
1460
+ max: 4,
1461
+ connectionTimeoutMillis: 5000,
1462
+ idleTimeoutMillis: 1e4,
1463
+ statement_timeout: 1e4,
1464
+ application_name: "workflows"
1465
+ });
1466
+ }
1467
+ async rows(sql, values = []) {
1468
+ try {
1469
+ if (this.execution?.lost)
1470
+ throw new WorkflowStorageError;
1471
+ return (await (this.execution?.client ?? this.pool).query(sql, values)).rows;
1472
+ } catch (error) {
1473
+ if (error?.code === "23505")
1474
+ throw Object.assign(new Error("workflow uniqueness conflict"), { code: "23505" });
1475
+ throw new WorkflowStorageError;
1476
+ }
1477
+ }
1478
+ async close() {
1479
+ await this.releaseExecution();
1480
+ await this.pool.end();
1481
+ }
1482
+ async acquireExecution(runId) {
1483
+ if (this.execution)
1484
+ return this.execution.runId === runId && !this.execution.lost;
1485
+ const client = await this.pool.connect();
1486
+ let lost = false;
1487
+ client.on("error", () => {
1488
+ lost = true;
1489
+ if (this.execution?.client === client)
1490
+ this.execution.lost = true;
1491
+ });
1492
+ try {
1493
+ const acquired = await client.query("SELECT pg_try_advisory_lock(hashtextextended($1,0)) AS owned", [`${this.prefix}:${runId}`]);
1494
+ if (!acquired.rows[0]?.owned) {
1495
+ client.release();
1496
+ return false;
1497
+ }
1498
+ this.execution = { runId, client, lost };
1499
+ return !lost;
1500
+ } catch {
1501
+ client.release(true);
1502
+ throw new WorkflowStorageError;
1503
+ }
1504
+ }
1505
+ async releaseExecution() {
1506
+ const owned = this.execution;
1507
+ if (!owned)
1508
+ return;
1509
+ this.execution = undefined;
1510
+ try {
1511
+ if (!owned.lost)
1512
+ await owned.client.query("SELECT pg_advisory_unlock(hashtextextended($1,0))", [`${this.prefix}:${owned.runId}`]);
1513
+ } finally {
1514
+ owned.client.release(owned.lost);
1515
+ }
1516
+ }
1517
+ async transaction(action) {
1518
+ if (this.execution?.lost)
1519
+ throw new WorkflowStorageError;
1520
+ const client = this.execution?.client ?? await this.pool.connect();
1521
+ const retained = client === this.execution?.client;
1522
+ try {
1523
+ await client.query("BEGIN");
1524
+ const result = await action(client);
1525
+ await client.query("COMMIT");
1526
+ return result;
1527
+ } catch {
1528
+ try {
1529
+ await client.query("ROLLBACK");
1530
+ } catch {}
1531
+ throw new WorkflowStorageError;
1532
+ } finally {
1533
+ if (!retained)
1534
+ client.release();
1535
+ }
1536
+ }
1537
+ async listTables() {
1538
+ return (await this.rows("SELECT table_name FROM information_schema.tables WHERE table_schema=$1 AND table_type='BASE TABLE' ORDER BY table_name", [this.prefix.slice(1, -1)])).map((row) => String(row.table_name));
1539
+ }
1540
+ async checkSchema() {
1541
+ if ((await this.listTables()).join(",") !== "memos,run_nodes,runs")
1542
+ return false;
1543
+ await this.rows(`SELECT id,graph_name,graph_version,status,attempts,context_json,result_json,error,started_at,finished_at,created_at,updated_at FROM ${this.prefix}.runs LIMIT 0`);
1544
+ await this.rows(`SELECT id,sequence,run_id,node_id,status,attempts,lane,exit_code,output_json,error,started_at,finished_at FROM ${this.prefix}.run_nodes LIMIT 0`);
1545
+ await this.rows(`SELECT key,graph_name,node_id,input_hash,output_json,hit_count,created_at FROM ${this.prefix}.memos LIMIT 0`);
1546
+ const schema = this.prefix.slice(1, -1);
1547
+ const indices = await this.rows(`SELECT i.indisunique,i.indisvalid,i.indisready,i.indnkeyatts,i.indnatts,i.indpred IS NULL AS unfiltered,pg_get_expr(i.indexprs,i.indrelid) AS expression FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid JOIN pg_class t ON t.oid=i.indrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname='runs_idempotency' AND t.relname='runs'`, [schema]);
1548
+ const index = indices[0];
1549
+ if (indices.length !== 1 || !index?.indisunique || !index.indisvalid || !index.indisready || !index.unfiltered || Number(index.indnkeyatts) !== 1 || Number(index.indnatts) !== 1 || String(index.expression).replace(/[()\s]/g, "") !== "context_json::jsonb#>>'{__wf,idempotencyKey}'::text[]")
1550
+ return false;
1551
+ const columns = await this.rows("SELECT table_name,column_name,data_type,is_nullable,is_identity,identity_generation FROM information_schema.columns WHERE table_schema=$1", [schema]);
1552
+ const required = {
1553
+ runs: ["id:text", "graph_name:text", "graph_version:text", "status:text", "attempts:integer", "context_json:text", "created_at:text", "updated_at:text"],
1554
+ run_nodes: ["id:text", "sequence:bigint", "run_id:text", "node_id:text", "status:text", "attempts:integer"],
1555
+ memos: ["key:text", "graph_name:text", "node_id:text", "input_hash:text", "output_json:text", "hit_count:integer", "created_at:text"]
1556
+ };
1557
+ for (const [table, fields] of Object.entries(required))
1558
+ for (const field of fields) {
1559
+ const [name, type] = field.split(":");
1560
+ const c = columns.find((row) => row.table_name === table && row.column_name === name);
1561
+ if (!c || c.data_type !== type || c.is_nullable !== "NO")
1562
+ return false;
1563
+ if (name === "sequence" && (c.is_identity !== "YES" || c.identity_generation !== "ALWAYS"))
1564
+ return false;
1565
+ }
1566
+ const constraints = await this.rows(`SELECT c.relname AS table_name,co.contype,pg_get_constraintdef(co.oid) AS definition FROM pg_constraint co JOIN pg_class c ON c.oid=co.conrelid JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1`, [schema]);
1567
+ for (const [table, key] of [["runs", "id"], ["run_nodes", "id"], ["memos", "key"]])
1568
+ if (!constraints.some((row) => row.table_name === table && row.contype === "p" && row.definition === `PRIMARY KEY (${key})`))
1569
+ return false;
1570
+ return constraints.some((row) => row.table_name === "run_nodes" && row.contype === "f" && String(row.definition).replaceAll('"', "") === `FOREIGN KEY (run_id) REFERENCES ${schema}.runs(id)`);
1571
+ }
1572
+ async initialize() {
1573
+ const tables = await this.listTables();
1574
+ if (tables.length) {
1575
+ if (await this.checkSchema())
1576
+ return;
1577
+ throw new WorkflowStorageError;
1578
+ }
1579
+ const client = await this.pool.connect();
1580
+ try {
1581
+ await client.query("BEGIN");
1582
+ await client.query(`CREATE SCHEMA IF NOT EXISTS ${this.prefix};
1583
+ CREATE TABLE IF NOT EXISTS ${this.prefix}.runs (
1584
+ id TEXT PRIMARY KEY, graph_name TEXT NOT NULL, graph_version TEXT NOT NULL, status TEXT NOT NULL,
1585
+ attempts INTEGER NOT NULL DEFAULT 0, context_json TEXT NOT NULL, result_json TEXT, error TEXT,
1586
+ started_at TEXT, finished_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
1587
+ CREATE TABLE IF NOT EXISTS ${this.prefix}.run_nodes (
1588
+ id TEXT PRIMARY KEY, sequence BIGINT GENERATED ALWAYS AS IDENTITY, run_id TEXT NOT NULL REFERENCES ${this.prefix}.runs(id), node_id TEXT NOT NULL,
1589
+ status TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, lane TEXT, exit_code INTEGER,
1590
+ output_json TEXT, error TEXT, started_at TEXT, finished_at TEXT);
1591
+ CREATE TABLE IF NOT EXISTS ${this.prefix}.memos (
1592
+ key TEXT PRIMARY KEY, graph_name TEXT NOT NULL, node_id TEXT NOT NULL, input_hash TEXT NOT NULL,
1593
+ output_json TEXT NOT NULL, hit_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL);
1594
+ CREATE UNIQUE INDEX IF NOT EXISTS runs_idempotency ON ${this.prefix}.runs ((context_json::jsonb #>> '{__wf,idempotencyKey}'));
1595
+ CREATE INDEX IF NOT EXISTS runs_status ON ${this.prefix}.runs(status);
1596
+ CREATE INDEX IF NOT EXISTS nodes_run ON ${this.prefix}.run_nodes(run_id);`);
1597
+ await client.query("COMMIT");
1598
+ } catch {
1599
+ await client.query("ROLLBACK");
1600
+ throw new WorkflowStorageError;
1601
+ } finally {
1602
+ client.release();
1603
+ }
1604
+ if (!await this.checkSchema())
1605
+ throw new WorkflowStorageError;
1606
+ }
1607
+ async createRun(input) {
1608
+ const at = new Date().toISOString();
1609
+ const rows = await this.rows(`INSERT INTO ${this.prefix}.runs(id,graph_name,graph_version,status,context_json,created_at,updated_at)
1610
+ VALUES($1,$2,$3,'pending',$4,$5,$5) RETURNING *`, [randomUUID(), input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), at]);
1611
+ return run(rows[0]);
1612
+ }
1613
+ async getRun(id) {
1614
+ const rows = await this.rows(`SELECT * FROM ${this.prefix}.runs WHERE id=$1`, [id]);
1615
+ return rows[0] ? run(rows[0]) : undefined;
1616
+ }
1617
+ async getRunByIdempotencyKey(key) {
1618
+ const rows = await this.rows(`SELECT * FROM ${this.prefix}.runs WHERE context_json::jsonb #>> '{__wf,idempotencyKey}'=$1`, [key]);
1619
+ return rows[0] ? run(rows[0]) : undefined;
1620
+ }
1621
+ async listRuns(options = {}) {
1622
+ const limit = Math.max(1, Math.min(1000, options.limit ?? 100));
1623
+ return (await this.rows(`SELECT * FROM ${this.prefix}.runs ${options.status ? "WHERE status=$2" : ""} ORDER BY created_at DESC,id DESC LIMIT $1`, options.status ? [limit, options.status] : [limit])).map(run);
1624
+ }
1625
+ async setRunStatus(id, status, patch = {}) {
1626
+ const values = [id, status, new Date().toISOString()];
1627
+ const sets = ["status=$2", "updated_at=$3"];
1628
+ for (const [field, key] of [["result_json", "result"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
1629
+ if (Object.hasOwn(patch, key)) {
1630
+ values.push(key === "result" ? JSON.stringify(patch.result) : patch[key]);
1631
+ sets.push(`${field}=$${values.length}`);
1632
+ }
1633
+ }
1634
+ if (status === "running" && !Object.hasOwn(patch, "startedAt"))
1635
+ sets.push("started_at=COALESCE(started_at,$3)");
1636
+ await this.rows(`UPDATE ${this.prefix}.runs SET ${sets.join(",")} WHERE id=$1`, values);
1637
+ }
1638
+ async compareRunStatus(id, expected, status, patch = {}) {
1639
+ const values = [id, expected, status, new Date().toISOString()];
1640
+ const sets = ["status=$3", "updated_at=$4"];
1641
+ for (const [field, key] of [["result_json", "result"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
1642
+ if (patch && Object.hasOwn(patch, key)) {
1643
+ values.push(key === "result" ? JSON.stringify(patch.result) : patch[key]);
1644
+ sets.push(`${field}=$${values.length}`);
1645
+ }
1646
+ }
1647
+ return (await this.rows(`UPDATE ${this.prefix}.runs SET ${sets.join(",")} WHERE id=$1 AND status=ANY($2::text[]) RETURNING id`, values)).length === 1;
1648
+ }
1649
+ async setRunContext(id, context) {
1650
+ await this.rows(`UPDATE ${this.prefix}.runs SET context_json=$2,updated_at=$3 WHERE id=$1`, [id, JSON.stringify(context), new Date().toISOString()]);
1651
+ }
1652
+ async setRunningContext(id, context) {
1653
+ return (await this.rows(`UPDATE ${this.prefix}.runs SET context_json=$2,updated_at=$3 WHERE id=$1 AND status='running' RETURNING id`, [id, JSON.stringify(context), new Date().toISOString()])).length === 1;
1654
+ }
1655
+ async blockRunResume(id) {
1656
+ await this.rows(`UPDATE ${this.prefix}.runs SET context_json=jsonb_set(context_json::jsonb,'{__wf,resumeBlocked}',to_jsonb($2::text))::text,updated_at=$3 WHERE id=$1`, [id, "execution cancellation has not been confirmed", new Date().toISOString()]);
1657
+ }
1658
+ async dispatchIfRunning(id, action) {
1659
+ const marked = await this.rows(`UPDATE ${this.prefix}.runs SET context_json=jsonb_set(context_json::jsonb,'{__wf,executionMayBeActive}','true'::jsonb)::text WHERE id=$1 AND status='running' RETURNING id`, [id]);
1660
+ if (!marked.length)
1661
+ return { started: false };
1662
+ return this.transaction(async (client) => {
1663
+ const run2 = await client.query(`SELECT status FROM ${this.prefix}.runs WHERE id=$1 FOR UPDATE`, [id]);
1664
+ if (run2.rows[0]?.status !== "running")
1665
+ return { started: false };
1666
+ return { started: true, result: action() };
1667
+ });
1668
+ }
1669
+ async finishDispatch(id, unconfirmed) {
1670
+ await this.rows(`UPDATE ${this.prefix}.runs SET context_json=(CASE WHEN $2 THEN jsonb_set(context_json::jsonb #- '{__wf,executionMayBeActive}','{__wf,resumeBlocked}',to_jsonb($3::text)) ELSE context_json::jsonb #- '{__wf,executionMayBeActive}' END)::text,updated_at=$4 WHERE id=$1`, [id, unconfirmed, "execution cancellation has not been confirmed", new Date().toISOString()]);
1671
+ }
1672
+ async commitStep(input) {
1673
+ return this.transaction(async (client) => {
1674
+ const nodes = await client.query(`SELECT run_id FROM ${this.prefix}.run_nodes WHERE id=$1 FOR UPDATE`, [input.nodeRowId]);
1675
+ if (nodes.rows[0]?.run_id !== input.runId)
1676
+ return false;
1677
+ const run2 = await client.query(`SELECT status FROM ${this.prefix}.runs WHERE id=$1 FOR UPDATE`, [input.runId]);
1678
+ if (run2.rows[0]?.status !== "running")
1679
+ return false;
1680
+ const patch = input.patch ?? {}, values = [input.nodeRowId, input.status];
1681
+ const sets = ["status=$2"];
1682
+ for (const [field, key] of [["exit_code", "exitCode"], ["output_json", "output"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
1683
+ if (Object.hasOwn(patch, key)) {
1684
+ values.push(key === "output" ? JSON.stringify(patch.output) : patch[key]);
1685
+ sets.push(`${field}=$${values.length}`);
1686
+ }
1687
+ }
1688
+ if (input.status === "running") {
1689
+ values.push(new Date().toISOString());
1690
+ sets.push(`started_at=COALESCE(started_at,$${values.length})`);
1691
+ }
1692
+ if (["completed", "failed", "skipped"].includes(input.status)) {
1693
+ values.push(new Date().toISOString());
1694
+ sets.push(`finished_at=$${values.length}`);
1695
+ }
1696
+ if (input.incrementAttempts)
1697
+ sets.push("attempts=attempts+1");
1698
+ await client.query(`UPDATE ${this.prefix}.run_nodes SET ${sets.join(",")} WHERE id=$1`, values);
1699
+ if (input.context !== undefined)
1700
+ await client.query(`UPDATE ${this.prefix}.runs SET context_json=$2,updated_at=$3 WHERE id=$1`, [input.runId, JSON.stringify(input.context), new Date().toISOString()]);
1701
+ if (input.memo) {
1702
+ const m = input.memo;
1703
+ await client.query(`INSERT INTO ${this.prefix}.memos(key,graph_name,node_id,input_hash,output_json,created_at) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(key) DO UPDATE SET output_json=EXCLUDED.output_json`, [m.key, m.graphName, m.nodeId, m.inputHash, m.outputJson, new Date().toISOString()]);
1704
+ }
1705
+ return true;
1706
+ });
1707
+ }
1708
+ async bumpAttempts(id) {
1709
+ await this.rows(`UPDATE ${this.prefix}.runs SET attempts=attempts+1,updated_at=$2 WHERE id=$1`, [id, new Date().toISOString()]);
1710
+ }
1711
+ async createRunNode(input) {
1712
+ const rows = await this.rows(`INSERT INTO ${this.prefix}.run_nodes(id,run_id,node_id,lane,status) VALUES($1,$2,$3,$4,'pending') RETURNING *`, [randomUUID(), input.runId, input.nodeId, input.lane ?? null]);
1713
+ return node(rows[0]);
1714
+ }
1715
+ async getRunNode(id) {
1716
+ const rows = await this.rows(`SELECT * FROM ${this.prefix}.run_nodes WHERE id=$1`, [id]);
1717
+ return rows[0] ? node(rows[0]) : undefined;
1718
+ }
1719
+ async listRunNodes(runId) {
1720
+ return (await this.rows(`SELECT * FROM ${this.prefix}.run_nodes WHERE run_id=$1 ORDER BY sequence`, [runId])).map(node);
1721
+ }
1722
+ async getLatestRunNode(runId, nodeId) {
1723
+ const rows = await this.rows(`SELECT * FROM ${this.prefix}.run_nodes WHERE run_id=$1 AND node_id=$2 ORDER BY sequence DESC LIMIT 1`, [runId, nodeId]);
1724
+ return rows[0] ? node(rows[0]) : undefined;
1725
+ }
1726
+ async setRunNodeStatus(id, status, patch = {}) {
1727
+ const values = [id, status];
1728
+ const sets = ["status=$2"];
1729
+ for (const [field, key] of [["exit_code", "exitCode"], ["output_json", "output"], ["error", "error"], ["started_at", "startedAt"], ["finished_at", "finishedAt"]]) {
1730
+ if (Object.hasOwn(patch, key)) {
1731
+ values.push(key === "output" ? JSON.stringify(patch.output) : patch[key]);
1732
+ sets.push(`${field}=$${values.length}`);
1733
+ }
1734
+ }
1735
+ if (status === "running" && !Object.hasOwn(patch, "startedAt")) {
1736
+ values.push(new Date().toISOString());
1737
+ sets.push(`started_at=COALESCE(started_at,$${values.length})`);
1738
+ }
1739
+ if (["completed", "failed", "skipped"].includes(status) && !Object.hasOwn(patch, "finishedAt")) {
1740
+ values.push(new Date().toISOString());
1741
+ sets.push(`finished_at=$${values.length}`);
1742
+ }
1743
+ await this.rows(`UPDATE ${this.prefix}.run_nodes SET ${sets.join(",")} WHERE id=$1`, values);
1744
+ }
1745
+ async bumpAttemptsNode(id) {
1746
+ await this.rows(`UPDATE ${this.prefix}.run_nodes SET attempts=attempts+1 WHERE id=$1`, [id]);
1747
+ }
1748
+ async memoPut(key, graphName, nodeId, inputHash2, outputJson) {
1749
+ await this.rows(`INSERT INTO ${this.prefix}.memos(key,graph_name,node_id,input_hash,output_json,created_at) VALUES($1,$2,$3,$4,$5,$6)
1750
+ ON CONFLICT(key) DO UPDATE SET output_json=EXCLUDED.output_json`, [key, graphName, nodeId, inputHash2, outputJson, new Date().toISOString()]);
1751
+ }
1752
+ async memoGet(key) {
1753
+ const rows = await this.rows(`SELECT * FROM ${this.prefix}.memos WHERE key=$1`, [key]);
1754
+ return rows[0] ? memo(rows[0]) : undefined;
1755
+ }
1756
+ async memoHit(key) {
1757
+ await this.rows(`UPDATE ${this.prefix}.memos SET hit_count=hit_count+1 WHERE key=$1`, [key]);
1758
+ }
1759
+ async memoList() {
1760
+ return (await this.rows(`SELECT * FROM ${this.prefix}.memos ORDER BY created_at DESC,key LIMIT 1000`)).map(memo);
1761
+ }
1762
+ async memoClear() {
1763
+ throw new Error("PostgreSQL memo removal requires the reviewed server-operator preservation procedure");
1764
+ }
1765
+ }
1766
+
1767
+ // src/daemon.ts
1768
+ init_types();
1769
+ init_types();
1770
+ var DEFAULT_LEASE_TTL_MS = 120000;
1771
+ var DEFAULT_MAX_ATTEMPTS2 = 3;
1772
+ var DEFAULT_MAX_DISPATCH = 4;
1773
+ var DEFAULT_MAX_STEPS = 25;
1774
+ var DEFAULT_TIMEOUT_MS = 120000;
1775
+ function nowIso() {
1776
+ return new Date().toISOString();
1777
+ }
1778
+ function stripRunState(context) {
1779
+ const { __wf: _wf, steps: _steps, ...user } = context;
1780
+ return user;
1781
+ }
1782
+ async function runCommand(job) {
1783
+ if (!job.command) {
1784
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "no command" };
1785
+ }
1786
+ const started = Date.now();
1787
+ const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1788
+ const proc = Bun.spawn({
1789
+ cmd: ["/bin/sh", "-c", job.command],
1790
+ cwd: job.cwd,
1791
+ env: subscriptionEnvironment(job.env),
1792
+ stdout: "pipe",
1793
+ stderr: "pipe",
1794
+ timeout: timeoutMs
25
1795
  });
1796
+ const [stdout, stderr, exitCode] = await Promise.all([
1797
+ new Response(proc.stdout).text(),
1798
+ new Response(proc.stderr).text(),
1799
+ proc.exited
1800
+ ]);
1801
+ const output = stderr ? `${stdout}
1802
+ ${stderr}`.trim() : stdout.trim();
1803
+ return { ok: exitCode === 0, exitCode, output, durationMs: Date.now() - started };
26
1804
  }
27
- function openApiDoc(service) {
28
- return {
29
- openapi: "3.0.3",
30
- info: { title: "workflows", version: service.version },
31
- paths: {
32
- "/health": { get: { summary: "Service health", responses: { "200": { description: "ok" } } } },
33
- "/ready": { get: { summary: "Service readiness", responses: { "200": { description: "ok" } } } },
34
- "/version": { get: { summary: "Service version", responses: { "200": { description: "ok" } } } },
35
- "/trigger": {
36
- post: {
37
- summary: "Authenticated trigger: run a workflow graph to a terminal state",
38
- security: [{ bearerAuth: [] }],
39
- requestBody: {
40
- content: {
41
- "application/json": {
42
- schema: {
43
- type: "object",
44
- required: ["graph"],
45
- properties: {
46
- graph: { type: "object", description: "the workflow graph" },
47
- context: { type: "object", description: "optional run context" },
48
- idempotencyKey: { type: "string", description: "re-run with the same key returns the same run" },
49
- maxCycles: { type: "number", description: "bound on reap cycles (default 500, max 2000)" }
50
- }
51
- }
52
- }
53
- }
54
- },
55
- responses: {
56
- "200": { description: "run summary" },
57
- "401": { description: "missing or invalid Bearer token" },
58
- "503": { description: "trigger not configured (WORKFLOWS_API_KEY unset)" }
1805
+
1806
+ class WorkflowsDaemon {
1807
+ store;
1808
+ wal;
1809
+ worker;
1810
+ leaseTtlMs;
1811
+ maxAttempts;
1812
+ maxDispatchPerCycle;
1813
+ maxStepsPerCycle;
1814
+ time;
1815
+ laneRunner;
1816
+ env;
1817
+ leases = new Map;
1818
+ fencingCounter = new Map;
1819
+ graphCache = new Map;
1820
+ isolatedRunId = null;
1821
+ constructor(store, wal, options = {}) {
1822
+ this.store = store;
1823
+ this.wal = wal;
1824
+ this.worker = options.worker ?? `worker-${randomUUID2()}`;
1825
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
1826
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS2;
1827
+ this.maxDispatchPerCycle = options.maxDispatchPerCycle ?? DEFAULT_MAX_DISPATCH;
1828
+ this.maxStepsPerCycle = options.maxStepsPerCycle ?? DEFAULT_MAX_STEPS;
1829
+ this.time = options.time ?? Date.now;
1830
+ this.env = options.env ?? {};
1831
+ this.laneRunner = options.laneRunner ?? defaultLaneRunner;
1832
+ this.isolatedRunId = options.isolatedRunId ?? null;
1833
+ this.replayRegisteredGraphs();
1834
+ }
1835
+ isolateRun(runId) {
1836
+ this.isolatedRunId = runId;
1837
+ }
1838
+ replayRegisteredGraphs() {
1839
+ const entries = this.wal.replay().entries;
1840
+ for (const entry of entries) {
1841
+ const op = entry.op;
1842
+ if (op.op === "graph_registered") {
1843
+ this.graphCache.set(op.name, op.graph);
1844
+ }
1845
+ }
1846
+ }
1847
+ liveLeases() {
1848
+ const now = this.time();
1849
+ for (const [runId, lease] of this.leases) {
1850
+ if (lease.expiresAtMs <= now)
1851
+ this.leases.delete(runId);
1852
+ }
1853
+ return this.leases;
1854
+ }
1855
+ claim(runId) {
1856
+ return this.wal.exclusive(() => this.claimLocked(runId));
1857
+ }
1858
+ claimLocked(runId) {
1859
+ const now = this.time();
1860
+ const existing = this.leases.get(runId);
1861
+ if (existing && existing.worker !== this.worker && existing.expiresAtMs > now) {
1862
+ return null;
1863
+ }
1864
+ const replay = this.wal.replay();
1865
+ const liveClaims = replay.liveClaims(now);
1866
+ const foreign = liveClaims.get(runId);
1867
+ if (foreign && foreign.worker !== this.worker && foreign.expiresAtMs > now) {
1868
+ return null;
1869
+ }
1870
+ const lastFencing = replay.entries.reduce((max, entry) => entry.op.op === "claim_acquired" && entry.op.runId === runId ? Math.max(max, entry.op.fencing) : max, 0);
1871
+ const fencing = Math.max(lastFencing, this.fencingCounter.get(runId) ?? 0) + 1;
1872
+ this.fencingCounter.set(runId, fencing);
1873
+ const lease = { runId, worker: this.worker, expiresAtMs: now + this.leaseTtlMs, fencing };
1874
+ this.leases.set(runId, lease);
1875
+ this.wal.append({
1876
+ op: "claim_acquired",
1877
+ runId,
1878
+ worker: this.worker,
1879
+ expiresAt: new Date(lease.expiresAtMs).toISOString(),
1880
+ fencing,
1881
+ at: nowIso()
1882
+ });
1883
+ return lease;
1884
+ }
1885
+ heartbeat(runId, fencing) {
1886
+ return this.wal.exclusive(() => this.heartbeatLocked(runId, fencing));
1887
+ }
1888
+ heartbeatLocked(runId, fencing) {
1889
+ const lease = this.leases.get(runId);
1890
+ if (!lease || lease.fencing !== fencing)
1891
+ return false;
1892
+ const current = this.wal.replay({ repair: false }).liveClaims(this.time()).get(runId);
1893
+ if (!current || current.worker !== lease.worker || current.fencing !== fencing)
1894
+ return false;
1895
+ lease.expiresAtMs = this.time() + this.leaseTtlMs;
1896
+ this.wal.append({
1897
+ op: "claim_acquired",
1898
+ runId,
1899
+ worker: lease.worker,
1900
+ fencing,
1901
+ expiresAt: new Date(lease.expiresAtMs).toISOString(),
1902
+ at: nowIso()
1903
+ });
1904
+ return true;
1905
+ }
1906
+ release(runId, fencing) {
1907
+ return this.wal.exclusive(() => this.releaseLocked(runId, fencing));
1908
+ }
1909
+ releaseLocked(runId, fencing) {
1910
+ const lease = this.leases.get(runId);
1911
+ if (!lease || lease.fencing !== fencing)
1912
+ return false;
1913
+ const current = this.wal.replay().liveClaims(this.time()).get(runId);
1914
+ if (current && (current.worker !== lease.worker || current.fencing !== fencing))
1915
+ return false;
1916
+ this.leases.delete(runId);
1917
+ this.wal.append({ op: "claim_released", runId, fencing, at: nowIso() });
1918
+ return true;
1919
+ }
1920
+ async reap() {
1921
+ const report = { expired: 0, interrupted: 0, requeued: 0, failed: 0, dispatched: 0, advanced: 0, completed: 0 };
1922
+ const now = this.time();
1923
+ for (const [runId, lease] of this.leases) {
1924
+ if (lease.expiresAtMs <= now) {
1925
+ this.leases.delete(runId);
1926
+ report.expired++;
1927
+ }
1928
+ }
1929
+ if (this.isolatedRunId === null) {
1930
+ const repair = await repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
1931
+ report.interrupted += repair.interrupted;
1932
+ report.requeued += repair.requeued;
1933
+ report.failed += repair.failed;
1934
+ }
1935
+ const pending = (await this.store.listRuns({ status: "pending", limit: this.maxDispatchPerCycle })).concat(await this.store.listRuns({ status: "interrupted", limit: this.maxDispatchPerCycle })).sort((a, b) => a.createdAt === b.createdAt ? a.id < b.id ? 1 : -1 : a.createdAt < b.createdAt ? 1 : -1).slice(0, this.maxDispatchPerCycle);
1936
+ for (const run2 of pending) {
1937
+ if (this.isolatedRunId !== null && run2.id !== this.isolatedRunId)
1938
+ continue;
1939
+ const lease = this.claim(run2.id);
1940
+ if (!lease)
1941
+ continue;
1942
+ await this.store.setRunStatus(run2.id, "running");
1943
+ this.wal.append({ op: "run_started", runId: run2.id, at: nowIso() });
1944
+ report.dispatched++;
1945
+ }
1946
+ const running = await this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
1947
+ let steps = 0;
1948
+ for (const run2 of running) {
1949
+ if (this.isolatedRunId !== null && run2.id !== this.isolatedRunId)
1950
+ continue;
1951
+ if (steps >= this.maxStepsPerCycle)
1952
+ break;
1953
+ const outcome = await this.advanceRun(run2.id);
1954
+ steps++;
1955
+ if (outcome.kind === "completed")
1956
+ report.completed++;
1957
+ if (outcome.kind === "failed")
1958
+ report.failed++;
1959
+ if (outcome.kind === "advanced")
1960
+ report.advanced++;
1961
+ }
1962
+ return report;
1963
+ }
1964
+ startRun(graph, context) {
1965
+ const validation = validateGraph(graph);
1966
+ if (!validation.ok) {
1967
+ throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
1968
+ }
1969
+ assertNoSecrets(context, "run context");
1970
+ assertNoSecrets(graph, "graph definition");
1971
+ const existing = this.graphCache.get(graph.name);
1972
+ const serialized = JSON.stringify(graph);
1973
+ if (!existing || JSON.stringify(existing) !== serialized) {
1974
+ this.graphCache.set(graph.name, graph);
1975
+ this.wal.append({ op: "graph_registered", name: graph.name, version: graph.version, graph, at: nowIso() });
1976
+ } else {
1977
+ this.graphCache.set(graph.name, existing);
1978
+ }
1979
+ const initial = context !== null && typeof context === "object" ? context : {};
1980
+ return this.store.createRun({
1981
+ graphName: graph.name,
1982
+ graphVersion: graph.version,
1983
+ context: { ...initial, __wf: { ...initial.__wf ?? {}, graph } }
1984
+ });
1985
+ }
1986
+ async advanceRun(runId) {
1987
+ const run2 = await this.store.getRun(runId);
1988
+ if (!run2 || run2.status !== "running")
1989
+ return { kind: "noop" };
1990
+ const lease = this.leases.get(runId);
1991
+ if (!lease || lease.expiresAtMs <= this.time())
1992
+ return { kind: "blocked" };
1993
+ const storedContext = JSON.parse(run2.contextJson);
1994
+ const pinned = storedContext.__wf?.graph;
1995
+ const graph = pinned ?? this.loadGraph(run2.graphName);
1996
+ if (!graph) {
1997
+ await this.failRun(run2, `graph ${run2.graphName} not found in daemon graph cache`);
1998
+ return { kind: "failed", error: "graph not found" };
1999
+ }
2000
+ const context = storedContext;
2001
+ const rawWf = context.__wf ?? {};
2002
+ const wf = {
2003
+ ...rawWf,
2004
+ cursor: typeof rawWf.cursor === "string" ? rawWf.cursor : undefined,
2005
+ loops: Object.assign(Object.create(null), rawWf.loops ?? {}),
2006
+ completedLoops: Object.assign(Object.create(null), rawWf.completedLoops ?? {})
2007
+ };
2008
+ try {
2009
+ if (rawWf.stepTerminal === true) {
2010
+ context.__wf = wf;
2011
+ await this.completeRun(run2, context);
2012
+ return { kind: "completed" };
2013
+ }
2014
+ const cursor = wf.cursor;
2015
+ const node2 = cursor === undefined ? graph.nodes.find((n) => n.type === "start") : findNode(graph, cursor);
2016
+ if (!node2) {
2017
+ await this.failRun(run2, `cursor ${cursor ?? "<start>"} resolves to no node`);
2018
+ return { kind: "failed", error: "no node at cursor" };
2019
+ }
2020
+ switch (node2.type) {
2021
+ case "start": {
2022
+ wf.cursor = node2.next;
2023
+ await this.persistContext(run2, context, wf);
2024
+ return { kind: "advanced", nodeId: node2.id };
2025
+ }
2026
+ case "step": {
2027
+ return await this.executeStep(run2, graph, context, wf, node2);
2028
+ }
2029
+ case "decision": {
2030
+ const value = evaluateExpr(node2.condition, this.conditionScope(context));
2031
+ const next = value ? node2.then : node2.else;
2032
+ if (!next) {
2033
+ await this.failRun(run2, `decision ${node2.id} evaluated ${value} but no ${value ? "then" : "else"} edge`);
2034
+ return { kind: "failed", error: "missing decision edge" };
59
2035
  }
2036
+ wf.cursor = next;
2037
+ await this.persistContext(run2, context, wf);
2038
+ return { kind: "advanced", nodeId: node2.id };
2039
+ }
2040
+ case "while": {
2041
+ return await this.executeWhile(run2, graph, context, wf, node2);
2042
+ }
2043
+ case "end": {
2044
+ await this.completeRun(run2, context);
2045
+ return { kind: "completed" };
2046
+ }
2047
+ }
2048
+ } catch (err) {
2049
+ if (err instanceof ExprEvalError || err instanceof ExprSyntaxError) {
2050
+ await this.failRun(run2, `condition error at ${wf.cursor ?? "<start>"}: ${err.message}`);
2051
+ return { kind: "failed", error: err.message };
2052
+ }
2053
+ throw err;
2054
+ }
2055
+ }
2056
+ loadGraph(name) {
2057
+ return this.graphCache.get(name);
2058
+ }
2059
+ conditionScope(context) {
2060
+ return { ...context, steps: context.steps ?? {} };
2061
+ }
2062
+ async persistContext(run2, context, wf) {
2063
+ context.__wf = wf;
2064
+ await this.store.setRunningContext(run2.id, context);
2065
+ }
2066
+ async discardStep(run2, nodeRowId) {
2067
+ const current = await this.store.getRun(run2.id);
2068
+ const node2 = await this.store.getRunNode(nodeRowId);
2069
+ if (current?.status === "cancelled" && node2 && ["pending", "running"].includes(node2.status))
2070
+ await this.store.setRunNodeStatus(nodeRowId, "skipped", { error: "run cancelled", finishedAt: nowIso() });
2071
+ return { kind: "noop" };
2072
+ }
2073
+ async executeStep(run2, graph, context, wf, node2) {
2074
+ const nodeId = node2.id;
2075
+ const latest = await this.store.getLatestRunNode(run2.id, nodeId);
2076
+ const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
2077
+ const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? await this.store.createRunNode({ runId: run2.id, nodeId, lane: node2.lane ?? "claude" }) : latest;
2078
+ const memoWatch = node2.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
2079
+ const memoSource = {
2080
+ command: node2.command,
2081
+ prompt: node2.prompt,
2082
+ context: stripRunState(context),
2083
+ lane: node2.lane ?? "claude",
2084
+ cwd: node2.cwd,
2085
+ codexMode: node2.lane === "codex" ? node2.codexMode ?? "repository" : undefined
2086
+ };
2087
+ const memoInput = node2.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
2088
+ if (memoInput !== undefined) {
2089
+ const hit = await tryMemoHit(this.store, run2.graphName, nodeId, memoInput);
2090
+ if (hit) {
2091
+ const cached = JSON.parse(hit.outputJson);
2092
+ this.recordStepResult(context, nodeId, { ...cached, memoHit: true });
2093
+ const continuation2 = this.advanceAfterStep(graph, context, wf, node2.next);
2094
+ if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "completed", patch: { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } }, context }))
2095
+ return this.discardStep(run2, nodeRow.id);
2096
+ this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "completed", at: nowIso() });
2097
+ if (continuation2.kind === "completed")
2098
+ await this.completeRun(run2, context);
2099
+ return continuation2;
2100
+ }
2101
+ }
2102
+ if (nodeRow.status !== "running") {
2103
+ if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "running" }))
2104
+ return this.discardStep(run2, nodeRow.id);
2105
+ this.wal.append({ op: "node_started", runId: run2.id, nodeId, at: nowIso() });
2106
+ }
2107
+ const job = {
2108
+ lane: node2.lane ?? "claude",
2109
+ prompt: node2.prompt,
2110
+ command: node2.command,
2111
+ cwd: node2.cwd,
2112
+ codexMode: node2.codexMode,
2113
+ timeoutMs: node2.timeoutMs,
2114
+ env: this.env
2115
+ };
2116
+ let result;
2117
+ const activeLease = this.leases.get(run2.id);
2118
+ const pulse = setInterval(() => {
2119
+ this.heartbeat(run2.id, activeLease.fencing);
2120
+ }, Math.max(1, Math.floor(this.leaseTtlMs / 3)));
2121
+ try {
2122
+ const dispatched = await this.store.dispatchIfRunning(run2.id, async () => {
2123
+ try {
2124
+ return await this.laneRunner(job);
2125
+ } catch (err) {
2126
+ return { ok: false, exitCode: 1, output: "", durationMs: 0, error: String(err instanceof Error ? err.message : err) };
2127
+ }
2128
+ });
2129
+ if (!dispatched.started) {
2130
+ await this.store.finishDispatch(run2.id, false);
2131
+ return this.discardStep(run2, nodeRow.id);
2132
+ }
2133
+ result = await dispatched.result;
2134
+ } finally {
2135
+ clearInterval(pulse);
2136
+ }
2137
+ const unconfirmed = result.retryable === false && /exceeded.*budget/.test(result.error ?? "");
2138
+ await this.store.finishDispatch(run2.id, unconfirmed);
2139
+ if (unconfirmed)
2140
+ Object.assign(wf, { resumeBlocked: "execution cancellation has not been confirmed" });
2141
+ const current = await this.store.getRun(run2.id);
2142
+ const currentClaim = this.wal.replay({ repair: false }).liveClaims(this.time()).get(run2.id);
2143
+ const lease = this.leases.get(run2.id);
2144
+ if (!current || current.status !== "running" || !currentClaim || !lease || currentClaim.worker !== lease.worker || currentClaim.fencing !== lease.fencing) {
2145
+ if (current?.status === "cancelled")
2146
+ await this.store.setRunNodeStatus(nodeRow.id, "skipped", { error: "run cancelled", finishedAt: nowIso() });
2147
+ return { kind: "noop" };
2148
+ }
2149
+ try {
2150
+ assertNoSecrets({ output: result.output, error: result.error }, `node ${nodeId} execution`);
2151
+ } catch {
2152
+ result = { ok: false, exitCode: 1, output: "", durationMs: result.durationMs, error: "execution result refused by secrets write-gate", retryable: false };
2153
+ }
2154
+ const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
2155
+ if (!result.ok) {
2156
+ const shouldRetry = result.retryable !== false && nodeRow.attempts < (node2.maxRetries ?? 0);
2157
+ if (shouldRetry) {
2158
+ if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "pending", patch: { error: result.error ?? `exit ${result.exitCode}` }, incrementAttempts: true }))
2159
+ return this.discardStep(run2, nodeRow.id);
2160
+ return { kind: "advanced", nodeId };
2161
+ }
2162
+ const error = result.error ?? `step ${nodeId} failed with exit ${result.exitCode}`;
2163
+ if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "failed", patch: { exitCode: result.exitCode, error }, incrementAttempts: true }))
2164
+ return this.discardStep(run2, nodeRow.id);
2165
+ this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "failed", at: nowIso() });
2166
+ await this.failRun(run2, error);
2167
+ return { kind: "failed", error };
2168
+ }
2169
+ try {
2170
+ assertNoSecrets(output, `node ${nodeId} output`);
2171
+ } catch (err) {
2172
+ const message = err instanceof Error ? err.message : String(err);
2173
+ if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "failed", patch: { error: message } }))
2174
+ return this.discardStep(run2, nodeRow.id);
2175
+ this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "failed", at: nowIso() });
2176
+ await this.failRun(run2, message);
2177
+ return { kind: "failed", error: message };
2178
+ }
2179
+ let memo2;
2180
+ if (memoInput !== undefined) {
2181
+ const memoInputAfter = node2.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
2182
+ const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
2183
+ if (stable) {
2184
+ memo2 = { key: memoKey(run2.graphName, nodeId, memoInput), graphName: run2.graphName, nodeId, inputHash: inputHash(memoInput), outputJson: JSON.stringify(output) };
2185
+ }
2186
+ }
2187
+ this.recordStepResult(context, nodeId, output);
2188
+ const continuation = this.advanceAfterStep(graph, context, wf, node2.next);
2189
+ if (!await this.store.commitStep({ runId: run2.id, nodeRowId: nodeRow.id, status: "completed", patch: { exitCode: result.exitCode, output }, context, memo: memo2 }))
2190
+ return this.discardStep(run2, nodeRow.id);
2191
+ this.wal.append({ op: "node_finished", runId: run2.id, nodeId, status: "completed", at: nowIso() });
2192
+ if (memo2)
2193
+ this.wal.append({ op: "memo_set", key: memo2.key, at: nowIso() });
2194
+ if (continuation.kind === "completed")
2195
+ await this.completeRun(run2, context);
2196
+ return continuation;
2197
+ }
2198
+ advanceAfterStep(graph, context, wf, next) {
2199
+ context.__wf = wf;
2200
+ if (next) {
2201
+ wf.cursor = next;
2202
+ return { kind: "advanced", nodeId: wf.cursor };
2203
+ }
2204
+ const cursor = wf.cursor;
2205
+ const whileNode = graph.nodes.find((n) => n.type === "while" && n.body.includes(cursor ?? ""));
2206
+ if (whileNode) {
2207
+ const index = whileNode.body.indexOf(cursor ?? "");
2208
+ if (index >= 0 && index < whileNode.body.length - 1) {
2209
+ wf.cursor = whileNode.body[index + 1];
2210
+ return { kind: "advanced", nodeId: wf.cursor };
2211
+ }
2212
+ wf.loops[whileNode.id] = (wf.loops[whileNode.id] ?? 0) + 1;
2213
+ wf.cursor = whileNode.id;
2214
+ return { kind: "advanced", nodeId: whileNode.id };
2215
+ }
2216
+ wf.stepTerminal = true;
2217
+ return { kind: "completed" };
2218
+ }
2219
+ async executeWhile(run2, graph, context, wf, node2) {
2220
+ const iteration = wf.loops[node2.id] ?? 0;
2221
+ if (iteration >= node2.maxIterations) {
2222
+ const error = `while node ${node2.id} exceeded maxIterations (${node2.maxIterations})`;
2223
+ await this.failRun(run2, error);
2224
+ return { kind: "failed", error };
2225
+ }
2226
+ const scope = { ...this.conditionScope(context), i: iteration };
2227
+ const keepGoing = evaluateExpr(node2.condition, scope);
2228
+ if (!keepGoing) {
2229
+ wf.completedLoops[node2.id] = iteration;
2230
+ delete wf.loops[node2.id];
2231
+ wf.cursor = node2.next;
2232
+ await this.persistContext(run2, context, wf);
2233
+ return { kind: "advanced", nodeId: node2.id };
2234
+ }
2235
+ wf.cursor = node2.body[0];
2236
+ await this.persistContext(run2, context, wf);
2237
+ return { kind: "advanced", nodeId: node2.id };
2238
+ }
2239
+ recordStepResult(context, nodeId, output) {
2240
+ const steps = Object.assign(Object.create(null), context.steps ?? {});
2241
+ steps[nodeId] = output;
2242
+ context.steps = steps;
2243
+ }
2244
+ async completeRun(run2, context) {
2245
+ const result = {
2246
+ steps: context.steps ?? {},
2247
+ iterations: context.__wf?.completedLoops ?? {}
2248
+ };
2249
+ try {
2250
+ assertNoSecrets(result, "run result");
2251
+ } catch (err) {
2252
+ const message = err instanceof Error ? err.message : String(err);
2253
+ await this.failRun(run2, message);
2254
+ return;
2255
+ }
2256
+ if (!await this.store.compareRunStatus(run2.id, ["running"], "completed", { result, finishedAt: nowIso(), error: null }))
2257
+ return;
2258
+ this.wal.append({ op: "run_finished", runId: run2.id, status: "completed", at: nowIso() });
2259
+ const lease = this.leases.get(run2.id);
2260
+ if (lease)
2261
+ this.release(run2.id, lease.fencing);
2262
+ }
2263
+ async failRun(run2, error) {
2264
+ if (!await this.store.compareRunStatus(run2.id, ["running"], "failed", { error, finishedAt: nowIso() }))
2265
+ return;
2266
+ this.wal.append({ op: "run_finished", runId: run2.id, status: "failed", at: nowIso() });
2267
+ const lease = this.leases.get(run2.id);
2268
+ if (lease)
2269
+ this.release(run2.id, lease.fencing);
2270
+ }
2271
+ }
2272
+ var defaultLaneRunner = async (job) => {
2273
+ if (job.command)
2274
+ return runCommand(job);
2275
+ const { runLaneJob: runLaneJob2 } = await Promise.resolve().then(() => (init_lanes(), exports_lanes));
2276
+ return runLaneJob2(job);
2277
+ };
2278
+ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
2279
+ const daemon = new WorkflowsDaemon(store, wal, {
2280
+ worker: `foreground-${randomUUID2()}`,
2281
+ laneRunner: options.laneRunner,
2282
+ time: options.time,
2283
+ env: options.env,
2284
+ isolatedRunId: options.isolatedRunId
2285
+ });
2286
+ const run2 = options.existingRunId ? await store.getRun(options.existingRunId) : await daemon.startRun(graph, context);
2287
+ if (!run2)
2288
+ throw new Error("run not found");
2289
+ options.onRunCreated?.(run2);
2290
+ daemon.isolateRun(run2.id);
2291
+ const maxCycles = options.maxCycles ?? 500;
2292
+ const hosted = store instanceof PostgresWorkflowsStore ? store : undefined;
2293
+ if (hosted && !await hosted.acquireExecution(run2.id))
2294
+ return await store.getRun(run2.id);
2295
+ const lease = daemon.claim(run2.id);
2296
+ if (!lease) {
2297
+ await hosted?.releaseExecution();
2298
+ return await store.getRun(run2.id);
2299
+ }
2300
+ try {
2301
+ if (!await store.compareRunStatus(run2.id, ["pending"], "running", { startedAt: nowIso() }))
2302
+ return await store.getRun(run2.id);
2303
+ wal.append({ op: "run_started", runId: run2.id, at: nowIso() });
2304
+ for (let cycle = 0;cycle < maxCycles; cycle++) {
2305
+ daemon.heartbeat(run2.id, lease.fencing);
2306
+ const outcome = await daemon.advanceRun(run2.id);
2307
+ const current = await store.getRun(run2.id);
2308
+ if (current.status !== "running" || outcome.kind === "blocked" || outcome.kind === "noop")
2309
+ return current;
2310
+ }
2311
+ await store.compareRunStatus(run2.id, ["running"], "interrupted", { error: "run reached maxCycles; resume to continue" });
2312
+ return await store.getRun(run2.id);
2313
+ } finally {
2314
+ daemon.release(run2.id, lease.fencing);
2315
+ await hosted?.releaseExecution();
2316
+ }
2317
+ }
2318
+
2319
+ // src/paths.ts
2320
+ import { existsSync } from "fs";
2321
+ import { homedir } from "os";
2322
+ import { join as join2 } from "path";
2323
+ import { homedir as pathsResolverHomedir } from "os";
2324
+ import { join as pathsResolverJoin } from "path";
2325
+ var PATHS_RESOLVER_KIND_ENV = {
2326
+ config: "HASNA_CONFIG_HOME",
2327
+ data: "HASNA_DATA_HOME",
2328
+ state: "HASNA_STATE_HOME",
2329
+ cache: "HASNA_CACHE_HOME"
2330
+ };
2331
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2332
+ function pathsResolverAssertApp(app) {
2333
+ if (typeof app !== "string" || app.length === 0) {
2334
+ throw new TypeError("paths: app must be a non-empty string");
2335
+ }
2336
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
2337
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
2338
+ }
2339
+ }
2340
+ function pathsResolverAssertKind(kind) {
2341
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
2342
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
2343
+ }
2344
+ }
2345
+ function pathsResolverBaseDir(kind, options) {
2346
+ pathsResolverAssertKind(kind);
2347
+ const env = options.env ?? process.env;
2348
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
2349
+ if (typeof override === "string" && override.length > 0)
2350
+ return override;
2351
+ const home = options.home ?? pathsResolverHomedir();
2352
+ const platform = options.platform ?? process.platform;
2353
+ if (platform === "darwin") {
2354
+ switch (kind) {
2355
+ case "config":
2356
+ case "data":
2357
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
2358
+ case "cache":
2359
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
2360
+ case "state":
2361
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
2362
+ }
2363
+ }
2364
+ switch (kind) {
2365
+ case "config":
2366
+ return pathsResolverJoin(home, ".config", "hasna");
2367
+ case "data":
2368
+ return pathsResolverJoin(home, ".local", "share", "hasna");
2369
+ case "state":
2370
+ return pathsResolverJoin(home, ".local", "state", "hasna");
2371
+ case "cache":
2372
+ return pathsResolverJoin(home, ".cache", "hasna");
2373
+ }
2374
+ }
2375
+ function pathsResolverResolve(kind, options) {
2376
+ pathsResolverAssertApp(options.app);
2377
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
2378
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
2379
+ }
2380
+ function dataDir(options) {
2381
+ return pathsResolverResolve("data", options);
2382
+ }
2383
+ var APP = "workflows";
2384
+ var STORE_DB_FILE = "workflows.db";
2385
+ function getHomeDir(env = process.env) {
2386
+ return env["HOME"] || env["USERPROFILE"] || homedir();
2387
+ }
2388
+ function getLegacyDataDir(env = process.env) {
2389
+ return join2(getHomeDir(env), ".hasna", APP);
2390
+ }
2391
+ function getResolverDataDir(env = process.env, platform = process.platform) {
2392
+ return dataDir({ app: APP, home: getHomeDir(env), env, platform });
2393
+ }
2394
+ function adoptResolverDataDir(resolved, env = process.env) {
2395
+ const dataOverride = env.HASNA_DATA_HOME;
2396
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
2397
+ return true;
2398
+ return existsSync(join2(resolved, STORE_DB_FILE));
2399
+ }
2400
+ function getEffectiveDataDir(env = process.env) {
2401
+ const resolved = getResolverDataDir(env);
2402
+ return adoptResolverDataDir(resolved, env) ? resolved : getLegacyDataDir(env);
2403
+ }
2404
+
2405
+ // src/store.ts
2406
+ import { Database } from "bun:sqlite";
2407
+ import { mkdirSync } from "fs";
2408
+ import { join as join3 } from "path";
2409
+ import { randomUUID as randomUUID3 } from "crypto";
2410
+ var RUN_STATUSES = ["pending", "running", "completed", "failed", "cancelled", "interrupted"];
2411
+ function nowIso2() {
2412
+ return new Date().toISOString();
2413
+ }
2414
+ var BUSY_TIMEOUT_MS = 3000;
2415
+ var BUSY_RETRIES = 2;
2416
+ var BUSY_RETRY_SLEEP_MS = 50;
2417
+ function isBusyError(err) {
2418
+ if (!(err instanceof Error))
2419
+ return false;
2420
+ const code = String(err.code ?? "");
2421
+ return code.includes("BUSY") || /\bdatabase is locked\b/i.test(err.message);
2422
+ }
2423
+ function retryBusy(fn) {
2424
+ let last;
2425
+ for (let attempt = 0;attempt <= BUSY_RETRIES; attempt++) {
2426
+ try {
2427
+ return fn();
2428
+ } catch (err) {
2429
+ if (!isBusyError(err))
2430
+ throw err;
2431
+ last = err;
2432
+ Bun.sleepSync(BUSY_RETRY_SLEEP_MS);
2433
+ }
2434
+ }
2435
+ throw last;
2436
+ }
2437
+ function openStore(dataDir2) {
2438
+ mkdirSync(dataDir2, { recursive: true });
2439
+ const db = new Database(join3(dataDir2, "workflows.db"));
2440
+ retryBusy(() => db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`));
2441
+ retryBusy(() => db.exec("PRAGMA journal_mode = WAL"));
2442
+ retryBusy(() => db.exec("PRAGMA foreign_keys = ON"));
2443
+ retryBusy(() => db.exec(`
2444
+ CREATE TABLE IF NOT EXISTS runs (
2445
+ id TEXT PRIMARY KEY,
2446
+ graph_name TEXT NOT NULL,
2447
+ graph_version TEXT NOT NULL,
2448
+ status TEXT NOT NULL,
2449
+ attempts INTEGER NOT NULL DEFAULT 0,
2450
+ context_json TEXT NOT NULL,
2451
+ result_json TEXT,
2452
+ error TEXT,
2453
+ started_at TEXT,
2454
+ finished_at TEXT,
2455
+ created_at TEXT NOT NULL,
2456
+ updated_at TEXT NOT NULL
2457
+ );
2458
+ CREATE TABLE IF NOT EXISTS run_nodes (
2459
+ id TEXT PRIMARY KEY,
2460
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
2461
+ node_id TEXT NOT NULL,
2462
+ status TEXT NOT NULL,
2463
+ attempts INTEGER NOT NULL DEFAULT 0,
2464
+ lane TEXT,
2465
+ exit_code INTEGER,
2466
+ output_json TEXT,
2467
+ error TEXT,
2468
+ started_at TEXT,
2469
+ finished_at TEXT
2470
+ );
2471
+ CREATE TABLE IF NOT EXISTS memos (
2472
+ key TEXT PRIMARY KEY,
2473
+ graph_name TEXT NOT NULL,
2474
+ node_id TEXT NOT NULL,
2475
+ input_hash TEXT NOT NULL,
2476
+ output_json TEXT NOT NULL,
2477
+ hit_count INTEGER NOT NULL DEFAULT 0,
2478
+ created_at TEXT NOT NULL
2479
+ );
2480
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_runs_idempotency ON runs(json_extract(context_json, '$.__wf.idempotencyKey'))
2481
+ WHERE json_type(context_json, '$.__wf.idempotencyKey') = 'text';
2482
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
2483
+ CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
2484
+ CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
2485
+ `));
2486
+ const rowToRun = (r) => ({
2487
+ id: String(r.id),
2488
+ graphName: String(r.graph_name),
2489
+ graphVersion: String(r.graph_version),
2490
+ status: r.status,
2491
+ attempts: Number(r.attempts),
2492
+ contextJson: String(r.context_json),
2493
+ resultJson: r.result_json === null ? null : String(r.result_json),
2494
+ error: r.error === null ? null : String(r.error),
2495
+ startedAt: r.started_at === null ? null : String(r.started_at),
2496
+ finishedAt: r.finished_at === null ? null : String(r.finished_at),
2497
+ createdAt: String(r.created_at),
2498
+ updatedAt: String(r.updated_at)
2499
+ });
2500
+ const rowToNode = (r) => ({
2501
+ id: String(r.id),
2502
+ runId: String(r.run_id),
2503
+ nodeId: String(r.node_id),
2504
+ status: r.status,
2505
+ attempts: Number(r.attempts),
2506
+ lane: r.lane === null ? null : String(r.lane),
2507
+ exitCode: r.exit_code === null ? null : Number(r.exit_code),
2508
+ outputJson: r.output_json === null ? null : String(r.output_json),
2509
+ error: r.error === null ? null : String(r.error),
2510
+ startedAt: r.started_at === null ? null : String(r.started_at),
2511
+ finishedAt: r.finished_at === null ? null : String(r.finished_at)
2512
+ });
2513
+ const rowToMemo = (r) => ({
2514
+ key: String(r.key),
2515
+ graphName: String(r.graph_name),
2516
+ nodeId: String(r.node_id),
2517
+ inputHash: String(r.input_hash),
2518
+ outputJson: String(r.output_json),
2519
+ hitCount: Number(r.hit_count),
2520
+ createdAt: String(r.created_at)
2521
+ });
2522
+ return {
2523
+ dataDir: dataDir2,
2524
+ close() {
2525
+ db.close();
2526
+ },
2527
+ listTables() {
2528
+ const rows = retryBusy(() => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all());
2529
+ return rows.map((r) => r.name);
2530
+ },
2531
+ createRun(input) {
2532
+ const id = randomUUID3();
2533
+ const now = nowIso2();
2534
+ retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
2535
+ VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
2536
+ return this.getRun(id);
2537
+ },
2538
+ getRun(id) {
2539
+ const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
2540
+ return row ? rowToRun(row) : undefined;
2541
+ },
2542
+ getRunByIdempotencyKey(key) {
2543
+ const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE json_valid(context_json) AND json_extract(context_json, '$.__wf.idempotencyKey') = ? ORDER BY created_at DESC LIMIT 1`).get(key));
2544
+ return row ? rowToRun(row) : undefined;
2545
+ },
2546
+ listRuns(opts = {}) {
2547
+ const limit = Math.min(opts.limit ?? 100, 1000);
2548
+ if (opts.status) {
2549
+ const status = opts.status;
2550
+ const rows2 = retryBusy(() => db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(status, limit));
2551
+ return rows2.map((r) => rowToRun(r));
2552
+ }
2553
+ const rows = retryBusy(() => db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit));
2554
+ return rows.map((r) => rowToRun(r));
2555
+ },
2556
+ setRunStatus(id, status, patch = {}) {
2557
+ const now = nowIso2();
2558
+ const existing = this.getRun(id);
2559
+ const resultJson = patch.result !== undefined ? JSON.stringify(patch.result) : existing?.resultJson ?? null;
2560
+ const error = patch.error !== undefined ? patch.error : existing?.error ?? null;
2561
+ const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
2562
+ const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
2563
+ const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
2564
+ retryBusy(() => db.query(`UPDATE runs SET status = ?, result_json = ?, error = ?, started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`).run(status, resultJson, error, startedAt, finishedAt, now, id));
2565
+ },
2566
+ compareRunStatus(id, expected, status, patch = {}) {
2567
+ return retryBusy(() => db.transaction(() => {
2568
+ const current = this.getRun(id);
2569
+ if (!current || !expected.includes(current.status))
2570
+ return false;
2571
+ this.setRunStatus(id, status, patch);
2572
+ return true;
2573
+ }).immediate());
2574
+ },
2575
+ bumpAttempts(id) {
2576
+ retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
2577
+ },
2578
+ setRunContext(id, context) {
2579
+ retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
2580
+ },
2581
+ setRunningContext(id, context) {
2582
+ return retryBusy(() => db.transaction(() => {
2583
+ if (this.getRun(id)?.status !== "running")
2584
+ return false;
2585
+ this.setRunContext(id, context);
2586
+ return true;
2587
+ }).immediate());
2588
+ },
2589
+ blockRunResume(id) {
2590
+ retryBusy(() => db.transaction(() => {
2591
+ const run2 = this.getRun(id);
2592
+ if (!run2)
2593
+ return;
2594
+ const context = JSON.parse(run2.contextJson);
2595
+ context.__wf = { ...context.__wf, resumeBlocked: "execution cancellation has not been confirmed" };
2596
+ this.setRunContext(id, context);
2597
+ }).immediate());
2598
+ },
2599
+ dispatchIfRunning(id, action) {
2600
+ return retryBusy(() => db.transaction(() => {
2601
+ const run2 = this.getRun(id);
2602
+ if (run2?.status !== "running")
2603
+ return { started: false };
2604
+ const context = JSON.parse(run2.contextJson);
2605
+ context.__wf = { ...context.__wf, executionMayBeActive: true };
2606
+ this.setRunContext(id, context);
2607
+ return { started: true, result: action() };
2608
+ }).immediate());
2609
+ },
2610
+ finishDispatch(id, unconfirmed) {
2611
+ retryBusy(() => db.transaction(() => {
2612
+ const run2 = this.getRun(id);
2613
+ if (!run2)
2614
+ return;
2615
+ const context = JSON.parse(run2.contextJson);
2616
+ delete context.__wf?.executionMayBeActive;
2617
+ if (unconfirmed)
2618
+ context.__wf = { ...context.__wf, resumeBlocked: "execution cancellation has not been confirmed" };
2619
+ this.setRunContext(id, context);
2620
+ }).immediate());
2621
+ },
2622
+ commitStep(input) {
2623
+ return retryBusy(() => db.transaction(() => {
2624
+ if (this.getRun(input.runId)?.status !== "running")
2625
+ return false;
2626
+ const node2 = this.getRunNode(input.nodeRowId);
2627
+ if (!node2 || node2.runId !== input.runId)
2628
+ return false;
2629
+ this.setRunNodeStatus(input.nodeRowId, input.status, input.patch);
2630
+ if (input.incrementAttempts)
2631
+ this.bumpAttemptsNode(input.nodeRowId);
2632
+ if (input.context !== undefined)
2633
+ this.setRunContext(input.runId, input.context);
2634
+ if (input.memo)
2635
+ this.memoPut(input.memo.key, input.memo.graphName, input.memo.nodeId, input.memo.inputHash, input.memo.outputJson);
2636
+ return true;
2637
+ }).immediate());
2638
+ },
2639
+ createRunNode(input) {
2640
+ const id = randomUUID3();
2641
+ retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
2642
+ VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
2643
+ return this.getRunNode(id);
2644
+ },
2645
+ getRunNode(id) {
2646
+ const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id));
2647
+ return row ? rowToNode(row) : undefined;
2648
+ },
2649
+ listRunNodes(runId) {
2650
+ const rows = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId));
2651
+ return rows.map((r) => rowToNode(r));
2652
+ },
2653
+ getLatestRunNode(runId, nodeId) {
2654
+ const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? AND node_id = ? ORDER BY rowid DESC LIMIT 1`).get(runId, nodeId));
2655
+ return row ? rowToNode(row) : undefined;
2656
+ },
2657
+ setRunNodeStatus(id, status, patch = {}) {
2658
+ const now = nowIso2();
2659
+ const existing = this.getRunNode(id);
2660
+ if (!existing)
2661
+ return;
2662
+ const exitCode = patch.exitCode !== undefined ? patch.exitCode : existing.exitCode;
2663
+ const outputJson = patch.output !== undefined ? JSON.stringify(patch.output) : existing.outputJson;
2664
+ const error = patch.error !== undefined ? patch.error : existing.error;
2665
+ const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
2666
+ const terminal = status === "completed" || status === "failed" || status === "skipped";
2667
+ const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
2668
+ retryBusy(() => db.query(`UPDATE run_nodes SET status = ?, exit_code = ?, output_json = ?, error = ?, started_at = ?, finished_at = ? WHERE id = ?`).run(status, exitCode, outputJson, error, startedAt, finishedAt, id));
2669
+ },
2670
+ bumpAttemptsNode(id) {
2671
+ retryBusy(() => db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id));
2672
+ },
2673
+ memoPut(key, graphName, nodeId, inputHash2, outputJson) {
2674
+ retryBusy(() => db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
2675
+ VALUES (?, ?, ?, ?, ?, 0, ?)
2676
+ ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2()));
2677
+ },
2678
+ memoGet(key) {
2679
+ const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
2680
+ return row ? rowToMemo(row) : undefined;
2681
+ },
2682
+ memoHit(key) {
2683
+ retryBusy(() => db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key));
2684
+ },
2685
+ memoList() {
2686
+ const rows = retryBusy(() => db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all());
2687
+ return rows.map((r) => rowToMemo(r));
2688
+ },
2689
+ memoClear() {
2690
+ retryBusy(() => db.exec(`DELETE FROM memos`));
2691
+ }
2692
+ };
2693
+ }
2694
+
2695
+ // src/wal.ts
2696
+ import { Database as Database2 } from "bun:sqlite";
2697
+ import { createHash as createHash2 } from "crypto";
2698
+ import { appendFileSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2699
+ import { basename, dirname, join as join4 } from "path";
2700
+ var WAL_FILE_NAME = "session.wal";
2701
+ var SESSIONS_DIR_NAME = "sessions";
2702
+ var MAX_WAL_BYTES = 256 * 1024 * 1024;
2703
+ function checksumOf(op) {
2704
+ if (op === undefined || op === null)
2705
+ return "";
2706
+ return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
2707
+ }
2708
+
2709
+ class SessionWAL {
2710
+ filePath;
2711
+ journalLocked = false;
2712
+ constructor(filePath) {
2713
+ this.filePath = filePath;
2714
+ }
2715
+ static open(dataDir2) {
2716
+ mkdirSync2(join4(dataDir2, SESSIONS_DIR_NAME), { recursive: true, mode: 448 });
2717
+ const canonical = join4(dataDir2, SESSIONS_DIR_NAME, WAL_FILE_NAME);
2718
+ const legacy = join4(dataDir2, WAL_FILE_NAME);
2719
+ if (existsSync2(legacy) && !existsSync2(canonical)) {
2720
+ try {
2721
+ preserveOriginal(legacy);
2722
+ renameSync(legacy, canonical);
2723
+ } catch {
2724
+ return new SessionWAL(legacy);
2725
+ }
2726
+ }
2727
+ return new SessionWAL(canonical);
2728
+ }
2729
+ static openAt(filePath) {
2730
+ return new SessionWAL(filePath);
2731
+ }
2732
+ append(op) {
2733
+ return this.withJournalLock(() => this.appendLocked(op));
2734
+ }
2735
+ withJournalLock(operation) {
2736
+ if (this.journalLocked)
2737
+ return operation();
2738
+ const parent = dirname(this.filePath);
2739
+ const dataDir2 = basename(parent) === SESSIONS_DIR_NAME ? dirname(parent) : parent;
2740
+ mkdirSync2(dataDir2, { recursive: true, mode: 448 });
2741
+ const mutex = new Database2(join4(dataDir2, "workflows.db"));
2742
+ try {
2743
+ mutex.exec("PRAGMA busy_timeout = 5000");
2744
+ mutex.exec("BEGIN IMMEDIATE");
2745
+ this.journalLocked = true;
2746
+ try {
2747
+ const result = operation();
2748
+ mutex.exec("COMMIT");
2749
+ return result;
2750
+ } catch (error) {
2751
+ mutex.exec("ROLLBACK");
2752
+ throw error;
2753
+ } finally {
2754
+ this.journalLocked = false;
2755
+ }
2756
+ } finally {
2757
+ mutex.close();
2758
+ }
2759
+ }
2760
+ exclusive(operation) {
2761
+ return this.withJournalLock(operation);
2762
+ }
2763
+ appendLocked(op) {
2764
+ const at = op.at ?? new Date().toISOString();
2765
+ const full = { ...op, at };
2766
+ const nextSeq = this.nextSeq();
2767
+ const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
2768
+ appendFileSync(this.filePath, JSON.stringify(entry) + `
2769
+ `, { encoding: "utf8", mode: 384 });
2770
+ return entry;
2771
+ }
2772
+ nextSeq() {
2773
+ const replay = this.replay();
2774
+ return replay.entries.length + 1;
2775
+ }
2776
+ replay(options = {}) {
2777
+ return this.withJournalLock(() => this.replayLocked(options));
2778
+ }
2779
+ replayLocked(options) {
2780
+ const entries = [];
2781
+ let torn = false;
2782
+ let repaired = false;
2783
+ let content;
2784
+ try {
2785
+ content = readFileSync2(this.filePath, "utf8");
2786
+ } catch {
2787
+ return { entries, torn, repaired, liveClaims: () => new Map };
2788
+ }
2789
+ if (content.length > MAX_WAL_BYTES) {
2790
+ throw new Error(`session WAL exceeds ${MAX_WAL_BYTES} bytes; refusing to replay a truncated journal`);
2791
+ }
2792
+ if (content === "") {
2793
+ return { entries, torn, repaired, liveClaims: () => new Map };
2794
+ }
2795
+ const lines = content.split(`
2796
+ `);
2797
+ let badOffset = null;
2798
+ let expectedSeq = 1;
2799
+ for (let i = 0;i < lines.length; i++) {
2800
+ const line = lines[i];
2801
+ if (line === "")
2802
+ continue;
2803
+ if (badOffset !== null)
2804
+ break;
2805
+ const lineOffset = cumulativeOffset(lines, i);
2806
+ let entry;
2807
+ try {
2808
+ entry = JSON.parse(line);
2809
+ } catch {
2810
+ badOffset = lineOffset;
2811
+ break;
2812
+ }
2813
+ if (typeof entry?.seq !== "number" || entry.seq !== expectedSeq || entry.checksum !== checksumOf(entry.op)) {
2814
+ badOffset = lineOffset;
2815
+ break;
2816
+ }
2817
+ entries.push(entry);
2818
+ expectedSeq++;
2819
+ }
2820
+ if (badOffset !== null) {
2821
+ torn = true;
2822
+ try {
2823
+ if (options.repair === false)
2824
+ return { entries, torn, repaired, liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs) };
2825
+ preserveOriginal(this.filePath);
2826
+ truncateSync(this.filePath, badOffset);
2827
+ repaired = true;
2828
+ } catch {}
2829
+ }
2830
+ return {
2831
+ entries,
2832
+ torn,
2833
+ repaired,
2834
+ liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs)
2835
+ };
2836
+ }
2837
+ close() {}
2838
+ }
2839
+ function cumulativeOffset(lines, index) {
2840
+ let offset = 0;
2841
+ for (let i = 0;i < index; i++) {
2842
+ offset += Buffer.byteLength(lines[i]) + 1;
2843
+ }
2844
+ return offset;
2845
+ }
2846
+ function reconstructLiveClaims(entries, nowMs = Date.now()) {
2847
+ const claims = new Map;
2848
+ for (const entry of entries) {
2849
+ const op = entry.op;
2850
+ if (op.op === "claim_acquired") {
2851
+ claims.set(op.runId, { worker: op.worker, fencing: op.fencing, expiresAtMs: Date.parse(op.expiresAt) });
2852
+ } else if (op.op === "claim_released") {
2853
+ const existing = claims.get(op.runId);
2854
+ if (existing && existing.fencing === op.fencing) {
2855
+ claims.delete(op.runId);
2856
+ }
2857
+ }
2858
+ }
2859
+ for (const [runId, claim] of claims) {
2860
+ if (claim.expiresAtMs <= nowMs)
2861
+ claims.delete(runId);
2862
+ }
2863
+ return claims;
2864
+ }
2865
+ function preserveOriginal(filePath) {
2866
+ const original = readFileSync2(filePath);
2867
+ const digest = createHash2("sha256").update(original).digest("hex");
2868
+ const backup = `${filePath}.preserved-${digest}`;
2869
+ if (!existsSync2(backup))
2870
+ copyFileSync(filePath, backup, constants.COPYFILE_EXCL);
2871
+ if (!readFileSync2(backup).equals(original))
2872
+ throw new Error("session WAL preservation readback failed; source retained");
2873
+ return backup;
2874
+ }
2875
+
2876
+ // src/service.ts
2877
+ import { hostname } from "os";
2878
+ import { resolveDatabaseUrl } from "@hasna/contracts";
2879
+ var DEFAULT_PORT = 8790;
2880
+ var DEFAULT_HOST = "127.0.0.1";
2881
+ function envString(names) {
2882
+ for (const name of names) {
2883
+ const value = process.env[name];
2884
+ if (value !== undefined && value !== "")
2885
+ return value;
2886
+ }
2887
+ return;
2888
+ }
2889
+ function envInt(names, fallback) {
2890
+ const value = envString(names);
2891
+ if (value === undefined)
2892
+ return fallback;
2893
+ const parsed = Number(value);
2894
+ return Number.isFinite(parsed) ? parsed : fallback;
2895
+ }
2896
+ function resolveWorkflowsConfig(overrides = {}) {
2897
+ const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
2898
+ if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
2899
+ throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
2900
+ return {
2901
+ port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
2902
+ host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
2903
+ dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? getEffectiveDataDir(),
2904
+ apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
2905
+ apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
2906
+ originAuth,
2907
+ storageBackend: overrides.storageBackend ?? "postgresql",
2908
+ databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
2909
+ databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
2910
+ };
2911
+ }
2912
+
2913
+ class WorkflowsService {
2914
+ execution;
2915
+ name = "workflows";
2916
+ version;
2917
+ config;
2918
+ startedAt = Date.now();
2919
+ databaseEnv;
2920
+ constructor(config = {}, execution = {}) {
2921
+ this.execution = execution;
2922
+ this.config = resolveWorkflowsConfig(config);
2923
+ this.databaseEnv = config.databaseUrl !== undefined ? { HASNA_WORKFLOWS_DATABASE_URL: config.databaseUrl } : {
2924
+ HASNA_WORKFLOWS_DATABASE_URL: process.env.HASNA_WORKFLOWS_DATABASE_URL,
2925
+ WORKFLOWS_DATABASE_URL: process.env.WORKFLOWS_DATABASE_URL
2926
+ };
2927
+ this.version = packageVersion();
2928
+ }
2929
+ health() {
2930
+ return {
2931
+ ok: true,
2932
+ status: "ok",
2933
+ backend: this.config.storageBackend,
2934
+ service: this.name,
2935
+ version: this.version,
2936
+ pid: process.pid,
2937
+ uptimeMs: Date.now() - this.startedAt
2938
+ };
2939
+ }
2940
+ async ready() {
2941
+ const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
2942
+ if (this.config.apiKey) {
2943
+ try {
2944
+ checks.storage = await this.withStore(async (store) => (store instanceof PostgresWorkflowsStore ? await store.checkSchema() : (await store.listTables()).sort().join(",") === "memos,run_nodes,runs") ? "ok" : "invalid-schema");
2945
+ } catch (error) {
2946
+ checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
2947
+ }
2948
+ }
2949
+ return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
2950
+ }
2951
+ async openEngineStore(verifySchema = true) {
2952
+ if (this.config.storageBackend === "sqlite")
2953
+ return openStore(this.config.dataDir);
2954
+ if (this.config.storageBackend !== "postgresql")
2955
+ throw new WorkflowRequestError("invalid_storage_backend", "storage backend must be postgresql or explicit embedded sqlite", 503);
2956
+ let databaseUrl;
2957
+ try {
2958
+ databaseUrl = resolveDatabaseUrl("workflows", this.databaseEnv);
2959
+ } catch (error) {
2960
+ throw new WorkflowRequestError("invalid_database_configuration", error instanceof Error ? error.message : "HASNA_WORKFLOWS_DATABASE_URL must configure PostgreSQL", 503);
2961
+ }
2962
+ let store;
2963
+ try {
2964
+ store = new PostgresWorkflowsStore({ databaseUrl, dataDir: this.config.dataDir, schema: this.config.databaseSchema });
2965
+ } catch {
2966
+ throw new WorkflowRequestError("invalid_database_schema", "HASNA_WORKFLOWS_DATABASE_SCHEMA must be a lowercase SQL identifier", 503);
2967
+ }
2968
+ try {
2969
+ if (verifySchema && !await store.checkSchema())
2970
+ throw new WorkflowRequestError("invalid_schema", "PostgreSQL workflow schema requires explicit initialization or a reviewed migration", 503);
2971
+ return store;
2972
+ } catch (error) {
2973
+ await store.close();
2974
+ throw error;
2975
+ }
2976
+ }
2977
+ async withStore(operation) {
2978
+ const store = await this.openEngineStore();
2979
+ try {
2980
+ return await operation(store);
2981
+ } finally {
2982
+ await store.close();
2983
+ }
2984
+ }
2985
+ async initializeStorage() {
2986
+ const store = await this.openEngineStore(false);
2987
+ try {
2988
+ if (!(store instanceof PostgresWorkflowsStore))
2989
+ throw new WorkflowRequestError("invalid_storage_backend", "init-db requires PostgreSQL", 503);
2990
+ await store.initialize();
2991
+ } finally {
2992
+ await store.close();
2993
+ }
2994
+ }
2995
+ listRuns(options = {}) {
2996
+ return this.withStore((store) => store.listRuns(options));
2997
+ }
2998
+ getRun(id) {
2999
+ return this.withStore((store) => store.getRun(id));
3000
+ }
3001
+ listNodes(id) {
3002
+ return this.withStore((store) => store.listRunNodes(id));
3003
+ }
3004
+ events(id) {
3005
+ return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
3006
+ }
3007
+ cancelRun(id) {
3008
+ return this.withStore(async (store) => {
3009
+ const run2 = await store.getRun(id);
3010
+ if (!run2)
3011
+ return;
3012
+ if (["completed", "failed", "cancelled"].includes(run2.status))
3013
+ throw new WorkflowRequestError("invalid_run_status", "cancellation requires a pending, running, or interrupted run", 409);
3014
+ if (!await store.compareRunStatus(id, ["pending", "running", "interrupted"], "cancelled", { finishedAt: new Date().toISOString() }))
3015
+ throw new WorkflowRequestError("run_status_conflict", "run changed before cancellation", 409);
3016
+ return store.getRun(id);
3017
+ });
3018
+ }
3019
+ async resumeRun(id) {
3020
+ const store = await this.openEngineStore();
3021
+ const wal = SessionWAL.open(this.config.dataDir);
3022
+ try {
3023
+ const run2 = await store.getRun(id);
3024
+ if (!run2)
3025
+ throw new WorkflowRequestError("run_not_found", "run id does not exist", 404);
3026
+ if (!["failed", "cancelled", "interrupted"].includes(run2.status))
3027
+ throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
3028
+ const context = JSON.parse(run2.contextJson);
3029
+ if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
3030
+ throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
3031
+ if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
3032
+ throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
3033
+ const graph = context.__wf?.graph;
3034
+ if (!graph)
3035
+ throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
3036
+ if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
3037
+ throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
3038
+ for (const node2 of await store.listRunNodes(id)) {
3039
+ if (node2.status !== "completed")
3040
+ await store.setRunNodeStatus(node2.id, "pending", { error: null });
3041
+ }
3042
+ const final = await runGraphToCompletion(store, wal, graph, context, { ...this.execution, existingRunId: id });
3043
+ return runSummary(final, false);
3044
+ } finally {
3045
+ await store.close();
3046
+ }
3047
+ }
3048
+ sessions(action) {
3049
+ const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3050
+ if (action === "list")
3051
+ return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
3052
+ return {
3053
+ entries: replay.entries.length,
3054
+ torn: replay.torn,
3055
+ repaired: false,
3056
+ liveClaims: [...replay.liveClaims().entries()].map(([runId, claim]) => ({ runId, worker: claim.worker, fencing: claim.fencing, expiresAt: new Date(claim.expiresAtMs).toISOString() }))
3057
+ };
3058
+ }
3059
+ machines(action) {
3060
+ const local = { name: hostname(), role: "server", version: this.version };
3061
+ if (action === "list")
3062
+ return [local];
3063
+ return { ...local, health: this.health(), sessions: this.sessions("pull") };
3064
+ }
3065
+ daemonStatus() {
3066
+ const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3067
+ return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
3068
+ }
3069
+ listMemos() {
3070
+ return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
3071
+ }
3072
+ async triggerRun(graph, context = {}, opts = {}) {
3073
+ const store = await this.openEngineStore();
3074
+ const wal = SessionWAL.open(this.config.dataDir);
3075
+ try {
3076
+ if (context === null || typeof context !== "object" || Array.isArray(context))
3077
+ throw new WorkflowRequestError("invalid_context", "context must be an object");
3078
+ if (Object.hasOwn(context, "__wf") || Object.hasOwn(context, "steps"))
3079
+ throw new WorkflowRequestError("reserved_context", "context.__wf and context.steps are reserved for the workflow engine");
3080
+ const maxCycles = opts.maxCycles ?? 500;
3081
+ if (!Number.isSafeInteger(maxCycles) || maxCycles < 1 || maxCycles > 2000)
3082
+ throw new WorkflowRequestError("invalid_max_cycles", "maxCycles must be an integer from 1 to 2000");
3083
+ if (opts.idempotencyKey !== undefined && !/^[A-Za-z0-9_.:-]{1,128}$/.test(opts.idempotencyKey))
3084
+ throw new WorkflowRequestError("invalid_idempotency_key", "idempotencyKey must contain 1 to 128 ASCII letters, digits, or _.:-");
3085
+ const requestHash = createHash3("sha256").update(canonicalJson({ graph, context, maxCycles })).digest("hex");
3086
+ const reuse = async () => {
3087
+ const existing2 = opts.idempotencyKey ? await store.getRunByIdempotencyKey(opts.idempotencyKey) : undefined;
3088
+ if (!existing2)
3089
+ return;
3090
+ if (JSON.parse(existing2.contextJson).__wf?.requestHash !== requestHash)
3091
+ throw new WorkflowRequestError("idempotency_conflict", "idempotencyKey is already bound to a different request", 409);
3092
+ return runSummary(existing2, true);
3093
+ };
3094
+ const existing = await reuse();
3095
+ if (existing)
3096
+ return existing;
3097
+ const contextWithKey = { ...context, __wf: { requestHash, ...opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {} } };
3098
+ let final;
3099
+ try {
3100
+ final = await runGraphToCompletion(store, wal, graph, contextWithKey, { ...this.execution, maxCycles });
3101
+ } catch (err) {
3102
+ if (err instanceof Error && (/UNIQUE constraint failed/.test(err.message) || err.code === "23505")) {
3103
+ const winner = await reuse();
3104
+ if (winner)
3105
+ return winner;
60
3106
  }
3107
+ throw err;
61
3108
  }
3109
+ return {
3110
+ runId: final.id,
3111
+ status: final.status,
3112
+ error: final.error ?? null,
3113
+ result: final.resultJson ? JSON.parse(final.resultJson) : null,
3114
+ reused: false
3115
+ };
3116
+ } finally {
3117
+ await store.close();
3118
+ }
3119
+ }
3120
+ }
3121
+ function createWorkflowsService(config = {}, execution = {}) {
3122
+ return new WorkflowsService(config, execution);
3123
+ }
3124
+
3125
+ class WorkflowRequestError extends Error {
3126
+ code;
3127
+ status;
3128
+ constructor(code, message, status = 400) {
3129
+ super(message);
3130
+ this.code = code;
3131
+ this.status = status;
3132
+ this.name = "WorkflowRequestError";
3133
+ }
3134
+ }
3135
+ function canonicalJson(value) {
3136
+ if (Array.isArray(value))
3137
+ return `[${value.map(canonicalJson).join(",")}]`;
3138
+ if (value && typeof value === "object")
3139
+ return `{${Object.keys(value).sort().filter((k) => value[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
3140
+ return JSON.stringify(value) ?? "null";
3141
+ }
3142
+ function runSummary(run2, reused) {
3143
+ return { runId: run2.id, status: run2.status, error: run2.error ?? null, result: run2.resultJson ? JSON.parse(run2.resultJson) : null, reused };
3144
+ }
3145
+
3146
+ // src/handlers.ts
3147
+ import { createHash as createHash4, timingSafeEqual } from "crypto";
3148
+
3149
+ // src/openapi.ts
3150
+ function buildOpenApi(version) {
3151
+ const string = { type: "string" }, integer = { type: "integer" }, nullableString = { type: "string", nullable: true };
3152
+ const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
3153
+ const object = (properties, required = Object.keys(properties)) => ({ type: "object", properties, required });
3154
+ const response = (schema, description = "Success") => ({ description, content: { "application/json": { schema } } });
3155
+ const id = [{ name: "id", in: "path", required: true, schema: { type: "string", pattern: "^[a-zA-Z0-9_-]{1,128}$" } }];
3156
+ const body = (schema) => ({ required: true, description: "JSON object, at most 1 MiB", content: { "application/json": { schema } } });
3157
+ const statuses = ["pending", "running", "completed", "failed", "interrupted", "cancelled"];
3158
+ const operation = (operationId, summary, schema, extra = {}, publicRoute = false) => ({
3159
+ operationId,
3160
+ summary,
3161
+ ...publicRoute ? {} : { security: [{ bearerAuth: [] }] },
3162
+ ...extra,
3163
+ responses: { "200": response(schema), ...publicRoute ? {} : {
3164
+ "400": response(ref("ApiError"), "Invalid field or request shape"),
3165
+ "401": response(ref("ApiError"), "Bearer credential missing or refused"),
3166
+ "404": response(ref("ApiError"), "Run or route absent"),
3167
+ "409": response(ref("ApiError"), "Conflicting key, run state or execution ownership"),
3168
+ "503": response(ref("ApiError"), "Authentication or PostgreSQL storage unavailable")
3169
+ } }
3170
+ });
3171
+ const doc = {
3172
+ openapi: "3.0.3",
3173
+ info: { title: "workflows", version },
3174
+ servers: [{ url: "./" }],
3175
+ paths: {
3176
+ "/health": { get: operation("getHealth", "Service health", ref("HealthReport"), {}, true) },
3177
+ "/ready": { get: { ...operation("getReadiness", "Authentication and actual schema readiness", ref("ReadinessReport"), {}, true), responses: { "200": response(ref("ReadinessReport")), "503": response(ref("ReadinessReport"), "Not ready") } } },
3178
+ "/version": { get: operation("getVersion", "Package version", object({ service: string, version: string }), {}, true) },
3179
+ "/validate": { post: operation("validateWorkflow", "Validate a workflow graph", ref("GraphValidation"), { requestBody: body(object({ graph: ref("WorkflowGraph") })) }) },
3180
+ "/trigger": { post: operation("triggerWorkflow", "Execute a bounded workflow; identical keyed retries reuse the original run", ref("RunSummary"), { requestBody: body(object({ graph: ref("WorkflowGraph"), context: { type: "object", description: "__wf and steps are reserved", additionalProperties: true }, idempotencyKey: { type: "string", pattern: "^[A-Za-z0-9_.:-]{1,128}$" }, maxCycles: { type: "integer", minimum: 1, maximum: 2000, default: 500 } }, ["graph"])) }) },
3181
+ "/runs": { get: operation("listRunRecords", "List recent runs", { type: "array", items: ref("RunRow") }, { parameters: [{ name: "status", in: "query", schema: { type: "string", enum: statuses } }, { name: "limit", in: "query", schema: { type: "integer", minimum: 1, maximum: 1000, default: 100 } }] }) },
3182
+ "/runs/{id}": { get: operation("getRunRecord", "Read a durable run", ref("RunRow"), { parameters: id }) },
3183
+ "/runs/{id}/nodes": { get: operation("getRunNodes", "Ordered node execution history", { type: "array", items: ref("RunNodeRow") }, { parameters: id }) },
3184
+ "/runs/{id}/events": { get: operation("getRunEvents", "Preserved execution journal events", { type: "array", items: { type: "object" } }, { parameters: id }) },
3185
+ "/runs/{id}/cancel": { post: operation("cancelWorkflow", "Cancel a pending, running or interrupted run and fence late results", ref("RunRow"), { parameters: id }) },
3186
+ "/runs/{id}/resume": { post: operation("resumeWorkflow", "Continue a failed, cancelled or interrupted run using its pinned graph; active or unconfirmed execution refuses resume", ref("RunSummary"), { parameters: id }) },
3187
+ "/lanes": { get: operation("listLanes", "Inspect lane substrates; this does not prove sign-in or model capacity", { type: "array", items: { type: "object" } }) },
3188
+ "/lanes/{lane}": { get: operation("probeLane", "Inspect one lane substrate", { type: "object" }, { parameters: [{ name: "lane", in: "path", required: true, schema: { type: "string", enum: ["claude", "codex", "cursor", "grok"] } }] }) },
3189
+ "/sessions/list": { get: operation("listSessionEvents", "Recent journal event metadata", { type: "array", items: { type: "object" } }) },
3190
+ "/sessions/pull": { get: operation("inspectSession", "Read journal and claim state without repair", { type: "object" }) },
3191
+ "/machines/list": { get: operation("listServerMachines", "This executor, not a fleet census", { type: "array", items: { type: "object" } }) },
3192
+ "/machines/status": { get: operation("inspectServerMachine", "This executor's health and claims", { type: "object" }) },
3193
+ "/daemon/status": { get: operation("inspectDaemon", "Bounded-trigger executor status", { type: "object" }) },
3194
+ "/memos/list": { get: operation("listMemoRecords", "Recent memo results", { type: "array", items: { type: "object" } }) }
62
3195
  },
63
3196
  components: {
64
- securitySchemes: {
65
- bearerAuth: { type: "http", scheme: "bearer" }
3197
+ securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } },
3198
+ schemas: {
3199
+ ApiError: object({ error: string, message: string }, ["error"]),
3200
+ HealthReport: object({ ok: { type: "boolean" }, status: { type: "string", enum: ["ok"] }, backend: { type: "string", enum: ["postgresql", "sqlite"] }, service: string, version: string, pid: integer, uptimeMs: integer }),
3201
+ ReadinessReport: object({ ok: { type: "boolean" }, service: string, version: string, checks: { type: "object", additionalProperties: string } }),
3202
+ GraphValidation: object({ ok: { type: "boolean" }, issues: { type: "array", items: object({ path: string, message: string }) } }),
3203
+ StartNode: object({ id: string, type: { type: "string", enum: ["start"] }, next: string }, ["id", "type"]),
3204
+ StepNode: object({ id: string, type: { type: "string", enum: ["step"] }, next: string, lane: { type: "string", enum: ["claude", "codex", "cursor", "grok"] }, prompt: string, command: string, cwd: { type: "string", minLength: 1 }, codexMode: { type: "string", enum: ["repository", "read-only"], default: "repository", description: "Codex prompt steps only. Read-only permits nonrepository execution with a read-only sandbox and no approvals. Repository retains native trust, sandbox and approval defaults." }, maxRetries: { type: "integer", minimum: 0 }, memo: { type: "boolean" }, timeoutMs: { type: "integer", minimum: 1 } }, ["id", "type"]),
3205
+ DecisionNode: object({ id: string, type: { type: "string", enum: ["decision"] }, condition: string, then: string, else: string }, ["id", "type", "condition"]),
3206
+ WhileNode: object({ id: string, type: { type: "string", enum: ["while"] }, condition: string, body: { type: "array", items: string }, maxIterations: { type: "integer", minimum: 1 }, next: string }, ["id", "type", "condition", "body", "maxIterations"]),
3207
+ EndNode: object({ id: string, type: { type: "string", enum: ["end"] } }),
3208
+ GraphNode: { oneOf: ["StartNode", "StepNode", "DecisionNode", "WhileNode", "EndNode"].map(ref) },
3209
+ WorkflowGraph: object({ name: string, version: string, nodes: { type: "array", items: ref("GraphNode") }, memoWatch: { type: "array", items: string } }, ["name", "version", "nodes"]),
3210
+ RunSummary: object({ runId: string, status: { type: "string", enum: statuses }, error: nullableString, result: {}, reused: { type: "boolean" } }),
3211
+ RunRow: object({ id: string, graphName: string, graphVersion: string, status: { type: "string", enum: statuses }, attempts: integer, contextJson: string, resultJson: nullableString, error: nullableString, startedAt: nullableString, finishedAt: nullableString, createdAt: string, updatedAt: string }),
3212
+ RunNodeRow: object({ id: string, runId: string, nodeId: string, status: { type: "string", enum: ["pending", "running", "completed", "failed", "skipped"] }, attempts: integer, lane: nullableString, exitCode: { type: "integer", nullable: true }, outputJson: nullableString, error: nullableString, startedAt: nullableString, finishedAt: nullableString })
66
3213
  }
67
3214
  }
68
3215
  };
3216
+ return doc;
3217
+ }
3218
+
3219
+ // src/handlers.ts
3220
+ init_graph();
3221
+ init_lanes();
3222
+ function jsonResponse(body, status = 200) {
3223
+ return new Response(JSON.stringify(body), {
3224
+ status,
3225
+ headers: { "content-type": "application/json" }
3226
+ });
3227
+ }
3228
+ function openApiDoc(service) {
3229
+ return buildOpenApi(service.version);
3230
+ }
3231
+ function authenticate(service, req) {
3232
+ if (!service.config.apiKey)
3233
+ return jsonResponse({ error: "auth_not_configured" }, 503);
3234
+ if (!bearerMatches(req.headers.get("authorization") ?? "", service.config.apiKey)) {
3235
+ return jsonResponse({ error: "unauthorized", message: "a valid Bearer token is required" }, 401);
3236
+ }
3237
+ }
3238
+ async function readObject(req) {
3239
+ const limit = 1048576;
3240
+ const reader = req.body?.getReader();
3241
+ if (!reader)
3242
+ throw new Error("invalid_json");
3243
+ const chunks = [];
3244
+ let size = 0;
3245
+ try {
3246
+ for (;; ) {
3247
+ const { value, done } = await reader.read();
3248
+ if (done)
3249
+ break;
3250
+ size += value.byteLength;
3251
+ if (size > limit) {
3252
+ await reader.cancel();
3253
+ throw new Error("body_too_large");
3254
+ }
3255
+ chunks.push(value);
3256
+ }
3257
+ } finally {
3258
+ reader.releaseLock();
3259
+ }
3260
+ const body = JSON.parse(await new Blob(chunks).text());
3261
+ if (body === null || typeof body !== "object" || Array.isArray(body))
3262
+ throw new Error("invalid_body");
3263
+ return body;
69
3264
  }
70
3265
  function bearerMatches(authHeader, expected) {
71
3266
  if (!authHeader.startsWith("Bearer "))
@@ -73,8 +3268,8 @@ function bearerMatches(authHeader, expected) {
73
3268
  const supplied = authHeader.slice("Bearer ".length);
74
3269
  if (supplied.length === 0)
75
3270
  return false;
76
- const a = createHash("sha256").update(supplied).digest();
77
- const b = createHash("sha256").update(expected).digest();
3271
+ const a = createHash4("sha256").update(supplied).digest();
3272
+ const b = createHash4("sha256").update(expected).digest();
78
3273
  return timingSafeEqual(a, b);
79
3274
  }
80
3275
  async function handleTrigger(service, req) {
@@ -86,50 +3281,139 @@ async function handleTrigger(service, req) {
86
3281
  if (!bearerMatches(auth, expected)) {
87
3282
  return jsonResponse({ error: "unauthorized", message: "a valid Bearer token is required" }, 401);
88
3283
  }
89
- let body;
3284
+ let record;
90
3285
  try {
91
- body = await req.json();
3286
+ record = await readObject(req);
92
3287
  } catch {
93
- return jsonResponse({ error: "invalid_json", message: "request body must be JSON" }, 400);
3288
+ return jsonResponse({ error: "invalid_json", message: "request body must be a JSON object within 1 MiB" }, 400);
94
3289
  }
95
- const record = body;
96
3290
  if (record.graph === null || typeof record.graph !== "object" || Array.isArray(record.graph)) {
97
3291
  return jsonResponse({ error: "graph_required", message: "body.graph must be a workflow graph object" }, 400);
98
3292
  }
99
3293
  const context = record.context ?? {};
100
- if (typeof context !== "object" || Array.isArray(context)) {
3294
+ if (context === null || typeof context !== "object" || Array.isArray(context)) {
101
3295
  return jsonResponse({ error: "invalid_context", message: "body.context must be an object" }, 400);
102
3296
  }
3297
+ if (record.maxCycles !== undefined && (typeof record.maxCycles !== "number" || !Number.isSafeInteger(record.maxCycles) || record.maxCycles < 1 || record.maxCycles > 2000)) {
3298
+ return jsonResponse({ error: "invalid_max_cycles", message: "maxCycles must be an integer from 1 to 2000" }, 400);
3299
+ }
3300
+ if (record.idempotencyKey !== undefined && (typeof record.idempotencyKey !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(record.idempotencyKey))) {
3301
+ return jsonResponse({ error: "invalid_idempotency_key", message: "idempotencyKey must contain 1 to 128 ASCII letters, digits, or _.:-" }, 400);
3302
+ }
3303
+ const validation = validateGraph(record.graph);
3304
+ if (!validation.ok)
3305
+ return jsonResponse({ error: "invalid_graph", issues: validation.issues }, 400);
103
3306
  const maxCycles = typeof record.maxCycles === "number" ? record.maxCycles : undefined;
104
3307
  const idempotencyKey = typeof record.idempotencyKey === "string" ? record.idempotencyKey : undefined;
105
3308
  try {
106
3309
  const summary = await service.triggerRun(record.graph, context, { maxCycles, idempotencyKey });
107
3310
  return jsonResponse(summary);
108
3311
  } catch (err) {
109
- const message = err instanceof Error ? err.message : String(err);
110
- return jsonResponse({ error: "trigger_failed", message }, 400);
3312
+ if (err instanceof WorkflowStorageError)
3313
+ return jsonResponse({ error: "storage_unavailable", message: err.message }, 503);
3314
+ if (err instanceof WorkflowRequestError)
3315
+ return jsonResponse({ error: err.code, message: err.message }, err.status);
3316
+ return jsonResponse({ error: "trigger_failed", message: "workflow execution was refused" }, 400);
111
3317
  }
112
3318
  }
113
3319
  function createRequestHandler(service) {
114
- return (req) => {
3320
+ return async (req) => {
115
3321
  const url = new URL(req.url);
116
- switch (url.pathname) {
3322
+ if (service.config.originAuth !== undefined && !["/health", "/ready"].includes(url.pathname)) {
3323
+ if (!bearerMatches(`Bearer ${req.headers.get("x-hasna-origin-auth") ?? ""}`, service.config.originAuth)) {
3324
+ return jsonResponse({ error: "WORKFLOWS_ORIGIN_DENIED" }, 403);
3325
+ }
3326
+ }
3327
+ const path = url.pathname.replace(/^\/v1(?=\/|$)/, "") || "/";
3328
+ const method = req.method;
3329
+ const allowed = path === "/trigger" || path === "/validate" || /^\/runs\/[^/]+\/(cancel|resume)$/.test(path) ? "POST" : "GET";
3330
+ if (method !== allowed)
3331
+ return new Response(JSON.stringify({ error: "method_not_allowed" }), { status: 405, headers: { "content-type": "application/json", allow: allowed } });
3332
+ switch (path) {
117
3333
  case "/health":
118
3334
  return jsonResponse(service.health());
119
- case "/ready":
120
- return jsonResponse(service.ready());
3335
+ case "/ready": {
3336
+ const ready = await service.ready();
3337
+ return jsonResponse(ready, ready.ok ? 200 : 503);
3338
+ }
121
3339
  case "/version":
122
3340
  return jsonResponse({ service: service.name, version: service.version });
123
3341
  case "/trigger":
124
3342
  return handleTrigger(service, req);
125
3343
  case "/openapi.json":
126
3344
  return jsonResponse(openApiDoc(service));
127
- default:
128
- return jsonResponse({ error: "not_found", path: url.pathname }, 404);
3345
+ }
3346
+ if (path !== "/validate" && path !== "/runs" && path !== "/lanes" && !/^\/lanes\/[^/]+$/.test(path) && !/^\/runs\/[^/]+(?:\/(nodes|events|cancel|resume))?$/.test(path) && !/^\/(sessions|machines|daemon|memos)\/(list|pull|status)$/.test(path)) {
3347
+ return jsonResponse({ error: "not_found" }, 404);
3348
+ }
3349
+ const refused = authenticate(service, req);
3350
+ if (refused)
3351
+ return refused;
3352
+ try {
3353
+ if (path === "/validate") {
3354
+ const body = await readObject(req);
3355
+ return jsonResponse(validateGraph(body.graph));
3356
+ }
3357
+ if (path === "/runs") {
3358
+ const limit = Number(url.searchParams.get("limit") ?? 100);
3359
+ const status = url.searchParams.get("status") ?? undefined;
3360
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000 || status !== undefined && !RUN_STATUSES.includes(status))
3361
+ return jsonResponse({ error: "invalid_query" }, 400);
3362
+ return jsonResponse(await service.listRuns({ limit, status }));
3363
+ }
3364
+ if (path === "/lanes")
3365
+ return jsonResponse(await laneInventory());
3366
+ if (path.startsWith("/lanes/")) {
3367
+ const lane = path.slice("/lanes/".length);
3368
+ if (!LANE_KINDS2.includes(lane))
3369
+ return jsonResponse({ error: "invalid_lane" }, 400);
3370
+ return jsonResponse(await probeLane(lane));
3371
+ }
3372
+ if (path === "/sessions/list")
3373
+ return jsonResponse(service.sessions("list"));
3374
+ if (path === "/sessions/pull")
3375
+ return jsonResponse(service.sessions("pull"));
3376
+ if (path === "/machines/list")
3377
+ return jsonResponse(service.machines("list"));
3378
+ if (path === "/machines/status")
3379
+ return jsonResponse(service.machines("status"));
3380
+ if (path === "/daemon/status")
3381
+ return jsonResponse(service.daemonStatus());
3382
+ if (path === "/memos/list")
3383
+ return jsonResponse(await service.listMemos());
3384
+ const match = /^\/runs\/([^/]+)(?:\/(nodes|events|cancel|resume))?$/.exec(path);
3385
+ if (match) {
3386
+ const id = decodeURIComponent(match[1]);
3387
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(id))
3388
+ return jsonResponse({ error: "invalid_run_id" }, 400);
3389
+ const run2 = await service.getRun(id);
3390
+ if (!run2)
3391
+ return jsonResponse({ error: "run_not_found" }, 404);
3392
+ switch (match[2]) {
3393
+ case "nodes":
3394
+ return jsonResponse(await service.listNodes(id));
3395
+ case "events":
3396
+ return jsonResponse(service.events(id));
3397
+ case "cancel":
3398
+ return jsonResponse(await service.cancelRun(id));
3399
+ case "resume":
3400
+ return jsonResponse(await service.resumeRun(id));
3401
+ default:
3402
+ return jsonResponse(run2);
3403
+ }
3404
+ }
3405
+ return jsonResponse({ error: "not_found" }, 404);
3406
+ } catch (err) {
3407
+ if (err instanceof WorkflowStorageError)
3408
+ return jsonResponse({ error: "storage_unavailable", message: err.message }, 503);
3409
+ if (err instanceof WorkflowRequestError)
3410
+ return jsonResponse({ error: err.code, message: err.message }, err.status);
3411
+ return jsonResponse({ error: "invalid_request", message: "request was refused" }, 400);
129
3412
  }
130
3413
  };
131
3414
  }
132
3415
  export {
3416
+ openApiDoc,
133
3417
  jsonResponse,
134
3418
  createRequestHandler
135
3419
  };