@hasna/workflows 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-tools.js +149 -51
- package/dist/cli/index.js +259 -93
- package/dist/index.js +158 -60
- package/dist/mcp/index.js +155 -57
- package/dist/serve/index.js +158 -60
- package/dist/service.js +155 -57
- package/hasna.contract.json +1 -1
- package/package.json +3 -3
- package/types/daemon.d.ts +3 -0
- package/types/graph.d.ts +7 -0
- package/types/session.d.ts +24 -0
- package/types/store.d.ts +5 -0
package/dist/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,13 +452,13 @@ var init_lanes = __esm(() => {
|
|
|
443
452
|
|
|
444
453
|
// src/cli/index.ts
|
|
445
454
|
import { Command } from "commander";
|
|
446
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync 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
|
|
457
|
+
import { join as join5 } from "path";
|
|
449
458
|
// package.json
|
|
450
459
|
var package_default = {
|
|
451
460
|
name: "@hasna/workflows",
|
|
452
|
-
version: "0.1.
|
|
461
|
+
version: "0.1.4",
|
|
453
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",
|
|
454
463
|
type: "module",
|
|
455
464
|
main: "./dist/index.js",
|
|
@@ -526,17 +535,14 @@ var package_default = {
|
|
|
526
535
|
commander: "^13.1.0"
|
|
527
536
|
},
|
|
528
537
|
devDependencies: {
|
|
529
|
-
"@hasna/contracts": "0.14.
|
|
530
|
-
"@types/bun": "
|
|
538
|
+
"@hasna/contracts": "0.14.2",
|
|
539
|
+
"@types/bun": "1.3.14",
|
|
531
540
|
"@types/pg": "^8.15.6",
|
|
532
541
|
pg: "^8.16.3",
|
|
533
542
|
typescript: "^5.7.3"
|
|
534
543
|
}
|
|
535
544
|
};
|
|
536
545
|
|
|
537
|
-
// src/daemon.ts
|
|
538
|
-
import { createHash as createHash2 } from "crypto";
|
|
539
|
-
|
|
540
546
|
// src/expr.ts
|
|
541
547
|
class ExprSyntaxError extends Error {
|
|
542
548
|
constructor(message) {
|
|
@@ -910,6 +916,18 @@ function validateGraph(graph) {
|
|
|
910
916
|
if (typeof graph.version !== "string" || graph.version.trim() === "") {
|
|
911
917
|
issue(issues, "version", "graph requires a version string");
|
|
912
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
|
+
}
|
|
913
931
|
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
|
914
932
|
issue(issues, "nodes", "graph requires at least one node");
|
|
915
933
|
return { ok: false, issues };
|
|
@@ -1144,6 +1162,8 @@ function assertNoSecrets(value, what) {
|
|
|
1144
1162
|
|
|
1145
1163
|
// src/session.ts
|
|
1146
1164
|
import { createHash } from "crypto";
|
|
1165
|
+
import { readFileSync, statSync } from "fs";
|
|
1166
|
+
import { isAbsolute, join } from "path";
|
|
1147
1167
|
var DEFAULT_MAX_ATTEMPTS = 3;
|
|
1148
1168
|
function repairTornRuns(store, wal, opts = {}) {
|
|
1149
1169
|
const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
@@ -1199,6 +1219,46 @@ function recordMemo(store, graphName, nodeId, input, output) {
|
|
|
1199
1219
|
const key = memoKey(graphName, nodeId, input);
|
|
1200
1220
|
store.memoPut(key, graphName, nodeId, inputHash(input), JSON.stringify(output));
|
|
1201
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
|
+
}
|
|
1202
1262
|
function resetRunNodes(store, run) {
|
|
1203
1263
|
for (const node of store.listRunNodes(run.id)) {
|
|
1204
1264
|
if (node.status === "running") {
|
|
@@ -1501,10 +1561,13 @@ class WorkflowsDaemon {
|
|
|
1501
1561
|
}
|
|
1502
1562
|
async executeStep(run, graph, context, wf, node) {
|
|
1503
1563
|
const nodeId = node.id;
|
|
1504
|
-
const
|
|
1505
|
-
const
|
|
1506
|
-
|
|
1507
|
-
|
|
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) {
|
|
1508
1571
|
const hit = tryMemoHit(this.store, run.graphName, nodeId, memoInput);
|
|
1509
1572
|
if (hit) {
|
|
1510
1573
|
const cached = JSON.parse(hit.outputJson);
|
|
@@ -1556,9 +1619,13 @@ class WorkflowsDaemon {
|
|
|
1556
1619
|
}
|
|
1557
1620
|
this.store.setRunNodeStatus(nodeRow.id, "completed", { exitCode: result.exitCode, output });
|
|
1558
1621
|
this.wal.append({ op: "node_finished", runId: run.id, nodeId, status: "completed", at: nowIso() });
|
|
1559
|
-
if (
|
|
1560
|
-
|
|
1561
|
-
|
|
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
|
+
}
|
|
1562
1629
|
}
|
|
1563
1630
|
this.recordStepResult(context, nodeId, output);
|
|
1564
1631
|
return this.afterNode(run, context, wf, node.next);
|
|
@@ -1653,6 +1720,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1653
1720
|
isolatedRunId: options.isolatedRunId
|
|
1654
1721
|
});
|
|
1655
1722
|
const run = daemon.startRun(graph, context);
|
|
1723
|
+
options.onRunCreated?.(run);
|
|
1656
1724
|
daemon.isolateRun(run.id);
|
|
1657
1725
|
const maxCycles = options.maxCycles ?? 500;
|
|
1658
1726
|
for (let cycle = 0;cycle < maxCycles; cycle++) {
|
|
@@ -1670,7 +1738,7 @@ async function runGraphToCompletion(store, wal, graph, context, options = {}) {
|
|
|
1670
1738
|
// src/paths.ts
|
|
1671
1739
|
import { existsSync } from "fs";
|
|
1672
1740
|
import { homedir } from "os";
|
|
1673
|
-
import { join } from "path";
|
|
1741
|
+
import { join as join2 } from "path";
|
|
1674
1742
|
import { dataDir } from "@hasna/paths";
|
|
1675
1743
|
var APP = "workflows";
|
|
1676
1744
|
var STORE_DB_FILE = "workflows.db";
|
|
@@ -1678,7 +1746,7 @@ function getHomeDir(env = process.env) {
|
|
|
1678
1746
|
return env["HOME"] || env["USERPROFILE"] || homedir();
|
|
1679
1747
|
}
|
|
1680
1748
|
function getLegacyDataDir(env = process.env) {
|
|
1681
|
-
return
|
|
1749
|
+
return join2(getHomeDir(env), ".hasna", APP);
|
|
1682
1750
|
}
|
|
1683
1751
|
function getResolverDataDir(env = process.env) {
|
|
1684
1752
|
return dataDir({ app: APP, home: getHomeDir(env), env });
|
|
@@ -1687,7 +1755,7 @@ function adoptResolverDataDir(resolved, env = process.env) {
|
|
|
1687
1755
|
const dataOverride = env.HASNA_DATA_HOME;
|
|
1688
1756
|
if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
|
|
1689
1757
|
return true;
|
|
1690
|
-
return existsSync(
|
|
1758
|
+
return existsSync(join2(resolved, STORE_DB_FILE));
|
|
1691
1759
|
}
|
|
1692
1760
|
function getEffectiveDataDir(env = process.env) {
|
|
1693
1761
|
const resolved = getResolverDataDir(env);
|
|
@@ -1697,17 +1765,41 @@ function getEffectiveDataDir(env = process.env) {
|
|
|
1697
1765
|
// src/store.ts
|
|
1698
1766
|
import { Database } from "bun:sqlite";
|
|
1699
1767
|
import { mkdirSync } from "fs";
|
|
1700
|
-
import { join as
|
|
1768
|
+
import { join as join3 } from "path";
|
|
1701
1769
|
import { randomUUID } from "crypto";
|
|
1702
1770
|
function nowIso2() {
|
|
1703
1771
|
return new Date().toISOString();
|
|
1704
1772
|
}
|
|
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
|
+
}
|
|
1705
1796
|
function openStore(dataDir2) {
|
|
1706
1797
|
mkdirSync(dataDir2, { recursive: true });
|
|
1707
|
-
const db = new Database(
|
|
1708
|
-
db.exec(
|
|
1709
|
-
db.exec("PRAGMA
|
|
1710
|
-
db.exec(
|
|
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(`
|
|
1711
1803
|
CREATE TABLE IF NOT EXISTS runs (
|
|
1712
1804
|
id TEXT PRIMARY KEY,
|
|
1713
1805
|
graph_name TEXT NOT NULL,
|
|
@@ -1747,7 +1839,7 @@ function openStore(dataDir2) {
|
|
|
1747
1839
|
CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
|
|
1748
1840
|
CREATE INDEX IF NOT EXISTS idx_runs_created ON runs(created_at);
|
|
1749
1841
|
CREATE INDEX IF NOT EXISTS idx_run_nodes_run ON run_nodes(run_id);
|
|
1750
|
-
`);
|
|
1842
|
+
`));
|
|
1751
1843
|
const rowToRun = (r) => ({
|
|
1752
1844
|
id: String(r.id),
|
|
1753
1845
|
graphName: String(r.graph_name),
|
|
@@ -1785,31 +1877,33 @@ function openStore(dataDir2) {
|
|
|
1785
1877
|
createdAt: String(r.created_at)
|
|
1786
1878
|
});
|
|
1787
1879
|
return {
|
|
1880
|
+
dataDir: dataDir2,
|
|
1788
1881
|
close() {
|
|
1789
1882
|
db.close();
|
|
1790
1883
|
},
|
|
1791
1884
|
listTables() {
|
|
1792
|
-
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());
|
|
1793
1886
|
return rows.map((r) => r.name);
|
|
1794
1887
|
},
|
|
1795
1888
|
createRun(input) {
|
|
1796
1889
|
const id = randomUUID();
|
|
1797
1890
|
const now = nowIso2();
|
|
1798
|
-
db.query(`INSERT INTO runs (id, graph_name, graph_version, status, attempts, context_json, created_at, updated_at)
|
|
1799
|
-
|
|
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));
|
|
1800
1893
|
return this.getRun(id);
|
|
1801
1894
|
},
|
|
1802
1895
|
getRun(id) {
|
|
1803
|
-
const row = db.query(`SELECT * FROM runs WHERE id = ?`).get(id);
|
|
1896
|
+
const row = retryBusy(() => db.query(`SELECT * FROM runs WHERE id = ?`).get(id));
|
|
1804
1897
|
return row ? rowToRun(row) : undefined;
|
|
1805
1898
|
},
|
|
1806
1899
|
listRuns(opts = {}) {
|
|
1807
1900
|
const limit = Math.min(opts.limit ?? 100, 1000);
|
|
1808
1901
|
if (opts.status) {
|
|
1809
|
-
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));
|
|
1810
1904
|
return rows2.map((r) => rowToRun(r));
|
|
1811
1905
|
}
|
|
1812
|
-
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));
|
|
1813
1907
|
return rows.map((r) => rowToRun(r));
|
|
1814
1908
|
},
|
|
1815
1909
|
setRunStatus(id, status, patch = {}) {
|
|
@@ -1820,28 +1914,32 @@ function openStore(dataDir2) {
|
|
|
1820
1914
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing?.startedAt === null ? now : existing?.startedAt ?? null;
|
|
1821
1915
|
const terminal = status === "completed" || status === "failed" || status === "cancelled" || status === "interrupted";
|
|
1822
1916
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing?.finishedAt === null ? now : existing?.finishedAt ?? null;
|
|
1823
|
-
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));
|
|
1824
1918
|
},
|
|
1825
1919
|
bumpAttempts(id) {
|
|
1826
|
-
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));
|
|
1827
1921
|
},
|
|
1828
1922
|
setRunContext(id, context) {
|
|
1829
|
-
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));
|
|
1830
1924
|
},
|
|
1831
1925
|
createRunNode(input) {
|
|
1832
1926
|
const id = randomUUID();
|
|
1833
|
-
db.query(`INSERT INTO run_nodes (id, run_id, node_id, status, attempts, lane)
|
|
1834
|
-
|
|
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));
|
|
1835
1929
|
return this.getRunNode(id);
|
|
1836
1930
|
},
|
|
1837
1931
|
getRunNode(id) {
|
|
1838
|
-
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));
|
|
1839
1933
|
return row ? rowToNode(row) : undefined;
|
|
1840
1934
|
},
|
|
1841
1935
|
listRunNodes(runId) {
|
|
1842
|
-
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));
|
|
1843
1937
|
return rows.map((r) => rowToNode(r));
|
|
1844
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
|
+
},
|
|
1845
1943
|
setRunNodeStatus(id, status, patch = {}) {
|
|
1846
1944
|
const now = nowIso2();
|
|
1847
1945
|
const existing = this.getRunNode(id);
|
|
@@ -1853,44 +1951,44 @@ function openStore(dataDir2) {
|
|
|
1853
1951
|
const startedAt = patch.startedAt !== undefined ? patch.startedAt : status === "running" && existing.startedAt === null ? now : existing.startedAt;
|
|
1854
1952
|
const terminal = status === "completed" || status === "failed" || status === "skipped";
|
|
1855
1953
|
const finishedAt = patch.finishedAt !== undefined ? patch.finishedAt : terminal && existing.finishedAt === null ? now : existing.finishedAt;
|
|
1856
|
-
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));
|
|
1857
1955
|
},
|
|
1858
1956
|
bumpAttemptsNode(id) {
|
|
1859
|
-
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));
|
|
1860
1958
|
},
|
|
1861
1959
|
memoPut(key, graphName, nodeId, inputHash2, outputJson) {
|
|
1862
|
-
db.query(`INSERT INTO memos (key, graph_name, node_id, input_hash, output_json, hit_count, created_at)
|
|
1863
|
-
|
|
1864
|
-
|
|
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()));
|
|
1865
1963
|
},
|
|
1866
1964
|
memoGet(key) {
|
|
1867
|
-
const row = db.query(`SELECT * FROM memos WHERE key = ?`).get(key);
|
|
1965
|
+
const row = retryBusy(() => db.query(`SELECT * FROM memos WHERE key = ?`).get(key));
|
|
1868
1966
|
return row ? rowToMemo(row) : undefined;
|
|
1869
1967
|
},
|
|
1870
1968
|
memoHit(key) {
|
|
1871
|
-
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));
|
|
1872
1970
|
},
|
|
1873
1971
|
memoList() {
|
|
1874
|
-
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());
|
|
1875
1973
|
return rows.map((r) => rowToMemo(r));
|
|
1876
1974
|
},
|
|
1877
1975
|
memoClear() {
|
|
1878
|
-
db.exec(`DELETE FROM memos`);
|
|
1976
|
+
retryBusy(() => db.exec(`DELETE FROM memos`));
|
|
1879
1977
|
}
|
|
1880
1978
|
};
|
|
1881
1979
|
}
|
|
1882
1980
|
|
|
1883
1981
|
// src/wal.ts
|
|
1884
|
-
import { createHash as
|
|
1885
|
-
import { appendFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, renameSync, truncateSync, writeFileSync } from "fs";
|
|
1886
|
-
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";
|
|
1887
1985
|
var WAL_FILE_NAME = "session.wal";
|
|
1888
1986
|
var SESSIONS_DIR_NAME = "sessions";
|
|
1889
1987
|
var MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
1890
1988
|
function checksumOf(op) {
|
|
1891
1989
|
if (op === undefined || op === null)
|
|
1892
1990
|
return "";
|
|
1893
|
-
return
|
|
1991
|
+
return createHash2("sha256").update(JSON.stringify(op)).digest("hex");
|
|
1894
1992
|
}
|
|
1895
1993
|
|
|
1896
1994
|
class SessionWAL {
|
|
@@ -1899,9 +1997,9 @@ class SessionWAL {
|
|
|
1899
1997
|
this.filePath = filePath;
|
|
1900
1998
|
}
|
|
1901
1999
|
static open(dataDir2) {
|
|
1902
|
-
mkdirSync2(
|
|
1903
|
-
const canonical =
|
|
1904
|
-
const legacy =
|
|
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);
|
|
1905
2003
|
if (existsSync2(legacy) && !existsSync2(canonical)) {
|
|
1906
2004
|
try {
|
|
1907
2005
|
renameSync(legacy, canonical);
|
|
@@ -1933,7 +2031,7 @@ class SessionWAL {
|
|
|
1933
2031
|
let repaired = false;
|
|
1934
2032
|
let content;
|
|
1935
2033
|
try {
|
|
1936
|
-
content =
|
|
2034
|
+
content = readFileSync2(this.filePath, "utf8");
|
|
1937
2035
|
} catch {
|
|
1938
2036
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
1939
2037
|
}
|
|
@@ -2196,7 +2294,7 @@ init_lanes();
|
|
|
2196
2294
|
init_types();
|
|
2197
2295
|
|
|
2198
2296
|
// src/handlers.ts
|
|
2199
|
-
import { createHash as
|
|
2297
|
+
import { createHash as createHash3, timingSafeEqual } from "crypto";
|
|
2200
2298
|
function jsonResponse(body, status = 200) {
|
|
2201
2299
|
return new Response(JSON.stringify(body), {
|
|
2202
2300
|
status,
|
|
@@ -2252,8 +2350,8 @@ function bearerMatches(authHeader, expected) {
|
|
|
2252
2350
|
const supplied = authHeader.slice("Bearer ".length);
|
|
2253
2351
|
if (supplied.length === 0)
|
|
2254
2352
|
return false;
|
|
2255
|
-
const a =
|
|
2256
|
-
const b =
|
|
2353
|
+
const a = createHash3("sha256").update(supplied).digest();
|
|
2354
|
+
const b = createHash3("sha256").update(expected).digest();
|
|
2257
2355
|
return timingSafeEqual(a, b);
|
|
2258
2356
|
}
|
|
2259
2357
|
async function handleTrigger(service, req) {
|
|
@@ -2354,7 +2452,7 @@ program.command("info").description("Show service configuration (never credentia
|
|
|
2354
2452
|
function loadGraph(file) {
|
|
2355
2453
|
let raw;
|
|
2356
2454
|
try {
|
|
2357
|
-
raw =
|
|
2455
|
+
raw = readFileSync3(file, "utf8");
|
|
2358
2456
|
} catch {
|
|
2359
2457
|
throw new Error(`cannot read graph file ${file}`);
|
|
2360
2458
|
}
|
|
@@ -2422,11 +2520,11 @@ function runSummary(row) {
|
|
|
2422
2520
|
}
|
|
2423
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) => {
|
|
2424
2522
|
const config = resolveWorkflowsConfig();
|
|
2425
|
-
mkdirSync3(
|
|
2426
|
-
mkdirSync3(
|
|
2523
|
+
mkdirSync3(join5(config.dataDir, "workflows"), { recursive: true });
|
|
2524
|
+
mkdirSync3(join5(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
2427
2525
|
const store = openStore(config.dataDir);
|
|
2428
2526
|
store.close();
|
|
2429
|
-
const target = file ??
|
|
2527
|
+
const target = file ?? join5(config.dataDir, "workflows", "demo.json");
|
|
2430
2528
|
if (existsSync3(target)) {
|
|
2431
2529
|
console.error(`refusing to overwrite existing file ${target}`);
|
|
2432
2530
|
process.exit(1);
|
|
@@ -2485,14 +2583,18 @@ program.command("graph").description("Render a workflow graph (text, DOT, or JSO
|
|
|
2485
2583
|
}
|
|
2486
2584
|
});
|
|
2487
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) => {
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
if (!validation.ok) {
|
|
2491
|
-
throw new Error(`graph validation failed: ${validation.issues.map((i) => `${i.path}: ${i.message}`).join("; ")}`);
|
|
2492
|
-
}
|
|
2493
|
-
const context = mergeRunContext(opts.context, opts.input ?? []);
|
|
2494
|
-
const { store, wal } = openLocal();
|
|
2586
|
+
let store;
|
|
2587
|
+
let createdRunId;
|
|
2495
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;
|
|
2496
2598
|
if (opts.idempotencyKey) {
|
|
2497
2599
|
const existing = findRunByIdempotencyKey(store, opts.idempotencyKey);
|
|
2498
2600
|
if (existing) {
|
|
@@ -2503,13 +2605,18 @@ program.command("run").description("Run a workflow graph to a terminal state (bo
|
|
|
2503
2605
|
console.log(`reused run ${existing.id}: ${existing.status}`);
|
|
2504
2606
|
}
|
|
2505
2607
|
if (existing.status !== "completed")
|
|
2506
|
-
process.
|
|
2608
|
+
process.exitCode = 1;
|
|
2507
2609
|
return;
|
|
2508
2610
|
}
|
|
2509
2611
|
}
|
|
2510
2612
|
const contextWithKey = opts.idempotencyKey ? withIdempotencyKey(context, opts.idempotencyKey) : context;
|
|
2511
2613
|
const maxCycles = Number(opts.maxCycles ?? 500);
|
|
2512
|
-
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
|
+
});
|
|
2513
2620
|
const summary = runSummary(final);
|
|
2514
2621
|
if (opts.json) {
|
|
2515
2622
|
console.log(JSON.stringify(summary, null, 2));
|
|
@@ -2519,9 +2626,17 @@ program.command("run").description("Run a workflow graph to a terminal state (bo
|
|
|
2519
2626
|
console.log(final.resultJson);
|
|
2520
2627
|
}
|
|
2521
2628
|
if (final.status !== "completed")
|
|
2522
|
-
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;
|
|
2523
2638
|
} finally {
|
|
2524
|
-
store
|
|
2639
|
+
store?.close();
|
|
2525
2640
|
}
|
|
2526
2641
|
});
|
|
2527
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) => {
|
|
@@ -2615,7 +2730,7 @@ program.command("nodes").description("List or show a run's node executions").arg
|
|
|
2615
2730
|
if (action === "show") {
|
|
2616
2731
|
if (!node)
|
|
2617
2732
|
throw new Error("nodes show requires a run id and a node id");
|
|
2618
|
-
const row = store.
|
|
2733
|
+
const row = store.getLatestRunNode(run, node);
|
|
2619
2734
|
if (!row)
|
|
2620
2735
|
throw new Error(`run ${run} has no node ${node}`);
|
|
2621
2736
|
console.log(JSON.stringify(row, null, 2));
|
|
@@ -2713,17 +2828,17 @@ program.command("machines").description("List observed machines or report this m
|
|
|
2713
2828
|
}
|
|
2714
2829
|
case "status": {
|
|
2715
2830
|
const replay = wal.replay();
|
|
2716
|
-
const pidFile =
|
|
2717
|
-
const pid = existsSync3(pidFile) ?
|
|
2831
|
+
const pidFile = join5(config.dataDir, "daemon.pid");
|
|
2832
|
+
const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
|
|
2718
2833
|
const running = pid !== null && processAlive(Number(pid));
|
|
2719
2834
|
const report = {
|
|
2720
2835
|
hostname: hostname(),
|
|
2721
2836
|
pid: process.pid,
|
|
2722
2837
|
dataDir: config.dataDir,
|
|
2723
2838
|
layout: {
|
|
2724
|
-
workflows: existsSync3(
|
|
2725
|
-
sessions: existsSync3(
|
|
2726
|
-
db: existsSync3(
|
|
2839
|
+
workflows: existsSync3(join5(config.dataDir, "workflows")),
|
|
2840
|
+
sessions: existsSync3(join5(config.dataDir, SESSIONS_DIR_NAME)),
|
|
2841
|
+
db: existsSync3(join5(config.dataDir, "workflows.db"))
|
|
2727
2842
|
},
|
|
2728
2843
|
store: {
|
|
2729
2844
|
tables: store.listTables(),
|
|
@@ -2787,8 +2902,8 @@ program.command("lanes").description("List the four lane adapters (wired vs not-
|
|
|
2787
2902
|
});
|
|
2788
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) => {
|
|
2789
2904
|
const config = resolveWorkflowsConfig();
|
|
2790
|
-
const pidFile =
|
|
2791
|
-
const statusFile =
|
|
2905
|
+
const pidFile = join5(config.dataDir, "daemon.pid");
|
|
2906
|
+
const statusFile = join5(config.dataDir, "daemon.status.json");
|
|
2792
2907
|
switch (action) {
|
|
2793
2908
|
case "start": {
|
|
2794
2909
|
const { store, wal } = openLocal();
|
|
@@ -2838,19 +2953,19 @@ program.command("daemon").description("Run or inspect the daemon").argument("<ac
|
|
|
2838
2953
|
case "status": {
|
|
2839
2954
|
if (!existsSync3(statusFile)) {
|
|
2840
2955
|
if (opts.json) {
|
|
2841
|
-
console.log(JSON.stringify({ running: false, lastReap: null }));
|
|
2956
|
+
console.log(JSON.stringify({ statusVersion: 1, running: false, pid: null, lastReap: null, cumulative: emptyReapReport() }));
|
|
2842
2957
|
} else {
|
|
2843
2958
|
console.log("daemon not running (no status record)");
|
|
2844
2959
|
}
|
|
2845
2960
|
return;
|
|
2846
2961
|
}
|
|
2847
|
-
const
|
|
2848
|
-
const pid = existsSync3(pidFile) ?
|
|
2962
|
+
const envelope = readReport(statusFile);
|
|
2963
|
+
const pid = existsSync3(pidFile) ? readFileSync3(pidFile, "utf8").trim() : null;
|
|
2849
2964
|
const running = pid !== null && processAlive(Number(pid));
|
|
2850
2965
|
if (opts.json) {
|
|
2851
|
-
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));
|
|
2852
2967
|
} else {
|
|
2853
|
-
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)}`);
|
|
2854
2969
|
}
|
|
2855
2970
|
return;
|
|
2856
2971
|
}
|
|
@@ -2859,7 +2974,7 @@ program.command("daemon").description("Run or inspect the daemon").argument("<ac
|
|
|
2859
2974
|
console.error("no daemon pid file \u2014 nothing to stop");
|
|
2860
2975
|
process.exit(1);
|
|
2861
2976
|
}
|
|
2862
|
-
const pid = Number(
|
|
2977
|
+
const pid = Number(readFileSync3(pidFile, "utf8").trim());
|
|
2863
2978
|
try {
|
|
2864
2979
|
process.kill(pid, "SIGTERM");
|
|
2865
2980
|
} catch {
|
|
@@ -2881,9 +2996,60 @@ function processAlive(pid) {
|
|
|
2881
2996
|
return false;
|
|
2882
2997
|
}
|
|
2883
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
|
+
}
|
|
2884
3018
|
function writeReport(statusFile, report) {
|
|
2885
|
-
mkdirSync3(
|
|
2886
|
-
|
|
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 };
|
|
2887
3053
|
}
|
|
2888
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) => {
|
|
2889
3055
|
const { store } = openLocal();
|