@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.
@@ -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.1",
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,7 +98,7 @@ 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
- "@hasna/contracts": "1.3.3",
101
+ "@hasna/contracts": "1.3.5",
102
102
  "@openai/codex-sdk": "0.160.0",
103
103
  commander: "^13.1.0",
104
104
  pg: "^8.16.3"
@@ -2732,7 +2732,7 @@ var init_store = __esm(() => {
2732
2732
  // src/wal.ts
2733
2733
  import { Database as Database2 } from "bun:sqlite";
2734
2734
  import { createHash as createHash2 } from "crypto";
2735
- 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";
2736
2736
  import { basename, dirname, join as join4 } from "path";
2737
2737
  function checksumOf(op) {
2738
2738
  if (op === undefined || op === null)
@@ -2799,8 +2799,20 @@ class SessionWAL {
2799
2799
  const full = { ...op, at };
2800
2800
  const nextSeq = this.nextSeq();
2801
2801
  const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
2802
- appendFileSync(this.filePath, JSON.stringify(entry) + `
2803
- `, { 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
+ }
2804
2816
  return entry;
2805
2817
  }
2806
2818
  nextSeq() {
@@ -2817,7 +2829,9 @@ class SessionWAL {
2817
2829
  let content;
2818
2830
  try {
2819
2831
  content = readFileSync2(this.filePath, "utf8");
2820
- } catch {
2832
+ } catch (error) {
2833
+ if (error.code !== "ENOENT")
2834
+ throw error;
2821
2835
  return { entries, torn, repaired, liveClaims: () => new Map };
2822
2836
  }
2823
2837
  if (content.length > MAX_WAL_BYTES) {
@@ -2911,6 +2925,40 @@ var init_wal = __esm(() => {
2911
2925
  MAX_WAL_BYTES = 256 * 1024 * 1024;
2912
2926
  });
2913
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
+
2914
2962
  // src/service.ts
2915
2963
  import { createHash as createHash3 } from "crypto";
2916
2964
  import { hostname } from "os";
@@ -2931,6 +2979,14 @@ function envInt(names, fallback) {
2931
2979
  return Number.isFinite(parsed) ? parsed : fallback;
2932
2980
  }
2933
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");
2934
2990
  const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
2935
2991
  if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
2936
2992
  throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
@@ -2941,9 +2997,11 @@ function resolveWorkflowsConfig(overrides = {}) {
2941
2997
  apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
2942
2998
  apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
2943
2999
  originAuth,
3000
+ dataIdentity,
2944
3001
  storageBackend: overrides.storageBackend ?? "postgresql",
2945
3002
  databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
2946
- 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])
2947
3005
  };
2948
3006
  }
2949
3007
 
@@ -2976,16 +3034,52 @@ class WorkflowsService {
2976
3034
  }
2977
3035
  async ready() {
2978
3036
  const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
2979
- 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") {
2980
3046
  try {
2981
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");
2982
3048
  } catch (error) {
2983
3049
  checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
2984
3050
  }
2985
3051
  }
2986
- 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)));
2987
3080
  }
