@hasna/workflows 0.1.4 → 0.2.1

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