@hasna/workflows 0.1.0 → 0.1.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/cli/index.js CHANGED
@@ -1,16 +1,459 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
+ var __defProp = Object.defineProperty;
4
+ var __returnValue = (v) => v;
5
+ function __exportSetter(name, newValue) {
6
+ this[name] = __returnValue.bind(null, newValue);
7
+ }
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, {
11
+ get: all[name],
12
+ enumerable: true,
13
+ configurable: true,
14
+ set: __exportSetter.bind(all, name)
15
+ });
16
+ };
17
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
18
+ var __require = import.meta.require;
19
+
20
+ // src/lanes/types.ts
21
+ import { statSync } from "fs";
22
+ function binaryOnPath(binary) {
23
+ const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
24
+ for (const entry of pathEntries) {
25
+ try {
26
+ const stat = statSync(`${entry}/${binary}`);
27
+ if (stat.isFile())
28
+ return true;
29
+ } catch {}
30
+ }
31
+ return false;
32
+ }
33
+ function withTimeout(promise, timeoutMs, what) {
34
+ return new Promise((resolve, reject) => {
35
+ const timer = setTimeout(() => {
36
+ reject(new Error(`${what} exceeded the ${timeoutMs}ms budget`));
37
+ }, timeoutMs);
38
+ promise.then((value) => {
39
+ clearTimeout(timer);
40
+ resolve(value);
41
+ }, (err) => {
42
+ clearTimeout(timer);
43
+ reject(err);
44
+ });
45
+ });
46
+ }
47
+ function spawnCli(cmd, opts) {
48
+ const started = Date.now();
49
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
50
+ let proc;
51
+ try {
52
+ proc = Bun.spawnSync({
53
+ cmd,
54
+ cwd: opts.cwd,
55
+ env: { ...process.env, ...opts.env ?? {} },
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ timeout: timeoutMs
59
+ });
60
+ } catch (err) {
61
+ const message = String(err instanceof Error ? err.message : err);
62
+ if (message.includes("ENOENT") || /not found/i.test(message)) {
63
+ throw new LaneDependencyMissingError(`lane binary ${JSON.stringify(opts.binaryName)} not found on PATH; install it or provide ${opts.packageHint}`);
64
+ }
65
+ return { ok: false, exitCode: 1, output: "", durationMs: Date.now() - started, error: message };
66
+ }
67
+ const durationMs = Date.now() - started;
68
+ const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
69
+ const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
70
+ const output = stderr ? `${stdout}
71
+ ${stderr}`.trim() : stdout.trim();
72
+ const exitCode = proc.exitCode ?? 1;
73
+ return { ok: exitCode === 0, exitCode, output, durationMs };
74
+ }
75
+ var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000;
76
+ var init_types = __esm(() => {
77
+ LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
78
+ LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
79
+ constructor(message) {
80
+ super(message);
81
+ this.name = "LaneDependencyMissingError";
82
+ }
83
+ };
84
+ LaneAdapterShapeError = class LaneAdapterShapeError extends Error {
85
+ constructor(message) {
86
+ super(message);
87
+ this.name = "LaneAdapterShapeError";
88
+ }
89
+ };
90
+ });
91
+
92
+ // src/lanes/claude.ts
93
+ function createClaudeAdapter(deps = {}) {
94
+ return {
95
+ kind: "claude",
96
+ async probe() {
97
+ const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
98
+ try {
99
+ await loader();
100
+ return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "sdk" };
101
+ } catch {
102
+ if (binaryOnPath(deps.cliPath ?? "claude")) {
103
+ return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "cli" };
104
+ }
105
+ return {
106
+ kind: "claude",
107
+ sdk: "@anthropic-ai/claude-agent-sdk",
108
+ wired: false,
109
+ reason: "neither @anthropic-ai/claude-agent-sdk nor the claude CLI is available on this machine"
110
+ };
111
+ }
112
+ },
113
+ async run(job) {
114
+ if (!job.prompt) {
115
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "claude lane requires a prompt" };
116
+ }
117
+ const started = Date.now();
118
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
119
+ const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
120
+ let sdk;
121
+ try {
122
+ sdk = await loader();
123
+ } catch (err) {
124
+ return runClaudeCli(job, deps.cliPath ?? "claude", started, timeoutMs);
125
+ }
126
+ const query = sdk.query;
127
+ if (typeof query !== "function") {
128
+ throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
129
+ }
130
+ const prompt = job.prompt;
131
+ try {
132
+ const { output, isError } = await withTimeout((async () => {
133
+ const generator = await query({
134
+ prompt,
135
+ options: { model: job.model, maxTurns: 32, abortSignal: undefined }
136
+ });
137
+ let output2 = "";
138
+ let isError2 = false;
139
+ for await (const message of generator) {
140
+ if (message.type === "assistant") {
141
+ const content = message.message?.content;
142
+ if (Array.isArray(content)) {
143
+ for (const block of content) {
144
+ if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
145
+ output2 += block.text;
146
+ }
147
+ }
148
+ }
149
+ }
150
+ if (message.type === "error")
151
+ isError2 = true;
152
+ }
153
+ return { output: output2, isError: isError2 };
154
+ })(), timeoutMs, `claude lane (${job.prompt.slice(0, 60)})`);
155
+ const durationMs = Date.now() - started;
156
+ return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
157
+ } catch (err) {
158
+ const durationMs = Date.now() - started;
159
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
160
+ }
161
+ }
162
+ };
163
+ }
164
+ function runClaudeCli(job, cliPath, started, timeoutMs) {
165
+ try {
166
+ const result = spawnCli([cliPath, "-p", job.prompt], {
167
+ binaryName: cliPath,
168
+ packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
169
+ timeoutMs,
170
+ env: job.env,
171
+ cwd: job.cwd
172
+ });
173
+ return { ...result, durationMs: Date.now() - started };
174
+ } catch (err) {
175
+ if (err instanceof LaneDependencyMissingError) {
176
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
177
+ }
178
+ throw err;
179
+ }
180
+ }
181
+ var init_claude = __esm(() => {
182
+ init_types();
183
+ });
184
+
185
+ // src/lanes/codex.ts
186
+ function createCodexAdapter(deps = {}) {
187
+ return {
188
+ kind: "codex",
189
+ async probe() {
190
+ const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
191
+ try {
192
+ await loader();
193
+ return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "sdk" };
194
+ } catch {
195
+ if (binaryOnPath(deps.cliPath ?? "codex")) {
196
+ return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "cli" };
197
+ }
198
+ return {
199
+ kind: "codex",
200
+ sdk: "@openai/codex-sdk",
201
+ wired: false,
202
+ reason: "neither @openai/codex-sdk nor the codex CLI is available on this machine"
203
+ };
204
+ }
205
+ },
206
+ async run(job) {
207
+ if (!job.prompt) {
208
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
209
+ }
210
+ const started = Date.now();
211
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
212
+ const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
213
+ let sdk;
214
+ try {
215
+ sdk = await loader();
216
+ } catch {
217
+ return runCodexCli(job, deps.cliPath ?? "codex", started, timeoutMs);
218
+ }
219
+ const CodexClass = sdk.Codex;
220
+ if (typeof CodexClass !== "function") {
221
+ throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
222
+ }
223
+ try {
224
+ const client = await withTimeout(Promise.resolve(new CodexClass({
225
+ config: { model: job.model },
226
+ env: job.env
227
+ })), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
228
+ const thread = client.startThread?.();
229
+ if (!thread || typeof thread.run !== "function") {
230
+ throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
231
+ }
232
+ const turn = await withTimeout(thread.run(job.prompt), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
233
+ const output = turn?.finalResponse ?? "";
234
+ const durationMs = Date.now() - started;
235
+ return { ok: true, exitCode: 0, output, durationMs };
236
+ } catch (err) {
237
+ const durationMs = Date.now() - started;
238
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
239
+ }
240
+ }
241
+ };
242
+ }
243
+ function runCodexCli(job, cliPath, started, timeoutMs) {
244
+ try {
245
+ const result = spawnCli([cliPath, "exec", "--", job.prompt], {
246
+ binaryName: cliPath,
247
+ packageHint: "@openai/codex-sdk or the codex CLI",
248
+ timeoutMs,
249
+ env: job.env,
250
+ cwd: job.cwd
251
+ });
252
+ return { ...result, durationMs: Date.now() - started };
253
+ } catch (err) {
254
+ if (err instanceof LaneDependencyMissingError) {
255
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
256
+ }
257
+ throw err;
258
+ }
259
+ }
260
+ var init_codex = __esm(() => {
261
+ init_types();
262
+ });
263
+
264
+ // src/lanes/cursor.ts
265
+ function createCursorAdapter(deps = {}) {
266
+ return {
267
+ kind: "cursor",
268
+ async probe() {
269
+ const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
270
+ try {
271
+ await loader();
272
+ return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "sdk (local mode)" };
273
+ } catch {
274
+ if (binaryOnPath(deps.cliPath ?? "cursor-agent")) {
275
+ return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "cli" };
276
+ }
277
+ return {
278
+ kind: "cursor",
279
+ sdk: "@cursor/sdk",
280
+ wired: false,
281
+ reason: "neither @cursor/sdk nor the cursor-agent CLI is available on this machine"
282
+ };
283
+ }
284
+ },
285
+ async run(job) {
286
+ if (!job.prompt) {
287
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
288
+ }
289
+ const started = Date.now();
290
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
291
+ const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
292
+ let sdk;
293
+ try {
294
+ sdk = await loader();
295
+ } catch {
296
+ return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
297
+ }
298
+ try {
299
+ const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
300
+ const durationMs = Date.now() - started;
301
+ const status = run?.status ?? "finished";
302
+ const ok = status === "finished";
303
+ return { ok, exitCode: ok ? 0 : 1, output: run?.result ?? "", durationMs };
304
+ } catch (err) {
305
+ const durationMs = Date.now() - started;
306
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
307
+ }
308
+ }
309
+ };
310
+ }
311
+ async function runLocalAgent(sdk, job) {
312
+ const AgentClass = sdk.Agent;
313
+ const CursorClass = sdk.Cursor;
314
+ if (typeof AgentClass !== "function" && typeof CursorClass !== "function") {
315
+ throw new LaneAdapterShapeError("@cursor/sdk exports no Agent/Cursor class");
316
+ }
317
+ const agent = typeof AgentClass === "function" ? new AgentClass : undefined;
318
+ if (!agent || typeof agent.send !== "function") {
319
+ throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
320
+ }
321
+ const run = await agent.send(job.prompt, { model: job.model });
322
+ if (!run || typeof run !== "object") {
323
+ throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
324
+ }
325
+ return run;
326
+ }
327
+ function runCursorCli(job, cliPath, started, timeoutMs) {
328
+ try {
329
+ const result = spawnCli([cliPath, "run", job.prompt], {
330
+ binaryName: cliPath,
331
+ packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
332
+ timeoutMs,
333
+ env: job.env,
334
+ cwd: job.cwd
335
+ });
336
+ return { ...result, durationMs: Date.now() - started };
337
+ } catch (err) {
338
+ if (err instanceof LaneDependencyMissingError) {
339
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
340
+ }
341
+ throw err;
342
+ }
343
+ }
344
+ var init_cursor = __esm(() => {
345
+ init_types();
346
+ });
347
+
348
+ // src/lanes/grok.ts
349
+ function createGrokAdapter(deps = {}) {
350
+ return {
351
+ kind: "grok",
352
+ async probe() {
353
+ const cliPath = deps.cliPath ?? "grok";
354
+ if (binaryOnPath(cliPath)) {
355
+ return { kind: "grok", sdk: "no npm SDK (measured 2026-08-25)", wired: true, via: "cli" };
356
+ }
357
+ return {
358
+ kind: "grok",
359
+ sdk: "no npm SDK (measured 2026-08-25)",
360
+ wired: false,
361
+ reason: `no xAI Grok SDK exists on npm (measured 2026-08-25) and the grok CLI is not on PATH`
362
+ };
363
+ },
364
+ async run(job) {
365
+ if (!job.prompt) {
366
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "grok lane requires a prompt" };
367
+ }
368
+ const started = Date.now();
369
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
370
+ const cliPath = deps.cliPath ?? "grok";
371
+ try {
372
+ const result = spawnCli([cliPath, job.prompt], {
373
+ binaryName: cliPath,
374
+ packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
375
+ timeoutMs,
376
+ env: job.env,
377
+ cwd: job.cwd
378
+ });
379
+ return { ...result, durationMs: Date.now() - started };
380
+ } catch (err) {
381
+ if (err instanceof LaneDependencyMissingError) {
382
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
383
+ }
384
+ throw err;
385
+ }
386
+ }
387
+ };
388
+ }
389
+ var init_grok = __esm(() => {
390
+ init_types();
391
+ });
392
+
393
+ // src/lanes/index.ts
394
+ var exports_lanes = {};
395
+ __export(exports_lanes, {
396
+ runLaneJob: () => runLaneJob,
397
+ resolveLane: () => resolveLane,
398
+ probeLane: () => probeLane,
399
+ laneInventory: () => laneInventory,
400
+ createGrokAdapter: () => createGrokAdapter,
401
+ createCursorAdapter: () => createCursorAdapter,
402
+ createCodexAdapter: () => createCodexAdapter,
403
+ createClaudeAdapter: () => createClaudeAdapter,
404
+ binaryOnPath: () => binaryOnPath,
405
+ LaneDependencyMissingError: () => LaneDependencyMissingError,
406
+ LaneAdapterShapeError: () => LaneAdapterShapeError,
407
+ LANE_KINDS: () => LANE_KINDS2
408
+ });
409
+ function resolveLane(kind) {
410
+ const adapter = adapters.get(kind);
411
+ if (!adapter)
412
+ throw new Error(`unknown lane ${kind} (expected one of ${LANE_KINDS2.join(", ")})`);
413
+ return adapter;
414
+ }
415
+ async function runLaneJob(job) {
416
+ return resolveLane(job.lane).run(job);
417
+ }
418
+ async function probeLane(kind) {
419
+ return resolveLane(kind).probe();
420
+ }
421
+ async function laneInventory() {
422
+ return Promise.all(LANE_KINDS2.map((kind) => probeLane(kind)));
423
+ }
424
+ var adapters;
425
+ var init_lanes = __esm(() => {
426
+ init_claude();
427
+ init_codex();
428
+ init_cursor();
429
+ init_grok();
430
+ init_types();
431
+ init_types();
432
+ init_claude();
433
+ init_codex();
434
+ init_cursor();
435
+ init_grok();
436
+ adapters = new Map([
437
+ ["claude", createClaudeAdapter()],
438
+ ["codex", createCodexAdapter()],
439
+ ["cursor", createCursorAdapter()],
440
+ ["grok", createGrokAdapter()]
441
+ ]);
442
+ });
3
443
 
