@hasna/workflows 0.1.0 → 0.1.2

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/index.js CHANGED
@@ -1,11 +1,452 @@
1
1
  // @bun
2
+ var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true,
12
+ configurable: true,
13
+ set: __exportSetter.bind(all, name)
14
+ });
15
+ };
16
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
+ var __require = import.meta.require;
18
+
19
+ // src/lanes/types.ts
20
+ import { statSync } from "fs";
21
+ function binaryOnPath(binary) {
22
+ const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
23
+ for (const entry of pathEntries) {
24
+ try {
25
+ const stat = statSync(`${entry}/${binary}`);
26
+ if (stat.isFile())
27
+ return true;
28
+ } catch {}
29
+ }
30
+ return false;
31
+ }
32
+ function withTimeout(promise, timeoutMs, what) {
33
+ return new Promise((resolve, reject) => {
34
+ const timer = setTimeout(() => {
35
+ reject(new Error(`${what} exceeded the ${timeoutMs}ms budget`));
36
+ }, timeoutMs);
37
+ promise.then((value) => {
38
+ clearTimeout(timer);
39
+ resolve(value);
40
+ }, (err) => {
41
+ clearTimeout(timer);
42
+ reject(err);
43
+ });
44
+ });
45
+ }
46
+ function spawnCli(cmd, opts) {
47
+ const started = Date.now();
48
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
49
+ let proc;
50
+ try {
51
+ proc = Bun.spawnSync({
52
+ cmd,
53
+ cwd: opts.cwd,
54
+ env: { ...process.env, ...opts.env ?? {} },
55
+ stdout: "pipe",
56
+ stderr: "pipe",
57
+ timeout: timeoutMs
58
+ });
59
+ } catch (err) {
60
+ const message = String(err instanceof Error ? err.message : err);
61
+ if (message.includes("ENOENT") || /not found/i.test(message)) {
62
+ throw new LaneDependencyMissingError(`lane binary ${JSON.stringify(opts.binaryName)} not found on PATH; install it or provide ${opts.packageHint}`);
63
+ }
64
+ return { ok: false, exitCode: 1, output: "", durationMs: Date.now() - started, error: message };
65
+ }
66
+ const durationMs = Date.now() - started;
67
+ const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
68
+ const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
69
+ const output = stderr ? `${stdout}
70
+ ${stderr}`.trim() : stdout.trim();
71
+ const exitCode = proc.exitCode ?? 1;
72
+ return { ok: exitCode === 0, exitCode, output, durationMs };
73
+ }
74
+ var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000;
75
+ var init_types = __esm(() => {
76
+ LANE_KINDS2 = ["claude", "codex", "cursor", "grok"];
77
+ LaneDependencyMissingError = class LaneDependencyMissingError extends Error {
78
+ constructor(message) {
79
+ super(message);
80
+ this.name = "LaneDependencyMissingError";
81
+ }
82
+ };
83
+ LaneAdapterShapeError = class LaneAdapterShapeError extends Error {
84
+ constructor(message) {
85
+ super(message);
86
+ this.name = "LaneAdapterShapeError";
87
+ }
88
+ };
89
+ });
90
+
91
+ // src/lanes/claude.ts
92
+ function createClaudeAdapter(deps = {}) {
93
+ return {
94
+ kind: "claude",
95
+ async probe() {
96
+ const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
97
+ try {
98
+ await loader();
99
+ return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "sdk" };
100
+ } catch {
101
+ if (binaryOnPath(deps.cliPath ?? "claude")) {
102
+ return { kind: "claude", sdk: "@anthropic-ai/claude-agent-sdk", wired: true, via: "cli" };
103
+ }
104
+ return {
105
+ kind: "claude",
106
+ sdk: "@anthropic-ai/claude-agent-sdk",
107
+ wired: false,
108
+ reason: "neither @anthropic-ai/claude-agent-sdk nor the claude CLI is available on this machine"
109
+ };
110
+ }
111
+ },
112
+ async run(job) {
113
+ if (!job.prompt) {
114
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "claude lane requires a prompt" };
115
+ }
116
+ const started = Date.now();
117
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
118
+ const loader = deps.sdkLoader ?? (async () => await import("@anthropic-ai/claude-agent-sdk"));
119
+ let sdk;
120
+ try {
121
+ sdk = await loader();
122
+ } catch (err) {
123
+ return runClaudeCli(job, deps.cliPath ?? "claude", started, timeoutMs);
124
+ }
125
+ const query = sdk.query;
126
+ if (typeof query !== "function") {
127
+ throw new LaneAdapterShapeError("@anthropic-ai/claude-agent-sdk exports no query() function");
128
+ }
129
+ const prompt = job.prompt;
130
+ try {
131
+ const { output, isError } = await withTimeout((async () => {
132
+ const generator = await query({
133
+ prompt,
134
+ options: { model: job.model, maxTurns: 32, abortSignal: undefined }
135
+ });
136
+ let output2 = "";
137
+ let isError2 = false;
138
+ for await (const message of generator) {
139
+ if (message.type === "assistant") {
140
+ const content = message.message?.content;
141
+ if (Array.isArray(content)) {
142
+ for (const block of content) {
143
+ if (block && typeof block === "object" && "text" in block && typeof block.text === "string") {
144
+ output2 += block.text;
145
+ }
146
+ }
147
+ }
148
+ }
149
+ if (message.type === "error")
150
+ isError2 = true;
151
+ }
152
+ return { output: output2, isError: isError2 };
153
+ })(), timeoutMs, `claude lane (${job.prompt.slice(0, 60)})`);
154
+ const durationMs = Date.now() - started;
155
+ return { ok: !isError, exitCode: isError ? 1 : 0, output: output.trim(), durationMs };
156
+ } catch (err) {
157
+ const durationMs = Date.now() - started;
158
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
159
+ }
160
+ }
161
+ };
162
+ }
163
+ function runClaudeCli(job, cliPath, started, timeoutMs) {
164
+ try {
165
+ const result = spawnCli([cliPath, "-p", job.prompt], {
166
+ binaryName: cliPath,
167
+ packageHint: "@anthropic-ai/claude-agent-sdk or the claude CLI",
168
+ timeoutMs,
169
+ env: job.env,
170
+ cwd: job.cwd
171
+ });
172
+ return { ...result, durationMs: Date.now() - started };
173
+ } catch (err) {
174
+ if (err instanceof LaneDependencyMissingError) {
175
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+ var init_claude = __esm(() => {
181
+ init_types();
182
+ });
183
+
184
+ // src/lanes/codex.ts
185
+ function createCodexAdapter(deps = {}) {
186
+ return {
187
+ kind: "codex",
188
+ async probe() {
189
+ const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
190
+ try {
191
+ await loader();
192
+ return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "sdk" };
193
+ } catch {
194
+ if (binaryOnPath(deps.cliPath ?? "codex")) {
195
+ return { kind: "codex", sdk: "@openai/codex-sdk", wired: true, via: "cli" };
196
+ }
197
+ return {
198
+ kind: "codex",
199
+ sdk: "@openai/codex-sdk",
200
+ wired: false,
201
+ reason: "neither @openai/codex-sdk nor the codex CLI is available on this machine"
202
+ };
203
+ }
204
+ },
205
+ async run(job) {
206
+ if (!job.prompt) {
207
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
208
+ }
209
+ const started = Date.now();
210
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
211
+ const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
212
+ let sdk;
213
+ try {
214
+ sdk = await loader();
215
+ } catch {
216
+ return runCodexCli(job, deps.cliPath ?? "codex", started, timeoutMs);
217
+ }
218
+ const CodexClass = sdk.Codex;
219
+ if (typeof CodexClass !== "function") {
220
+ throw new LaneAdapterShapeError("@openai/codex-sdk exports no Codex class");
221
+ }
222
+ try {
223
+ const client = await withTimeout(Promise.resolve(new CodexClass({
224
+ config: { model: job.model },
225
+ env: job.env
226
+ })), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
227
+ const thread = client.startThread?.();
228
+ if (!thread || typeof thread.run !== "function") {
229
+ throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
230
+ }
231
+ const turn = await withTimeout(thread.run(job.prompt), timeoutMs, `codex lane (${job.prompt.slice(0, 60)})`);
232
+ const output = turn?.finalResponse ?? "";
233
+ const durationMs = Date.now() - started;
234
+ return { ok: true, exitCode: 0, output, durationMs };
235
+ } catch (err) {
236
+ const durationMs = Date.now() - started;
237
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
238
+ }
239
+ }
240
+ };
241
+ }
242
+ function runCodexCli(job, cliPath, started, timeoutMs) {
243
+ try {
244
+ const result = spawnCli([cliPath, "exec", "--", job.prompt], {
245
+ binaryName: cliPath,
246
+ packageHint: "@openai/codex-sdk or the codex CLI",
247
+ timeoutMs,
248
+ env: job.env,
249
+ cwd: job.cwd
250
+ });
251
+ return { ...result, durationMs: Date.now() - started };
252
+ } catch (err) {
253
+ if (err instanceof LaneDependencyMissingError) {
254
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
255
+ }
256
+ throw err;
257
+ }
258
+ }
259
+ var init_codex = __esm(() => {
260
+ init_types();
261
+ });
262
+
263
+ // src/lanes/cursor.ts
264
+ function createCursorAdapter(deps = {}) {
265
+ return {
266
+ kind: "cursor",
267
+ async probe() {
268
+ const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
269
+ try {
270
+ await loader();
271
+ return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "sdk (local mode)" };
272
+ } catch {
273
+ if (binaryOnPath(deps.cliPath ?? "cursor-agent")) {
274
+ return { kind: "cursor", sdk: "@cursor/sdk", wired: true, via: "cli" };
275
+ }
276
+ return {
277
+ kind: "cursor",
278
+ sdk: "@cursor/sdk",
279
+ wired: false,
280
+ reason: "neither @cursor/sdk nor the cursor-agent CLI is available on this machine"
281
+ };
282
+ }
283
+ },
284
+ async run(job) {
285
+ if (!job.prompt) {
286
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "cursor lane requires a prompt" };
287
+ }
288
+ const started = Date.now();
289
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
290
+ const loader = deps.sdkLoader ?? (async () => await import("@cursor/sdk"));
291
+ let sdk;
292
+ try {
293
+ sdk = await loader();
294
+ } catch {
295
+ return runCursorCli(job, deps.cliPath ?? "cursor-agent", started, timeoutMs);
296
+ }
297
+ try {
298
+ const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
299
+ const durationMs = Date.now() - started;
300
+ const status = run?.status ?? "finished";
301
+ const ok = status === "finished";
302
+ return { ok, exitCode: ok ? 0 : 1, output: run?.result ?? "", durationMs };
303
+ } catch (err) {
304
+ const durationMs = Date.now() - started;
305
+ return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
306
+ }
307
+ }
308
+ };
309
+ }
310
+ async function runLocalAgent(sdk, job) {
311
+ const AgentClass = sdk.Agent;
312
+ const CursorClass = sdk.Cursor;
313
+ if (typeof AgentClass !== "function" && typeof CursorClass !== "function") {
314
+ throw new LaneAdapterShapeError("@cursor/sdk exports no Agent/Cursor class");
315
+ }
316
+ const agent = typeof AgentClass === "function" ? new AgentClass : undefined;
317
+ if (!agent || typeof agent.send !== "function") {
318
+ throw new LaneAdapterShapeError("@cursor/sdk Agent has no send()");
319
+ }
320
+ const run = await agent.send(job.prompt, { model: job.model });
321
+ if (!run || typeof run !== "object") {
322
+ throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
323
+ }
324
+ return run;
325
+ }
326
+ function runCursorCli(job, cliPath, started, timeoutMs) {
327
+ try {
328
+ const result = spawnCli([cliPath, "run", job.prompt], {
329
+ binaryName: cliPath,
330
+ packageHint: "@cursor/sdk (local mode) or the cursor-agent CLI",
331
+ timeoutMs,
332
+ env: job.env,
333
+ cwd: job.cwd
334
+ });
335
+ return { ...result, durationMs: Date.now() - started };
336
+ } catch (err) {
337
+ if (err instanceof LaneDependencyMissingError) {
338
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
339
+ }
340
+ throw err;
341
+ }
342
+ }
343
+ var init_cursor = __esm(() => {
344
+ init_types();
345
+ });
346
+
347
+ // src/lanes/grok.ts
348
+ function createGrokAdapter(deps = {}) {
349
+ return {
350
+ kind: "grok",
351
+ async probe() {
352
+ const cliPath = deps.cliPath ?? "grok";
353
+ if (binaryOnPath(cliPath)) {
354
+ return { kind: "grok", sdk: "no npm SDK (measured 2026-08-25)", wired: true, via: "cli" };
355
+ }
356
+ return {
357
+ kind: "grok",
358
+ sdk: "no npm SDK (measured 2026-08-25)",
359
+ wired: false,
360
+ reason: `no xAI Grok SDK exists on npm (measured 2026-08-25) and the grok CLI is not on PATH`
361
+ };
362
+ },
363
+ async run(job) {
364
+ if (!job.prompt) {
365
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "grok lane requires a prompt" };
366
+ }
367
+ const started = Date.now();
368
+ const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
369
+ const cliPath = deps.cliPath ?? "grok";
370
+ try {
371
+ const result = spawnCli([cliPath, job.prompt], {
372
+ binaryName: cliPath,
373
+ packageHint: "the grok CLI (no xAI Grok SDK exists on npm \u2014 measured 2026-08-25)",
374
+ timeoutMs,
375
+ env: job.env,
376
+ cwd: job.cwd
377
+ });
378
+ return { ...result, durationMs: Date.now() - started };
379
+ } catch (err) {
380
+ if (err instanceof LaneDependencyMissingError) {
381
+ return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
382
+ }
383
+ throw err;
384
+ }
385
+ }
386
+ };
387
+ }
388
+ var init_grok = __esm(() => {
389
+ init_types();
390
+ });
391
+
392
+ // src/lanes/index.ts
393
+ var exports_lanes = {};
394
+ __export(exports_lanes, {
395
+ runLaneJob: () => runLaneJob,
396
+ resolveLane: () => resolveLane,
397
+ probeLane: () => probeLane,
398
+ laneInventory: () => laneInventory,
399
+ createGrokAdapter: () => createGrokAdapter,
400
+ createCursorAdapter: () => createCursorAdapter,
401
+ createCodexAdapter: () => createCodexAdapter,
402
+ createClaudeAdapter: () => createClaudeAdapter,
403
+ binaryOnPath: () => binaryOnPath,
404
+ LaneDependencyMissingError: () => LaneDependencyMissingError,
405
+ LaneAdapterShapeError: () => LaneAdapterShapeError,
406
+ LANE_KINDS: () => LANE_KINDS2
407
+ });
408
+ function resolveLane(kind) {
409
+ const adapter = adapters.get(kind);
410
+ if (!adapter)
411
+ throw new Error(`unknown lane ${kind} (expected one of ${LANE_KINDS2.join(", ")})`);
412
+ return adapter;
413
+ }
414
+ async function runLaneJob(job) {
415
+ return resolveLane(job.lane).run(job);
416
+ }
417
+ async function probeLane(kind) {
418
+ return resolveLane(kind).probe();
419
+ }
420
+ async function laneInventory() {
421
+ return Promise.all(LANE_KINDS2.map((kind) => probeLane(kind)));
422
+ }
423
+ var adapters;
424
+ var init_lanes = __esm(() => {
425
+ init_claude();
426
+ init_codex();
427
+ init_cursor();
428
+ init_grok();
429
+ init_types();
430
+ init_types();
431
+ init_claude();
432
+ init_codex();
433
+ init_cursor();
434
+ init_grok();
435
+ adapters = new Map([
436
+ ["claude", createClaudeAdapter()],
437
+ ["codex", createCodexAdapter()],
438
+ ["cursor", createCursorAdapter()],
439
+ ["grok", createGrokAdapter()]
440
+ ]);
441
+ });
442
+
2
443
  // src/service.ts
