@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/handlers.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 {
|
|
@@ -3362,12 +3462,12 @@ function createRequestHandler(service) {
|
|
|
3362
3462
|
return jsonResponse(await service.listRuns({ limit, status }));
|
|
3363
3463
|
}
|
|
3364
3464
|
if (path === "/lanes")
|
|
3365
|
-
return jsonResponse(await laneInventory());
|
|
3465
|
+
return jsonResponse(await service.laneInventory());
|
|
3366
3466
|
if (path.startsWith("/lanes/")) {
|
|
3367
3467
|
const lane = path.slice("/lanes/".length);
|
|
3368
3468
|
if (!LANE_KINDS2.includes(lane))
|
|
3369
3469
|
return jsonResponse({ error: "invalid_lane" }, 400);
|
|
3370
|
-
return jsonResponse(await probeLane(lane));
|
|
3470
|
+
return jsonResponse(await service.probeLane(lane));
|
|
3371
3471
|
}
|
|
3372
3472
|
if (path === "/sessions/list")
|
|
3373
3473
|
return jsonResponse(service.sessions("list"));
|
package/dist/index.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 {
|
|
@@ -3362,12 +3462,12 @@ function createRequestHandler(service) {
|
|
|
3362
3462
|
return jsonResponse(await service.listRuns({ limit, status }));
|
|
3363
3463
|
}
|
|
3364
3464
|
if (path === "/lanes")
|
|
3365
|
-
return jsonResponse(await laneInventory());
|
|
3465
|
+
return jsonResponse(await service.laneInventory());
|
|
3366
3466
|
if (path.startsWith("/lanes/")) {
|
|
3367
3467
|
const lane = path.slice("/lanes/".length);
|
|
3368
3468
|
if (!LANE_KINDS2.includes(lane))
|
|
3369
3469
|
return jsonResponse({ error: "invalid_lane" }, 400);
|
|
3370
|
-
return jsonResponse(await probeLane(lane));
|
|
3470
|
+
return jsonResponse(await service.probeLane(lane));
|
|
3371
3471
|
}
|
|
3372
3472
|
if (path === "/sessions/list")
|
|
3373
3473
|
return jsonResponse(service.sessions("list"));
|
|
@@ -3415,7 +3515,6 @@ function createRequestHandler(service) {
|
|
|
3415
3515
|
|
|
3416
3516
|
// src/agent-tools.ts
|
|
3417
3517
|
init_graph();
|
|
3418
|
-
init_lanes();
|
|
3419
3518
|
|
|
3420
3519
|
// src/tool-definitions.ts
|
|
3421
3520
|
function textResult(payload) {
|
|
@@ -3509,7 +3608,7 @@ async function callWorkflowTool(service, name, args) {
|
|
|
3509
3608
|
limit: typeof args.limit === "number" ? args.limit : 100
|
|
3510
3609
|
}));
|
|
3511
3610
|
case "workflows_lanes_list":
|
|
3512
|
-
return textResult(await laneInventory());
|
|
3611
|
+
return textResult(await service.laneInventory());
|
|
3513
3612
|
default:
|
|
3514
3613
|
throw new Error("Unknown workflows tool");
|
|
3515
3614
|
}
|
package/dist/mcp/index.js
CHANGED
|
@@ -73,7 +73,7 @@ function workflowsTools(_service) {
|
|
|
73
73
|
// package.json
|
|
74
74
|
var package_default = {
|
|
75
75
|
name: "@hasna/workflows",
|
|
76
|
-
version: "0.2.
|
|
76
|
+
version: "0.2.2",
|
|
77
77
|
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",
|
|
78
78
|
type: "module",
|
|
79
79
|
main: "./dist/index.js",
|
|
@@ -149,7 +149,7 @@ var package_default = {
|
|
|
149
149
|
dependencies: {
|
|
150
150
|
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
151
151
|
"@cursor/sdk": "^1.0.28",
|
|
152
|
-
"@hasna/contracts": "1.3.
|
|
152
|
+
"@hasna/contracts": "1.3.5",
|
|
153
153
|
"@openai/codex-sdk": "0.160.0",
|
|
154
154
|
commander: "^13.1.0",
|
|
155
155
|
pg: "^8.16.3"
|
|
@@ -653,7 +653,7 @@ function validateNode(graph, node, issues) {
|
|
|
653
653
|
}
|
|
654
654
|
}
|
|
655
655
|
|
|
656
|
-
//
|
|
656
|
+
// ../../app-test-sandbox-1/source/node_modules/@hasna/contracts/dist/client/transport.js
|
|
657
657
|
import { isIP } from "net";
|
|
658
658
|
import { spawnSync } from "child_process";
|
|
659
659
|
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
|
package/dist/sdk.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ../../app-test-sandbox-1/source/node_modules/@hasna/contracts/dist/client/transport.js
|
|
2
2
|
import { isIP } from "net";
|
|
3
3
|
import { spawnSync } from "child_process";
|
|
4
4
|
import { closeSync as closeSync2, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync2 } from "fs";
|
|
@@ -1448,7 +1448,7 @@ function workflowAgent(env = process.env, explicit) {
|
|
|
1448
1448
|
// package.json
|
|
1449
1449
|
var package_default = {
|
|
1450
1450
|
name: "@hasna/workflows",
|
|
1451
|
-
version: "0.2.
|
|
1451
|
+
version: "0.2.2",
|
|
1452
1452
|
description: "Universal graph workflow app — 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",
|
|
1453
1453
|
type: "module",
|
|
1454
1454
|
main: "./dist/index.js",
|
|
@@ -1524,7 +1524,7 @@ var package_default = {
|
|
|
1524
1524
|
dependencies: {
|
|
1525
1525
|
"@anthropic-ai/claude-agent-sdk": "0.3.234",
|
|
1526
1526
|
"@cursor/sdk": "^1.0.28",
|
|
1527
|
-
"@hasna/contracts": "1.3.
|
|
1527
|
+
"@hasna/contracts": "1.3.5",
|
|
1528
1528
|
"@openai/codex-sdk": "0.160.0",
|
|
1529
1529
|
commander: "^13.1.0",
|
|
1530
1530
|
pg: "^8.16.3"
|