4
444
  // src/cli/index.ts
5
445
  import { Command } from "commander";
446
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2, rmSync } from "fs";
447
+ import { hostname } from "os";
448
+ import { join as join4 } from "path";
6
449
 
7
450
  // src/service.ts
8
- import { join } from "path";
451
+ import { join as join3 } from "path";
9
452
  // package.json
10
453
  var package_default = {
11
454
  name: "@hasna/workflows",
12
- version: "0.1.0",
13
- description: "Universal graph workflow app \u2014 scaffold with version/health/readiness on all four surfaces; graph language, store, session WAL, daemon, and lane adapters are roadmap",
455
+ version: "0.1.1",
456
+ 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",
14
457
  type: "module",
15
458
  main: "./dist/index.js",
16
459
  types: "./types/index.d.ts",
@@ -80,10 +523,13 @@ var package_default = {
80
523
  author: "Hasna",
81
524
  license: "Apache-2.0",
82
525
  dependencies: {
526
+ "@anthropic-ai/claude-agent-sdk": "^0.3.234",
527
+ "@cursor/sdk": "^1.0.28",
528
+ "@openai/codex-sdk": "^0.147.0",
83
529
  commander: "^13.1.0"
84
530
  },
85
531
  devDependencies: {
86
- "@hasna/contracts": "0.14.0",
532
+ "@hasna/contracts": "0.14.1",
87
533
  "@types/bun": "latest",
88
534
  "@types/pg": "^8.15.6",
89
535
  pg: "^8.16.3",
@@ -91,6 +537,1456 @@ var package_default = {
91
537
  }
92
538
  };
93
539
 
540
+ // src/daemon.ts
541
+ import { createHash as createHash2 } from "crypto";
542
+
543
+ // src/expr.ts
544
+ class ExprSyntaxError extends Error {
545
+ constructor(message) {
546
+ super(message);
547
+ this.name = "ExprSyntaxError";
548
+ }
549
+ }
550
+
551
+ class ExprEvalError extends Error {
552
+ constructor(message) {
553
+ super(message);
554
+ this.name = "ExprEvalError";
555
+ }
556
+ }
557
+ var COMPARISON_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
558
+ function tokenize(input) {
559
+ const tokens = [];
560
+ let i = 0;
561
+ while (i < input.length) {
562
+ const ch = input[i];
563
+ if (ch === " " || ch === "\t" || ch === `
564
+ ` || ch === "\r") {
565
+ i++;
566
+ continue;
567
+ }
568
+ if (ch === "(") {
569
+ tokens.push({ kind: "lparen", value: "(" });
570
+ i++;
571
+ continue;
572
+ }
573
+ if (ch === ")") {
574
+ tokens.push({ kind: "rparen", value: ")" });
575
+ i++;
576
+ continue;
577
+ }
578
+ if (ch === '"' || ch === "'") {
579
+ const quote = ch;
580
+ let j = i + 1;
581
+ let out = "";
582
+ let closed = false;
583
+ while (j < input.length) {
584
+ if (input[j] === quote) {
585
+ closed = true;
586
+ break;
587
+ }
588
+ out += input[j];
589
+ j++;
590
+ }
591
+ if (!closed)
592
+ throw new ExprSyntaxError(`unterminated string literal at offset ${i}`);
593
+ tokens.push({ kind: "string", value: out });
594
+ i = j + 1;
595
+ continue;
596
+ }
597
+ const two = input.slice(i, i + 2);
598
+ if (COMPARISON_OPS.has(two)) {
599
+ tokens.push({ kind: "op", value: two });
600
+ i += 2;
601
+ continue;
602
+ }
603
+ if (ch === "<" || ch === ">" || ch === "=" || ch === "!") {
604
+ tokens.push({ kind: "op", value: ch });
605
+ i++;
606
+ continue;
607
+ }
608
+ if (/[0-9]/.test(ch)) {
609
+ let j = i;
610
+ while (j < input.length && /[0-9.]/.test(input[j]))
611
+ j++;
612
+ tokens.push({ kind: "number", value: input.slice(i, j) });
613
+ i = j;
614
+ continue;
615
+ }
616
+ if (/[A-Za-z_]/.test(ch)) {
617
+ let j = i;
618
+ while (j < input.length && /[A-Za-z0-9_.]/.test(input[j]))
619
+ j++;
620
+ tokens.push({ kind: "ident", value: input.slice(i, j) });
621
+ i = j;
622
+ continue;
623
+ }
624
+ throw new ExprSyntaxError(`unexpected character ${JSON.stringify(ch)} at offset ${i}`);
625
+ }
626
+ return tokens;
627
+ }
628
+
629
+ class Parser {
630
+ tokens;
631
+ pos = 0;
632
+ constructor(tokens) {
633
+ this.tokens = tokens;
634
+ }
635
+ peek() {
636
+ return this.tokens[this.pos];
637
+ }
638
+ next() {
639
+ return this.tokens[this.pos++];
640
+ }
641
+ expectOp(value) {
642
+ const t = this.next();
643
+ if (!t || t.kind !== "op" || t.value !== value) {
644
+ throw new ExprSyntaxError(`expected ${JSON.stringify(value)}`);
645
+ }
646
+ }
647
+ parse() {
648
+ if (this.tokens.length === 0)
649
+ throw new ExprSyntaxError("empty expression");
650
+ const node = this.parseOr();
651
+ if (this.pos < this.tokens.length) {
652
+ throw new ExprSyntaxError(`unexpected trailing token ${JSON.stringify(this.peek().value)}`);
653
+ }
654
+ return node;
655
+ }
656
+ parseOr() {
657
+ let left = this.parseAnd();
658
+ while (this.peek()?.kind === "ident" && this.peek().value === "or") {
659
+ this.next();
660
+ const right = this.parseAnd();
661
+ left = { kind: "or", left, right };
662
+ }
663
+ return left;
664
+ }
665
+ parseAnd() {
666
+ let left = this.parseNot();
667
+ while (this.peek()?.kind === "ident" && this.peek().value === "and") {
668
+ this.next();
669
+ const right = this.parseNot();
670
+ left = { kind: "and", left, right };
671
+ }
672
+ return left;
673
+ }
674
+ parseNot() {
675
+ if (this.peek()?.kind === "ident" && this.peek().value === "not") {
676
+ this.next();
677
+ return { kind: "not", operand: this.parseNot() };
678
+ }
679
+ return this.parseComparison();
680
+ }
681
+ parseComparison() {
682
+ const left = this.parsePrimary();
683
+ const t = this.peek();
684
+ if (t && t.kind === "op" && COMPARISON_OPS.has(t.value)) {
685
+ this.next();
686
+ const right = this.parsePrimary();
687
+ return { kind: "comparison", op: t.value, left, right };
688
+ }
689
+ return left;
690
+ }
691
+ parsePrimary() {
692
+ const t = this.next();
693
+ if (!t)
694
+ throw new ExprSyntaxError("unexpected end of expression");
695
+ if (t.kind === "lparen") {
696
+ const inner = this.parseOr();
697
+ this.expectCloseParen();
698
+ return inner;
699
+ }
700
+ if (t.kind === "number") {
701
+ const value = Number(t.value);
702
+ if (!Number.isFinite(value))
703
+ throw new ExprSyntaxError(`invalid number ${JSON.stringify(t.value)}`);
704
+ return { kind: "literal", value };
705
+ }
706
+ if (t.kind === "string")
707
+ return { kind: "literal", value: t.value };
708
+ if (t.kind === "ident") {
709
+ if (t.value === "true")
710
+ return { kind: "literal", value: true };
711
+ if (t.value === "false")
712
+ return { kind: "literal", value: false };
713
+ return { kind: "path", segments: t.value.split(".") };
714
+ }
715
+ throw new ExprSyntaxError(`unexpected token ${JSON.stringify(t.value)}`);
716
+ }
717
+ expectCloseParen() {
718
+ const t = this.next();
719
+ if (!t || t.kind !== "rparen")
720
+ throw new ExprSyntaxError("expected )");
721
+ }
722
+ }
723
+ function parseExpr(input) {
724
+ return new Parser(tokenize(input)).parse();
725
+ }
726
+ function resolvePath(segments, context) {
727
+ let current = context;
728
+ for (const segment of segments) {
729
+ if (current === null || typeof current !== "object")
730
+ return;
731
+ const record = current;
732
+ if (!(segment in record))
733
+ return;
734
+ current = record[segment];
735
+ }
736
+ if (typeof current === "number" || typeof current === "string" || typeof current === "boolean") {
737
+ return current;
738
+ }
739
+ return;
740
+ }
741
+ function evalNode(node, context) {
742
+ switch (node.kind) {
743
+ case "literal":
744
+ return node.value;
745
+ case "path":
746
+ return resolvePath(node.segments, context);
747
+ case "comparison": {
748
+ const left = evalNode(node.left, context);
749
+ const right = evalNode(node.right, context);
750
+ if (left === undefined || right === undefined) {
751
+ if (node.op === "==")
752
+ return left === undefined && right === undefined;
753
+ if (node.op === "!=")
754
+ return left !== right;
755
+ throw new ExprEvalError("cannot order-compare an unknown path");
756
+ }
757
+ if (typeof left !== typeof right) {
758
+ throw new ExprEvalError(`cannot compare ${JSON.stringify(left)} (${typeof left}) with ${JSON.stringify(right)} (${typeof right})`);
759
+ }
760
+ switch (node.op) {
761
+ case "==":
762
+ return left === right;
763
+ case "!=":
764
+ return left !== right;
765
+ case "<":
766
+ return left < right;
767
+ case "<=":
768
+ return left <= right;
769
+ case ">":
770
+ return left > right;
771
+ case ">=":
772
+ return left >= right;
773
+ default:
774
+ throw new ExprEvalError(`unknown operator ${node.op}`);
775
+ }
776
+ }
777
+ case "and": {
778
+ const left = evalNode(node.left, context);
779
+ const right = evalNode(node.right, context);
780
+ if (typeof left !== "boolean" || typeof right !== "boolean") {
781
+ throw new ExprEvalError("and requires boolean operands");
782
+ }
783
+ return left && right;
784
+ }
785
+ case "or": {
786
+ const left = evalNode(node.left, context);
787
+ const right = evalNode(node.right, context);
788
+ if (typeof left !== "boolean" || typeof right !== "boolean") {
789
+ throw new ExprEvalError("or requires boolean operands");
790
+ }
791
+ return left || right;
792
+ }
793
+ case "not": {
794
+ const operand = evalNode(node.operand, context);
795
+ if (typeof operand !== "boolean")
796
+ throw new ExprEvalError("not requires a boolean operand");
797
+ return !operand;
798
+ }
799
+ }
800
+ }
801
+ function evaluateExpr(input, context) {
802
+ const ast = parseExpr(input);
803
+ const value = evalNode(ast, context);
804
+ if (typeof value !== "boolean") {
805
+ throw new ExprEvalError(`condition must evaluate to a boolean, got ${typeof value}`);
806
+ }
807
+ return value;
808
+ }
809
+
810
+ // src/graph.ts
811
+ var GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
812
+ var LANE_KINDS = ["claude", "codex", "cursor", "grok"];
813
+ var NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
814
+ function issue(issues, path, message) {
815
+ issues.push({ path, message });
816
+ }
817
+ function findNode(graph, id) {
818
+ return graph.nodes.find((n) => n.id === id);
819
+ }
820
+ function collectEdges(graph) {
821
+ const edges = [];
822
+ for (const node of graph.nodes) {
823
+ switch (node.type) {
824
+ case "start":
825
+ case "step":
826
+ if (node.next)
827
+ edges.push({ from: node.id, to: node.next });
828
+ break;
829
+ case "decision":
830
+ if (node.then)
831
+ edges.push({ from: node.id, to: node.then });
832
+ if (node.else)
833
+ edges.push({ from: node.id, to: node.else });
834
+ break;
835
+ case "while":
836
+ if (node.next)
837
+ edges.push({ from: node.id, to: node.next });
838
+ break;
839
+ case "end":
840
+ break;
841
+ }
842
+ }
843
+ return edges;
844
+ }
845
+ function reachableNodes(graph) {
846
+ const start = graph.nodes.find((n) => n.type === "start");
847
+ if (!start)
848
+ return [];
849
+ const seen = new Set;
850
+ const stack = [start.id];
851
+ while (stack.length > 0) {
852
+ const id = stack.pop();
853
+ if (seen.has(id))
854
+ continue;
855
+ seen.add(id);
856
+ const node = findNode(graph, id);
857
+ if (!node)
858
+ continue;
859
+ if (node.type === "start" || node.type === "step") {
860
+ if (node.next)
861
+ stack.push(node.next);
862
+ } else if (node.type === "decision") {
863
+ if (node.then)
864
+ stack.push(node.then);
865
+ if (node.else)
866
+ stack.push(node.else);
867
+ } else if (node.type === "while") {
868
+ for (const bodyId of node.body)
869
+ stack.push(bodyId);
870
+ if (node.next)
871
+ stack.push(node.next);
872
+ }
873
+ }
874
+ return [...seen];
875
+ }
876
+ function findCycles(graph) {
877
+ const edges = collectEdges(graph);
878
+ const adj = new Map;
879
+ for (const e of edges) {
880
+ const list = adj.get(e.from) ?? [];
881
+ list.push(e.to);
882
+ adj.set(e.from, list);
883
+ }
884
+ const color = new Map;
885
+ const stack = [];
886
+ const cycles = [];
887
+ function visit(id) {
888
+ color.set(id, "grey");
889
+ stack.push(id);
890
+ for (const to of adj.get(id) ?? []) {
891
+ const c = color.get(to);
892
+ if (c === "grey") {
893
+ const idx = stack.indexOf(to);
894
+ cycles.push([...stack.slice(idx), to]);
895
+ } else if (c === undefined) {
896
+ visit(to);
897
+ }
898
+ }
899
+ stack.pop();
900
+ color.set(id, "black");
901
+ }
902
+ for (const node of graph.nodes) {
903
+ if (color.get(node.id) === undefined)
904
+ visit(node.id);
905
+ }
906
+ return cycles;
907
+ }
908
+ function validateGraph(graph) {
909
+ const issues = [];
910
+ if (typeof graph.name !== "string" || graph.name.trim() === "") {
911
+ issue(issues, "name", "graph requires a non-empty name");
912
+ }
913
+ if (typeof graph.version !== "string" || graph.version.trim() === "") {
914
+ issue(issues, "version", "graph requires a version string");
915
+ }
916
+ if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
917
+ issue(issues, "nodes", "graph requires at least one node");
918
+ return { ok: false, issues };
919
+ }
920
+ const starts = graph.nodes.filter((n) => n.type === "start");
921
+ if (starts.length === 0)
922
+ issue(issues, "nodes", "graph requires exactly one start node (found none)");
923
+ if (starts.length > 1)
924
+ issue(issues, "nodes", "graph requires exactly one start node (found multiple)");
925
+ const ends = graph.nodes.filter((n) => n.type === "end");
926
+ if (ends.length === 0)
927
+ issue(issues, "nodes", "graph requires at least one end node");
928
+ const seenIds = new Map;
929
+ for (const node of graph.nodes) {
930
+ if (typeof node.id !== "string" || !NODE_ID_RE.test(node.id)) {
931
+ issue(issues, "nodes", `node id ${JSON.stringify(node.id)} must match ${String(NODE_ID_RE)}`);
932
+ continue;
933
+ }
934
+ if (seenIds.has(node.id)) {
935
+ issue(issues, `nodes.${node.id}`, `duplicate node id ${node.id}`);
936
+ continue;
937
+ }
938
+ seenIds.set(node.id, node);
939
+ if (!GRAPH_NODE_TYPES.includes(node.type)) {
940
+ issue(issues, `nodes.${node.id}`, `unknown node type ${JSON.stringify(node.type)}`);
941
+ continue;
942
+ }
943
+ validateNode(graph, node, issues);
944
+ }
945
+ for (const node of graph.nodes) {
946
+ const edges = nodeEdges(node);
947
+ for (const { kind, target } of edges) {
948
+ if (!seenIds.has(target)) {
949
+ issue(issues, `nodes.${node.id}`, `${kind} edge targets nonexistent node ${target}`);
950
+ }
951
+ }
952
+ }
953
+ if (starts.length === 1) {
954
+ const reachable = new Set(reachableNodes(graph));
955
+ for (const node of graph.nodes) {
956
+ if (!reachable.has(node.id)) {
957
+ issue(issues, `nodes.${node.id}`, `node ${node.id} is unreachable from start`);
958
+ }
959
+ }
960
+ }
961
+ const cycles = findCycles(graph);
962
+ for (const cycle of cycles) {
963
+ issue(issues, "nodes", `explicit cycle detected: ${cycle.join(" -> ")} (loop only via a while node)`);
964
+ }
965
+ return { ok: issues.length === 0, issues };
966
+ }
967
+ function nodeEdges(node) {
968
+ switch (node.type) {
969
+ case "start":
970
+ case "step":
971
+ return node.next ? [{ kind: "next", target: node.next }] : [];
972
+ case "decision":
973
+ return [
974
+ ...node.then ? [{ kind: "then", target: node.then }] : [],
975
+ ...node.else ? [{ kind: "else", target: node.else }] : []
976
+ ];
977
+ case "while":
978
+ return node.next ? [{ kind: "next", target: node.next }] : [];
979
+ case "end":
980
+ return [];
981
+ }
982
+ }
983
+ function validateNode(graph, node, issues) {
984
+ switch (node.type) {
985
+ case "start":
986
+ if (!node.next)
987
+ issue(issues, `nodes.${node.id}`, "start node requires a next edge");
988
+ break;
989
+ case "step": {
990
+ if (!node.prompt && !node.command) {
991
+ issue(issues, `nodes.${node.id}`, "step node requires prompt or command");
992
+ }
993
+ if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
994
+ issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
995
+ }
996
+ if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
997
+ issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
998
+ }
999
+ if (node.timeoutMs !== undefined && (!Number.isInteger(node.timeoutMs) || node.timeoutMs < 1)) {
1000
+ issue(issues, `nodes.${node.id}`, "timeoutMs must be a positive integer");
1001
+ }
1002
+ break;
1003
+ }
1004
+ case "decision": {
1005
+ if (typeof node.condition !== "string" || node.condition.trim() === "") {
1006
+ issue(issues, `nodes.${node.id}`, "decision node requires a condition expression");
1007
+ } else {
1008
+ try {
1009
+ parseExpr(node.condition);
1010
+ } catch (err) {
1011
+ if (err instanceof ExprSyntaxError) {
1012
+ issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
1013
+ } else {
1014
+ throw err;
1015
+ }
1016
+ }
1017
+ }
1018
+ if (!node.then && !node.else) {
1019
+ issue(issues, `nodes.${node.id}`, "decision node requires then and/or else edge");
1020
+ }
1021
+ break;
1022
+ }
1023
+ case "while": {
1024
+ if (typeof node.condition !== "string" || node.condition.trim() === "") {
1025
+ issue(issues, `nodes.${node.id}`, "while node requires a condition expression");
1026
+ } else {
1027
+ try {
1028
+ parseExpr(node.condition);
1029
+ } catch (err) {
1030
+ if (err instanceof ExprSyntaxError) {
1031
+ issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
1032
+ } else {
1033
+ throw err;
1034
+ }
1035
+ }
1036
+ }
1037
+ if (node.maxIterations === undefined) {
1038
+ issue(issues, `nodes.${node.id}`, "while node requires maxIterations (finite loop bound)");
1039
+ } else if (!Number.isInteger(node.maxIterations) || node.maxIterations < 1) {
1040
+ issue(issues, `nodes.${node.id}`, "maxIterations must be a positive integer");
1041
+ }
1042
+ if (!Array.isArray(node.body) || node.body.length === 0) {
1043
+ issue(issues, `nodes.${node.id}`, "while node requires a non-empty body");
1044
+ } else {
1045
+ for (const bodyId of node.body) {
1046
+ const bodyNode = findNode(graph, bodyId);
1047
+ if (!bodyNode) {
1048
+ issue(issues, `nodes.${node.id}`, `while body references nonexistent node ${bodyId}`);
1049
+ continue;
1050
+ }
1051
+ if (bodyNode.type === "start" || bodyNode.type === "end" || bodyNode.type === "while") {
1052
+ issue(issues, `nodes.${node.id}`, `while body may only contain step/decision nodes in v1 (found ${bodyNode.type})`);
1053
+ }
1054
+ }
1055
+ const bodySet = new Set(node.body);
1056
+ for (const bodyId of node.body) {
1057
+ const bodyNode = findNode(graph, bodyId);
1058
+ if (!bodyNode)
1059
+ continue;
1060
+ for (const { kind, target } of nodeEdges(bodyNode)) {
1061
+ if (!bodySet.has(target)) {
1062
+ issue(issues, `nodes.${node.id}`, `while body node ${bodyId} ${kind}-edge leaves the body (target ${target})`);
1063
+ }
1064
+ }
1065
+ }
1066
+ }
1067
+ break;
1068
+ }
1069
+ case "end":
1070
+ break;
1071
+ }
1072
+ }
1073
+
1074
+ // src/secrets.ts
1075
+ class SecretsGateError extends Error {
1076
+ constructor(message) {
1077
+ super(message);
1078
+ this.name = "SecretsGateError";
1079
+ }
1080
+ }
1081
+ var p = (...parts) => parts.join("");
1082
+ var VALUE_PATTERNS = [
1083
+ { detector: "anthropic_api_key", re: new RegExp(p("sk-", "ant-") + "[A-Za-z0-9_-]{10,}") },
1084
+ { detector: "openai_api_key", re: new RegExp(p("sk-", "proj-") + "[A-Za-z0-9_-]{10,}") },
1085
+ { detector: "npm_token", re: /npm_[A-Za-z0-9]{20,}/ },
1086
+ { detector: "github_token", re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
1087
+ { detector: "aws_access_key", re: /AKIA[0-9A-Z]{16}/ },
1088
+ { detector: "google_api_key", re: /AIza[0-9A-Za-z_-]{20,}/ },
1089
+ { detector: "xai_api_key", re: new RegExp(p("xai-") + "[A-Za-z0-9]{20,}") },
1090
+ { detector: "codex_token", re: new RegExp(p("ctx7", "sk-") + "[A-Za-z0-9]{10,}") },
1091
+ { detector: "private_key_block", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
1092
+ { detector: "bearer_token", re: /Bearer [A-Za-z0-9._~+/=-]{20,}/ }
1093
+ ];
1094
+ var CREDENTIAL_KEY_RE = /^(token|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|refresh[_-]?token|session[_-]?token|bot[_-]?token)$/i;
1095
+ function scanString(value, path, findings) {
1096
+ for (const { detector, re } of VALUE_PATTERNS) {
1097
+ if (re.test(value)) {
1098
+ findings.push({ path, detector });
1099
+ return;
1100
+ }
1101
+ }
1102
+ }
1103
+ function isTrivial(value) {
1104
+ const v = value.trim();
1105
+ if (v.length === 0)
1106
+ return true;
1107
+ const upper = v.toUpperCase();
1108
+ if (upper.startsWith("REPLACE") || upper.startsWith("CHANGE") || upper.startsWith("YOUR_") || upper.startsWith("EXAMPLE") || upper.startsWith("PLACEHOLDER"))
1109
+ return true;
1110
+ if (v.length < 8)
1111
+ return true;
1112
+ return false;
1113
+ }
1114
+ function scanForSecrets(value, path = "$") {
1115
+ const findings = [];
1116
+ if (value === null || value === undefined)
1117
+ return findings;
1118
+ if (typeof value === "string") {
1119
+ scanString(value, path, findings);
1120
+ return findings;
1121
+ }
1122
+ if (Array.isArray(value)) {
1123
+ value.forEach((item, index) => {
1124
+ findings.push(...scanForSecrets(item, `${path}[${index}]`));
1125
+ });
1126
+ return findings;
1127
+ }
1128
+ if (typeof value === "object") {
1129
+ const record = value;
1130
+ for (const [key, child] of Object.entries(record)) {
1131
+ if (CREDENTIAL_KEY_RE.test(key) && typeof child === "string" && !isTrivial(child)) {
1132
+ findings.push({ path: `${path}.${key}`, detector: "credential_key_field" });
1133
+ } else {
1134
+ findings.push(...scanForSecrets(child, `${path}.${key}`));
1135
+ }
1136
+ }
1137
+ }
1138
+ return findings;
1139
+ }
1140
+ function assertNoSecrets(value, what) {
1141
+ const findings = scanForSecrets(value);
1142
+ if (findings.length === 0)
1143
+ return;
1144
+ const detail = findings.map((f) => `${f.path} (${f.detector})`).join(", ");
1145
+ throw new SecretsGateError(`secrets write-gate refused to persist ${what}: credential-shaped value at ${detail}`);
1146
+ }
1147
+
1148
+ // src/session.ts
1149
+ import { createHash } from "crypto";
1150
+ var DEFAULT_MAX_ATTEMPTS = 3;
1151
+ function repairTornRuns(store, wal, opts = {}) {
1152
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1153
+ const replay = wal.replay();
1154
+ const liveClaims = replay.liveClaims(opts.now);
1155
+ const report = { interrupted: 0, requeued: 0, failed: 0 };
1156
+ const running = store.listRuns({ status: "running", limit: 1000 });
1157
+ for (const run of running) {
1158
+ if (liveClaims.has(run.id))
1159
+ continue;
1160
+ report.interrupted++;
1161
+ const nodes = store.listRunNodes(run.id);
1162
+ if (run.attempts < maxAttempts) {
1163
+ store.bumpAttempts(run.id);
1164
+ store.setRunStatus(run.id, "interrupted", {
1165
+ error: `torn run interrupted after claim loss (attempt ${run.attempts + 1}/${maxAttempts})`
1166
+ });
1167
+ for (const node of nodes) {
1168
+ if (node.status === "running") {
1169
+ store.setRunNodeStatus(node.id, "pending", { error: undefined });
1170
+ }
1171
+ }
1172
+ report.requeued++;
1173
+ } else {
1174
+ store.setRunStatus(run.id, "failed", {
1175
+ error: `torn run failed after ${maxAttempts} interrupted attempts`
1176
+ });
1177
+ for (const node of nodes) {
1178
+ if (node.status === "running") {
1179
+ store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
1180
+ }
1181
+ }
1182
+ report.failed++;
1183
+ }
1184
+ }
1185
+ return report;
1186
+ }
1187
+ function inputHash(payload) {
1188
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1189
+ }
1190
+ function memoKey(graphName, nodeId, input) {
1191
+ return `${graphName}:${nodeId}:${inputHash(input)}`;
1192
+ }
1193
+ function tryMemoHit(store, graphName, nodeId, input) {
1194
+ const key = memoKey(graphName, nodeId, input);
1195
+ const memo = store.memoGet(key);
1196
+ if (!memo)
1197
+ return;
1198
+ store.memoHit(key);
1199
+ return memo;
1200
+ }
1201
+ function recordMemo(store, graphName, nodeId, input, output) {
1202
+ const key = memoKey(graphName, nodeId, input);
1203
+ store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
1204
+ }
1205
+ function resetRunNodes(store, run) {
1206
+ for (const node of store.listRunNodes(run.id)) {
1207
+ if (node.status === "running") {
1208
+ store.setRunNodeStatus(node.id, "pending", { error: undefined });
1209
+ }
1210
+ }
1211
+ }
1212
+ function restoreInterruptedRun(store, runId) {
1213
+ const run = store.getRun(runId);
1214
+ if (!run)
1215
+ throw new Error(`no such run ${runId}`);
1216
+ if (run.status !== "interrupted") {
1217
+ throw new Error(`run ${runId} is ${run.status}; top-level resume restores interrupted runs \u2014 use 'runs resume' for cancelled/failed runs`);
1218
+ }
1219
+ const nodes = store.listRunNodes(runId);
1220
+ let nodesRestored = 0;
1221
+ let memoizedNodes = 0;
1222
+ for (const node of nodes) {
1223
+ if (node.status === "completed" && node.outputJson !== null) {
1224
+ memoizedNodes++;
1225
+ } else if (node.status === "running" || node.status === "pending") {
1226
+ store.setRunNodeStatus(node.id, "pending", { error: undefined });
1227
+ nodesRestored++;
1228
+ }
1229
+ }
1230
+ store.setRunStatus(runId, "pending", { error: null });
1231
+ return { runId, status: "pending", nodesRestored, memoizedNodes, attempts: run.attempts };
1232
+ }
1233
+ function findRunByIdempotencyKey(store, key) {
1234
+ const runs = store.listRuns({ limit: 1000 });
1235
+ for (const run of runs) {
1236
+ try {
1237
+ const context = JSON.parse(run.contextJson);
1238
+ if (context?.__wf?.idempotencyKey === key)
1239
+ return run;
1240
+ } catch {}
1241
+ }
1242
+ return;
1243
+ }
1244
+
1245
+ // src/daemon.ts
1246
+ init_types();
1247
+ var DEFAULT_LEASE_TTL_MS = 120000;
1248
+ var DEFAULT_MAX_ATTEMPTS2 = 3;
1249
+ var DEFAULT_MAX_DISPATCH = 4;
1250
+ var DEFAULT_MAX_STEPS = 25;
1251
+ var DEFAULT_TIMEOUT_MS = 120000;
1252
+ function nowIso() {
1253
+ return new Date().toISOString();
1254
+ }
1255
+ function stripRunState(context) {
1256
+ const { __wf: _wf, steps: _steps, ...user } = context;
1257
+ return user;
1258
+ }
1259
+ function runCommand(job) {
1260
+ if (!job.command) {
1261
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "no command" };
1262
+ }
1263
+ const started = Date.now();
1264
+ const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1265
+ const proc = Bun.spawnSync({
1266
+ cmd: ["/bin/sh", "-c", job.command],
1267
+ cwd: job.cwd,
1268
+ env: { ...process.env, ...job.env ?? {} },
1269
+ stdout: "pipe",
1270
+ stderr: "pipe",
1271
+ timeout: timeoutMs
1272
+ });
1273
+ const durationMs = Date.now() - started;
1274
+ const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
1275
+ const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
1276
+ const output = stderr ? `${stdout}
1277
+ ${stderr}`.trim() : stdout.trim();
1278
+ const exitCode = proc.exitCode ?? (proc.signalCode ? 1 : 1);
1279
+ return { ok: exitCode === 0, exitCode, output, durationMs };
1280
+ }
1281
+
1282
+ class WorkflowsDaemon {
1283
+ store;
1284
+ wal;
1285
+ worker;
1286
+ leaseTtlMs;
1287
+ maxAttempts;
1288
+ maxDispatchPerCycle;
1289
+ maxStepsPerCycle;
1290
+ time;
1291
+ laneRunner;
1292
+ env;
1293
+ leases = new Map;
1294
+ fencingCounter = new Map;
1295
+ graphCache = new Map;
1296
+ isolatedRunId = null;
1297
+ constructor(store, wal, options = {}) {
1298
+ this.store = store;
1299
+ this.wal = wal;
1300
+ this.worker = options.worker ?? "local";
1301
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
1302
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS2;
1303
+ this.maxDispatchPerCycle = options.maxDispatchPerCycle ?? DEFAULT_MAX_DISPATCH;
1304
+ this.maxStepsPerCycle = options.maxStepsPerCycle ?? DEFAULT_MAX_STEPS;
1305
+ this.time = options.time ?? Date.now;
1306
+ this.env = options.env ?? {};
1307
+ this.laneRunner = options.laneRunner ?? defaultLaneRunner;
1308
+ this.isolatedRunId = options.isolatedRunId ?? null;
1309
+ this.replayRegisteredGraphs();
1310
+ }
1311
+ isolateRun(runId) {
1312
+ this.isolatedRunId = runId;
1313
+ }
1314
+ replayRegisteredGraphs() {
1315
+ const entries = this.wal.replay().entries;
1316
+ for (const entry of entries) {
1317
+ const op = entry.op;
1318
+ if (op.op === "graph_registered") {
1319
+ this.graphCache.set(op.name, op.graph);
1320
+ }
1321
+ }
1322
+ }
1323
+ liveLeases() {
1324
+ const now = this.time();
1325
+ for (const [runId, lease] of this.leases) {
1326
+ if (lease.expiresAtMs <= now)
1327
+ this.leases.delete(runId);
1328
+ }
1329
+ return this.leases;
1330
+ }
1331
+ claim(runId) {
1332
+ const now = this.time();
1333
+ const existing = this.leases.get(runId);
1334
+ if (existing && existing.worker !== this.worker && existing.expiresAtMs > now) {
1335
+ return null;
1336
+ }
1337
+ const liveClaims = this.wal.replay().liveClaims(now);
1338
+ const foreign = liveClaims.get(runId);
1339
+ if (foreign && foreign.worker !== this.worker && foreign.expiresAtMs > now) {
1340
+ return null;
1341
+ }
1342
+ const fencing = (this.fencingCounter.get(runId) ?? 0) + 1;
1343
+ this.fencingCounter.set(runId, fencing);
1344
+ const lease = { runId, worker: this.worker, expiresAtMs: now + this.leaseTtlMs, fencing };
1345
+ this.leases.set(runId, lease);
1346
+ this.wal.append({
1347
+ op: "claim_acquired",
1348
+ runId,
1349
+ worker: this.worker,
1350
+ expiresAt: new Date(lease.expiresAtMs).toISOString(),
1351
+ fencing,
1352
+ at: nowIso()
1353
+ });
1354
+ return lease;
1355
+ }
1356
+ heartbeat(runId, fencing) {
1357
+ const lease = this.leases.get(runId);
1358
+ if (!lease || lease.fencing !== fencing)
1359
+ return false;
1360
+ lease.expiresAtMs = this.time() + this.leaseTtlMs;
1361
+ return true;
1362
+ }
1363
+ release(runId, fencing) {
1364
+ const lease = this.leases.get(runId);
1365
+ if (!lease || lease.fencing !== fencing)
1366
+ return false;
1367
+ this.leases.delete(runId);
1368
+ this.wal.append({ op: "claim_released", runId, fencing, at: nowIso() });
1369
+ return true;
1370
+ }
1371
+ async reap() {
1372
+ const report = { expired: 0, interrupted: 0, requeued: 0, failed: 0, dispatched: 0, advanced: 0, completed: 0 };
1373
+ const now = this.time();
1374
+ for (const [runId, lease] of this.leases) {
1375
+ if (lease.expiresAtMs <= now) {
1376
+ this.leases.delete(runId);
1377
+ report.expired++;
1378
+ }
1379
+ }
1380
+ if (this.isolatedRunId === null) {
1381
+ const repair = repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
1382
+ report.interrupted += repair.interrupted;
1383
+ report.requeued += repair.requeued;
1384
+ report.failed += repair.failed;
1385
+ }
1386
+ const pending = this.store.listRuns({ status: "pending", limit: this.maxDispatchPerCycle }).concat(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);
1387
+ for (const run of pending) {
1388
+ if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
1389
+ continue;
1390
+ const lease = this.claim(run.id);
1391
+ if (!lease)
1392
+ continue;
1393
+ this.store.setRunStatus(run.id, "running");
1394
+ this.wal.append({ op: "run_started", runId: run.id, at: nowIso() });
1395
+ report.dispatched++;
1396
+ }
1397
+ const running = this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
1398
+ let steps = 0;
1399
+ for (const run of running) {
1400
+ if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
1401
+ continue;
1402
+ if (steps >= this.maxStepsPerCycle)
1403
+ break;
1404
+ const outcome = await this.advanceRun(run.id);
1405
+ steps++;
1406
+ if (outcome.kind === "completed")
1407
+ report.completed++;
1408
+ if (outcome.kind === "failed")
1409
+ report.failed++;
1410
+ if (outcome.kind === "advanced")
1411
+ report.advanced++;
1412
+ }
1413
+ return report;
1414
+ }
1415
+ startRun(graph, context) {
1416
+ const validation = validateGraph(graph);
1417
+ if (!validation.ok) {
1418
+ throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
1419
+ }
1420
+ assertNoSecrets(context, "run context");
1421
+ assertNoSecrets(graph, "graph definition");
1422
+ const existing = this.graphCache.get(graph.name);
1423
+ const serialized = JSON.stringify(graph);
1424
+ if (!existing || JSON.stringify(existing) !== serialized) {
1425
+ this.graphCache.set(graph.name, graph);
1426
+ this.wal.append({ op: "graph_registered", name: graph.name, version: graph.version, graph, at: nowIso() });
1427
+ } else {
1428
+ this.graphCache.set(graph.name, existing);
1429
+ }
1430
+ return this.store.createRun({ graphName: graph.name, graphVersion: graph.version, context: context ?? {} });
1431
+ }
1432
+ async advanceRun(runId) {
1433
+ const run = this.store.getRun(runId);
1434
+ if (!run || run.status !== "running")
1435
+ return { kind: "noop" };
1436
+ const lease = this.leases.get(runId);
1437
+ if (!lease || lease.expiresAtMs <= this.time())
1438
+ return { kind: "blocked" };
1439
+ const graph = this.loadGraph(run.graphName);
1440
+ if (!graph) {
1441
+ this.failRun(run, `graph ${run.graphName} not found in daemon graph cache`);
1442
+ return { kind: "failed", error: "graph not found" };
1443
+ }
1444
+ const context = JSON.parse(run.contextJson);
1445
+ const rawWf = context.__wf ?? {};
1446
+ const wf = {
1447
+ ...rawWf,
1448
+ cursor: typeof rawWf.cursor === "string" ? rawWf.cursor : undefined,
1449
+ loops: rawWf.loops ?? {},
1450
+ completedLoops: rawWf.completedLoops ?? {}
1451
+ };
1452
+ try {
1453
+ const cursor = wf.cursor;
1454
+ const node = cursor === undefined ? graph.nodes.find((n) => n.type === "start") : findNode(graph, cursor);
1455
+ if (!node) {
1456
+ this.failRun(run, `cursor ${cursor ?? "<start>"} resolves to no node`);
1457
+ return { kind: "failed", error: "no node at cursor" };
1458
+ }
1459
+ switch (node.type) {
1460
+ case "start": {
1461
+ wf.cursor = node.next;
1462
+ this.persistContext(run, context, wf);
1463
+ return { kind: "advanced", nodeId: node.id };
1464
+ }
1465
+ case "step": {
1466
+ return await this.executeStep(run, graph, context, wf, node);
1467
+ }
1468
+ case "decision": {
1469
+ const value = evaluateExpr(node.condition, this.conditionScope(context));
1470
+ const next = value ? node.then : node.else;
1471
+ if (!next) {
1472
+ this.failRun(run, `decision ${node.id} evaluated ${value} but no ${value ? "then" : "else"} edge`);
1473
+ return { kind: "failed", error: "missing decision edge" };
1474
+ }
1475
+ wf.cursor = next;
1476
+ this.persistContext(run, context, wf);
1477
+ return { kind: "advanced", nodeId: node.id };
1478
+ }
1479
+ case "while": {
1480
+ return this.executeWhile(run, graph, context, wf, node);
1481
+ }
1482
+ case "end": {
1483
+ this.completeRun(run, context);
1484
+ return { kind: "completed" };
1485
+ }
1486
+ }
1487
+ } catch (err) {
1488
+ if (err instanceof ExprEvalError || err instanceof ExprSyntaxError) {
1489
+ this.failRun(run, `condition error at ${wf.cursor ?? "<start>"}: ${err.message}`);
1490
+ return { kind: "failed", error: err.message };
1491
+ }
1492
+ throw err;
1493
+ }
1494
+ }
1495
+ loadGraph(name) {
1496
+ return this.graphCache.get(name);
1497
+ }
1498
+ conditionScope(context) {
1499
+ return { ...context, steps: context.steps ?? {} };
1500
+ }
1501
+ persistContext(run, context, wf) {
1502
+ context.__wf = wf;
1503
+ this.store.setRunContext(run.id, context);
1504
+ }
1505
+ async executeStep(run, graph, context, wf, node) {
1506
+ const nodeId = node.id;
1507
+ const existing = this.store.listRunNodes(run.id).find((n) => n.nodeId === nodeId);
1508
+ const nodeRow = existing ?? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" });
1509
+ if (node.memo) {
1510
+ const memoInput = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
1511
+ const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
1512
+ if (hit) {
1513
+ const cached = JSON.parse(hit.outputJson);
1514
+ this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } });
1515
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
1516
+ this.recordStepResult(context, nodeId, { ...cached, memoHit: true });
1517
+ return this.afterNode(run, context, wf, node.next);
1518
+ }
1519
+ }
1520
+ if (nodeRow.status !== "running") {
1521
+ this.store.setRunNodeStatus(nodeRow.id, "running");
1522
+ this.wal.append({ op: "node_started", runId: run.id, nodeId, at: nowIso() });
1523
+ }
1524
+ const job = {
1525
+ lane: node.lane ?? "claude",
1526
+ prompt: node.prompt,
1527
+ command: node.command,
1528
+ timeoutMs: node.timeoutMs,
1529
+ env: this.env
1530
+ };
1531
+ let result;
1532
+ try {
1533
+ result = await this.laneRunner(job);
1534
+ } catch (err) {
1535
+ result = { ok: false, exitCode: 1, output: "", durationMs: 0, error: String(err instanceof Error ? err.message : err) };
1536
+ }
1537
+ const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
1538
+ if (!result.ok) {
1539
+ const retriesLeft = (node.maxRetries ?? 0) + 1 - nodeRow.attempts;
1540
+ if (retriesLeft > 0) {
1541
+ this.store.bumpAttemptsNode(nodeRow.id);
1542
+ this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
1543
+ return { kind: "advanced", nodeId };
1544
+ }
1545
+ const error = result.error ?? `step ${nodeId} failed with exit ${result.exitCode}`;
1546
+ this.store.setRunNodeStatus(nodeRow.id, "failed", { exitCode: result.exitCode, error });
1547
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
1548
+ this.failRun(run, error);
1549
+ return { kind: "failed", error };
1550
+ }
1551
+ try {
1552
+ assertNoSecrets(output, `node ${nodeId} output`);
1553
+ } catch (err) {
1554
+ const message = err instanceof Error ? err.message : String(err);
1555
+ this.store.setRunNodeStatus(nodeRow.id, "failed", { error: message });
1556
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
1557
+ this.failRun(run, message);
1558
+ return { kind: "failed", error: message };
1559
+ }
1560
+ this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
1561
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
1562
+ if (node.memo) {
1563
+ recordMemo(this.store, run.graphName, nodeId, { command: node.command, prompt: node.prompt, context: stripRunState(context) }, output);
1564
+ this.wal.append({ op: "memo_set", key: `${run.graphName}:${nodeId}:${createHash2("sha256").update(JSON.stringify({ command: node.command, prompt: node.prompt, context: stripRunState(context) })).digest("hex")}`, at: nowIso() });
1565
+ }
1566
+ this.recordStepResult(context, nodeId, output);
1567
+ return this.afterNode(run, context, wf, node.next);
1568
+ }
1569
+ afterNode(run, context, wf, next) {
1570
+ if (next) {
1571
+ wf.cursor = next;
1572
+ this.persistContext(run, context, wf);
1573
+ return { kind: "advanced", nodeId: wf.cursor };
1574
+ }
1575
+ const cursor = wf.cursor;
1576
+ const graph = this.loadGraph(run.graphName);
1577
+ const whileNode = graph.nodes.find((n) => n.type === "while" && n.body.includes(cursor ?? ""));
1578
+ if (whileNode) {
1579
+ const index = whileNode.body.indexOf(cursor ?? "");
1580
+ if (index >= 0 && index < whileNode.body.length - 1) {
1581
+ wf.cursor = whileNode.body[index + 1];
1582
+ this.persistContext(run, context, wf);
1583
+ return { kind: "advanced", nodeId: wf.cursor };
1584
+ }
1585
+ wf.loops[whileNode.id] = (wf.loops[whileNode.id] ?? 0) + 1;
1586
+ wf.cursor = whileNode.id;
1587
+ this.persistContext(run, context, wf);
1588
+ return { kind: "advanced", nodeId: whileNode.id };
1589
+ }
1590
+ this.completeRun(run, context);
1591
+ return { kind: "completed" };
1592
+ }
1593
+ executeWhile(run, graph, context, wf, node) {
1594
+ const iteration = wf.loops[node.id] ?? 0;
1595
+ if (iteration >= node.maxIterations) {
1596
+ const error = `while node ${node.id} exceeded maxIterations (${node.maxIterations})`;
1597
+ this.failRun(run, error);
1598
+ return { kind: "failed", error };
1599
+ }
1600
+ const scope = { ...this.conditionScope(context), i: iteration };
1601
+ const keepGoing = evaluateExpr(node.condition, scope);
1602
+ if (!keepGoing) {
1603
+ wf.completedLoops[node.id] = iteration;
1604
+ delete wf.loops[node.id];
1605
+ wf.cursor = node.next;
1606
+ this.persistContext(run, context, wf);
1607
+ return { kind: "advanced", nodeId: node.id };
1608
+ }
1609
+ wf.cursor = node.body[0];
1610
+ this.persistContext(run, context, wf);
1611
+ return { kind: "advanced", nodeId: node.id };
1612
+ }
1613
+ recordStepResult(context, nodeId, output) {
1614
+ const steps = context.steps ?? {};
1615
+ steps[nodeId] = output;
1616
+ context.steps = steps;
1617
+ }
1618
+ completeRun(run, context) {
1619
+ const result = {
1620
+ steps: context.steps ?? {},
1621
+ iterations: context.__wf?.completedLoops ?? {}
1622
+ };
1623
+ try {
1624
+ assertNoSecrets(result, "run result");
1625
+ } catch (err) {
1626
+ const message = err instanceof Error ? err.message : String(err);
1627
+ this.failRun(run, message);
1628
+ return;
1629
+ }
1630
+ this.store.setRunStatus(run.id, "completed", { result, finishedAt: nowIso(), error: null });
1631
+ this.wal.append({ op: "run_finished", runId: run.id, status: "completed", at: nowIso() });
1632
+ const lease = this.leases.get(run.id);
1633
+ if (lease)
1634
+ this.release(run.id, lease.fencing);
1635
+ }
1636
+ failRun(run, error) {
1637
+ this.store.setRunStatus(run.id, "failed", { error, finishedAt: nowIso() });
1638
+ this.wal.append({ op: "run_finished", runId: run.id, status: "failed", at: nowIso() });
1639
+ const lease = this.leases.get(run.id);
1640
+ if (lease)
1641
+ this.release(run.id, lease.fencing);
1642
+ }
1643
+ }
1644
+ var defaultLaneRunner = async (job) => {
1645
+ if (job.command)
1646
+ return runCommand(job);
1647
+ const { runLaneJob: runLaneJob2 } = await Promise.resolve().then(() => (init_lanes(), exports_lanes));
1648
+ return runLaneJob2(job);
1649
+ };
1650
+ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
1651
+ const daemon = new WorkflowsDaemon(store, wal, {
1652
+ worker: "local",
1653
+ laneRunner: options.laneRunner,
1654
+ time: options.time,
1655
+ env: options.env,
1656
+ isolatedRunId: options.isolatedRunId
1657
+ });
1658
+ const run = daemon.startRun(graph, context);
1659
+ daemon.isolateRun(run.id);
1660
+ const maxCycles = options.maxCycles ?? 500;
1661
+ for (let cycle = 0;cycle < maxCycles; cycle++) {
1662
+ const report = await daemon.reap();
1663
+ const current = store.getRun(run.id);
1664
+ if (current.status !== "running")
1665
+ return current;
1666
+ if (report.advanced === 0 && report.dispatched === 0) {
1667
+ return store.getRun(run.id);
1668
+ }
1669
+ }
1670
+ return store.getRun(run.id);
1671
+ }
1672
+
1673
+ // src/store.ts
1674
+ import { Database } from "bun:sqlite";
1675
+ import { mkdirSync } from "fs";
1676
+ import { join } from "path";
1677
+ import { randomUUID } from "crypto";
1678
+ function nowIso2() {
1679
+ return new Date().toISOString();
1680
+ }
1681
+ function openStore(dataDir) {
1682
+ mkdirSync(dataDir, { recursive: true });
1683
+ const db = new Database(join(dataDir, "workflows.db"));
1684
+ db.exec("PRAGMA journal_mode = WAL");
1685
+ db.exec("PRAGMA foreign_keys = ON");
1686
+ db.exec(`
1687
+ CREATE TABLE IF NOT EXISTS runs (
1688
+ id TEXT PRIMARY KEY,
1689
+ graph_name TEXT NOT NULL,
1690
+ graph_version TEXT NOT NULL,
1691
+ status TEXT NOT NULL,
1692
+ attempts INTEGER NOT NULL DEFAULT 0,
1693
+ context_json TEXT NOT NULL,
1694
+ result_json TEXT,
1695
+ error TEXT,
1696
+ started_at TEXT,
1697
+ finished_at TEXT,
1698
+ created_at TEXT NOT NULL,
1699
+ updated_at TEXT NOT NULL
1700
+ );
1701
+ CREATE TABLE IF NOT EXISTS run_nodes (
1702
+ id TEXT PRIMARY KEY,
1703
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1704
+ node_id TEXT NOT NULL,
1705
+ status TEXT NOT NULL,
1706
+ attempts INTEGER NOT NULL DEFAULT 0,
1707
+ lane TEXT,
1708
+ exit_code INTEGER,
1709
+ output_json TEXT,
1710
+ error TEXT,
1711
+ started_at TEXT,
1712
+ finished_at TEXT
1713
+ );
1714
+ CREATE TABLE IF NOT EXISTS memos (
1715
+ key TEXT PRIMARY KEY,
1716
+ graph_name TEXT NOT NULL,
1717
+ node_id TEXT NOT NULL,
1718
+ input_hash TEXT NOT NULL,
1719
+ output_json TEXT NOT NULL,
1720
+ hit_count INTEGER NOT NULL DEFAULT 0,
1721
+ created_at TEXT NOT NULL
1722
+ );
1723
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
1724
+ CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
1725
+ CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
1726
+ `);
1727
+ const rowToRun = (r) => ({
1728
+ id: String(r.id),
1729
+ graphName: String(r.graph_name),
1730
+ graphVersion: String(r.graph_version),
1731
+ status: r.status,
1732
+ attempts: Number(r.attempts),
1733
+ contextJson: String(r.context_json),
1734
+ resultJson: r.result_json === null ? null : String(r.result_json),
1735
+ error: r.error === null ? null : String(r.error),
1736
+ startedAt: r.started_at === null ? null : String(r.started_at),
1737
+ finishedAt: r.finished_at === null ? null : String(r.finished_at),
1738
+ createdAt: String(r.created_at),
1739
+ updatedAt: String(r.updated_at)
1740
+ });
1741
+ const rowToNode = (r) => ({
1742
+ id: String(r.id),
1743
+ runId: String(r.run_id),
1744
+ nodeId: String(r.node_id),
1745
+ status: r.status,
1746
+ attempts: Number(r.attempts),
1747
+ lane: r.lane === null ? null : String(r.lane),
1748
+ exitCode: r.exit_code === null ? null : Number(r.exit_code),
1749
+ outputJson: r.output_json === null ? null : String(r.output_json),
1750
+ error: r.error === null ? null : String(r.error),
1751
+ startedAt: r.started_at === null ? null : String(r.started_at),
1752
+ finishedAt: r.finished_at === null ? null : String(r.finished_at)
1753
+ });
1754
+ const rowToMemo = (r) => ({
1755
+ key: String(r.key),
1756
+ graphName: String(r.graph_name),
1757
+ nodeId: String(r.node_id),
1758
+ inputHash: String(r.input_hash),
1759
+ outputJson: String(r.output_json),
1760
+ hitCount: Number(r.hit_count),
1761
+ createdAt: String(r.created_at)
1762
+ });
1763
+ return {
1764
+ close() {
1765
+ db.close();
1766
+ },
1767
+ listTables() {
1768
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
1769
+ return rows.map((r) => r.name);
1770
+ },
1771
+ createRun(input) {
1772
+ const id = randomUUID();
1773
+ const now = nowIso2();
1774
+ db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
1775
+ VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now);
1776
+ return this.getRun(id);
1777
+ },
1778
+ getRun(id) {
1779
+ const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
1780
+ return row ? rowToRun(row) : undefined;
1781
+ },
1782
+ listRuns(opts = {}) {
1783
+ const limit = Math.min(opts.limit ?? 100, 1000);
1784
+ if (opts.status) {
1785
+ const rows2 = db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(opts.status, limit);
1786
+ return rows2.map((r) => rowToRun(r));
1787
+ }
1788
+ const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
1789
+ return rows.map((r) => rowToRun(r));
1790
+ },
1791
+ setRunStatus(id, status, patch = {}) {
1792
+ const now = nowIso2();
1793
+ const existing = this.getRun(id);
1794
+ const resultJson = patch.result !== undefined ? JSON.stringify(patch.result) : existing?.resultJson ?? null;
1795
+ const error = patch.error !== undefined ? patch.error : existing?.error ?? null;
1796
+ const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
1797
+ const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
1798
+ const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
1799
+ 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);
1800
+ },
1801
+ bumpAttempts(id) {
1802
+ db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
1803
+ },
1804
+ setRunContext(id, context) {
1805
+ db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
1806
+ },
1807
+ createRunNode(input) {
1808
+ const id = randomUUID();
1809
+ db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
1810
+ VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null);
1811
+ return this.getRunNode(id);
1812
+ },
1813
+ getRunNode(id) {
1814
+ const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
1815
+ return row ? rowToNode(row) : undefined;
1816
+ },
1817
+ listRunNodes(runId) {
1818
+ const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
1819
+ return rows.map((r) => rowToNode(r));
1820
+ },
1821
+ setRunNodeStatus(id, status, patch = {}) {
1822
+ const now = nowIso2();
1823
+ const existing = this.getRunNode(id);
1824
+ if (!existing)
1825
+ return;
1826
+ const exitCode = patch.exitCode !== undefined ? patch.exitCode : existing.exitCode;
1827
+ const outputJson = patch.output !== undefined ? JSON.stringify(patch.output) : existing.outputJson;
1828
+ const error = patch.error !== undefined ? patch.error : existing.error;
1829
+ const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
1830
+ const terminal = status === "completed" || status === "failed" || status === "skipped";
1831
+ const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
1832
+ 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);
1833
+ },
1834
+ bumpAttemptsNode(id) {
1835
+ db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
1836
+ },
1837
+ memoPut(key, graphName, nodeId, inputHash2, outputJson) {
1838
+ db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
1839
+ VALUES (?, ?, ?, ?, ?, 0, ?)
1840
+ ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2());
1841
+ },
1842
+ memoGet(key) {
1843
+ const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
1844
+ return row ? rowToMemo(row) : undefined;
1845
+ },
1846
+ memoHit(key) {
1847
+ db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
1848
+ },
1849
+ memoList() {
1850
+ const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
1851
+ return rows.map((r) => rowToMemo(r));
1852
+ },
1853
+ memoClear() {
1854
+ db.exec(`DELETE FROM memos`);
1855
+ }
1856
+ };
1857
+ }
1858
+
1859
+ // src/wal.ts
1860
+ import { createHash as createHash3 } from "crypto";
1861
+ import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
1862
+ import { join as join2 } from "path";
1863
+ var WAL_FILE_NAME = "session.wal";
1864
+ var SESSIONS_DIR_NAME = "sessions";
1865
+ var MAX_WAL_BYTES = 256 * 1024 * 1024;
1866
+ function checksumOf(op) {
1867
+ if (op === undefined || op === null)
1868
+ return "";
1869
+ return createHash3("sha256").update(JSON.stringify(op)).digest("hex");
1870
+ }
1871
+
1872
+ class SessionWAL {
1873
+ filePath;
1874
+ constructor(filePath) {
1875
+ this.filePath = filePath;
1876
+ }
1877
+ static open(dataDir) {
1878
+ mkdirSync2(join2(dataDir, SESSIONS_DIR_NAME), { recursive: true });
1879
+ const canonical = join2(dataDir, SESSIONS_DIR_NAME, WAL_FILE_NAME);
1880
+ const legacy = join2(dataDir, WAL_FILE_NAME);
1881
+ if (existsSync(legacy) && !existsSync(canonical)) {
1882
+ try {
1883
+ renameSync(legacy, canonical);
1884
+ } catch {
1885
+ return new SessionWAL(legacy);
1886
+ }
1887
+ }
1888
+ return new SessionWAL(canonical);
1889
+ }
1890
+ static openAt(filePath) {
1891
+ return new SessionWAL(filePath);
1892
+ }
1893
+ append(op) {
1894
+ const at = op.at ?? new Date().toISOString();
1895
+ const full = { ...op, at };
1896
+ const nextSeq = this.nextSeq();
1897
+ const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
1898
+ appendFileSync(this.filePath, JSON.stringify(entry) + `
1899
+ `, "utf8");
1900
+ return entry;
1901
+ }
1902
+ nextSeq() {
1903
+ const replay = this.replay();
1904
+ return replay.entries.length + 1;
1905
+ }
1906
+ replay() {
1907
+ const entries = [];
1908
+ let torn = false;
1909
+ let repaired = false;
1910
+ let content;
1911
+ try {
1912
+ content = readFileSync(this.filePath, "utf8");
1913
+ } catch {
1914
+ return { entries, torn, repaired, liveClaims: () => new Map };
1915
+ }
1916
+ if (content.length > MAX_WAL_BYTES) {
1917
+ throw new Error(`session WAL exceeds ${MAX_WAL_BYTES} bytes; refusing to replay a truncated journal`);
1918
+ }
1919
+ if (content === "") {
1920
+ return { entries, torn, repaired, liveClaims: () => new Map };
1921
+ }
1922
+ const lines = content.split(`
1923
+ `);
1924
+ let badOffset = null;
1925
+ let expectedSeq = 1;
1926
+ for (let i = 0;i < lines.length; i++) {
1927
+ const line = lines[i];
1928
+ if (line === "")
1929
+ continue;
1930
+ if (badOffset !== null)
1931
+ break;
1932
+ const lineOffset = cumulativeOffset(lines, i);
1933
+ let entry;
1934
+ try {
1935
+ entry = JSON.parse(line);
1936
+ } catch {
1937
+ badOffset = lineOffset;
1938
+ break;
1939
+ }
1940
+ if (typeof entry?.seq !== "number" || entry.seq !== expectedSeq || entry.checksum !== checksumOf(entry.op)) {
1941
+ badOffset = lineOffset;
1942
+ break;
1943
+ }
1944
+ entries.push(entry);
1945
+ expectedSeq++;
1946
+ }
1947
+ if (badOffset !== null) {
1948
+ torn = true;
1949
+ try {
1950
+ truncateSync(this.filePath, badOffset);
1951
+ repaired = true;
1952
+ } catch {}
1953
+ }
1954
+ return {
1955
+ entries,
1956
+ torn,
1957
+ repaired,
1958
+ liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs)
1959
+ };
1960
+ }
1961
+ close() {}
1962
+ }
1963
+ function cumulativeOffset(lines, index) {
1964
+ let offset = 0;
1965
+ for (let i = 0;i < index; i++) {
1966
+ offset += Buffer.byteLength(lines[i]) + 1;
1967
+ }
1968
+ return offset;
1969
+ }
1970
+ function reconstructLiveClaims(entries, nowMs = Date.now()) {
1971
+ const claims = new Map;
1972
+ for (const entry of entries) {
1973
+ const op = entry.op;
1974
+ if (op.op === "claim_acquired") {
1975
+ claims.set(op.runId, { worker: op.worker, fencing: op.fencing, expiresAtMs: Date.parse(op.expiresAt) });
1976
+ } else if (op.op === "claim_released") {
1977
+ const existing = claims.get(op.runId);
1978
+ if (existing && existing.fencing === op.fencing) {
1979
+ claims.delete(op.runId);
1980
+ }
1981
+ }
1982
+ }
1983
+ for (const [runId, claim] of claims) {
1984
+ if (claim.expiresAtMs <= nowMs)
1985
+ claims.delete(runId);
1986
+ }
1987
+ return claims;
1988
+ }
1989
+
94
1990
  // src/service.ts