3
- import { join } from "path";
444
+ import { join as join3 } from "path";
4
445
  // package.json
5
446
  var package_default = {
6
447
  name: "@hasna/workflows",
7
- version: "0.1.0",
8
- 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",
448
+ version: "0.1.2",
449
+ 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",
9
450
  type: "module",
10
451
  main: "./dist/index.js",
11
452
  types: "./types/index.d.ts",
@@ -69,16 +510,18 @@ var package_default = {
69
510
  url: "https://github.com/hasna/apps/issues"
70
511
  },
71
512
  engines: {
72
- bun: ">=1.0.0",
73
- node: ">=20.0.0"
513
+ bun: ">=1.0.0"
74
514
  },
75
515
  author: "Hasna",
76
516
  license: "Apache-2.0",
77
517
  dependencies: {
518
+ "@anthropic-ai/claude-agent-sdk": "^0.3.234",
519
+ "@cursor/sdk": "^1.0.28",
520
+ "@openai/codex-sdk": "^0.147.0",
78
521
  commander: "^13.1.0"
79
522
  },
80
523
  devDependencies: {
81
- "@hasna/contracts": "0.14.0",
524
+ "@hasna/contracts": "0.14.1",
82
525
  "@types/bun": "latest",
83
526
  "@types/pg": "^8.15.6",
84
527
  pg: "^8.16.3",
@@ -86,6 +529,1428 @@ var package_default = {
86
529
  }
87
530
  };
88
531
 
532
+ // src/daemon.ts
533
+ import { createHash as createHash2 } from "crypto";
534
+
535
+ // src/expr.ts
536
+ class ExprSyntaxError extends Error {
537
+ constructor(message) {
538
+ super(message);
539
+ this.name = "ExprSyntaxError";
540
+ }
541
+ }
542
+
543
+ class ExprEvalError extends Error {
544
+ constructor(message) {
545
+ super(message);
546
+ this.name = "ExprEvalError";
547
+ }
548
+ }
549
+ var COMPARISON_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
550
+ function tokenize(input) {
551
+ const tokens = [];
552
+ let i = 0;
553
+ while (i < input.length) {
554
+ const ch = input[i];
555
+ if (ch === " " || ch === "\t" || ch === `
556
+ ` || ch === "\r") {
557
+ i++;
558
+ continue;
559
+ }
560
+ if (ch === "(") {
561
+ tokens.push({ kind: "lparen", value: "(" });
562
+ i++;
563
+ continue;
564
+ }
565
+ if (ch === ")") {
566
+ tokens.push({ kind: "rparen", value: ")" });
567
+ i++;
568
+ continue;
569
+ }
570
+ if (ch === '"' || ch === "'") {
571
+ const quote = ch;
572
+ let j = i + 1;
573
+ let out = "";
574
+ let closed = false;
575
+ while (j < input.length) {
576
+ if (input[j] === quote) {
577
+ closed = true;
578
+ break;
579
+ }
580
+ out += input[j];
581
+ j++;
582
+ }
583
+ if (!closed)
584
+ throw new ExprSyntaxError(`unterminated string literal at offset ${i}`);
585
+ tokens.push({ kind: "string", value: out });
586
+ i = j + 1;
587
+ continue;
588
+ }
589
+ const two = input.slice(i, i + 2);
590
+ if (COMPARISON_OPS.has(two)) {
591
+ tokens.push({ kind: "op", value: two });
592
+ i += 2;
593
+ continue;
594
+ }
595
+ if (ch === "<" || ch === ">" || ch === "=" || ch === "!") {
596
+ tokens.push({ kind: "op", value: ch });
597
+ i++;
598
+ continue;
599
+ }
600
+ if (/[0-9]/.test(ch)) {
601
+ let j = i;
602
+ while (j < input.length && /[0-9.]/.test(input[j]))
603
+ j++;
604
+ tokens.push({ kind: "number", value: input.slice(i, j) });
605
+ i = j;
606
+ continue;
607
+ }
608
+ if (/[A-Za-z_]/.test(ch)) {
609
+ let j = i;
610
+ while (j < input.length && /[A-Za-z0-9_.]/.test(input[j]))
611
+ j++;
612
+ tokens.push({ kind: "ident", value: input.slice(i, j) });
613
+ i = j;
614
+ continue;
615
+ }
616
+ throw new ExprSyntaxError(`unexpected character ${JSON.stringify(ch)} at offset ${i}`);
617
+ }
618
+ return tokens;
619
+ }
620
+
621
+ class Parser {
622
+ tokens;
623
+ pos = 0;
624
+ constructor(tokens) {
625
+ this.tokens = tokens;
626
+ }
627
+ peek() {
628
+ return this.tokens[this.pos];
629
+ }
630
+ next() {
631
+ return this.tokens[this.pos++];
632
+ }
633
+ expectOp(value) {
634
+ const t = this.next();
635
+ if (!t || t.kind !== "op" || t.value !== value) {
636
+ throw new ExprSyntaxError(`expected ${JSON.stringify(value)}`);
637
+ }
638
+ }
639
+ parse() {
640
+ if (this.tokens.length === 0)
641
+ throw new ExprSyntaxError("empty expression");
642
+ const node = this.parseOr();
643
+ if (this.pos < this.tokens.length) {
644
+ throw new ExprSyntaxError(`unexpected trailing token ${JSON.stringify(this.peek().value)}`);
645
+ }
646
+ return node;
647
+ }
648
+ parseOr() {
649
+ let left = this.parseAnd();
650
+ while (this.peek()?.kind === "ident" && this.peek().value === "or") {
651
+ this.next();
652
+ const right = this.parseAnd();
653
+ left = { kind: "or", left, right };
654
+ }
655
+ return left;
656
+ }
657
+ parseAnd() {
658
+ let left = this.parseNot();
659
+ while (this.peek()?.kind === "ident" && this.peek().value === "and") {
660
+ this.next();
661
+ const right = this.parseNot();
662
+ left = { kind: "and", left, right };
663
+ }
664
+ return left;
665
+ }
666
+ parseNot() {
667
+ if (this.peek()?.kind === "ident" && this.peek().value === "not") {
668
+ this.next();
669
+ return { kind: "not", operand: this.parseNot() };
670
+ }
671
+ return this.parseComparison();
672
+ }
673
+ parseComparison() {
674
+ const left = this.parsePrimary();
675
+ const t = this.peek();
676
+ if (t && t.kind === "op" && COMPARISON_OPS.has(t.value)) {
677
+ this.next();
678
+ const right = this.parsePrimary();
679
+ return { kind: "comparison", op: t.value, left, right };
680
+ }
681
+ return left;
682
+ }
683
+ parsePrimary() {
684
+ const t = this.next();
685
+ if (!t)
686
+ throw new ExprSyntaxError("unexpected end of expression");
687
+ if (t.kind === "lparen") {
688
+ const inner = this.parseOr();
689
+ this.expectCloseParen();
690
+ return inner;
691
+ }
692
+ if (t.kind === "number") {
693
+ const value = Number(t.value);
694
+ if (!Number.isFinite(value))
695
+ throw new ExprSyntaxError(`invalid number ${JSON.stringify(t.value)}`);
696
+ return { kind: "literal", value };
697
+ }
698
+ if (t.kind === "string")
699
+ return { kind: "literal", value: t.value };
700
+ if (t.kind === "ident") {
701
+ if (t.value === "true")
702
+ return { kind: "literal", value: true };
703
+ if (t.value === "false")
704
+ return { kind: "literal", value: false };
705
+ return { kind: "path", segments: t.value.split(".") };
706
+ }
707
+ throw new ExprSyntaxError(`unexpected token ${JSON.stringify(t.value)}`);
708
+ }
709
+ expectCloseParen() {
710
+ const t = this.next();
711
+ if (!t || t.kind !== "rparen")
712
+ throw new ExprSyntaxError("expected )");
713
+ }
714
+ }
715
+ function parseExpr(input) {
716
+ return new Parser(tokenize(input)).parse();
717
+ }
718
+ function resolvePath(segments, context) {
719
+ let current = context;
720
+ for (const segment of segments) {
721
+ if (current === null || typeof current !== "object")
722
+ return;
723
+ const record = current;
724
+ if (!(segment in record))
725
+ return;
726
+ current = record[segment];
727
+ }
728
+ if (typeof current === "number" || typeof current === "string" || typeof current === "boolean") {
729
+ return current;
730
+ }
731
+ return;
732
+ }
733
+ function evalNode(node, context) {
734
+ switch (node.kind) {
735
+ case "literal":
736
+ return node.value;
737
+ case "path":
738
+ return resolvePath(node.segments, context);
739
+ case "comparison": {
740
+ const left = evalNode(node.left, context);
741
+ const right = evalNode(node.right, context);
742
+ if (left === undefined || right === undefined) {
743
+ if (node.op === "==")
744
+ return left === undefined && right === undefined;
745
+ if (node.op === "!=")
746
+ return left !== right;
747
+ throw new ExprEvalError("cannot order-compare an unknown path");
748
+ }
749
+ if (typeof left !== typeof right) {
750
+ throw new ExprEvalError(`cannot compare ${JSON.stringify(left)} (${typeof left}) with ${JSON.stringify(right)} (${typeof right})`);
751
+ }
752
+ switch (node.op) {
753
+ case "==":
754
+ return left === right;
755
+ case "!=":
756
+ return left !== right;
757
+ case "<":
758
+ return left < right;
759
+ case "<=":
760
+ return left <= right;
761
+ case ">":
762
+ return left > right;
763
+ case ">=":
764
+ return left >= right;
765
+ default:
766
+ throw new ExprEvalError(`unknown operator ${node.op}`);
767
+ }
768
+ }
769
+ case "and": {
770
+ const left = evalNode(node.left, context);
771
+ const right = evalNode(node.right, context);
772
+ if (typeof left !== "boolean" || typeof right !== "boolean") {
773
+ throw new ExprEvalError("and requires boolean operands");
774
+ }
775
+ return left && right;
776
+ }
777
+ case "or": {
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("or requires boolean operands");
782
+ }
783
+ return left || right;
784
+ }
785
+ case "not": {
786
+ const operand = evalNode(node.operand, context);
787
+ if (typeof operand !== "boolean")
788
+ throw new ExprEvalError("not requires a boolean operand");
789
+ return !operand;
790
+ }
791
+ }
792
+ }
793
+ function evaluateExpr(input, context) {
794
+ const ast = parseExpr(input);
795
+ const value = evalNode(ast, context);
796
+ if (typeof value !== "boolean") {
797
+ throw new ExprEvalError(`condition must evaluate to a boolean, got ${typeof value}`);
798
+ }
799
+ return value;
800
+ }
801
+
802
+ // src/graph.ts
803
+ var GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
804
+ var LANE_KINDS = ["claude", "codex", "cursor", "grok"];
805
+ var NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
806
+ function issue(issues, path, message) {
807
+ issues.push({ path, message });
808
+ }
809
+ function findNode(graph, id) {
810
+ return graph.nodes.find((n) => n.id === id);
811
+ }
812
+ function collectEdges(graph) {
813
+ const edges = [];
814
+ for (const node of graph.nodes) {
815
+ switch (node.type) {
816
+ case "start":
817
+ case "step":
818
+ if (node.next)
819
+ edges.push({ from: node.id, to: node.next });
820
+ break;
821
+ case "decision":
822
+ if (node.then)
823
+ edges.push({ from: node.id, to: node.then });
824
+ if (node.else)
825
+ edges.push({ from: node.id, to: node.else });
826
+ break;
827
+ case "while":
828
+ if (node.next)
829
+ edges.push({ from: node.id, to: node.next });
830
+ break;
831
+ case "end":
832
+ break;
833
+ }
834
+ }
835
+ return edges;
836
+ }
837
+ function reachableNodes(graph) {
838
+ const start = graph.nodes.find((n) => n.type === "start");
839
+ if (!start)
840
+ return [];
841
+ const seen = new Set;
842
+ const stack = [start.id];
843
+ while (stack.length > 0) {
844
+ const id = stack.pop();
845
+ if (seen.has(id))
846
+ continue;
847
+ seen.add(id);
848
+ const node = findNode(graph, id);
849
+ if (!node)
850
+ continue;
851
+ if (node.type === "start" || node.type === "step") {
852
+ if (node.next)
853
+ stack.push(node.next);
854
+ } else if (node.type === "decision") {
855
+ if (node.then)
856
+ stack.push(node.then);
857
+ if (node.else)
858
+ stack.push(node.else);
859
+ } else if (node.type === "while") {
860
+ for (const bodyId of node.body)
861
+ stack.push(bodyId);
862
+ if (node.next)
863
+ stack.push(node.next);
864
+ }
865
+ }
866
+ return [...seen];
867
+ }
868
+ function findCycles(graph) {
869
+ const edges = collectEdges(graph);
870
+ const adj = new Map;
871
+ for (const e of edges) {
872
+ const list = adj.get(e.from) ?? [];
873
+ list.push(e.to);
874
+ adj.set(e.from, list);
875
+ }
876
+ const color = new Map;
877
+ const stack = [];
878
+ const cycles = [];
879
+ function visit(id) {
880
+ color.set(id, "grey");
881
+ stack.push(id);
882
+ for (const to of adj.get(id) ?? []) {
883
+ const c = color.get(to);
884
+ if (c === "grey") {
885
+ const idx = stack.indexOf(to);
886
+ cycles.push([...stack.slice(idx), to]);
887
+ } else if (c === undefined) {
888
+ visit(to);
889
+ }
890
+ }
891
+ stack.pop();
892
+ color.set(id, "black");
893
+ }
894
+ for (const node of graph.nodes) {
895
+ if (color.get(node.id) === undefined)
896
+ visit(node.id);
897
+ }
898
+ return cycles;
899
+ }
900
+ function validateGraph(graph) {
901
+ const issues = [];
902
+ if (typeof graph.name !== "string" || graph.name.trim() === "") {
903
+ issue(issues, "name", "graph requires a non-empty name");
904
+ }
905
+ if (typeof graph.version !== "string" || graph.version.trim() === "") {
906
+ issue(issues, "version", "graph requires a version string");
907
+ }
908
+ if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
909
+ issue(issues, "nodes", "graph requires at least one node");
910
+ return { ok: false, issues };
911
+ }
912
+ const starts = graph.nodes.filter((n) => n.type === "start");
913
+ if (starts.length === 0)
914
+ issue(issues, "nodes", "graph requires exactly one start node (found none)");
915
+ if (starts.length > 1)
916
+ issue(issues, "nodes", "graph requires exactly one start node (found multiple)");
917
+ const ends = graph.nodes.filter((n) => n.type === "end");
918
+ if (ends.length === 0)
919
+ issue(issues, "nodes", "graph requires at least one end node");
920
+ const seenIds = new Map;
921
+ for (const node of graph.nodes) {
922
+ if (typeof node.id !== "string" || !NODE_ID_RE.test(node.id)) {
923
+ issue(issues, "nodes", `node id ${JSON.stringify(node.id)} must match ${String(NODE_ID_RE)}`);
924
+ continue;
925
+ }
926
+ if (seenIds.has(node.id)) {
927
+ issue(issues, `nodes.${node.id}`, `duplicate node id ${node.id}`);
928
+ continue;
929
+ }
930
+ seenIds.set(node.id, node);
931
+ if (!GRAPH_NODE_TYPES.includes(node.type)) {
932
+ issue(issues, `nodes.${node.id}`, `unknown node type ${JSON.stringify(node.type)}`);
933
+ continue;
934
+ }
935
+ validateNode(graph, node, issues);
936
+ }
937
+ for (const node of graph.nodes) {
938
+ const edges = nodeEdges(node);
939
+ for (const { kind, target } of edges) {
940
+ if (!seenIds.has(target)) {
941
+ issue(issues, `nodes.${node.id}`, `${kind} edge targets nonexistent node ${target}`);
942
+ }
943
+ }
944
+ }
945
+ if (starts.length === 1) {
946
+ const reachable = new Set(reachableNodes(graph));
947
+ for (const node of graph.nodes) {
948
+ if (!reachable.has(node.id)) {
949
+ issue(issues, `nodes.${node.id}`, `node ${node.id} is unreachable from start`);
950
+ }
951
+ }
952
+ }
953
+ const cycles = findCycles(graph);
954
+ for (const cycle of cycles) {
955
+ issue(issues, "nodes", `explicit cycle detected: ${cycle.join(" -> ")} (loop only via a while node)`);
956
+ }
957
+ return { ok: issues.length === 0, issues };
958
+ }
959
+ function nodeEdges(node) {
960
+ switch (node.type) {
961
+ case "start":
962
+ case "step":
963
+ return node.next ? [{ kind: "next", target: node.next }] : [];
964
+ case "decision":
965
+ return [
966
+ ...node.then ? [{ kind: "then", target: node.then }] : [],
967
+ ...node.else ? [{ kind: "else", target: node.else }] : []
968
+ ];
969
+ case "while":
970
+ return node.next ? [{ kind: "next", target: node.next }] : [];
971
+ case "end":
972
+ return [];
973
+ }
974
+ }
975
+ function validateNode(graph, node, issues) {
976
+ switch (node.type) {
977
+ case "start":
978
+ if (!node.next)
979
+ issue(issues, `nodes.${node.id}`, "start node requires a next edge");
980
+ break;
981
+ case "step": {
982
+ if (!node.prompt && !node.command) {
983
+ issue(issues, `nodes.${node.id}`, "step node requires prompt or command");
984
+ }
985
+ if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
986
+ issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
987
+ }
988
+ if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
989
+ issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
990
+ }
991
+ if (node.timeoutMs !== undefined && (!Number.isInteger(node.timeoutMs) || node.timeoutMs < 1)) {
992
+ issue(issues, `nodes.${node.id}`, "timeoutMs must be a positive integer");
993
+ }
994
+ break;
995
+ }
996
+ case "decision": {
997
+ if (typeof node.condition !== "string" || node.condition.trim() === "") {
998
+ issue(issues, `nodes.${node.id}`, "decision node requires a condition expression");
999
+ } else {
1000
+ try {
1001
+ parseExpr(node.condition);
1002
+ } catch (err) {
1003
+ if (err instanceof ExprSyntaxError) {
1004
+ issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
1005
+ } else {
1006
+ throw err;
1007
+ }
1008
+ }
1009
+ }
1010
+ if (!node.then && !node.else) {
1011
+ issue(issues, `nodes.${node.id}`, "decision node requires then and/or else edge");
1012
+ }
1013
+ break;
1014
+ }
1015
+ case "while": {
1016
+ if (typeof node.condition !== "string" || node.condition.trim() === "") {
1017
+ issue(issues, `nodes.${node.id}`, "while node requires a condition expression");
1018
+ } else {
1019
+ try {
1020
+ parseExpr(node.condition);
1021
+ } catch (err) {
1022
+ if (err instanceof ExprSyntaxError) {
1023
+ issue(issues, `nodes.${node.id}.condition`, `invalid condition expression: ${err.message}`);
1024
+ } else {
1025
+ throw err;
1026
+ }
1027
+ }
1028
+ }
1029
+ if (node.maxIterations === undefined) {
1030
+ issue(issues, `nodes.${node.id}`, "while node requires maxIterations (finite loop bound)");
1031
+ } else if (!Number.isInteger(node.maxIterations) || node.maxIterations < 1) {
1032
+ issue(issues, `nodes.${node.id}`, "maxIterations must be a positive integer");
1033
+ }
1034
+ if (!Array.isArray(node.body) || node.body.length === 0) {
1035
+ issue(issues, `nodes.${node.id}`, "while node requires a non-empty body");
1036
+ } else {
1037
+ for (const bodyId of node.body) {
1038
+ const bodyNode = findNode(graph, bodyId);
1039
+ if (!bodyNode) {
1040
+ issue(issues, `nodes.${node.id}`, `while body references nonexistent node ${bodyId}`);
1041
+ continue;
1042
+ }
1043
+ if (bodyNode.type === "start" || bodyNode.type === "end" || bodyNode.type === "while") {
1044
+ issue(issues, `nodes.${node.id}`, `while body may only contain step/decision nodes in v1 (found ${bodyNode.type})`);
1045
+ }
1046
+ }
1047
+ const bodySet = new Set(node.body);
1048
+ for (const bodyId of node.body) {
1049
+ const bodyNode = findNode(graph, bodyId);
1050
+ if (!bodyNode)
1051
+ continue;
1052
+ for (const { kind, target } of nodeEdges(bodyNode)) {
1053
+ if (!bodySet.has(target)) {
1054
+ issue(issues, `nodes.${node.id}`, `while body node ${bodyId} ${kind}-edge leaves the body (target ${target})`);
1055
+ }
1056
+ }
1057
+ }
1058
+ }
1059
+ break;
1060
+ }
1061
+ case "end":
1062
+ break;
1063
+ }
1064
+ }
1065
+
1066
+ // src/secrets.ts
1067
+ class SecretsGateError extends Error {
1068
+ constructor(message) {
1069
+ super(message);
1070
+ this.name = "SecretsGateError";
1071
+ }
1072
+ }
1073
+ var p = (...parts) => parts.join("");
1074
+ var VALUE_PATTERNS = [
1075
+ { detector: "anthropic_api_key", re: new RegExp(p("sk-", "ant-") + "[A-Za-z0-9_-]{10,}") },
1076
+ { detector: "openai_api_key", re: new RegExp(p("sk-", "proj-") + "[A-Za-z0-9_-]{10,}") },
1077
+ { detector: "npm_token", re: /npm_[A-Za-z0-9]{20,}/ },
1078
+ { detector: "github_token", re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
1079
+ { detector: "aws_access_key", re: /AKIA[0-9A-Z]{16}/ },
1080
+ { detector: "google_api_key", re: /AIza[0-9A-Za-z_-]{20,}/ },
1081
+ { detector: "xai_api_key", re: new RegExp(p("xai-") + "[A-Za-z0-9]{20,}") },
1082
+ { detector: "codex_token", re: new RegExp(p("ctx7", "sk-") + "[A-Za-z0-9]{10,}") },
1083
+ { detector: "private_key_block", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/ },
1084
+ { detector: "bearer_token", re: /Bearer [A-Za-z0-9._~+/=-]{20,}/ }
1085
+ ];
1086
+ var CREDENTIAL_KEY_RE = /^(token|api[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|secret[_-]?key|refresh[_-]?token|session[_-]?token|bot[_-]?token)$/i;
1087
+ function scanString(value, path, findings) {
1088
+ for (const { detector, re } of VALUE_PATTERNS) {
1089
+ if (re.test(value)) {
1090
+ findings.push({ path, detector });
1091
+ return;
1092
+ }
1093
+ }
1094
+ }
1095
+ function isTrivial(value) {
1096
+ const v = value.trim();
1097
+ if (v.length === 0)
1098
+ return true;
1099
+ const upper = v.toUpperCase();
1100
+ if (upper.startsWith("REPLACE") || upper.startsWith("CHANGE") || upper.startsWith("YOUR_") || upper.startsWith("EXAMPLE") || upper.startsWith("PLACEHOLDER"))
1101
+ return true;
1102
+ if (v.length < 8)
1103
+ return true;
1104
+ return false;
1105
+ }
1106
+ function scanForSecrets(value, path = "$") {
1107
+ const findings = [];
1108
+ if (value === null || value === undefined)
1109
+ return findings;
1110
+ if (typeof value === "string") {
1111
+ scanString(value, path, findings);
1112
+ return findings;
1113
+ }
1114
+ if (Array.isArray(value)) {
1115
+ value.forEach((item, index) => {
1116
+ findings.push(...scanForSecrets(item, `${path}[${index}]`));
1117
+ });
1118
+ return findings;
1119
+ }
1120
+ if (typeof value === "object") {
1121
+ const record = value;
1122
+ for (const [key, child] of Object.entries(record)) {
1123
+ if (CREDENTIAL_KEY_RE.test(key) && typeof child === "string" && !isTrivial(child)) {
1124
+ findings.push({ path: `${path}.${key}`, detector: "credential_key_field" });
1125
+ } else {
1126
+ findings.push(...scanForSecrets(child, `${path}.${key}`));
1127
+ }
1128
+ }
1129
+ }
1130
+ return findings;
1131
+ }
1132
+ function assertNoSecrets(value, what) {
1133
+ const findings = scanForSecrets(value);
1134
+ if (findings.length === 0)
1135
+ return;
1136
+ const detail = findings.map((f) => `${f.path} (${f.detector})`).join(", ");
1137
+ throw new SecretsGateError(`secrets write-gate refused to persist ${what}: credential-shaped value at ${detail}`);
1138
+ }
1139
+
1140
+ // src/session.ts
1141
+ import { createHash } from "crypto";
1142
+ var DEFAULT_MAX_ATTEMPTS = 3;
1143
+ function repairTornRuns(store, wal, opts = {}) {
1144
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
1145
+ const replay = wal.replay();
1146
+ const liveClaims = replay.liveClaims(opts.now);
1147
+ const report = { interrupted: 0, requeued: 0, failed: 0 };
1148
+ const running = store.listRuns({ status: "running", limit: 1000 });
1149
+ for (const run of running) {
1150
+ if (liveClaims.has(run.id))
1151
+ continue;
1152
+ report.interrupted++;
1153
+ const nodes = store.listRunNodes(run.id);
1154
+ if (run.attempts < maxAttempts) {
1155
+ store.bumpAttempts(run.id);
1156
+ store.setRunStatus(run.id, "interrupted", {
1157
+ error: `torn run interrupted after claim loss (attempt ${run.attempts + 1}/${maxAttempts})`
1158
+ });
1159
+ for (const node of nodes) {
1160
+ if (node.status === "running") {
1161
+ store.setRunNodeStatus(node.id, "pending", { error: undefined });
1162
+ }
1163
+ }
1164
+ report.requeued++;
1165
+ } else {
1166
+ store.setRunStatus(run.id, "failed", {
1167
+ error: `torn run failed after ${maxAttempts} interrupted attempts`
1168
+ });
1169
+ for (const node of nodes) {
1170
+ if (node.status === "running") {
1171
+ store.setRunNodeStatus(node.id, "failed", { error: "run interrupted beyond retry budget" });
1172
+ }
1173
+ }
1174
+ report.failed++;
1175
+ }
1176
+ }
1177
+ return report;
1178
+ }
1179
+ function inputHash(payload) {
1180
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
1181
+ }
1182
+ function memoKey(graphName, nodeId, input) {
1183
+ return `${graphName}:${nodeId}:${inputHash(input)}`;
1184
+ }
1185
+ function tryMemoHit(store, graphName, nodeId, input) {
1186
+ const key = memoKey(graphName, nodeId, input);
1187
+ const memo = store.memoGet(key);
1188
+ if (!memo)
1189
+ return;
1190
+ store.memoHit(key);
1191
+ return memo;
1192
+ }
1193
+ function recordMemo(store, graphName, nodeId, input, output) {
1194
+ const key = memoKey(graphName, nodeId, input);
1195
+ store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
1196
+ }
1197
+ function findRunByIdempotencyKey(store, key) {
1198
+ const runs = store.listRuns({ limit: 1000 });
1199
+ for (const run of runs) {
1200
+ try {
1201
+ const context = JSON.parse(run.contextJson);
1202
+ if (context?.__wf?.idempotencyKey === key)
1203
+ return run;
1204
+ } catch {}
1205
+ }
1206
+ return;
1207
+ }
1208
+
1209
+ // src/daemon.ts
1210
+ init_types();
1211
+ var DEFAULT_LEASE_TTL_MS = 120000;
1212
+ var DEFAULT_MAX_ATTEMPTS2 = 3;
1213
+ var DEFAULT_MAX_DISPATCH = 4;
1214
+ var DEFAULT_MAX_STEPS = 25;
1215
+ var DEFAULT_TIMEOUT_MS = 120000;
1216
+ function nowIso() {
1217
+ return new Date().toISOString();
1218
+ }
1219
+ function stripRunState(context) {
1220
+ const { __wf: _wf, steps: _steps, ...user } = context;
1221
+ return user;
1222
+ }
1223
+ function runCommand(job) {
1224
+ if (!job.command) {
1225
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "no command" };
1226
+ }
1227
+ const started = Date.now();
1228
+ const timeoutMs = job.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1229
+ const proc = Bun.spawnSync({
1230
+ cmd: ["/bin/sh", "-c", job.command],
1231
+ cwd: job.cwd,
1232
+ env: { ...process.env, ...job.env ?? {} },
1233
+ stdout: "pipe",
1234
+ stderr: "pipe",
1235
+ timeout: timeoutMs
1236
+ });
1237
+ const durationMs = Date.now() - started;
1238
+ const stdout = proc.stdout ? new TextDecoder().decode(proc.stdout) : "";
1239
+ const stderr = proc.stderr ? new TextDecoder().decode(proc.stderr) : "";
1240
+ const output = stderr ? `${stdout}
1241
+ ${stderr}`.trim() : stdout.trim();
1242
+ const exitCode = proc.exitCode ?? (proc.signalCode ? 1 : 1);
1243
+ return { ok: exitCode === 0, exitCode, output, durationMs };
1244
+ }
1245
+
1246
+ class WorkflowsDaemon {
1247
+ store;
1248
+ wal;
1249
+ worker;
1250
+ leaseTtlMs;
1251
+ maxAttempts;
1252
+ maxDispatchPerCycle;
1253
+ maxStepsPerCycle;
1254
+ time;
1255
+ laneRunner;
1256
+ env;
1257
+ leases = new Map;
1258
+ fencingCounter = new Map;
1259
+ graphCache = new Map;
1260
+ isolatedRunId = null;
1261
+ constructor(store, wal, options = {}) {
1262
+ this.store = store;
1263
+ this.wal = wal;
1264
+ this.worker = options.worker ?? "local";
1265
+ this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
1266
+ this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS2;
1267
+ this.maxDispatchPerCycle = options.maxDispatchPerCycle ?? DEFAULT_MAX_DISPATCH;
1268
+ this.maxStepsPerCycle = options.maxStepsPerCycle ?? DEFAULT_MAX_STEPS;
1269
+ this.time = options.time ?? Date.now;
1270
+ this.env = options.env ?? {};
1271
+ this.laneRunner = options.laneRunner ?? defaultLaneRunner;
1272
+ this.isolatedRunId = options.isolatedRunId ?? null;
1273
+ this.replayRegisteredGraphs();
1274
+ }
1275
+ isolateRun(runId) {
1276
+ this.isolatedRunId = runId;
1277
+ }
1278
+ replayRegisteredGraphs() {
1279
+ const entries = this.wal.replay().entries;
1280
+ for (const entry of entries) {
1281
+ const op = entry.op;
1282
+ if (op.op === "graph_registered") {
1283
+ this.graphCache.set(op.name, op.graph);
1284
+ }
1285
+ }
1286
+ }
1287
+ liveLeases() {
1288
+ const now = this.time();
1289
+ for (const [runId, lease] of this.leases) {
1290
+ if (lease.expiresAtMs <= now)
1291
+ this.leases.delete(runId);
1292
+ }
1293
+ return this.leases;
1294
+ }
1295
+ claim(runId) {
1296
+ const now = this.time();
1297
+ const existing = this.leases.get(runId);
1298
+ if (existing && existing.worker !== this.worker && existing.expiresAtMs > now) {
1299
+ return null;
1300
+ }
1301
+ const liveClaims = this.wal.replay().liveClaims(now);
1302
+ const foreign = liveClaims.get(runId);
1303
+ if (foreign && foreign.worker !== this.worker && foreign.expiresAtMs > now) {
1304
+ return null;
1305
+ }
1306
+ const fencing = (this.fencingCounter.get(runId) ?? 0) + 1;
1307
+ this.fencingCounter.set(runId, fencing);
1308
+ const lease = { runId, worker: this.worker, expiresAtMs: now + this.leaseTtlMs, fencing };
1309
+ this.leases.set(runId, lease);
1310
+ this.wal.append({
1311
+ op: "claim_acquired",
1312
+ runId,
1313
+ worker: this.worker,
1314
+ expiresAt: new Date(lease.expiresAtMs).toISOString(),
1315
+ fencing,
1316
+ at: nowIso()
1317
+ });
1318
+ return lease;
1319
+ }
1320
+ heartbeat(runId, fencing) {
1321
+ const lease = this.leases.get(runId);
1322
+ if (!lease || lease.fencing !== fencing)
1323
+ return false;
1324
+ lease.expiresAtMs = this.time() + this.leaseTtlMs;
1325
+ return true;
1326
+ }
1327
+ release(runId, fencing) {
1328
+ const lease = this.leases.get(runId);
1329
+ if (!lease || lease.fencing !== fencing)
1330
+ return false;
1331
+ this.leases.delete(runId);
1332
+ this.wal.append({ op: "claim_released", runId, fencing, at: nowIso() });
1333
+ return true;
1334
+ }
1335
+ async reap() {
1336
+ const report = { expired: 0, interrupted: 0, requeued: 0, failed: 0, dispatched: 0, advanced: 0, completed: 0 };
1337
+ const now = this.time();
1338
+ for (const [runId, lease] of this.leases) {
1339
+ if (lease.expiresAtMs <= now) {
1340
+ this.leases.delete(runId);
1341
+ report.expired++;
1342
+ }
1343
+ }
1344
+ if (this.isolatedRunId === null) {
1345
+ const repair = repairTornRuns(this.store, this.wal, { maxAttempts: this.maxAttempts, now });
1346
+ report.interrupted += repair.interrupted;
1347
+ report.requeued += repair.requeued;
1348
+ report.failed += repair.failed;
1349
+ }
1350
+ 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);
1351
+ for (const run of pending) {
1352
+ if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
1353
+ continue;
1354
+ const lease = this.claim(run.id);
1355
+ if (!lease)
1356
+ continue;
1357
+ this.store.setRunStatus(run.id, "running");
1358
+ this.wal.append({ op: "run_started", runId: run.id, at: nowIso() });
1359
+ report.dispatched++;
1360
+ }
1361
+ const running = this.store.listRuns({ status: "running", limit: this.maxStepsPerCycle });
1362
+ let steps = 0;
1363
+ for (const run of running) {
1364
+ if (this.isolatedRunId !== null && run.id !== this.isolatedRunId)
1365
+ continue;
1366
+ if (steps >= this.maxStepsPerCycle)
1367
+ break;
1368
+ const outcome = await this.advanceRun(run.id);
1369
+ steps++;
1370
+ if (outcome.kind === "completed")
1371
+ report.completed++;
1372
+ if (outcome.kind === "failed")
1373
+ report.failed++;
1374
+ if (outcome.kind === "advanced")
1375
+ report.advanced++;
1376
+ }
1377
+ return report;
1378
+ }
1379
+ startRun(graph, context) {
1380
+ const validation = validateGraph(graph);
1381
+ if (!validation.ok) {
1382
+ throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
1383
+ }
1384
+ assertNoSecrets(context, "run context");
1385
+ assertNoSecrets(graph, "graph definition");
1386
+ const existing = this.graphCache.get(graph.name);
1387
+ const serialized = JSON.stringify(graph);
1388
+ if (!existing || JSON.stringify(existing) !== serialized) {
1389
+ this.graphCache.set(graph.name, graph);
1390
+ this.wal.append({ op: "graph_registered", name: graph.name, version: graph.version, graph, at: nowIso() });
1391
+ } else {
1392
+ this.graphCache.set(graph.name, existing);
1393
+ }
1394
+ return this.store.createRun({ graphName: graph.name, graphVersion: graph.version, context: context ?? {} });
1395
+ }
1396
+ async advanceRun(runId) {
1397
+ const run = this.store.getRun(runId);
1398
+ if (!run || run.status !== "running")
1399
+ return { kind: "noop" };
1400
+ const lease = this.leases.get(runId);
1401
+ if (!lease || lease.expiresAtMs <= this.time())
1402
+ return { kind: "blocked" };
1403
+ const graph = this.loadGraph(run.graphName);
1404
+ if (!graph) {
1405
+ this.failRun(run, `graph ${run.graphName} not found in daemon graph cache`);
1406
+ return { kind: "failed", error: "graph not found" };
1407
+ }
1408
+ const context = JSON.parse(run.contextJson);
1409
+ const rawWf = context.__wf ?? {};
1410
+ const wf = {
1411
+ ...rawWf,
1412
+ cursor: typeof rawWf.cursor === "string" ? rawWf.cursor : undefined,
1413
+ loops: rawWf.loops ?? {},
1414
+ completedLoops: rawWf.completedLoops ?? {}
1415
+ };
1416
+ try {
1417
+ const cursor = wf.cursor;
1418
+ const node = cursor === undefined ? graph.nodes.find((n) => n.type === "start") : findNode(graph, cursor);
1419
+ if (!node) {
1420
+ this.failRun(run, `cursor ${cursor ?? "<start>"} resolves to no node`);
1421
+ return { kind: "failed", error: "no node at cursor" };
1422
+ }
1423
+ switch (node.type) {
1424
+ case "start": {
1425
+ wf.cursor = node.next;
1426
+ this.persistContext(run, context, wf);
1427
+ return { kind: "advanced", nodeId: node.id };
1428
+ }
1429
+ case "step": {
1430
+ return await this.executeStep(run, graph, context, wf, node);
1431
+ }
1432
+ case "decision": {
1433
+ const value = evaluateExpr(node.condition, this.conditionScope(context));
1434
+ const next = value ? node.then : node.else;
1435
+ if (!next) {
1436
+ this.failRun(run, `decision ${node.id} evaluated ${value} but no ${value ? "then" : "else"} edge`);
1437
+ return { kind: "failed", error: "missing decision edge" };
1438
+ }
1439
+ wf.cursor = next;
1440
+ this.persistContext(run, context, wf);
1441
+ return { kind: "advanced", nodeId: node.id };
1442
+ }
1443
+ case "while": {
1444
+ return this.executeWhile(run, graph, context, wf, node);
1445
+ }
1446
+ case "end": {
1447
+ this.completeRun(run, context);
1448
+ return { kind: "completed" };
1449
+ }
1450
+ }
1451
+ } catch (err) {
1452
+ if (err instanceof ExprEvalError || err instanceof ExprSyntaxError) {
1453
+ this.failRun(run, `condition error at ${wf.cursor ?? "<start>"}: ${err.message}`);
1454
+ return { kind: "failed", error: err.message };
1455
+ }
1456
+ throw err;
1457
+ }
1458
+ }
1459
+ loadGraph(name) {
1460
+ return this.graphCache.get(name);
1461
+ }
1462
+ conditionScope(context) {
1463
+ return { ...context, steps: context.steps ?? {} };
1464
+ }
1465
+ persistContext(run, context, wf) {
1466
+ context.__wf = wf;
1467
+ this.store.setRunContext(run.id, context);
1468
+ }
1469
+ async executeStep(run, graph, context, wf, node) {
1470
+ const nodeId = node.id;
1471
+ const existing = this.store.listRunNodes(run.id).find((n) => n.nodeId === nodeId);
1472
+ const nodeRow = existing ?? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" });
1473
+ if (node.memo) {
1474
+ const memoInput = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
1475
+ const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
1476
+ if (hit) {
1477
+ const cached = JSON.parse(hit.outputJson);
1478
+ this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: Number(cached.exitCode ?? 0), output: { ...cached, memoHit: true } });
1479
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
1480
+ this.recordStepResult(context, nodeId, { ...cached, memoHit: true });
1481
+ return this.afterNode(run, context, wf, node.next);
1482
+ }
1483
+ }
1484
+ if (nodeRow.status !== "running") {
1485
+ this.store.setRunNodeStatus(nodeRow.id, "running");
1486
+ this.wal.append({ op: "node_started", runId: run.id, nodeId, at: nowIso() });
1487
+ }
1488
+ const job = {
1489
+ lane: node.lane ?? "claude",
1490
+ prompt: node.prompt,
1491
+ command: node.command,
1492
+ timeoutMs: node.timeoutMs,
1493
+ env: this.env
1494
+ };
1495
+ let result;
1496
+ try {
1497
+ result = await this.laneRunner(job);
1498
+ } catch (err) {
1499
+ result = { ok: false, exitCode: 1, output: "", durationMs: 0, error: String(err instanceof Error ? err.message : err) };
1500
+ }
1501
+ const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
1502
+ if (!result.ok) {
1503
+ const retriesLeft = (node.maxRetries ?? 0) + 1 - nodeRow.attempts;
1504
+ if (retriesLeft > 0) {
1505
+ this.store.bumpAttemptsNode(nodeRow.id);
1506
+ this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
1507
+ return { kind: "advanced", nodeId };
1508
+ }
1509
+ const error = result.error ?? `step ${nodeId} failed with exit ${result.exitCode}`;
1510
+ this.store.setRunNodeStatus(nodeRow.id, "failed", { exitCode: result.exitCode, error });
1511
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
1512
+ this.failRun(run, error);
1513
+ return { kind: "failed", error };
1514
+ }
1515
+ try {
1516
+ assertNoSecrets(output, `node ${nodeId} output`);
1517
+ } catch (err) {
1518
+ const message = err instanceof Error ? err.message : String(err);
1519
+ this.store.setRunNodeStatus(nodeRow.id, "failed", { error: message });
1520
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "failed", at: nowIso() });
1521
+ this.failRun(run, message);
1522
+ return { kind: "failed", error: message };
1523
+ }
1524
+ this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
1525
+ this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
1526
+ if (node.memo) {
1527
+ recordMemo(this.store, run.graphName, nodeId, { command: node.command, prompt: node.prompt, context: stripRunState(context) }, output);
1528
+ 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() });
1529
+ }
1530
+ this.recordStepResult(context, nodeId, output);
1531
+ return this.afterNode(run, context, wf, node.next);
1532
+ }
1533
+ afterNode(run, context, wf, next) {
1534
+ if (next) {
1535
+ wf.cursor = next;
1536
+ this.persistContext(run, context, wf);
1537
+ return { kind: "advanced", nodeId: wf.cursor };
1538
+ }
1539
+ const cursor = wf.cursor;
1540
+ const graph = this.loadGraph(run.graphName);
1541
+ const whileNode = graph.nodes.find((n) => n.type === "while" && n.body.includes(cursor ?? ""));
1542
+ if (whileNode) {
1543
+ const index = whileNode.body.indexOf(cursor ?? "");
1544
+ if (index >= 0 && index < whileNode.body.length - 1) {
1545
+ wf.cursor = whileNode.body[index + 1];
1546
+ this.persistContext(run, context, wf);
1547
+ return { kind: "advanced", nodeId: wf.cursor };
1548
+ }
1549
+ wf.loops[whileNode.id] = (wf.loops[whileNode.id] ?? 0) + 1;
1550
+ wf.cursor = whileNode.id;
1551
+ this.persistContext(run, context, wf);
1552
+ return { kind: "advanced", nodeId: whileNode.id };
1553
+ }
1554
+ this.completeRun(run, context);
1555
+ return { kind: "completed" };
1556
+ }
1557
+ executeWhile(run, graph, context, wf, node) {
1558
+ const iteration = wf.loops[node.id] ?? 0;
1559
+ if (iteration >= node.maxIterations) {
1560
+ const error = `while node ${node.id} exceeded maxIterations (${node.maxIterations})`;
1561
+ this.failRun(run, error);
1562
+ return { kind: "failed", error };
1563
+ }
1564
+ const scope = { ...this.conditionScope(context), i: iteration };
1565
+ const keepGoing = evaluateExpr(node.condition, scope);
1566
+ if (!keepGoing) {
1567
+ wf.completedLoops[node.id] = iteration;
1568
+ delete wf.loops[node.id];
1569
+ wf.cursor = node.next;
1570
+ this.persistContext(run, context, wf);
1571
+ return { kind: "advanced", nodeId: node.id };
1572
+ }
1573
+ wf.cursor = node.body[0];
1574
+ this.persistContext(run, context, wf);
1575
+ return { kind: "advanced", nodeId: node.id };
1576
+ }
1577
+ recordStepResult(context, nodeId, output) {
1578
+ const steps = context.steps ?? {};
1579
+ steps[nodeId] = output;
1580
+ context.steps = steps;
1581
+ }
1582
+ completeRun(run, context) {
1583
+ const result = {
1584
+ steps: context.steps ?? {},
1585
+ iterations: context.__wf?.completedLoops ?? {}
1586
+ };
1587
+ try {
1588
+ assertNoSecrets(result, "run result");
1589
+ } catch (err) {
1590
+ const message = err instanceof Error ? err.message : String(err);
1591
+ this.failRun(run, message);
1592
+ return;
1593
+ }
1594
+ this.store.setRunStatus(run.id, "completed", { result, finishedAt: nowIso(), error: null });
1595
+ this.wal.append({ op: "run_finished", runId: run.id, status: "completed", at: nowIso() });
1596
+ const lease = this.leases.get(run.id);
1597
+ if (lease)
1598
+ this.release(run.id, lease.fencing);
1599
+ }
1600
+ failRun(run, error) {
1601
+ this.store.setRunStatus(run.id, "failed", { error, finishedAt: nowIso() });
1602
+ this.wal.append({ op: "run_finished", runId: run.id, status: "failed", at: nowIso() });
1603
+ const lease = this.leases.get(run.id);
1604
+ if (lease)
1605
+ this.release(run.id, lease.fencing);
1606
+ }
1607
+ }
1608
+ var defaultLaneRunner = async (job) => {
1609
+ if (job.command)
1610
+ return runCommand(job);
1611
+ const { runLaneJob: runLaneJob2 } = await Promise.resolve().then(() => (init_lanes(), exports_lanes));
1612
+ return runLaneJob2(job);
1613
+ };
1614
+ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
1615
+ const daemon = new WorkflowsDaemon(store, wal, {
1616
+ worker: "local",
1617
+ laneRunner: options.laneRunner,
1618
+ time: options.time,
1619
+ env: options.env,
1620
+ isolatedRunId: options.isolatedRunId
1621
+ });
1622
+ const run = daemon.startRun(graph, context);
1623
+ daemon.isolateRun(run.id);
1624
+ const maxCycles = options.maxCycles ?? 500;
1625
+ for (let cycle = 0;cycle < maxCycles; cycle++) {
1626
+ const report = await daemon.reap();
1627
+ const current = store.getRun(run.id);
1628
+ if (current.status !== "running")
1629
+ return current;
1630
+ if (report.advanced === 0 && report.dispatched === 0) {
1631
+ return store.getRun(run.id);
1632
+ }
1633
+ }
1634
+ return store.getRun(run.id);
1635
+ }
1636
+
1637
+ // src/store.ts
1638
+ import { Database } from "bun:sqlite";
1639
+ import { mkdirSync } from "fs";
1640
+ import { join } from "path";
1641
+ import { randomUUID } from "crypto";
1642
+ function nowIso2() {
1643
+ return new Date().toISOString();
1644
+ }
1645
+ function openStore(dataDir) {
1646
+ mkdirSync(dataDir, { recursive: true });
1647
+ const db = new Database(join(dataDir, "workflows.db"));
1648
+ db.exec("PRAGMA journal_mode = WAL");
1649
+ db.exec("PRAGMA foreign_keys = ON");
1650
+ db.exec(`
1651
+ CREATE TABLE IF NOT EXISTS runs (
1652
+ id TEXT PRIMARY KEY,
1653
+ graph_name TEXT NOT NULL,
1654
+ graph_version TEXT NOT NULL,
1655
+ status TEXT NOT NULL,
1656
+ attempts INTEGER NOT NULL DEFAULT 0,
1657
+ context_json TEXT NOT NULL,
1658
+ result_json TEXT,
1659
+ error TEXT,
1660
+ started_at TEXT,
1661
+ finished_at TEXT,
1662
+ created_at TEXT NOT NULL,
1663
+ updated_at TEXT NOT NULL
1664
+ );
1665
+ CREATE TABLE IF NOT EXISTS run_nodes (
1666
+ id TEXT PRIMARY KEY,
1667
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1668
+ node_id TEXT NOT NULL,
1669
+ status TEXT NOT NULL,
1670
+ attempts INTEGER NOT NULL DEFAULT 0,
1671
+ lane TEXT,
1672
+ exit_code INTEGER,
1673
+ output_json TEXT,
1674
+ error TEXT,
1675
+ started_at TEXT,
1676
+ finished_at TEXT
1677
+ );
1678
+ CREATE TABLE IF NOT EXISTS memos (
1679
+ key TEXT PRIMARY KEY,
1680
+ graph_name TEXT NOT NULL,
1681
+ node_id TEXT NOT NULL,
1682
+ input_hash TEXT NOT NULL,
1683
+ output_json TEXT NOT NULL,
1684
+ hit_count INTEGER NOT NULL DEFAULT 0,
1685
+ created_at TEXT NOT NULL
1686
+ );
1687
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
1688
+ CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
1689
+ CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
1690
+ `);
1691
+ const rowToRun = (r) => ({
1692
+ id: String(r.id),
1693
+ graphName: String(r.graph_name),
1694
+ graphVersion: String(r.graph_version),
1695
+ status: r.status,
1696
+ attempts: Number(r.attempts),
1697
+ contextJson: String(r.context_json),
1698
+ resultJson: r.result_json === null ? null : String(r.result_json),
1699
+ error: r.error === null ? null : String(r.error),
1700
+ startedAt: r.started_at === null ? null : String(r.started_at),
1701
+ finishedAt: r.finished_at === null ? null : String(r.finished_at),
1702
+ createdAt: String(r.created_at),
1703
+ updatedAt: String(r.updated_at)
1704
+ });
1705
+ const rowToNode = (r) => ({
1706
+ id: String(r.id),
1707
+ runId: String(r.run_id),
1708
+ nodeId: String(r.node_id),
1709
+ status: r.status,
1710
+ attempts: Number(r.attempts),
1711
+ lane: r.lane === null ? null : String(r.lane),
1712
+ exitCode: r.exit_code === null ? null : Number(r.exit_code),
1713
+ outputJson: r.output_json === null ? null : String(r.output_json),
1714
+ error: r.error === null ? null : String(r.error),
1715
+ startedAt: r.started_at === null ? null : String(r.started_at),
1716
+ finishedAt: r.finished_at === null ? null : String(r.finished_at)
1717
+ });
1718
+ const rowToMemo = (r) => ({
1719
+ key: String(r.key),
1720
+ graphName: String(r.graph_name),
1721
+ nodeId: String(r.node_id),
1722
+ inputHash: String(r.input_hash),
1723
+ outputJson: String(r.output_json),
1724
+ hitCount: Number(r.hit_count),
1725
+ createdAt: String(r.created_at)
1726
+ });
1727
+ return {
1728
+ close() {
1729
+ db.close();
1730
+ },
1731
+ listTables() {
1732
+ const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
1733
+ return rows.map((r) => r.name);
1734
+ },
1735
+ createRun(input) {
1736
+ const id = randomUUID();
1737
+ const now = nowIso2();
1738
+ db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
1739
+ VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now);
1740
+ return this.getRun(id);
1741
+ },
1742
+ getRun(id) {
1743
+ const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
1744
+ return row ? rowToRun(row) : undefined;
1745
+ },
1746
+ listRuns(opts = {}) {
1747
+ const limit = Math.min(opts.limit ?? 100, 1000);
1748
+ if (opts.status) {
1749
+ const rows2 = db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(opts.status, limit);
1750
+ return rows2.map((r) => rowToRun(r));
1751
+ }
1752
+ const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
1753
+ return rows.map((r) => rowToRun(r));
1754
+ },
1755
+ setRunStatus(id, status, patch = {}) {
1756
+ const now = nowIso2();
1757
+ const existing = this.getRun(id);
1758
+ const resultJson = patch.result !== undefined ? JSON.stringify(patch.result) : existing?.resultJson ?? null;
1759
+ const error = patch.error !== undefined ? patch.error : existing?.error ?? null;
1760
+ const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
1761
+ const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
1762
+ const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
1763
+ 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);
1764
+ },
1765
+ bumpAttempts(id) {
1766
+ db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
1767
+ },
1768
+ setRunContext(id, context) {
1769
+ db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
1770
+ },
1771
+ createRunNode(input) {
1772
+ const id = randomUUID();
1773
+ db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
1774
+ VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null);
1775
+ return this.getRunNode(id);
1776
+ },
1777
+ getRunNode(id) {
1778
+ const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
1779
+ return row ? rowToNode(row) : undefined;
1780
+ },
1781
+ listRunNodes(runId) {
1782
+ const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
1783
+ return rows.map((r) => rowToNode(r));
1784
+ },
1785
+ setRunNodeStatus(id, status, patch = {}) {
1786
+ const now = nowIso2();
1787
+ const existing = this.getRunNode(id);
1788
+ if (!existing)
1789
+ return;
1790
+ const exitCode = patch.exitCode !== undefined ? patch.exitCode : existing.exitCode;
1791
+ const outputJson = patch.output !== undefined ? JSON.stringify(patch.output) : existing.outputJson;
1792
+ const error = patch.error !== undefined ? patch.error : existing.error;
1793
+ const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
1794
+ const terminal = status === "completed" || status === "failed" || status === "skipped";
1795
+ const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
1796
+ 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);
1797
+ },
1798
+ bumpAttemptsNode(id) {
1799
+ db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
1800
+ },
1801
+ memoPut(key, graphName, nodeId, inputHash2, outputJson) {
1802
+ db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
1803
+ VALUES (?, ?, ?, ?, ?, 0, ?)
1804
+ ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2());
1805
+ },
1806
+ memoGet(key) {
1807
+ const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
1808
+ return row ? rowToMemo(row) : undefined;
1809
+ },
1810
+ memoHit(key) {
1811
+ db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
1812
+ },
1813
+ memoList() {
1814
+ const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
1815
+ return rows.map((r) => rowToMemo(r));
1816
+ },
1817
+ memoClear() {
1818
+ db.exec(`DELETE FROM memos`);
1819
+ }
1820
+ };
1821
+ }
1822
+
1823
+ // src/wal.ts
1824
+ import { createHash as createHash3 } from "crypto";
1825
+ import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
1826
+ import { join as join2 } from "path";
1827
+ var WAL_FILE_NAME = "session.wal";
1828
+ var SESSIONS_DIR_NAME = "sessions";
1829
+ var MAX_WAL_BYTES = 256 * 1024 * 1024;
1830
+ function checksumOf(op) {
1831
+ if (op === undefined || op === null)
1832
+ return "";
1833
+ return createHash3("sha256").update(JSON.stringify(op)).digest("hex");
1834
+ }
1835
+
1836
+ class SessionWAL {
1837
+ filePath;
1838
+ constructor(filePath) {
1839
+ this.filePath = filePath;
1840
+ }
1841
+ static open(dataDir) {
1842
+ mkdirSync2(join2(dataDir, SESSIONS_DIR_NAME), { recursive: true });
1843
+ const canonical = join2(dataDir, SESSIONS_DIR_NAME, WAL_FILE_NAME);
1844
+ const legacy = join2(dataDir, WAL_FILE_NAME);
1845
+ if (existsSync(legacy) && !existsSync(canonical)) {
1846
+ try {
1847
+ renameSync(legacy, canonical);
1848
+ } catch {
1849
+ return new SessionWAL(legacy);
1850
+ }
1851
+ }
1852
+ return new SessionWAL(canonical);
1853
+ }
1854
+ static openAt(filePath) {
1855
+ return new SessionWAL(filePath);
1856
+ }
1857
+ append(op) {
1858
+ const at = op.at ?? new Date().toISOString();
1859
+ const full = { ...op, at };
1860
+ const nextSeq = this.nextSeq();
1861
+ const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
1862
+ appendFileSync(this.filePath, JSON.stringify(entry) + `
1863
+ `, "utf8");
1864
+ return entry;
1865
+ }
1866
+ nextSeq() {
1867
+ const replay = this.replay();
1868
+ return replay.entries.length + 1;
1869
+ }
1870
+ replay() {
1871
+ const entries = [];
1872
+ let torn = false;
1873
+ let repaired = false;
1874
+ let content;
1875
+ try {
1876
+ content = readFileSync(this.filePath, "utf8");
1877
+ } catch {
1878
+ return { entries, torn, repaired, liveClaims: () => new Map };
1879
+ }
1880
+ if (content.length > MAX_WAL_BYTES) {
1881
+ throw new Error(`session WAL exceeds ${MAX_WAL_BYTES} bytes; refusing to replay a truncated journal`);
1882
+ }
1883
+ if (content === "") {
1884
+ return { entries, torn, repaired, liveClaims: () => new Map };
1885
+ }
1886
+ const lines = content.split(`
1887
+ `);
1888
+ let badOffset = null;
1889
+ let expectedSeq = 1;
1890
+ for (let i = 0;i < lines.length; i++) {
1891
+ const line = lines[i];
1892
+ if (line === "")
1893
+ continue;
1894
+ if (badOffset !== null)
1895
+ break;
1896
+ const lineOffset = cumulativeOffset(lines, i);
1897
+ let entry;
1898
+ try {
1899
+ entry = JSON.parse(line);
1900
+ } catch {
1901
+ badOffset = lineOffset;
1902
+ break;
1903
+ }
1904
+ if (typeof entry?.seq !== "number" || entry.seq !== expectedSeq || entry.checksum !== checksumOf(entry.op)) {
1905
+ badOffset = lineOffset;
1906
+ break;
1907
+ }
1908
+ entries.push(entry);
1909
+ expectedSeq++;
1910
+ }
1911
+ if (badOffset !== null) {
1912
+ torn = true;
1913
+ try {
1914
+ truncateSync(this.filePath, badOffset);
1915
+ repaired = true;
1916
+ } catch {}
1917
+ }
1918
+ return {
1919
+ entries,
1920
+ torn,
1921
+ repaired,
1922
+ liveClaims: (nowMs) => reconstructLiveClaims(entries, nowMs)
1923
+ };
1924
+ }
1925
+ close() {}
1926
+ }
1927
+ function cumulativeOffset(lines, index) {
1928
+ let offset = 0;
1929
+ for (let i = 0;i < index; i++) {
1930
+ offset += Buffer.byteLength(lines[i]) + 1;
1931
+ }
1932
+ return offset;
1933
+ }
1934
+ function reconstructLiveClaims(entries, nowMs = Date.now()) {
1935
+ const claims = new Map;
1936
+ for (const entry of entries) {
1937
+ const op = entry.op;
1938
+ if (op.op === "claim_acquired") {
1939
+ claims.set(op.runId, { worker: op.worker, fencing: op.fencing, expiresAtMs: Date.parse(op.expiresAt) });
1940
+ } else if (op.op === "claim_released") {
1941
+ const existing = claims.get(op.runId);
1942
+ if (existing && existing.fencing === op.fencing) {
1943
+ claims.delete(op.runId);
1944
+ }
1945
+ }
1946
+ }
1947
+ for (const [runId, claim] of claims) {
1948
+ if (claim.expiresAtMs <= nowMs)
1949
+ claims.delete(runId);
1950
+ }
1951
+ return claims;
1952
+ }
1953
+
89
1954
  // src/service.ts
