@hasna/workflows 0.1.3 → 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/agent-tools.js +149 -51
- package/dist/cli/index.js +259 -93
- package/dist/index.js +158 -60
- package/dist/mcp/index.js +155 -57
- package/dist/serve/index.js +158 -60
- package/dist/service.js +155 -57
- package/hasna.contract.json +1 -1
- package/package.json +3 -3
- package/types/daemon.d.ts +3 -0
- package/types/graph.d.ts +7 -0
- package/types/session.d.ts +24 -0
- package/types/store.d.ts +5 -0
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 =
|
|
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
|
|
300
|
+
const status = run.status;
|
|
301
301
|
const ok = status === "finished";
|
|
302
|
-
return { ok, exitCode: ok ? 0 : 1, output: run
|
|
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
|
-
|
|
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 {
|
|
@@ -442,7 +451,7 @@ var init_lanes = __esm(() => {
|
|
|
442
451
|
// package.json
|
|
443
452
|
var package_default = {
|
|
444
453
|
name: "@hasna/workflows",
|
|
445
|
-
version: "0.1.
|
|
454
|
+
version: "0.1.4",
|
|
446
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",
|
|
447
456
|
type: "module",
|
|
448
457
|
main: "./dist/index.js",
|
|
@@ -519,17 +528,14 @@ var package_default = {
|
|
|
519
528
|
commander: "^13.1.0"
|
|
520
529
|
},
|
|
521
530
|
devDependencies: {
|
|
522
|
-
"@hasna/contracts": "0.14.
|
|
523
|
-
"@types/bun": "
|
|
531
|
+
"@hasna/contracts": "0.14.2",
|
|
532
|
+
"@types/bun": "1.3.14",
|
|
524
533
|
"@types/pg": "^8.15.6",
|
|
525
534
|
pg: "^8.16.3",
|
|
526
535
|
typescript: "^5.7.3"
|
|
527
536
|
}
|
|
528
537
|
};
|
|
529
538
|
|
|
530
|
-
// src/daemon.ts
|
|
531
|
-
import { createHash as createHash2 } from "crypto";
|
|
532
|
-
|
|
533
539
|
// src/expr.ts
|
|
534
540
|
class ExprSyntaxError extends Error {
|
|
535
541
|
constructor(message) {
|
|
@@ -903,6 +909,18 @@ function validateGraph(graph) {
|
|
|
903
909
|
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
904
910
|
issue(issues, "version", "graph requires a version string");
|
|
905
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
|
+
}
|
|
906
924
|
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
907
925
|
issue(issues, "nodes", "graph requires at least one node");
|
|
908
926
|
return { ok: false, issues };
|
|
@@ -1137,6 +1155,8 @@ function assertNoSecrets(value, what) {
|
|
|
1137
1155
|
|
|
1138
1156
|
// src/session.ts
|
|
1139
1157
|
import { createHash } from "crypto";
|
|
1158
|
+
import { readFileSync, statSync } from "fs";
|
|
1159
|
+
import { isAbsolute, join } from "path";
|
|
1140
1160
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1141
1161
|
function repairTornRuns(store, wal, opts = {}) {
|
|
1142
1162
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -1192,6 +1212,46 @@ function recordMemo(store, graphName, nodeId, input, output) {
|
|
|
1192
1212
|
const key = memoKey(graphName, nodeId, input);
|
|
1193
1213
|
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1194
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
|
+
}
|
|
1195
1255
|
function findRunByIdempotencyKey(store, key) {
|
|
1196
1256
|
const runs = store.listRuns({ limit: 1000 });
|
|
1197
1257
|
for (const run of runs) {
|
|
@@ -1466,10 +1526,13 @@ class WorkflowsDaemon {
|
|
|
1466
1526
|
}
|
|
1467
1527
|
async executeStep(run, graph, context, wf, node) {
|
|
1468
1528
|
const nodeId = node.id;
|
|
1469
|
-
const
|
|
1470
|
-
const
|
|
1471
|
-
|
|
1472
|
-
|
|
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) {
|
|
1473
1536
|
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1474
1537
|
if (hit) {
|
|
1475
1538
|
const cached = JSON.parse(hit.outputJson);
|
|
@@ -1521,9 +1584,13 @@ class WorkflowsDaemon {
|
|
|
1521
1584
|
}
|
|
1522
1585
|
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1523
1586
|
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1524
|
-
if (
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
+
}
|
|
1527
1594
|
}
|
|
1528
1595
|
this.recordStepResult(context, nodeId, output);
|
|
1529
1596
|
return this.afterNode(run, context, wf, node.next);
|
|
@@ -1618,6 +1685,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1618
1685
|
isolatedRunId: options.isolatedRunId
|
|
1619
1686
|
});
|
|
1620
1687
|
const run = daemon.startRun(graph, context);
|
|
1688
|
+
options.onRunCreated?.(run);
|
|
1621
1689
|
daemon.isolateRun(run.id);
|
|
1622
1690
|
const maxCycles = options.maxCycles ?? 500;
|
|
1623
1691
|
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
@@ -1635,7 +1703,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1635
1703
|
// src/paths.ts
|
|
1636
1704
|
import { existsSync } from "fs";
|
|
1637
1705
|
import { homedir } from "os";
|
|
1638
|
-
import { join } from "path";
|
|
1706
|
+
import { join as join2 } from "path";
|
|
1639
1707
|
import { dataDir } from "@hasna/paths";
|
|
1640
1708
|
var APP = "workflows";
|
|
1641
1709
|
var STORE_DB_FILE = "workflows.db";
|
|
@@ -1643,7 +1711,7 @@ function getHomeDir(env = process.env) {
|
|
|
1643
1711
|
return env["HOME"] || env["USERPROFILE"] || homedir();
|
|
1644
1712
|
}
|
|
1645
1713
|
function getLegacyDataDir(env = process.env) {
|
|
1646
|
-
return
|
|
1714
|
+
return join2(getHomeDir(env), ".hasna", APP);
|
|
1647
1715
|
}
|
|
1648
1716
|
function getResolverDataDir(env = process.env) {
|
|
1649
1717
|
return dataDir({ app: APP, home: getHomeDir(env), env });
|
|
@@ -1652,7 +1720,7 @@ function adoptResolverDataDir(resolved, env = process.env) {
|
|
|
1652
1720
|
const dataOverride = env.HASNA_DATA_HOME;
|
|
1653
1721
|
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
1654
1722
|
return true;
|
|
1655
|
-
return existsSync(
|
|
1723
|
+
return existsSync(join2(resolved, STORE_DB_FILE));
|
|
1656
1724
|
}
|
|
1657
1725
|
function getEffectiveDataDir(env = process.env) {
|
|
1658
1726
|
const resolved = getResolverDataDir(env);
|
|
@@ -1662,17 +1730,41 @@ function getEffectiveDataDir(env = process.env) {
|
|
|
1662
1730
|
// src/store.ts
|
|
1663
1731
|
import { Database } from "bun:sqlite";
|
|
1664
1732
|
import { mkdirSync } from "fs";
|
|
1665
|
-
import { join as
|
|
1733
|
+
import { join as join3 } from "path";
|
|
1666
1734
|
import { randomUUID } from "crypto";
|
|
1667
1735
|
function nowIso2() {
|
|
1668
1736
|
return new Date().toISOString();
|
|
1669
1737
|
}
|
|
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
|
+
}
|
|
1670
1761
|
function openStore(dataDir2) {
|
|
1671
1762
|
mkdirSync(dataDir2, { recursive: true });
|
|
1672
|
-
const db = new Database(
|
|
1673
|
-
db.exec(
|
|
1674
|
-
db.exec("PRAGMA
|
|
1675
|
-
db.exec(
|
|
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(`
|
|
1676
1768
|
CREATE TABLE IF NOT EXISTS runs (
|
|
1677
1769
|
id TEXT PRIMARY KEY,
|
|
1678
1770
|
graph_name TEXT NOT NULL,
|
|
@@ -1712,7 +1804,7 @@ function openStore(dataDir2) {
|
|
|
1712
1804
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1713
1805
|
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1714
1806
|
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1715
|
-
`);
|
|
1807
|
+
`));
|
|
1716
1808
|
const rowToRun = (r) => ({
|
|
1717
1809
|
id: String(r.id),
|
|
1718
1810
|
graphName: String(r.graph_name),
|
|
@@ -1750,31 +1842,33 @@ function openStore(dataDir2) {
|
|
|
1750
1842
|
createdAt: String(r.created_at)
|
|
1751
1843
|
});
|
|
1752
1844
|
return {
|
|
1845
|
+
dataDir: dataDir2,
|
|
1753
1846
|
close() {
|
|
1754
1847
|
db.close();
|
|
1755
1848
|
},
|
|
1756
1849
|
listTables() {
|
|
1757
|
-
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());
|
|
1758
1851
|
return rows.map((r) => r.name);
|
|
1759
1852
|
},
|
|
1760
1853
|
createRun(input) {
|
|
1761
1854
|
const id = randomUUID();
|
|
1762
1855
|
const now = nowIso2();
|
|
1763
|
-
db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1764
|
-
|
|
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));
|
|
1765
1858
|
return this.getRun(id);
|
|
1766
1859
|
},
|
|
1767
1860
|
getRun(id) {
|
|
1768
|
-
const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
1861
|
+
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1769
1862
|
return row ? rowToRun(row) : undefined;
|
|
1770
1863
|
},
|
|
1771
1864
|
listRuns(opts = {}) {
|
|
1772
1865
|
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1773
1866
|
if (opts.status) {
|
|
1774
|
-
const
|
|
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));
|
|
1775
1869
|
return rows2.map((r) => rowToRun(r));
|
|
1776
1870
|
}
|
|
1777
|
-
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));
|
|
1778
1872
|
return rows.map((r) => rowToRun(r));
|
|
1779
1873
|
},
|
|
1780
1874
|
setRunStatus(id, status, patch = {}) {
|
|
@@ -1785,28 +1879,32 @@ function openStore(dataDir2) {
|
|
|
1785
1879
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1786
1880
|
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1787
1881
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1788
|
-
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));
|
|
1789
1883
|
},
|
|
1790
1884
|
bumpAttempts(id) {
|
|
1791
|
-
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));
|
|
1792
1886
|
},
|
|
1793
1887
|
setRunContext(id, context) {
|
|
1794
|
-
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));
|
|
1795
1889
|
},
|
|
1796
1890
|
createRunNode(input) {
|
|
1797
1891
|
const id = randomUUID();
|
|
1798
|
-
db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1799
|
-
|
|
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));
|
|
1800
1894
|
return this.getRunNode(id);
|
|
1801
1895
|
},
|
|
1802
1896
|
getRunNode(id) {
|
|
1803
|
-
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));
|
|
1804
1898
|
return row ? rowToNode(row) : undefined;
|
|
1805
1899
|
},
|
|
1806
1900
|
listRunNodes(runId) {
|
|
1807
|
-
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));
|
|
1808
1902
|
return rows.map((r) => rowToNode(r));
|
|
1809
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
|
+
},
|
|
1810
1908
|
setRunNodeStatus(id, status, patch = {}) {
|
|
1811
1909
|
const now = nowIso2();
|
|
1812
1910
|
const existing = this.getRunNode(id);
|
|
@@ -1818,44 +1916,44 @@ function openStore(dataDir2) {
|
|
|
1818
1916
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1819
1917
|
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1820
1918
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1821
|
-
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));
|
|
1822
1920
|
},
|
|
1823
1921
|
bumpAttemptsNode(id) {
|
|
1824
|
-
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));
|
|
1825
1923
|
},
|
|
1826
1924
|
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1827
|
-
db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1828
|
-
|
|
1829
|
-
|
|
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()));
|
|
1830
1928
|
},
|
|
1831
1929
|
memoGet(key) {
|
|
1832
|
-
const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
|
|
1930
|
+
const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
|
|
1833
1931
|
return row ? rowToMemo(row) : undefined;
|
|
1834
1932
|
},
|
|
1835
1933
|
memoHit(key) {
|
|
1836
|
-
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));
|
|
1837
1935
|
},
|
|
1838
1936
|
memoList() {
|
|
1839
|
-
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());
|
|
1840
1938
|
return rows.map((r) => rowToMemo(r));
|
|
1841
1939
|
},
|
|
1842
1940
|
memoClear() {
|
|
1843
|
-
db.exec(`DELETE FROM memos`);
|
|
1941
|
+
retryBusy(() => db.exec(`DELETE FROM memos`));
|
|
1844
1942
|
}
|
|
1845
1943
|
};
|
|
1846
1944
|
}
|
|
1847
1945
|
|
|
1848
1946
|
// src/wal.ts
|
|
1849
|
-
import { createHash as
|
|
1850
|
-
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1851
|
-
import { join as
|
|
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";
|
|
1852
1950
|
var WAL_FILE_NAME = "session.wal";
|
|
1853
1951
|
var SESSIONS_DIR_NAME = "sessions";
|
|
1854
1952
|
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1855
1953
|
function checksumOf(op) {
|
|
1856
1954
|
if (op === undefined || op === null)
|
|
1857
1955
|
return "";
|
|
1858
|
-
return
|
|
1956
|
+
return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1859
1957
|
}
|
|
1860
1958
|
|
|
1861
1959
|
class SessionWAL {
|
|
@@ -1864,9 +1962,9 @@ class SessionWAL {
|
|
|
1864
1962
|
this.filePath = filePath;
|
|
1865
1963
|
}
|
|
1866
1964
|
static open(dataDir2) {
|
|
1867
|
-
mkdirSync2(
|
|
1868
|
-
const canonical =
|
|
1869
|
-
const legacy =
|
|
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);
|
|
1870
1968
|
if (existsSync2(legacy) && !existsSync2(canonical)) {
|
|
1871
1969
|
try {
|
|
1872
1970
|
renameSync(legacy, canonical);
|
|
@@ -1898,7 +1996,7 @@ class SessionWAL {
|
|
|
1898
1996
|
let repaired = false;
|
|
1899
1997
|
let content;
|
|
1900
1998
|
try {
|
|
1901
|
-
content =
|
|
1999
|
+
content = readFileSync2(this.filePath, "utf8");
|
|
1902
2000
|
} catch {
|
|
1903
2001
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1904
2002
|
}
|
package/hasna.contract.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "workflows",
|
|
4
4
|
"class": "service",
|
|
5
5
|
"contractVersion": "v1",
|
|
6
|
-
"kitVersion": "0.14.
|
|
6
|
+
"kitVersion": "0.14.2",
|
|
7
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",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/workflows",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -77,8 +77,8 @@
|
|
|
77
77
|
"commander": "^13.1.0"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
|
-
"@hasna/contracts": "0.14.
|
|
81
|
-
"@types/bun": "
|
|
80
|
+
"@hasna/contracts": "0.14.2",
|
|
81
|
+
"@types/bun": "1.3.14",
|
|
82
82
|
"@types/pg": "^8.15.6",
|
|
83
83
|
"pg": "^8.16.3",
|
|
84
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". */
|
package/types/session.d.ts
CHANGED
|
@@ -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;
|