@hasna/workflows 0.2.1 → 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/handlers.js CHANGED
@@ -1108,7 +1108,7 @@ import { createHash as createHash3 } from "crypto";
1108
1108
  // package.json
1109
1109
  var package_default = {
1110
1110
  name: "@hasna/workflows",
1111
- version: "0.2.1",
1111
+ version: "0.2.2",
1112
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
1113
  type: "module",
1114
1114
  main: "./dist/index.js",
@@ -1184,7 +1184,7 @@ var package_default = {
1184
1184
  dependencies: {
1185
1185
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
1186
1186
  "@cursor/sdk": "^1.0.28",
1187
- "@hasna/contracts": "1.3.3",
1187
+ "@hasna/contracts": "1.3.5",
1188
1188
  "@openai/codex-sdk": "0.160.0",
1189
1189
  commander: "^13.1.0",
1190
1190
  pg: "^8.16.3"
@@ -2695,7 +2695,7 @@ function openStore(dataDir2) {
2695
2695
  // src/wal.ts
2696
2696
  import { Database as Database2 } from "bun:sqlite";
2697
2697
  import { createHash as createHash2 } from "crypto";
2698
- import { appendFileSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2698
+ import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2699
2699
  import { basename, dirname, join as join4 } from "path";
2700
2700
  var WAL_FILE_NAME = "session.wal";
2701
2701
  var SESSIONS_DIR_NAME = "sessions";
@@ -2765,8 +2765,20 @@ class SessionWAL {
2765
2765
  const full = { ...op, at };
2766
2766
  const nextSeq = this.nextSeq();
2767
2767
  const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
2768
- appendFileSync(this.filePath, JSON.stringify(entry) + `
2769
- `, { encoding: "utf8", mode: 384 });
2768
+ const fd = openSync(this.filePath, "a", 384);
2769
+ try {
2770
+ writeFileSync(fd, JSON.stringify(entry) + `
2771
+ `, "utf8");
2772
+ fsyncSync(fd);
2773
+ } finally {
2774
+ closeSync(fd);
2775
+ }
2776
+ const parent = openSync(dirname(this.filePath), "r");
2777
+ try {
2778
+ fsyncSync(parent);
2779
+ } finally {
2780
+ closeSync(parent);
2781
+ }
2770
2782
  return entry;
2771
2783
  }
2772
2784
  nextSeq() {
@@ -2783,7 +2795,9 @@ class SessionWAL {
2783
2795
  let content;
2784
2796
  try {
2785
2797
  content = readFileSync2(this.filePath, "utf8");
2786
- } catch {
2798
+ } catch (error) {
2799
+ if (error.code !== "ENOENT")
2800
+ throw error;
2787
2801
  return { entries, torn, repaired, liveClaims: () => new Map };
2788
2802
  }
2789
2803
  if (content.length > MAX_WAL_BYTES) {
@@ -2873,7 +2887,42 @@ function preserveOriginal(filePath) {
2873
2887
  return backup;
2874
2888
  }
2875
2889
 
2890
+ // src/journal-directory.ts
2891
+ import { accessSync, closeSync as closeSync2, constants as constants2, fstatSync, lstatSync, openSync as openSync2, readFileSync as readFileSync3, readSync } from "fs";
2892
+ import { join as join5 } from "path";
2893
+ function assertJournalDirectory(dataDir2, identity) {
2894
+ try {
2895
+ if (!lstatSync(dataDir2).isDirectory() || !lstatSync(join5(dataDir2, "sessions")).isDirectory())
2896
+ throw new Error;
2897
+ accessSync(dataDir2, constants2.R_OK | constants2.W_OK | constants2.X_OK);
2898
+ accessSync(join5(dataDir2, "sessions"), constants2.R_OK | constants2.W_OK | constants2.X_OK);
2899
+ const marker = openSync2(join5(dataDir2, ".workflows-data-identity"), constants2.O_RDONLY | constants2.O_NOFOLLOW);
2900
+ try {
2901
+ const stat = fstatSync(marker);
2902
+ if (!stat.isFile() || stat.size > 256 || readFileSync3(marker, "utf8") !== identity + `
2903
+ `)
2904
+ throw new Error;
2905
+ } finally {
2906
+ closeSync2(marker);
2907
+ }
2908
+ for (const file of ["workflows.db", "sessions/session.wal"]) {
2909
+ const fd = openSync2(join5(dataDir2, file), constants2.O_RDWR | constants2.O_NOFOLLOW);
2910
+ try {
2911
+ if (!fstatSync(fd).isFile())
2912
+ throw new Error;
2913
+ readSync(fd, Buffer.alloc(1), 0, 1, 0);
2914
+ } finally {
2915
+ closeSync2(fd);
2916
+ }
2917
+ }
2918
+ } catch {
2919
+ throw new Error("configured execution journal identity or accessibility check failed");
2920
+ }
2921
+ }
2922
+
2876
2923
  // src/service.ts
2924
+ init_graph();
2925
+ init_lanes();
2877
2926
  import { hostname } from "os";
2878
2927
  import { resolveDatabaseUrl } from "@hasna/contracts";
2879
2928
  var DEFAULT_PORT = 8790;
@@ -2894,6 +2943,14 @@ function envInt(names, fallback) {
2894
2943
  return Number.isFinite(parsed) ? parsed : fallback;
2895
2944
  }
2896
2945
  function resolveWorkflowsConfig(overrides = {}) {
2946
+ const rawLanes = process.env.HASNA_WORKFLOWS_ENABLED_LANES;
2947
+ const enabledLanes = overrides.enabledLanes ?? (rawLanes === undefined ? LANE_KINDS : rawLanes.trim() === "" ? [] : rawLanes.split(",").map((value) => value.trim()));
2948
+ if (!Array.isArray(enabledLanes) || enabledLanes.some((lane) => !LANE_KINDS.includes(lane)) || new Set(enabledLanes).size !== enabledLanes.length) {
2949
+ 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");
2950
+ }
2951
+ const dataIdentity = overrides.dataIdentity ?? process.env.HASNA_WORKFLOWS_DATA_IDENTITY;
2952
+ if (dataIdentity !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(dataIdentity))
2953
+ throw new Error("HASNA_WORKFLOWS_DATA_IDENTITY must be a nonempty data identity of at most 128 characters");
2897
2954
  const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
2898
2955
  if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
2899
2956
  throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
@@ -2904,9 +2961,11 @@ function resolveWorkflowsConfig(overrides = {}) {
2904
2961
  apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
2905
2962
  apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
2906
2963
  originAuth,
2964
+ dataIdentity,
2907
2965
  storageBackend: overrides.storageBackend ?? "postgresql",
2908
2966
  databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
2909
- databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
2967
+ databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"]),
2968
+ enabledLanes: Object.freeze([...enabledLanes])
2910
2969
  };
2911
2970
  }
2912
2971
 
@@ -2939,16 +2998,52 @@ class WorkflowsService {
2939
2998
  }
2940
2999
  async ready() {
2941
3000
  const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
2942
- if (this.config.apiKey) {
3001
+ if (this.config.dataIdentity !== undefined) {
3002
+ try {
3003
+ this.verifyJournalDirectory();
3004
+ checks.journal = "ok";
3005
+ } catch {
3006
+ checks.journal = "unavailable";
3007
+ }
3008
+ }
3009
+ if (this.config.apiKey && checks.journal !== "unavailable") {
2943
3010
  try {
2944
3011
  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
3012
  } catch (error) {
2946
3013
  checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
2947
3014
  }
2948
3015
  }
2949
- return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
3016
+ return { ok: checks.auth === "ok" && checks.storage === "ok" && checks.journal !== "unavailable", service: this.name, version: this.version, checks };
3017
+ }
3018
+ verifyJournalDirectory() {
3019
+ if (this.config.dataIdentity !== undefined) {
3020
+ try {
3021
+ assertJournalDirectory(this.config.dataDir, this.config.dataIdentity);
3022
+ } catch {
3023
+ throw new WorkflowRequestError("journal_unavailable", "configured execution journal identity or accessibility check failed", 503);
3024
+ }
3025
+ }
3026
+ }
3027
+ assertEnabledGraphLanes(graph) {
3028
+ if (!Array.isArray(graph?.nodes))
3029
+ return;
3030
+ for (const node2 of graph.nodes) {
3031
+ if (node2.type === "step" && !node2.command && !this.config.enabledLanes.includes(node2.lane ?? "claude")) {
3032
+ throw new WorkflowRequestError("lane_disabled", "the workflow contains a model lane disabled by server configuration", 403);
3033
+ }
3034
+ }
3035
+ }
3036
+ async probeLane(kind) {
3037
+ if (this.config.enabledLanes.includes(kind))
3038
+ return probeLane(kind);
3039
+ const sdk = { claude: "@anthropic-ai/claude-agent-sdk", codex: "@openai/codex-sdk", cursor: "@cursor/sdk", grok: "no npm SDK (measured 2026-08-25)" };
3040
+ return { kind, sdk: sdk[kind], wired: false, reason: "disabled by server configuration" };
3041
+ }
3042
+ laneInventory() {
3043
+ return Promise.all(LANE_KINDS.map((kind) => this.probeLane(kind)));
2950
3044
  }
2951
3045
  async openEngineStore(verifySchema = true) {
3046
+ this.verifyJournalDirectory();
2952
3047
  if (this.config.storageBackend === "sqlite")
2953
3048
  return openStore(this.config.dataDir);
2954
3049
  if (this.config.storageBackend !== "postgresql")
@@ -3002,6 +3097,7 @@ class WorkflowsService {
3002
3097
  return this.withStore((store) => store.listRunNodes(id));
3003
3098
  }
3004
3099
  events(id) {
3100
+ this.verifyJournalDirectory();
3005
3101
  return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
3006
3102
  }
3007
3103
  cancelRun(id) {
@@ -3018,7 +3114,6 @@ class WorkflowsService {
3018
3114
  }
3019
3115
  async resumeRun(id) {
3020
3116
  const store = await this.openEngineStore();
3021
- const wal = SessionWAL.open(this.config.dataDir);
3022
3117
  try {
3023
3118
  const run2 = await store.getRun(id);
3024
3119
  if (!run2)
@@ -3026,13 +3121,15 @@ class WorkflowsService {
3026
3121
  if (!["failed", "cancelled", "interrupted"].includes(run2.status))
3027
3122
  throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
3028
3123
  const context = JSON.parse(run2.contextJson);
3124
+ const graph = context.__wf?.graph;
3125
+ if (!graph)
3126
+ throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
3127
+ this.assertEnabledGraphLanes(graph);
3128
+ const wal = SessionWAL.open(this.config.dataDir);
3029
3129
  if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
3030
3130
  throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
3031
3131
  if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
3032
3132
  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
3133
  if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
3037
3134
  throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
3038
3135
  for (const node2 of await store.listRunNodes(id)) {
@@ -3046,6 +3143,7 @@ class WorkflowsService {
3046
3143
  }
3047
3144
  }
3048
3145
  sessions(action) {
3146
+ this.verifyJournalDirectory();
3049
3147
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3050
3148
  if (action === "list")
3051
3149
  return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
@@ -3063,6 +3161,7 @@ class WorkflowsService {
3063
3161
  return { ...local, health: this.health(), sessions: this.sessions("pull") };
3064
3162
  }
3065
3163
  daemonStatus() {
3164
+ this.verifyJournalDirectory();
3066
3165
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3067
3166
  return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
3068
3167
  }
@@ -3070,6 +3169,7 @@ class WorkflowsService {
3070
3169
  return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
3071
3170
  }
3072
3171
  async triggerRun(graph, context = {}, opts = {}) {
3172
+ this.assertEnabledGraphLanes(graph);
3073
3173
  const store = await this.openEngineStore();
3074
3174
  const wal = SessionWAL.open(this.config.dataDir);
3075
3175
  try {
@@ -3362,12 +3462,12 @@ function createRequestHandler(service) {
3362
3462
  return jsonResponse(await service.listRuns({ limit, status }));
3363
3463
  }
3364
3464
  if (path === "/lanes")
3365
- return jsonResponse(await laneInventory());
3465
+ return jsonResponse(await service.laneInventory());
3366
3466
  if (path.startsWith("/lanes/")) {
3367
3467
  const lane = path.slice("/lanes/".length);
3368
3468
  if (!LANE_KINDS2.includes(lane))
3369
3469
  return jsonResponse({ error: "invalid_lane" }, 400);
3370
- return jsonResponse(await probeLane(lane));
3470
+ return jsonResponse(await service.probeLane(lane));
3371
3471
  }
3372
3472
  if (path === "/sessions/list")
3373
3473
  return jsonResponse(service.sessions("list"));
package/dist/index.js CHANGED
@@ -1108,7 +1108,7 @@ import { createHash as createHash3 } from "crypto";
1108
1108
  // package.json
1109
1109
  var package_default = {
1110
1110
  name: "@hasna/workflows",
1111
- version: "0.2.1",
1111
+ version: "0.2.2",
1112
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
1113
  type: "module",
1114
1114
  main: "./dist/index.js",
@@ -1184,7 +1184,7 @@ var package_default = {
1184
1184
  dependencies: {
1185
1185
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
1186
1186
  "@cursor/sdk": "^1.0.28",
1187
- "@hasna/contracts": "1.3.3",
1187
+ "@hasna/contracts": "1.3.5",
1188
1188
  "@openai/codex-sdk": "0.160.0",
1189
1189
  commander: "^13.1.0",
1190
1190
  pg: "^8.16.3"
@@ -2695,7 +2695,7 @@ function openStore(dataDir2) {
2695
2695
  // src/wal.ts
2696
2696
  import { Database as Database2 } from "bun:sqlite";
2697
2697
  import { createHash as createHash2 } from "crypto";
2698
- import { appendFileSync, copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2698
+ import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
2699
2699
  import { basename, dirname, join as join4 } from "path";
2700
2700
  var WAL_FILE_NAME = "session.wal";
2701
2701
  var SESSIONS_DIR_NAME = "sessions";
@@ -2765,8 +2765,20 @@ class SessionWAL {
2765
2765
  const full = { ...op, at };
2766
2766
  const nextSeq = this.nextSeq();
2767
2767
  const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
2768
- appendFileSync(this.filePath, JSON.stringify(entry) + `
2769
- `, { encoding: "utf8", mode: 384 });
2768
+ const fd = openSync(this.filePath, "a", 384);
2769
+ try {
2770
+ writeFileSync(fd, JSON.stringify(entry) + `
2771
+ `, "utf8");
2772
+ fsyncSync(fd);
2773
+ } finally {
2774
+ closeSync(fd);
2775
+ }
2776
+ const parent = openSync(dirname(this.filePath), "r");
2777
+ try {
2778
+ fsyncSync(parent);
2779
+ } finally {
2780
+ closeSync(parent);
2781
+ }
2770
2782
  return entry;
2771
2783
  }
2772
2784
  nextSeq() {
@@ -2783,7 +2795,9 @@ class SessionWAL {
2783
2795
  let content;
2784
2796
  try {
2785
2797
  content = readFileSync2(this.filePath, "utf8");
2786
- } catch {
2798
+ } catch (error) {
2799
+ if (error.code !== "ENOENT")
2800
+ throw error;
2787
2801
  return { entries, torn, repaired, liveClaims: () => new Map };
2788
2802
  }
2789
2803
  if (content.length > MAX_WAL_BYTES) {
@@ -2873,7 +2887,42 @@ function preserveOriginal(filePath) {
2873
2887
  return backup;
2874
2888
  }
2875
2889
 
2890
+ // src/journal-directory.ts
2891
+ import { accessSync, closeSync as closeSync2, constants as constants2, fstatSync, lstatSync, openSync as openSync2, readFileSync as readFileSync3, readSync } from "fs";
2892
+ import { join as join5 } from "path";
2893
+ function assertJournalDirectory(dataDir2, identity) {
2894
+ try {
2895
+ if (!lstatSync(dataDir2).isDirectory() || !lstatSync(join5(dataDir2, "sessions")).isDirectory())
2896
+ throw new Error;
2897
+ accessSync(dataDir2, constants2.R_OK | constants2.W_OK | constants2.X_OK);
2898
+ accessSync(join5(dataDir2, "sessions"), constants2.R_OK | constants2.W_OK | constants2.X_OK);
2899
+ const marker = openSync2(join5(dataDir2, ".workflows-data-identity"), constants2.O_RDONLY | constants2.O_NOFOLLOW);
2900
+ try {
2901
+ const stat = fstatSync(marker);
2902
+ if (!stat.isFile() || stat.size > 256 || readFileSync3(marker, "utf8") !== identity + `
2903
+ `)
2904
+ throw new Error;
2905
+ } finally {
2906
+ closeSync2(marker);
2907
+ }
2908
+ for (const file of ["workflows.db", "sessions/session.wal"]) {
2909
+ const fd = openSync2(join5(dataDir2, file), constants2.O_RDWR | constants2.O_NOFOLLOW);
2910
+ try {
2911
+ if (!fstatSync(fd).isFile())
2912
+ throw new Error;
2913
+ readSync(fd, Buffer.alloc(1), 0, 1, 0);
2914
+ } finally {
2915
+ closeSync2(fd);
2916
+ }
2917
+ }
2918
+ } catch {
2919
+ throw new Error("configured execution journal identity or accessibility check failed");
2920
+ }
2921
+ }
2922
+
2876
2923
  // src/service.ts
2924
+ init_graph();
2925
+ init_lanes();
2877
2926
  import { hostname } from "os";
2878
2927
  import { resolveDatabaseUrl } from "@hasna/contracts";
2879
2928
  var DEFAULT_PORT = 8790;
@@ -2894,6 +2943,14 @@ function envInt(names, fallback) {
2894
2943
  return Number.isFinite(parsed) ? parsed : fallback;
2895
2944
  }
2896
2945
  function resolveWorkflowsConfig(overrides = {}) {
2946
+ const rawLanes = process.env.HASNA_WORKFLOWS_ENABLED_LANES;
2947
+ const enabledLanes = overrides.enabledLanes ?? (rawLanes === undefined ? LANE_KINDS : rawLanes.trim() === "" ? [] : rawLanes.split(",").map((value) => value.trim()));
2948
+ if (!Array.isArray(enabledLanes) || enabledLanes.some((lane) => !LANE_KINDS.includes(lane)) || new Set(enabledLanes).size !== enabledLanes.length) {
2949
+ 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");
2950
+ }
2951
+ const dataIdentity = overrides.dataIdentity ?? process.env.HASNA_WORKFLOWS_DATA_IDENTITY;
2952
+ if (dataIdentity !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(dataIdentity))
2953
+ throw new Error("HASNA_WORKFLOWS_DATA_IDENTITY must be a nonempty data identity of at most 128 characters");
2897
2954
  const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
2898
2955
  if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
2899
2956
  throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
@@ -2904,9 +2961,11 @@ function resolveWorkflowsConfig(overrides = {}) {
2904
2961
  apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
2905
2962
  apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
2906
2963
  originAuth,
2964
+ dataIdentity,
2907
2965
  storageBackend: overrides.storageBackend ?? "postgresql",
2908
2966
  databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
2909
- databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
2967
+ databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"]),
2968
+ enabledLanes: Object.freeze([...enabledLanes])
2910
2969
  };
2911
2970
  }
2912
2971
 
@@ -2939,16 +2998,52 @@ class WorkflowsService {
2939
2998
  }
2940
2999
  async ready() {
2941
3000
  const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
2942
- if (this.config.apiKey) {
3001
+ if (this.config.dataIdentity !== undefined) {
3002
+ try {
3003
+ this.verifyJournalDirectory();
3004
+ checks.journal = "ok";
3005
+ } catch {
3006
+ checks.journal = "unavailable";
3007
+ }
3008
+ }
3009
+ if (this.config.apiKey && checks.journal !== "unavailable") {
2943
3010
  try {
2944
3011
  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
3012
  } catch (error) {
2946
3013
  checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
2947
3014
  }
2948
3015
  }
2949
- return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
3016
+ return { ok: checks.auth === "ok" && checks.storage === "ok" && checks.journal !== "unavailable", service: this.name, version: this.version, checks };
3017
+ }
3018
+ verifyJournalDirectory() {
3019
+ if (this.config.dataIdentity !== undefined) {
3020
+ try {
3021
+ assertJournalDirectory(this.config.dataDir, this.config.dataIdentity);
3022
+ } catch {
3023
+ throw new WorkflowRequestError("journal_unavailable", "configured execution journal identity or accessibility check failed", 503);
3024
+ }
3025
+ }
3026
+ }
3027
+ assertEnabledGraphLanes(graph) {
3028
+ if (!Array.isArray(graph?.nodes))
3029
+ return;
3030
+ for (const node2 of graph.nodes) {
3031
+ if (node2.type === "step" && !node2.command && !this.config.enabledLanes.includes(node2.lane ?? "claude")) {
3032
+ throw new WorkflowRequestError("lane_disabled", "the workflow contains a model lane disabled by server configuration", 403);
3033
+ }
3034
+ }
3035
+ }
3036
+ async probeLane(kind) {
3037
+ if (this.config.enabledLanes.includes(kind))
3038
+ return probeLane(kind);
3039
+ const sdk = { claude: "@anthropic-ai/claude-agent-sdk", codex: "@openai/codex-sdk", cursor: "@cursor/sdk", grok: "no npm SDK (measured 2026-08-25)" };
3040
+ return { kind, sdk: sdk[kind], wired: false, reason: "disabled by server configuration" };
3041
+ }
3042
+ laneInventory() {
3043
+ return Promise.all(LANE_KINDS.map((kind) => this.probeLane(kind)));
2950
3044
  }
2951
3045
  async openEngineStore(verifySchema = true) {
3046
+ this.verifyJournalDirectory();
2952
3047
  if (this.config.storageBackend === "sqlite")
2953
3048
  return openStore(this.config.dataDir);
2954
3049
  if (this.config.storageBackend !== "postgresql")
@@ -3002,6 +3097,7 @@ class WorkflowsService {
3002
3097
  return this.withStore((store) => store.listRunNodes(id));
3003
3098
  }
3004
3099
  events(id) {
3100
+ this.verifyJournalDirectory();
3005
3101
  return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
3006
3102
  }
3007
3103
  cancelRun(id) {
@@ -3018,7 +3114,6 @@ class WorkflowsService {
3018
3114
  }
3019
3115
  async resumeRun(id) {
3020
3116
  const store = await this.openEngineStore();
3021
- const wal = SessionWAL.open(this.config.dataDir);
3022
3117
  try {
3023
3118
  const run2 = await store.getRun(id);
3024
3119
  if (!run2)
@@ -3026,13 +3121,15 @@ class WorkflowsService {
3026
3121
  if (!["failed", "cancelled", "interrupted"].includes(run2.status))
3027
3122
  throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
3028
3123
  const context = JSON.parse(run2.contextJson);
3124
+ const graph = context.__wf?.graph;
3125
+ if (!graph)
3126
+ throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
3127
+ this.assertEnabledGraphLanes(graph);
3128
+ const wal = SessionWAL.open(this.config.dataDir);
3029
3129
  if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
3030
3130
  throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
3031
3131
  if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
3032
3132
  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
3133
  if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
3037
3134
  throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
3038
3135
  for (const node2 of await store.listRunNodes(id)) {
@@ -3046,6 +3143,7 @@ class WorkflowsService {
3046
3143
  }
3047
3144
  }
3048
3145
  sessions(action) {
3146
+ this.verifyJournalDirectory();
3049
3147
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3050
3148
  if (action === "list")
3051
3149
  return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
@@ -3063,6 +3161,7 @@ class WorkflowsService {
3063
3161
  return { ...local, health: this.health(), sessions: this.sessions("pull") };
3064
3162
  }
3065
3163
  daemonStatus() {
3164
+ this.verifyJournalDirectory();
3066
3165
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3067
3166
  return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
3068
3167
  }
@@ -3070,6 +3169,7 @@ class WorkflowsService {
3070
3169
  return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
3071
3170
  }
3072
3171
  async triggerRun(graph, context = {}, opts = {}) {
3172
+ this.assertEnabledGraphLanes(graph);
3073
3173
  const store = await this.openEngineStore();
3074
3174
  const wal = SessionWAL.open(this.config.dataDir);
3075
3175
  try {
@@ -3362,12 +3462,12 @@ function createRequestHandler(service) {
3362
3462
  return jsonResponse(await service.listRuns({ limit, status }));
3363
3463
  }
3364
3464
  if (path === "/lanes")
3365
- return jsonResponse(await laneInventory());
3465
+ return jsonResponse(await service.laneInventory());
3366
3466
  if (path.startsWith("/lanes/")) {
3367
3467
  const lane = path.slice("/lanes/".length);
3368
3468
  if (!LANE_KINDS2.includes(lane))
3369
3469
  return jsonResponse({ error: "invalid_lane" }, 400);
3370
- return jsonResponse(await probeLane(lane));
3470
+ return jsonResponse(await service.probeLane(lane));
3371
3471
  }
3372
3472
  if (path === "/sessions/list")
3373
3473
  return jsonResponse(service.sessions("list"));
@@ -3415,7 +3515,6 @@ function createRequestHandler(service) {
3415
3515
 
3416
3516
  // src/agent-tools.ts
3417
3517
  init_graph();
3418
- init_lanes();
3419
3518
 
3420
3519
  // src/tool-definitions.ts
3421
3520
  function textResult(payload) {
@@ -3509,7 +3608,7 @@ async function callWorkflowTool(service, name, args) {
3509
3608
  limit: typeof args.limit === "number" ? args.limit : 100
3510
3609
  }));
3511
3610
  case "workflows_lanes_list":
3512
- return textResult(await laneInventory());
3611
+ return textResult(await service.laneInventory());
3513
3612
  default:
3514
3613
  throw new Error("Unknown workflows tool");
3515
3614
  }
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.1",
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,7 +149,7 @@ var package_default = {
149
149
  dependencies: {
150
150
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
151
151
  "@cursor/sdk": "^1.0.28",
152
- "@hasna/contracts": "1.3.3",
152
+ "@hasna/contracts": "1.3.5",
153
153
  "@openai/codex-sdk": "0.160.0",
154
154
  commander: "^13.1.0",
155
155
  pg: "^8.16.3"
@@ -653,7 +653,7 @@ function validateNode(graph, node, issues) {
653
653
  }
654
654
  }
655
655
 
656
- // ../../../../workflows-codex-execution-repair-1/dependency-update-1/standalone/workflows/node_modules/@hasna/contracts/dist/client/transport.js
656
+ // ../../app-test-sandbox-1/source/node_modules/@hasna/contracts/dist/client/transport.js
657
657
  import { isIP } from "net";
658
658
  import { spawnSync } from "child_process";
659
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-codex-execution-repair-1/dependency-update-1/standalone/workflows/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.1",
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,7 +1524,7 @@ var package_default = {
1524
1524
  dependencies: {
1525
1525
  "@anthropic-ai/claude-agent-sdk": "0.3.234",
1526
1526
  "@cursor/sdk": "^1.0.28",
1527
- "@hasna/contracts": "1.3.3",
1527
+ "@hasna/contracts": "1.3.5",
1528
1528
  "@openai/codex-sdk": "0.160.0",
1529
1529
  commander: "^13.1.0",
1530
1530
  pg: "^8.16.3"