90
1955
  var DEFAULT_PORT = 8790;
91
1956
  var DEFAULT_HOST = "127.0.0.1";
@@ -112,7 +1977,7 @@ function resolveWorkflowsConfig(overrides = {}) {
112
1977
  return {
113
1978
  port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
114
1979
  host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
115
- dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? join(home, ".hasna", "workflows"),
1980
+ dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? join3(home, ".hasna", "workflows"),
116
1981
  apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
117
1982
  apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
118
1983
  };
@@ -144,12 +2009,52 @@ class WorkflowsService {
144
2009
  checks: { version: "ok" }
145
2010
  };
146
2011
  }
2012
+ async triggerRun(graph, context = {}, opts = {}) {
2013
+ const store = openStore(this.config.dataDir);
2014
+ const wal = SessionWAL.open(this.config.dataDir);
2015
+ try {
2016
+ if (opts.idempotencyKey) {
2017
+ const existing = findRunByIdempotencyKey(store, opts.idempotencyKey);
2018
+ if (existing) {
2019
+ return {
2020
+ runId: existing.id,
2021
+ status: existing.status,
2022
+ error: existing.error ?? null,
2023
+ result: existing.resultJson ? JSON.parse(existing.resultJson) : null,
2024
+ reused: true
2025
+ };
2026
+ }
2027
+ }
2028
+ const contextWithKey = opts.idempotencyKey ? {
2029
+ ...context,
2030
+ __wf: {
2031
+ cursor: undefined,
2032
+ loops: {},
2033
+ completedLoops: {},
2034
+ ...context?.__wf ?? {},
2035
+ idempotencyKey: opts.idempotencyKey
2036
+ }
2037
+ } : context;
2038
+ const maxCycles = Math.min(Math.max(opts.maxCycles ?? 500, 1), 2000);
2039
+ const final = await runGraphToCompletion(store, wal, graph, contextWithKey, { maxCycles });
2040
+ return {
2041
+ runId: final.id,
2042
+ status: final.status,
2043
+ error: final.error ?? null,
2044
+ result: final.resultJson ? JSON.parse(final.resultJson) : null,
2045
+ reused: false
2046
+ };
2047
+ } finally {
2048
+ store.close();
2049
+ }
2050
+ }
147
2051
  }