95
1991
  var DEFAULT_PORT = 8790;
96
1992
  var DEFAULT_HOST = "127.0.0.1";
@@ -117,7 +2013,7 @@ function resolveWorkflowsConfig(overrides = {}) {
117
2013
  return {
118
2014
  port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
119
2015
  host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
120
- dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? join(home, ".hasna", "workflows"),
2016
+ dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? join3(home, ".hasna", "workflows"),
121
2017
  apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
122
2018
  apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
123
2019
  };
@@ -149,11 +2045,263 @@ class WorkflowsService {
149
2045
  checks: { version: "ok" }
150
2046
  };
151
2047
  }
2048
+ async triggerRun(graph, context = {}, opts = {}) {
2049
+ const store = openStore(this.config.dataDir);
2050
+ const wal = SessionWAL.open(this.config.dataDir);
2051
+ try {
2052
+ if (opts.idempotencyKey) {
2053
+ const existing = findRunByIdempotencyKey(store, opts.idempotencyKey);
2054
+ if (existing) {
2055
+ return {
2056
+ runId: existing.id,
2057
+ status: existing.status,
2058
+ error: existing.error ?? null,
2059
+ result: existing.resultJson ? JSON.parse(existing.resultJson) : null,
2060
+ reused: true
2061
+ };
2062
+ }
2063
+ }
2064
+ const contextWithKey = opts.idempotencyKey ? {
2065
+ ...context,
2066
+ __wf: {
2067
+ cursor: undefined,
2068
+ loops: {},
2069
+ completedLoops: {},
2070
+ ...context?.__wf ?? {},
2071
+ idempotencyKey: opts.idempotencyKey
2072
+ }
2073
+ } : context;
2074
+ const maxCycles = Math.min(Math.max(opts.maxCycles ?? 500, 1), 2000);
2075
+ const final = await runGraphToCompletion(store, wal, graph, contextWithKey, { maxCycles });
2076
+ return {
2077
+ runId: final.id,
2078
+ status: final.status,
2079
+ error: final.error ?? null,
2080
+ result: final.resultJson ? JSON.parse(final.resultJson) : null,
2081
+ reused: false
2082
+ };
2083
+ } finally {
2084
+ store.close();
2085
+ }
2086
+ }
152
2087
  }
