@voyantjs/workflows-orchestrator-node 0.107.5 → 0.107.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard-chunks.d.ts +17 -0
- package/dist/dashboard-chunks.d.ts.map +1 -0
- package/dist/dashboard-chunks.js +19 -0
- package/dist/dashboard-http-server.d.ts +6 -0
- package/dist/dashboard-http-server.d.ts.map +1 -0
- package/dist/dashboard-http-server.js +99 -0
- package/dist/dashboard-metrics.d.ts +3 -0
- package/dist/dashboard-metrics.d.ts.map +1 -0
- package/dist/dashboard-metrics.js +26 -0
- package/dist/dashboard-request.d.ts +7 -0
- package/dist/dashboard-request.d.ts.map +1 -0
- package/dist/dashboard-request.js +436 -0
- package/dist/dashboard-server.d.ts +9 -171
- package/dist/dashboard-server.d.ts.map +1 -1
- package/dist/dashboard-server.js +7 -1229
- package/dist/dashboard-sse.d.ts +7 -0
- package/dist/dashboard-sse.d.ts.map +1 -0
- package/dist/dashboard-sse.js +134 -0
- package/dist/dashboard-static.d.ts +7 -0
- package/dist/dashboard-static.d.ts.map +1 -0
- package/dist/dashboard-static.js +89 -0
- package/dist/dashboard-types.d.ts +134 -0
- package/dist/dashboard-types.d.ts.map +1 -0
- package/dist/dashboard-types.js +1 -0
- package/dist/node-selfhost-defaults.d.ts +7 -0
- package/dist/node-selfhost-defaults.d.ts.map +1 -0
- package/dist/node-selfhost-defaults.js +8 -0
- package/dist/node-selfhost-deps.d.ts +4 -0
- package/dist/node-selfhost-deps.d.ts.map +1 -0
- package/dist/node-selfhost-deps.js +403 -0
- package/dist/node-selfhost-resume-input.d.ts +4 -0
- package/dist/node-selfhost-resume-input.d.ts.map +1 -0
- package/dist/node-selfhost-resume-input.js +20 -0
- package/dist/node-standalone-driver.d.ts.map +1 -1
- package/dist/node-standalone-driver.js +40 -3
- package/dist/node-step-runner.d.ts +3 -0
- package/dist/node-step-runner.d.ts.map +1 -0
- package/dist/node-step-runner.js +26 -0
- package/dist/postgres-manifest-store.d.ts.map +1 -1
- package/dist/postgres-manifest-store.js +6 -2
- package/dist/postgres-run-record-store.js +1 -1
- package/dist/postgres-schema.d.ts.map +1 -1
- package/dist/postgres-schema.js +2 -0
- package/dist/sleep-alarm-manager.d.ts.map +1 -1
- package/dist/sleep-alarm-manager.js +9 -1
- package/dist/store-stream.d.ts.map +1 -1
- package/dist/store-stream.js +9 -1
- package/dist/wakeup-poller.d.ts.map +1 -1
- package/dist/wakeup-poller.js +9 -1
- package/package.json +3 -3
- package/src/dashboard-chunks.ts +35 -0
- package/src/dashboard-http-server.ts +118 -0
- package/src/dashboard-metrics.ts +39 -0
- package/src/dashboard-request.ts +488 -0
- package/src/dashboard-server.ts +17 -1535
- package/src/dashboard-sse.ts +150 -0
- package/src/dashboard-static.ts +88 -0
- package/src/dashboard-types.ts +106 -0
- package/src/node-selfhost-defaults.ts +9 -0
- package/src/node-selfhost-deps.ts +495 -0
- package/src/node-selfhost-resume-input.ts +27 -0
- package/src/node-standalone-driver.ts +59 -3
- package/src/node-step-runner.ts +28 -0
- package/src/postgres-manifest-store.ts +2 -0
- package/src/postgres-run-record-store.ts +1 -1
- package/src/postgres-schema.ts +2 -0
- package/src/sleep-alarm-manager.ts +12 -1
- package/src/store-stream.ts +12 -1
- package/src/wakeup-poller.ts +12 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface ChunkEvent {
|
|
2
|
+
runId: string;
|
|
3
|
+
chunk: {
|
|
4
|
+
streamId: string;
|
|
5
|
+
seq: number;
|
|
6
|
+
encoding: "text" | "json" | "base64";
|
|
7
|
+
chunk: unknown;
|
|
8
|
+
final: boolean;
|
|
9
|
+
at: number;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export interface ChunkBus {
|
|
13
|
+
publish(event: ChunkEvent): void;
|
|
14
|
+
subscribe(fn: (event: ChunkEvent) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function createChunkBus(): ChunkBus;
|
|
17
|
+
//# sourceMappingURL=dashboard-chunks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-chunks.d.ts","sourceRoot":"","sources":["../src/dashboard-chunks.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE;QACL,QAAQ,EAAE,MAAM,CAAA;QAChB,GAAG,EAAE,MAAM,CAAA;QACX,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;QACpC,KAAK,EAAE,OAAO,CAAA;QACd,KAAK,EAAE,OAAO,CAAA;QACd,EAAE,EAAE,MAAM,CAAA;KACX,CAAA;CACF;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAA;IAChC,SAAS,CAAC,EAAE,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,MAAM,IAAI,CAAA;CACvD;AAED,wBAAgB,cAAc,IAAI,QAAQ,CAiBzC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function createChunkBus() {
|
|
2
|
+
const subs = new Set();
|
|
3
|
+
return {
|
|
4
|
+
publish(event) {
|
|
5
|
+
for (const fn of subs) {
|
|
6
|
+
try {
|
|
7
|
+
fn(event);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
// Ignore subscriber errors so streaming keeps going.
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
subscribe(fn) {
|
|
15
|
+
subs.add(fn);
|
|
16
|
+
return () => subs.delete(fn);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-http-server.d.ts","sourceRoot":"","sources":["../src/dashboard-http-server.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAA;AASlE,wBAAsB,WAAW,CAC/B,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EACvC,IAAI,EAAE,SAAS,GACd,OAAO,CAAC,WAAW,CAAC,CAiFtB"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { handleRequest } from "./dashboard-request.js";
|
|
2
|
+
import { handleRunSseStream, handleSseStream } from "./dashboard-sse.js";
|
|
3
|
+
import { createStaticReader, urlPath } from "./dashboard-static.js";
|
|
4
|
+
import { createStoreStream } from "./store-stream.js";
|
|
5
|
+
function closeAllConnections(server) {
|
|
6
|
+
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
|
7
|
+
server.closeAllConnections();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function startServer(options, deps) {
|
|
11
|
+
const readStatic = deps.readStatic ?? (deps.staticDir ? createStaticReader(deps.staticDir) : undefined);
|
|
12
|
+
const hasStaticDashboard = Boolean(readStatic);
|
|
13
|
+
let storeStream;
|
|
14
|
+
const getStoreStream = () => {
|
|
15
|
+
if (!storeStream)
|
|
16
|
+
storeStream = createStoreStream(deps.store);
|
|
17
|
+
return storeStream;
|
|
18
|
+
};
|
|
19
|
+
const server = deps.createServer(async (req, res) => {
|
|
20
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
21
|
+
const url = req.url ?? "/";
|
|
22
|
+
if ((method === "GET" || method === "HEAD") && urlPath(url) === "/api/runs/stream") {
|
|
23
|
+
handleSseStream(res, getStoreStream(), deps.chunkBus);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const perRunMatch = urlPath(url).match(/^\/api\/runs\/([^/]+)\/stream$/);
|
|
27
|
+
if ((method === "GET" || method === "HEAD") && perRunMatch) {
|
|
28
|
+
const runId = decodeURIComponent(perRunMatch[1]);
|
|
29
|
+
handleRunSseStream(res, runId, getStoreStream(), deps.chunkBus, deps.store);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const body = method === "POST" ? await readRequestBody(req) : undefined;
|
|
34
|
+
const response = await handleRequest({ method, url, body }, {
|
|
35
|
+
store: deps.store,
|
|
36
|
+
healthCheck: deps.healthCheck,
|
|
37
|
+
readinessCheck: deps.readinessCheck,
|
|
38
|
+
collectMetrics: deps.collectMetrics,
|
|
39
|
+
readStatic,
|
|
40
|
+
hasStaticDashboard,
|
|
41
|
+
triggerRun: deps.triggerRun,
|
|
42
|
+
resumeRun: deps.resumeRun,
|
|
43
|
+
listWorkflows: deps.listWorkflows,
|
|
44
|
+
injectWaitpoint: deps.injectWaitpoint,
|
|
45
|
+
listSchedules: deps.listSchedules,
|
|
46
|
+
cancelRun: deps.cancelRun,
|
|
47
|
+
});
|
|
48
|
+
res.writeHead(response.status, response.headers);
|
|
49
|
+
res.end(response.body);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
54
|
+
res.end(JSON.stringify({ error: "internal_error", message }));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
await new Promise((resolve, reject) => {
|
|
58
|
+
server.once("error", reject);
|
|
59
|
+
server.listen(options.port, options.host, () => {
|
|
60
|
+
server.off("error", reject);
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
deps.scheduler?.start();
|
|
65
|
+
return {
|
|
66
|
+
url: `http://${options.host}:${options.port}`,
|
|
67
|
+
close: () => new Promise((resolve, reject) => {
|
|
68
|
+
deps.scheduler?.stop();
|
|
69
|
+
storeStream?.stop();
|
|
70
|
+
closeAllConnections(server);
|
|
71
|
+
server.close((err) => {
|
|
72
|
+
if (err) {
|
|
73
|
+
reject(err);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
Promise.resolve(deps.shutdown?.()).then(() => resolve(), reject);
|
|
77
|
+
});
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async function readRequestBody(req) {
|
|
82
|
+
const maxBytes = 1_000_000;
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
let total = 0;
|
|
85
|
+
const chunks = [];
|
|
86
|
+
req.on("data", (chunk) => {
|
|
87
|
+
total += chunk.length;
|
|
88
|
+
if (total > maxBytes) {
|
|
89
|
+
req.destroy(new Error("request body exceeds 1MB"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
chunks.push(chunk);
|
|
93
|
+
});
|
|
94
|
+
req.on("end", () => {
|
|
95
|
+
resolve(Buffer.concat(chunks).toString("utf8"));
|
|
96
|
+
});
|
|
97
|
+
req.on("error", reject);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-metrics.d.ts","sourceRoot":"","sources":["../src/dashboard-metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAE3D,wBAAgB,aAAa,CAAC,QAAQ,EAAE,eAAe,GAAG,MAAM,CAgC/D"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function renderMetrics(snapshot) {
|
|
2
|
+
const lines = [
|
|
3
|
+
"# HELP voyant_selfhost_up Self-host server availability.",
|
|
4
|
+
"# TYPE voyant_selfhost_up gauge",
|
|
5
|
+
"voyant_selfhost_up 1",
|
|
6
|
+
"# HELP voyant_selfhost_workflows_registered Registered workflow count.",
|
|
7
|
+
"# TYPE voyant_selfhost_workflows_registered gauge",
|
|
8
|
+
`voyant_selfhost_workflows_registered ${snapshot.workflowsRegistered}`,
|
|
9
|
+
"# HELP voyant_selfhost_schedules_registered Registered schedule count.",
|
|
10
|
+
"# TYPE voyant_selfhost_schedules_registered gauge",
|
|
11
|
+
`voyant_selfhost_schedules_registered ${snapshot.schedulesRegistered}`,
|
|
12
|
+
"# HELP voyant_selfhost_runs_total Persisted run count.",
|
|
13
|
+
"# TYPE voyant_selfhost_runs_total gauge",
|
|
14
|
+
`voyant_selfhost_runs_total ${snapshot.runsTotal}`,
|
|
15
|
+
"# HELP voyant_selfhost_runs_status Run count by status.",
|
|
16
|
+
"# TYPE voyant_selfhost_runs_status gauge",
|
|
17
|
+
];
|
|
18
|
+
for (const [status, count] of Object.entries(snapshot.runsByStatus).sort(([a], [b]) => a.localeCompare(b))) {
|
|
19
|
+
lines.push(`voyant_selfhost_runs_status{status="${escapeMetricLabelValue(status)}"} ${count}`);
|
|
20
|
+
}
|
|
21
|
+
lines.push("# HELP voyant_selfhost_wakeups_total Persisted wakeup count.", "# TYPE voyant_selfhost_wakeups_total gauge", `voyant_selfhost_wakeups_total ${snapshot.wakeupsTotal}`, "# HELP voyant_selfhost_metrics_generated_at_seconds Metrics generation timestamp.", "# TYPE voyant_selfhost_metrics_generated_at_seconds gauge", `voyant_selfhost_metrics_generated_at_seconds ${Math.floor(snapshot.generatedAtMs / 1000)}`, "");
|
|
22
|
+
return lines.join("\n");
|
|
23
|
+
}
|
|
24
|
+
function escapeMetricLabelValue(value) {
|
|
25
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HandlerResponse, RequestHandlerDeps } from "./dashboard-types.js";
|
|
2
|
+
export declare function handleRequest(req: {
|
|
3
|
+
method: string;
|
|
4
|
+
url: string;
|
|
5
|
+
body?: string;
|
|
6
|
+
}, deps: RequestHandlerDeps): Promise<HandlerResponse>;
|
|
7
|
+
//# sourceMappingURL=dashboard-request.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dashboard-request.d.ts","sourceRoot":"","sources":["../src/dashboard-request.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAgB,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AAG7F,wBAAsB,aAAa,CACjC,GAAG,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,EACnD,IAAI,EAAE,kBAAkB,GACvB,OAAO,CAAC,eAAe,CAAC,CAiT1B"}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import { URL } from "node:url";
|
|
2
|
+
import { renderMetrics } from "./dashboard-metrics.js";
|
|
3
|
+
import { mimeFor } from "./dashboard-static.js";
|
|
4
|
+
export async function handleRequest(req, deps) {
|
|
5
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
6
|
+
const url = new URL(req.url, "http://local");
|
|
7
|
+
if (method === "OPTIONS") {
|
|
8
|
+
return {
|
|
9
|
+
status: 204,
|
|
10
|
+
headers: {
|
|
11
|
+
"access-control-allow-origin": "*",
|
|
12
|
+
"access-control-allow-methods": "GET, OPTIONS, POST",
|
|
13
|
+
"access-control-allow-headers": "content-type",
|
|
14
|
+
},
|
|
15
|
+
body: "",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (method === "POST") {
|
|
19
|
+
const cancelMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/cancel$/);
|
|
20
|
+
if (cancelMatch) {
|
|
21
|
+
if (!deps.cancelRun) {
|
|
22
|
+
return json(501, {
|
|
23
|
+
error: "cancel_not_supported",
|
|
24
|
+
message: "This self-host server was started without a workflow entry. " +
|
|
25
|
+
"Restart with `--file <path>` to enable cancellation.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const runId = decodeURIComponent(cancelMatch[1]);
|
|
29
|
+
const result = await deps.cancelRun({ runId });
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
32
|
+
error: "cancel_failed",
|
|
33
|
+
message: result.message,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return json(200, { saved: result.saved });
|
|
37
|
+
}
|
|
38
|
+
const resumeMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/resume$/);
|
|
39
|
+
if (resumeMatch) {
|
|
40
|
+
if (!deps.resumeRun) {
|
|
41
|
+
return json(501, {
|
|
42
|
+
error: "resume_not_supported",
|
|
43
|
+
message: "This self-host server was started without a workflow entry. " +
|
|
44
|
+
"Restart with `--file <path>` to enable failed-step resume.",
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const parsed = parseResumeRequestBody(req.body);
|
|
48
|
+
if (!parsed.ok) {
|
|
49
|
+
return json(parsed.status, { error: parsed.error, message: parsed.message });
|
|
50
|
+
}
|
|
51
|
+
const parentRunId = decodeURIComponent(resumeMatch[1]);
|
|
52
|
+
const result = await deps.resumeRun({ parentRunId, ...parsed.body });
|
|
53
|
+
if (!result.ok) {
|
|
54
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
55
|
+
error: "resume_failed",
|
|
56
|
+
message: result.message,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return json(200, {
|
|
60
|
+
saved: result.saved,
|
|
61
|
+
parentRunId: result.parentRunId,
|
|
62
|
+
resumeFromStep: result.resumeFromStep,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const eventsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/events$/);
|
|
66
|
+
const signalsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/signals$/);
|
|
67
|
+
const tokenMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/tokens\/([^/]+)$/);
|
|
68
|
+
if (eventsMatch || signalsMatch || tokenMatch) {
|
|
69
|
+
if (!deps.injectWaitpoint) {
|
|
70
|
+
return json(501, {
|
|
71
|
+
error: "inject_not_supported",
|
|
72
|
+
message: "This self-host server was started without a workflow entry. " +
|
|
73
|
+
"Restart with `--file <path>` to enable event / signal / token injection.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
let parsed;
|
|
77
|
+
try {
|
|
78
|
+
parsed = req.body ? JSON.parse(req.body) : {};
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return json(400, {
|
|
82
|
+
error: "invalid_json",
|
|
83
|
+
message: err instanceof Error ? err.message : String(err),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
let injection;
|
|
87
|
+
if (eventsMatch) {
|
|
88
|
+
if (typeof parsed.eventType !== "string" || parsed.eventType.length === 0) {
|
|
89
|
+
return json(400, { error: "invalid_body", message: "`eventType` (string) is required" });
|
|
90
|
+
}
|
|
91
|
+
injection = { kind: "EVENT", eventType: parsed.eventType, payload: parsed.payload };
|
|
92
|
+
}
|
|
93
|
+
else if (signalsMatch) {
|
|
94
|
+
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
95
|
+
return json(400, { error: "invalid_body", message: "`name` (string) is required" });
|
|
96
|
+
}
|
|
97
|
+
injection = { kind: "SIGNAL", name: parsed.name, payload: parsed.payload };
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
injection = {
|
|
101
|
+
kind: "MANUAL",
|
|
102
|
+
tokenId: decodeURIComponent(tokenMatch[2]),
|
|
103
|
+
payload: parsed.payload,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const runId = decodeURIComponent((eventsMatch?.[1] ?? signalsMatch?.[1] ?? tokenMatch?.[1]));
|
|
107
|
+
const result = await deps.injectWaitpoint({ runId, injection });
|
|
108
|
+
if (!result.ok) {
|
|
109
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
110
|
+
error: "inject_failed",
|
|
111
|
+
message: result.message,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return json(200, { saved: result.saved });
|
|
115
|
+
}
|
|
116
|
+
if (url.pathname === "/api/runs") {
|
|
117
|
+
if (!deps.triggerRun) {
|
|
118
|
+
return json(501, {
|
|
119
|
+
error: "trigger_not_supported",
|
|
120
|
+
message: "This self-host server was started without a workflow entry. " +
|
|
121
|
+
"Restart with `--file <path>` to enable triggering.",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
let parsed;
|
|
125
|
+
try {
|
|
126
|
+
parsed = req.body ? JSON.parse(req.body) : {};
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return json(400, {
|
|
130
|
+
error: "invalid_json",
|
|
131
|
+
message: err instanceof Error ? err.message : String(err),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (typeof parsed.workflowId !== "string" || parsed.workflowId.length === 0) {
|
|
135
|
+
return json(400, {
|
|
136
|
+
error: "invalid_body",
|
|
137
|
+
message: "`workflowId` (string) is required",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (parsed.runId !== undefined && typeof parsed.runId !== "string") {
|
|
141
|
+
return json(400, {
|
|
142
|
+
error: "invalid_body",
|
|
143
|
+
message: "`runId` must be a string when provided",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (parsed.tags !== undefined && !isStringArray(parsed.tags)) {
|
|
147
|
+
return json(400, {
|
|
148
|
+
error: "invalid_body",
|
|
149
|
+
message: "`tags` must be an array of strings when provided",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (parsed.triggeredByUserId !== undefined &&
|
|
153
|
+
parsed.triggeredByUserId !== null &&
|
|
154
|
+
typeof parsed.triggeredByUserId !== "string") {
|
|
155
|
+
return json(400, {
|
|
156
|
+
error: "invalid_body",
|
|
157
|
+
message: "`triggeredByUserId` must be a string or null when provided",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const result = await deps.triggerRun({
|
|
161
|
+
workflowId: parsed.workflowId,
|
|
162
|
+
input: parsed.input,
|
|
163
|
+
runId: parsed.runId,
|
|
164
|
+
tags: parsed.tags,
|
|
165
|
+
triggeredByUserId: parsed.triggeredByUserId,
|
|
166
|
+
});
|
|
167
|
+
if (!result.ok) {
|
|
168
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
169
|
+
error: "trigger_failed",
|
|
170
|
+
message: result.message,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
return json(200, { saved: result.saved });
|
|
174
|
+
}
|
|
175
|
+
return json(404, { error: "route_not_found", path: url.pathname });
|
|
176
|
+
}
|
|
177
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
178
|
+
return json(405, { error: "method_not_allowed", allowed: ["GET", "HEAD", "OPTIONS", "POST"] });
|
|
179
|
+
}
|
|
180
|
+
if (url.pathname === "/healthz") {
|
|
181
|
+
const report = await resolveHealthReport(deps.healthCheck, {
|
|
182
|
+
ok: true,
|
|
183
|
+
service: "voyant-workflows-selfhost",
|
|
184
|
+
});
|
|
185
|
+
return json(report.ok ? 200 : 503, report);
|
|
186
|
+
}
|
|
187
|
+
if (url.pathname === "/readyz") {
|
|
188
|
+
const report = await resolveHealthReport(deps.readinessCheck, {
|
|
189
|
+
ok: Boolean(deps.triggerRun),
|
|
190
|
+
service: "voyant-workflows-selfhost",
|
|
191
|
+
checks: {
|
|
192
|
+
workflowEntry: deps.triggerRun ? "ok" : "error",
|
|
193
|
+
},
|
|
194
|
+
details: deps.triggerRun
|
|
195
|
+
? undefined
|
|
196
|
+
: {
|
|
197
|
+
workflowEntry: "This self-host server was started without a workflow entry.",
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
return json(report.ok ? 200 : 503, report);
|
|
201
|
+
}
|
|
202
|
+
if (url.pathname === "/metrics") {
|
|
203
|
+
const body = await resolveMetricsBody(deps.collectMetrics);
|
|
204
|
+
return {
|
|
205
|
+
status: 200,
|
|
206
|
+
headers: {
|
|
207
|
+
"content-type": "text/plain; version=0.0.4; charset=utf-8",
|
|
208
|
+
"cache-control": "no-store",
|
|
209
|
+
},
|
|
210
|
+
body,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
214
|
+
if (deps.hasStaticDashboard && deps.readStatic) {
|
|
215
|
+
const bytes = await deps.readStatic("index.html");
|
|
216
|
+
if (bytes) {
|
|
217
|
+
return {
|
|
218
|
+
status: 200,
|
|
219
|
+
headers: {
|
|
220
|
+
"content-type": "text/html; charset=utf-8",
|
|
221
|
+
"cache-control": "no-store",
|
|
222
|
+
},
|
|
223
|
+
body: bytes,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return json(200, {
|
|
228
|
+
service: "voyant workflows selfhost",
|
|
229
|
+
endpoints: ["/api/runs", "/api/runs/:id"],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (deps.hasStaticDashboard && deps.readStatic && !url.pathname.startsWith("/api/")) {
|
|
233
|
+
const clean = url.pathname.replace(/^\/+/, "");
|
|
234
|
+
if (clean && !clean.includes("..")) {
|
|
235
|
+
const bytes = await deps.readStatic(clean);
|
|
236
|
+
if (bytes) {
|
|
237
|
+
return {
|
|
238
|
+
status: 200,
|
|
239
|
+
headers: {
|
|
240
|
+
"content-type": mimeFor(clean),
|
|
241
|
+
"cache-control": "no-store",
|
|
242
|
+
},
|
|
243
|
+
body: bytes,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (url.pathname === "/api/workflows") {
|
|
249
|
+
const workflows = deps.listWorkflows ? deps.listWorkflows() : [];
|
|
250
|
+
return json(200, { workflows });
|
|
251
|
+
}
|
|
252
|
+
if (url.pathname === "/api/schedules") {
|
|
253
|
+
const schedules = deps.listSchedules ? deps.listSchedules() : [];
|
|
254
|
+
return json(200, { schedules });
|
|
255
|
+
}
|
|
256
|
+
if (url.pathname === "/api/runs") {
|
|
257
|
+
const filter = {};
|
|
258
|
+
const workflowId = url.searchParams.get("workflow") ?? url.searchParams.get("workflowId");
|
|
259
|
+
if (workflowId)
|
|
260
|
+
filter.workflowId = workflowId;
|
|
261
|
+
const status = url.searchParams.get("status");
|
|
262
|
+
if (status)
|
|
263
|
+
filter.status = status;
|
|
264
|
+
const limitRaw = url.searchParams.get("limit");
|
|
265
|
+
if (limitRaw !== null) {
|
|
266
|
+
const limit = Number.parseInt(limitRaw, 10);
|
|
267
|
+
if (Number.isNaN(limit) || limit < 0) {
|
|
268
|
+
return json(400, {
|
|
269
|
+
error: "invalid_limit",
|
|
270
|
+
message: `limit must be a non-negative integer (got "${limitRaw}")`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
filter.limit = limit;
|
|
274
|
+
}
|
|
275
|
+
const runs = await deps.store.list(filter);
|
|
276
|
+
return json(200, { runs });
|
|
277
|
+
}
|
|
278
|
+
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/);
|
|
279
|
+
if (runMatch) {
|
|
280
|
+
const runId = decodeURIComponent(runMatch[1]);
|
|
281
|
+
const run = await deps.store.get(runId);
|
|
282
|
+
if (!run)
|
|
283
|
+
return json(404, { error: "not_found", runId });
|
|
284
|
+
return json(200, { run });
|
|
285
|
+
}
|
|
286
|
+
return json(404, { error: "route_not_found", path: url.pathname });
|
|
287
|
+
}
|
|
288
|
+
function parseResumeRequestBody(body) {
|
|
289
|
+
let parsed;
|
|
290
|
+
try {
|
|
291
|
+
parsed = body ? JSON.parse(body) : {};
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
status: 400,
|
|
297
|
+
error: "invalid_json",
|
|
298
|
+
message: err instanceof Error ? err.message : String(err),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
if (!isPlainObject(parsed)) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
status: 400,
|
|
305
|
+
error: "invalid_body",
|
|
306
|
+
message: "request body must be an object",
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
if (parsed.resumeFromStep !== undefined && typeof parsed.resumeFromStep !== "string") {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
status: 400,
|
|
313
|
+
error: "invalid_body",
|
|
314
|
+
message: "`resumeFromStep` must be a string when provided",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (parsed.workflowId !== undefined && typeof parsed.workflowId !== "string") {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
status: 400,
|
|
321
|
+
error: "invalid_body",
|
|
322
|
+
message: "`workflowId` must be a string when provided",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (parsed.runId !== undefined && typeof parsed.runId !== "string") {
|
|
326
|
+
return {
|
|
327
|
+
ok: false,
|
|
328
|
+
status: 400,
|
|
329
|
+
error: "invalid_body",
|
|
330
|
+
message: "`runId` must be a string when provided",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
if (parsed.triggeredByUserId !== undefined &&
|
|
334
|
+
parsed.triggeredByUserId !== null &&
|
|
335
|
+
typeof parsed.triggeredByUserId !== "string") {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
status: 400,
|
|
339
|
+
error: "invalid_body",
|
|
340
|
+
message: "`triggeredByUserId` must be a string or null when provided",
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (parsed.tags !== undefined && !isStringArray(parsed.tags)) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
status: 400,
|
|
347
|
+
error: "invalid_body",
|
|
348
|
+
message: "`tags` must be an array of strings when provided",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (parsed.seedResults !== undefined && !isPlainObject(parsed.seedResults)) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
status: 400,
|
|
355
|
+
error: "invalid_body",
|
|
356
|
+
message: "`seedResults` must be an object when provided",
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
body: {
|
|
362
|
+
input: parsed.input,
|
|
363
|
+
workflowId: parsed.workflowId,
|
|
364
|
+
resumeFromStep: parsed.resumeFromStep,
|
|
365
|
+
seedResults: parsed.seedResults,
|
|
366
|
+
runId: parsed.runId,
|
|
367
|
+
tags: parsed.tags,
|
|
368
|
+
triggeredByUserId: parsed.triggeredByUserId,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function isPlainObject(value) {
|
|
373
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
374
|
+
}
|
|
375
|
+
function isStringArray(value) {
|
|
376
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
377
|
+
}
|
|
378
|
+
function json(status, body) {
|
|
379
|
+
return {
|
|
380
|
+
status,
|
|
381
|
+
headers: {
|
|
382
|
+
"content-type": "application/json; charset=utf-8",
|
|
383
|
+
"access-control-allow-origin": "*",
|
|
384
|
+
"cache-control": "no-store",
|
|
385
|
+
},
|
|
386
|
+
body: JSON.stringify(body, null, 2),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
async function resolveHealthReport(check, fallback) {
|
|
390
|
+
if (!check)
|
|
391
|
+
return fallback;
|
|
392
|
+
try {
|
|
393
|
+
return await check();
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
service: fallback.service,
|
|
399
|
+
checks: {
|
|
400
|
+
...(fallback.checks ?? {}),
|
|
401
|
+
self: "error",
|
|
402
|
+
},
|
|
403
|
+
details: {
|
|
404
|
+
...(fallback.details ?? {}),
|
|
405
|
+
error: err instanceof Error ? err.message : String(err),
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async function resolveMetricsBody(collectMetrics) {
|
|
411
|
+
if (!collectMetrics) {
|
|
412
|
+
return renderMetrics({
|
|
413
|
+
workflowsRegistered: 0,
|
|
414
|
+
schedulesRegistered: 0,
|
|
415
|
+
runsTotal: 0,
|
|
416
|
+
wakeupsTotal: 0,
|
|
417
|
+
runsByStatus: {},
|
|
418
|
+
generatedAtMs: Date.now(),
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
return await collectMetrics();
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
return [
|
|
426
|
+
"# HELP voyant_selfhost_metrics_error Metrics collection failure state.",
|
|
427
|
+
"# TYPE voyant_selfhost_metrics_error gauge",
|
|
428
|
+
"voyant_selfhost_metrics_error 1",
|
|
429
|
+
`# metrics_error ${escapeMetricLabelValue(err instanceof Error ? err.message : String(err))}`,
|
|
430
|
+
"",
|
|
431
|
+
].join("\n");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function escapeMetricLabelValue(value) {
|
|
435
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
436
|
+
}
|