@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/README.md +1 -1
- package/dist/agent-tools.js +152 -54
- package/dist/cli/index.js +297 -107
- package/dist/index.js +193 -71
- package/dist/mcp/index.js +186 -64
- package/dist/serve/index.js +193 -71
- package/dist/service.js +190 -68
- package/hasna.contract.json +2 -2
- package/package.json +6 -5
- package/types/daemon.d.ts +3 -0
- package/types/graph.d.ts +7 -0
- package/types/paths.d.ts +51 -0
- package/types/session.d.ts +24 -0
- package/types/store.d.ts +5 -0
- package/types/types.d.ts +3 -1
package/dist/index.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 {
|
|
@@ -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.
|
|
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": "
|
|
524
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
519
525
|
"@cursor/sdk": "^1.0.28",
|
|
520
|
-
"@
|
|
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.
|
|
525
|
-
"@types/bun": "
|
|
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
|
|
1472
|
-
const
|
|
1473
|
-
|
|
1474
|
-
|
|
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
|
|
1504
|
-
|
|
1505
|
-
|
|
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 (
|
|
1527
|
-
|
|
1528
|
-
|
|
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
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1804
|
-
|
|
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
|
|
1825
|
-
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1826
|
-
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";
|
|
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
|
|
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(
|
|
1842
|
-
mkdirSync2(
|
|
1843
|
-
const canonical =
|
|
1844
|
-
const legacy =
|
|
1845
|
-
if (
|
|
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 =
|
|
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"]) ??
|
|
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
|
};
|
|
@@ -2054,7 +2176,7 @@ function createWorkflowsService(config = {}) {
|
|
|
2054
2176
|
}
|
|
2055
2177
|
|
|
2056
2178
|
// src/handlers.ts
|
|
2057
|
-
import { createHash as
|
|
2179
|
+
import { createHash as createHash3, timingSafeEqual } from "crypto";
|
|
2058
2180
|
function jsonResponse(body, status = 200) {
|
|
2059
2181
|
return new Response(JSON.stringify(body), {
|
|
2060
2182
|
status,
|
|
@@ -2110,8 +2232,8 @@ function bearerMatches(authHeader, expected) {
|
|
|
2110
2232
|
const supplied = authHeader.slice("Bearer ".length);
|
|
2111
2233
|
if (supplied.length === 0)
|
|
2112
2234
|
return false;
|
|
2113
|
-
const a =
|
|
2114
|
-
const b =
|
|
2235
|
+
const a = createHash3("sha256").update(supplied).digest();
|
|
2236
|
+
const b = createHash3("sha256").update(expected).digest();
|
|
2115
2237
|
return timingSafeEqual(a, b);
|
|
2116
2238
|
}
|
|
2117
2239
|
async function handleTrigger(service, req) {
|