@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/README.md
CHANGED
|
@@ -102,7 +102,7 @@ console.log(svc.health());
|
|
|
102
102
|
|---|---|---|
|
|
103
103
|
| `HASNA_WORKFLOWS_PORT` / `WORKFLOWS_PORT` | serve port | 8790 |
|
|
104
104
|
| `HASNA_WORKFLOWS_HOST` / `WORKFLOWS_HOST` | serve bind host | 127.0.0.1 |
|
|
105
|
-
| `HASNA_WORKFLOWS_DATA_DIR` / `WORKFLOWS_DATA_DIR` | data directory | `~/.hasna/workflows` |
|
|
105
|
+
| `HASNA_WORKFLOWS_DATA_DIR` / `WORKFLOWS_DATA_DIR` | data directory | `~/.hasna/workflows` (the @hasna/paths XDG data home `~/.local/share/hasna/workflows` is adopted once `HASNA_DATA_HOME` is set or the store is migrated there) |
|
|
106
106
|
|
|
107
107
|
The server data backend is sqlite (default) or postgresql when
|
|
108
108
|
`HASNA_WORKFLOWS_DATABASE_URL` is set.
|
package/dist/agent-tools.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 {
|
|
@@ -813,6 +822,18 @@ function validateGraph(graph) {
|
|
|
813
822
|
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
814
823
|
issue(issues, "version", "graph requires a version string");
|
|
815
824
|
}
|
|
825
|
+
if (graph.memoWatch !== undefined) {
|
|
826
|
+
if (!Array.isArray(graph.memoWatch) || graph.memoWatch.length === 0) {
|
|
827
|
+
issue(issues, "memoWatch", "memoWatch must be a non-empty array of file paths or globs");
|
|
828
|
+
} else {
|
|
829
|
+
for (const entry of graph.memoWatch) {
|
|
830
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
831
|
+
issue(issues, "memoWatch", "each memoWatch entry must be a non-empty path or glob string");
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
816
837
|
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
817
838
|
issue(issues, "nodes", "graph requires at least one node");
|
|
818
839
|
return { ok: false, issues };
|
|
@@ -971,9 +992,6 @@ function validateNode(graph, node, issues) {
|
|
|
971
992
|
}
|
|
972
993
|
}
|
|
973
994
|
|
|
974
|
-
// src/daemon.ts
|
|
975
|
-
import { createHash as createHash2 } from "crypto";
|
|
976
|
-
|
|
977
995
|
// src/secrets.ts
|
|
978
996
|
class SecretsGateError extends Error {
|
|
979
997
|
constructor(message) {
|
|
@@ -1050,6 +1068,8 @@ function assertNoSecrets(value, what) {
|
|
|
1050
1068
|
|
|
1051
1069
|
// src/session.ts
|
|
1052
1070
|
import { createHash } from "crypto";
|
|
1071
|
+
import { readFileSync, statSync } from "fs";
|
|
1072
|
+
import { isAbsolute, join } from "path";
|
|
1053
1073
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1054
1074
|
function repairTornRuns(store, wal, opts = {}) {
|
|
1055
1075
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -1105,6 +1125,46 @@ function recordMemo(store, graphName, nodeId, input, output) {
|
|
|
1105
1125
|
const key = memoKey(graphName, nodeId, input);
|
|
1106
1126
|
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1107
1127
|
}
|
|
1128
|
+
function memoWatchFingerprint(graph, dataDir) {
|
|
1129
|
+
if (!graph.memoWatch || graph.memoWatch.length === 0)
|
|
1130
|
+
return;
|
|
1131
|
+
const entries = [];
|
|
1132
|
+
for (const pattern of graph.memoWatch) {
|
|
1133
|
+
if (hasGlobMagic(pattern)) {
|
|
1134
|
+
const matches = [...new Bun.Glob(pattern).scanSync({ cwd: dataDir, onlyFiles: true })].sort();
|
|
1135
|
+
for (const match of matches) {
|
|
1136
|
+
const path2 = isAbsolute(match) ? match : join(dataDir, match);
|
|
1137
|
+
entries.push(fingerprintFile(pattern, path2));
|
|
1138
|
+
}
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
const path = isAbsolute(pattern) ? pattern : join(dataDir, pattern);
|
|
1142
|
+
entries.push(fingerprintFile(pattern, path));
|
|
1143
|
+
}
|
|
1144
|
+
return entries;
|
|
1145
|
+
}
|
|
1146
|
+
function hasGlobMagic(pattern) {
|
|
1147
|
+
return /[*?[\]{}]/.test(pattern);
|
|
1148
|
+
}
|
|
1149
|
+
function fingerprintFile(pattern, path) {
|
|
1150
|
+
try {
|
|
1151
|
+
const stat = statSync(path);
|
|
1152
|
+
if (!stat.isFile()) {
|
|
1153
|
+
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1154
|
+
}
|
|
1155
|
+
const content = readFileSync(path);
|
|
1156
|
+
return {
|
|
1157
|
+
pattern,
|
|
1158
|
+
path,
|
|
1159
|
+
missing: false,
|
|
1160
|
+
mtimeMs: stat.mtimeMs,
|
|
1161
|
+
size: content.byteLength,
|
|
1162
|
+
sha256: createHash("sha256").update(content).digest("hex")
|
|
1163
|
+
};
|
|
1164
|
+
} catch {
|
|
1165
|
+
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1108
1168
|
function findRunByIdempotencyKey(store, key) {
|
|
1109
1169
|
const runs = store.listRuns({ limit: 1000 });
|
|
1110
1170
|
for (const run of runs) {
|
|
@@ -1379,10 +1439,13 @@ class WorkflowsDaemon {
|
|
|
1379
1439
|
}
|
|
1380
1440
|
async executeStep(run, graph, context, wf, node) {
|
|
1381
1441
|
const nodeId = node.id;
|
|
1382
|
-
const
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
|
|
1442
|
+
const latest = this.store.getLatestRunNode(run.id, nodeId);
|
|
1443
|
+
const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
|
|
1444
|
+
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" }) : latest;
|
|
1445
|
+
const memoWatch = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1446
|
+
const memoSource = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
|
|
1447
|
+
const memoInput = node.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
|
|
1448
|
+
if (memoInput !== undefined) {
|
|
1386
1449
|
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1387
1450
|
if (hit) {
|
|
1388
1451
|
const cached = JSON.parse(hit.outputJson);
|
|
@@ -1411,9 +1474,9 @@ class WorkflowsDaemon {
|
|
|
1411
1474
|
}
|
|
1412
1475
|
const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
|
|
1413
1476
|
if (!result.ok) {
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1477
|
+
const shouldRetry = nodeRow.attempts < (node.maxRetries ?? 0);
|
|
1478
|
+
this.store.bumpAttemptsNode(nodeRow.id);
|
|
1479
|
+
if (shouldRetry) {
|
|
1417
1480
|
this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
|
|
1418
1481
|
return { kind: "advanced", nodeId };
|
|
1419
1482
|
}
|
|
@@ -1434,9 +1497,13 @@ class WorkflowsDaemon {
|
|
|
1434
1497
|
}
|
|
1435
1498
|
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1436
1499
|
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1437
|
-
if (
|
|
1438
|
-
|
|
1439
|
-
|
|
1500
|
+
if (memoInput !== undefined) {
|
|
1501
|
+
const memoInputAfter = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1502
|
+
const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
|
|
1503
|
+
if (stable) {
|
|
1504
|
+
recordMemo(this.store, run.graphName, nodeId, memoInput, output);
|
|
1505
|
+
this.wal.append({ op: "memo_set", key: memoKey(run.graphName, nodeId, memoInput), at: nowIso() });
|
|
1506
|
+
}
|
|
1440
1507
|
}
|
|
1441
1508
|
this.recordStepResult(context, nodeId, output);
|
|
1442
1509
|
return this.afterNode(run, context, wf, node.next);
|
|
@@ -1531,6 +1598,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1531
1598
|
isolatedRunId: options.isolatedRunId
|
|
1532
1599
|
});
|
|
1533
1600
|
const run = daemon.startRun(graph, context);
|
|
1601
|
+
options.onRunCreated?.(run);
|
|
1534
1602
|
daemon.isolateRun(run.id);
|
|
1535
1603
|
const maxCycles = options.maxCycles ?? 500;
|
|
1536
1604
|
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
@@ -1551,17 +1619,41 @@ init_lanes();
|
|
|
1551
1619
|
// src/store.ts
|
|
1552
1620
|
import { Database } from "bun:sqlite";
|
|
1553
1621
|
import { mkdirSync } from "fs";
|
|
1554
|
-
import { join } from "path";
|
|
1622
|
+
import { join as join2 } from "path";
|
|
1555
1623
|
import { randomUUID } from "crypto";
|
|
1556
1624
|
function nowIso2() {
|
|
1557
1625
|
return new Date().toISOString();
|
|
1558
1626
|
}
|
|
1627
|
+
var BUSY_TIMEOUT_MS = 3000;
|
|
1628
|
+
var BUSY_RETRIES = 2;
|
|
1629
|
+
var BUSY_RETRY_SLEEP_MS = 50;
|
|
1630
|
+
function isBusyError(err) {
|
|
1631
|
+
if (!(err instanceof Error))
|
|
1632
|
+
return false;
|
|
1633
|
+
const code = String(err.code ?? "");
|
|
1634
|
+
return code.includes("BUSY") || /\bdatabase is locked\b/i.test(err.message);
|
|
1635
|
+
}
|
|
1636
|
+
function retryBusy(fn) {
|
|
1637
|
+
let last;
|
|
1638
|
+
for (let attempt = 0;attempt <= BUSY_RETRIES; attempt++) {
|
|
1639
|
+
try {
|
|
1640
|
+
return fn();
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
if (!isBusyError(err))
|
|
1643
|
+
throw err;
|
|
1644
|
+
last = err;
|
|
1645
|
+
Bun.sleepSync(BUSY_RETRY_SLEEP_MS);
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
throw last;
|
|
1649
|
+
}
|
|
1559
1650
|
function openStore(dataDir) {
|
|
1560
1651
|
mkdirSync(dataDir, { recursive: true });
|
|
1561
|
-
const db = new Database(
|
|
1562
|
-
db.exec(
|
|
1563
|
-
db.exec("PRAGMA
|
|
1564
|
-
db.exec(
|
|
1652
|
+
const db = new Database(join2(dataDir, "workflows.db"));
|
|
1653
|
+
retryBusy(() => db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`));
|
|
1654
|
+
retryBusy(() => db.exec("PRAGMA journal_mode = WAL"));
|
|
1655
|
+
retryBusy(() => db.exec("PRAGMA foreign_keys = ON"));
|
|
1656
|
+
retryBusy(() => db.exec(`
|
|
1565
1657
|
CREATE TABLE IF NOT EXISTS runs (
|
|
1566
1658
|
id TEXT PRIMARY KEY,
|
|
1567
1659
|
graph_name TEXT NOT NULL,
|
|
@@ -1601,7 +1693,7 @@ function openStore(dataDir) {
|
|
|
1601
1693
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1602
1694
|
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1603
1695
|
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1604
|
-
`);
|
|
1696
|
+
`));
|
|
1605
1697
|
const rowToRun = (r) => ({
|
|
1606
1698
|
id: String(r.id),
|
|
1607
1699
|
graphName: String(r.graph_name),
|
|
@@ -1639,31 +1731,33 @@ function openStore(dataDir) {
|
|
|
1639
1731
|
createdAt: String(r.created_at)
|
|
1640
1732
|
});
|
|
1641
1733
|
return {
|
|
1734
|
+
dataDir,
|
|
1642
1735
|
close() {
|
|
1643
1736
|
db.close();
|
|
1644
1737
|
},
|
|
1645
1738
|
listTables() {
|
|
1646
|
-
const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
1739
|
+
const rows = retryBusy(() => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all());
|
|
1647
1740
|
return rows.map((r) => r.name);
|
|
1648
1741
|
},
|
|
1649
1742
|
createRun(input) {
|
|
1650
1743
|
const id = randomUUID();
|
|
1651
1744
|
const now = nowIso2();
|
|
1652
|
-
db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1653
|
-
|
|
1745
|
+
retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1746
|
+
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
|
|
1654
1747
|
return this.getRun(id);
|
|
1655
1748
|
},
|
|
1656
1749
|
getRun(id) {
|
|
1657
|
-
const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
1750
|
+
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1658
1751
|
return row ? rowToRun(row) : undefined;
|
|
1659
1752
|
},
|
|
1660
1753
|
listRuns(opts = {}) {
|
|
1661
1754
|
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1662
1755
|
if (opts.status) {
|
|
1663
|
-
const
|
|
1756
|
+
const status = opts.status;
|
|
1757
|
+
const rows2 = retryBusy(() => db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(status, limit));
|
|
1664
1758
|
return rows2.map((r) => rowToRun(r));
|
|
1665
1759
|
}
|
|
1666
|
-
const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
|
|
1760
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit));
|
|
1667
1761
|
return rows.map((r) => rowToRun(r));
|
|
1668
1762
|
},
|
|
1669
1763
|
setRunStatus(id, status, patch = {}) {
|
|
@@ -1674,28 +1768,32 @@ function openStore(dataDir) {
|
|
|
1674
1768
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1675
1769
|
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1676
1770
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1677
|
-
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);
|
|
1771
|
+
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));
|
|
1678
1772
|
},
|
|
1679
1773
|
bumpAttempts(id) {
|
|
1680
|
-
db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
|
|
1774
|
+
retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
|
|
1681
1775
|
},
|
|
1682
1776
|
setRunContext(id, context) {
|
|
1683
|
-
db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
|
|
1777
|
+
retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
|
|
1684
1778
|
},
|
|
1685
1779
|
createRunNode(input) {
|
|
1686
1780
|
const id = randomUUID();
|
|
1687
|
-
db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1688
|
-
|
|
1781
|
+
retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1782
|
+
VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
|
|
1689
1783
|
return this.getRunNode(id);
|
|
1690
1784
|
},
|
|
1691
1785
|
getRunNode(id) {
|
|
1692
|
-
const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
|
|
1786
|
+
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id));
|
|
1693
1787
|
return row ? rowToNode(row) : undefined;
|
|
1694
1788
|
},
|
|
1695
1789
|
listRunNodes(runId) {
|
|
1696
|
-
const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
|
|
1790
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId));
|
|
1697
1791
|
return rows.map((r) => rowToNode(r));
|
|
1698
1792
|
},
|
|
1793
|
+
getLatestRunNode(runId, nodeId) {
|
|
1794
|
+
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? AND node_id = ? ORDER BY rowid DESC LIMIT 1`).get(runId, nodeId));
|
|
1795
|
+
return row ? rowToNode(row) : undefined;
|
|
1796
|
+
},
|
|
1699
1797
|
setRunNodeStatus(id, status, patch = {}) {
|
|
1700
1798
|
const now = nowIso2();
|
|
1701
1799
|
const existing = this.getRunNode(id);
|
|
@@ -1707,44 +1805,44 @@ function openStore(dataDir) {
|
|
|
1707
1805
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1708
1806
|
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1709
1807
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1710
|
-
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);
|
|
1808
|
+
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));
|
|
1711
1809
|
},
|
|
1712
1810
|
bumpAttemptsNode(id) {
|
|
1713
|
-
db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
|
|
1811
|
+
retryBusy(() => db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id));
|
|
1714
1812
|
},
|
|
1715
1813
|
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1716
|
-
db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1717
|
-
|
|
1718
|
-
|
|
1814
|
+
retryBusy(() => db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1815
|
+
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
1816
|
+
ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2()));
|
|
1719
1817
|
},
|
|
1720
1818
|
memoGet(key) {
|
|
1721
|
-
const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
|
|
1819
|
+
const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
|
|
1722
1820
|
return row ? rowToMemo(row) : undefined;
|
|
1723
1821
|
},
|
|
1724
1822
|
memoHit(key) {
|
|
1725
|
-
db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
|
|
1823
|
+
retryBusy(() => db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key));
|
|
1726
1824
|
},
|
|
1727
1825
|
memoList() {
|
|
1728
|
-
const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
|
|
1826
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all());
|
|
1729
1827
|
return rows.map((r) => rowToMemo(r));
|
|
1730
1828
|
},
|
|
1731
1829
|
memoClear() {
|
|
1732
|
-
db.exec(`DELETE FROM memos`);
|
|
1830
|
+
retryBusy(() => db.exec(`DELETE FROM memos`));
|
|
1733
1831
|
}
|
|
1734
1832
|
};
|
|
1735
1833
|
}
|
|
1736
1834
|
|
|
1737
1835
|
// src/wal.ts
|
|
1738
|
-
import { createHash as
|
|
1739
|
-
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1740
|
-
import { join as
|
|
1836
|
+
import { createHash as createHash2 } from "crypto";
|
|
1837
|
+
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1838
|
+
import { join as join3 } from "path";
|
|
1741
1839
|
var WAL_FILE_NAME = "session.wal";
|
|
1742
1840
|
var SESSIONS_DIR_NAME = "sessions";
|
|
1743
1841
|
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1744
1842
|
function checksumOf(op) {
|
|
1745
1843
|
if (op === undefined || op === null)
|
|
1746
1844
|
return "";
|
|
1747
|
-
return
|
|
1845
|
+
return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1748
1846
|
}
|
|
1749
1847
|
|
|
1750
1848
|
class SessionWAL {
|
|
@@ -1753,9 +1851,9 @@ class SessionWAL {
|
|
|
1753
1851
|
this.filePath = filePath;
|
|
1754
1852
|
}
|
|
1755
1853
|
static open(dataDir) {
|
|
1756
|
-
mkdirSync2(
|
|
1757
|
-
const canonical =
|
|
1758
|
-
const legacy =
|
|
1854
|
+
mkdirSync2(join3(dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
1855
|
+
const canonical = join3(dataDir, SESSIONS_DIR_NAME, WAL_FILE_NAME);
|
|
1856
|
+
const legacy = join3(dataDir, WAL_FILE_NAME);
|
|
1759
1857
|
if (existsSync(legacy) && !existsSync(canonical)) {
|
|
1760
1858
|
try {
|
|
1761
1859
|
renameSync(legacy, canonical);
|
|
@@ -1787,7 +1885,7 @@ class SessionWAL {
|
|
|
1787
1885
|
let repaired = false;
|
|
1788
1886
|
let content;
|
|
1789
1887
|
try {
|
|
1790
|
-
content =
|
|
1888
|
+
content = readFileSync2(this.filePath, "utf8");
|
|
1791
1889
|
} catch {
|
|
1792
1890
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1793
1891
|
}
|