2988
3081
  async openEngineStore(verifySchema = true) {
3082
+ this.verifyJournalDirectory();
2989
3083
  if (this.config.storageBackend === "sqlite")
2990
3084
  return openStore(this.config.dataDir);
2991
3085
  if (this.config.storageBackend !== "postgresql")
@@ -3039,6 +3133,7 @@ class WorkflowsService {
3039
3133
  return this.withStore((store) => store.listRunNodes(id));
3040
3134
  }
3041
3135
  events(id) {
3136
+ this.verifyJournalDirectory();
3042
3137
  return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
3043
3138
  }
3044
3139
  cancelRun(id) {
@@ -3055,7 +3150,6 @@ class WorkflowsService {
3055
3150
  }
3056
3151
  async resumeRun(id) {
3057
3152
  const store = await this.openEngineStore();
3058
- const wal = SessionWAL.open(this.config.dataDir);
3059
3153
  try {
3060
3154
  const run2 = await store.getRun(id);
3061
3155
  if (!run2)
@@ -3063,13 +3157,15 @@ class WorkflowsService {
3063
3157
  if (!["failed", "cancelled", "interrupted"].includes(run2.status))
3064
3158
  throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
3065
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);
3066
3165
  if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
3067
3166
  throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
3068
3167
  if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
3069
3168
  throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
3070
- const graph = context.__wf?.graph;
3071
- if (!graph)
3072
- throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
3073
3169
  if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
3074
3170
  throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
3075
3171
  for (const node2 of await store.listRunNodes(id)) {
@@ -3083,6 +3179,7 @@ class WorkflowsService {
3083
3179
  }
3084
3180
  }
3085
3181
  sessions(action) {
3182
+ this.verifyJournalDirectory();
3086
3183
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3087
3184
  if (action === "list")
3088
3185
  return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
@@ -3100,6 +3197,7 @@ class WorkflowsService {
3100
3197
  return { ...local, health: this.health(), sessions: this.sessions("pull") };
3101
3198
  }
3102
3199
  daemonStatus() {
3200
+ this.verifyJournalDirectory();
3103
3201
  const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
3104
3202
  return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
3105
3203
  }
@@ -3107,6 +3205,7 @@ class WorkflowsService {
3107
3205
  return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
3108
3206
  }
3109
3207
  async triggerRun(graph, context = {}, opts = {}) {
3208
+ this.assertEnabledGraphLanes(graph);
3110
3209
  const store = await this.openEngineStore();
3111
3210
  const wal = SessionWAL.open(this.config.dataDir);
3112
3211
  try {
@@ -3176,6 +3275,9 @@ var init_service = __esm(() => {
3176
3275
  init_postgres_store();
3177
3276
  init_store();
3178
3277
  init_wal();
3278
+ init_journal_directory();
3279
+ init_graph();
3280
+ init_lanes();
3179
3281
  init_version();
3180
3282
  WorkflowRequestError = class WorkflowRequestError extends Error {
3181
3283
  code;
@@ -3486,12 +3588,12 @@ function createRequestHandler(service) {
3486
3588
  return jsonResponse(await service.listRuns({ limit, status }));
3487
3589
  }
3488
3590
  if (path === "/lanes")
3489
- return jsonResponse(await laneInventory());
3591
+ return jsonResponse(await service.laneInventory());
3490
3592
  if (path.startsWith("/lanes/")) {
3491
3593
  const lane = path.slice("/lanes/".length);
3492
3594
  if (!LANE_KINDS2.includes(lane))
3493
3595
  return jsonResponse({ error: "invalid_lane" }, 400);
3494
- return jsonResponse(await probeLane(lane));
3596
+ return jsonResponse(await service.probeLane(lane));
3495
3597
  }
3496
3598
  if (path === "/sessions/list")
3497
3599
  return jsonResponse(service.sessions("list"));
@@ -3567,13 +3669,13 @@ var init_server = __esm(() => {
3567
3669
  // src/serve/admin.ts
3568
3670
  var exports_admin = {};
3569
3671
  import { Command } from "commander";
3570
- 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";
3571
3673
  import { hostname as hostname2 } from "os";
3572
- import { join as join5 } from "path";
3674
+ import { join as join6 } from "path";
3573
3675
  function loadGraph(file) {
3574
3676
  let raw;
3575
3677
  try {
3576
- raw = readFileSync3(file, "utf8");
3678
+ raw = readFileSync4(file, "utf8");
3577
3679
  } catch {
3578
3680
  throw new Error(`cannot read graph file ${file}`);
3579
3681
  }
@@ -3666,7 +3768,7 @@ function addReapReports(left, right) {
3666
3768
  return total;
3667
3769
  }
3668
3770
  function writeReport(statusFile, report) {
3669
- mkdirSync3(join5(statusFile, ".."), { recursive: true });
3771
+ mkdirSync3(join6(statusFile, ".."), { recursive: true });
3670
3772
  let previous = emptyReapReport();
3671
3773
  if (existsSync3(statusFile)) {
3672
3774
  previous = readReport(statusFile).cumulative;
@@ -3686,7 +3788,7 @@ function writeReport(statusFile, report) {
3686
3788
  }
3687
3789
  }
3688
3790
  function readReport(statusFile) {
3689
- const parsed = JSON.parse(readFileSync3(statusFile, "utf8"));
3791
+ const parsed = JSON.parse(readFileSync4(statusFile, "utf8"));
3690
3792
  if (parsed !== null && typeof parsed === "object") {
3691
3793
  const record = parsed;
3692
3794
  if (record.version === 1 && record.latestCycle !== undefined && record.cumulative !== undefined) {
@@ -3740,11 +3842,11 @@ var init_admin = __esm(() => {
3740
3842
  });
3741
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) => {
3742
3844
  const config = resolveWorkflowsConfig();
3743
- mkdirSync3(join5(config.dataDir, "workflows"), { recursive: true });
3744
- 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 });
3745
3847
  const store = openStore(config.dataDir);
3746
3848
  store.close();
3747
- const target = file ?? join5(config.dataDir, "workflows", "demo.json");
3849
+ const target = file ?? join6(config.dataDir, "workflows", "demo.json");
3748
3850
  if (existsSync3(target)) {
3749
3851
  console.error(`refusing to overwrite existing file ${target}`);
3750
3852
  process.exit(1);
@@ -4048,17 +4150,17 @@ var init_admin = __esm(() => {
4048
4150
  }
4049
4151
  case "status": {
4050
4152
  const replay = wal.replay();
4051
- const pidFile = join5(config.dataDir, "daemon.pid");
4052
- 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;
4053
4155
  const running = pid !== null && processAlive(Number(pid));
4054
4156
  const report = {
4055
4157
  hostname: hostname2(),
4056
4158
  pid: process.pid,
4057
4159
  dataDir: config.dataDir,
4058
4160
  layout: {
4059
- workflows: existsSync3(join5(config.dataDir, "workflows")),
4060
- sessions: existsSync3(join5(config.dataDir, SESSIONS_DIR_NAME)),
4061
- 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"))
4062
4164
  },
4063
4165
  store: {
4064
4166
  tables: store.listTables(),
@@ -4122,8 +4224,8 @@ var init_admin = __esm(() => {
4122
4224
  });
4123
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) => {
4124
4226
  const config = resolveWorkflowsConfig();
4125
- const pidFile = join5(config.dataDir, "daemon.pid");
4126
- 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");
4127
4229
  switch (action) {
4128
4230
  case "start": {
4129
4231
  const { store, wal } = openLocal();
@@ -4180,7 +4282,7 @@ var init_admin = __esm(() => {
4180
4282
  return;
4181
4283
  }
4182
4284
  const envelope = readReport(statusFile);
4183
- const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
4285
+ const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
4184
4286
  const running = pid !== null && processAlive(Number(pid));
4185
4287
  if (opts.json) {
4186
4288
  console.log(JSON.stringify({ statusVersion: 1, running, pid, lastReap: envelope.latestCycle, cumulative: envelope.cumulative }, null, 2));
@@ -4194,7 +4296,7 @@ var init_admin = __esm(() => {
4194
4296
  console.error("no daemon pid file \u2014 nothing to stop");
4195
4297
  process.exit(1);
4196
4298
  }
4197
- const pid = Number(readFileSync3(pidFile, "utf8").trim());
4299
+ const pid = Number(readFileSync4(pidFile, "utf8").trim());
4198
4300
  try {
4199
4301
  process.kill(pid, "SIGTERM");
4200
4302
  } catch {