@hasna/workflows 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/index.js CHANGED
@@ -73,7 +73,7 @@ function workflowsTools(_service) {
73
73
  // package.json
74
74
  var package_default = {
75
75
  name: "@hasna/workflows",
76
- version: "0.2.0",
76
+ version: "0.2.2",
77
77
  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",
78
78
  type: "module",
79
79
  main: "./dist/index.js",
@@ -149,9 +149,9 @@ var package_default = {
149
149
  dependencies: {
150
150
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
151
151
  "@cursor/sdk": "^1.0.28",
152
- "@openai/codex-sdk": "0.147.0",
152
+ "@hasna/contracts": "1.3.5",
153
+ "@openai/codex-sdk": "0.160.0",
153
154
  commander: "^13.1.0",
154
- "@hasna/contracts": "1.3.3",
155
155
  pg: "^8.16.3"
156
156
  },
157
157
  devDependencies: {
@@ -346,6 +346,7 @@ function parseExpr(input) {
346
346
  // src/graph.ts
347
347
  var GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
348
348
  var LANE_KINDS = ["claude", "codex", "cursor", "grok"];
349
+ var CODEX_EXECUTION_MODES = ["repository", "read-only"];
349
350
  var NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
350
351
  function issue(issues, path, message) {
351
352
  issues.push({ path, message });
@@ -564,6 +565,16 @@ function validateNode(graph, node, issues) {
564
565
  if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
565
566
  issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
566
567
  }
568
+ if (node.cwd !== undefined && (typeof node.cwd !== "string" || !node.cwd.trim() || node.cwd.includes("\x00"))) {
569
+ issue(issues, `nodes.${node.id}.cwd`, "cwd must be a non-empty path string without NUL characters on the executor");
570
+ }
571
+ if (node.codexMode !== undefined) {
572
+ if (!CODEX_EXECUTION_MODES.includes(node.codexMode)) {
573
+ issue(issues, `nodes.${node.id}.codexMode`, "codexMode must be repository or read-only");
574
+ } else if (node.lane !== "codex" || !node.prompt || node.command !== undefined) {
575
+ issue(issues, `nodes.${node.id}.codexMode`, "codexMode is supported only for a codex prompt step without command");
576
+ }
577
+ }
567
578
  if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
568
579
  issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
569
580
  }
@@ -642,7 +653,7 @@ function validateNode(graph, node, issues) {
642
653
  }
643
654
  }
644
655
 
645
- // ../../workflows-release-artifact-1/app/node_modules/@hasna/contracts/dist/client/transport.js
656
+ // ../../app-test-sandbox-1/source/node_modules/@hasna/contracts/dist/client/transport.js
646
657
  import { isIP } from "net";
647
658
  import { spawnSync } from "child_process";
648
659
  import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
package/dist/sdk.js CHANGED
@@ -1,4 +1,4 @@
1
- // ../../workflows-release-artifact-1/app/node_modules/@hasna/contracts/dist/client/transport.js
1
+ // ../../app-test-sandbox-1/source/node_modules/@hasna/contracts/dist/client/transport.js
2
2
  import { isIP } from "net";
3
3
  import { spawnSync } from "child_process";
4
4
  import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
@@ -1448,7 +1448,7 @@ function workflowAgent(env = process.env, explicit) {
1448
1448
  // package.json
1449
1449
  var package_default = {
1450
1450
  name: "@hasna/workflows",
1451
- version: "0.2.0",
1451
+ version: "0.2.2",
1452
1452
  description: "Universal graph workflow app — 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",
1453
1453
  type: "module",
1454
1454
  main: "./dist/index.js",
@@ -1524,9 +1524,9 @@ var package_default = {
1524
1524
  dependencies: {
1525
1525
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
1526
1526
  "@cursor/sdk": "^1.0.28",
1527
- "@openai/codex-sdk": "0.147.0",
1527
+ "@hasna/contracts": "1.3.5",
1528
+ "@openai/codex-sdk": "0.160.0",
1528
1529
  commander: "^13.1.0",
1529
- "@hasna/contracts": "1.3.3",
1530
1530
  pg: "^8.16.3"
1531
1531
  },
1532
1532
  devDependencies: {
@@ -22,7 +22,7 @@ var package_default;
22
22
  var init_package = __esm(() => {
23
23
  package_default = {
24
24
  name: "@hasna/workflows",
25
- version: "0.2.0",
25
+ version: "0.2.2",
26
26
  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",
27
27
  type: "module",
28
28
  main: "./dist/index.js",
@@ -98,9 +98,9 @@ var init_package = __esm(() => {
98
98
  dependencies: {
99
99
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
100
100
  "@cursor/sdk": "^1.0.28",
101
- "@openai/codex-sdk": "0.147.0",
101
+ "@hasna/contracts": "1.3.5",
102
+ "@openai/codex-sdk": "0.160.0",
102
103
  commander: "^13.1.0",
103
- "@hasna/contracts": "1.3.3",
104
104
  pg: "^8.16.3"
105
105
  },
106
106
  devDependencies: {
@@ -606,6 +606,16 @@ function validateNode(graph, node, issues) {
606
606
  if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
607
607
  issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
608
608
  }
609
+ if (node.cwd !== undefined && (typeof node.cwd !== "string" || !node.cwd.trim() || node.cwd.includes("\x00"))) {
610
+ issue(issues, `nodes.${node.id}.cwd`, "cwd must be a non-empty path string without NUL characters on the executor");
611
+ }
612
+ if (node.codexMode !== undefined) {
613
+ if (!CODEX_EXECUTION_MODES.includes(node.codexMode)) {
614
+ issue(issues, `nodes.${node.id}.codexMode`, "codexMode must be repository or read-only");
615
+ } else if (node.lane !== "codex" || !node.prompt || node.command !== undefined) {
616
+ issue(issues, `nodes.${node.id}.codexMode`, "codexMode is supported only for a codex prompt step without command");
617
+ }
618
+ }
609
619
  if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
610
620
  issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
611
621
  }
@@ -683,11 +693,12 @@ function validateNode(graph, node, issues) {
683
693
  break;
684
694
  }
685
695
  }
686
- var GRAPH_NODE_TYPES, LANE_KINDS, NODE_ID_RE;
696
+ var GRAPH_NODE_TYPES, LANE_KINDS, CODEX_EXECUTION_MODES, NODE_ID_RE;
687
697
  var init_graph = __esm(() => {
688
698
  init_expr();
689
699
  GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
690
700
  LANE_KINDS = ["claude", "codex", "cursor", "grok"];
701
+ CODEX_EXECUTION_MODES = ["repository", "read-only"];
691
702
  NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
692
703
  });
693
704
 
@@ -1355,9 +1366,15 @@ async function spawnCli(cmd, opts) {
1355
1366
  new Response(proc.stderr).text(),
1356
1367
  proc.exited
1357
1368
  ]);
1358
- const output = stderr ? `${stdout}
1369
+ const output = opts.stdoutOnly ? stdout.trim() : stderr ? `${stdout}
1359
1370
  ${stderr}`.trim() : stdout.trim();
1360
- return { ok: exitCode === 0, exitCode, output, durationMs: Date.now() - started };
1371
+ return {
1372
+ ok: exitCode === 0,
1373
+ exitCode,
1374
+ output,
1375
+ durationMs: Date.now() - started,
1376
+ ...opts.stdoutOnly && exitCode !== 0 && stderr.trim() ? { error: stderr.trim() } : {}
1377
+ };
1361
1378
  }
1362
1379
  var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000, LaneTimeoutError;
1363
1380
  var init_types = __esm(() => {
@@ -1501,6 +1518,9 @@ function createCodexAdapter(deps = {}) {
1501
1518
  if (!job.prompt) {
1502
1519
  return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
1503
1520
  }
1521
+ if (job.codexMode !== undefined && (!CODEX_EXECUTION_MODES.includes(job.codexMode) || job.lane !== "codex" || job.command !== undefined)) {
1522
+ return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codexMode must be repository or read-only on a codex prompt without command", retryable: false };
1523
+ }
1504
1524
  const started = Date.now();
1505
1525
  const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
1506
1526
  const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
@@ -1520,13 +1540,20 @@ function createCodexAdapter(deps = {}) {
1520
1540
  config: { model: job.model },
1521
1541
  env: subscriptionEnvironment(job.env)
1522
1542
  })), timeoutMs, "codex lane");
1523
- const thread = client.startThread?.({ model: job.model, workingDirectory: job.cwd });
1543
+ const thread = client.startThread?.({
1544
+ model: job.model,
1545
+ workingDirectory: job.cwd,
1546
+ ...job.codexMode === "read-only" ? { sandboxMode: "read-only", skipGitRepoCheck: true, approvalPolicy: "never" } : {}
1547
+ });
1524
1548
  if (!thread || typeof thread.run !== "function") {
1525
1549
  throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
1526
1550
  }
1527
1551
  const turn = await withTimeout(thread.run(job.prompt, { signal: abortController.signal }), timeoutMs, "codex lane", () => abortController.abort());
1528
- const output = turn?.finalResponse ?? "";
1529
1552
  const durationMs = Date.now() - started;
1553
+ const output = turn?.finalResponse;
1554
+ if (typeof output !== "string" || !output.trim()) {
1555
+ return missingResponse(durationMs);
1556
+ }
1530
1557
  return { ok: true, exitCode: 0, output, durationMs };
1531
1558
  } catch (err) {
1532
1559
  const durationMs = Date.now() - started;
@@ -1537,14 +1564,17 @@ function createCodexAdapter(deps = {}) {
1537
1564
  }
1538
1565
  async function runCodexCli(job, cliPath, started, timeoutMs) {
1539
1566
  try {
1540
- const result = await spawnCli([cliPath, "exec", "--", job.prompt], {
1567
+ const controls = job.codexMode === "read-only" ? ["--sandbox", "read-only", "--skip-git-repo-check", "--config", 'approval_policy="never"'] : [];
1568
+ const result = await spawnCli([cliPath, "exec", ...controls, "--", job.prompt], {
1541
1569
  binaryName: cliPath,
1542
1570
  packageHint: "@openai/codex-sdk or the codex CLI",
1543
1571
  timeoutMs,
1544
1572
  env: job.env,
1545
- cwd: job.cwd
1573
+ cwd: job.cwd,
1574
+ stdoutOnly: true
1546
1575
  });
1547
- return { ...result, durationMs: Date.now() - started };
1576
+ const durationMs = Date.now() - started;
1577
+ return result.ok && !result.output.trim() ? missingResponse(durationMs) : { ...result, durationMs };
1548
1578
  } catch (err) {
1549
1579
  if (err instanceof LaneDependencyMissingError) {
1550
1580
  return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
@@ -1552,7 +1582,11 @@ async function runCodexCli(job, cliPath, started, timeoutMs) {
1552
1582
  throw err;
1553
1583
  }
1554
1584
  }
1585
+ function missingResponse(durationMs) {
1586
+ return { ok: false, exitCode: 1, output: "", durationMs, error: "Codex completed without an agent response; verify the native subscription and model route", retryable: false };
1587
+ }
1555
1588
  var init_codex = __esm(() => {
1589
+ init_graph();
1556
1590
  init_types();
1557
1591
  });
1558
1592
 
@@ -2069,7 +2103,14 @@ class WorkflowsDaemon {
2069
2103
  const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
2070
2104
  const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? await this.store.createRunNode({ runId: run2.id, nodeId, lane: node2.lane ?? "claude" }) : latest;
2071
2105
  const memoWatch = node2.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
2072
- const memoSource = { command: node2.command, prompt: node2.prompt, context: stripRunState(context) };
2106
+ const memoSource = {
2107
+ command: node2.command,
2108
+ prompt: node2.prompt,
2109
+ context: stripRunState(context),
2110
+ lane: node2.lane ?? "claude",
2111
+ cwd: node2.cwd,
2112
+ codexMode: node2.lane === "codex" ? node2.codexMode ?? "repository" : undefined
2113
+ };
2073
2114
  const memoInput = node2.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
2074
2115
  if (memoInput !== undefined) {
2075
2116
  const hit = await tryMemoHit(this.store, run2.graphName, nodeId, memoInput);
@@ -2094,6 +2135,8 @@ class WorkflowsDaemon {
2094
2135
  lane: node2.lane ?? "claude",
2095
2136
  prompt: node2.prompt,
2096
2137
  command: node2.command,
2138
+ cwd: node2.cwd,
2139
+ codexMode: node2.codexMode,
2097
2140
  timeoutMs: node2.timeoutMs,
2098
2141
  env: this.env
2099
2142
  };
@@ -2689,7 +2732,7 @@ var init_store = __esm(() => {
2689
2732
  // src/wal.ts
2690
2733
  import { Database as Database2 } from "bun:sqlite";
2691
2734
  import { createHash as createHash2 } from "crypto";
2692
- import { appendFileSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2735
+ import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2693
2736
  import { basename, dirname, join as join4 } from "path";
2694
2737
  function checksumOf(op) {
2695
2738
  if (op === undefined || op === null)
@@ -2756,8 +2799,20 @@ class SessionWAL {
2756
2799
  const full = { ...op, at };
2757
2800
  const nextSeq = this.nextSeq();
2758
2801
  const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
2759
- appendFileSync(this.filePath, JSON.stringify(entry) + `
2760
- `, { encoding: "utf8", mode: 384 });
2802
+ const fd = openSync(this.filePath, "a", 384);
2803
+ try {
2804
+ writeFileSync(fd, JSON.stringify(entry) + `
2805
+ `, "utf8");
2806
+ fsyncSync(fd);
2807
+ } finally {
2808
+ closeSync(fd);
2809
+ }
2810
+ const parent = openSync(dirname(this.filePath), "r");
2811
+ try {
2812
+ fsyncSync(parent);
2813
+ } finally {
2814
+ closeSync(parent);
2815
+ }
2761
2816
  return entry;
2762
2817
  }
2763
2818
  nextSeq() {
@@ -2774,7 +2829,9 @@ class SessionWAL {
2774
2829
  let content;
2775
2830
  try {
2776
2831
  content = readFileSync2(this.filePath, "utf8");
2777
- } catch {
2832
+ } catch (error) {
2833
+ if (error.code !== "ENOENT")
2834
+ throw error;
2778
2835
  return { entries, torn, repaired, liveClaims: () => new Map };
2779
2836
  }
2780
2837
  if (content.length > MAX_WAL_BYTES) {
@@ -2868,6 +2925,40 @@ var init_wal = __esm(() => {
2868
2925
  MAX_WAL_BYTES = 256 * 1024 * 1024;
2869
2926
  });
2870
2927
 
2928
+ // src/journal-directory.ts
2929
+ import { accessSync, closeSync as closeSync2, constants as constants2, fstatSync, lstatSync, openSync as openSync2, readFileSync as readFileSync3, readSync } from "fs";
2930
+ import { join as join5 } from "path";
2931
+ function assertJournalDirectory(dataDir2, identity) {
2932
+ try {
2933
+ if (!lstatSync(dataDir2).isDirectory() || !lstatSync(join5(dataDir2, "sessions")).isDirectory())
2934
+ throw new Error;
2935
+ accessSync(dataDir2, constants2.R_OK | constants2.W_OK | constants2.X_OK);
2936
+ accessSync(join5(dataDir2, "sessions"), constants2.R_OK | constants2.W_OK | constants2.X_OK);
2937
+ const marker = openSync2(join5(dataDir2, ".workflows-data-identity"), constants2.O_RDONLY | constants2.O_NOFOLLOW);
2938
+ try {
2939
+ const stat = fstatSync(marker);
2940
+ if (!stat.isFile() || stat.size > 256 || readFileSync3(marker, "utf8") !== identity + `
2941
+ `)
2942
+ throw new Error;
2943
+ } finally {
2944
+ closeSync2(marker);
2945
+ }
2946
+ for (const file of ["workflows.db", "sessions/session.wal"]) {
2947
+ const fd = openSync2(join5(dataDir2, file), constants2.O_RDWR | constants2.O_NOFOLLOW);
2948
+ try {
2949
+ if (!fstatSync(fd).isFile())
2950
+ throw new Error;
2951
+ readSync(fd, Buffer.alloc(1), 0, 1, 0);
2952
+ } finally {
2953
+ closeSync2(fd);
2954
+ }
2955
+ }
2956
+ } catch {
2957
+ throw new Error("configured execution journal identity or accessibility check failed");
2958
+ }
2959
+ }
2960
+ var init_journal_directory = () => {};
2961
+
2871
2962
  // src/service.ts
2872
2963
  import { createHash as createHash3 } from "crypto";
2873
2964
  import { hostname } from "os";
@@ -2888,6 +2979,14 @@ function envInt(names, fallback) {
2888
2979
  return Number.isFinite(parsed) ? parsed : fallback;
2889
2980
  }
2890
2981
  function resolveWorkflowsConfig(overrides = {}) {
2982
+ const rawLanes = process.env.HASNA_WORKFLOWS_ENABLED_LANES;
2983
+ const enabledLanes = overrides.enabledLanes ?? (rawLanes === undefined ? LANE_KINDS : rawLanes.trim() === "" ? [] : rawLanes.split(",").map((value) => value.trim()));
2984
+ if (!Array.isArray(enabledLanes) || enabledLanes.some((lane) => !LANE_KINDS.includes(lane)) || new Set(enabledLanes).size !== enabledLanes.length) {
2985
+ throw new Error("HASNA_WORKFLOWS_ENABLED_LANES must be a unique comma-separated subset of claude,codex,cursor,grok; an empty value disables model lanes");
2986
+ }
2987
+ const dataIdentity = overrides.dataIdentity ?? process.env.HASNA_WORKFLOWS_DATA_IDENTITY;
2988
+ if (dataIdentity !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(dataIdentity))
2989
+ throw new Error("HASNA_WORKFLOWS_DATA_IDENTITY must be a nonempty data identity of at most 128 characters");
2891
2990
  const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
2892
2991
  if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
2893
2992
  throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
@@ -2898,9 +2997,11 @@ function resolveWorkflowsConfig(overrides = {}) {
2898
2997
  apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
2899
2998
  apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
2900
2999
  originAuth,
3000
+ dataIdentity,
2901
3001
  storageBackend: overrides.storageBackend ?? "postgresql",
2902
3002
  databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
2903
- databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
3003
+ databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"]),
3004
+ enabledLanes: Object.freeze([...enabledLanes])
2904
3005
  };
2905
3006
  }
2906
3007
 
@@ -2933,16 +3034,52 @@ class WorkflowsService {
2933
3034
  }
2934
3035
  async ready() {
2935
3036
  const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
2936
- if (this.config.apiKey) {
3037
+ if (this.config.dataIdentity !== undefined) {
3038
+ try {
3039
+ this.verifyJournalDirectory();
3040
+ checks.journal = "ok";
3041
+ } catch {
3042
+ checks.journal = "unavailable";
3043
+ }
3044
+ }
3045
+ if (this.config.apiKey && checks.journal !== "unavailable") {
2937
3046
  try {
2938
3047
  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");
2939
3048
  } catch (error) {
2940
3049
  checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
2941
3050
  }
2942
3051
  }
2943
- return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
3052
+ return { ok: checks.auth === "ok" && checks.storage === "ok" && checks.journal !== "unavailable", service: this.name, version: this.version, checks };
3053
+ }
3054
+ verifyJournalDirectory() {
3055
+ if (this.config.dataIdentity !== undefined) {
3056
+ try {
3057
+ assertJournalDirectory(this.config.dataDir, this.config.dataIdentity);
3058
+ } catch {
3059
+ throw new WorkflowRequestError("journal_unavailable", "configured execution journal identity or accessibility check failed", 503);
3060
+ }
3061
+ }
3062
+ }
3063
+ assertEnabledGraphLanes(graph) {
3064
+ if (!Array.isArray(graph?.nodes))
3065
+ return;
3066
+ for (const node2 of graph.nodes) {
3067
+ if (node2.type === "step" && !node2.command && !this.config.enabledLanes.includes(node2.lane ?? "claude")) {
3068
+ throw new WorkflowRequestError("lane_disabled", "the workflow contains a model lane disabled by server configuration", 403);
3069
+ }
3070
+ }
3071
+ }
3072
+ async probeLane(kind) {
3073
+ if (this.config.enabledLanes.includes(kind))
3074
+ return probeLane(kind);
3075
+ const sdk = { claude: "@anthropic-ai/claude-agent-sdk", codex: "@openai/codex-sdk", cursor: "@cursor/sdk", grok: "no npm SDK (measured 2026-08-25)" };
3076
+ return { kind, sdk: sdk[kind], wired: false, reason: "disabled by server configuration" };
3077
+ }
3078
+ laneInventory() {
3079
+ return Promise.all(LANE_KINDS.map((kind) => this.probeLane(kind)));
2944
3080
  }
2945
3081
  async openEngineStore(verifySchema = true) {
3082
+ this.verifyJournalDirectory();
2946
3083
  if (this.config.storageBackend === "sqlite")
2947
3084
  return openStore(this.config.dataDir);
2948
3085
  if (this.config.storageBackend !== "postgresql")
@@ -2996,6 +3133,7 @@ class WorkflowsService {
2996
3133
  return this.withStore((store) => store.listRunNodes(id));
2997
3134
  }
2998
3135
  events(id) {
3136
+ this.verifyJournalDirectory();
2999
3137
  return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
3000
3138
  }
3001
3139
  cancelRun(id) {
@@ -3012,7 +3150,6 @@ class WorkflowsService {
3012
3150
  }
3013
3151
  async resumeRun(id) {
3014
3152
  const store = await this.openEngineStore();
3015
- const wal = SessionWAL.open(this.config.dataDir);
3016
3153
  try {
3017
3154
  const run2 = await store.getRun(id);
3018
3155
  if (!run2)
@@ -3020,13 +3157,15 @@ class WorkflowsService {
3020
3157
  if (!["failed", "cancelled", "interrupted"].includes(run2.status))
3021
3158
  throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
3022
3159
  const context = JSON.parse(run2.contextJson);
3160
+ const graph = context.__wf?.graph;
3161
+ if (!graph)
3162
+ throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
3163
+ this.assertEnabledGraphLanes(graph);
3164
+ const wal = SessionWAL.open(this.config.dataDir);
3023
3165
  if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
3024
3166
  throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
3025
3167
  if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
3026
3168
  throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
3027
- const graph = context.__wf?.graph;
3028
- if (!graph)
3029
- throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
3030
3169
  if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
3031
3170
  throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
3032
3171
  for (const node2 of await store.listRunNodes(id)) {
@@ -3040,6 +3179,7 @@ class WorkflowsService {
3040
3179
  }
3041
3180
  }
3042
3181
  sessions(action) {
3182
+ this.verifyJournalDirectory();
3043
3183
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3044
3184
  if (action === "list")
3045
3185
  return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
@@ -3057,6 +3197,7 @@ class WorkflowsService {
3057
3197
  return { ...local, health: this.health(), sessions: this.sessions("pull") };
3058
3198
  }
3059
3199
  daemonStatus() {
3200
+ this.verifyJournalDirectory();
3060
3201
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3061
3202
  return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
3062
3203
  }
@@ -3064,6 +3205,7 @@ class WorkflowsService {
3064
3205
  return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
3065
3206
  }
3066
3207
  async triggerRun(graph, context = {}, opts = {}) {
3208
+ this.assertEnabledGraphLanes(graph);
3067
3209
  const store = await this.openEngineStore();
3068
3210
  const wal = SessionWAL.open(this.config.dataDir);
3069
3211
  try {
@@ -3133,6 +3275,9 @@ var init_service = __esm(() => {
3133
3275
  init_postgres_store();
3134
3276
  init_store();
3135
3277
  init_wal();
3278
+ init_journal_directory();
3279
+ init_graph();
3280
+ init_lanes();
3136
3281
  init_version();
3137
3282
  WorkflowRequestError = class WorkflowRequestError extends Error {
3138
3283
  code;
@@ -3283,7 +3428,7 @@ function buildOpenApi(version) {
3283
3428
  ReadinessReport: object({ ok: { type: "boolean" }, service: string, version: string, checks: { type: "object", additionalProperties: string } }),
3284
3429
  GraphValidation: object({ ok: { type: "boolean" }, issues: { type: "array", items: object({ path: string, message: string }) } }),
3285
3430
  StartNode: object({ id: string, type: { type: "string", enum: ["start"] }, next: string }, ["id", "type"]),
3286
- StepNode: object({ id: string, type: { type: "string", enum: ["step"] }, next: string, lane: { type: "string", enum: ["claude", "codex", "cursor", "grok"] }, prompt: string, command: string, maxRetries: { type: "integer", minimum: 0 }, memo: { type: "boolean" }, timeoutMs: { type: "integer", minimum: 1 } }, ["id", "type"]),
3431
+ 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"]),
3287
3432
  DecisionNode: object({ id: string, type: { type: "string", enum: ["decision"] }, condition: string, then: string, else: string }, ["id", "type", "condition"]),
3288
3433
  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"]),
3289
3434
  EndNode: object({ id: string, type: { type: "string", enum: ["end"] } }),
@@ -3443,12 +3588,12 @@ function createRequestHandler(service) {
3443
3588
  return jsonResponse(await service.listRuns({ limit, status }));
3444
3589
  }
3445
3590
  if (path === "/lanes")
3446
- return jsonResponse(await laneInventory());
3591
+ return jsonResponse(await service.laneInventory());
3447
3592
  if (path.startsWith("/lanes/")) {
3448
3593
  const lane = path.slice("/lanes/".length);
3449
3594
  if (!LANE_KINDS2.includes(lane))
3450
3595
  return jsonResponse({ error: "invalid_lane" }, 400);
3451
- return jsonResponse(await probeLane(lane));
3596
+ return jsonResponse(await service.probeLane(lane));
3452
3597
  }
3453
3598
  if (path === "/sessions/list")
3454
3599
  return jsonResponse(service.sessions("list"));
@@ -3524,13 +3669,13 @@ var init_server = __esm(() => {
3524
3669
  // src/serve/admin.ts
3525
3670
  var exports_admin = {};
3526
3671
  import { Command } from "commander";
3527
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
3672
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
3528
3673
  import { hostname as hostname2 } from "os";
3529
- import { join as join5 } from "path";
3674
+ import { join as join6 } from "path";
3530
3675
  function loadGraph(file) {
3531
3676
  let raw;
3532
3677
  try {
3533
- raw = readFileSync3(file, "utf8");
3678
+ raw = readFileSync4(file, "utf8");
3534
3679
  } catch {
3535
3680
  throw new Error(`cannot read graph file ${file}`);
3536
3681
  }
@@ -3623,7 +3768,7 @@ function addReapReports(left, right) {
3623
3768
  return total;
3624
3769
  }
3625
3770
  function writeReport(statusFile, report) {
3626
- mkdirSync3(join5(statusFile, ".."), { recursive: true });
3771
+ mkdirSync3(join6(statusFile, ".."), { recursive: true });
3627
3772
  let previous = emptyReapReport();
3628
3773
  if (existsSync3(statusFile)) {
3629
3774
  previous = readReport(statusFile).cumulative;
@@ -3643,7 +3788,7 @@ function writeReport(statusFile, report) {
3643
3788
  }
3644
3789
  }
3645
3790
  function readReport(statusFile) {
3646
- const parsed = JSON.parse(readFileSync3(statusFile, "utf8"));
3791
+ const parsed = JSON.parse(readFileSync4(statusFile, "utf8"));
3647
3792
  if (parsed !== null && typeof parsed === "object") {
3648
3793
  const record = parsed;
3649
3794
  if (record.version === 1 && record.latestCycle !== undefined && record.cumulative !== undefined) {
@@ -3697,11 +3842,11 @@ var init_admin = __esm(() => {
3697
3842
  });
3698
3843
  program.command("init").description("Create the store layout (workflows/, sessions/, workflows.db) and scaffold a sample graph").argument("[file]", "sample graph output path (default <dataDir>/workflows/demo.json)").action((file) => {
3699
3844
  const config = resolveWorkflowsConfig();
3700
- mkdirSync3(join5(config.dataDir, "workflows"), { recursive: true });
3701
- mkdirSync3(join5(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
3845
+ mkdirSync3(join6(config.dataDir, "workflows"), { recursive: true });
3846
+ mkdirSync3(join6(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
3702
3847
  const store = openStore(config.dataDir);
3703
3848
  store.close();
3704
- const target = file ?? join5(config.dataDir, "workflows", "demo.json");
3849
+ const target = file ?? join6(config.dataDir, "workflows", "demo.json");
3705
3850
  if (existsSync3(target)) {
3706
3851
  console.error(`refusing to overwrite existing file ${target}`);
3707
3852
  process.exit(1);
@@ -4005,17 +4150,17 @@ var init_admin = __esm(() => {
4005
4150
  }
4006
4151
  case "status": {
4007
4152
  const replay = wal.replay();
4008
- const pidFile = join5(config.dataDir, "daemon.pid");
4009
- const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
4153
+ const pidFile = join6(config.dataDir, "daemon.pid");
4154
+ const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
4010
4155
  const running = pid !== null && processAlive(Number(pid));
4011
4156
  const report = {
4012
4157
  hostname: hostname2(),
4013
4158
  pid: process.pid,
4014
4159
  dataDir: config.dataDir,
4015
4160
  layout: {
4016
- workflows: existsSync3(join5(config.dataDir, "workflows")),
4017
- sessions: existsSync3(join5(config.dataDir, SESSIONS_DIR_NAME)),
4018
- db: existsSync3(join5(config.dataDir, "workflows.db"))
4161
+ workflows: existsSync3(join6(config.dataDir, "workflows")),
4162
+ sessions: existsSync3(join6(config.dataDir, SESSIONS_DIR_NAME)),
4163
+ db: existsSync3(join6(config.dataDir, "workflows.db"))
4019
4164
  },
4020
4165
  store: {
4021
4166
  tables: store.listTables(),
@@ -4079,8 +4224,8 @@ var init_admin = __esm(() => {
4079
4224
  });
4080
4225
  program.command("daemon").description("Run or inspect the daemon").argument("<action>", "start | status | stop").option("--once", "run a single reap cycle and exit (start)").option("--interval-ms <n>", "reap interval (start)", "5000").option("--cycles <n>", "bound on consecutive cycles (start; default infinite until SIGINT)", "0").option("-j, --json", "JSON output").action(async (action, opts) => {
4081
4226
  const config = resolveWorkflowsConfig();
4082
- const pidFile = join5(config.dataDir, "daemon.pid");
4083
- const statusFile = join5(config.dataDir, "daemon.status.json");
4227
+ const pidFile = join6(config.dataDir, "daemon.pid");
4228
+ const statusFile = join6(config.dataDir, "daemon.status.json");
4084
4229
  switch (action) {
4085
4230
  case "start": {
4086
4231
  const { store, wal } = openLocal();
@@ -4137,7 +4282,7 @@ var init_admin = __esm(() => {
4137
4282
  return;
4138
4283
  }
4139
4284
  const envelope = readReport(statusFile);
4140
- const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
4285
+ const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
4141
4286
  const running = pid !== null && processAlive(Number(pid));
4142
4287
  if (opts.json) {
4143
4288
  console.log(JSON.stringify({ statusVersion: 1, running, pid, lastReap: envelope.latestCycle, cumulative: envelope.cumulative }, null, 2));
@@ -4151,7 +4296,7 @@ var init_admin = __esm(() => {
4151
4296
  console.error("no daemon pid file \u2014 nothing to stop");
4152
4297
  process.exit(1);
4153
4298
  }
4154
- const pid = Number(readFileSync3(pidFile, "utf8").trim());
4299
+ const pid = Number(readFileSync4(pidFile, "utf8").trim());
4155
4300
  try {
4156
4301
  process.kill(pid, "SIGTERM");
4157
4302
  } catch {