153
2088
  function createWorkflowsService(config = {}) {
154
2089
  return new WorkflowsService(config);
155
2090
  }
156
2091
 
2092
+ // src/render.ts
2093
+ function nodeLabel(node) {
2094
+ switch (node.type) {
2095
+ case "start":
2096
+ return "start";
2097
+ case "end":
2098
+ return "end";
2099
+ case "step": {
2100
+ const work = node.prompt ? `prompt=${JSON.stringify(node.prompt.slice(0, 40))}` : `command=${JSON.stringify((node.command ?? "").slice(0, 60))}`;
2101
+ return `step ${work}${node.lane ? ` lane=${node.lane}` : ""}`;
2102
+ }
2103
+ case "decision":
2104
+ return `decision if ${node.condition}`;
2105
+ case "while":
2106
+ return `while ${node.condition} maxIterations=${node.maxIterations} body=[${node.body.join(", ")}]`;
2107
+ }
2108
+ }
2109
+ function renderGraphText(graph) {
2110
+ const lines = [];
2111
+ lines.push(`graph ${graph.name}@${graph.version} \u2014 ${graph.nodes.length} node(s)`);
2112
+ for (const node of graph.nodes) {
2113
+ switch (node.type) {
2114
+ case "start":
2115
+ case "step":
2116
+ lines.push(` ${node.id} [${nodeLabel(node)}]${node.next ? ` -> ${node.next}` : ""}`);
2117
+ break;
2118
+ case "decision":
2119
+ lines.push(` ${node.id} [${nodeLabel(node)}]${node.then ? ` then-> ${node.then}` : ""}${node.else ? ` else-> ${node.else}` : ""}`);
2120
+ break;
2121
+ case "while":
2122
+ lines.push(` ${node.id} [${nodeLabel(node)}]${node.next ? ` -> ${node.next}` : ""}`);
2123
+ break;
2124
+ case "end":
2125
+ lines.push(` ${node.id} [end]`);
2126
+ break;
2127
+ }
2128
+ }
2129
+ return lines.join(`
2130
+ `);
2131
+ }
2132
+ function renderGraphDot(graph) {
2133
+ const lines = [];
2134
+ lines.push(`digraph "${graph.name}" {`);
2135
+ lines.push(` graph [label="${graph.name}@${graph.version}"];`);
2136
+ for (const node of graph.nodes) {
2137
+ const shape = node.type === "decision" ? "diamond" : node.type === "while" ? "doubleoctagon" : node.type === "end" ? "doublecircle" : "box";
2138
+ lines.push(` "${node.id}" [shape=${shape}, label="${node.id}"];`);
2139
+ }
2140
+ for (const edge of collectEdges(graph)) {
2141
+ lines.push(` "${edge.from}" -> "${edge.to}";`);
2142
+ }
2143
+ for (const node of graph.nodes) {
2144
+ if (node.type === "while") {
2145
+ for (const bodyId of node.body) {
2146
+ lines.push(` "${node.id}" -> "${bodyId}" [style=dashed, label="body"];`);
2147
+ }
2148
+ }
2149
+ }
2150
+ lines.push("}");
2151
+ return lines.join(`
2152
+ `);
2153
+ }
2154
+ function renderGraphJson(graph) {
2155
+ const nodes = graph.nodes.map((node) => ({
2156
+ id: node.id,
2157
+ type: node.type,
2158
+ ...node.type === "step" ? { lane: node.lane ?? "claude", hasPrompt: node.prompt !== undefined, hasCommand: node.command !== undefined } : {},
2159
+ ...node.type === "decision" ? { condition: node.condition } : {},
2160
+ ...node.type === "while" ? { condition: node.condition, maxIterations: node.maxIterations, body: node.body } : {}
2161
+ }));
2162
+ return {
2163
+ name: graph.name,
2164
+ version: graph.version,
2165
+ nodes,
2166
+ edges: collectEdges(graph),
2167
+ whileBodies: graph.nodes.filter((n) => n.type === "while").map((n) => ({ id: n.id, body: n.body }))
2168
+ };
2169
+ }
2170
+
2171
+ // src/cli/index.ts
2172
+ init_lanes();
2173
+ init_types();
2174
+
2175
+ // src/handlers.ts
2176
+ import { createHash as createHash4, timingSafeEqual } from "crypto";
2177
+ function jsonResponse(body, status = 200) {
2178
+ return new Response(JSON.stringify(body), {
2179
+ status,
2180
+ headers: { "content-type": "application/json" }
2181
+ });
2182
+ }
2183
+ function openApiDoc(service) {
2184
+ return {
2185
+ openapi: "3.0.3",
2186
+ info: { title: "workflows", version: service.version },
2187
+ paths: {
2188
+ "/health": { get: { summary: "Service health", responses: { "200": { description: "ok" } } } },
2189
+ "/ready": { get: { summary: "Service readiness", responses: { "200": { description: "ok" } } } },
2190
+ "/version": { get: { summary: "Service version", responses: { "200": { description: "ok" } } } },
2191
+ "/trigger": {
2192
+ post: {
2193
+ summary: "Authenticated trigger: run a workflow graph to a terminal state",
2194
+ security: [{ bearerAuth: [] }],
2195
+ requestBody: {
2196
+ content: {
2197
+ "application/json": {
2198
+ schema: {
2199
+ type: "object",
2200
+ required: ["graph"],
2201
+ properties: {
2202
+ graph: { type: "object", description: "the workflow graph" },
2203
+ context: { type: "object", description: "optional run context" },
2204
+ idempotencyKey: { type: "string", description: "re-run with the same key returns the same run" },
2205
+ maxCycles: { type: "number", description: "bound on reap cycles (default 500, max 2000)" }
2206
+ }
2207
+ }
2208
+ }
2209
+ }
2210
+ },
2211
+ responses: {
2212
+ "200": { description: "run summary" },
2213
+ "401": { description: "missing or invalid Bearer token" },
2214
+ "503": { description: "trigger not configured (WORKFLOWS_API_KEY unset)" }
2215
+ }
2216
+ }
2217
+ }
2218
+ },
2219
+ components: {
2220
+ securitySchemes: {
2221
+ bearerAuth: { type: "http", scheme: "bearer" }
2222
+ }
2223
+ }
2224
+ };
2225
+ }
2226
+ function bearerMatches(authHeader, expected) {
2227
+ if (!authHeader.startsWith("Bearer "))
2228
+ return false;
2229
+ const supplied = authHeader.slice("Bearer ".length);
2230
+ if (supplied.length === 0)
2231
+ return false;
2232
+ const a = createHash4("sha256").update(supplied).digest();
2233
+ const b = createHash4("sha256").update(expected).digest();
2234
+ return timingSafeEqual(a, b);
2235
+ }
2236
+ async function handleTrigger(service, req) {
2237
+ const expected = service.config.apiKey;
2238
+ if (!expected) {
2239
+ return jsonResponse({ error: "trigger_not_configured", message: "WORKFLOWS_API_KEY is not configured \u2014 set it to enable the authenticated trigger" }, 503);
2240
+ }
2241
+ const auth = req.headers.get("authorization") ?? "";
2242
+ if (!bearerMatches(auth, expected)) {
2243
+ return jsonResponse({ error: "unauthorized", message: "a valid Bearer token is required" }, 401);
2244
+ }
2245
+ let body;
2246
+ try {
2247
+ body = await req.json();
2248
+ } catch {
2249
+ return jsonResponse({ error: "invalid_json", message: "request body must be JSON" }, 400);
2250
+ }
2251
+ const record = body;
2252
+ if (record.graph === null || typeof record.graph !== "object" || Array.isArray(record.graph)) {
2253
+ return jsonResponse({ error: "graph_required", message: "body.graph must be a workflow graph object" }, 400);
2254
+ }
2255
+ const context = record.context ?? {};
2256
+ if (typeof context !== "object" || Array.isArray(context)) {
2257
+ return jsonResponse({ error: "invalid_context", message: "body.context must be an object" }, 400);
2258
+ }
2259
+ const maxCycles = typeof record.maxCycles === "number" ? record.maxCycles : undefined;
2260
+ const idempotencyKey = typeof record.idempotencyKey === "string" ? record.idempotencyKey : undefined;
2261
+ try {
2262
+ const summary = await service.triggerRun(record.graph, context, { maxCycles, idempotencyKey });
2263
+ return jsonResponse(summary);
2264
+ } catch (err) {
2265
+ const message = err instanceof Error ? err.message : String(err);
2266
+ return jsonResponse({ error: "trigger_failed", message }, 400);
2267
+ }
2268
+ }
2269
+ function createRequestHandler(service) {
2270
+ return (req) => {
2271
+ const url = new URL(req.url);
2272
+ switch (url.pathname) {
2273
+ case "/health":
2274
+ return jsonResponse(service.health());
2275
+ case "/ready":
2276
+ return jsonResponse(service.ready());
2277
+ case "/version":
2278
+ return jsonResponse({ service: service.name, version: service.version });
2279
+ case "/trigger":
2280
+ return handleTrigger(service, req);
2281
+ case "/openapi.json":
2282
+ return jsonResponse(openApiDoc(service));
2283
+ default:
2284
+ return jsonResponse({ error: "not_found", path: url.pathname }, 404);
2285
+ }
2286
+ };
2287
+ }
2288
+
2289
+ // src/serve/server.ts
2290
+ function createWorkflowsServer(service) {
2291
+ const handler = createRequestHandler(service);
2292
+ const server = Bun.serve({
2293
+ hostname: service.config.host,
2294
+ port: service.config.port,
2295
+ fetch: handler
2296
+ });
2297
+ return {
2298
+ port: server.port ?? 0,
2299
+ stop: () => {
2300
+ server.stop();
2301
+ }
2302
+ };
2303
+ }
2304
+
157
2305
  // src/cli/index.ts