148
2052
  function createWorkflowsService(config = {}) {
149
2053
  return new WorkflowsService(config);
150
2054
  }
151
2055
 
152
2056
  // src/handlers.ts
2057
+ import { createHash as createHash4, timingSafeEqual } from "crypto";
153
2058
  function jsonResponse(body, status = 200) {
154
2059
  return new Response(JSON.stringify(body), {
155
2060
  status,
@@ -163,10 +2068,85 @@ function openApiDoc(service) {
163
2068
  paths: {
164
2069
  "/health": { get: { summary: "Service health", responses: { "200": { description: "ok" } } } },
165
2070
  "/ready": { get: { summary: "Service readiness", responses: { "200": { description: "ok" } } } },
166
- "/version": { get: { summary: "Service version", responses: { "200": { description: "ok" } } } }
2071
+ "/version": { get: { summary: "Service version", responses: { "200": { description: "ok" } } } },
2072
+ "/trigger": {
2073
+ post: {
2074
+ summary: "Authenticated trigger: run a workflow graph to a terminal state",
2075
+ security: [{ bearerAuth: [] }],
2076
+ requestBody: {
2077
+ content: {
2078
+ "application/json": {
2079
+ schema: {
2080
+ type: "object",
2081
+ required: ["graph"],
2082
+ properties: {
2083
+ graph: { type: "object", description: "the workflow graph" },
2084
+ context: { type: "object", description: "optional run context" },
2085
+ idempotencyKey: { type: "string", description: "re-run with the same key returns the same run" },
2086
+ maxCycles: { type: "number", description: "bound on reap cycles (default 500, max 2000)" }
2087
+ }
2088
+ }
2089
+ }
2090
+ }
2091
+ },
2092
+ responses: {
2093
+ "200": { description: "run summary" },
2094
+ "401": { description: "missing or invalid Bearer token" },
2095
+ "503": { description: "trigger not configured (WORKFLOWS_API_KEY unset)" }
2096
+ }
2097
+ }
2098
+ }
2099
+ },
2100
+ components: {
2101
+ securitySchemes: {
2102
+ bearerAuth: { type: "http", scheme: "bearer" }
2103
+ }
167
2104
  }
168
2105
  };
169
2106
  }
2107
+ function bearerMatches(authHeader, expected) {
2108
+ if (!authHeader.startsWith("Bearer "))
2109
+ return false;
2110
+ const supplied = authHeader.slice("Bearer ".length);
2111
+ if (supplied.length === 0)
2112
+ return false;
2113
+ const a = createHash4("sha256").update(supplied).digest();
2114
+ const b = createHash4("sha256").update(expected).digest();
2115
+ return timingSafeEqual(a, b);
2116
+ }
2117
+ async function handleTrigger(service, req) {
2118
+ const expected = service.config.apiKey;
2119
+ if (!expected) {
2120
+ return jsonResponse({ error: "trigger_not_configured", message: "WORKFLOWS_API_KEY is not configured \u2014 set it to enable the authenticated trigger" }, 503);
2121
+ }
2122
+ const auth = req.headers.get("authorization") ?? "";
2123
+ if (!bearerMatches(auth, expected)) {
2124
+ return jsonResponse({ error: "unauthorized", message: "a valid Bearer token is required" }, 401);
2125
+ }
2126
+ let body;
2127
+ try {
2128
+ body = await req.json();
2129
+ } catch {
2130
+ return jsonResponse({ error: "invalid_json", message: "request body must be JSON" }, 400);
2131
+ }
2132
+ const record = body;
2133
+ if (record.graph === null || typeof record.graph !== "object" || Array.isArray(record.graph)) {
2134
+ return jsonResponse({ error: "graph_required", message: "body.graph must be a workflow graph object" }, 400);
2135
+ }
2136
+ const context = record.context ?? {};
2137
+ if (typeof context !== "object" || Array.isArray(context)) {
2138
+ return jsonResponse({ error: "invalid_context", message: "body.context must be an object" }, 400);
2139
+ }
2140
+ const maxCycles = typeof record.maxCycles === "number" ? record.maxCycles : undefined;
2141
+ const idempotencyKey = typeof record.idempotencyKey === "string" ? record.idempotencyKey : undefined;
2142
+ try {
2143
+ const summary = await service.triggerRun(record.graph, context, { maxCycles, idempotencyKey });
2144
+ return jsonResponse(summary);
2145
+ } catch (err) {
2146
+ const message = err instanceof Error ? err.message : String(err);
2147
+ return jsonResponse({ error: "trigger_failed", message }, 400);
2148
+ }
2149
+ }
170
2150
  function createRequestHandler(service) {
171
2151
  return (req) => {
172
2152
  const url = new URL(req.url);
@@ -177,6 +2157,8 @@ function createRequestHandler(service) {
177
2157
  return jsonResponse(service.ready());
178
2158
  case "/version":
179
2159
  return jsonResponse({ service: service.name, version: service.version });
2160
+ case "/trigger":
2161
+ return handleTrigger(service, req);
180
2162
  case "/openapi.json":
181
2163
  return jsonResponse(openApiDoc(service));
182
2164
  default:
@@ -186,6 +2168,7 @@ function createRequestHandler(service) {
186
2168
  }
187
2169
 
188
2170
  // src/agent-tools.ts
2171
+ init_lanes();
189
2172
  function textResult(payload) {
190
2173
  return { content: [{ type: "text", text: JSON.stringify(payload) }], isError: false };
191
2174
  }
@@ -205,10 +2188,47 @@ function workflowsTools(_service) {
205
2188
  name: "workflows_ready",
206
2189
  description: "Report the workflows service readiness (version check).",
207
2190
  inputSchema: { type: "object", properties: {} }
2191
+ },
2192
+ {
2193
+ name: "workflows_validate",
2194
+ description: "Validate a workflow graph (JSON object with name, version, nodes).",
2195
+ inputSchema: {
2196
+ type: "object",
2197
+ properties: { graph: { type: "object", description: "the workflow graph" } },
2198
+ required: ["graph"]
2199
+ }
2200
+ },
2201
+ {
2202
+ name: "workflows_run",
2203
+ description: "Run a workflow graph to a terminal state (bounded cycles; command steps execute; lane steps need the lane substrate).",
2204
+ inputSchema: {
2205
+ type: "object",
2206
+ properties: {
2207
+ graph: { type: "object", description: "the workflow graph" },
2208
+ context: { type: "object", description: "optional run context" }
2209
+ },
2210
+ required: ["graph"]
2211
+ }
2212
+ },
2213
+ {
2214
+ name: "workflows_runs_list",
2215
+ description: "List recent runs from the local store.",
2216
+ inputSchema: {
2217
+ type: "object",
2218
+ properties: {
2219
+ status: { type: "string", description: "optional status filter" },
2220
+ limit: { type: "number", description: "max rows (default 100)" }
2221
+ }
2222
+ }
2223
+ },
2224
+ {
2225
+ name: "workflows_lanes_list",
2226
+ description: "List the four lane adapters with their wired-vs-not-ready-with-reason registry shape.",
2227
+ inputSchema: { type: "object", properties: {} }
208
2228
  }
209
2229
  ];
210
2230
  }
211
- function callWorkflowTool(service, name, _args) {
2231
+ async function callWorkflowTool(service, name, args) {
212
2232
  switch (name) {
213
2233
  case "workflows_version":
214
2234
  return textResult({ service: service.name, version: service.version });
@@ -216,6 +2236,39 @@ function callWorkflowTool(service, name, _args) {
216
2236
  return textResult(service.health());
217
2237
  case "workflows_ready":
218
2238
  return textResult(service.ready());
2239
+ case "workflows_validate": {
2240
+ const graph = args.graph;
2241
+ const result = validateGraph(graph);
2242
+ return textResult(result);
2243
+ }
2244
+ case "workflows_run": {
2245
+ const graph = args.graph;
2246
+ const store = openStore(service.config.dataDir);
2247
+ const wal = SessionWAL.open(service.config.dataDir);
2248
+ try {
2249
+ const final = await runGraphToCompletion(store, wal, graph, args.context ?? {});
2250
+ return textResult({
2251
+ runId: final.id,
2252
+ status: final.status,
2253
+ error: final.error ?? null,
2254
+ result: final.resultJson ? JSON.parse(final.resultJson) : null
2255
+ });
2256
+ } finally {
2257
+ store.close();
2258
+ }
2259
+ }
2260
+ case "workflows_runs_list": {
2261
+ const store = openStore(service.config.dataDir);
2262
+ try {
2263
+ const status = typeof args.status === "string" ? args.status : undefined;
2264
+ const limit = typeof args.limit === "number" ? args.limit : 100;
2265
+ return textResult(store.listRuns({ status, limit }));
2266
+ } finally {
2267
+ store.close();
2268
+ }
2269
+ }
2270
+ case "workflows_lanes_list":
2271
+ return textResult(await laneInventory());
219
2272
  default:
220
2273
  throw new Error(`Unknown tool: ${name}`);
221
2274
  }