@hasna/workflows 0.2.0 → 0.2.2
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 +53 -0
- package/dist/agent-tools.js +36 -500
- package/dist/cli/index.js +23 -12
- package/dist/handlers.js +1226 -1074
- package/dist/index.js +1228 -1076
- package/dist/mcp/index.js +15 -4
- package/dist/sdk.js +4 -4
- package/dist/serve/admin.js +188 -43
- package/dist/serve/index.js +229 -43
- package/dist/service.js +1223 -1072
- package/hasna.contract.json +1 -1
- package/package.json +3 -3
- package/types/graph.d.ts +6 -0
- package/types/journal-directory.d.ts +4 -0
- package/types/lanes/codex.d.ts +0 -6
- package/types/lanes/types.d.ts +4 -0
- package/types/sdk/generated-api.d.ts +2 -0
- package/types/serve/journal-lease.d.ts +7 -0
- package/types/service.d.ts +12 -4
- package/types/types.d.ts +4 -0
package/dist/serve/index.js
CHANGED
|
@@ -22,7 +22,7 @@ var package_default;
|
|
|
22
22
|
var init_package = __esm(() => {
|
|
23
23
|
package_default = {
|
|
24
24
|
name: "@hasna/workflows",
|
|
25
|
-
version: "0.2.
|
|
25
|
+
version: "0.2.2",
|
|
26
26
|
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",
|
|
27
27
|
type: "module",
|
|
28
28
|
main: "./dist/index.js",
|
|
@@ -98,9 +98,9 @@ var init_package = __esm(() => {
|
|
|
98
98
|
dependencies: {
|
|
99
99
|
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
100
100
|
"@cursor/sdk": "^1.0.28",
|
|
101
|
-
"@
|
|
101
|
+
"@hasna/contracts": "1.3.5",
|
|
102
|
+
"@openai/codex-sdk": "0.160.0",
|
|
102
103
|
commander: "^13.1.0",
|
|
103
|
-
"@hasna/contracts": "1.3.3",
|
|
104
104
|
pg: "^8.16.3"
|
|
105
105
|
},
|
|
106
106
|
devDependencies: {
|
|
@@ -606,6 +606,16 @@ function validateNode(graph, node, issues) {
|
|
|
606
606
|
if (node.lane !== undefined && !LANE_KINDS.includes(node.lane)) {
|
|
607
607
|
issue(issues, `nodes.${node.id}`, `unknown lane ${JSON.stringify(node.lane)} (expected one of ${LANE_KINDS.join(", ")})`);
|
|
608
608
|
}
|
|
609
|
+
if (node.cwd !== undefined && (typeof node.cwd !== "string" || !node.cwd.trim() || node.cwd.includes("\x00"))) {
|
|
610
|
+
issue(issues, `nodes.${node.id}.cwd`, "cwd must be a non-empty path string without NUL characters on the executor");
|
|
611
|
+
}
|
|
612
|
+
if (node.codexMode !== undefined) {
|
|
613
|
+
if (!CODEX_EXECUTION_MODES.includes(node.codexMode)) {
|
|
614
|
+
issue(issues, `nodes.${node.id}.codexMode`, "codexMode must be repository or read-only");
|
|
615
|
+
} else if (node.lane !== "codex" || !node.prompt || node.command !== undefined) {
|
|
616
|
+
issue(issues, `nodes.${node.id}.codexMode`, "codexMode is supported only for a codex prompt step without command");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
609
619
|
if (node.maxRetries !== undefined && (!Number.isInteger(node.maxRetries) || node.maxRetries < 0)) {
|
|
610
620
|
issue(issues, `nodes.${node.id}`, "maxRetries must be a non-negative integer");
|
|
611
621
|
}
|
|
@@ -683,11 +693,12 @@ function validateNode(graph, node, issues) {
|
|
|
683
693
|
break;
|
|
684
694
|
}
|
|
685
695
|
}
|
|
686
|
-
var GRAPH_NODE_TYPES, LANE_KINDS, NODE_ID_RE;
|
|
696
|
+
var GRAPH_NODE_TYPES, LANE_KINDS, CODEX_EXECUTION_MODES, NODE_ID_RE;
|
|
687
697
|
var init_graph = __esm(() => {
|
|
688
698
|
init_expr();
|
|
689
699
|
GRAPH_NODE_TYPES = ["start", "step", "decision", "while", "end"];
|
|
690
700
|
LANE_KINDS = ["claude", "codex", "cursor", "grok"];
|
|
701
|
+
CODEX_EXECUTION_MODES = ["repository", "read-only"];
|
|
691
702
|
NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
692
703
|
});
|
|
693
704
|
|
|
@@ -1355,9 +1366,15 @@ async function spawnCli(cmd, opts) {
|
|
|
1355
1366
|
new Response(proc.stderr).text(),
|
|
1356
1367
|
proc.exited
|
|
1357
1368
|
]);
|
|
1358
|
-
const output = stderr ? `${stdout}
|
|
1369
|
+
const output = opts.stdoutOnly ? stdout.trim() : stderr ? `${stdout}
|
|
1359
1370
|
${stderr}`.trim() : stdout.trim();
|
|
1360
|
-
return {
|
|
1371
|
+
return {
|
|
1372
|
+
ok: exitCode === 0,
|
|
1373
|
+
exitCode,
|
|
1374
|
+
output,
|
|
1375
|
+
durationMs: Date.now() - started,
|
|
1376
|
+
...opts.stdoutOnly && exitCode !== 0 && stderr.trim() ? { error: stderr.trim() } : {}
|
|
1377
|
+
};
|
|
1361
1378
|
}
|
|
1362
1379
|
var LANE_KINDS2, LaneDependencyMissingError, LaneAdapterShapeError, DEFAULT_LANE_TIMEOUT_MS = 120000, LaneTimeoutError;
|
|
1363
1380
|
var init_types = __esm(() => {
|
|
@@ -1501,6 +1518,9 @@ function createCodexAdapter(deps = {}) {
|
|
|
1501
1518
|
if (!job.prompt) {
|
|
1502
1519
|
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codex lane requires a prompt" };
|
|
1503
1520
|
}
|
|
1521
|
+
if (job.codexMode !== undefined && (!CODEX_EXECUTION_MODES.includes(job.codexMode) || job.lane !== "codex" || job.command !== undefined)) {
|
|
1522
|
+
return { ok: false, exitCode: 2, output: "", durationMs: 0, error: "codexMode must be repository or read-only on a codex prompt without command", retryable: false };
|
|
1523
|
+
}
|
|
1504
1524
|
const started = Date.now();
|
|
1505
1525
|
const timeoutMs = job.timeoutMs ?? DEFAULT_LANE_TIMEOUT_MS;
|
|
1506
1526
|
const loader = deps.sdkLoader ?? (async () => await import("@openai/codex-sdk"));
|
|
@@ -1520,13 +1540,20 @@ function createCodexAdapter(deps = {}) {
|
|
|
1520
1540
|
config: { model: job.model },
|
|
1521
1541
|
env: subscriptionEnvironment(job.env)
|
|
1522
1542
|
})), timeoutMs, "codex lane");
|
|
1523
|
-
const thread = client.startThread?.({
|
|
1543
|
+
const thread = client.startThread?.({
|
|
1544
|
+
model: job.model,
|
|
1545
|
+
workingDirectory: job.cwd,
|
|
1546
|
+
...job.codexMode === "read-only" ? { sandboxMode: "read-only", skipGitRepoCheck: true, approvalPolicy: "never" } : {}
|
|
1547
|
+
});
|
|
1524
1548
|
if (!thread || typeof thread.run !== "function") {
|
|
1525
1549
|
throw new LaneAdapterShapeError("@openai/codex-sdk Codex.startThread() returned no run()");
|
|
1526
1550
|
}
|
|
1527
1551
|
const turn = await withTimeout(thread.run(job.prompt, { signal: abortController.signal }), timeoutMs, "codex lane", () => abortController.abort());
|
|
1528
|
-
const output = turn?.finalResponse ?? "";
|
|
1529
1552
|
const durationMs = Date.now() - started;
|
|
1553
|
+
const output = turn?.finalResponse;
|
|
1554
|
+
if (typeof output !== "string" || !output.trim()) {
|
|
1555
|
+
return missingResponse(durationMs);
|
|
1556
|
+
}
|
|
1530
1557
|
return { ok: true, exitCode: 0, output, durationMs };
|
|
1531
1558
|
} catch (err) {
|
|
1532
1559
|
const durationMs = Date.now() - started;
|
|
@@ -1537,14 +1564,17 @@ function createCodexAdapter(deps = {}) {
|
|
|
1537
1564
|
}
|
|
1538
1565
|
async function runCodexCli(job, cliPath, started, timeoutMs) {
|
|
1539
1566
|
try {
|
|
1540
|
-
const
|
|
1567
|
+
const controls = job.codexMode === "read-only" ? ["--sandbox", "read-only", "--skip-git-repo-check", "--config", 'approval_policy="never"'] : [];
|
|
1568
|
+
const result = await spawnCli([cliPath, "exec", ...controls, "--", job.prompt], {
|
|
1541
1569
|
binaryName: cliPath,
|
|
1542
1570
|
packageHint: "@openai/codex-sdk or the codex CLI",
|
|
1543
1571
|
timeoutMs,
|
|
1544
1572
|
env: job.env,
|
|
1545
|
-
cwd: job.cwd
|
|
1573
|
+
cwd: job.cwd,
|
|
1574
|
+
stdoutOnly: true
|
|
1546
1575
|
});
|
|
1547
|
-
|
|
1576
|
+
const durationMs = Date.now() - started;
|
|
1577
|
+
return result.ok && !result.output.trim() ? missingResponse(durationMs) : { ...result, durationMs };
|
|
1548
1578
|
} catch (err) {
|
|
1549
1579
|
if (err instanceof LaneDependencyMissingError) {
|
|
1550
1580
|
return { ok: false, exitCode: 127, output: "", durationMs: Date.now() - started, error: err.message };
|
|
@@ -1552,7 +1582,11 @@ async function runCodexCli(job, cliPath, started, timeoutMs) {
|
|
|
1552
1582
|
throw err;
|
|
1553
1583
|
}
|
|
1554
1584
|
}
|
|
1585
|
+
function missingResponse(durationMs) {
|
|
1586
|
+
return { ok: false, exitCode: 1, output: "", durationMs, error: "Codex completed without an agent response; verify the native subscription and model route", retryable: false };
|
|
1587
|
+
}
|
|
1555
1588
|
var init_codex = __esm(() => {
|
|
1589
|
+
init_graph();
|
|
1556
1590
|
init_types();
|
|
1557
1591
|
});
|
|
1558
1592
|
|
|
@@ -2069,7 +2103,14 @@ class WorkflowsDaemon {
|
|
|
2069
2103
|
const isLoopBody = graph.nodes.some((candidate) => candidate.type === "while" && candidate.body.includes(nodeId));
|
|
2070
2104
|
const nodeRow = latest === undefined || isLoopBody && latest.status === "completed" ? await this.store.createRunNode({ runId: run2.id, nodeId, lane: node2.lane ?? "claude" }) : latest;
|
|
2071
2105
|
const memoWatch = node2.memo ? memoWatchFingerprint(graph, this.store.dataDir) : undefined;
|
|
2072
|
-
const memoSource = {
|
|
2106
|
+
const memoSource = {
|
|
2107
|
+
command: node2.command,
|
|
2108
|
+
prompt: node2.prompt,
|
|
2109
|
+
context: stripRunState(context),
|
|
2110
|
+
lane: node2.lane ?? "claude",
|
|
2111
|
+
cwd: node2.cwd,
|
|
2112
|
+
codexMode: node2.lane === "codex" ? node2.codexMode ?? "repository" : undefined
|
|
2113
|
+
};
|
|
2073
2114
|
const memoInput = node2.memo ? memoWatch !== undefined ? { ...memoSource, memoWatch } : memoSource : undefined;
|
|
2074
2115
|
if (memoInput !== undefined) {
|
|
2075
2116
|
const hit = await tryMemoHit(this.store, run2.graphName, nodeId, memoInput);
|
|
@@ -2094,6 +2135,8 @@ class WorkflowsDaemon {
|
|
|
2094
2135
|
lane: node2.lane ?? "claude",
|
|
2095
2136
|
prompt: node2.prompt,
|
|
2096
2137
|
command: node2.command,
|
|
2138
|
+
cwd: node2.cwd,
|
|
2139
|
+
codexMode: node2.codexMode,
|
|
2097
2140
|
timeoutMs: node2.timeoutMs,
|
|
2098
2141
|
env: this.env
|
|
2099
2142
|
};
|
|
@@ -2689,7 +2732,7 @@ var init_store = __esm(() => {
|
|
|
2689
2732
|
// src/wal.ts
|
|
2690
2733
|
import { Database as Database2 } from "bun:sqlite";
|
|
2691
2734
|
import { createHash as createHash2 } from "crypto";
|
|
2692
|
-
import {
|
|
2735
|
+
import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
|
|
2693
2736
|
import { basename, dirname, join as join4 } from "path";
|
|
2694
2737
|
function checksumOf(op) {
|
|
2695
2738
|
if (op === undefined || op === null)
|
|
@@ -2756,8 +2799,20 @@ class SessionWAL {
|
|
|
2756
2799
|
const full = { ...op, at };
|
|
2757
2800
|
const nextSeq = this.nextSeq();
|
|
2758
2801
|
const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
|
|
2759
|
-
|
|
2760
|
-
|
|
2802
|
+
const fd = openSync(this.filePath, "a", 384);
|
|
2803
|
+
try {
|
|
2804
|
+
writeFileSync(fd, JSON.stringify(entry) + `
|
|
2805
|
+
`, "utf8");
|
|
2806
|
+
fsyncSync(fd);
|
|
2807
|
+
} finally {
|
|
2808
|
+
closeSync(fd);
|
|
2809
|
+
}
|
|
2810
|
+
const parent = openSync(dirname(this.filePath), "r");
|
|
2811
|
+
try {
|
|
2812
|
+
fsyncSync(parent);
|
|
2813
|
+
} finally {
|
|
2814
|
+
closeSync(parent);
|
|
2815
|
+
}
|
|
2761
2816
|
return entry;
|
|
2762
2817
|
}
|
|
2763
2818
|
nextSeq() {
|
|
@@ -2774,7 +2829,9 @@ class SessionWAL {
|
|
|
2774
2829
|
let content;
|
|
2775
2830
|
try {
|
|
2776
2831
|
content = readFileSync2(this.filePath, "utf8");
|
|
2777
|
-
} catch {
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
if (error.code !== "ENOENT")
|
|
2834
|
+
throw error;
|
|
2778
2835
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
2779
2836
|
}
|
|
2780
2837
|
if (content.length > MAX_WAL_BYTES) {
|
|
@@ -2868,6 +2925,40 @@ var init_wal = __esm(() => {
|
|
|
2868
2925
|
MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
2869
2926
|
});
|
|
2870
2927
|
|
|
2928
|
+
// src/journal-directory.ts
|
|
2929
|
+
import { accessSync, closeSync as closeSync2, constants as constants2, fstatSync, lstatSync, openSync as openSync2, readFileSync as readFileSync3, readSync } from "fs";
|
|
2930
|
+
import { join as join5 } from "path";
|
|
2931
|
+
function assertJournalDirectory(dataDir2, identity) {
|
|
2932
|
+
try {
|
|
2933
|
+
if (!lstatSync(dataDir2).isDirectory() || !lstatSync(join5(dataDir2, "sessions")).isDirectory())
|
|
2934
|
+
throw new Error;
|
|
2935
|
+
accessSync(dataDir2, constants2.R_OK | constants2.W_OK | constants2.X_OK);
|
|
2936
|
+
accessSync(join5(dataDir2, "sessions"), constants2.R_OK | constants2.W_OK | constants2.X_OK);
|
|
2937
|
+
const marker = openSync2(join5(dataDir2, ".workflows-data-identity"), constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
2938
|
+
try {
|
|
2939
|
+
const stat = fstatSync(marker);
|
|
2940
|
+
if (!stat.isFile() || stat.size > 256 || readFileSync3(marker, "utf8") !== identity + `
|
|
2941
|
+
`)
|
|
2942
|
+
throw new Error;
|
|
2943
|
+
} finally {
|
|
2944
|
+
closeSync2(marker);
|
|
2945
|
+
}
|
|
2946
|
+
for (const file of ["workflows.db", "sessions/session.wal"]) {
|
|
2947
|
+
const fd = openSync2(join5(dataDir2, file), constants2.O_RDWR | constants2.O_NOFOLLOW);
|
|
2948
|
+
try {
|
|
2949
|
+
if (!fstatSync(fd).isFile())
|
|
2950
|
+
throw new Error;
|
|
2951
|
+
readSync(fd, Buffer.alloc(1), 0, 1, 0);
|
|
2952
|
+
} finally {
|
|
2953
|
+
closeSync2(fd);
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
} catch {
|
|
2957
|
+
throw new Error("configured execution journal identity or accessibility check failed");
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
var init_journal_directory = () => {};
|
|
2961
|
+
|
|
2871
2962
|
// src/service.ts
|
|
2872
2963
|
import { createHash as createHash3 } from "crypto";
|
|
2873
2964
|
import { hostname } from "os";
|
|
@@ -2888,6 +2979,14 @@ function envInt(names, fallback) {
|
|
|
2888
2979
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
2889
2980
|
}
|
|
2890
2981
|
function resolveWorkflowsConfig(overrides = {}) {
|
|
2982
|
+
const rawLanes = process.env.HASNA_WORKFLOWS_ENABLED_LANES;
|
|
2983
|
+
const enabledLanes = overrides.enabledLanes ?? (rawLanes === undefined ? LANE_KINDS : rawLanes.trim() === "" ? [] : rawLanes.split(",").map((value) => value.trim()));
|
|
2984
|
+
if (!Array.isArray(enabledLanes) || enabledLanes.some((lane) => !LANE_KINDS.includes(lane)) || new Set(enabledLanes).size !== enabledLanes.length) {
|
|
2985
|
+
throw new Error("HASNA_WORKFLOWS_ENABLED_LANES must be a unique comma-separated subset of claude,codex,cursor,grok; an empty value disables model lanes");
|
|
2986
|
+
}
|
|
2987
|
+
const dataIdentity = overrides.dataIdentity ?? process.env.HASNA_WORKFLOWS_DATA_IDENTITY;
|
|
2988
|
+
if (dataIdentity !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(dataIdentity))
|
|
2989
|
+
throw new Error("HASNA_WORKFLOWS_DATA_IDENTITY must be a nonempty data identity of at most 128 characters");
|
|
2891
2990
|
const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
|
|
2892
2991
|
if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
|
|
2893
2992
|
throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
|
|
@@ -2898,9 +2997,11 @@ function resolveWorkflowsConfig(overrides = {}) {
|
|
|
2898
2997
|
apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
|
|
2899
2998
|
apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
|
|
2900
2999
|
originAuth,
|
|
3000
|
+
dataIdentity,
|
|
2901
3001
|
storageBackend: overrides.storageBackend ?? "postgresql",
|
|
2902
3002
|
databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
|
|
2903
|
-
databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
|
|
3003
|
+
databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"]),
|
|
3004
|
+
enabledLanes: Object.freeze([...enabledLanes])
|
|
2904
3005
|
};
|
|
2905
3006
|
}
|
|
2906
3007
|
|
|
@@ -2933,16 +3034,52 @@ class WorkflowsService {
|
|
|
2933
3034
|
}
|
|
2934
3035
|
async ready() {
|
|
2935
3036
|
const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
|
|
2936
|
-
if (this.config.
|
|
3037
|
+
if (this.config.dataIdentity !== undefined) {
|
|
3038
|
+
try {
|
|
3039
|
+
this.verifyJournalDirectory();
|
|
3040
|
+
checks.journal = "ok";
|
|
3041
|
+
} catch {
|
|
3042
|
+
checks.journal = "unavailable";
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
if (this.config.apiKey && checks.journal !== "unavailable") {
|
|
2937
3046
|
try {
|
|
2938
3047
|
checks.storage = await this.withStore(async (store) => (store instanceof PostgresWorkflowsStore ? await store.checkSchema() : (await store.listTables()).sort().join(",") === "memos,run_nodes,runs") ? "ok" : "invalid-schema");
|
|
2939
3048
|
} catch (error) {
|
|
2940
3049
|
checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
|
|
2941
3050
|
}
|
|
2942
3051
|
}
|
|
2943
|
-
return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
|
|
3052
|
+
return { ok: checks.auth === "ok" && checks.storage === "ok" && checks.journal !== "unavailable", service: this.name, version: this.version, checks };
|
|
3053
|
+
}
|
|
3054
|
+
verifyJournalDirectory() {
|
|
3055
|
+
if (this.config.dataIdentity !== undefined) {
|
|
3056
|
+
try {
|
|
3057
|
+
assertJournalDirectory(this.config.dataDir, this.config.dataIdentity);
|
|
3058
|
+
} catch {
|
|
3059
|
+
throw new WorkflowRequestError("journal_unavailable", "configured execution journal identity or accessibility check failed", 503);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
assertEnabledGraphLanes(graph) {
|
|
3064
|
+
if (!Array.isArray(graph?.nodes))
|
|
3065
|
+
return;
|
|
3066
|
+
for (const node2 of graph.nodes) {
|
|
3067
|
+
if (node2.type === "step" && !node2.command && !this.config.enabledLanes.includes(node2.lane ?? "claude")) {
|
|
3068
|
+
throw new WorkflowRequestError("lane_disabled", "the workflow contains a model lane disabled by server configuration", 403);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
async probeLane(kind) {
|
|
3073
|
+
if (this.config.enabledLanes.includes(kind))
|
|
3074
|
+
return probeLane(kind);
|
|
3075
|
+
const sdk = { claude: "@anthropic-ai/claude-agent-sdk", codex: "@openai/codex-sdk", cursor: "@cursor/sdk", grok: "no npm SDK (measured 2026-08-25)" };
|
|
3076
|
+
return { kind, sdk: sdk[kind], wired: false, reason: "disabled by server configuration" };
|
|
3077
|
+
}
|
|
3078
|
+
laneInventory() {
|
|
3079
|
+
return Promise.all(LANE_KINDS.map((kind) => this.probeLane(kind)));
|
|
2944
3080
|
}
|
|
2945
3081
|
async openEngineStore(verifySchema = true) {
|
|
3082
|
+
this.verifyJournalDirectory();
|
|
2946
3083
|
if (this.config.storageBackend === "sqlite")
|
|
2947
3084
|
return openStore(this.config.dataDir);
|
|
2948
3085
|
if (this.config.storageBackend !== "postgresql")
|
|
@@ -2996,6 +3133,7 @@ class WorkflowsService {
|
|
|
2996
3133
|
return this.withStore((store) => store.listRunNodes(id));
|
|
2997
3134
|
}
|
|
2998
3135
|
events(id) {
|
|
3136
|
+
this.verifyJournalDirectory();
|
|
2999
3137
|
return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
|
|
3000
3138
|
}
|
|
3001
3139
|
cancelRun(id) {
|
|
@@ -3012,7 +3150,6 @@ class WorkflowsService {
|
|
|
3012
3150
|
}
|
|
3013
3151
|
async resumeRun(id) {
|
|
3014
3152
|
const store = await this.openEngineStore();
|
|
3015
|
-
const wal = SessionWAL.open(this.config.dataDir);
|
|
3016
3153
|
try {
|
|
3017
3154
|
const run2 = await store.getRun(id);
|
|
3018
3155
|
if (!run2)
|
|
@@ -3020,13 +3157,15 @@ class WorkflowsService {
|
|
|
3020
3157
|
if (!["failed", "cancelled", "interrupted"].includes(run2.status))
|
|
3021
3158
|
throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
|
|
3022
3159
|
const context = JSON.parse(run2.contextJson);
|
|
3160
|
+
const graph = context.__wf?.graph;
|
|
3161
|
+
if (!graph)
|
|
3162
|
+
throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
|
|
3163
|
+
this.assertEnabledGraphLanes(graph);
|
|
3164
|
+
const wal = SessionWAL.open(this.config.dataDir);
|
|
3023
3165
|
if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
|
|
3024
3166
|
throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
|
|
3025
3167
|
if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
|
|
3026
3168
|
throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
|
|
3027
|
-
const graph = context.__wf?.graph;
|
|
3028
|
-
if (!graph)
|
|
3029
|
-
throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
|
|
3030
3169
|
if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
|
|
3031
3170
|
throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
|
|
3032
3171
|
for (const node2 of await store.listRunNodes(id)) {
|
|
@@ -3040,6 +3179,7 @@ class WorkflowsService {
|
|
|
3040
3179
|
}
|
|
3041
3180
|
}
|
|
3042
3181
|
sessions(action) {
|
|
3182
|
+
this.verifyJournalDirectory();
|
|
3043
3183
|
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3044
3184
|
if (action === "list")
|
|
3045
3185
|
return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
|
|
@@ -3057,6 +3197,7 @@ class WorkflowsService {
|
|
|
3057
3197
|
return { ...local, health: this.health(), sessions: this.sessions("pull") };
|
|
3058
3198
|
}
|
|
3059
3199
|
daemonStatus() {
|
|
3200
|
+
this.verifyJournalDirectory();
|
|
3060
3201
|
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3061
3202
|
return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
|
|
3062
3203
|
}
|
|
@@ -3064,6 +3205,7 @@ class WorkflowsService {
|
|
|
3064
3205
|
return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
|
|
3065
3206
|
}
|
|
3066
3207
|
async triggerRun(graph, context = {}, opts = {}) {
|
|
3208
|
+
this.assertEnabledGraphLanes(graph);
|
|
3067
3209
|
const store = await this.openEngineStore();
|
|
3068
3210
|
const wal = SessionWAL.open(this.config.dataDir);
|
|
3069
3211
|
try {
|
|
@@ -3133,6 +3275,9 @@ var init_service = __esm(() => {
|
|
|
3133
3275
|
init_postgres_store();
|
|
3134
3276
|
init_store();
|
|
3135
3277
|
init_wal();
|
|
3278
|
+
init_journal_directory();
|
|
3279
|
+
init_graph();
|
|
3280
|
+
init_lanes();
|
|
3136
3281
|
init_version();
|
|
3137
3282
|
WorkflowRequestError = class WorkflowRequestError extends Error {
|
|
3138
3283
|
code;
|
|
@@ -3283,7 +3428,7 @@ function buildOpenApi(version) {
|
|
|
3283
3428
|
ReadinessReport: object({ ok: { type: "boolean" }, service: string, version: string, checks: { type: "object", additionalProperties: string } }),
|
|
3284
3429
|
GraphValidation: object({ ok: { type: "boolean" }, issues: { type: "array", items: object({ path: string, message: string }) } }),
|
|
3285
3430
|
StartNode: object({ id: string, type: { type: "string", enum: ["start"] }, next: string }, ["id", "type"]),
|
|
3286
|
-
StepNode: object({ id: string, type: { type: "string", enum: ["step"] }, next: string, lane: { type: "string", enum: ["claude", "codex", "cursor", "grok"] }, prompt: string, command: string, maxRetries: { type: "integer", minimum: 0 }, memo: { type: "boolean" }, timeoutMs: { type: "integer", minimum: 1 } }, ["id", "type"]),
|
|
3431
|
+
StepNode: object({ id: string, type: { type: "string", enum: ["step"] }, next: string, lane: { type: "string", enum: ["claude", "codex", "cursor", "grok"] }, prompt: string, command: string, cwd: { type: "string", minLength: 1 }, codexMode: { type: "string", enum: ["repository", "read-only"], default: "repository", description: "Codex prompt steps only. Read-only permits nonrepository execution with a read-only sandbox and no approvals. Repository retains native trust, sandbox and approval defaults." }, maxRetries: { type: "integer", minimum: 0 }, memo: { type: "boolean" }, timeoutMs: { type: "integer", minimum: 1 } }, ["id", "type"]),
|
|
3287
3432
|
DecisionNode: object({ id: string, type: { type: "string", enum: ["decision"] }, condition: string, then: string, else: string }, ["id", "type", "condition"]),
|
|
3288
3433
|
WhileNode: object({ id: string, type: { type: "string", enum: ["while"] }, condition: string, body: { type: "array", items: string }, maxIterations: { type: "integer", minimum: 1 }, next: string }, ["id", "type", "condition", "body", "maxIterations"]),
|
|
3289
3434
|
EndNode: object({ id: string, type: { type: "string", enum: ["end"] } }),
|
|
@@ -3443,12 +3588,12 @@ function createRequestHandler(service) {
|
|
|
3443
3588
|
return jsonResponse(await service.listRuns({ limit, status }));
|
|
3444
3589
|
}
|
|
3445
3590
|
if (path === "/lanes")
|
|
3446
|
-
return jsonResponse(await laneInventory());
|
|
3591
|
+
return jsonResponse(await service.laneInventory());
|
|
3447
3592
|
if (path.startsWith("/lanes/")) {
|
|
3448
3593
|
const lane = path.slice("/lanes/".length);
|
|
3449
3594
|
if (!LANE_KINDS2.includes(lane))
|
|
3450
3595
|
return jsonResponse({ error: "invalid_lane" }, 400);
|
|
3451
|
-
return jsonResponse(await probeLane(lane));
|
|
3596
|
+
return jsonResponse(await service.probeLane(lane));
|
|
3452
3597
|
}
|
|
3453
3598
|
if (path === "/sessions/list")
|
|
3454
3599
|
return jsonResponse(service.sessions("list"));
|
|
@@ -3524,13 +3669,13 @@ var init_server = __esm(() => {
|
|
|
3524
3669
|
// src/serve/admin.ts
|
|
3525
3670
|
var exports_admin = {};
|
|
3526
3671
|
import { Command } from "commander";
|
|
3527
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as
|
|
3672
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
3528
3673
|
import { hostname as hostname2 } from "os";
|
|
3529
|
-
import { join as
|
|
3674
|
+
import { join as join6 } from "path";
|
|
3530
3675
|
function loadGraph(file) {
|
|
3531
3676
|
let raw;
|
|
3532
3677
|
try {
|
|
3533
|
-
raw =
|
|
3678
|
+
raw = readFileSync4(file, "utf8");
|
|
3534
3679
|
} catch {
|
|
3535
3680
|
throw new Error(`cannot read graph file ${file}`);
|
|
3536
3681
|
}
|
|
@@ -3623,7 +3768,7 @@ function addReapReports(left, right) {
|
|
|
3623
3768
|
return total;
|
|
3624
3769
|
}
|
|
3625
3770
|
function writeReport(statusFile, report) {
|
|
3626
|
-
mkdirSync3(
|
|
3771
|
+
mkdirSync3(join6(statusFile, ".."), { recursive: true });
|
|
3627
3772
|
let previous = emptyReapReport();
|
|
3628
3773
|
if (existsSync3(statusFile)) {
|
|
3629
3774
|
previous = readReport(statusFile).cumulative;
|
|
@@ -3643,7 +3788,7 @@ function writeReport(statusFile, report) {
|
|
|
3643
3788
|
}
|
|
3644
3789
|
}
|
|
3645
3790
|
function readReport(statusFile) {
|
|
3646
|
-
const parsed = JSON.parse(
|
|
3791
|
+
const parsed = JSON.parse(readFileSync4(statusFile, "utf8"));
|
|
3647
3792
|
if (parsed !== null && typeof parsed === "object") {
|
|
3648
3793
|
const record = parsed;
|
|
3649
3794
|
if (record.version === 1 && record.latestCycle !== undefined && record.cumulative !== undefined) {
|
|
@@ -3697,11 +3842,11 @@ var init_admin = __esm(() => {
|
|
|
3697
3842
|
});
|
|
3698
3843
|
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) => {
|
|
3699
3844
|
const config = resolveWorkflowsConfig();
|
|
3700
|
-
mkdirSync3(
|
|
3701
|
-
mkdirSync3(
|
|
3845
|
+
mkdirSync3(join6(config.dataDir, "workflows"), { recursive: true });
|
|
3846
|
+
mkdirSync3(join6(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
3702
3847
|
const store = openStore(config.dataDir);
|
|
3703
3848
|
store.close();
|
|
3704
|
-
const target = file ??
|
|
3849
|
+
const target = file ?? join6(config.dataDir, "workflows", "demo.json");
|
|
3705
3850
|
if (existsSync3(target)) {
|
|
3706
3851
|
console.error(`refusing to overwrite existing file ${target}`);
|
|
3707
3852
|
process.exit(1);
|
|
@@ -4005,17 +4150,17 @@ var init_admin = __esm(() => {
|
|
|
4005
4150
|
}
|
|
4006
4151
|
case "status": {
|
|
4007
4152
|
const replay = wal.replay();
|
|
4008
|
-
const pidFile =
|
|
4009
|
-
const pid = existsSync3(pidFile) ?
|
|
4153
|
+
const pidFile = join6(config.dataDir, "daemon.pid");
|
|
4154
|
+
const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
|
|
4010
4155
|
const running = pid !== null && processAlive(Number(pid));
|
|
4011
4156
|
const report = {
|
|
4012
4157
|
hostname: hostname2(),
|
|
4013
4158
|
pid: process.pid,
|
|
4014
4159
|
dataDir: config.dataDir,
|
|
4015
4160
|
layout: {
|
|
4016
|
-
workflows: existsSync3(
|
|
4017
|
-
sessions: existsSync3(
|
|
4018
|
-
db: existsSync3(
|
|
4161
|
+
workflows: existsSync3(join6(config.dataDir, "workflows")),
|
|
4162
|
+
sessions: existsSync3(join6(config.dataDir, SESSIONS_DIR_NAME)),
|
|
4163
|
+
db: existsSync3(join6(config.dataDir, "workflows.db"))
|
|
4019
4164
|
},
|
|
4020
4165
|
store: {
|
|
4021
4166
|
tables: store.listTables(),
|
|
@@ -4079,8 +4224,8 @@ var init_admin = __esm(() => {
|
|
|
4079
4224
|
});
|
|
4080
4225
|
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) => {
|
|
4081
4226
|
const config = resolveWorkflowsConfig();
|
|
4082
|
-
const pidFile =
|
|
4083
|
-
const statusFile =
|
|
4227
|
+
const pidFile = join6(config.dataDir, "daemon.pid");
|
|
4228
|
+
const statusFile = join6(config.dataDir, "daemon.status.json");
|
|
4084
4229
|
switch (action) {
|
|
4085
4230
|
case "start": {
|
|
4086
4231
|
const { store, wal } = openLocal();
|
|
@@ -4137,7 +4282,7 @@ var init_admin = __esm(() => {
|
|
|
4137
4282
|
return;
|
|
4138
4283
|
}
|
|
4139
4284
|
const envelope = readReport(statusFile);
|
|
4140
|
-
const pid = existsSync3(pidFile) ?
|
|
4285
|
+
const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
|
|
4141
4286
|
const running = pid !== null && processAlive(Number(pid));
|
|
4142
4287
|
if (opts.json) {
|
|
4143
4288
|
console.log(JSON.stringify({ statusVersion: 1, running, pid, lastReap: envelope.latestCycle, cumulative: envelope.cumulative }, null, 2));
|
|
@@ -4151,7 +4296,7 @@ var init_admin = __esm(() => {
|
|
|
4151
4296
|
console.error("no daemon pid file \u2014 nothing to stop");
|
|
4152
4297
|
process.exit(1);
|
|
4153
4298
|
}
|
|
4154
|
-
const pid = Number(
|
|
4299
|
+
const pid = Number(readFileSync4(pidFile, "utf8").trim());
|
|
4155
4300
|
try {
|
|
4156
4301
|
process.kill(pid, "SIGTERM");
|
|
4157
4302
|
} catch {
|
|
@@ -4249,6 +4394,35 @@ var init_admin = __esm(() => {
|
|
|
4249
4394
|
// src/serve/index.ts
|
|
4250
4395
|
init_service();
|
|
4251
4396
|
init_server();
|
|
4397
|
+
|
|
4398
|
+
// src/serve/journal-lease.ts
|
|
4399
|
+
init_journal_directory();
|
|
4400
|
+
import { Database as Database3 } from "bun:sqlite";
|
|
4401
|
+
import { join as join7 } from "path";
|
|
4402
|
+
function acquireJournalServerLease(dataDir2, identity) {
|
|
4403
|
+
assertJournalDirectory(dataDir2, identity);
|
|
4404
|
+
const mutex = new Database3(join7(dataDir2, ".server-lease.sqlite"));
|
|
4405
|
+
try {
|
|
4406
|
+
mutex.exec("PRAGMA busy_timeout = 0");
|
|
4407
|
+
mutex.exec("BEGIN IMMEDIATE");
|
|
4408
|
+
} catch {
|
|
4409
|
+
mutex.close();
|
|
4410
|
+
throw new Error("execution journal is already leased or unavailable; refusing to start another server");
|
|
4411
|
+
}
|
|
4412
|
+
let closed = false;
|
|
4413
|
+
return { close() {
|
|
4414
|
+
if (closed)
|
|
4415
|
+
return;
|
|
4416
|
+
closed = true;
|
|
4417
|
+
try {
|
|
4418
|
+
mutex.exec("ROLLBACK");
|
|
4419
|
+
} finally {
|
|
4420
|
+
mutex.close();
|
|
4421
|
+
}
|
|
4422
|
+
} };
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
// src/serve/index.ts
|
|
4252
4426
|
var HELP_TEXT = `workflows-serve \u2014 HTTP server for @hasna/workflows
|
|
4253
4427
|
|
|
4254
4428
|
Usage:
|
|
@@ -4271,6 +4445,8 @@ Configuration (env):
|
|
|
4271
4445
|
HASNA_WORKFLOWS_DATABASE_URL required PostgreSQL connection URL
|
|
4272
4446
|
HASNA_WORKFLOWS_DATABASE_SCHEMA SQL schema (default workflows)
|
|
4273
4447
|
HASNA_WORKFLOWS_DATA_DIR durable execution journal directory
|
|
4448
|
+
HASNA_WORKFLOWS_DATA_IDENTITY require a verified restored journal and one server writer
|
|
4449
|
+
HASNA_WORKFLOWS_ENABLED_LANES optional comma-separated model lanes; empty permits command steps only
|
|
4274
4450
|
WORKFLOWS_ aliases remain supported; database URL aliases must agree`;
|
|
4275
4451
|
var argv = process.argv.slice(2);
|
|
4276
4452
|
if (argv[0] === "admin") {
|
|
@@ -4299,6 +4475,16 @@ if (argv[0] === "admin") {
|
|
|
4299
4475
|
console.error("unknown workflows-serve argument; use --help");
|
|
4300
4476
|
process.exit(2);
|
|
4301
4477
|
}
|
|
4478
|
+
let lease;
|
|
4479
|
+
if (service.config.dataIdentity !== undefined) {
|
|
4480
|
+
try {
|
|
4481
|
+
lease = acquireJournalServerLease(service.config.dataDir, service.config.dataIdentity);
|
|
4482
|
+
} catch {
|
|
4483
|
+
console.error("execution journal identity, access or exclusive server lease refused");
|
|
4484
|
+
process.exit(1);
|
|
4485
|
+
}
|
|
4486
|
+
process.once("exit", () => lease?.close());
|
|
4487
|
+
}
|
|
4302
4488
|
const server = createWorkflowsServer(service);
|
|
4303
4489
|
console.log(`workflows-serve listening on http://${service.config.host}:${server.port}`);
|
|
4304
4490
|
}
|