@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/serve/index.js
CHANGED
|
@@ -18,12 +18,12 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
|
18
18
|
var __require = import.meta.require;
|
|
19
19
|
|
|
20
20
|
// src/lanes/types.ts
|
|
21
|
-
import { statSync } from "fs";
|
|
21
|
+
import { statSync as statSync2 } from "fs";
|
|
22
22
|
function binaryOnPath(binary) {
|
|
23
23
|
const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
|
|
24
24
|
for (const entry of pathEntries) {
|
|
25
25
|
try {
|
|
26
|
-
const stat =
|
|
26
|
+
const stat = statSync2(`${entry}/${binary}`);
|
|
27
27
|
if (stat.isFile())
|
|
28
28
|
return true;
|
|
29
29
|
} catch {}
|
|
@@ -298,10 +298,12 @@ function createCursorAdapter(deps = {}) {
|
|
|
298
298
|
try {
|
|
299
299
|
const run = await withTimeout(runLocalAgent(sdk, job), timeoutMs, `cursor lane (${job.prompt.slice(0, 60)})`);
|
|
300
300
|
const durationMs = Date.now() - started;
|
|
301
|
-
const status = run
|
|
301
|
+
const status = run.status;
|
|
302
302
|
const ok = status === "finished";
|
|
303
|
-
return { ok, exitCode: ok ? 0 : 1, output: run
|
|
303
|
+
return { ok, exitCode: ok ? 0 : 1, output: run.result, durationMs };
|
|
304
304
|
} catch (err) {
|
|
305
|
+
if (err instanceof LaneAdapterShapeError)
|
|
306
|
+
throw err;
|
|
305
307
|
const durationMs = Date.now() - started;
|
|
306
308
|
return { ok: false, exitCode: 1, output: "", durationMs, error: String(err instanceof Error ? err.message : err) };
|
|
307
309
|
}
|
|
@@ -322,7 +324,14 @@ async function runLocalAgent(sdk, job) {
|
|
|
322
324
|
if (!run || typeof run !== "object") {
|
|
323
325
|
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned no Run");
|
|
324
326
|
}
|
|
325
|
-
|
|
327
|
+
const candidate = run;
|
|
328
|
+
if (typeof candidate.status !== "string" || !["finished", "error", "cancelled"].includes(candidate.status)) {
|
|
329
|
+
throw new LaneAdapterShapeError(`@cursor/sdk Agent.send() returned a Run without a terminal status (got ${JSON.stringify(candidate.status)}; expected finished|error|cancelled)`);
|
|
330
|
+
}
|
|
331
|
+
if (typeof candidate.result !== "string") {
|
|
332
|
+
throw new LaneAdapterShapeError("@cursor/sdk Agent.send() returned a Run without a string result");
|
|
333
|
+
}
|
|
334
|
+
return { status: candidate.status, result: candidate.result };
|
|
326
335
|
}
|
|
327
336
|
function runCursorCli(job, cliPath, started, timeoutMs) {
|
|
328
337
|
try {
|
|
@@ -440,13 +449,10 @@ var init_lanes = __esm(() => {
|
|
|
440
449
|
["grok", createGrokAdapter()]
|
|
441
450
|
]);
|
|
442
451
|
});
|
|
443
|
-
|
|
444
|
-
// src/service.ts
|
|
445
|
-
import { join as join3 } from "path";
|
|
446
452
|
// package.json
|
|
447
453
|
var package_default = {
|
|
448
454
|
name: "@hasna/workflows",
|
|
449
|
-
version: "0.1.
|
|
455
|
+
version: "0.1.4",
|
|
450
456
|
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",
|
|
451
457
|
type: "module",
|
|
452
458
|
main: "./dist/index.js",
|
|
@@ -516,23 +522,21 @@ var package_default = {
|
|
|
516
522
|
author: "Hasna",
|
|
517
523
|
license: "Apache-2.0",
|
|
518
524
|
dependencies: {
|
|
519
|
-
"@anthropic-ai/claude-agent-sdk": "
|
|
525
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
520
526
|
"@cursor/sdk": "^1.0.28",
|
|
521
|
-
"@
|
|
527
|
+
"@hasna/paths": "0.1.0",
|
|
528
|
+
"@openai/codex-sdk": "0.147.0",
|
|
522
529
|
commander: "^13.1.0"
|
|
523
530
|
},
|
|
524
531
|
devDependencies: {
|
|
525
|
-
"@hasna/contracts": "0.14.
|
|
526
|
-
"@types/bun": "
|
|
532
|
+
"@hasna/contracts": "0.14.2",
|
|
533
|
+
"@types/bun": "1.3.14",
|
|
527
534
|
"@types/pg": "^8.15.6",
|
|
528
535
|
pg: "^8.16.3",
|
|
529
536
|
typescript: "^5.7.3"
|
|
530
537
|
}
|
|
531
538
|
};
|
|
532
539
|
|
|
533
|
-
// src/daemon.ts
|
|
534
|
-
import { createHash as createHash2 } from "crypto";
|
|
535
|
-
|
|
536
540
|
// src/expr.ts
|
|
537
541
|
class ExprSyntaxError extends Error {
|
|
538
542
|
constructor(message) {
|
|
@@ -906,6 +910,18 @@ function validateGraph(graph) {
|
|
|
906
910
|
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
907
911
|
issue(issues, "version", "graph requires a version string");
|
|
908
912
|
}
|
|
913
|
+
if (graph.memoWatch !== undefined) {
|
|
914
|
+
if (!Array.isArray(graph.memoWatch) || graph.memoWatch.length === 0) {
|
|
915
|
+
issue(issues, "memoWatch", "memoWatch must be a non-empty array of file paths or globs");
|
|
916
|
+
} else {
|
|
917
|
+
for (const entry of graph.memoWatch) {
|
|
918
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
919
|
+
issue(issues, "memoWatch", "each memoWatch entry must be a non-empty path or glob string");
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
909
925
|
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
910
926
|
issue(issues, "nodes", "graph requires at least one node");
|
|
911
927
|
return { ok: false, issues };
|
|
@@ -1140,6 +1156,8 @@ function assertNoSecrets(value, what) {
|
|
|
1140
1156
|
|
|
1141
1157
|
// src/session.ts
|
|
1142
1158
|
import { createHash } from "crypto";
|
|
1159
|
+
import { readFileSync, statSync } from "fs";
|
|
1160
|
+
import { isAbsolute, join } from "path";
|
|
1143
1161
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1144
1162
|
function repairTornRuns(store, wal, opts = {}) {
|
|
1145
1163
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -1195,6 +1213,46 @@ function recordMemo(store, graphName, nodeId, input, output) {
|
|
|
1195
1213
|
const key = memoKey(graphName, nodeId, input);
|
|
1196
1214
|
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1197
1215
|
}
|
|
1216
|
+
function memoWatchFingerprint(graph, dataDir) {
|
|
1217
|
+
if (!graph.memoWatch || graph.memoWatch.length === 0)
|
|
1218
|
+
return;
|
|
1219
|
+
const entries = [];
|
|
1220
|
+
for (const pattern of graph.memoWatch) {
|
|
1221
|
+
if (hasGlobMagic(pattern)) {
|
|
1222
|
+
const matches = [...new Bun.Glob(pattern).scanSync({ cwd: dataDir, onlyFiles: true })].sort();
|
|
1223
|
+
for (const match of matches) {
|
|
1224
|
+
const path2 = isAbsolute(match) ? match : join(dataDir, match);
|
|
1225
|
+
entries.push(fingerprintFile(pattern, path2));
|
|
1226
|
+
}
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
const path = isAbsolute(pattern) ? pattern : join(dataDir, pattern);
|
|
1230
|
+
entries.push(fingerprintFile(pattern, path));
|
|
1231
|
+
}
|
|
1232
|
+
return entries;
|
|
1233
|
+
}
|
|
1234
|
+
function hasGlobMagic(pattern) {
|
|
1235
|
+
return /[*?[\]{}]/.test(pattern);
|
|
1236
|
+
}
|
|
1237
|
+
function fingerprintFile(pattern, path) {
|
|
1238
|
+
try {
|
|
1239
|
+
const stat = statSync(path);
|
|
1240
|
+
if (!stat.isFile()) {
|
|
1241
|
+
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1242
|
+
}
|
|
1243
|
+
const content = readFileSync(path);
|
|
1244
|
+
return {
|
|
1245
|
+
pattern,
|
|
1246
|
+
path,
|
|
1247
|
+
missing: false,
|
|
1248
|
+
mtimeMs: stat.mtimeMs,
|
|
1249
|
+
size: content.byteLength,
|
|
1250
|
+
sha256: createHash("sha256").update(content).digest("hex")
|
|
1251
|
+
};
|
|
1252
|
+
} catch {
|
|
1253
|
+
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1198
1256
|
function findRunByIdempotencyKey(store, key) {
|
|
1199
1257
|
const runs = store.listRuns({ limit: 1000 });
|
|
1200
1258
|
for (const run of runs) {
|
|
@@ -1469,10 +1527,13 @@ class WorkflowsDaemon {
|
|
|
1469
1527
|
}
|
|
1470
1528
|
async executeStep(run, graph, context, wf, node) {
|
|
1471
1529
|
const nodeId = node.id;
|
|
1472
|
-
const
|
|
1473
|
-
const
|
|
1474
|
-
|
|
1475
|
-
|
|
1530
|
+
const latest = this.store.getLatestRunNode(run.id, nodeId);
|
|
1531
|
+
const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
|
|
1532
|
+
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" }) : latest;
|
|
1533
|
+
const memoWatch = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1534
|
+
const memoSource = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
|
|
1535
|
+
const memoInput = node.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
|
|
1536
|
+
if (memoInput !== undefined) {
|
|
1476
1537
|
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1477
1538
|
if (hit) {
|
|
1478
1539
|
const cached = JSON.parse(hit.outputJson);
|
|
@@ -1501,9 +1562,9 @@ class WorkflowsDaemon {
|
|
|
1501
1562
|
}
|
|
1502
1563
|
const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
|
|
1503
1564
|
if (!result.ok) {
|
|
1504
|
-
const
|
|
1505
|
-
|
|
1506
|
-
|
|
1565
|
+
const shouldRetry = nodeRow.attempts < (node.maxRetries ?? 0);
|
|
1566
|
+
this.store.bumpAttemptsNode(nodeRow.id);
|
|
1567
|
+
if (shouldRetry) {
|
|
1507
1568
|
this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
|
|
1508
1569
|
return { kind: "advanced", nodeId };
|
|
1509
1570
|
}
|
|
@@ -1524,9 +1585,13 @@ class WorkflowsDaemon {
|
|
|
1524
1585
|
}
|
|
1525
1586
|
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1526
1587
|
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1527
|
-
if (
|
|
1528
|
-
|
|
1529
|
-
|
|
1588
|
+
if (memoInput !== undefined) {
|
|
1589
|
+
const memoInputAfter = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1590
|
+
const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
|
|
1591
|
+
if (stable) {
|
|
1592
|
+
recordMemo(this.store, run.graphName, nodeId, memoInput, output);
|
|
1593
|
+
this.wal.append({ op: "memo_set", key: memoKey(run.graphName, nodeId, memoInput), at: nowIso() });
|
|
1594
|
+
}
|
|
1530
1595
|
}
|
|
1531
1596
|
this.recordStepResult(context, nodeId, output);
|
|
1532
1597
|
return this.afterNode(run, context, wf, node.next);
|
|
@@ -1621,6 +1686,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1621
1686
|
isolatedRunId: options.isolatedRunId
|
|
1622
1687
|
});
|
|
1623
1688
|
const run = daemon.startRun(graph, context);
|
|
1689
|
+
options.onRunCreated?.(run);
|
|
1624
1690
|
daemon.isolateRun(run.id);
|
|
1625
1691
|
const maxCycles = options.maxCycles ?? 500;
|
|
1626
1692
|
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
@@ -1635,20 +1701,71 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1635
1701
|
return store.getRun(run.id);
|
|
1636
1702
|
}
|
|
1637
1703
|
|
|
1704
|
+
// src/paths.ts
|
|
1705
|
+
import { existsSync } from "fs";
|
|
1706
|
+
import { homedir } from "os";
|
|
1707
|
+
import { join as join2 } from "path";
|
|
1708
|
+
import { dataDir } from "@hasna/paths";
|
|
1709
|
+
var APP = "workflows";
|
|
1710
|
+
var STORE_DB_FILE = "workflows.db";
|
|
1711
|
+
function getHomeDir(env = process.env) {
|
|
1712
|
+
return env["HOME"] || env["USERPROFILE"] || homedir();
|
|
1713
|
+
}
|
|
1714
|
+
function getLegacyDataDir(env = process.env) {
|
|
1715
|
+
return join2(getHomeDir(env), ".hasna", APP);
|
|
1716
|
+
}
|
|
1717
|
+
function getResolverDataDir(env = process.env) {
|
|
1718
|
+
return dataDir({ app: APP, home: getHomeDir(env), env });
|
|
1719
|
+
}
|
|
1720
|
+
function adoptResolverDataDir(resolved, env = process.env) {
|
|
1721
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
1722
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
1723
|
+
return true;
|
|
1724
|
+
return existsSync(join2(resolved, STORE_DB_FILE));
|
|
1725
|
+
}
|
|
1726
|
+
function getEffectiveDataDir(env = process.env) {
|
|
1727
|
+
const resolved = getResolverDataDir(env);
|
|
1728
|
+
return adoptResolverDataDir(resolved, env) ? resolved : getLegacyDataDir(env);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1638
1731
|
// src/store.ts
|
|
1639
1732
|
import { Database } from "bun:sqlite";
|
|
1640
1733
|
import { mkdirSync } from "fs";
|
|
1641
|
-
import { join } from "path";
|
|
1734
|
+
import { join as join3 } from "path";
|
|
1642
1735
|
import { randomUUID } from "crypto";
|
|
1643
1736
|
function nowIso2() {
|
|
1644
1737
|
return new Date().toISOString();
|
|
1645
1738
|
}
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1739
|
+
var BUSY_TIMEOUT_MS = 3000;
|
|
1740
|
+
var BUSY_RETRIES = 2;
|
|
1741
|
+
var BUSY_RETRY_SLEEP_MS = 50;
|
|
1742
|
+
function isBusyError(err) {
|
|
1743
|
+
if (!(err instanceof Error))
|
|
1744
|
+
return false;
|
|
1745
|
+
const code = String(err.code ?? "");
|
|
1746
|
+
return code.includes("BUSY") || /\bdatabase is locked\b/i.test(err.message);
|
|
1747
|
+
}
|
|
1748
|
+
function retryBusy(fn) {
|
|
1749
|
+
let last;
|
|
1750
|
+
for (let attempt = 0;attempt <= BUSY_RETRIES; attempt++) {
|
|
1751
|
+
try {
|
|
1752
|
+
return fn();
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
if (!isBusyError(err))
|
|
1755
|
+
throw err;
|
|
1756
|
+
last = err;
|
|
1757
|
+
Bun.sleepSync(BUSY_RETRY_SLEEP_MS);
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
throw last;
|
|
1761
|
+
}
|
|
1762
|
+
function openStore(dataDir2) {
|
|
1763
|
+
mkdirSync(dataDir2, { recursive: true });
|
|
1764
|
+
const db = new Database(join3(dataDir2, "workflows.db"));
|
|
1765
|
+
retryBusy(() => db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`));
|
|
1766
|
+
retryBusy(() => db.exec("PRAGMA journal_mode = WAL"));
|
|
1767
|
+
retryBusy(() => db.exec("PRAGMA foreign_keys = ON"));
|
|
1768
|
+
retryBusy(() => db.exec(`
|
|
1652
1769
|
CREATE TABLE IF NOT EXISTS runs (
|
|
1653
1770
|
id TEXT PRIMARY KEY,
|
|
1654
1771
|
graph_name TEXT NOT NULL,
|
|
@@ -1688,7 +1805,7 @@ function openStore(dataDir) {
|
|
|
1688
1805
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1689
1806
|
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1690
1807
|
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1691
|
-
`);
|
|
1808
|
+
`));
|
|
1692
1809
|
const rowToRun = (r) => ({
|
|
1693
1810
|
id: String(r.id),
|
|
1694
1811
|
graphName: String(r.graph_name),
|
|
@@ -1726,31 +1843,33 @@ function openStore(dataDir) {
|
|
|
1726
1843
|
createdAt: String(r.created_at)
|
|
1727
1844
|
});
|
|
1728
1845
|
return {
|
|
1846
|
+
dataDir: dataDir2,
|
|
1729
1847
|
close() {
|
|
1730
1848
|
db.close();
|
|
1731
1849
|
},
|
|
1732
1850
|
listTables() {
|
|
1733
|
-
const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
1851
|
+
const rows = retryBusy(() => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all());
|
|
1734
1852
|
return rows.map((r) => r.name);
|
|
1735
1853
|
},
|
|
1736
1854
|
createRun(input) {
|
|
1737
1855
|
const id = randomUUID();
|
|
1738
1856
|
const now = nowIso2();
|
|
1739
|
-
db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1740
|
-
|
|
1857
|
+
retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1858
|
+
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
|
|
1741
1859
|
return this.getRun(id);
|
|
1742
1860
|
},
|
|
1743
1861
|
getRun(id) {
|
|
1744
|
-
const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
1862
|
+
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1745
1863
|
return row ? rowToRun(row) : undefined;
|
|
1746
1864
|
},
|
|
1747
1865
|
listRuns(opts = {}) {
|
|
1748
1866
|
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1749
1867
|
if (opts.status) {
|
|
1750
|
-
const
|
|
1868
|
+
const status = opts.status;
|
|
1869
|
+
const rows2 = retryBusy(() => db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(status, limit));
|
|
1751
1870
|
return rows2.map((r) => rowToRun(r));
|
|
1752
1871
|
}
|
|
1753
|
-
const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
|
|
1872
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit));
|
|
1754
1873
|
return rows.map((r) => rowToRun(r));
|
|
1755
1874
|
},
|
|
1756
1875
|
setRunStatus(id, status, patch = {}) {
|
|
@@ -1761,28 +1880,32 @@ function openStore(dataDir) {
|
|
|
1761
1880
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1762
1881
|
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1763
1882
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1764
|
-
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);
|
|
1883
|
+
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));
|
|
1765
1884
|
},
|
|
1766
1885
|
bumpAttempts(id) {
|
|
1767
|
-
db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
|
|
1886
|
+
retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
|
|
1768
1887
|
},
|
|
1769
1888
|
setRunContext(id, context) {
|
|
1770
|
-
db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
|
|
1889
|
+
retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
|
|
1771
1890
|
},
|
|
1772
1891
|
createRunNode(input) {
|
|
1773
1892
|
const id = randomUUID();
|
|
1774
|
-
db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1775
|
-
|
|
1893
|
+
retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1894
|
+
VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
|
|
1776
1895
|
return this.getRunNode(id);
|
|
1777
1896
|
},
|
|
1778
1897
|
getRunNode(id) {
|
|
1779
|
-
const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
|
|
1898
|
+
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id));
|
|
1780
1899
|
return row ? rowToNode(row) : undefined;
|
|
1781
1900
|
},
|
|
1782
1901
|
listRunNodes(runId) {
|
|
1783
|
-
const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
|
|
1902
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId));
|
|
1784
1903
|
return rows.map((r) => rowToNode(r));
|
|
1785
1904
|
},
|
|
1905
|
+
getLatestRunNode(runId, nodeId) {
|
|
1906
|
+
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? AND node_id = ? ORDER BY rowid DESC LIMIT 1`).get(runId, nodeId));
|
|
1907
|
+
return row ? rowToNode(row) : undefined;
|
|
1908
|
+
},
|
|
1786
1909
|
setRunNodeStatus(id, status, patch = {}) {
|
|
1787
1910
|
const now = nowIso2();
|
|
1788
1911
|
const existing = this.getRunNode(id);
|
|
@@ -1794,44 +1917,44 @@ function openStore(dataDir) {
|
|
|
1794
1917
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1795
1918
|
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1796
1919
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1797
|
-
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);
|
|
1920
|
+
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));
|
|
1798
1921
|
},
|
|
1799
1922
|
bumpAttemptsNode(id) {
|
|
1800
|
-
db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
|
|
1923
|
+
retryBusy(() => db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id));
|
|
1801
1924
|
},
|
|
1802
1925
|
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1803
|
-
db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1804
|
-
|
|
1805
|
-
|
|
1926
|
+
retryBusy(() => db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1927
|
+
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
1928
|
+
ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2()));
|
|
1806
1929
|
},
|
|
1807
1930
|
memoGet(key) {
|
|
1808
|
-
const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
|
|
1931
|
+
const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
|
|
1809
1932
|
return row ? rowToMemo(row) : undefined;
|
|
1810
1933
|
},
|
|
1811
1934
|
memoHit(key) {
|
|
1812
|
-
db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
|
|
1935
|
+
retryBusy(() => db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key));
|
|
1813
1936
|
},
|
|
1814
1937
|
memoList() {
|
|
1815
|
-
const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
|
|
1938
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all());
|
|
1816
1939
|
return rows.map((r) => rowToMemo(r));
|
|
1817
1940
|
},
|
|
1818
1941
|
memoClear() {
|
|
1819
|
-
db.exec(`DELETE FROM memos`);
|
|
1942
|
+
retryBusy(() => db.exec(`DELETE FROM memos`));
|
|
1820
1943
|
}
|
|
1821
1944
|
};
|
|
1822
1945
|
}
|
|
1823
1946
|
|
|
1824
1947
|
// src/wal.ts
|
|
1825
|
-
import { createHash as
|
|
1826
|
-
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1827
|
-
import { join as
|
|
1948
|
+
import { createHash as createHash2 } from "crypto";
|
|
1949
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1950
|
+
import { join as join4 } from "path";
|
|
1828
1951
|
var WAL_FILE_NAME = "session.wal";
|
|
1829
1952
|
var SESSIONS_DIR_NAME = "sessions";
|
|
1830
1953
|
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1831
1954
|
function checksumOf(op) {
|
|
1832
1955
|
if (op === undefined || op === null)
|
|
1833
1956
|
return "";
|
|
1834
|
-
return
|
|
1957
|
+
return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1835
1958
|
}
|
|
1836
1959
|
|
|
1837
1960
|
class SessionWAL {
|
|
@@ -1839,11 +1962,11 @@ class SessionWAL {
|
|
|
1839
1962
|
constructor(filePath) {
|
|
1840
1963
|
this.filePath = filePath;
|
|
1841
1964
|
}
|
|
1842
|
-
static open(
|
|
1843
|
-
mkdirSync2(
|
|
1844
|
-
const canonical =
|
|
1845
|
-
const legacy =
|
|
1846
|
-
if (
|
|
1965
|
+
static open(dataDir2) {
|
|
1966
|
+
mkdirSync2(join4(dataDir2, SESSIONS_DIR_NAME), { recursive: true });
|
|
1967
|
+
const canonical = join4(dataDir2, SESSIONS_DIR_NAME, WAL_FILE_NAME);
|
|
1968
|
+
const legacy = join4(dataDir2, WAL_FILE_NAME);
|
|
1969
|
+
if (existsSync2(legacy) && !existsSync2(canonical)) {
|
|
1847
1970
|
try {
|
|
1848
1971
|
renameSync(legacy, canonical);
|
|
1849
1972
|
} catch {
|
|
@@ -1874,7 +1997,7 @@ class SessionWAL {
|
|
|
1874
1997
|
let repaired = false;
|
|
1875
1998
|
let content;
|
|
1876
1999
|
try {
|
|
1877
|
-
content =
|
|
2000
|
+
content = readFileSync2(this.filePath, "utf8");
|
|
1878
2001
|
} catch {
|
|
1879
2002
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1880
2003
|
}
|
|
@@ -1974,11 +2097,10 @@ function envInt(names, fallback) {
|
|
|
1974
2097
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1975
2098
|
}
|
|
1976
2099
|
function resolveWorkflowsConfig(overrides = {}) {
|
|
1977
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
1978
2100
|
return {
|
|
1979
2101
|
port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
|
|
1980
2102
|
host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
|
|
1981
|
-
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ??
|
|
2103
|
+
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? getEffectiveDataDir(),
|
|
1982
2104
|
apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
|
|
1983
2105
|
apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
|
|
1984
2106
|
};
|
|
@@ -2055,7 +2177,7 @@ function createWorkflowsService(config = {}) {
|
|
|
2055
2177
|
}
|
|
2056
2178
|
|
|
2057
2179
|
// src/handlers.ts
|
|
2058
|
-
import { createHash as
|
|
2180
|
+
import { createHash as createHash3, timingSafeEqual } from "crypto";
|
|
2059
2181
|
function jsonResponse(body, status = 200) {
|
|
2060
2182
|
return new Response(JSON.stringify(body), {
|
|
2061
2183
|
status,
|
|
@@ -2111,8 +2233,8 @@ function bearerMatches(authHeader, expected) {
|
|
|
2111
2233
|
const supplied = authHeader.slice("Bearer ".length);
|
|
2112
2234
|
if (supplied.length === 0)
|
|
2113
2235
|
return false;
|
|
2114
|
-
const a =
|
|
2115
|
-
const b =
|
|
2236
|
+
const a = createHash3("sha256").update(supplied).digest();
|
|
2237
|
+
const b = createHash3("sha256").update(expected).digest();
|
|
2116
2238
|
return timingSafeEqual(a, b);
|
|
2117
2239
|
}
|
|
2118
2240
|
async function handleTrigger(service, req) {
|