158
2306
  var program = new Command;
159
2307
  program.name("workflows").description("Universal graph workflow app \u2014 CLI surface of @hasna/workflows").version(packageVersion());
@@ -180,6 +2328,614 @@ program.command("info").description("Show service configuration (never credentia
180
2328
  apiUrl: service.config.apiUrl ?? null
181
2329
  }, null, 2));
182
2330
  });
2331
+ function loadGraph(file) {
2332
+ let raw;
2333
+ try {
2334
+ raw = readFileSync2(file, "utf8");
2335
+ } catch {
2336
+ throw new Error(`cannot read graph file ${file}`);
2337
+ }
2338
+ let parsed;
2339
+ try {
2340
+ parsed = JSON.parse(raw);
2341
+ } catch {
2342
+ throw new Error(`graph file ${file} is not valid JSON`);
2343
+ }
2344
+ return parsed;
2345
+ }
2346
+ function openLocal() {
2347
+ const config = resolveWorkflowsConfig();
2348
+ mkdirSync3(config.dataDir, { recursive: true });
2349
+ return { store: openStore(config.dataDir), wal: SessionWAL.open(config.dataDir) };
2350
+ }
2351
+ function parseContext(raw) {
2352
+ if (!raw)
2353
+ return {};
2354
+ try {
2355
+ const parsed = JSON.parse(raw);
2356
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2357
+ throw new Error("context must be a JSON object");
2358
+ }
2359
+ return parsed;
2360
+ } catch (err) {
2361
+ throw new Error(`invalid --context JSON: ${err instanceof Error ? err.message : String(err)}`);
2362
+ }
2363
+ }
2364
+ function collectInput(value, previous) {
2365
+ return [...previous, value];
2366
+ }
2367
+ function parseInputs(raw) {
2368
+ const out = {};
2369
+ for (const entry of raw) {
2370
+ const eq = entry.indexOf("=");
2371
+ if (eq <= 0)
2372
+ throw new Error(`--input expects k=v, got ${JSON.stringify(entry)}`);
2373
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
2374
+ }
2375
+ return out;
2376
+ }
2377
+ function mergeRunContext(contextRaw, inputs) {
2378
+ return { ...parseContext(contextRaw), ...parseInputs(inputs) };
2379
+ }
2380
+ function withIdempotencyKey(context, key) {
2381
+ return {
2382
+ ...context,
2383
+ __wf: {
2384
+ cursor: undefined,
2385
+ loops: {},
2386
+ completedLoops: {},
2387
+ ...context.__wf ?? {},
2388
+ idempotencyKey: key
2389
+ }
2390
+ };
2391
+ }
2392
+ function runSummary(row) {
2393
+ return {
2394
+ runId: row.id,
2395
+ status: row.status,
2396
+ error: row.error ?? null,
2397
+ result: row.resultJson ? JSON.parse(row.resultJson) : null
2398
+ };
2399
+ }
2400
+ program.command("init").description("Create the store layout (workflows/, sessions/, workflows.db) and scaffold a sample graph").argument("[file]", "sample graph output path (default <dataDir>/workflows/demo.json)").action((file) => {
2401
+ const config = resolveWorkflowsConfig();
2402
+ mkdirSync3(join4(config.dataDir, "workflows"), { recursive: true });
2403
+ mkdirSync3(join4(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
2404
+ const store = openStore(config.dataDir);
2405
+ store.close();
2406
+ const target = file ?? join4(config.dataDir, "workflows", "demo.json");
2407
+ if (existsSync2(target)) {
2408
+ console.error(`refusing to overwrite existing file ${target}`);
2409
+ process.exit(1);
2410
+ }
2411
+ const sample = {
2412
+ name: "demo",
2413
+ version: "1.0.0",
2414
+ nodes: [
2415
+ { id: "start", type: "start", next: "w" },
2416
+ { id: "w", type: "while", condition: "steps.check.ok != true", body: ["work", "check"], maxIterations: 5, next: "done" },
2417
+ { id: "work", type: "step", lane: "claude", prompt: "fix the failing check", maxRetries: 1 },
2418
+ { id: "check", type: "step", command: "echo check-ok" },
2419
+ { id: "done", type: "end" }
2420
+ ]
2421
+ };
2422
+ writeFileSync2(target, JSON.stringify(sample, null, 2) + `
2423
+ `, "utf8");
2424
+ console.log(`wrote ${target}`);
2425
+ console.log(`store layout ready at ${config.dataDir} (workflows/, sessions/, workflows.db)`);
2426
+ });
2427
+ program.command("validate").description("Validate a workflow graph file").argument("<file>", "graph JSON file").option("-j, --json", "JSON output").action((file, opts) => {
2428
+ const graph = loadGraph(file);
2429
+ const result = validateGraph(graph);
2430
+ if (opts.json) {
2431
+ console.log(JSON.stringify(result, null, 2));
2432
+ } else if (result.ok) {
2433
+ console.log(`valid: ${graph.name}@${graph.version} (${graph.nodes.length} nodes)`);
2434
+ } else {
2435
+ for (const issue2 of result.issues) {
2436
+ console.error(`${issue2.path}: ${issue2.message}`);
2437
+ }
2438
+ console.error(`invalid: ${result.issues.length} issue(s)`);
2439
+ }
2440
+ if (!result.ok)
2441
+ process.exit(1);
2442
+ });
2443
+ program.command("graph").description("Render a workflow graph (text, DOT, or JSON)").argument("<file>", "graph JSON file").option("--format <format>", "text | dot | json (default text)", "text").option("-j, --json", "JSON output (shorthand for --format json)").action((file, opts) => {
2444
+ const graph = loadGraph(file);
2445
+ const validation = validateGraph(graph);
2446
+ if (!validation.ok) {
2447
+ console.error(`graph is invalid; rendering the structure as-is (${validation.issues.length} issue(s))`);
2448
+ }
2449
+ const format = opts.json ? "json" : opts.format ?? "text";
2450
+ switch (format) {
2451
+ case "text":
2452
+ console.log(renderGraphText(graph));
2453
+ return;
2454
+ case "dot":
2455
+ console.log(renderGraphDot(graph));
2456
+ return;
2457
+ case "json":
2458
+ console.log(JSON.stringify(renderGraphJson(graph), null, 2));
2459
+ return;
2460
+ default:
2461
+ throw new Error(`unknown --format ${format} (expected text | dot | json)`);
2462
+ }
2463
+ });
2464
+ program.command("run").description("Run a workflow graph to a terminal state (bounded)").argument("<file>", "graph JSON file").option("--context <json>", "JSON context object for the run").option("--input <k=v>", "run input key=value (repeatable; merged over --context)", collectInput, []).option("--idempotency-key <key>", "re-run with the same key returns the existing run").option("--max-cycles <n>", "bound on reap cycles", "500").option("-j, --json", "JSON output").action(async (file, opts) => {
2465
+ const graph = loadGraph(file);
2466
+ const validation = validateGraph(graph);
2467
+ if (!validation.ok) {
2468
+ throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
2469
+ }
2470
+ const context = mergeRunContext(opts.context, opts.input ?? []);
2471
+ const { store, wal } = openLocal();
2472
+ try {
2473
+ if (opts.idempotencyKey) {
2474
+ const existing = findRunByIdempotencyKey(store, opts.idempotencyKey);
2475
+ if (existing) {
2476
+ const summary2 = { ...runSummary(existing), reused: true };
2477
+ if (opts.json) {
2478
+ console.log(JSON.stringify(summary2, null, 2));
2479
+ } else {
2480
+ console.log(`reused run ${existing.id}: ${existing.status}`);
2481
+ }
2482
+ if (existing.status !== "completed")
2483
+ process.exit(1);
2484
+ return;
2485
+ }
2486
+ }
2487
+ const contextWithKey = opts.idempotencyKey ? withIdempotencyKey(context, opts.idempotencyKey) : context;
2488
+ const maxCycles = Number(opts.maxCycles ?? 500);
2489
+ const final = await runGraphToCompletion(store, wal, graph, contextWithKey, { maxCycles });
2490
+ const summary = runSummary(final);
2491
+ if (opts.json) {
2492
+ console.log(JSON.stringify(summary, null, 2));
2493
+ } else {
2494
+ console.log(`run ${final.id}: ${final.status}${final.error ? ` \u2014 ${final.error}` : ""}`);
2495
+ if (final.resultJson)
2496
+ console.log(final.resultJson);
2497
+ }
2498
+ if (final.status !== "completed")
2499
+ process.exit(1);
2500
+ } finally {
2501
+ store.close();
2502
+ }
2503
+ });
2504
+ program.command("runs").description("Manage runs").argument("<action>", "list | show | cancel | resume | events").argument("[id]", "run id (show/cancel/resume/events)").option("--status <status>", "filter by status (list)").option("--limit <n>", "max rows (list)", "100").option("-j, --json", "JSON output").action((action, id, opts) => {
2505
+ const { store, wal } = openLocal();
2506
+ try {
2507
+ switch (action) {
2508
+ case "list": {
2509
+ const runs = store.listRuns({ status: opts.status, limit: Number(opts.limit ?? 100) });
2510
+ if (opts.json) {
2511
+ console.log(JSON.stringify(runs, null, 2));
2512
+ } else {
2513
+ for (const run of runs) {
2514
+ console.log(`${run.status.padEnd(11)} ${run.id} ${run.graphName}@${run.graphVersion} attempts=${run.attempts}`);
2515
+ }
2516
+ }
2517
+ return;
2518
+ }
2519
+ case "show": {
2520
+ if (!id)
2521
+ throw new Error("runs show requires a run id");
2522
+ const run = store.getRun(id);
2523
+ if (!run)
2524
+ throw new Error(`no such run ${id}`);
2525
+ console.log(JSON.stringify(run, null, 2));
2526
+ return;
2527
+ }
2528
+ case "cancel": {
2529
+ if (!id)
2530
+ throw new Error("runs cancel requires a run id");
2531
+ const run = store.getRun(id);
2532
+ if (!run)
2533
+ throw new Error(`no such run ${id}`);
2534
+ if (run.status !== "pending" && run.status !== "running") {
2535
+ throw new Error(`run ${id} is ${run.status}; only pending/running runs can be cancelled`);
2536
+ }
2537
+ store.setRunStatus(id, "cancelled", { error: "cancelled by operator" });
2538
+ console.log(`cancelled ${id}`);
2539
+ return;
2540
+ }
2541
+ case "resume": {
2542
+ if (!id)
2543
+ throw new Error("runs resume requires a run id");
2544
+ const run = store.getRun(id);
2545
+ if (!run)
2546
+ throw new Error(`no such run ${id}`);
2547
+ if (run.status !== "cancelled" && run.status !== "failed") {
2548
+ throw new Error(`run ${id} is ${run.status}; only cancelled/failed runs can be resumed`);
2549
+ }
2550
+ resetRunNodes(store, run);
2551
+ store.setRunStatus(id, "pending", { error: undefined });
2552
+ console.log(`resumed ${id} to pending`);
2553
+ return;
2554
+ }
2555
+ case "events": {
2556
+ if (!id)
2557
+ throw new Error("runs events requires a run id");
2558
+ const run = store.getRun(id);
2559
+ if (!run)
2560
+ throw new Error(`no such run ${id}`);
2561
+ const events = wal.replay().entries.filter((entry) => {
2562
+ const op = entry.op;
2563
+ return typeof op.runId === "string" && op.runId === id;
2564
+ }).map((entry) => ({ seq: entry.seq, at: entry.at, op: entry.op }));
2565
+ if (opts.json) {
2566
+ console.log(JSON.stringify(events, null, 2));
2567
+ } else {
2568
+ for (const event of events) {
2569
+ console.log(`${event.seq.toString().padStart(4)} ${event.at} ${JSON.stringify(event.op)}`);
2570
+ }
2571
+ console.log(`${events.length} event(s)`);
2572
+ }
2573
+ return;
2574
+ }
2575
+ default:
2576
+ throw new Error(`unknown runs action ${action} (expected list | show | cancel | resume | events)`);
2577
+ }
2578
+ } finally {
2579
+ store.close();
2580
+ }
2581
+ });
2582
+ program.command("nodes").description("List or show a run's node executions").argument("[action]", "list | show (default list)").argument("[run]", "run id").argument("[node]", "node id (show)").option("--status <status>", "filter by status (list)").option("-j, --json", "JSON output").action((action, run, node, opts) => {
2583
+ if (action !== "list" && action !== "show") {
2584
+ node = run;
2585
+ run = action;
2586
+ action = "list";
2587
+ }
2588
+ if (!run)
2589
+ throw new Error("nodes requires a run id");
2590
+ const { store } = openLocal();
2591
+ try {
2592
+ if (action === "show") {
2593
+ if (!node)
2594
+ throw new Error("nodes show requires a run id and a node id");
2595
+ const row = store.listRunNodes(run).find((n) => n.nodeId === node);
2596
+ if (!row)
2597
+ throw new Error(`run ${run} has no node ${node}`);
2598
+ console.log(JSON.stringify(row, null, 2));
2599
+ return;
2600
+ }
2601
+ const nodes = store.listRunNodes(run).filter((n) => opts.status ? n.status === opts.status : true);
2602
+ if (opts.json) {
2603
+ console.log(JSON.stringify(nodes, null, 2));
2604
+ } else {
2605
+ for (const n of nodes) {
2606
+ console.log(`${n.status.padEnd(10)} ${n.nodeId} attempts=${n.attempts} lane=${n.lane ?? "-"} exit=${n.exitCode ?? "-"}`);
2607
+ }
2608
+ }
2609
+ } finally {
2610
+ store.close();
2611
+ }
2612
+ });
2613
+ program.command("sessions").description("Inspect the session WAL").argument("<action>", "list | pull").option("-j, --json", "JSON output").action((action, opts) => {
2614
+ const { wal } = openLocal();
2615
+ const replay = wal.replay();
2616
+ switch (action) {
2617
+ case "list": {
2618
+ const entries = replay.entries.map((entry) => ({ seq: entry.seq, at: entry.at, op: entry.op }));
2619
+ if (opts.json) {
2620
+ console.log(JSON.stringify(entries, null, 2));
2621
+ } else {
2622
+ for (const entry of entries) {
2623
+ console.log(`${entry.seq.toString().padStart(4)} ${entry.at} ${JSON.stringify(entry.op)}`);
2624
+ }
2625
+ console.log(`${entries.length} WAL entry(ies)`);
2626
+ }
2627
+ return;
2628
+ }
2629
+ case "pull": {
2630
+ const liveClaims = [...replay.liveClaims().entries()].map(([runId, claim]) => ({
2631
+ runId,
2632
+ worker: claim.worker,
2633
+ fencing: claim.fencing,
2634
+ expiresAt: new Date(claim.expiresAtMs).toISOString()
2635
+ }));
2636
+ const report = {
2637
+ entries: replay.entries.length,
2638
+ torn: replay.torn,
2639
+ repaired: replay.repaired,
2640
+ liveClaims,
2641
+ walFile: wal.filePath
2642
+ };
2643
+ if (opts.json) {
2644
+ console.log(JSON.stringify(report, null, 2));
2645
+ } else {
2646
+ console.log(`sessions pull: ${report.entries} WAL entr${report.entries === 1 ? "y" : "ies"}` + ` torn=${report.torn} repaired=${report.repaired} liveClaims=${report.liveClaims.length}`);
2647
+ for (const claim of report.liveClaims) {
2648
+ console.log(` claim ${claim.runId} by ${claim.worker} until ${claim.expiresAt} (fencing ${claim.fencing})`);
2649
+ }
2650
+ }
2651
+ return;
2652
+ }
2653
+ default:
2654
+ throw new Error(`unknown sessions action ${action} (expected list | pull)`);
2655
+ }
2656
+ });
2657
+ program.command("machines").description("List observed machines or report this machine's workflows status").argument("<action>", "list | status").option("-j, --json", "JSON output").action((action, opts) => {
2658
+ const config = resolveWorkflowsConfig();
2659
+ const { store, wal } = openLocal();
2660
+ try {
2661
+ switch (action) {
2662
+ case "list": {
2663
+ const seen = new Map;
2664
+ for (const entry of wal.replay().entries) {
2665
+ const op = entry.op;
2666
+ if (op.op === "claim_acquired") {
2667
+ const prev = seen.get(op.worker);
2668
+ seen.set(op.worker, { lastSeenAt: op.at, claims: (prev?.claims ?? 0) + 1 });
2669
+ }
2670
+ }
2671
+ const local = hostname();
2672
+ const rows = [
2673
+ { name: local, local: true, dataDir: config.dataDir, lastSeenAt: null, claims: 0 },
2674
+ ...[...seen.entries()].map(([name, info]) => ({
2675
+ name,
2676
+ local: name === local,
2677
+ dataDir: null,
2678
+ lastSeenAt: info.lastSeenAt,
2679
+ claims: info.claims
2680
+ }))
2681
+ ];
2682
+ if (opts.json) {
2683
+ console.log(JSON.stringify(rows, null, 2));
2684
+ } else {
2685
+ for (const row of rows) {
2686
+ console.log(`${row.local ? "local " : " "} ${row.name} claims=${row.claims} lastSeen=${row.lastSeenAt ?? "-"}`);
2687
+ }
2688
+ }
2689
+ return;
2690
+ }
2691
+ case "status": {
2692
+ const replay = wal.replay();
2693
+ const pidFile = join4(config.dataDir, "daemon.pid");
2694
+ const pid = existsSync2(pidFile) ? readFileSync2(pidFile, "utf8").trim() : null;
2695
+ const running = pid !== null && processAlive(Number(pid));
2696
+ const report = {
2697
+ hostname: hostname(),
2698
+ pid: process.pid,
2699
+ dataDir: config.dataDir,
2700
+ layout: {
2701
+ workflows: existsSync2(join4(config.dataDir, "workflows")),
2702
+ sessions: existsSync2(join4(config.dataDir, SESSIONS_DIR_NAME)),
2703
+ db: existsSync2(join4(config.dataDir, "workflows.db"))
2704
+ },
2705
+ store: {
2706
+ tables: store.listTables(),
2707
+ runs: store.listRuns({ limit: 1000 }).length,
2708
+ nodes: store.listRunNodes
2709
+ },
2710
+ wal: { entries: replay.entries.length, torn: replay.torn, repaired: replay.repaired },
2711
+ daemon: { running, pid }
2712
+ };
2713
+ if (opts.json) {
2714
+ console.log(JSON.stringify(report, null, 2));
2715
+ } else {
2716
+ console.log(`machine ${report.hostname} (pid ${report.pid})`);
2717
+ console.log(` dataDir ${report.dataDir}`);
2718
+ console.log(` layout workflows=${report.layout.workflows} sessions=${report.layout.sessions} db=${report.layout.db}`);
2719
+ console.log(` store tables=${report.store.tables.join(",")} runs=${report.store.runs}`);
2720
+ console.log(` wal ${report.wal.entries} entr${report.wal.entries === 1 ? "y" : "ies"} torn=${report.wal.torn}`);
2721
+ console.log(` daemon ${report.daemon.running ? `running (pid ${report.daemon.pid})` : "not running"}`);
2722
+ }
2723
+ return;
2724
+ }
2725
+ default:
2726
+ throw new Error(`unknown machines action ${action} (expected list | status)`);
2727
+ }
2728
+ } finally {
2729
+ store.close();
2730
+ }
2731
+ });
2732
+ program.command("lanes").description("List the four lane adapters (wired vs not-ready-with-reason) or probe one").argument("[action]", "list | probe (default list)").argument("[lane]", "lane to probe (claude | codex | cursor | grok)").option("-j, --json", "JSON output").action(async (action, lane, opts) => {
2733
+ if (action === "probe") {
2734
+ const kind = lane;
2735
+ if (!LANE_KINDS2.includes(kind)) {
2736
+ throw new Error(`unknown lane ${lane} (expected one of ${LANE_KINDS2.join(", ")})`);
2737
+ }
2738
+ const probe = await probeLane(kind);
2739
+ if (opts.json) {
2740
+ console.log(JSON.stringify(probe, null, 2));
2741
+ } else if (probe.wired) {
2742
+ console.log(`${probe.kind}: wired (${probe.via})`);
2743
+ } else {
2744
+ console.log(`${probe.kind}: NOT wired \u2014 ${probe.reason}`);
2745
+ process.exitCode = 1;
2746
+ }
2747
+ return;
2748
+ }
2749
+ if (action !== undefined && action !== "list") {
2750
+ throw new Error(`unknown lanes action ${action} (expected list | probe)`);
2751
+ }
2752
+ const inventory = await laneInventory();
2753
+ if (opts.json) {
2754
+ console.log(JSON.stringify(inventory, null, 2));
2755
+ } else {
2756
+ for (const laneProbe of inventory) {
2757
+ if (laneProbe.wired) {
2758
+ console.log(`${laneProbe.kind.padEnd(8)} wired (${laneProbe.via})`);
2759
+ } else {
2760
+ console.log(`${laneProbe.kind.padEnd(8)} NOT wired \u2014 ${laneProbe.reason}`);
2761
+ }
2762
+ }
2763
+ }
2764
+ });
2765
+ program.command("daemon").description("Run or inspect the daemon").argument("<action>", "start | status | stop").option("--once", "run a single reap cycle and exit (start)").option("--interval-ms <n>", "reap interval (start)", "5000").option("--cycles <n>", "bound on consecutive cycles (start; default infinite until SIGINT)", "0").option("-j, --json", "JSON output").action(async (action, opts) => {
2766
+ const config = resolveWorkflowsConfig();
2767
+ const pidFile = join4(config.dataDir, "daemon.pid");
2768
+ const statusFile = join4(config.dataDir, "daemon.status.json");
2769
+ switch (action) {
2770
+ case "start": {
2771
+ const { store, wal } = openLocal();
2772
+ try {
2773
+ if (opts.once) {
2774
+ const daemon2 = new WorkflowsDaemon(store, wal);
2775
+ const report = await daemon2.reap();
2776
+ writeReport(statusFile, report);
2777
+ console.log(JSON.stringify(report, null, 2));
2778
+ return;
2779
+ }
2780
+ mkdirSync3(config.dataDir, { recursive: true });
2781
+ writeFileSync2(pidFile, String(process.pid) + `
2782
+ `, "utf8");
2783
+ const intervalMs = Number(opts.intervalMs ?? 5000);
2784
+ const cycles = Number(opts.cycles ?? 0);
2785
+ const daemon = new WorkflowsDaemon(store, wal);
2786
+ let ran = 0;
2787
+ console.log(`daemon ${process.pid} started (interval ${intervalMs}ms)`);
2788
+ const stop = () => {
2789
+ cleanup();
2790
+ process.exit(0);
2791
+ };
2792
+ process.on("SIGINT", stop);
2793
+ process.on("SIGTERM", stop);
2794
+ const cleanup = () => {
2795
+ try {
2796
+ rmSync(pidFile, { force: true });
2797
+ } catch {}
2798
+ };
2799
+ for (;; ) {
2800
+ const report = await daemon.reap();
2801
+ writeReport(statusFile, report);
2802
+ ran++;
2803
+ if (opts.json)
2804
+ console.log(JSON.stringify(report));
2805
+ if (cycles > 0 && ran >= cycles) {
2806
+ cleanup();
2807
+ process.exit(0);
2808
+ }
2809
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
2810
+ }
2811
+ } finally {
2812
+ store.close();
2813
+ }
2814
+ }
2815
+ case "status": {
2816
+ if (!existsSync2(statusFile)) {
2817
+ if (opts.json) {
2818
+ console.log(JSON.stringify({ running: false, lastReap: null }));
2819
+ } else {
2820
+ console.log("daemon not running (no status record)");
2821
+ }
2822
+ return;
2823
+ }
2824
+ const lastReap = JSON.parse(readFileSync2(statusFile, "utf8"));
2825
+ const pid = existsSync2(pidFile) ? readFileSync2(pidFile, "utf8").trim() : null;
2826
+ const running = pid !== null && processAlive(Number(pid));
2827
+ if (opts.json) {
2828
+ console.log(JSON.stringify({ running, pid, lastReap }, null, 2));
2829
+ } else {
2830
+ console.log(`daemon ${running ? `running (pid ${pid})` : "not running"} \u2014 last reap: ${JSON.stringify(lastReap)}`);
2831
+ }
2832
+ return;
2833
+ }
2834
+ case "stop": {
2835
+ if (!existsSync2(pidFile)) {
2836
+ console.error("no daemon pid file \u2014 nothing to stop");
2837
+ process.exit(1);
2838
+ }
2839
+ const pid = Number(readFileSync2(pidFile, "utf8").trim());
2840
+ try {
2841
+ process.kill(pid, "SIGTERM");
2842
+ } catch {
2843
+ console.error(`cannot signal pid ${pid}`);
2844
+ process.exit(1);
2845
+ }
2846
+ console.log(`stopping daemon ${pid}`);
2847
+ return;
2848
+ }
2849
+ default:
2850
+ throw new Error(`unknown daemon action ${action} (expected start | status | stop)`);
2851
+ }
2852
+ });
2853
+ function processAlive(pid) {
2854
+ try {
2855
+ process.kill(pid, 0);
2856
+ return true;
2857
+ } catch {
2858
+ return false;
2859
+ }
2860
+ }
2861
+ function writeReport(statusFile, report) {
2862
+ mkdirSync3(join4(statusFile, ".."), { recursive: true });
2863
+ writeFileSync2(statusFile, JSON.stringify({ ...report, at: new Date().toISOString() }), "utf8");
2864
+ }
2865
+ program.command("memos").description("Inspect or clear the memoization cache").argument("<action>", "list | clear").option("--yes", "confirm clear").option("-j, --json", "JSON output").action((action, opts) => {
2866
+ const { store } = openLocal();
2867
+ try {
2868
+ switch (action) {
2869
+ case "list": {
2870
+ const memos = store.memoList();
2871
+ if (opts.json) {
2872
+ console.log(JSON.stringify(memos, null, 2));
2873
+ } else {
2874
+ for (const memo of memos) {
2875
+ console.log(`${memo.key} hits=${memo.hitCount}`);
2876
+ }
2877
+ console.log(`${memos.length} memo(s)`);
2878
+ }
2879
+ return;
2880
+ }
2881
+ case "clear": {
2882
+ if (!opts.yes) {
2883
+ console.error("refusing without --yes (memos clear is destructive)");
2884
+ process.exit(1);
2885
+ }
2886
+ store.memoClear();
2887
+ console.log("memo cache cleared");
2888
+ return;
2889
+ }
2890
+ default:
2891
+ throw new Error(`unknown memos action ${action} (expected list | clear)`);
2892
+ }
2893
+ } finally {
2894
+ store.close();
2895
+ }
2896
+ });
2897
+ program.command("resume").description("Restore an INTERRUPTED run from its durable cursor, reusing memoized node outputs").argument("<run-id>", "run id").option("-j, --json", "JSON output").action((runId, opts) => {
2898
+ const { store } = openLocal();
2899
+ try {
2900
+ const restored = restoreInterruptedRun(store, runId);
2901
+ if (opts.json) {
2902
+ console.log(JSON.stringify(restored, null, 2));
2903
+ } else {
2904
+ console.log(`restored ${restored.runId} to ${restored.status} (nodesRestored=${restored.nodesRestored}, memoizedNodes=${restored.memoizedNodes}, attempts=${restored.attempts})`);
2905
+ }
2906
+ } finally {
2907
+ store.close();
2908
+ }
2909
+ });
2910
+ program.command("serve").description("Start the HTTP server surface (same as the workflows-serve bin)").option("--port <n>", "bind port (default from config)").option("--host <h>", "bind host (default 127.0.0.1)").action(async (opts) => {
2911
+ const service = createWorkflowsService({
2912
+ port: opts.port !== undefined ? Number(opts.port) : undefined,
2913
+ host: opts.host
2914
+ });
2915
+ const server = createWorkflowsServer(service);
2916
+ console.log(`workflows-serve listening on http://${service.config.host}:${server.port}`);
2917
+ await new Promise((resolve) => {
2918
+ const stop = () => {
2919
+ server.stop();
2920
+ resolve();
2921
+ };
2922
+ process.on("SIGINT", stop);
2923
+ process.on("SIGTERM", stop);
2924
+ });
2925
+ });
2926
+ program.command("repair").description("Repair torn runs (running runs whose claim is gone)").option("--max-attempts <n>", "retry budget before failing a torn run", "3").option("-j, --json", "JSON output").action((opts) => {
2927
+ const { store, wal } = openLocal();
2928
+ try {
2929
+ const report = repairTornRuns(store, wal, { maxAttempts: Number(opts.maxAttempts ?? 3) });
2930
+ if (opts.json) {
2931
+ console.log(JSON.stringify(report, null, 2));
2932
+ } else {
2933
+ console.log(`interrupted=${report.interrupted} requeued=${report.requeued} failed=${report.failed}`);
2934
+ }
2935
+ } finally {
2936
+ store.close();
2937
+ }
2938
+ });
183
2939
  program.parseAsync(process.argv).catch((err) => {
184
2940
  console.error(String(err instanceof Error ? err.message : err));
185
2941
  process.exit(1);