@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/cli/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 {
|
|
@@ -443,16 +452,13 @@ var init_lanes = __esm(() => {
|
|
|
443
452
|
|
|
444
453
|
// src/cli/index.ts
|
|
445
454
|
import { Command } from "commander";
|
|
446
|
-
import { existsSync as
|
|
455
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
447
456
|
import { hostname } from "os";
|
|
448
|
-
import { join as
|
|
449
|
-
|
|
450
|
-
// src/service.ts
|
|
451
|
-
import { join as join3 } from "path";
|
|
457
|
+
import { join as join5 } from "path";
|
|
452
458
|
// package.json
|
|
453
459
|
var package_default = {
|
|
454
460
|
name: "@hasna/workflows",
|
|
455
|
-
version: "0.1.
|
|
461
|
+
version: "0.1.4",
|
|
456
462
|
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",
|
|
457
463
|
type: "module",
|
|
458
464
|
main: "./dist/index.js",
|
|
@@ -522,23 +528,21 @@ var package_default = {
|
|
|
522
528
|
author: "Hasna",
|
|
523
529
|
license: "Apache-2.0",
|
|
524
530
|
dependencies: {
|
|
525
|
-
"@anthropic-ai/claude-agent-sdk": "
|
|
531
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
526
532
|
"@cursor/sdk": "^1.0.28",
|
|
527
|
-
"@
|
|
533
|
+
"@hasna/paths": "0.1.0",
|
|
534
|
+
"@openai/codex-sdk": "0.147.0",
|
|
528
535
|
commander: "^13.1.0"
|
|
529
536
|
},
|
|
530
537
|
devDependencies: {
|
|
531
|
-
"@hasna/contracts": "0.14.
|
|
532
|
-
"@types/bun": "
|
|
538
|
+
"@hasna/contracts": "0.14.2",
|
|
539
|
+
"@types/bun": "1.3.14",
|
|
533
540
|
"@types/pg": "^8.15.6",
|
|
534
541
|
pg: "^8.16.3",
|
|
535
542
|
typescript: "^5.7.3"
|
|
536
543
|
}
|
|
537
544
|
};
|
|
538
545
|
|
|
539
|
-
// src/daemon.ts
|
|
540
|
-
import { createHash as createHash2 } from "crypto";
|
|
541
|
-
|
|
542
546
|
// src/expr.ts
|
|
543
547
|
class ExprSyntaxError extends Error {
|
|
544
548
|
constructor(message) {
|
|
@@ -912,6 +916,18 @@ function validateGraph(graph) {
|
|
|
912
916
|
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
913
917
|
issue(issues, "version", "graph requires a version string");
|
|
914
918
|
}
|
|
919
|
+
if (graph.memoWatch !== undefined) {
|
|
920
|
+
if (!Array.isArray(graph.memoWatch) || graph.memoWatch.length === 0) {
|
|
921
|
+
issue(issues, "memoWatch", "memoWatch must be a non-empty array of file paths or globs");
|
|
922
|
+
} else {
|
|
923
|
+
for (const entry of graph.memoWatch) {
|
|
924
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
925
|
+
issue(issues, "memoWatch", "each memoWatch entry must be a non-empty path or glob string");
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
915
931
|
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
916
932
|
issue(issues, "nodes", "graph requires at least one node");
|
|
917
933
|
return { ok: false, issues };
|
|
@@ -1146,6 +1162,8 @@ function assertNoSecrets(value, what) {
|
|
|
1146
1162
|
|
|
1147
1163
|
// src/session.ts
|
|
1148
1164
|
import { createHash } from "crypto";
|
|
1165
|
+
import { readFileSync, statSync } from "fs";
|
|
1166
|
+
import { isAbsolute, join } from "path";
|
|
1149
1167
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1150
1168
|
function repairTornRuns(store, wal, opts = {}) {
|
|
1151
1169
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -1201,6 +1219,46 @@ function recordMemo(store, graphName, nodeId, input, output) {
|
|
|
1201
1219
|
const key = memoKey(graphName, nodeId, input);
|
|
1202
1220
|
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1203
1221
|
}
|
|
1222
|
+
function memoWatchFingerprint(graph, dataDir) {
|
|
1223
|
+
if (!graph.memoWatch || graph.memoWatch.length === 0)
|
|
1224
|
+
return;
|
|
1225
|
+
const entries = [];
|
|
1226
|
+
for (const pattern of graph.memoWatch) {
|
|
1227
|
+
if (hasGlobMagic(pattern)) {
|
|
1228
|
+
const matches = [...new Bun.Glob(pattern).scanSync({ cwd: dataDir, onlyFiles: true })].sort();
|
|
1229
|
+
for (const match of matches) {
|
|
1230
|
+
const path2 = isAbsolute(match) ? match : join(dataDir, match);
|
|
1231
|
+
entries.push(fingerprintFile(pattern, path2));
|
|
1232
|
+
}
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
const path = isAbsolute(pattern) ? pattern : join(dataDir, pattern);
|
|
1236
|
+
entries.push(fingerprintFile(pattern, path));
|
|
1237
|
+
}
|
|
1238
|
+
return entries;
|
|
1239
|
+
}
|
|
1240
|
+
function hasGlobMagic(pattern) {
|
|
1241
|
+
return /[*?[\]{}]/.test(pattern);
|
|
1242
|
+
}
|
|
1243
|
+
function fingerprintFile(pattern, path) {
|
|
1244
|
+
try {
|
|
1245
|
+
const stat = statSync(path);
|
|
1246
|
+
if (!stat.isFile()) {
|
|
1247
|
+
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1248
|
+
}
|
|
1249
|
+
const content = readFileSync(path);
|
|
1250
|
+
return {
|
|
1251
|
+
pattern,
|
|
1252
|
+
path,
|
|
1253
|
+
missing: false,
|
|
1254
|
+
mtimeMs: stat.mtimeMs,
|
|
1255
|
+
size: content.byteLength,
|
|
1256
|
+
sha256: createHash("sha256").update(content).digest("hex")
|
|
1257
|
+
};
|
|
1258
|
+
} catch {
|
|
1259
|
+
return { pattern, path, missing: true, mtimeMs: null, size: null, sha256: null };
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1204
1262
|
function resetRunNodes(store, run) {
|
|
1205
1263
|
for (const node of store.listRunNodes(run.id)) {
|
|
1206
1264
|
if (node.status === "running") {
|
|
@@ -1503,10 +1561,13 @@ class WorkflowsDaemon {
|
|
|
1503
1561
|
}
|
|
1504
1562
|
async executeStep(run, graph, context, wf, node) {
|
|
1505
1563
|
const nodeId = node.id;
|
|
1506
|
-
const
|
|
1507
|
-
const
|
|
1508
|
-
|
|
1509
|
-
|
|
1564
|
+
const latest = this.store.getLatestRunNode(run.id, nodeId);
|
|
1565
|
+
const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
|
|
1566
|
+
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? this.store.createRunNode({ runId: run.id, nodeId, lane: node.lane ?? "claude" }) : latest;
|
|
1567
|
+
const memoWatch = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1568
|
+
const memoSource = { command: node.command, prompt: node.prompt, context: stripRunState(context) };
|
|
1569
|
+
const memoInput = node.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
|
|
1570
|
+
if (memoInput !== undefined) {
|
|
1510
1571
|
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1511
1572
|
if (hit) {
|
|
1512
1573
|
const cached = JSON.parse(hit.outputJson);
|
|
@@ -1535,9 +1596,9 @@ class WorkflowsDaemon {
|
|
|
1535
1596
|
}
|
|
1536
1597
|
const output = { ok: result.ok, exitCode: result.exitCode, output: result.output, durationMs: result.durationMs };
|
|
1537
1598
|
if (!result.ok) {
|
|
1538
|
-
const
|
|
1539
|
-
|
|
1540
|
-
|
|
1599
|
+
const shouldRetry = nodeRow.attempts < (node.maxRetries ?? 0);
|
|
1600
|
+
this.store.bumpAttemptsNode(nodeRow.id);
|
|
1601
|
+
if (shouldRetry) {
|
|
1541
1602
|
this.store.setRunNodeStatus(nodeRow.id, "pending", { error: result.error ?? `exit ${result.exitCode}` });
|
|
1542
1603
|
return { kind: "advanced", nodeId };
|
|
1543
1604
|
}
|
|
@@ -1558,9 +1619,13 @@ class WorkflowsDaemon {
|
|
|
1558
1619
|
}
|
|
1559
1620
|
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1560
1621
|
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1561
|
-
if (
|
|
1562
|
-
|
|
1563
|
-
|
|
1622
|
+
if (memoInput !== undefined) {
|
|
1623
|
+
const memoInputAfter = node.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
1624
|
+
const stable = memoWatch === undefined && memoInputAfter === undefined || memoWatch !== undefined && memoInputAfter !== undefined && JSON.stringify(memoWatch) === JSON.stringify(memoInputAfter);
|
|
1625
|
+
if (stable) {
|
|
1626
|
+
recordMemo(this.store, run.graphName, nodeId, memoInput, output);
|
|
1627
|
+
this.wal.append({ op: "memo_set", key: memoKey(run.graphName, nodeId, memoInput), at: nowIso() });
|
|
1628
|
+
}
|
|
1564
1629
|
}
|
|
1565
1630
|
this.recordStepResult(context, nodeId, output);
|
|
1566
1631
|
return this.afterNode(run, context, wf, node.next);
|
|
@@ -1655,6 +1720,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1655
1720
|
isolatedRunId: options.isolatedRunId
|
|
1656
1721
|
});
|
|
1657
1722
|
const run = daemon.startRun(graph, context);
|
|
1723
|
+
options.onRunCreated?.(run);
|
|
1658
1724
|
daemon.isolateRun(run.id);
|
|
1659
1725
|
const maxCycles = options.maxCycles ?? 500;
|
|
1660
1726
|
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
@@ -1669,20 +1735,71 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1669
1735
|
return store.getRun(run.id);
|
|
1670
1736
|
}
|
|
1671
1737
|
|
|
1738
|
+
// src/paths.ts
|
|
1739
|
+
import { existsSync } from "fs";
|
|
1740
|
+
import { homedir } from "os";
|
|
1741
|
+
import { join as join2 } from "path";
|
|
1742
|
+
import { dataDir } from "@hasna/paths";
|
|
1743
|
+
var APP = "workflows";
|
|
1744
|
+
var STORE_DB_FILE = "workflows.db";
|
|
1745
|
+
function getHomeDir(env = process.env) {
|
|
1746
|
+
return env["HOME"] || env["USERPROFILE"] || homedir();
|
|
1747
|
+
}
|
|
1748
|
+
function getLegacyDataDir(env = process.env) {
|
|
1749
|
+
return join2(getHomeDir(env), ".hasna", APP);
|
|
1750
|
+
}
|
|
1751
|
+
function getResolverDataDir(env = process.env) {
|
|
1752
|
+
return dataDir({ app: APP, home: getHomeDir(env), env });
|
|
1753
|
+
}
|
|
1754
|
+
function adoptResolverDataDir(resolved, env = process.env) {
|
|
1755
|
+
const dataOverride = env.HASNA_DATA_HOME;
|
|
1756
|
+
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
1757
|
+
return true;
|
|
1758
|
+
return existsSync(join2(resolved, STORE_DB_FILE));
|
|
1759
|
+
}
|
|
1760
|
+
function getEffectiveDataDir(env = process.env) {
|
|
1761
|
+
const resolved = getResolverDataDir(env);
|
|
1762
|
+
return adoptResolverDataDir(resolved, env) ? resolved : getLegacyDataDir(env);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1672
1765
|
// src/store.ts
|
|
1673
1766
|
import { Database } from "bun:sqlite";
|
|
1674
1767
|
import { mkdirSync } from "fs";
|
|
1675
|
-
import { join } from "path";
|
|
1768
|
+
import { join as join3 } from "path";
|
|
1676
1769
|
import { randomUUID } from "crypto";
|
|
1677
1770
|
function nowIso2() {
|
|
1678
1771
|
return new Date().toISOString();
|
|
1679
1772
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1773
|
+
var BUSY_TIMEOUT_MS = 3000;
|
|
1774
|
+
var BUSY_RETRIES = 2;
|
|
1775
|
+
var BUSY_RETRY_SLEEP_MS = 50;
|
|
1776
|
+
function isBusyError(err) {
|
|
1777
|
+
if (!(err instanceof Error))
|
|
1778
|
+
return false;
|
|
1779
|
+
const code = String(err.code ?? "");
|
|
1780
|
+
return code.includes("BUSY") || /\bdatabase is locked\b/i.test(err.message);
|
|
1781
|
+
}
|
|
1782
|
+
function retryBusy(fn) {
|
|
1783
|
+
let last;
|
|
1784
|
+
for (let attempt = 0;attempt <= BUSY_RETRIES; attempt++) {
|
|
1785
|
+
try {
|
|
1786
|
+
return fn();
|
|
1787
|
+
} catch (err) {
|
|
1788
|
+
if (!isBusyError(err))
|
|
1789
|
+
throw err;
|
|
1790
|
+
last = err;
|
|
1791
|
+
Bun.sleepSync(BUSY_RETRY_SLEEP_MS);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
throw last;
|
|
1795
|
+
}
|
|
1796
|
+
function openStore(dataDir2) {
|
|
1797
|
+
mkdirSync(dataDir2, { recursive: true });
|
|
1798
|
+
const db = new Database(join3(dataDir2, "workflows.db"));
|
|
1799
|
+
retryBusy(() => db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`));
|
|
1800
|
+
retryBusy(() => db.exec("PRAGMA journal_mode = WAL"));
|
|
1801
|
+
retryBusy(() => db.exec("PRAGMA foreign_keys = ON"));
|
|
1802
|
+
retryBusy(() => db.exec(`
|
|
1686
1803
|
CREATE TABLE IF NOT EXISTS runs (
|
|
1687
1804
|
id TEXT PRIMARY KEY,
|
|
1688
1805
|
graph_name TEXT NOT NULL,
|
|
@@ -1722,7 +1839,7 @@ function openStore(dataDir) {
|
|
|
1722
1839
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1723
1840
|
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1724
1841
|
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1725
|
-
`);
|
|
1842
|
+
`));
|
|
1726
1843
|
const rowToRun = (r) => ({
|
|
1727
1844
|
id: String(r.id),
|
|
1728
1845
|
graphName: String(r.graph_name),
|
|
@@ -1760,31 +1877,33 @@ function openStore(dataDir) {
|
|
|
1760
1877
|
createdAt: String(r.created_at)
|
|
1761
1878
|
});
|
|
1762
1879
|
return {
|
|
1880
|
+
dataDir: dataDir2,
|
|
1763
1881
|
close() {
|
|
1764
1882
|
db.close();
|
|
1765
1883
|
},
|
|
1766
1884
|
listTables() {
|
|
1767
|
-
const rows = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all();
|
|
1885
|
+
const rows = retryBusy(() => db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all());
|
|
1768
1886
|
return rows.map((r) => r.name);
|
|
1769
1887
|
},
|
|
1770
1888
|
createRun(input) {
|
|
1771
1889
|
const id = randomUUID();
|
|
1772
1890
|
const now = nowIso2();
|
|
1773
|
-
db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1774
|
-
|
|
1891
|
+
retryBusy(() => db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1892
|
+
VALUES (?, ?, ?, 'pending', 0, ?, ?, ?)`).run(id, input.graphName, input.graphVersion, JSON.stringify(input.context ?? {}), now, now));
|
|
1775
1893
|
return this.getRun(id);
|
|
1776
1894
|
},
|
|
1777
1895
|
getRun(id) {
|
|
1778
|
-
const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
1896
|
+
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1779
1897
|
return row ? rowToRun(row) : undefined;
|
|
1780
1898
|
},
|
|
1781
1899
|
listRuns(opts = {}) {
|
|
1782
1900
|
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1783
1901
|
if (opts.status) {
|
|
1784
|
-
const
|
|
1902
|
+
const status = opts.status;
|
|
1903
|
+
const rows2 = retryBusy(() => db.query(`SELECT * FROM runs WHERE status = ? ORDER BY created_at DESC, id DESC LIMIT ?`).all(status, limit));
|
|
1785
1904
|
return rows2.map((r) => rowToRun(r));
|
|
1786
1905
|
}
|
|
1787
|
-
const rows = db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit);
|
|
1906
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM runs ORDER BY created_at DESC, id DESC LIMIT ?`).all(limit));
|
|
1788
1907
|
return rows.map((r) => rowToRun(r));
|
|
1789
1908
|
},
|
|
1790
1909
|
setRunStatus(id, status, patch = {}) {
|
|
@@ -1795,28 +1914,32 @@ function openStore(dataDir) {
|
|
|
1795
1914
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1796
1915
|
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1797
1916
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1798
|
-
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);
|
|
1917
|
+
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));
|
|
1799
1918
|
},
|
|
1800
1919
|
bumpAttempts(id) {
|
|
1801
|
-
db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id);
|
|
1920
|
+
retryBusy(() => db.query(`UPDATE runs SET attempts = attempts + 1, updated_at = ? WHERE id = ?`).run(nowIso2(), id));
|
|
1802
1921
|
},
|
|
1803
1922
|
setRunContext(id, context) {
|
|
1804
|
-
db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id);
|
|
1923
|
+
retryBusy(() => db.query(`UPDATE runs SET context_json = ?, updated_at = ? WHERE id = ?`).run(JSON.stringify(context), nowIso2(), id));
|
|
1805
1924
|
},
|
|
1806
1925
|
createRunNode(input) {
|
|
1807
1926
|
const id = randomUUID();
|
|
1808
|
-
db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1809
|
-
|
|
1927
|
+
retryBusy(() => db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1928
|
+
VALUES (?, ?, ?, 'pending', 0, ?)`).run(id, input.runId, input.nodeId, input.lane ?? null));
|
|
1810
1929
|
return this.getRunNode(id);
|
|
1811
1930
|
},
|
|
1812
1931
|
getRunNode(id) {
|
|
1813
|
-
const row = db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id);
|
|
1932
|
+
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE id = ?`).get(id));
|
|
1814
1933
|
return row ? rowToNode(row) : undefined;
|
|
1815
1934
|
},
|
|
1816
1935
|
listRunNodes(runId) {
|
|
1817
|
-
const rows = db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId);
|
|
1936
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? ORDER BY rowid ASC`).all(runId));
|
|
1818
1937
|
return rows.map((r) => rowToNode(r));
|
|
1819
1938
|
},
|
|
1939
|
+
getLatestRunNode(runId, nodeId) {
|
|
1940
|
+
const row = retryBusy(() => db.query(`SELECT * FROM run_nodes WHERE run_id = ? AND node_id = ? ORDER BY rowid DESC LIMIT 1`).get(runId, nodeId));
|
|
1941
|
+
return row ? rowToNode(row) : undefined;
|
|
1942
|
+
},
|
|
1820
1943
|
setRunNodeStatus(id, status, patch = {}) {
|
|
1821
1944
|
const now = nowIso2();
|
|
1822
1945
|
const existing = this.getRunNode(id);
|
|
@@ -1828,44 +1951,44 @@ function openStore(dataDir) {
|
|
|
1828
1951
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1829
1952
|
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1830
1953
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1831
|
-
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);
|
|
1954
|
+
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));
|
|
1832
1955
|
},
|
|
1833
1956
|
bumpAttemptsNode(id) {
|
|
1834
|
-
db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id);
|
|
1957
|
+
retryBusy(() => db.query(`UPDATE run_nodes SET attempts = attempts + 1 WHERE id = ?`).run(id));
|
|
1835
1958
|
},
|
|
1836
1959
|
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1837
|
-
db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1838
|
-
|
|
1839
|
-
|
|
1960
|
+
retryBusy(() => db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1961
|
+
VALUES (?, ?, ?, ?, ?, 0, ?)
|
|
1962
|
+
ON CONFLICT(key) DO UPDATE SET output_json = excluded.output_json`).run(key, graphName, nodeId, inputHash2, outputJson, nowIso2()));
|
|
1840
1963
|
},
|
|
1841
1964
|
memoGet(key) {
|
|
1842
|
-
const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
|
|
1965
|
+
const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
|
|
1843
1966
|
return row ? rowToMemo(row) : undefined;
|
|
1844
1967
|
},
|
|
1845
1968
|
memoHit(key) {
|
|
1846
|
-
db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key);
|
|
1969
|
+
retryBusy(() => db.query(`UPDATE memos SET hit_count = hit_count + 1 WHERE key = ?`).run(key));
|
|
1847
1970
|
},
|
|
1848
1971
|
memoList() {
|
|
1849
|
-
const rows = db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all();
|
|
1972
|
+
const rows = retryBusy(() => db.query(`SELECT * FROM memos ORDER BY created_at DESC LIMIT 1000`).all());
|
|
1850
1973
|
return rows.map((r) => rowToMemo(r));
|
|
1851
1974
|
},
|
|
1852
1975
|
memoClear() {
|
|
1853
|
-
db.exec(`DELETE FROM memos`);
|
|
1976
|
+
retryBusy(() => db.exec(`DELETE FROM memos`));
|
|
1854
1977
|
}
|
|
1855
1978
|
};
|
|
1856
1979
|
}
|
|
1857
1980
|
|
|
1858
1981
|
// src/wal.ts
|
|
1859
|
-
import { createHash as
|
|
1860
|
-
import { appendFileSync, existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1861
|
-
import { join as
|
|
1982
|
+
import { createHash as createHash2 } from "crypto";
|
|
1983
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1984
|
+
import { join as join4 } from "path";
|
|
1862
1985
|
var WAL_FILE_NAME = "session.wal";
|
|
1863
1986
|
var SESSIONS_DIR_NAME = "sessions";
|
|
1864
1987
|
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1865
1988
|
function checksumOf(op) {
|
|
1866
1989
|
if (op === undefined || op === null)
|
|
1867
1990
|
return "";
|
|
1868
|
-
return
|
|
1991
|
+
return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1869
1992
|
}
|
|
1870
1993
|
|
|
1871
1994
|
class SessionWAL {
|
|
@@ -1873,11 +1996,11 @@ class SessionWAL {
|
|
|
1873
1996
|
constructor(filePath) {
|
|
1874
1997
|
this.filePath = filePath;
|
|
1875
1998
|
}
|
|
1876
|
-
static open(
|
|
1877
|
-
mkdirSync2(
|
|
1878
|
-
const canonical =
|
|
1879
|
-
const legacy =
|
|
1880
|
-
if (
|
|
1999
|
+
static open(dataDir2) {
|
|
2000
|
+
mkdirSync2(join4(dataDir2, SESSIONS_DIR_NAME), { recursive: true });
|
|
2001
|
+
const canonical = join4(dataDir2, SESSIONS_DIR_NAME, WAL_FILE_NAME);
|
|
2002
|
+
const legacy = join4(dataDir2, WAL_FILE_NAME);
|
|
2003
|
+
if (existsSync2(legacy) && !existsSync2(canonical)) {
|
|
1881
2004
|
try {
|
|
1882
2005
|
renameSync(legacy, canonical);
|
|
1883
2006
|
} catch {
|
|
@@ -1908,7 +2031,7 @@ class SessionWAL {
|
|
|
1908
2031
|
let repaired = false;
|
|
1909
2032
|
let content;
|
|
1910
2033
|
try {
|
|
1911
|
-
content =
|
|
2034
|
+
content = readFileSync2(this.filePath, "utf8");
|
|
1912
2035
|
} catch {
|
|
1913
2036
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1914
2037
|
}
|
|
@@ -2008,11 +2131,10 @@ function envInt(names, fallback) {
|
|
|
2008
2131
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
2009
2132
|
}
|
|
2010
2133
|
function resolveWorkflowsConfig(overrides = {}) {
|
|
2011
|
-
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
2012
2134
|
return {
|
|
2013
2135
|
port: overrides.port ?? envInt(["HASNA_WORKFLOWS_PORT", "WORKFLOWS_PORT"], DEFAULT_PORT),
|
|
2014
2136
|
host: overrides.host ?? envString(["HASNA_WORKFLOWS_HOST", "WORKFLOWS_HOST"]) ?? DEFAULT_HOST,
|
|
2015
|
-
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ??
|
|
2137
|
+
dataDir: overrides.dataDir ?? envString(["HASNA_WORKFLOWS_DATA_DIR", "WORKFLOWS_DATA_DIR"]) ?? getEffectiveDataDir(),
|
|
2016
2138
|
apiUrl: overrides.apiUrl ?? envString(["WORKFLOWS_API_URL"]),
|
|
2017
2139
|
apiKey: overrides.apiKey ?? envString(["WORKFLOWS_API_KEY"])
|
|
2018
2140
|
};
|
|
@@ -2172,7 +2294,7 @@ init_lanes();
|
|
|
2172
2294
|
init_types();
|
|
2173
2295
|
|
|
2174
2296
|
// src/handlers.ts
|
|
2175
|
-
import { createHash as
|
|
2297
|
+
import { createHash as createHash3, timingSafeEqual } from "crypto";
|
|
2176
2298
|
function jsonResponse(body, status = 200) {
|
|
2177
2299
|
return new Response(JSON.stringify(body), {
|
|
2178
2300
|
status,
|
|
@@ -2228,8 +2350,8 @@ function bearerMatches(authHeader, expected) {
|
|
|
2228
2350
|
const supplied = authHeader.slice("Bearer ".length);
|
|
2229
2351
|
if (supplied.length === 0)
|
|
2230
2352
|
return false;
|
|
2231
|
-
const a =
|
|
2232
|
-
const b =
|
|
2353
|
+
const a = createHash3("sha256").update(supplied).digest();
|
|
2354
|
+
const b = createHash3("sha256").update(expected).digest();
|
|
2233
2355
|
return timingSafeEqual(a, b);
|
|
2234
2356
|
}
|
|
2235
2357
|
async function handleTrigger(service, req) {
|
|
@@ -2330,7 +2452,7 @@ program.command("info").description("Show service configuration (never credentia
|
|
|
2330
2452
|
function loadGraph(file) {
|
|
2331
2453
|
let raw;
|
|
2332
2454
|
try {
|
|
2333
|
-
raw =
|
|
2455
|
+
raw = readFileSync3(file, "utf8");
|
|
2334
2456
|
} catch {
|
|
2335
2457
|
throw new Error(`cannot read graph file ${file}`);
|
|
2336
2458
|
}
|
|
@@ -2398,12 +2520,12 @@ function runSummary(row) {
|
|
|
2398
2520
|
}
|
|
2399
2521
|
program.command("init").description("Create the store layout (workflows/, sessions/, workflows.db) and scaffold a sample graph").argument("[file]", "sample graph output path (default <dataDir>/workflows/demo.json)").action((file) => {
|
|
2400
2522
|
const config = resolveWorkflowsConfig();
|
|
2401
|
-
mkdirSync3(
|
|
2402
|
-
mkdirSync3(
|
|
2523
|
+
mkdirSync3(join5(config.dataDir, "workflows"), { recursive: true });
|
|
2524
|
+
mkdirSync3(join5(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
2403
2525
|
const store = openStore(config.dataDir);
|
|
2404
2526
|
store.close();
|
|
2405
|
-
const target = file ??
|
|
2406
|
-
if (
|
|
2527
|
+
const target = file ?? join5(config.dataDir, "workflows", "demo.json");
|
|
2528
|
+
if (existsSync3(target)) {
|
|
2407
2529
|
console.error(`refusing to overwrite existing file ${target}`);
|
|
2408
2530
|
process.exit(1);
|
|
2409
2531
|
}
|
|
@@ -2461,14 +2583,18 @@ program.command("graph").description("Render a workflow graph (text, DOT, or JSO
|
|
|
2461
2583
|
}
|
|
2462
2584
|
});
|
|
2463
2585
|
program.command("run").description("Run a workflow graph to a terminal state (bounded)").argument("<file>", "graph JSON file").option("--context <json>", "JSON context object for the run").option("--input <k=v>", "run input key=value (repeatable; merged over --context)", collectInput, []).option("--idempotency-key <key>", "re-run with the same key returns the existing run").option("--max-cycles <n>", "bound on reap cycles", "500").option("-j, --json", "JSON output").action(async (file, opts) => {
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
if (!validation.ok) {
|
|
2467
|
-
throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
2468
|
-
}
|
|
2469
|
-
const context = mergeRunContext(opts.context, opts.input ?? []);
|
|
2470
|
-
const { store, wal } = openLocal();
|
|
2586
|
+
let store;
|
|
2587
|
+
let createdRunId;
|
|
2471
2588
|
try {
|
|
2589
|
+
const graph = loadGraph(file);
|
|
2590
|
+
const validation = validateGraph(graph);
|
|
2591
|
+
if (!validation.ok) {
|
|
2592
|
+
throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
2593
|
+
}
|
|
2594
|
+
const context = mergeRunContext(opts.context, opts.input ?? []);
|
|
2595
|
+
const local = openLocal();
|
|
2596
|
+
store = local.store;
|
|
2597
|
+
const wal = local.wal;
|
|
2472
2598
|
if (opts.idempotencyKey) {
|
|
2473
2599
|
const existing = findRunByIdempotencyKey(store, opts.idempotencyKey);
|
|
2474
2600
|
if (existing) {
|
|
@@ -2479,13 +2605,18 @@ program.command("run").description("Run a workflow graph to a terminal state (bo
|
|
|
2479
2605
|
console.log(`reused run ${existing.id}: ${existing.status}`);
|
|
2480
2606
|
}
|
|
2481
2607
|
if (existing.status !== "completed")
|
|
2482
|
-
process.
|
|
2608
|
+
process.exitCode = 1;
|
|
2483
2609
|
return;
|
|
2484
2610
|
}
|
|
2485
2611
|
}
|
|
2486
2612
|
const contextWithKey = opts.idempotencyKey ? withIdempotencyKey(context, opts.idempotencyKey) : context;
|
|
2487
2613
|
const maxCycles = Number(opts.maxCycles ?? 500);
|
|
2488
|
-
const final = await runGraphToCompletion(store, wal, graph, contextWithKey, {
|
|
2614
|
+
const final = await runGraphToCompletion(store, wal, graph, contextWithKey, {
|
|
2615
|
+
maxCycles,
|
|
2616
|
+
onRunCreated: (run) => {
|
|
2617
|
+
createdRunId = run.id;
|
|
2618
|
+
}
|
|
2619
|
+
});
|
|
2489
2620
|
const summary = runSummary(final);
|
|
2490
2621
|
if (opts.json) {
|
|
2491
2622
|
console.log(JSON.stringify(summary, null, 2));
|
|
@@ -2495,9 +2626,17 @@ program.command("run").description("Run a workflow graph to a terminal state (bo
|
|
|
2495
2626
|
console.log(final.resultJson);
|
|
2496
2627
|
}
|
|
2497
2628
|
if (final.status !== "completed")
|
|
2498
|
-
process.
|
|
2629
|
+
process.exitCode = 1;
|
|
2630
|
+
} catch (err) {
|
|
2631
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
2632
|
+
if (opts.json) {
|
|
2633
|
+
console.log(JSON.stringify({ error, ...createdRunId ? { runId: createdRunId } : {} }, null, 2));
|
|
2634
|
+
} else {
|
|
2635
|
+
console.error(error);
|
|
2636
|
+
}
|
|
2637
|
+
process.exitCode = 1;
|
|
2499
2638
|
} finally {
|
|
2500
|
-
store
|
|
2639
|
+
store?.close();
|
|
2501
2640
|
}
|
|
2502
2641
|
});
|
|
2503
2642
|
program.command("runs").description("Manage runs").argument("<action>", "list | show | cancel | resume | events").argument("[id]", "run id (show/cancel/resume/events)").option("--status <status>", "filter by status (list)").option("--limit <n>", "max rows (list)", "100").option("-j, --json", "JSON output").action((action, id, opts) => {
|
|
@@ -2591,7 +2730,7 @@ program.command("nodes").description("List or show a run's node executions").arg
|
|
|
2591
2730
|
if (action === "show") {
|
|
2592
2731
|
if (!node)
|
|
2593
2732
|
throw new Error("nodes show requires a run id and a node id");
|
|
2594
|
-
const row = store.
|
|
2733
|
+
const row = store.getLatestRunNode(run, node);
|
|
2595
2734
|
if (!row)
|
|
2596
2735
|
throw new Error(`run ${run} has no node ${node}`);
|
|
2597
2736
|
console.log(JSON.stringify(row, null, 2));
|
|
@@ -2689,17 +2828,17 @@ program.command("machines").description("List observed machines or report this m
|
|
|
2689
2828
|
}
|
|
2690
2829
|
case "status": {
|
|
2691
2830
|
const replay = wal.replay();
|
|
2692
|
-
const pidFile =
|
|
2693
|
-
const pid =
|
|
2831
|
+
const pidFile = join5(config.dataDir, "daemon.pid");
|
|
2832
|
+
const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
|
|
2694
2833
|
const running = pid !== null && processAlive(Number(pid));
|
|
2695
2834
|
const report = {
|
|
2696
2835
|
hostname: hostname(),
|
|
2697
2836
|
pid: process.pid,
|
|
2698
2837
|
dataDir: config.dataDir,
|
|
2699
2838
|
layout: {
|
|
2700
|
-
workflows:
|
|
2701
|
-
sessions:
|
|
2702
|
-
db:
|
|
2839
|
+
workflows: existsSync3(join5(config.dataDir, "workflows")),
|
|
2840
|
+
sessions: existsSync3(join5(config.dataDir, SESSIONS_DIR_NAME)),
|
|
2841
|
+
db: existsSync3(join5(config.dataDir, "workflows.db"))
|
|
2703
2842
|
},
|
|
2704
2843
|
store: {
|
|
2705
2844
|
tables: store.listTables(),
|
|
@@ -2763,8 +2902,8 @@ program.command("lanes").description("List the four lane adapters (wired vs not-
|
|
|
2763
2902
|
});
|
|
2764
2903
|
program.command("daemon").description("Run or inspect the daemon").argument("<action>", "start | status | stop").option("--once", "run a single reap cycle and exit (start)").option("--interval-ms <n>", "reap interval (start)", "5000").option("--cycles <n>", "bound on consecutive cycles (start; default infinite until SIGINT)", "0").option("-j, --json", "JSON output").action(async (action, opts) => {
|
|
2765
2904
|
const config = resolveWorkflowsConfig();
|
|
2766
|
-
const pidFile =
|
|
2767
|
-
const statusFile =
|
|
2905
|
+
const pidFile = join5(config.dataDir, "daemon.pid");
|
|
2906
|
+
const statusFile = join5(config.dataDir, "daemon.status.json");
|
|
2768
2907
|
switch (action) {
|
|
2769
2908
|
case "start": {
|
|
2770
2909
|
const { store, wal } = openLocal();
|
|
@@ -2812,30 +2951,30 @@ program.command("daemon").description("Run or inspect the daemon").argument("<ac
|
|
|
2812
2951
|
}
|
|
2813
2952
|
}
|
|
2814
2953
|
case "status": {
|
|
2815
|
-
if (!
|
|
2954
|
+
if (!existsSync3(statusFile)) {
|
|
2816
2955
|
if (opts.json) {
|
|
2817
|
-
console.log(JSON.stringify({ running: false, lastReap: null }));
|
|
2956
|
+
console.log(JSON.stringify({ statusVersion: 1, running: false, pid: null, lastReap: null, cumulative: emptyReapReport() }));
|
|
2818
2957
|
} else {
|
|
2819
2958
|
console.log("daemon not running (no status record)");
|
|
2820
2959
|
}
|
|
2821
2960
|
return;
|
|
2822
2961
|
}
|
|
2823
|
-
const
|
|
2824
|
-
const pid =
|
|
2962
|
+
const envelope = readReport(statusFile);
|
|
2963
|
+
const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
|
|
2825
2964
|
const running = pid !== null && processAlive(Number(pid));
|
|
2826
2965
|
if (opts.json) {
|
|
2827
|
-
console.log(JSON.stringify({ running, pid, lastReap }, null, 2));
|
|
2966
|
+
console.log(JSON.stringify({ statusVersion: 1, running, pid, lastReap: envelope.latestCycle, cumulative: envelope.cumulative }, null, 2));
|
|
2828
2967
|
} else {
|
|
2829
|
-
console.log(`daemon ${running ? `running (pid ${pid})` : "not running"} \u2014
|
|
2968
|
+
console.log(`daemon ${running ? `running (pid ${pid})` : "not running"} \u2014 latest reap: ${JSON.stringify(envelope.latestCycle)} cumulative: ${JSON.stringify(envelope.cumulative)}`);
|
|
2830
2969
|
}
|
|
2831
2970
|
return;
|
|
2832
2971
|
}
|
|
2833
2972
|
case "stop": {
|
|
2834
|
-
if (!
|
|
2973
|
+
if (!existsSync3(pidFile)) {
|
|
2835
2974
|
console.error("no daemon pid file \u2014 nothing to stop");
|
|
2836
2975
|
process.exit(1);
|
|
2837
2976
|
}
|
|
2838
|
-
const pid = Number(
|
|
2977
|
+
const pid = Number(readFileSync3(pidFile, "utf8").trim());
|
|
2839
2978
|
try {
|
|
2840
2979
|
process.kill(pid, "SIGTERM");
|
|
2841
2980
|
} catch {
|
|
@@ -2857,9 +2996,60 @@ function processAlive(pid) {
|
|
|
2857
2996
|
return false;
|
|
2858
2997
|
}
|
|
2859
2998
|
}
|
|
2999
|
+
var REAP_REPORT_KEYS = ["expired", "interrupted", "requeued", "failed", "dispatched", "advanced", "completed"];
|
|
3000
|
+
function emptyReapReport() {
|
|
3001
|
+
return { expired: 0, interrupted: 0, requeued: 0, failed: 0, dispatched: 0, advanced: 0, completed: 0 };
|
|
3002
|
+
}
|
|
3003
|
+
function normalizeReapReport(value) {
|
|
3004
|
+
const source = value !== null && typeof value === "object" ? value : {};
|
|
3005
|
+
const report = emptyReapReport();
|
|
3006
|
+
for (const key of REAP_REPORT_KEYS) {
|
|
3007
|
+
const count = source[key];
|
|
3008
|
+
report[key] = typeof count === "number" && Number.isFinite(count) ? count : 0;
|
|
3009
|
+
}
|
|
3010
|
+
return report;
|
|
3011
|
+
}
|
|
3012
|
+
function addReapReports(left, right) {
|
|
3013
|
+
const total = emptyReapReport();
|
|
3014
|
+
for (const key of REAP_REPORT_KEYS)
|
|
3015
|
+
total[key] = left[key] + right[key];
|
|
3016
|
+
return total;
|
|
3017
|
+
}
|
|
2860
3018
|
function writeReport(statusFile, report) {
|
|
2861
|
-
mkdirSync3(
|
|
2862
|
-
|
|
3019
|
+
mkdirSync3(join5(statusFile, ".."), { recursive: true });
|
|
3020
|
+
let previous = emptyReapReport();
|
|
3021
|
+
if (existsSync3(statusFile)) {
|
|
3022
|
+
previous = readReport(statusFile).cumulative;
|
|
3023
|
+
}
|
|
3024
|
+
const tempFile = `${statusFile}.tmp-${process.pid}`;
|
|
3025
|
+
const envelope = {
|
|
3026
|
+
version: 1,
|
|
3027
|
+
updatedAt: new Date().toISOString(),
|
|
3028
|
+
latestCycle: report,
|
|
3029
|
+
cumulative: addReapReports(previous, report)
|
|
3030
|
+
};
|
|
3031
|
+
try {
|
|
3032
|
+
writeFileSync2(tempFile, JSON.stringify(envelope), "utf8");
|
|
3033
|
+
renameSync2(tempFile, statusFile);
|
|
3034
|
+
} finally {
|
|
3035
|
+
rmSync(tempFile, { force: true });
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
function readReport(statusFile) {
|
|
3039
|
+
const parsed = JSON.parse(readFileSync3(statusFile, "utf8"));
|
|
3040
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
3041
|
+
const record = parsed;
|
|
3042
|
+
if (record.version === 1 && record.latestCycle !== undefined && record.cumulative !== undefined) {
|
|
3043
|
+
return {
|
|
3044
|
+
version: 1,
|
|
3045
|
+
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : new Date().toISOString(),
|
|
3046
|
+
latestCycle: normalizeReapReport(record.latestCycle),
|
|
3047
|
+
cumulative: normalizeReapReport(record.cumulative)
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
const legacy = normalizeReapReport(parsed);
|
|
3052
|
+
return { version: 1, updatedAt: new Date().toISOString(), latestCycle: legacy, cumulative: legacy };
|
|
2863
3053
|
}
|
|
2864
3054
|
program.command("memos").description("Inspect or clear the memoization cache").argument("<action>", "list | clear").option("--yes", "confirm clear").option("-j, --json", "JSON output").action((action, opts) => {
|
|
2865
3055
|
const { store } = openLocal();
|