@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,488 @@
|
|
|
1
|
+
import { URL } from "node:url"
|
|
2
|
+
import type { WaitpointInjection } from "@voyantjs/workflows-orchestrator"
|
|
3
|
+
import { renderMetrics } from "./dashboard-metrics.js"
|
|
4
|
+
import { mimeFor } from "./dashboard-static.js"
|
|
5
|
+
import type { HandlerResponse, HealthReport, RequestHandlerDeps } from "./dashboard-types.js"
|
|
6
|
+
import type { ListFilter } from "./snapshot-run-store.js"
|
|
7
|
+
|
|
8
|
+
export async function handleRequest(
|
|
9
|
+
req: { method: string; url: string; body?: string },
|
|
10
|
+
deps: RequestHandlerDeps,
|
|
11
|
+
): Promise<HandlerResponse> {
|
|
12
|
+
const method = (req.method ?? "GET").toUpperCase()
|
|
13
|
+
const url = new URL(req.url, "http://local")
|
|
14
|
+
|
|
15
|
+
if (method === "OPTIONS") {
|
|
16
|
+
return {
|
|
17
|
+
status: 204,
|
|
18
|
+
headers: {
|
|
19
|
+
"access-control-allow-origin": "*",
|
|
20
|
+
"access-control-allow-methods": "GET, OPTIONS, POST",
|
|
21
|
+
"access-control-allow-headers": "content-type",
|
|
22
|
+
},
|
|
23
|
+
body: "",
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (method === "POST") {
|
|
28
|
+
const cancelMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/cancel$/)
|
|
29
|
+
if (cancelMatch) {
|
|
30
|
+
if (!deps.cancelRun) {
|
|
31
|
+
return json(501, {
|
|
32
|
+
error: "cancel_not_supported",
|
|
33
|
+
message:
|
|
34
|
+
"This self-host server was started without a workflow entry. " +
|
|
35
|
+
"Restart with `--file <path>` to enable cancellation.",
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
const runId = decodeURIComponent(cancelMatch[1]!)
|
|
39
|
+
const result = await deps.cancelRun({ runId })
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
42
|
+
error: "cancel_failed",
|
|
43
|
+
message: result.message,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return json(200, { saved: result.saved })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const resumeMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/resume$/)
|
|
50
|
+
if (resumeMatch) {
|
|
51
|
+
if (!deps.resumeRun) {
|
|
52
|
+
return json(501, {
|
|
53
|
+
error: "resume_not_supported",
|
|
54
|
+
message:
|
|
55
|
+
"This self-host server was started without a workflow entry. " +
|
|
56
|
+
"Restart with `--file <path>` to enable failed-step resume.",
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
const parsed = parseResumeRequestBody(req.body)
|
|
60
|
+
if (!parsed.ok) {
|
|
61
|
+
return json(parsed.status, { error: parsed.error, message: parsed.message })
|
|
62
|
+
}
|
|
63
|
+
const parentRunId = decodeURIComponent(resumeMatch[1]!)
|
|
64
|
+
const result = await deps.resumeRun({ parentRunId, ...parsed.body })
|
|
65
|
+
if (!result.ok) {
|
|
66
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
67
|
+
error: "resume_failed",
|
|
68
|
+
message: result.message,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
return json(200, {
|
|
72
|
+
saved: result.saved,
|
|
73
|
+
parentRunId: result.parentRunId,
|
|
74
|
+
resumeFromStep: result.resumeFromStep,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const eventsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/events$/)
|
|
79
|
+
const signalsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/signals$/)
|
|
80
|
+
const tokenMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/tokens\/([^/]+)$/)
|
|
81
|
+
if (eventsMatch || signalsMatch || tokenMatch) {
|
|
82
|
+
if (!deps.injectWaitpoint) {
|
|
83
|
+
return json(501, {
|
|
84
|
+
error: "inject_not_supported",
|
|
85
|
+
message:
|
|
86
|
+
"This self-host server was started without a workflow entry. " +
|
|
87
|
+
"Restart with `--file <path>` to enable event / signal / token injection.",
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
let parsed: Record<string, unknown>
|
|
91
|
+
try {
|
|
92
|
+
parsed = req.body ? (JSON.parse(req.body) as Record<string, unknown>) : {}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
return json(400, {
|
|
95
|
+
error: "invalid_json",
|
|
96
|
+
message: err instanceof Error ? err.message : String(err),
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
let injection: WaitpointInjection
|
|
100
|
+
if (eventsMatch) {
|
|
101
|
+
if (typeof parsed.eventType !== "string" || parsed.eventType.length === 0) {
|
|
102
|
+
return json(400, { error: "invalid_body", message: "`eventType` (string) is required" })
|
|
103
|
+
}
|
|
104
|
+
injection = { kind: "EVENT", eventType: parsed.eventType, payload: parsed.payload }
|
|
105
|
+
} else if (signalsMatch) {
|
|
106
|
+
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
|
|
107
|
+
return json(400, { error: "invalid_body", message: "`name` (string) is required" })
|
|
108
|
+
}
|
|
109
|
+
injection = { kind: "SIGNAL", name: parsed.name, payload: parsed.payload }
|
|
110
|
+
} else {
|
|
111
|
+
injection = {
|
|
112
|
+
kind: "MANUAL",
|
|
113
|
+
tokenId: decodeURIComponent(tokenMatch![2]!),
|
|
114
|
+
payload: parsed.payload,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const runId = decodeURIComponent((eventsMatch?.[1] ?? signalsMatch?.[1] ?? tokenMatch?.[1])!)
|
|
118
|
+
const result = await deps.injectWaitpoint({ runId, injection })
|
|
119
|
+
if (!result.ok) {
|
|
120
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
121
|
+
error: "inject_failed",
|
|
122
|
+
message: result.message,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
return json(200, { saved: result.saved })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (url.pathname === "/api/runs") {
|
|
129
|
+
if (!deps.triggerRun) {
|
|
130
|
+
return json(501, {
|
|
131
|
+
error: "trigger_not_supported",
|
|
132
|
+
message:
|
|
133
|
+
"This self-host server was started without a workflow entry. " +
|
|
134
|
+
"Restart with `--file <path>` to enable triggering.",
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
let parsed: {
|
|
138
|
+
workflowId?: unknown
|
|
139
|
+
input?: unknown
|
|
140
|
+
runId?: unknown
|
|
141
|
+
tags?: unknown
|
|
142
|
+
triggeredByUserId?: unknown
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
parsed = req.body ? JSON.parse(req.body) : {}
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return json(400, {
|
|
148
|
+
error: "invalid_json",
|
|
149
|
+
message: err instanceof Error ? err.message : String(err),
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
if (typeof parsed.workflowId !== "string" || parsed.workflowId.length === 0) {
|
|
153
|
+
return json(400, {
|
|
154
|
+
error: "invalid_body",
|
|
155
|
+
message: "`workflowId` (string) is required",
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
if (parsed.runId !== undefined && typeof parsed.runId !== "string") {
|
|
159
|
+
return json(400, {
|
|
160
|
+
error: "invalid_body",
|
|
161
|
+
message: "`runId` must be a string when provided",
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
if (parsed.tags !== undefined && !isStringArray(parsed.tags)) {
|
|
165
|
+
return json(400, {
|
|
166
|
+
error: "invalid_body",
|
|
167
|
+
message: "`tags` must be an array of strings when provided",
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
parsed.triggeredByUserId !== undefined &&
|
|
172
|
+
parsed.triggeredByUserId !== null &&
|
|
173
|
+
typeof parsed.triggeredByUserId !== "string"
|
|
174
|
+
) {
|
|
175
|
+
return json(400, {
|
|
176
|
+
error: "invalid_body",
|
|
177
|
+
message: "`triggeredByUserId` must be a string or null when provided",
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
const result = await deps.triggerRun({
|
|
181
|
+
workflowId: parsed.workflowId,
|
|
182
|
+
input: parsed.input,
|
|
183
|
+
runId: parsed.runId,
|
|
184
|
+
tags: parsed.tags,
|
|
185
|
+
triggeredByUserId: parsed.triggeredByUserId,
|
|
186
|
+
})
|
|
187
|
+
if (!result.ok) {
|
|
188
|
+
return json(result.exitCode === 2 ? 400 : 404, {
|
|
189
|
+
error: "trigger_failed",
|
|
190
|
+
message: result.message,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
return json(200, { saved: result.saved })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return json(404, { error: "route_not_found", path: url.pathname })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
200
|
+
return json(405, { error: "method_not_allowed", allowed: ["GET", "HEAD", "OPTIONS", "POST"] })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (url.pathname === "/healthz") {
|
|
204
|
+
const report = await resolveHealthReport(deps.healthCheck, {
|
|
205
|
+
ok: true,
|
|
206
|
+
service: "voyant-workflows-selfhost",
|
|
207
|
+
})
|
|
208
|
+
return json(report.ok ? 200 : 503, report)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (url.pathname === "/readyz") {
|
|
212
|
+
const report = await resolveHealthReport(deps.readinessCheck, {
|
|
213
|
+
ok: Boolean(deps.triggerRun),
|
|
214
|
+
service: "voyant-workflows-selfhost",
|
|
215
|
+
checks: {
|
|
216
|
+
workflowEntry: deps.triggerRun ? "ok" : "error",
|
|
217
|
+
},
|
|
218
|
+
details: deps.triggerRun
|
|
219
|
+
? undefined
|
|
220
|
+
: {
|
|
221
|
+
workflowEntry: "This self-host server was started without a workflow entry.",
|
|
222
|
+
},
|
|
223
|
+
})
|
|
224
|
+
return json(report.ok ? 200 : 503, report)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (url.pathname === "/metrics") {
|
|
228
|
+
const body = await resolveMetricsBody(deps.collectMetrics)
|
|
229
|
+
return {
|
|
230
|
+
status: 200,
|
|
231
|
+
headers: {
|
|
232
|
+
"content-type": "text/plain; version=0.0.4; charset=utf-8",
|
|
233
|
+
"cache-control": "no-store",
|
|
234
|
+
},
|
|
235
|
+
body,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
240
|
+
if (deps.hasStaticDashboard && deps.readStatic) {
|
|
241
|
+
const bytes = await deps.readStatic("index.html")
|
|
242
|
+
if (bytes) {
|
|
243
|
+
return {
|
|
244
|
+
status: 200,
|
|
245
|
+
headers: {
|
|
246
|
+
"content-type": "text/html; charset=utf-8",
|
|
247
|
+
"cache-control": "no-store",
|
|
248
|
+
},
|
|
249
|
+
body: bytes,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return json(200, {
|
|
254
|
+
service: "voyant workflows selfhost",
|
|
255
|
+
endpoints: ["/api/runs", "/api/runs/:id"],
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (deps.hasStaticDashboard && deps.readStatic && !url.pathname.startsWith("/api/")) {
|
|
260
|
+
const clean = url.pathname.replace(/^\/+/, "")
|
|
261
|
+
if (clean && !clean.includes("..")) {
|
|
262
|
+
const bytes = await deps.readStatic(clean)
|
|
263
|
+
if (bytes) {
|
|
264
|
+
return {
|
|
265
|
+
status: 200,
|
|
266
|
+
headers: {
|
|
267
|
+
"content-type": mimeFor(clean),
|
|
268
|
+
"cache-control": "no-store",
|
|
269
|
+
},
|
|
270
|
+
body: bytes,
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (url.pathname === "/api/workflows") {
|
|
277
|
+
const workflows = deps.listWorkflows ? deps.listWorkflows() : []
|
|
278
|
+
return json(200, { workflows })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (url.pathname === "/api/schedules") {
|
|
282
|
+
const schedules = deps.listSchedules ? deps.listSchedules() : []
|
|
283
|
+
return json(200, { schedules })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (url.pathname === "/api/runs") {
|
|
287
|
+
const filter: ListFilter = {}
|
|
288
|
+
const workflowId = url.searchParams.get("workflow") ?? url.searchParams.get("workflowId")
|
|
289
|
+
if (workflowId) filter.workflowId = workflowId
|
|
290
|
+
const status = url.searchParams.get("status")
|
|
291
|
+
if (status) filter.status = status
|
|
292
|
+
const limitRaw = url.searchParams.get("limit")
|
|
293
|
+
if (limitRaw !== null) {
|
|
294
|
+
const limit = Number.parseInt(limitRaw, 10)
|
|
295
|
+
if (Number.isNaN(limit) || limit < 0) {
|
|
296
|
+
return json(400, {
|
|
297
|
+
error: "invalid_limit",
|
|
298
|
+
message: `limit must be a non-negative integer (got "${limitRaw}")`,
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
filter.limit = limit
|
|
302
|
+
}
|
|
303
|
+
const runs = await deps.store.list(filter)
|
|
304
|
+
return json(200, { runs })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/)
|
|
308
|
+
if (runMatch) {
|
|
309
|
+
const runId = decodeURIComponent(runMatch[1]!)
|
|
310
|
+
const run = await deps.store.get(runId)
|
|
311
|
+
if (!run) return json(404, { error: "not_found", runId })
|
|
312
|
+
return json(200, { run })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return json(404, { error: "route_not_found", path: url.pathname })
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseResumeRequestBody(body: string | undefined):
|
|
319
|
+
| {
|
|
320
|
+
ok: true
|
|
321
|
+
body: {
|
|
322
|
+
input?: unknown
|
|
323
|
+
workflowId?: string
|
|
324
|
+
resumeFromStep?: string
|
|
325
|
+
seedResults?: Record<string, unknown>
|
|
326
|
+
runId?: string
|
|
327
|
+
tags?: string[]
|
|
328
|
+
triggeredByUserId?: string | null
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
| { ok: false; status: number; error: string; message: string } {
|
|
332
|
+
let parsed: Record<string, unknown>
|
|
333
|
+
try {
|
|
334
|
+
parsed = body ? (JSON.parse(body) as Record<string, unknown>) : {}
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
status: 400,
|
|
339
|
+
error: "invalid_json",
|
|
340
|
+
message: err instanceof Error ? err.message : String(err),
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!isPlainObject(parsed)) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
status: 400,
|
|
347
|
+
error: "invalid_body",
|
|
348
|
+
message: "request body must be an object",
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (parsed.resumeFromStep !== undefined && typeof parsed.resumeFromStep !== "string") {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
status: 400,
|
|
355
|
+
error: "invalid_body",
|
|
356
|
+
message: "`resumeFromStep` must be a string when provided",
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (parsed.workflowId !== undefined && typeof parsed.workflowId !== "string") {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
status: 400,
|
|
363
|
+
error: "invalid_body",
|
|
364
|
+
message: "`workflowId` must be a string when provided",
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (parsed.runId !== undefined && typeof parsed.runId !== "string") {
|
|
368
|
+
return {
|
|
369
|
+
ok: false,
|
|
370
|
+
status: 400,
|
|
371
|
+
error: "invalid_body",
|
|
372
|
+
message: "`runId` must be a string when provided",
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (
|
|
376
|
+
parsed.triggeredByUserId !== undefined &&
|
|
377
|
+
parsed.triggeredByUserId !== null &&
|
|
378
|
+
typeof parsed.triggeredByUserId !== "string"
|
|
379
|
+
) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
status: 400,
|
|
383
|
+
error: "invalid_body",
|
|
384
|
+
message: "`triggeredByUserId` must be a string or null when provided",
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (parsed.tags !== undefined && !isStringArray(parsed.tags)) {
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
status: 400,
|
|
391
|
+
error: "invalid_body",
|
|
392
|
+
message: "`tags` must be an array of strings when provided",
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (parsed.seedResults !== undefined && !isPlainObject(parsed.seedResults)) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
status: 400,
|
|
399
|
+
error: "invalid_body",
|
|
400
|
+
message: "`seedResults` must be an object when provided",
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
body: {
|
|
406
|
+
input: parsed.input,
|
|
407
|
+
workflowId: parsed.workflowId as string | undefined,
|
|
408
|
+
resumeFromStep: parsed.resumeFromStep,
|
|
409
|
+
seedResults: parsed.seedResults as Record<string, unknown> | undefined,
|
|
410
|
+
runId: parsed.runId,
|
|
411
|
+
tags: parsed.tags as string[] | undefined,
|
|
412
|
+
triggeredByUserId: parsed.triggeredByUserId as string | null | undefined,
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
418
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function isStringArray(value: unknown): value is string[] {
|
|
422
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function json(status: number, body: unknown): HandlerResponse {
|
|
426
|
+
return {
|
|
427
|
+
status,
|
|
428
|
+
headers: {
|
|
429
|
+
"content-type": "application/json; charset=utf-8",
|
|
430
|
+
"access-control-allow-origin": "*",
|
|
431
|
+
"cache-control": "no-store",
|
|
432
|
+
},
|
|
433
|
+
body: JSON.stringify(body, null, 2),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function resolveHealthReport(
|
|
438
|
+
check: (() => Promise<HealthReport> | HealthReport) | undefined,
|
|
439
|
+
fallback: HealthReport,
|
|
440
|
+
): Promise<HealthReport> {
|
|
441
|
+
if (!check) return fallback
|
|
442
|
+
try {
|
|
443
|
+
return await check()
|
|
444
|
+
} catch (err) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
service: fallback.service,
|
|
448
|
+
checks: {
|
|
449
|
+
...(fallback.checks ?? {}),
|
|
450
|
+
self: "error",
|
|
451
|
+
},
|
|
452
|
+
details: {
|
|
453
|
+
...(fallback.details ?? {}),
|
|
454
|
+
error: err instanceof Error ? err.message : String(err),
|
|
455
|
+
},
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function resolveMetricsBody(
|
|
461
|
+
collectMetrics: (() => Promise<string> | string) | undefined,
|
|
462
|
+
): Promise<string> {
|
|
463
|
+
if (!collectMetrics) {
|
|
464
|
+
return renderMetrics({
|
|
465
|
+
workflowsRegistered: 0,
|
|
466
|
+
schedulesRegistered: 0,
|
|
467
|
+
runsTotal: 0,
|
|
468
|
+
wakeupsTotal: 0,
|
|
469
|
+
runsByStatus: {},
|
|
470
|
+
generatedAtMs: Date.now(),
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
return await collectMetrics()
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return [
|
|
477
|
+
"# HELP voyant_selfhost_metrics_error Metrics collection failure state.",
|
|
478
|
+
"# TYPE voyant_selfhost_metrics_error gauge",
|
|
479
|
+
"voyant_selfhost_metrics_error 1",
|
|
480
|
+
`# metrics_error ${escapeMetricLabelValue(err instanceof Error ? err.message : String(err))}`,
|
|
481
|
+
"",
|
|
482
|
+
].join("\n")
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function escapeMetricLabelValue(value: string): string {
|
|
487
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")
|
|
488
|
+
}
|