@hasna/workflows 0.1.2 → 0.1.4

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/service.js CHANGED
@@ -17,12 +17,12 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
  var __require = import.meta.require;
18
18
 
19
19
  // src/lanes/types.ts
20
- import { statSync } from "fs";
20
+ import { statSync as statSync2 } from "fs";
21
21
  function binaryOnPath(binary) {
22
22
  const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
23
23
  for (const entry of pathEntries) {
24
24
  try {
25
- const stat = statSync(`${entry}/${binary}`);
25
+ const stat = statSync2(`${entry}/${binary}`);
26
26
  if (stat.isFile())
27
27
  return true;
28
28
  } catch {}
@@ -297,10 +297,12 @@ function createCursorAdapter(deps = {}) {
297
297
  try {
298
298
  const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
299
299
  const durationMs = Date.now() - started;
300
- const status = run?.status ?? "finished";
300
+ const status = run.status;
301
301
  const ok = status === "finished";
302
- return { ok, exitCode: ok ? 0 : 1, output: run?.result ?? "", durationMs };
302
+ return { ok, exitCode: ok ? 0 : 1, output: run.result, durationMs };
303
303
  } catch (err) {
304
+ if (err instanceof LaneAdapterShapeError)
305
+ throw err;
304
306
  const durationMs = Date.now() - started;
305
307
  return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
306
308
  }
@@ -321,7 +323,14 @@ async function runLocalAgent(sdk, job) {
321
323
  if (!run || typeof run !== "object") {
322
324
  throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
323
325
  }
324
- return run;
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)`);
329
+ }
330
+ if (typeof candidate.result !== "string") {
331
+ throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned a Run without a string result");
332
+ }
333
+ return { status: candidate.status, result: candidate.result };
325
334
  }
326
335
  function runCursorCli(job, cliPath, started, timeoutMs) {
327
336
  try {
@@ -439,13 +448,10 @@ var init_lanes = __esm(() => {
439
448
  ["grok", createGrokAdapter()]
440
449
  ]);
441
450
  });
442
-
443
- // src/service.ts
444
- import { join as join3 } from "path";
445
451
  // package.json
446
452
  var package_default = {
447
453
  name: "@hasna/workflows",
448
- version: "0.1.2",
454
+ version: "0.1.4",
449
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",
450
456
  type: "module",
451
457
  main: "./dist/index.js",
@@ -515,23 +521,21 @@ var package_default = {
515
521
  author: "Hasna",
516
522
  license: "Apache-2.0",
517
523
  dependencies: {
518
- "@anthropic-ai/claude-agent-sdk": "^0.3.234",
524
+ "@anthropic-ai/claude-agent-sdk": "0.3.234",
519
525
  "@cursor/sdk": "^1.0.28",
520
- "@openai/codex-sdk": "^0.147.0",
526
+ "@hasna/paths": "0.1.0",
527
+ "@openai/codex-sdk": "0.147.0",
521
528
  commander: "^13.1.0"
522
529
  },
523
530
  devDependencies: {
524
- "@hasna/contracts": "0.14.1",
525
- "@types/bun": "latest",
531
+ "@hasna/contracts": "0.14.2",
532
+ "@types/bun": "1.3.14",
526
533
  "@types/pg": "^8.15.6",
527
534
  pg: "^8.16.3",
528
535
  typescript: "^5.7.3"
529
536
  }
530
537
  };
531
538
 
532
- // src/daemon.ts
533
- import { createHash as createHash2 } from "crypto";
534
-
535
539
  // src/expr.ts
536
540
  class ExprSyntaxError extends Error {
537
541
  constructor(message) {
@@ -905,6 +909,18 @@ function validateGraph(graph) {
905
909
  if (typeof graph.version !== "string" || graph.version.trim() === "") {
906
910
  issue(issues, "version", "graph requires a version string");
907
911
  }
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;
920
+ }
921
+ }
922
+ }
923
+ }
908
924
  if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
909
925
  issue(issues, "nodes", "graph requires at least one node");
910
926
  return { ok: false, issues };
@@ -1139,6 +1155,8 @@ function assertNoSecrets(value, what) {
1139
1155
 
1140
1156
  // src/session.ts
1141
1157
  import { createHash } from "crypto";
1158
+ import { readFileSync, statSync } from "fs";
1159
+ import { isAbsolute, join } from "path";
1142
1160
  var DEFAULT_MAX_ATTEMPTS = 3;
1143
1161
  function repairTornRuns(store, wal, opts = {}) {
1144
1162
  const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
@@ -1194,6 +1212,46 @@ function recordMemo(store, graphName, nodeId, input, output) {
1194
1212
  const key = memoKey(graphName, nodeId, input);
1195
1213
  store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
1196
1214
  }
1215
+ function memoWatchFingerprint(graph, dataDir) {
1216
+ if (!graph.memoWatch || graph.memoWatch.length === 0)
1217
+ return;
1218
+ const entries = [];
1219
+ for (const pattern of graph.memoWatch) {
1220
+ if (hasGlobMagic(pattern)) {
1221
+ const matches = [...new Bun.Glob(pattern).scanSync({ cwd: dataDir, onlyFiles: true })].sort();
1222
+ for (const match of matches) {
1223
+ const path2 = isAbsolute(match) ? match : join(dataDir, match);
1224
+ entries.push(fingerprintFile(pattern, path2));
1225
+ }
1226
+ continue;
1227
+ }
1228
+ const path = isAbsolute(pattern) ? pattern : join(dataDir, pattern);
1229
+ entries.push(fingerprintFile(pattern, path));
1230
+ }
1231
+ return entries;
1232
+ }
1233
+ function hasGlobMagic(pattern) {
1234
+ return /[*?[\]{}]/.test(pattern);
1235
+ }
1236
+ function fingerprintFile(pattern, path) {
1237
+ try {
1238
+ const stat = statSync(path);
1239
+ if (!stat.isFile()) {
1240
+ return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
1241
+ }
1242
+ const content = readFileSync(path);
1243
+ return {
1244
+ pattern,
1245
+ path,
1246
+ missing: false,
1247
+ mtimeMs: stat.mtimeMs,
1248
+ size: content.byteLength,
1249
+ sha256: createHash("sha256").update(content).digest("hex")
1250
+ };
1251
+ } catch {
1252
+ return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
1253
+ }
1254
+ }
1197
1255
  function findRunByIdempotencyKey(store, key) {
1198
1256
  const runs = store.listRuns({ limit: 1000 });
1199
1257
  for (const run of runs) {
@@ -1468,10 +1526,13 @@ class WorkflowsDaemon {
1468
1526
  }
1469
1527
  async executeStep(run, graph, context, wf, node) {
1470
1528
  const nodeId = node.id;
1471
- const existing = this.store.listRunNodes(run.id).find((n) => n.nodeId === nodeId);
1472
- const nodeRow = existing ?? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" });
1473
- if (node.memo) {
1474
- const memoInput = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
1529
+ const latest = this.store.getLatestRunNode(run.id, nodeId);
1530
+ 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;
1535
+ if (memoInput !== undefined) {
1475
1536
  const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
1476
1537
  if (hit) {
1477
1538
  const cached = JSON.parse(hit.outputJson);
@@ -1500,9 +1561,9 @@ class WorkflowsDaemon {
1500
1561
  }
1501
1562
  const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
1502
1563
  if (!result.ok) {
1503
- const retriesLeft = (node.maxRetries ?? 0) + 1 - nodeRow.attempts;
1504
- if (retriesLeft > 0) {
1505
- this.store.bumpAttemptsNode(nodeRow.id);
1564
+ const shouldRetry = nodeRow.attempts < (node.maxRetries ?? 0);
1565
+ this.store.bumpAttemptsNode(nodeRow.id);
1566
+ if (shouldRetry) {
1506
1567
  this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
1507
1568
  return { kind: "advanced", nodeId };
1508
1569
  }
@@ -1523,9 +1584,13 @@ class WorkflowsDaemon {
1523
1584
  }
1524
1585
  this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
1525
1586
  this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
1526
- if (node.memo) {
1527
- recordMemo(this.store, run.graphName, nodeId, { command: node.command, prompt: node.prompt, context: stripRunState(context) }, output);
1528
- this.wal.append({ op: "memo_set", key: `${run.graphName}:${nodeId}:${createHash2("sha256").update(JSON.stringify({ command: node.command, prompt: node.prompt, context: stripRunState(context) })).digest("hex")}`, at: nowIso() });
1587
+ if (memoInput !== undefined) {
1588
+ const memoInputAfter = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
1589
+ const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
1590
+ 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() });
1593
+ }
1529
1594
  }
1530
1595
  this.recordStepResult(context, nodeId, output);
1531
1596
  return this.afterNode(run, context, wf, node.next);
@@ -1620,6 +1685,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
1620
1685
  isolatedRunId: options.isolatedRunId
1621
1686
  });
1622
1687
  const run = daemon.startRun(graph, context);
1688
+ options.onRunCreated?.(run);
1623
1689
  daemon.isolateRun(run.id);
1624
1690
  const maxCycles = options.maxCycles ?? 500;
1625
1691
  for (let cycle = 0;cycle < maxCycles; cycle++) {
@@ -1634,20 +1700,71 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
1634
1700
  return store.getRun(run.id);
1635
1701
  }
1636
1702
 
1703
+ // src/paths.ts
1704
+ import { existsSync } from "fs";
1705
+ import { homedir } from "os";
1706
+ import { join as join2 } from "path";
1707
+ import { dataDir } from "@hasna/paths";
1708
+ var APP = "workflows";
1709
+ var STORE_DB_FILE = "workflows.db";
1710
+ function getHomeDir(env = process.env) {
1711
+ return env["HOME"] || env["USERPROFILE"] || homedir();
1712
+ }
1713
+ function getLegacyDataDir(env = process.env) {
1714
+ return join2(getHomeDir(env), ".hasna", APP);
1715
+ }
1716
+ function getResolverDataDir(env = process.env) {
1717
+ return dataDir({ app: APP, home: getHomeDir(env), env });
1718
+ }
1719
+ function adoptResolverDataDir(resolved, env = process.env) {
1720
+ const dataOverride = env.HASNA_DATA_HOME;
1721
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
1722
+ return true;
1723
+ return existsSync(join2(resolved, STORE_DB_FILE));
1724
+ }
1725
+ function getEffectiveDataDir(env = process.env) {
1726
+ const resolved = getResolverDataDir(env);
1727
+ return adoptResolverDataDir(resolved, env) ? resolved : getLegacyDataDir(env);
1728
+ }
1729
+
1637
1730
  // src/store.ts
1638
1731
  import { Database } from "bun:sqlite";
1639
1732
  import { mkdirSync } from "fs";
1640
- import { join } from "path";
1733
+ import { join as join3 } from "path";
1641
1734
  import { randomUUID } from "crypto";
1642
1735
  function nowIso2() {
1643
1736
  return new Date().toISOString();
1644
1737
  }
1645
- function openStore(dataDir) {
1646
- mkdirSync(dataDir, { recursive: true });
1647
- const db = new Database(join(dataDir, "workflows.db"));
1648
- db.exec("PRAGMA journal_mode = WAL");
1649
- db.exec("PRAGMA foreign_keys = ON");
1650
- db.exec(`
1738
+ var BUSY_TIMEOUT_MS = 3000;
1739
+ var BUSY_RETRIES = 2;
1740
+ var BUSY_RETRY_SLEEP_MS = 50;
1741
+ function isBusyError(err) {
1742
+ if (!(err instanceof Error))
1743
+ return false;
1744
+ const code = String(err.code ?? "");
1745
+ return code.includes("BUSY") || /\bdatabase is locked\b/i.test(err.message);
1746
+ }
1747
+ function retryBusy(fn) {
1748
+ let last;
1749
+ for (let attempt = 0;attempt <= BUSY_RETRIES; attempt++) {
1750
+ try {
1751
+ return fn();
1752
+ } catch (err) {
1753
+ if (!isBusyError(err))
1754
+ throw err;
1755
+ last = err;
1756
+ Bun.sleepSync(BUSY_RETRY_SLEEP_MS);
1757
+ }
1758
+ }
1759
+ throw last;
1760
+ }
1761
+ function openStore(dataDir2) {
1762
+ mkdirSync(dataDir2, { recursive: true });
1763
+ const db = new Database(join3(dataDir2, "workflows.db"));
1764
+ retryBusy(() => db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`));
1765
+ retryBusy(() => db.exec("PRAGMA journal_mode = WAL"));
1766
+ retryBusy(() => db.exec("PRAGMA foreign_keys = ON"));
1767
+ retryBusy(() => db.exec(`
1651
1768
  CREATE TABLE IF NOT EXISTS runs (
1652
1769
  id TEXT PRIMARY KEY,
1653
1770
  graph_name TEXT NOT NULL,
@@ -1687,7 +1804,7 @@ function openStore(dataDir) {
1687
1804
  CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
1688
1805
  CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
1689
1806
  CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
1690
- `);
1807
+ `));
1691
1808
  const rowToRun = (r) => ({
1692
1809
  id: String(r.id),
1693
1810
  graphName: String(r.graph_name),
@@ -1725,31 +1842,33 @@ function openStore(dataDir) {
1725
1842
  createdAt: String(r.created_at)
1726
1843
  });
1727
1844
  return {
1845
+ dataDir: dataDir2,
1728
1846
  close() {
1729
1847
  db.close();
1730
1848
  },
1731
1849
  listTables() {
1732
- const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
1850
+ const rows = retryBusy(() => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all());
1733
1851
  return rows.map((r) => r.name);
1734
1852
  },
1735
1853
  createRun(input) {
1736
1854
  const id = randomUUID();
1737
1855
  const now = nowIso2();
1738
- db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
1739
- VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now);
1856
+ retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
1857
+ VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
1740
1858
  return this.getRun(id);
1741
1859
  },
1742
1860
  getRun(id) {
1743
- const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
1861
+ const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
1744
1862
  return row ? rowToRun(row) : undefined;
1745
1863
  },
1746
1864
  listRuns(opts = {}) {
1747
1865
  const limit = Math.min(opts.limit ?? 100, 1000);
1748
1866
  if (opts.status) {
1749
- const rows2 = db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(opts.status, limit);
1867
+ const status = opts.status;
1868
+ const rows2 = retryBusy(() => db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(status, limit));
1750
1869
  return rows2.map((r) => rowToRun(r));
1751
1870
  }
1752
- const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
1871
+ const rows = retryBusy(() => db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit));
1753
1872
  return rows.map((r) => rowToRun(r));
1754
1873
  },
1755
1874
  setRunStatus(id, status, patch = {}) {
@@ -1760,28 +1879,32 @@ function openStore(dataDir) {
1760
1879
  const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
1761
1880
  const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
1762
1881
  const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
1763
- db.query(`UPDATE runs SET status = ?, result_json = ?, error = ?, started_at = ?, finished_at = ?, updated_at = ? WHERE id = ?`).run(status, resultJson, error, startedAt, finishedAt, now, id);
1882
+ 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));
1764
1883
  },
1765
1884
  bumpAttempts(id) {
1766
- db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
1885
+ retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
1767
1886
  },
1768
1887
  setRunContext(id, context) {
1769
- db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
1888
+ retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
1770
1889
  },
1771
1890
  createRunNode(input) {
1772
1891
  const id = randomUUID();
1773
- db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
1774
- VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null);
1892
+ retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
1893
+ VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
1775
1894
  return this.getRunNode(id);
1776
1895
  },
1777
1896
  getRunNode(id) {
1778
- const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
1897
+ const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id));
1779
1898
  return row ? rowToNode(row) : undefined;
1780
1899
  },
1781
1900
  listRunNodes(runId) {
1782
- const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
1901
+ const rows = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId));
1783
1902
  return rows.map((r) => rowToNode(r));
