@hasna/workflows 0.2.1 → 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 +25 -0
- package/dist/agent-tools.js +1 -506
- package/dist/cli/index.js +11 -11
- package/dist/handlers.js +115 -15
- package/dist/index.js +116 -17
- package/dist/mcp/index.js +3 -3
- package/dist/sdk.js +3 -3
- package/dist/serve/admin.js +134 -32
- package/dist/serve/index.js +175 -32
- package/dist/service.js +113 -13
- package/hasna.contract.json +1 -1
- package/package.json +2 -2
- package/types/journal-directory.d.ts +4 -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,7 +98,7 @@ 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
|
-
"@hasna/contracts": "1.3.
|
|
101
|
+
"@hasna/contracts": "1.3.5",
|
|
102
102
|
"@openai/codex-sdk": "0.160.0",
|
|
103
103
|
commander: "^13.1.0",
|
|
104
104
|
pg: "^8.16.3"
|
|
@@ -2732,7 +2732,7 @@ var init_store = __esm(() => {
|
|
|
2732
2732
|
// src/wal.ts
|
|
2733
2733
|
import { Database as Database2 } from "bun:sqlite";
|
|
2734
2734
|
import { createHash as createHash2 } from "crypto";
|
|
2735
|
-
import {
|
|
2735
|
+
import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
|
|
2736
2736
|
import { basename, dirname, join as join4 } from "path";
|
|
2737
2737
|
function checksumOf(op) {
|
|
2738
2738
|
if (op === undefined || op === null)
|
|
@@ -2799,8 +2799,20 @@ class SessionWAL {
|
|
|
2799
2799
|
const full = { ...op, at };
|
|
2800
2800
|
const nextSeq = this.nextSeq();
|
|
2801
2801
|
const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
|
|
2802
|
-
|
|
2803
|
-
|
|
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
|
+
}
|
|
2804
2816
|
return entry;
|
|
2805
2817
|
}
|
|
2806
2818
|
nextSeq() {
|
|
@@ -2817,7 +2829,9 @@ class SessionWAL {
|
|
|
2817
2829
|
let content;
|
|
2818
2830
|
try {
|
|
2819
2831
|
content = readFileSync2(this.filePath, "utf8");
|
|
2820
|
-
} catch {
|
|
2832
|
+
} catch (error) {
|
|
2833
|
+
if (error.code !== "ENOENT")
|
|
2834
|
+
throw error;
|
|
2821
2835
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
2822
2836
|
}
|
|
2823
2837
|
if (content.length > MAX_WAL_BYTES) {
|
|
@@ -2911,6 +2925,40 @@ var init_wal = __esm(() => {
|
|
|
2911
2925
|
MAX_WAL_BYTES = 256 * 1024 * 1024;
|
|
2912
2926
|
});
|
|
2913
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
|
+
|
|
2914
2962
|
// src/service.ts
|
|
2915
2963
|
import { createHash as createHash3 } from "crypto";
|
|
2916
2964
|
import { hostname } from "os";
|
|
@@ -2931,6 +2979,14 @@ function envInt(names, fallback) {
|
|
|
2931
2979
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
2932
2980
|
}
|
|
2933
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");
|
|
2934
2990
|
const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
|
|
2935
2991
|
if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
|
|
2936
2992
|
throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
|
|
@@ -2941,9 +2997,11 @@ function resolveWorkflowsConfig(overrides = {}) {
|
|
|
2941
2997
|
apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
|
|
2942
2998
|
apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
|
|
2943
2999
|
originAuth,
|
|
3000
|
+
dataIdentity,
|
|
2944
3001
|
storageBackend: overrides.storageBackend ?? "postgresql",
|
|
2945
3002
|
databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
|
|
2946
|
-
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])
|
|
2947
3005
|
};
|
|
2948
3006
|
}
|
|
2949
3007
|
|
|
@@ -2976,16 +3034,52 @@ class WorkflowsService {
|
|
|
2976
3034
|
}
|
|
2977
3035
|
async ready() {
|
|
2978
3036
|
const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
|
|
2979
|
-
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") {
|
|
2980
3046
|
try {
|
|
2981
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");
|
|
2982
3048
|
} catch (error) {
|
|
2983
3049
|
checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
|
|
2984
3050
|
}
|
|
2985
3051
|
}
|
|
2986
|
-
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)));
|
|
2987
3080
|
}
|
|
2988
3081
|
async openEngineStore(verifySchema = true) {
|
|
3082
|
+
this.verifyJournalDirectory();
|
|
2989
3083
|
if (this.config.storageBackend === "sqlite")
|
|
2990
3084
|
return openStore(this.config.dataDir);
|
|
2991
3085
|
if (this.config.storageBackend !== "postgresql")
|
|
@@ -3039,6 +3133,7 @@ class WorkflowsService {
|
|
|
3039
3133
|
return this.withStore((store) => store.listRunNodes(id));
|
|
3040
3134
|
}
|
|
3041
3135
|
events(id) {
|
|
3136
|
+
this.verifyJournalDirectory();
|
|
3042
3137
|
return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
|
|
3043
3138
|
}
|
|
3044
3139
|
cancelRun(id) {
|
|
@@ -3055,7 +3150,6 @@ class WorkflowsService {
|
|
|
3055
3150
|
}
|
|
3056
3151
|
async resumeRun(id) {
|
|
3057
3152
|
const store = await this.openEngineStore();
|
|
3058
|
-
const wal = SessionWAL.open(this.config.dataDir);
|
|
3059
3153
|
try {
|
|
3060
3154
|
const run2 = await store.getRun(id);
|
|
3061
3155
|
if (!run2)
|
|
@@ -3063,13 +3157,15 @@ class WorkflowsService {
|
|
|
3063
3157
|
if (!["failed", "cancelled", "interrupted"].includes(run2.status))
|
|
3064
3158
|
throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
|
|
3065
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);
|
|
3066
3165
|
if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
|
|
3067
3166
|
throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
|
|
3068
3167
|
if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
|
|
3069
3168
|
throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
|
|
3070
|
-
const graph = context.__wf?.graph;
|
|
3071
|
-
if (!graph)
|
|
3072
|
-
throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
|
|
3073
3169
|
if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
|
|
3074
3170
|
throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
|
|
3075
3171
|
for (const node2 of await store.listRunNodes(id)) {
|
|
@@ -3083,6 +3179,7 @@ class WorkflowsService {
|
|
|
3083
3179
|
}
|
|
3084
3180
|
}
|
|
3085
3181
|
sessions(action) {
|
|
3182
|
+
this.verifyJournalDirectory();
|
|
3086
3183
|
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3087
3184
|
if (action === "list")
|
|
3088
3185
|
return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
|
|
@@ -3100,6 +3197,7 @@ class WorkflowsService {
|
|
|
3100
3197
|
return { ...local, health: this.health(), sessions: this.sessions("pull") };
|
|
3101
3198
|
}
|
|
3102
3199
|
daemonStatus() {
|
|
3200
|
+
this.verifyJournalDirectory();
|
|
3103
3201
|
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3104
3202
|
return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
|
|
3105
3203
|
}
|
|
@@ -3107,6 +3205,7 @@ class WorkflowsService {
|
|
|
3107
3205
|
return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
|
|
3108
3206
|
}
|
|
3109
3207
|
async triggerRun(graph, context = {}, opts = {}) {
|
|
3208
|
+
this.assertEnabledGraphLanes(graph);
|
|
3110
3209
|
const store = await this.openEngineStore();
|
|
3111
3210
|
const wal = SessionWAL.open(this.config.dataDir);
|
|
3112
3211
|
try {
|
|
@@ -3176,6 +3275,9 @@ var init_service = __esm(() => {
|
|
|
3176
3275
|
init_postgres_store();
|
|
3177
3276
|
init_store();
|
|
3178
3277
|
init_wal();
|
|
3278
|
+
init_journal_directory();
|
|
3279
|
+
init_graph();
|
|
3280
|
+
init_lanes();
|
|
3179
3281
|
init_version();
|
|
3180
3282
|
WorkflowRequestError = class WorkflowRequestError extends Error {
|
|
3181
3283
|
code;
|
|
@@ -3486,12 +3588,12 @@ function createRequestHandler(service) {
|
|
|
3486
3588
|
return jsonResponse(await service.listRuns({ limit, status }));
|
|
3487
3589
|
}
|
|
3488
3590
|
if (path === "/lanes")
|
|
3489
|
-
return jsonResponse(await laneInventory());
|
|
3591
|
+
return jsonResponse(await service.laneInventory());
|
|
3490
3592
|
if (path.startsWith("/lanes/")) {
|
|
3491
3593
|
const lane = path.slice("/lanes/".length);
|
|
3492
3594
|
if (!LANE_KINDS2.includes(lane))
|
|
3493
3595
|
return jsonResponse({ error: "invalid_lane" }, 400);
|
|
3494
|
-
return jsonResponse(await probeLane(lane));
|
|
3596
|
+
return jsonResponse(await service.probeLane(lane));
|
|
3495
3597
|
}
|
|
3496
3598
|
if (path === "/sessions/list")
|
|
3497
3599
|
return jsonResponse(service.sessions("list"));
|
|
@@ -3567,13 +3669,13 @@ var init_server = __esm(() => {
|
|
|
3567
3669
|
// src/serve/admin.ts
|
|
3568
3670
|
var exports_admin = {};
|
|
3569
3671
|
import { Command } from "commander";
|
|
3570
|
-
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";
|
|
3571
3673
|
import { hostname as hostname2 } from "os";
|
|
3572
|
-
import { join as
|
|
3674
|
+
import { join as join6 } from "path";
|
|
3573
3675
|
function loadGraph(file) {
|
|
3574
3676
|
let raw;
|
|
3575
3677
|
try {
|
|
3576
|
-
raw =
|
|
3678
|
+
raw = readFileSync4(file, "utf8");
|
|
3577
3679
|
} catch {
|
|
3578
3680
|
throw new Error(`cannot read graph file ${file}`);
|
|
3579
3681
|
}
|
|
@@ -3666,7 +3768,7 @@ function addReapReports(left, right) {
|
|
|
3666
3768
|
return total;
|
|
3667
3769
|
}
|
|
3668
3770
|
function writeReport(statusFile, report) {
|
|
3669
|
-
mkdirSync3(
|
|
3771
|
+
mkdirSync3(join6(statusFile, ".."), { recursive: true });
|
|
3670
3772
|
let previous = emptyReapReport();
|
|
3671
3773
|
if (existsSync3(statusFile)) {
|
|
3672
3774
|
previous = readReport(statusFile).cumulative;
|
|
@@ -3686,7 +3788,7 @@ function writeReport(statusFile, report) {
|
|
|
3686
3788
|
}
|
|
3687
3789
|
}
|
|
3688
3790
|
function readReport(statusFile) {
|
|
3689
|
-
const parsed = JSON.parse(
|
|
3791
|
+
const parsed = JSON.parse(readFileSync4(statusFile, "utf8"));
|
|
3690
3792
|
if (parsed !== null && typeof parsed === "object") {
|
|
3691
3793
|
const record = parsed;
|
|
3692
3794
|
if (record.version === 1 && record.latestCycle !== undefined && record.cumulative !== undefined) {
|
|
@@ -3740,11 +3842,11 @@ var init_admin = __esm(() => {
|
|
|
3740
3842
|
});
|
|
3741
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) => {
|
|
3742
3844
|
const config = resolveWorkflowsConfig();
|
|
3743
|
-
mkdirSync3(
|
|
3744
|
-
mkdirSync3(
|
|
3845
|
+
mkdirSync3(join6(config.dataDir, "workflows"), { recursive: true });
|
|
3846
|
+
mkdirSync3(join6(config.dataDir, SESSIONS_DIR_NAME), { recursive: true });
|
|
3745
3847
|
const store = openStore(config.dataDir);
|
|
3746
3848
|
store.close();
|
|
3747
|
-
const target = file ??
|
|
3849
|
+
const target = file ?? join6(config.dataDir, "workflows", "demo.json");
|
|
3748
3850
|
if (existsSync3(target)) {
|
|
3749
3851
|
console.error(`refusing to overwrite existing file ${target}`);
|
|
3750
3852
|
process.exit(1);
|
|
@@ -4048,17 +4150,17 @@ var init_admin = __esm(() => {
|
|
|
4048
4150
|
}
|
|
4049
4151
|
case "status": {
|
|
4050
4152
|
const replay = wal.replay();
|
|
4051
|
-
const pidFile =
|
|
4052
|
-
const pid = existsSync3(pidFile) ?
|
|
4153
|
+
const pidFile = join6(config.dataDir, "daemon.pid");
|
|
4154
|
+
const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
|
|
4053
4155
|
const running = pid !== null && processAlive(Number(pid));
|
|
4054
4156
|
const report = {
|
|
4055
4157
|
hostname: hostname2(),
|
|
4056
4158
|
pid: process.pid,
|
|
4057
4159
|
dataDir: config.dataDir,
|
|
4058
4160
|
layout: {
|
|
4059
|
-
workflows: existsSync3(
|
|
4060
|
-
sessions: existsSync3(
|
|
4061
|
-
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"))
|
|
4062
4164
|
},
|
|
4063
4165
|
store: {
|
|
4064
4166
|
tables: store.listTables(),
|
|
@@ -4122,8 +4224,8 @@ var init_admin = __esm(() => {
|
|
|
4122
4224
|
});
|
|
4123
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) => {
|
|
4124
4226
|
const config = resolveWorkflowsConfig();
|
|
4125
|
-
const pidFile =
|
|
4126
|
-
const statusFile =
|
|
4227
|
+
const pidFile = join6(config.dataDir, "daemon.pid");
|
|
4228
|
+
const statusFile = join6(config.dataDir, "daemon.status.json");
|
|
4127
4229
|
switch (action) {
|
|
4128
4230
|
case "start": {
|
|
4129
4231
|
const { store, wal } = openLocal();
|
|
@@ -4180,7 +4282,7 @@ var init_admin = __esm(() => {
|
|
|
4180
4282
|
return;
|
|
4181
4283
|
}
|
|
4182
4284
|
const envelope = readReport(statusFile);
|
|
4183
|
-
const pid = existsSync3(pidFile) ?
|
|
4285
|
+
const pid = existsSync3(pidFile) ? readFileSync4(pidFile, "utf8").trim() : null;
|
|
4184
4286
|
const running = pid !== null && processAlive(Number(pid));
|
|
4185
4287
|
if (opts.json) {
|
|
4186
4288
|
console.log(JSON.stringify({ statusVersion: 1, running, pid, lastReap: envelope.latestCycle, cumulative: envelope.cumulative }, null, 2));
|
|
@@ -4194,7 +4296,7 @@ var init_admin = __esm(() => {
|
|
|
4194
4296
|
console.error("no daemon pid file \u2014 nothing to stop");
|
|
4195
4297
|
process.exit(1);
|
|
4196
4298
|
}
|
|
4197
|
-
const pid = Number(
|
|
4299
|
+
const pid = Number(readFileSync4(pidFile, "utf8").trim());
|
|
4198
4300
|
try {
|
|
4199
4301
|
process.kill(pid, "SIGTERM");
|
|
4200
4302
|
} catch {
|
|
@@ -4292,6 +4394,35 @@ var init_admin = __esm(() => {
|
|
|
4292
4394
|
// src/serve/index.ts
|
|
4293
4395
|
init_service();
|
|
4294
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
|
|
4295
4426
|
var HELP_TEXT = `workflows-serve \u2014 HTTP server for @hasna/workflows
|
|
4296
4427
|
|
|
4297
4428
|
Usage:
|
|
@@ -4314,6 +4445,8 @@ Configuration (env):
|
|
|
4314
4445
|
HASNA_WORKFLOWS_DATABASE_URL required PostgreSQL connection URL
|
|
4315
4446
|
HASNA_WORKFLOWS_DATABASE_SCHEMA SQL schema (default workflows)
|
|
4316
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
|
|
4317
4450
|
WORKFLOWS_ aliases remain supported; database URL aliases must agree`;
|
|
4318
4451
|
var argv = process.argv.slice(2);
|
|
4319
4452
|
if (argv[0] === "admin") {
|
|
@@ -4342,6 +4475,16 @@ if (argv[0] === "admin") {
|
|
|
4342
4475
|
console.error("unknown workflows-serve argument; use --help");
|
|
4343
4476
|
process.exit(2);
|
|
4344
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
|
+
}
|
|
4345
4488
|
const server = createWorkflowsServer(service);
|
|
4346
4489
|
console.log(`workflows-serve listening on http://${service.config.host}:${server.port}`);
|
|
4347
4490
|
}
|
package/dist/service.js
CHANGED
|
@@ -1108,7 +1108,7 @@ import { createHash as createHash3 } from "crypto";
|
|
|
1108
1108
|
// package.json
|
|
1109
1109
|
var package_default = {
|
|
1110
1110
|
name: "@hasna/workflows",
|
|
1111
|
-
version: "0.2.
|
|
1111
|
+
version: "0.2.2",
|
|
1112
1112
|
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",
|
|
1113
1113
|
type: "module",
|
|
1114
1114
|
main: "./dist/index.js",
|
|
@@ -1184,7 +1184,7 @@ var package_default = {
|
|
|
1184
1184
|
dependencies: {
|
|
1185
1185
|
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
1186
1186
|
"@cursor/sdk": "^1.0.28",
|
|
1187
|
-
"@hasna/contracts": "1.3.
|
|
1187
|
+
"@hasna/contracts": "1.3.5",
|
|
1188
1188
|
"@openai/codex-sdk": "0.160.0",
|
|
1189
1189
|
commander: "^13.1.0",
|
|
1190
1190
|
pg: "^8.16.3"
|
|
@@ -2695,7 +2695,7 @@ function openStore(dataDir2) {
|
|
|
2695
2695
|
// src/wal.ts
|
|
2696
2696
|
import { Database as Database2 } from "bun:sqlite";
|
|
2697
2697
|
import { createHash as createHash2 } from "crypto";
|
|
2698
|
-
import {
|
|
2698
|
+
import { closeSync, copyFileSync, existsSync as existsSync2, fsyncSync, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, renameSync, truncateSync, writeFileSync, constants } from "fs";
|
|
2699
2699
|
import { basename, dirname, join as join4 } from "path";
|
|
2700
2700
|
var WAL_FILE_NAME = "session.wal";
|
|
2701
2701
|
var SESSIONS_DIR_NAME = "sessions";
|
|
@@ -2765,8 +2765,20 @@ class SessionWAL {
|
|
|
2765
2765
|
const full = { ...op, at };
|
|
2766
2766
|
const nextSeq = this.nextSeq();
|
|
2767
2767
|
const entry = { seq: nextSeq, at, checksum: checksumOf(full), op: full };
|
|
2768
|
-
|
|
2769
|
-
|
|
2768
|
+
const fd = openSync(this.filePath, "a", 384);
|
|
2769
|
+
try {
|
|
2770
|
+
writeFileSync(fd, JSON.stringify(entry) + `
|
|
2771
|
+
`, "utf8");
|
|
2772
|
+
fsyncSync(fd);
|
|
2773
|
+
} finally {
|
|
2774
|
+
closeSync(fd);
|
|
2775
|
+
}
|
|
2776
|
+
const parent = openSync(dirname(this.filePath), "r");
|
|
2777
|
+
try {
|
|
2778
|
+
fsyncSync(parent);
|
|
2779
|
+
} finally {
|
|
2780
|
+
closeSync(parent);
|
|
2781
|
+
}
|
|
2770
2782
|
return entry;
|
|
2771
2783
|
}
|
|
2772
2784
|
nextSeq() {
|
|
@@ -2783,7 +2795,9 @@ class SessionWAL {
|
|
|
2783
2795
|
let content;
|
|
2784
2796
|
try {
|
|
2785
2797
|
content = readFileSync2(this.filePath, "utf8");
|
|
2786
|
-
} catch {
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
if (error.code !== "ENOENT")
|
|
2800
|
+
throw error;
|
|
2787
2801
|
return { entries, torn, repaired, liveClaims: () => new Map };
|
|
2788
2802
|
}
|
|
2789
2803
|
if (content.length > MAX_WAL_BYTES) {
|
|
@@ -2873,7 +2887,42 @@ function preserveOriginal(filePath) {
|
|
|
2873
2887
|
return backup;
|
|
2874
2888
|
}
|
|
2875
2889
|
|
|
2890
|
+
// src/journal-directory.ts
|
|
2891
|
+
import { accessSync, closeSync as closeSync2, constants as constants2, fstatSync, lstatSync, openSync as openSync2, readFileSync as readFileSync3, readSync } from "fs";
|
|
2892
|
+
import { join as join5 } from "path";
|
|
2893
|
+
function assertJournalDirectory(dataDir2, identity) {
|
|
2894
|
+
try {
|
|
2895
|
+
if (!lstatSync(dataDir2).isDirectory() || !lstatSync(join5(dataDir2, "sessions")).isDirectory())
|
|
2896
|
+
throw new Error;
|
|
2897
|
+
accessSync(dataDir2, constants2.R_OK | constants2.W_OK | constants2.X_OK);
|
|
2898
|
+
accessSync(join5(dataDir2, "sessions"), constants2.R_OK | constants2.W_OK | constants2.X_OK);
|
|
2899
|
+
const marker = openSync2(join5(dataDir2, ".workflows-data-identity"), constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
2900
|
+
try {
|
|
2901
|
+
const stat = fstatSync(marker);
|
|
2902
|
+
if (!stat.isFile() || stat.size > 256 || readFileSync3(marker, "utf8") !== identity + `
|
|
2903
|
+
`)
|
|
2904
|
+
throw new Error;
|
|
2905
|
+
} finally {
|
|
2906
|
+
closeSync2(marker);
|
|
2907
|
+
}
|
|
2908
|
+
for (const file of ["workflows.db", "sessions/session.wal"]) {
|
|
2909
|
+
const fd = openSync2(join5(dataDir2, file), constants2.O_RDWR | constants2.O_NOFOLLOW);
|
|
2910
|
+
try {
|
|
2911
|
+
if (!fstatSync(fd).isFile())
|
|
2912
|
+
throw new Error;
|
|
2913
|
+
readSync(fd, Buffer.alloc(1), 0, 1, 0);
|
|
2914
|
+
} finally {
|
|
2915
|
+
closeSync2(fd);
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
} catch {
|
|
2919
|
+
throw new Error("configured execution journal identity or accessibility check failed");
|
|
2920
|
+
}
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2876
2923
|
// src/service.ts
|
|
2924
|
+
init_graph();
|
|
2925
|
+
init_lanes();
|
|
2877
2926
|
import { hostname } from "os";
|
|
2878
2927
|
import { resolveDatabaseUrl } from "@hasna/contracts";
|
|
2879
2928
|
var DEFAULT_PORT = 8790;
|
|
@@ -2894,6 +2943,14 @@ function envInt(names, fallback) {
|
|
|
2894
2943
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
2895
2944
|
}
|
|
2896
2945
|
function resolveWorkflowsConfig(overrides = {}) {
|
|
2946
|
+
const rawLanes = process.env.HASNA_WORKFLOWS_ENABLED_LANES;
|
|
2947
|
+
const enabledLanes = overrides.enabledLanes ?? (rawLanes === undefined ? LANE_KINDS : rawLanes.trim() === "" ? [] : rawLanes.split(",").map((value) => value.trim()));
|
|
2948
|
+
if (!Array.isArray(enabledLanes) || enabledLanes.some((lane) => !LANE_KINDS.includes(lane)) || new Set(enabledLanes).size !== enabledLanes.length) {
|
|
2949
|
+
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");
|
|
2950
|
+
}
|
|
2951
|
+
const dataIdentity = overrides.dataIdentity ?? process.env.HASNA_WORKFLOWS_DATA_IDENTITY;
|
|
2952
|
+
if (dataIdentity !== undefined && !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(dataIdentity))
|
|
2953
|
+
throw new Error("HASNA_WORKFLOWS_DATA_IDENTITY must be a nonempty data identity of at most 128 characters");
|
|
2897
2954
|
const originAuth = overrides.originAuth ?? process.env.HASNA_WORKFLOWS_ORIGIN_AUTH;
|
|
2898
2955
|
if (originAuth !== undefined && !/^[A-Za-z0-9_-]{32,256}$/.test(originAuth))
|
|
2899
2956
|
throw new Error("HASNA_WORKFLOWS_ORIGIN_AUTH must be a valid gateway origin identity");
|
|
@@ -2904,9 +2961,11 @@ function resolveWorkflowsConfig(overrides = {}) {
|
|
|
2904
2961
|
apiUrl: overrides.apiUrl ?? envString(["HASNA_WORKFLOWS_API_URL", "WORKFLOWS_API_URL"]),
|
|
2905
2962
|
apiKey: overrides.apiKey ?? envString(["HASNA_WORKFLOWS_API_KEY", "WORKFLOWS_API_KEY"]),
|
|
2906
2963
|
originAuth,
|
|
2964
|
+
dataIdentity,
|
|
2907
2965
|
storageBackend: overrides.storageBackend ?? "postgresql",
|
|
2908
2966
|
databaseUrl: overrides.databaseUrl ?? envString(["HASNA_WORKFLOWS_DATABASE_URL", "WORKFLOWS_DATABASE_URL"]),
|
|
2909
|
-
databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"])
|
|
2967
|
+
databaseSchema: overrides.databaseSchema ?? envString(["HASNA_WORKFLOWS_DATABASE_SCHEMA", "WORKFLOWS_DATABASE_SCHEMA"]),
|
|
2968
|
+
enabledLanes: Object.freeze([...enabledLanes])
|
|
2910
2969
|
};
|
|
2911
2970
|
}
|
|
2912
2971
|
|
|
@@ -2939,16 +2998,52 @@ class WorkflowsService {
|
|
|
2939
2998
|
}
|
|
2940
2999
|
async ready() {
|
|
2941
3000
|
const checks = { version: "ok", auth: this.config.apiKey ? "ok" : "missing", storage: "not-checked" };
|
|
2942
|
-
if (this.config.
|
|
3001
|
+
if (this.config.dataIdentity !== undefined) {
|
|
3002
|
+
try {
|
|
3003
|
+
this.verifyJournalDirectory();
|
|
3004
|
+
checks.journal = "ok";
|
|
3005
|
+
} catch {
|
|
3006
|
+
checks.journal = "unavailable";
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
if (this.config.apiKey && checks.journal !== "unavailable") {
|
|
2943
3010
|
try {
|
|
2944
3011
|
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");
|
|
2945
3012
|
} catch (error) {
|
|
2946
3013
|
checks.storage = error instanceof WorkflowRequestError && error.code === "invalid_schema" ? "invalid-schema" : "unavailable";
|
|
2947
3014
|
}
|
|
2948
3015
|
}
|
|
2949
|
-
return { ok: checks.auth === "ok" && checks.storage === "ok", service: this.name, version: this.version, checks };
|
|
3016
|
+
return { ok: checks.auth === "ok" && checks.storage === "ok" && checks.journal !== "unavailable", service: this.name, version: this.version, checks };
|
|
3017
|
+
}
|
|
3018
|
+
verifyJournalDirectory() {
|
|
3019
|
+
if (this.config.dataIdentity !== undefined) {
|
|
3020
|
+
try {
|
|
3021
|
+
assertJournalDirectory(this.config.dataDir, this.config.dataIdentity);
|
|
3022
|
+
} catch {
|
|
3023
|
+
throw new WorkflowRequestError("journal_unavailable", "configured execution journal identity or accessibility check failed", 503);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
assertEnabledGraphLanes(graph) {
|
|
3028
|
+
if (!Array.isArray(graph?.nodes))
|
|
3029
|
+
return;
|
|
3030
|
+
for (const node2 of graph.nodes) {
|
|
3031
|
+
if (node2.type === "step" && !node2.command && !this.config.enabledLanes.includes(node2.lane ?? "claude")) {
|
|
3032
|
+
throw new WorkflowRequestError("lane_disabled", "the workflow contains a model lane disabled by server configuration", 403);
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
}
|
|
3036
|
+
async probeLane(kind) {
|
|
3037
|
+
if (this.config.enabledLanes.includes(kind))
|
|
3038
|
+
return probeLane(kind);
|
|
3039
|
+
const sdk = { claude: "@anthropic-ai/claude-agent-sdk", codex: "@openai/codex-sdk", cursor: "@cursor/sdk", grok: "no npm SDK (measured 2026-08-25)" };
|
|
3040
|
+
return { kind, sdk: sdk[kind], wired: false, reason: "disabled by server configuration" };
|
|
3041
|
+
}
|
|
3042
|
+
laneInventory() {
|
|
3043
|
+
return Promise.all(LANE_KINDS.map((kind) => this.probeLane(kind)));
|
|
2950
3044
|
}
|
|
2951
3045
|
async openEngineStore(verifySchema = true) {
|
|
3046
|
+
this.verifyJournalDirectory();
|
|
2952
3047
|
if (this.config.storageBackend === "sqlite")
|
|
2953
3048
|
return openStore(this.config.dataDir);
|
|
2954
3049
|
if (this.config.storageBackend !== "postgresql")
|
|
@@ -3002,6 +3097,7 @@ class WorkflowsService {
|
|
|
3002
3097
|
return this.withStore((store) => store.listRunNodes(id));
|
|
3003
3098
|
}
|
|
3004
3099
|
events(id) {
|
|
3100
|
+
this.verifyJournalDirectory();
|
|
3005
3101
|
return SessionWAL.open(this.config.dataDir).replay({ repair: false }).entries.filter((entry) => ("runId" in entry.op) && entry.op.runId === id);
|
|
3006
3102
|
}
|
|
3007
3103
|
cancelRun(id) {
|
|
@@ -3018,7 +3114,6 @@ class WorkflowsService {
|
|
|
3018
3114
|
}
|
|
3019
3115
|
async resumeRun(id) {
|
|
3020
3116
|
const store = await this.openEngineStore();
|
|
3021
|
-
const wal = SessionWAL.open(this.config.dataDir);
|
|
3022
3117
|
try {
|
|
3023
3118
|
const run2 = await store.getRun(id);
|
|
3024
3119
|
if (!run2)
|
|
@@ -3026,13 +3121,15 @@ class WorkflowsService {
|
|
|
3026
3121
|
if (!["failed", "cancelled", "interrupted"].includes(run2.status))
|
|
3027
3122
|
throw new WorkflowRequestError("invalid_run_status", "resume requires a failed, cancelled, or interrupted run", 409);
|
|
3028
3123
|
const context = JSON.parse(run2.contextJson);
|
|
3124
|
+
const graph = context.__wf?.graph;
|
|
3125
|
+
if (!graph)
|
|
3126
|
+
throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
|
|
3127
|
+
this.assertEnabledGraphLanes(graph);
|
|
3128
|
+
const wal = SessionWAL.open(this.config.dataDir);
|
|
3029
3129
|
if (context.__wf?.resumeBlocked || context.__wf?.executionMayBeActive || wal.replay({ repair: false }).liveClaims().has(id))
|
|
3030
3130
|
throw new WorkflowRequestError("execution_active", "previous execution must finish before resume", 409);
|
|
3031
3131
|
if (store instanceof PostgresWorkflowsStore && !await store.acquireExecution(id))
|
|
3032
3132
|
throw new WorkflowRequestError("execution_active", "another executor still owns this run", 409);
|
|
3033
|
-
const graph = context.__wf?.graph;
|
|
3034
|
-
if (!graph)
|
|
3035
|
-
throw new WorkflowRequestError("graph_unavailable", "legacy run needs its original graph registered by the server operator", 409);
|
|
3036
3133
|
if (!await store.compareRunStatus(id, ["failed", "cancelled", "interrupted"], "pending", { error: null }))
|
|
3037
3134
|
throw new WorkflowRequestError("run_status_conflict", "run changed before resume", 409);
|
|
3038
3135
|
for (const node2 of await store.listRunNodes(id)) {
|
|
@@ -3046,6 +3143,7 @@ class WorkflowsService {
|
|
|
3046
3143
|
}
|
|
3047
3144
|
}
|
|
3048
3145
|
sessions(action) {
|
|
3146
|
+
this.verifyJournalDirectory();
|
|
3049
3147
|
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3050
3148
|
if (action === "list")
|
|
3051
3149
|
return replay.entries.slice(-100).map(({ seq, at, op }) => ({ seq, at, operation: op.op }));
|
|
@@ -3063,6 +3161,7 @@ class WorkflowsService {
|
|
|
3063
3161
|
return { ...local, health: this.health(), sessions: this.sessions("pull") };
|
|
3064
3162
|
}
|
|
3065
3163
|
daemonStatus() {
|
|
3164
|
+
this.verifyJournalDirectory();
|
|
3066
3165
|
const replay = SessionWAL.open(this.config.dataDir).replay({ repair: false });
|
|
3067
3166
|
return { activeClaims: replay.liveClaims().size, mode: "bounded-trigger", continuousDaemon: false };
|
|
3068
3167
|
}
|
|
@@ -3070,6 +3169,7 @@ class WorkflowsService {
|
|
|
3070
3169
|
return this.withStore(async (store) => (await store.memoList()).slice(0, 100));
|
|
3071
3170
|
}
|
|
3072
3171
|
async triggerRun(graph, context = {}, opts = {}) {
|
|
3172
|
+
this.assertEnabledGraphLanes(graph);
|
|
3073
3173
|
const store = await this.openEngineStore();
|
|
3074
3174
|
const wal = SessionWAL.open(this.config.dataDir);
|
|
3075
3175
|
try {
|