1784
1903
  },
1904
+ getLatestRunNode(runId, nodeId) {
1905
+ const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? AND node_id = ? ORDER BY rowid DESC LIMIT 1`).get(runId, nodeId));
1906
+ return row ? rowToNode(row) : undefined;
1907
+ },
1785
1908
  setRunNodeStatus(id, status, patch = {}) {
1786
1909
  const now = nowIso2();
1787
1910
  const existing = this.getRunNode(id);
@@ -1793,44 +1916,44 @@ function openStore(dataDir) {
1793
1916
  const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
1794
1917
  const terminal = status === "completed" || status === "failed" || status === "skipped";
1795
1918
  const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
1796
- db.query(`UPDATE run_nodes SET status = ?, exit_code = ?, output_json = ?, error = ?, started_at = ?, finished_at = ? WHERE id = ?`).run(status, exitCode, outputJson, error, startedAt, finishedAt, id);
1919
+ retryBusy(() => db.query(`UPDATE run_nodes SET status = ?, exit_code = ?, output_json = ?, error = ?, started_at = ?, finished_at = ? WHERE id = ?`).run(status, exitCode, outputJson, error, startedAt, finishedAt, id));
1797
1920
  },
1798
1921
  bumpAttemptsNode(id) {
1799
- db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
1922
+ retryBusy(() => db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id));
1800
1923
  },
1801
1924
  memoPut(key, graphName, nodeId, inputHash2, outputJson) {
1802
- db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
1803
- VALUES (?, ?, ?, ?, ?, 0, ?)
1804
- ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2());
1925
+ retryBusy(() => db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
1926
+ VALUES (?, ?, ?, ?, ?, 0, ?)
1927
+ ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2()));
1805
1928
  },
1806
1929
  memoGet(key) {
1807
- const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
1930
+ const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
1808
1931
  return row ? rowToMemo(row) : undefined;
1809
1932
  },
1810
1933
  memoHit(key) {
1811
- db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
1934
+ retryBusy(() => db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key));
1812
1935
  },
1813
1936
  memoList() {
1814
- const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
1937
+ const rows = retryBusy(() => db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all());
1815
1938
  return rows.map((r) => rowToMemo(r));
1816
1939
  },
1817
1940
  memoClear() {
1818
- db.exec(`DELETE FROM memos`);
1941
+ retryBusy(() => db.exec(`DELETE FROM memos`));
1819
1942
  }
1820
1943
  };
1821
1944
  }
1822
1945
 
1823
1946
  // src/wal.ts
1824
- import { createHash as createHash3 } from "crypto";
1825
- import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
1826
- import { join as join2 } from "path";
1947
+ 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";
1827
1950
  var WAL_FILE_NAME = "session.wal";
1828
1951
  var SESSIONS_DIR_NAME = "sessions";
1829
1952
  var MAX_WAL_BYTES = 256 * 1024 * 1024;
1830
1953
  function checksumOf(op) {
1831
1954
  if (op === undefined || op === null)
1832
1955
  return "";
1833
- return createHash3("sha256").update(JSON.stringify(op)).digest("hex");
1956
+ return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
1834
1957
  }
1835
1958
 
1836
1959
  class SessionWAL {
@@ -1838,11 +1961,11 @@ class SessionWAL {
1838
1961
  constructor(filePath) {
1839
1962
  this.filePath = filePath;
1840
1963
  }
1841
- static open(dataDir) {
1842
- mkdirSync2(join2(dataDir, SESSIONS_DIR_NAME), { recursive: true });
1843
- const canonical = join2(dataDir, SESSIONS_DIR_NAME, WAL_FILE_NAME);
1844
- const legacy = join2(dataDir, WAL_FILE_NAME);
1845
- if (existsSync(legacy) && !existsSync(canonical)) {
1964
+ static open(dataDir2) {
1965
+ mkdirSync2(join4(dataDir2, SESSIONS_DIR_NAME), { recursive: true });
1966
+ const canonical = join4(dataDir2, SESSIONS_DIR_NAME, WAL_FILE_NAME);
1967
+ const legacy = join4(dataDir2, WAL_FILE_NAME);
1968
+ if (existsSync2(legacy) && !existsSync2(canonical)) {
1846
1969
  try {
1847
1970
  renameSync(legacy, canonical);
1848
1971
  } catch {
@@ -1873,7 +1996,7 @@ class SessionWAL {
1873
1996
  let repaired = false;
1874
1997
  let content;
1875
1998
  try {
1876
- content = readFileSync(this.filePath, "utf8");
1999
+ content = readFileSync2(this.filePath, "utf8");
1877
2000
  } catch {
1878
2001
  return { entries, torn, repaired, liveClaims: () => new Map };
1879
2002
  }
@@ -1973,11 +2096,10 @@ function envInt(names, fallback) {
1973
2096
  return Number.isFinite(parsed) ? parsed : fallback;
1974
2097
  }
1975
2098
  function resolveWorkflowsConfig(overrides = {}) {
1976
- const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
1977
2099
  return {
1978
2100
  port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
1979
2101
  host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
1980
- dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? join3(home, ".hasna", "workflows"),
2102
+ dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? getEffectiveDataDir(),
1981
2103
  apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
1982
2104
  apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
1983
2105
  };
@@ -3,8 +3,8 @@
3
3
  "name": "workflows",
4
4
  "class": "service",
5
5
  "contractVersion": "v1",
6
- "kitVersion": "0.14.1",
7
- "description": "Universal graph workflow app: graph language (start/step/decision/while/end), three-table run store, session WAL, claims/leases daemon, four lane adapters (claude, codex, cursor, grok), and a full CLI on four surfaces (CLI, MCP, serve, SDK).",
6
+ "kitVersion": "0.14.2",
7
+ "description": "Universal graph workflow app: graph language (start/step/decision/while/end), three-table run store, session WAL, claims/leases daemon, four lane adapters (claude, codex, cursor, grok), and a full CLI on four surfaces (CLI, MCP, serve, SDK). Local state lives at the effective workflows data home (~/.hasna/workflows by default, resolved via @hasna/paths; the XDG data home is adopted once the store is migrated there or HASNA_DATA_HOME is set).",
8
8
  "bins": [
9
9
  "workflows",
10
10
  "workflows-mcp",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/workflows",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "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",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -70,14 +70,15 @@
70
70
  "author": "Hasna",
71
71
  "license": "Apache-2.0",
72
72
  "dependencies": {
73
- "@anthropic-ai/claude-agent-sdk": "^0.3.234",
73
+ "@anthropic-ai/claude-agent-sdk": "0.3.234",
74
74
  "@cursor/sdk": "^1.0.28",
75
- "@openai/codex-sdk": "^0.147.0",
75
+ "@hasna/paths": "0.1.0",
76
+ "@openai/codex-sdk": "0.147.0",
76
77
  "commander": "^13.1.0"
77
78
  },
78
79
  "devDependencies": {
79
- "@hasna/contracts": "0.14.1",
80
- "@types/bun": "latest",
80
+ "@hasna/contracts": "0.14.2",
81
+ "@types/bun": "1.3.14",
81
82
  "@types/pg": "^8.15.6",
82
83
  "pg": "^8.16.3",
83
84
  "typescript": "^5.7.3"
package/types/daemon.d.ts CHANGED
@@ -114,6 +114,9 @@ export interface RunToCompletionOptions {
114
114
  /** Pre-set the daemon's isolation; the foreground driver always isolates
115
115
  * to its own run anyway (see isolateRun in runGraphToCompletion). */
116
116
  isolatedRunId?: string;
117
+ /** Observe the durable run identity immediately after creation (the CLI -j
118
+ * failure surface needs the runId even when the run later throws). */
119
+ onRunCreated?: (run: RunRow) => void;
117
120
  }
118
121
  /** Drive a run to a terminal state with bounded cycles. Returns the final row. */
119
122
  export declare function runGraphToCompletion(store: WorkflowsStore, wal: SessionWAL, graph: WorkflowGraph, context: unknown, options?: RunToCompletionOptions): Promise<RunRow>;
package/types/graph.d.ts CHANGED
@@ -53,6 +53,13 @@ export interface WorkflowGraph {
53
53
  name: string;
54
54
  version: string;
55
55
  nodes: GraphNode[];
56
+ /**
57
+ * Optional files/globs (relative to the data dir, or absolute) whose
58
+ * fingerprint (mtime + sha256) joins every memoized node's input.
59
+ * A memoized command that reads external state MUST declare it here, or
60
+ * it can serve a stale cached value that contradicts a live run.
61
+ */
62
+ memoWatch?: string[];
56
63
  }
57
64
  export interface GraphIssue {
58
65
  /** Dotted path to the offending member, e.g. "nodes.build" or "nodes.w.body". */
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Workflows data-home resolution — the effective local data home for the
3
+ * @hasna/workflows package, resolved through @hasna/paths.
4
+ *
5
+ * Resolution order:
6
+ * 1. exact-app override — `HASNA_WORKFLOWS_DATA_DIR` / `WORKFLOWS_DATA_DIR`
7
+ * (the shipped overrides, applied in `resolveWorkflowsConfig`) — wins
8
+ * unconditionally;
9
+ * 2. the @hasna/paths data home (XDG / macOS home layout) once adopted;
10
+ * 3. otherwise the legacy `~/.hasna/workflows` default.
11
+ *
12
+ * Gated legacy adoption: the legacy `~/.hasna/workflows` home stays effective
13
+ * until the store has actually been migrated to the XDG data home
14
+ * (`workflows.db` exists at the resolver home) or the operator sets the
15
+ * data-kind override `HASNA_DATA_HOME`. An existing local store never becomes
16
+ * invisible on upgrade.
17
+ */
18
+ /** The workflows app slug used by the @hasna/paths resolver. */
19
+ export declare const APP: "workflows";
20
+ /** The store database file name, the marker that a store lives at a data home. */
21
+ export declare const STORE_DB_FILE = "workflows.db";
22
+ /**
23
+ * The effective user home, mirroring the pre-existing resolution
24
+ * (`HOME` || `USERPROFILE` || os.homedir()).
25
+ */
26
+ export declare function getHomeDir(env?: NodeJS.ProcessEnv): string;
27
+ /** Pre-XDG legacy workflows data home: ~/.hasna/workflows. */
28
+ export declare function getLegacyDataDir(env?: NodeJS.ProcessEnv): string;
29
+ /**
30
+ * The @hasna/paths-resolved workflows data home (XDG / macOS home layout):
31
+ * `~/.local/share/hasna/workflows` on Linux, `~/Library/Application
32
+ * Support/Hasna/workflows` on macOS. The home override mirrors the
33
+ * pre-existing `$HOME`-first resolution so the resolver follows the same home
34
+ * the legacy path does.
35
+ */
36
+ export declare function getResolverDataDir(env?: NodeJS.ProcessEnv): string;
37
+ /**
38
+ * Whether the resolver (XDG) data home should be adopted as the effective
39
+ * workflows data home. The resolver home is adopted only when the operator
40
+ * has set `HASNA_DATA_HOME` (the data-kind override — a deliberate opt-in to
41
+ * the XDG layout) or the store has already been physically migrated there
42
+ * (`workflows.db` exists). A machine that only redirects another kind (e.g.
43
+ * cache to tmpfs) must NOT have its data home moved, and a live store at the
44
+ * legacy home must never become invisible on upgrade.
45
+ */
46
+ export declare function adoptResolverDataDir(resolved: string, env?: NodeJS.ProcessEnv): boolean;
47
+ /**
48
+ * The effective workflows data home: the resolver home once adopted,
49
+ * otherwise the legacy `~/.hasna/workflows` default.
50
+ */
51
+ export declare function getEffectiveDataDir(env?: NodeJS.ProcessEnv): string;
@@ -1,5 +1,6 @@
1
1
  import { SessionWAL } from "./wal.js";
2
2
  import type { RunRow, WorkflowsStore } from "./store.js";
3
+ import type { WorkflowGraph } from "./graph.js";
3
4
  export interface RepairResult {
4
5
  /** runs found running with no live claim */
5
6
  interrupted: number;
@@ -21,6 +22,29 @@ export declare function memoKey(graphName: string, nodeId: string, input: unknow
21
22
  export declare function tryMemoHit(store: WorkflowsStore, graphName: string, nodeId: string, input: unknown): import("./store.js").MemoRow | undefined;
22
23
  /** Persist a node output for future runs with the same input. */
23
24
  export declare function recordMemo(store: WorkflowsStore, graphName: string, nodeId: string, input: unknown, output: unknown): void;
25
+ /** One watched file's fingerprint entry (mtime + sha256). */
26
+ export interface MemoWatchEntry {
27
+ /** The pattern as declared on the graph. */
28
+ pattern: string;
29
+ /** The resolved absolute path (or the glob-relative file path). */
30
+ path: string;
31
+ missing: boolean;
32
+ mtimeMs: number | null;
33
+ size: number | null;
34
+ sha256: string | null;
35
+ }
36
+ /**
37
+ * Fingerprint of the graph's memoWatch files: mtimes + content hashes.
38
+ * Stress V3/V4 F2 (measured 2026-08-30): a memoized node reading
39
+ * $WORKFLOWS_DATA_DIR/scratch/wf2.cnt served a stale cached "0" while the
40
+ * file said "2" — the memo key covered only {command, prompt, context}. The
41
+ * fingerprint joins the memo input, so a changed watched file invalidates the
42
+ * cache exactly when the command's live result can differ. Exact paths and
43
+ * glob patterns are supported (relative to `dataDir` or absolute); a missing
44
+ * exact path keeps a stable `missing` entry so its later appearance (or a
45
+ * re-creation with different content) still changes the fingerprint.
46
+ */
47
+ export declare function memoWatchFingerprint(graph: WorkflowGraph, dataDir: string): MemoWatchEntry[] | undefined;
24
48
  /** Requeue a cancelled run's in-flight nodes (used by cancel + resume). */
25
49
  export declare function resetRunNodes(store: WorkflowsStore, run: RunRow): void;
26
50
  export interface RestoreResult {
package/types/store.d.ts CHANGED
@@ -49,6 +49,8 @@ export interface CreateRunNodeInput {
49
49
  lane?: string;
50
50
  }
51
51
  export interface WorkflowsStore {
52
+ /** The data dir this store was opened on (resolves relative memoWatch paths). */
53
+ readonly dataDir: string;
52
54
  close(): void;
53
55
  listTables(): string[];
54
56
  createRun(input: CreateRunInput): RunRow;
@@ -69,6 +71,9 @@ export interface WorkflowsStore {
69
71
  createRunNode(input: CreateRunNodeInput): RunNodeRow;
70
72
  getRunNode(id: string): RunNodeRow | undefined;
71
73
  listRunNodes(runId: string): RunNodeRow[];
74
+ /** The newest run_nodes row for a (run, node) pair — a node may own one row
75
+ * per while iteration, and `nodes show` + engine row-reuse need the latest. */
76
+ getLatestRunNode(runId: string, nodeId: string): RunNodeRow | undefined;
72
77
  setRunNodeStatus(id: string, status: NodeStatus, patch?: {
73
78
  exitCode?: number | null;
74
79
  output?: unknown;