@voyantjs/workflows-orchestrator-node 0.107.5 → 0.107.6

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.
Files changed (69) hide show
  1. package/dist/dashboard-chunks.d.ts +17 -0
  2. package/dist/dashboard-chunks.d.ts.map +1 -0
  3. package/dist/dashboard-chunks.js +19 -0
  4. package/dist/dashboard-http-server.d.ts +6 -0
  5. package/dist/dashboard-http-server.d.ts.map +1 -0
  6. package/dist/dashboard-http-server.js +99 -0
  7. package/dist/dashboard-metrics.d.ts +3 -0
  8. package/dist/dashboard-metrics.d.ts.map +1 -0
  9. package/dist/dashboard-metrics.js +26 -0
  10. package/dist/dashboard-request.d.ts +7 -0
  11. package/dist/dashboard-request.d.ts.map +1 -0
  12. package/dist/dashboard-request.js +436 -0
  13. package/dist/dashboard-server.d.ts +9 -171
  14. package/dist/dashboard-server.d.ts.map +1 -1
  15. package/dist/dashboard-server.js +7 -1229
  16. package/dist/dashboard-sse.d.ts +7 -0
  17. package/dist/dashboard-sse.d.ts.map +1 -0
  18. package/dist/dashboard-sse.js +134 -0
  19. package/dist/dashboard-static.d.ts +7 -0
  20. package/dist/dashboard-static.d.ts.map +1 -0
  21. package/dist/dashboard-static.js +89 -0
  22. package/dist/dashboard-types.d.ts +134 -0
  23. package/dist/dashboard-types.d.ts.map +1 -0
  24. package/dist/dashboard-types.js +1 -0
  25. package/dist/node-selfhost-defaults.d.ts +7 -0
  26. package/dist/node-selfhost-defaults.d.ts.map +1 -0
  27. package/dist/node-selfhost-defaults.js +8 -0
  28. package/dist/node-selfhost-deps.d.ts +4 -0
  29. package/dist/node-selfhost-deps.d.ts.map +1 -0
  30. package/dist/node-selfhost-deps.js +403 -0
  31. package/dist/node-selfhost-resume-input.d.ts +4 -0
  32. package/dist/node-selfhost-resume-input.d.ts.map +1 -0
  33. package/dist/node-selfhost-resume-input.js +20 -0
  34. package/dist/node-standalone-driver.d.ts.map +1 -1
  35. package/dist/node-standalone-driver.js +40 -3
  36. package/dist/node-step-runner.d.ts +3 -0
  37. package/dist/node-step-runner.d.ts.map +1 -0
  38. package/dist/node-step-runner.js +26 -0
  39. package/dist/postgres-manifest-store.d.ts.map +1 -1
  40. package/dist/postgres-manifest-store.js +6 -2
  41. package/dist/postgres-run-record-store.js +1 -1
  42. package/dist/postgres-schema.d.ts.map +1 -1
  43. package/dist/postgres-schema.js +2 -0
  44. package/dist/sleep-alarm-manager.d.ts.map +1 -1
  45. package/dist/sleep-alarm-manager.js +9 -1
  46. package/dist/store-stream.d.ts.map +1 -1
  47. package/dist/store-stream.js +9 -1
  48. package/dist/wakeup-poller.d.ts.map +1 -1
  49. package/dist/wakeup-poller.js +9 -1
  50. package/package.json +3 -3
  51. package/src/dashboard-chunks.ts +35 -0
  52. package/src/dashboard-http-server.ts +118 -0
  53. package/src/dashboard-metrics.ts +39 -0
  54. package/src/dashboard-request.ts +488 -0
  55. package/src/dashboard-server.ts +17 -1535
  56. package/src/dashboard-sse.ts +150 -0
  57. package/src/dashboard-static.ts +88 -0
  58. package/src/dashboard-types.ts +106 -0
  59. package/src/node-selfhost-defaults.ts +9 -0
  60. package/src/node-selfhost-deps.ts +495 -0
  61. package/src/node-selfhost-resume-input.ts +27 -0
  62. package/src/node-standalone-driver.ts +59 -3
  63. package/src/node-step-runner.ts +28 -0
  64. package/src/postgres-manifest-store.ts +2 -0
  65. package/src/postgres-run-record-store.ts +1 -1
  66. package/src/postgres-schema.ts +2 -0
  67. package/src/sleep-alarm-manager.ts +12 -1
  68. package/src/store-stream.ts +12 -1
  69. package/src/wakeup-poller.ts +12 -1
@@ -1,1535 +1,17 @@
1
- import { readFile, stat } from "node:fs/promises"
2
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
3
- import { extname, join, resolve as resolvePath } from "node:path"
4
- import { URL } from "node:url"
5
- import type { ServiceResolver } from "@voyantjs/workflows/driver"
6
- import { handleStepRequest, type StepRunner } from "@voyantjs/workflows/handler"
7
- import { createInMemoryRateLimiter } from "@voyantjs/workflows/rate-limit"
8
- import {
9
- createInMemoryRunStore,
10
- type RunRecord,
11
- resume,
12
- resumeDueAlarms,
13
- type StepHandler,
14
- trigger,
15
- type WaitpointInjection,
16
- } from "@voyantjs/workflows-orchestrator"
17
- import { loadEntryFile } from "./entry-loader.js"
18
- import { durationToMs, generateLocalRunId } from "./local-runtime.js"
19
- import { createPersistentWakeupManager } from "./persistent-wakeup-manager.js"
20
- import { createPostgresConnection } from "./postgres.js"
21
- import { createPostgresSnapshotRunStore } from "./postgres-snapshot-run-store.js"
22
- import { createPostgresWakeupStore } from "./postgres-wakeup-store.js"
23
- import { buildResumeJournal, buildSeededResumeJournal } from "./resume-run.js"
24
- import { recordToSnapshot, snapshotToRecord } from "./run-record-snapshot.js"
25
- import { createScheduler, type SchedulerHandle, type ScheduleSource } from "./scheduler.js"
26
- import {
27
- createFsSnapshotRunStore,
28
- type ListFilter,
29
- type SnapshotRunStore,
30
- type StoredRun,
31
- } from "./snapshot-run-store.js"
32
- import { createStoreStream, type StoreEvent, type StoreStream } from "./store-stream.js"
33
- import { createFsWakeupStore } from "./wakeup-store.js"
34
-
35
- export interface ServeDeps {
36
- store: SnapshotRunStore
37
- createServer: typeof createServer
38
- shutdown?: () => void | Promise<void>
39
- healthCheck?: () => Promise<HealthReport> | HealthReport
40
- readinessCheck?: () => Promise<HealthReport> | HealthReport
41
- collectMetrics?: () => Promise<string> | string
42
- staticDir?: string
43
- readStatic?: (path: string) => Promise<Uint8Array | null>
44
- triggerRun?: (args: {
45
- workflowId: string
46
- input: unknown
47
- runId?: string
48
- tags?: string[]
49
- triggeredByUserId?: string | null
50
- }) => Promise<{ ok: true; saved: StoredRun } | { ok: false; message: string; exitCode: number }>
51
- resumeRun?: (args: {
52
- parentRunId: string
53
- workflowId?: string
54
- input?: unknown
55
- resumeFromStep?: string
56
- seedResults?: Record<string, unknown>
57
- runId?: string
58
- tags?: string[]
59
- triggeredByUserId?: string | null
60
- }) => Promise<
61
- | { ok: true; saved: StoredRun; parentRunId: string; resumeFromStep: string }
62
- | { ok: false; message: string; exitCode: number }
63
- >
64
- listWorkflows?: () => { id: string; description?: string }[]
65
- injectWaitpoint?: (args: {
66
- runId: string
67
- injection: WaitpointInjection
68
- }) => Promise<{ ok: true; saved: StoredRun } | { ok: false; message: string; exitCode: number }>
69
- scheduler?: SchedulerHandle
70
- listSchedules?: () => { workflowId: string; name?: string; nextAt: number; done: boolean }[]
71
- cancelRun?: (args: {
72
- runId: string
73
- }) => Promise<{ ok: true; saved: StoredRun } | { ok: false; message: string; exitCode: number }>
74
- chunkBus?: ChunkBus
75
- }
76
-
77
- export interface ChunkEvent {
78
- runId: string
79
- chunk: {
80
- streamId: string
81
- seq: number
82
- encoding: "text" | "json" | "base64"
83
- chunk: unknown
84
- final: boolean
85
- at: number
86
- }
87
- }
88
-
89
- export interface ChunkBus {
90
- publish(event: ChunkEvent): void
91
- subscribe(fn: (event: ChunkEvent) => void): () => void
92
- }
93
-
94
- export function createChunkBus(): ChunkBus {
95
- const subs = new Set<(event: ChunkEvent) => void>()
96
- return {
97
- publish(event) {
98
- for (const fn of subs) {
99
- try {
100
- fn(event)
101
- } catch {
102
- // Ignore subscriber errors so streaming keeps going.
103
- }
104
- }
105
- },
106
- subscribe(fn) {
107
- subs.add(fn)
108
- return () => subs.delete(fn)
109
- },
110
- }
111
- }
112
-
113
- export interface RequestHandlerDeps {
114
- store: SnapshotRunStore
115
- healthCheck?: ServeDeps["healthCheck"]
116
- readinessCheck?: ServeDeps["readinessCheck"]
117
- collectMetrics?: ServeDeps["collectMetrics"]
118
- readStatic?: (path: string) => Promise<Uint8Array | null>
119
- hasStaticDashboard?: boolean
120
- triggerRun?: ServeDeps["triggerRun"]
121
- resumeRun?: ServeDeps["resumeRun"]
122
- listWorkflows?: ServeDeps["listWorkflows"]
123
- injectWaitpoint?: ServeDeps["injectWaitpoint"]
124
- listSchedules?: ServeDeps["listSchedules"]
125
- cancelRun?: ServeDeps["cancelRun"]
126
- }
127
-
128
- export interface HandlerResponse {
129
- status: number
130
- headers: Record<string, string>
131
- body: string | Uint8Array
132
- }
133
-
134
- export interface HealthReport {
135
- ok: boolean
136
- service?: string
137
- checks?: Record<string, "ok" | "error">
138
- details?: Record<string, unknown>
139
- }
140
-
141
- export interface MetricsSnapshot {
142
- workflowsRegistered: number
143
- schedulesRegistered: number
144
- runsTotal: number
145
- wakeupsTotal: number
146
- runsByStatus: Record<string, number>
147
- generatedAtMs: number
148
- }
149
-
150
- export async function handleRequest(
151
- req: { method: string; url: string; body?: string },
152
- deps: RequestHandlerDeps,
153
- ): Promise<HandlerResponse> {
154
- const method = (req.method ?? "GET").toUpperCase()
155
- const url = new URL(req.url, "http://local")
156
-
157
- if (method === "OPTIONS") {
158
- return {
159
- status: 204,
160
- headers: {
161
- "access-control-allow-origin": "*",
162
- "access-control-allow-methods": "GET, OPTIONS, POST",
163
- "access-control-allow-headers": "content-type",
164
- },
165
- body: "",
166
- }
167
- }
168
-
169
- if (method === "POST") {
170
- const cancelMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/cancel$/)
171
- if (cancelMatch) {
172
- if (!deps.cancelRun) {
173
- return json(501, {
174
- error: "cancel_not_supported",
175
- message:
176
- "This self-host server was started without a workflow entry. " +
177
- "Restart with `--file <path>` to enable cancellation.",
178
- })
179
- }
180
- const runId = decodeURIComponent(cancelMatch[1]!)
181
- const result = await deps.cancelRun({ runId })
182
- if (!result.ok) {
183
- return json(result.exitCode === 2 ? 400 : 404, {
184
- error: "cancel_failed",
185
- message: result.message,
186
- })
187
- }
188
- return json(200, { saved: result.saved })
189
- }
190
-
191
- const resumeMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/resume$/)
192
- if (resumeMatch) {
193
- if (!deps.resumeRun) {
194
- return json(501, {
195
- error: "resume_not_supported",
196
- message:
197
- "This self-host server was started without a workflow entry. " +
198
- "Restart with `--file <path>` to enable failed-step resume.",
199
- })
200
- }
201
- const parsed = parseResumeRequestBody(req.body)
202
- if (!parsed.ok) {
203
- return json(parsed.status, { error: parsed.error, message: parsed.message })
204
- }
205
- const parentRunId = decodeURIComponent(resumeMatch[1]!)
206
- const result = await deps.resumeRun({ parentRunId, ...parsed.body })
207
- if (!result.ok) {
208
- return json(result.exitCode === 2 ? 400 : 404, {
209
- error: "resume_failed",
210
- message: result.message,
211
- })
212
- }
213
- return json(200, {
214
- saved: result.saved,
215
- parentRunId: result.parentRunId,
216
- resumeFromStep: result.resumeFromStep,
217
- })
218
- }
219
-
220
- const eventsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/events$/)
221
- const signalsMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/signals$/)
222
- const tokenMatch = url.pathname.match(/^\/api\/runs\/([^/]+)\/tokens\/([^/]+)$/)
223
- if (eventsMatch || signalsMatch || tokenMatch) {
224
- if (!deps.injectWaitpoint) {
225
- return json(501, {
226
- error: "inject_not_supported",
227
- message:
228
- "This self-host server was started without a workflow entry. " +
229
- "Restart with `--file <path>` to enable event / signal / token injection.",
230
- })
231
- }
232
- let parsed: Record<string, unknown>
233
- try {
234
- parsed = req.body ? (JSON.parse(req.body) as Record<string, unknown>) : {}
235
- } catch (err) {
236
- return json(400, {
237
- error: "invalid_json",
238
- message: err instanceof Error ? err.message : String(err),
239
- })
240
- }
241
- let injection: WaitpointInjection
242
- if (eventsMatch) {
243
- if (typeof parsed.eventType !== "string" || parsed.eventType.length === 0) {
244
- return json(400, { error: "invalid_body", message: "`eventType` (string) is required" })
245
- }
246
- injection = { kind: "EVENT", eventType: parsed.eventType, payload: parsed.payload }
247
- } else if (signalsMatch) {
248
- if (typeof parsed.name !== "string" || parsed.name.length === 0) {
249
- return json(400, { error: "invalid_body", message: "`name` (string) is required" })
250
- }
251
- injection = { kind: "SIGNAL", name: parsed.name, payload: parsed.payload }
252
- } else {
253
- injection = {
254
- kind: "MANUAL",
255
- tokenId: decodeURIComponent(tokenMatch![2]!),
256
- payload: parsed.payload,
257
- }
258
- }
259
- const runId = decodeURIComponent((eventsMatch?.[1] ?? signalsMatch?.[1] ?? tokenMatch?.[1])!)
260
- const result = await deps.injectWaitpoint({ runId, injection })
261
- if (!result.ok) {
262
- return json(result.exitCode === 2 ? 400 : 404, {
263
- error: "inject_failed",
264
- message: result.message,
265
- })
266
- }
267
- return json(200, { saved: result.saved })
268
- }
269
-
270
- if (url.pathname === "/api/runs") {
271
- if (!deps.triggerRun) {
272
- return json(501, {
273
- error: "trigger_not_supported",
274
- message:
275
- "This self-host server was started without a workflow entry. " +
276
- "Restart with `--file <path>` to enable triggering.",
277
- })
278
- }
279
- let parsed: {
280
- workflowId?: unknown
281
- input?: unknown
282
- runId?: unknown
283
- tags?: unknown
284
- triggeredByUserId?: unknown
285
- }
286
- try {
287
- parsed = req.body ? JSON.parse(req.body) : {}
288
- } catch (err) {
289
- return json(400, {
290
- error: "invalid_json",
291
- message: err instanceof Error ? err.message : String(err),
292
- })
293
- }
294
- if (typeof parsed.workflowId !== "string" || parsed.workflowId.length === 0) {
295
- return json(400, {
296
- error: "invalid_body",
297
- message: "`workflowId` (string) is required",
298
- })
299
- }
300
- if (parsed.runId !== undefined && typeof parsed.runId !== "string") {
301
- return json(400, {
302
- error: "invalid_body",
303
- message: "`runId` must be a string when provided",
304
- })
305
- }
306
- if (parsed.tags !== undefined && !isStringArray(parsed.tags)) {
307
- return json(400, {
308
- error: "invalid_body",
309
- message: "`tags` must be an array of strings when provided",
310
- })
311
- }
312
- if (
313
- parsed.triggeredByUserId !== undefined &&
314
- parsed.triggeredByUserId !== null &&
315
- typeof parsed.triggeredByUserId !== "string"
316
- ) {
317
- return json(400, {
318
- error: "invalid_body",
319
- message: "`triggeredByUserId` must be a string or null when provided",
320
- })
321
- }
322
- const result = await deps.triggerRun({
323
- workflowId: parsed.workflowId,
324
- input: parsed.input,
325
- runId: parsed.runId,
326
- tags: parsed.tags,
327
- triggeredByUserId: parsed.triggeredByUserId,
328
- })
329
- if (!result.ok) {
330
- return json(result.exitCode === 2 ? 400 : 404, {
331
- error: "trigger_failed",
332
- message: result.message,
333
- })
334
- }
335
- return json(200, { saved: result.saved })
336
- }
337
-
338
- return json(404, { error: "route_not_found", path: url.pathname })
339
- }
340
-
341
- if (method !== "GET" && method !== "HEAD") {
342
- return json(405, { error: "method_not_allowed", allowed: ["GET", "HEAD", "OPTIONS", "POST"] })
343
- }
344
-
345
- if (url.pathname === "/healthz") {
346
- const report = await resolveHealthReport(deps.healthCheck, {
347
- ok: true,
348
- service: "voyant-workflows-selfhost",
349
- })
350
- return json(report.ok ? 200 : 503, report)
351
- }
352
-
353
- if (url.pathname === "/readyz") {
354
- const report = await resolveHealthReport(deps.readinessCheck, {
355
- ok: Boolean(deps.triggerRun),
356
- service: "voyant-workflows-selfhost",
357
- checks: {
358
- workflowEntry: deps.triggerRun ? "ok" : "error",
359
- },
360
- details: deps.triggerRun
361
- ? undefined
362
- : {
363
- workflowEntry: "This self-host server was started without a workflow entry.",
364
- },
365
- })
366
- return json(report.ok ? 200 : 503, report)
367
- }
368
-
369
- if (url.pathname === "/metrics") {
370
- const body = await resolveMetricsBody(deps.collectMetrics)
371
- return {
372
- status: 200,
373
- headers: {
374
- "content-type": "text/plain; version=0.0.4; charset=utf-8",
375
- "cache-control": "no-store",
376
- },
377
- body,
378
- }
379
- }
380
-
381
- if (url.pathname === "/" || url.pathname === "") {
382
- if (deps.hasStaticDashboard && deps.readStatic) {
383
- const bytes = await deps.readStatic("index.html")
384
- if (bytes) {
385
- return {
386
- status: 200,
387
- headers: {
388
- "content-type": "text/html; charset=utf-8",
389
- "cache-control": "no-store",
390
- },
391
- body: bytes,
392
- }
393
- }
394
- }
395
- return json(200, {
396
- service: "voyant workflows selfhost",
397
- endpoints: ["/api/runs", "/api/runs/:id"],
398
- })
399
- }
400
-
401
- if (deps.hasStaticDashboard && deps.readStatic && !url.pathname.startsWith("/api/")) {
402
- const clean = url.pathname.replace(/^\/+/, "")
403
- if (clean && !clean.includes("..")) {
404
- const bytes = await deps.readStatic(clean)
405
- if (bytes) {
406
- return {
407
- status: 200,
408
- headers: {
409
- "content-type": mimeFor(clean),
410
- "cache-control": "no-store",
411
- },
412
- body: bytes,
413
- }
414
- }
415
- }
416
- }
417
-
418
- if (url.pathname === "/api/workflows") {
419
- const workflows = deps.listWorkflows ? deps.listWorkflows() : []
420
- return json(200, { workflows })
421
- }
422
-
423
- if (url.pathname === "/api/schedules") {
424
- const schedules = deps.listSchedules ? deps.listSchedules() : []
425
- return json(200, { schedules })
426
- }
427
-
428
- if (url.pathname === "/api/runs") {
429
- const filter: ListFilter = {}
430
- const workflowId = url.searchParams.get("workflow") ?? url.searchParams.get("workflowId")
431
- if (workflowId) filter.workflowId = workflowId
432
- const status = url.searchParams.get("status")
433
- if (status) filter.status = status
434
- const limitRaw = url.searchParams.get("limit")
435
- if (limitRaw !== null) {
436
- const limit = Number.parseInt(limitRaw, 10)
437
- if (Number.isNaN(limit) || limit < 0) {
438
- return json(400, {
439
- error: "invalid_limit",
440
- message: `limit must be a non-negative integer (got "${limitRaw}")`,
441
- })
442
- }
443
- filter.limit = limit
444
- }
445
- const runs = await deps.store.list(filter)
446
- return json(200, { runs })
447
- }
448
-
449
- const runMatch = url.pathname.match(/^\/api\/runs\/([^/]+)$/)
450
- if (runMatch) {
451
- const runId = decodeURIComponent(runMatch[1]!)
452
- const run = await deps.store.get(runId)
453
- if (!run) return json(404, { error: "not_found", runId })
454
- return json(200, { run })
455
- }
456
-
457
- return json(404, { error: "route_not_found", path: url.pathname })
458
- }
459
-
460
- function parseResumeRequestBody(body: string | undefined):
461
- | {
462
- ok: true
463
- body: {
464
- input?: unknown
465
- workflowId?: string
466
- resumeFromStep?: string
467
- seedResults?: Record<string, unknown>
468
- runId?: string
469
- tags?: string[]
470
- triggeredByUserId?: string | null
471
- }
472
- }
473
- | { ok: false; status: number; error: string; message: string } {
474
- let parsed: Record<string, unknown>
475
- try {
476
- parsed = body ? (JSON.parse(body) as Record<string, unknown>) : {}
477
- } catch (err) {
478
- return {
479
- ok: false,
480
- status: 400,
481
- error: "invalid_json",
482
- message: err instanceof Error ? err.message : String(err),
483
- }
484
- }
485
- if (!isPlainObject(parsed)) {
486
- return {
487
- ok: false,
488
- status: 400,
489
- error: "invalid_body",
490
- message: "request body must be an object",
491
- }
492
- }
493
- if (parsed.resumeFromStep !== undefined && typeof parsed.resumeFromStep !== "string") {
494
- return {
495
- ok: false,
496
- status: 400,
497
- error: "invalid_body",
498
- message: "`resumeFromStep` must be a string when provided",
499
- }
500
- }
501
- if (parsed.workflowId !== undefined && typeof parsed.workflowId !== "string") {
502
- return {
503
- ok: false,
504
- status: 400,
505
- error: "invalid_body",
506
- message: "`workflowId` must be a string when provided",
507
- }
508
- }
509
- if (parsed.runId !== undefined && typeof parsed.runId !== "string") {
510
- return {
511
- ok: false,
512
- status: 400,
513
- error: "invalid_body",
514
- message: "`runId` must be a string when provided",
515
- }
516
- }
517
- if (
518
- parsed.triggeredByUserId !== undefined &&
519
- parsed.triggeredByUserId !== null &&
520
- typeof parsed.triggeredByUserId !== "string"
521
- ) {
522
- return {
523
- ok: false,
524
- status: 400,
525
- error: "invalid_body",
526
- message: "`triggeredByUserId` must be a string or null when provided",
527
- }
528
- }
529
- if (parsed.tags !== undefined && !isStringArray(parsed.tags)) {
530
- return {
531
- ok: false,
532
- status: 400,
533
- error: "invalid_body",
534
- message: "`tags` must be an array of strings when provided",
535
- }
536
- }
537
- if (parsed.seedResults !== undefined && !isPlainObject(parsed.seedResults)) {
538
- return {
539
- ok: false,
540
- status: 400,
541
- error: "invalid_body",
542
- message: "`seedResults` must be an object when provided",
543
- }
544
- }
545
- return {
546
- ok: true,
547
- body: {
548
- input: parsed.input,
549
- workflowId: parsed.workflowId as string | undefined,
550
- resumeFromStep: parsed.resumeFromStep,
551
- seedResults: parsed.seedResults as Record<string, unknown> | undefined,
552
- runId: parsed.runId,
553
- tags: parsed.tags as string[] | undefined,
554
- triggeredByUserId: parsed.triggeredByUserId as string | null | undefined,
555
- },
556
- }
557
- }
558
-
559
- export function createStaticReader(rootDir: string): (path: string) => Promise<Uint8Array | null> {
560
- const root = resolvePath(rootDir)
561
- return async (path: string) => {
562
- const absolute = resolvePath(root, path)
563
- if (!absolute.startsWith(`${root}/`) && absolute !== root) return null
564
- try {
565
- return await readFile(absolute)
566
- } catch {
567
- return null
568
- }
569
- }
570
- }
571
-
572
- export async function findDashboardDir(startFrom: string): Promise<string | undefined> {
573
- const candidates = [
574
- join(startFrom, "apps/workflows-local-dashboard/dist"),
575
- join(startFrom, "../local-dashboard/dist"),
576
- join(startFrom, "../../apps/workflows-local-dashboard/dist"),
577
- join(startFrom, "../../../apps/workflows-local-dashboard/dist"),
578
- ]
579
- for (const candidate of candidates) {
580
- try {
581
- const entry = await stat(join(candidate, "index.html"))
582
- if (entry.isFile()) return candidate
583
- } catch {
584
- // Continue scanning candidate locations.
585
- }
586
- }
587
- return undefined
588
- }
589
-
590
- export interface ServeHandle {
591
- close: () => Promise<void>
592
- url: string
593
- }
594
-
595
- export async function startServer(
596
- options: { port: number; host: string },
597
- deps: ServeDeps,
598
- ): Promise<ServeHandle> {
599
- const readStatic =
600
- deps.readStatic ?? (deps.staticDir ? createStaticReader(deps.staticDir) : undefined)
601
- const hasStaticDashboard = Boolean(readStatic)
602
-
603
- let storeStream: StoreStream | undefined
604
- const getStoreStream = (): StoreStream => {
605
- if (!storeStream) storeStream = createStoreStream(deps.store)
606
- return storeStream
607
- }
608
-
609
- const server: Server = deps.createServer(async (req: IncomingMessage, res: ServerResponse) => {
610
- const method = (req.method ?? "GET").toUpperCase()
611
- const url = req.url ?? "/"
612
-
613
- if ((method === "GET" || method === "HEAD") && urlPath(url) === "/api/runs/stream") {
614
- handleSseStream(res, getStoreStream(), deps.chunkBus)
615
- return
616
- }
617
-
618
- const perRunMatch = urlPath(url).match(/^\/api\/runs\/([^/]+)\/stream$/)
619
- if ((method === "GET" || method === "HEAD") && perRunMatch) {
620
- const runId = decodeURIComponent(perRunMatch[1]!)
621
- handleRunSseStream(res, runId, getStoreStream(), deps.chunkBus, deps.store)
622
- return
623
- }
624
-
625
- try {
626
- const body = method === "POST" ? await readRequestBody(req) : undefined
627
- const response = await handleRequest(
628
- { method, url, body },
629
- {
630
- store: deps.store,
631
- healthCheck: deps.healthCheck,
632
- readinessCheck: deps.readinessCheck,
633
- collectMetrics: deps.collectMetrics,
634
- readStatic,
635
- hasStaticDashboard,
636
- triggerRun: deps.triggerRun,
637
- resumeRun: deps.resumeRun,
638
- listWorkflows: deps.listWorkflows,
639
- injectWaitpoint: deps.injectWaitpoint,
640
- listSchedules: deps.listSchedules,
641
- cancelRun: deps.cancelRun,
642
- },
643
- )
644
- res.writeHead(response.status, response.headers)
645
- res.end(response.body)
646
- } catch (err) {
647
- const message = err instanceof Error ? err.message : String(err)
648
- res.writeHead(500, { "content-type": "application/json" })
649
- res.end(JSON.stringify({ error: "internal_error", message }))
650
- }
651
- })
652
-
653
- await new Promise<void>((resolve, reject) => {
654
- server.once("error", reject)
655
- server.listen(options.port, options.host, () => {
656
- server.off("error", reject)
657
- resolve()
658
- })
659
- })
660
-
661
- deps.scheduler?.start()
662
-
663
- return {
664
- url: `http://${options.host}:${options.port}`,
665
- close: () =>
666
- new Promise<void>((resolve, reject) => {
667
- deps.scheduler?.stop()
668
- storeStream?.stop()
669
- ;(server as unknown as { closeAllConnections?: () => void }).closeAllConnections?.()
670
- server.close((err) => {
671
- if (err) {
672
- reject(err)
673
- return
674
- }
675
- Promise.resolve(deps.shutdown?.()).then(() => resolve(), reject)
676
- })
677
- }),
678
- }
679
- }
680
-
681
- export function handleSseStream(
682
- res: ServerResponse,
683
- stream: StoreStream,
684
- chunkBus?: ChunkBus,
685
- ): void {
686
- res.writeHead(200, {
687
- "content-type": "text/event-stream",
688
- "cache-control": "no-store",
689
- connection: "keep-alive",
690
- "access-control-allow-origin": "*",
691
- })
692
- res.write("retry: 3000\n\n")
693
-
694
- const writeEvent = (event: StoreEvent): void => {
695
- try {
696
- res.write(`event: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`)
697
- } catch {
698
- // Ignore write errors on closed sockets.
699
- }
700
- }
701
-
702
- const writeChunk = (event: ChunkEvent): void => {
703
- try {
704
- res.write(`event: stream.chunk\ndata: ${JSON.stringify(event)}\n\n`)
705
- } catch {
706
- // Ignore write errors on closed sockets.
707
- }
708
- }
709
-
710
- const unsubscribeStore = stream.subscribe(writeEvent)
711
- const unsubscribeChunk = chunkBus ? chunkBus.subscribe(writeChunk) : () => {}
712
- const ping = setInterval(() => {
713
- try {
714
- res.write(`: ping ${Date.now()}\n\n`)
715
- } catch {
716
- // Ignore write errors on closed sockets.
717
- }
718
- }, 25_000)
719
- ;(ping as unknown as { unref?: () => void }).unref?.()
720
-
721
- res.on("close", () => {
722
- clearInterval(ping)
723
- unsubscribeStore()
724
- unsubscribeChunk()
725
- })
726
- }
727
-
728
- const TERMINAL_STATUSES = new Set([
729
- "completed",
730
- "failed",
731
- "cancelled",
732
- "compensated",
733
- "compensation_failed",
734
- ])
735
-
736
- export function handleRunSseStream(
737
- res: ServerResponse,
738
- runId: string,
739
- stream: StoreStream,
740
- chunkBus: ChunkBus | undefined,
741
- store: SnapshotRunStore,
742
- ): void {
743
- res.writeHead(200, {
744
- "content-type": "text/event-stream",
745
- "cache-control": "no-store",
746
- connection: "keep-alive",
747
- "access-control-allow-origin": "*",
748
- })
749
- res.write("retry: 3000\n\n")
750
-
751
- let closed = false
752
- const close = (): void => {
753
- if (closed) return
754
- closed = true
755
- try {
756
- res.end()
757
- } catch {
758
- // Ignore close failures.
759
- }
760
- }
761
-
762
- const writeEvent = (kind: string, data: unknown): void => {
763
- if (closed) return
764
- try {
765
- res.write(`event: ${kind}\ndata: ${JSON.stringify(data)}\n\n`)
766
- } catch {
767
- // Ignore write errors on closed sockets.
768
- }
769
- }
770
-
771
- void store.get(runId).then((run) => {
772
- if (run) {
773
- writeEvent("hello", { run })
774
- if (TERMINAL_STATUSES.has(run.status)) close()
775
- } else {
776
- writeEvent("hello", { run: null })
777
- }
778
- })
779
-
780
- const unsubscribeStore = stream.subscribe((event) => {
781
- if (event.kind === "added" || event.kind === "updated") {
782
- if (event.run.id !== runId) return
783
- writeEvent(event.kind, event)
784
- if (TERMINAL_STATUSES.has(event.run.status)) close()
785
- } else if (event.kind === "removed") {
786
- if (event.runId !== runId) return
787
- writeEvent(event.kind, event)
788
- close()
789
- }
790
- })
791
-
792
- const unsubscribeChunk = chunkBus
793
- ? chunkBus.subscribe((event) => {
794
- if (event.runId !== runId) return
795
- writeEvent("stream.chunk", event)
796
- })
797
- : () => {}
798
-
799
- const ping = setInterval(() => {
800
- try {
801
- res.write(`: ping ${Date.now()}\n\n`)
802
- } catch {
803
- // Ignore write errors on closed sockets.
804
- }
805
- }, 25_000)
806
- ;(ping as unknown as { unref?: () => void }).unref?.()
807
-
808
- res.on("close", () => {
809
- closed = true
810
- clearInterval(ping)
811
- unsubscribeStore()
812
- unsubscribeChunk()
813
- })
814
- }
815
-
816
- export interface NodeSelfHostServerOptions {
817
- entryFile: string
818
- port?: number
819
- host?: string
820
- staticDir?: string
821
- cacheBustEntry?: boolean
822
- /**
823
- * Read-only service resolver surfaced to workflow bodies as `ctx.services`.
824
- *
825
- * Use this when the loaded entry file registers package workflows that
826
- * resolve host-provided services, for example
827
- * `ctx.services.resolve("promotions:bulk-reindex-products")`.
828
- */
829
- services?: ServiceResolver
830
- store?: SnapshotRunStore
831
- databaseUrl?: string
832
- wakeupPollIntervalMs?: number
833
- wakeupLeaseMs?: number
834
- wakeupLeaseOwner?: string
835
- }
836
-
837
- export async function startNodeSelfHostServer(
838
- opts: NodeSelfHostServerOptions,
839
- ): Promise<ServeHandle> {
840
- const deps = await createNodeSelfHostDeps(opts)
841
- return startServer(
842
- {
843
- port: opts.port ?? 3232,
844
- host: opts.host ?? "127.0.0.1",
845
- },
846
- deps,
847
- )
848
- }
849
-
850
- export async function createNodeSelfHostDeps(
851
- opts: Pick<
852
- NodeSelfHostServerOptions,
853
- | "entryFile"
854
- | "staticDir"
855
- | "cacheBustEntry"
856
- | "services"
857
- | "store"
858
- | "databaseUrl"
859
- | "wakeupPollIntervalMs"
860
- | "wakeupLeaseMs"
861
- | "wakeupLeaseOwner"
862
- >,
863
- ): Promise<ServeDeps> {
864
- let staticDir = opts.staticDir
865
- if (!staticDir) staticDir = await findDashboardDir(process.cwd())
866
- if (!staticDir && typeof import.meta.url === "string") {
867
- const here = resolvePath(new URL(".", import.meta.url).pathname)
868
- staticDir = await findDashboardDir(here)
869
- }
870
- if (staticDir) {
871
- await assertReadableDirectory(staticDir, "dashboard static dir")
872
- }
873
-
874
- const databaseUrl = opts.databaseUrl ?? process.env.DATABASE_URL
875
- const pg = databaseUrl ? createPostgresConnection({ databaseUrl }) : undefined
876
- const store =
877
- opts.store ?? (pg ? createPostgresSnapshotRunStore({ db: pg.db }) : createFsSnapshotRunStore())
878
- const wfMod = (await import("@voyantjs/workflows")) as unknown as {
879
- __resetRegistry: () => void
880
- __listRegisteredWorkflows: () => Array<{
881
- id: string
882
- config: {
883
- description?: string
884
- schedule?: unknown
885
- timeout?: unknown
886
- }
887
- }>
888
- }
889
- wfMod.__resetRegistry()
890
-
891
- const entryAbs = resolvePath(process.cwd(), opts.entryFile)
892
- await assertReadableFile(entryAbs, "workflow entry")
893
- await loadEntryFile(entryAbs, { cacheBust: opts.cacheBustEntry })
894
-
895
- const _handlerMod = await import("@voyantjs/workflows/handler")
896
- const rateLimiter = createInMemoryRateLimiter()
897
- const chunkBus = createChunkBus()
898
-
899
- const nodeStepRunner: StepRunner = async ({ attempt, fn, stepCtx }) => {
900
- const startedAt = Date.now()
901
- try {
902
- const output = await fn(stepCtx)
903
- return { attempt, status: "ok", output, startedAt, finishedAt: Date.now() }
904
- } catch (err) {
905
- const error = err as Error
906
- const code =
907
- typeof (err as { code?: unknown }).code === "string"
908
- ? (err as { code: string }).code
909
- : "UNKNOWN"
910
- return {
911
- attempt,
912
- status: "err",
913
- error: {
914
- category: "USER_ERROR",
915
- code,
916
- message: error?.message ?? String(err),
917
- name: error?.name,
918
- stack: error?.stack,
919
- },
920
- startedAt,
921
- finishedAt: Date.now(),
922
- }
923
- }
924
- }
925
-
926
- const stepHandler: StepHandler = async (req, stepOpts) =>
927
- handleStepRequest(req, { rateLimiter, nodeStepRunner, services: opts.services }, stepOpts)
928
- const tenantMeta = {
929
- tenantId: "tnt_local",
930
- projectId: "prj_local",
931
- organizationId: "org_local",
932
- }
933
-
934
- const wakeupStore = pg ? createPostgresWakeupStore({ db: pg.db }) : createFsWakeupStore()
935
- const leaseOwner =
936
- opts.wakeupLeaseOwner ??
937
- `node-selfhost-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
938
-
939
- const listWorkflows = () =>
940
- wfMod.__listRegisteredWorkflows().map((workflow) => ({
941
- id: workflow.id,
942
- description: workflow.config.description,
943
- }))
944
- const registeredWorkflows = listWorkflows()
945
- if (registeredWorkflows.length === 0) {
946
- throw new Error(
947
- "voyant workflows selfhost: workflow entry registered no workflows. " +
948
- `Check "${entryAbs}" and ensure it calls workflow(...).`,
949
- )
950
- }
951
-
952
- const healthCheck = (): HealthReport => ({
953
- ok: true,
954
- service: "voyant-workflows-selfhost",
955
- })
956
-
957
- const readinessCheck = async (): Promise<HealthReport> => {
958
- const checks: Record<string, "ok" | "error"> = {
959
- workflowEntry: "ok",
960
- }
961
- const details: Record<string, unknown> = {}
962
-
963
- if (pg) {
964
- try {
965
- await pg.pool.query("select 1")
966
- checks.database = "ok"
967
- } catch (err) {
968
- checks.database = "error"
969
- details.database = err instanceof Error ? err.message : String(err)
970
- }
971
- }
972
-
973
- return {
974
- ok: Object.values(checks).every((status) => status === "ok"),
975
- service: "voyant-workflows-selfhost",
976
- checks,
977
- details: Object.keys(details).length > 0 ? details : undefined,
978
- }
979
- }
980
-
981
- const collectMetrics = async (): Promise<string> => {
982
- const runs = await store.list()
983
- const wakeups = await wakeupStore.list()
984
- const runsByStatus = runs.reduce<Record<string, number>>((acc, run) => {
985
- acc[run.status] = (acc[run.status] ?? 0) + 1
986
- return acc
987
- }, {})
988
- return renderMetrics({
989
- workflowsRegistered: listWorkflows().length,
990
- schedulesRegistered: listSchedules ? listSchedules().length : 0,
991
- runsTotal: runs.length,
992
- wakeupsTotal: wakeups.length,
993
- runsByStatus,
994
- generatedAtMs: Date.now(),
995
- })
996
- }
997
-
998
- const wakeupManager = createPersistentWakeupManager({
999
- wakeupStore,
1000
- listRuns: () => store.list(),
1001
- getRun: (runId) => store.get(runId),
1002
- saveRun: async (stored) => {
1003
- if (!store.update) {
1004
- throw new Error("snapshot run store does not support update")
1005
- }
1006
- return store.update(stored)
1007
- },
1008
- toRecord: (stored) => snapshotToRecord(stored),
1009
- fromRecord: (record, base) => recordToSnapshot(record, base),
1010
- handler: stepHandler,
1011
- onStreamChunk: ({ runId, chunk }) => chunkBus.publish({ runId, chunk }),
1012
- logger: (level, message, data) => {
1013
- const error =
1014
- typeof data === "object" && data !== null && "error" in data ? data.error : undefined
1015
- const details = error ? `: ${String(error)}` : ""
1016
- if (level === "error") console.error(`[voyant] ${message}${details}`)
1017
- else console.warn(`[voyant] ${message}${details}`)
1018
- },
1019
- createRunStore: createInMemoryRunStore,
1020
- resumeDueAlarmsImpl: resumeDueAlarms,
1021
- leaseOwner,
1022
- intervalMs: opts.wakeupPollIntervalMs,
1023
- leaseMs: opts.wakeupLeaseMs,
1024
- })
1025
-
1026
- const cancelRun: ServeDeps["cancelRun"] = async ({ runId }) => {
1027
- const existing = await store.get(runId)
1028
- if (!existing) return { ok: false, message: `run "${runId}" not found`, exitCode: 1 }
1029
- if (existing.status !== "waiting") {
1030
- return {
1031
- ok: false,
1032
- message: `run "${runId}" is not parked (status: ${existing.status})`,
1033
- exitCode: 2,
1034
- }
1035
- }
1036
- if (!store.update) {
1037
- return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
1038
- }
1039
- const now = Date.now()
1040
- const updated: StoredRun = {
1041
- ...existing,
1042
- status: "cancelled",
1043
- completedAt: now,
1044
- durationMs: now - existing.startedAt,
1045
- result: {
1046
- ...existing.result,
1047
- status: "cancelled",
1048
- cancelledAt: now,
1049
- },
1050
- }
1051
- const saved = await store.update(updated)
1052
- await wakeupManager.clear(runId)
1053
- return { ok: true, saved }
1054
- }
1055
-
1056
- const triggerRun: ServeDeps["triggerRun"] = async ({
1057
- workflowId,
1058
- input,
1059
- runId,
1060
- tags,
1061
- triggeredByUserId,
1062
- }) => {
1063
- const workflow = wfMod.__listRegisteredWorkflows().find((entry) => entry.id === workflowId)
1064
- if (!workflow) {
1065
- return {
1066
- ok: false,
1067
- message: `workflow "${workflowId}" is not registered in ${entryAbs}.`,
1068
- exitCode: 2,
1069
- }
1070
- }
1071
- const nextRunId = runId ?? generateLocalRunId()
1072
- const memStore = createInMemoryRunStore()
1073
- let record: RunRecord
1074
- try {
1075
- record = await trigger(
1076
- {
1077
- runId: nextRunId,
1078
- workflowId,
1079
- workflowVersion: "local",
1080
- input,
1081
- tenantMeta,
1082
- tags,
1083
- triggeredBy:
1084
- triggeredByUserId === undefined || triggeredByUserId === null
1085
- ? { kind: "api" }
1086
- : { kind: "api", actor: triggeredByUserId },
1087
- timeoutMs: durationToMs(workflow.config.timeout),
1088
- },
1089
- {
1090
- store: memStore,
1091
- handler: stepHandler,
1092
- onStreamChunk: (chunk) => chunkBus.publish({ runId: nextRunId, chunk }),
1093
- },
1094
- )
1095
- } catch (err) {
1096
- return {
1097
- ok: false,
1098
- message: err instanceof Error ? err.message : String(err),
1099
- exitCode: 1,
1100
- }
1101
- }
1102
- if (!store.update) {
1103
- return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
1104
- }
1105
- const stored = recordToSnapshot(record)
1106
- stored.entryFile = entryAbs
1107
- const saved = await store.update(stored)
1108
- await wakeupManager.syncStoredRun(saved)
1109
- return { ok: true, saved }
1110
- }
1111
-
1112
- const resumeRun: ServeDeps["resumeRun"] = async ({
1113
- parentRunId,
1114
- workflowId: requestedWorkflowId,
1115
- input,
1116
- resumeFromStep,
1117
- seedResults,
1118
- runId,
1119
- tags,
1120
- triggeredByUserId,
1121
- }) => {
1122
- const existing = await store.get(parentRunId)
1123
- let parent: RunRecord | undefined
1124
- if (existing) {
1125
- try {
1126
- parent = snapshotToRecord(existing)
1127
- } catch (err) {
1128
- return {
1129
- ok: false,
1130
- message: err instanceof Error ? err.message : String(err),
1131
- exitCode: 1,
1132
- }
1133
- }
1134
- } else if (!requestedWorkflowId) {
1135
- return {
1136
- ok: false,
1137
- message:
1138
- `parent run "${parentRunId}" not found; pass workflowId, resumeFromStep, ` +
1139
- "and seedResults to resume from an external workflow-runs parent",
1140
- exitCode: 1,
1141
- }
1142
- }
1143
-
1144
- const workflowId = parent?.workflowId ?? requestedWorkflowId!
1145
- const workflow = wfMod.__listRegisteredWorkflows().find((entry) => entry.id === workflowId)
1146
- if (!workflow) {
1147
- return {
1148
- ok: false,
1149
- message: `workflow "${workflowId}" is not registered in ${entryAbs}.`,
1150
- exitCode: 2,
1151
- }
1152
- }
1153
-
1154
- let resumeSeed: ReturnType<typeof buildResumeJournal>
1155
- try {
1156
- resumeSeed = parent
1157
- ? buildResumeJournal({
1158
- parent,
1159
- resumeFromStep,
1160
- seedResults,
1161
- })
1162
- : buildSeededResumeJournal({
1163
- parentRunId,
1164
- resumeFromStep: requireExternalResumeFromStep(resumeFromStep),
1165
- seedResults: requireExternalSeedResults(seedResults),
1166
- })
1167
- } catch (err) {
1168
- return {
1169
- ok: false,
1170
- message: err instanceof Error ? err.message : String(err),
1171
- exitCode: 2,
1172
- }
1173
- }
1174
-
1175
- const memStore = createInMemoryRunStore()
1176
- const nextRunId = runId ?? generateLocalRunId()
1177
- let record: RunRecord
1178
- try {
1179
- record = await trigger(
1180
- {
1181
- runId: nextRunId,
1182
- workflowId,
1183
- workflowVersion: parent?.workflowVersion ?? "local",
1184
- input: input === undefined ? parent?.input : input,
1185
- tenantMeta: parent?.tenantMeta ?? tenantMeta,
1186
- environment: parent?.environment,
1187
- triggeredBy:
1188
- triggeredByUserId === undefined || triggeredByUserId === null
1189
- ? { kind: "api" }
1190
- : { kind: "api", actor: triggeredByUserId },
1191
- tags: mergeTags(parent?.tags, [
1192
- "resume:true",
1193
- `parentRunId:${parent?.id ?? parentRunId}`,
1194
- ...(tags ?? []),
1195
- ]),
1196
- timeoutMs: durationToMs(workflow.config.timeout),
1197
- initialJournal: resumeSeed.journal,
1198
- initialMetadataAppliedCount: resumeSeed.metadataAppliedCount,
1199
- },
1200
- {
1201
- store: memStore,
1202
- handler: stepHandler,
1203
- onStreamChunk: (chunk) => chunkBus.publish({ runId: nextRunId, chunk }),
1204
- },
1205
- )
1206
- } catch (err) {
1207
- return {
1208
- ok: false,
1209
- message: err instanceof Error ? err.message : String(err),
1210
- exitCode: 1,
1211
- }
1212
- }
1213
-
1214
- if (!store.update) {
1215
- return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
1216
- }
1217
- const stored = recordToSnapshot(record, {
1218
- entryFile: entryAbs,
1219
- replayOf: parent?.id ?? parentRunId,
1220
- })
1221
- const saved = await store.update(stored)
1222
- await wakeupManager.syncStoredRun(saved)
1223
- return {
1224
- ok: true,
1225
- saved,
1226
- parentRunId: parent?.id ?? parentRunId,
1227
- resumeFromStep: resumeSeed.resumeFromStep,
1228
- }
1229
- }
1230
-
1231
- const injectWaitpoint: ServeDeps["injectWaitpoint"] = async ({ runId, injection }) => {
1232
- const existing = await store.get(runId)
1233
- if (!existing) {
1234
- return { ok: false, message: `run "${runId}" not found`, exitCode: 1 }
1235
- }
1236
- if (existing.status !== "waiting") {
1237
- return {
1238
- ok: false,
1239
- message: `run "${runId}" is not parked (status: ${existing.status})`,
1240
- exitCode: 2,
1241
- }
1242
- }
1243
- const record = snapshotToRecord(existing)
1244
- if (!record) {
1245
- return { ok: false, message: `run "${runId}" has no resumable snapshot`, exitCode: 1 }
1246
- }
1247
- const memStore = createInMemoryRunStore()
1248
- await memStore.save(record)
1249
- const out = await resume(
1250
- { runId, injection },
1251
- {
1252
- store: memStore,
1253
- handler: stepHandler,
1254
- onStreamChunk: (chunk) => chunkBus.publish({ runId, chunk }),
1255
- },
1256
- )
1257
- if (!out.ok) {
1258
- const exitCode = out.status === "no_match" || out.status === "not_parked" ? 2 : 1
1259
- return { ok: false, message: out.message, exitCode }
1260
- }
1261
- if (!store.update) {
1262
- return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
1263
- }
1264
- const saved = await store.update(recordToSnapshot(out.record, existing))
1265
- await wakeupManager.syncStoredRun(saved)
1266
- return { ok: true, saved }
1267
- }
1268
-
1269
- try {
1270
- await wakeupManager.bootstrap()
1271
- } catch (err) {
1272
- console.warn(
1273
- `[voyant] failed to bootstrap wakeup leases from run store: ${
1274
- err instanceof Error ? err.message : String(err)
1275
- }`,
1276
- )
1277
- }
1278
- wakeupManager.start()
1279
-
1280
- let scheduler: SchedulerHandle | undefined
1281
- let listSchedules: ServeDeps["listSchedules"]
1282
- const sources: ScheduleSource[] = []
1283
- for (const workflow of wfMod.__listRegisteredWorkflows()) {
1284
- const decl = workflow.config.schedule
1285
- if (!decl) continue
1286
- const decls = Array.isArray(decl) ? decl : [decl]
1287
- for (const source of decls) {
1288
- sources.push({ workflowId: workflow.id, decl: source })
1289
- }
1290
- }
1291
- if (sources.length > 0) {
1292
- scheduler = createScheduler({
1293
- sources,
1294
- onFire: async ({ workflowId, input }) => {
1295
- await triggerRun({ workflowId, input })
1296
- },
1297
- logger: (level, message) => {
1298
- if (level === "error") console.error(`[scheduler] ${message}`)
1299
- else if (level === "warn") console.warn(`[scheduler] ${message}`)
1300
- },
1301
- })
1302
- listSchedules = () => scheduler!.nextFirings()
1303
- }
1304
-
1305
- return {
1306
- store,
1307
- createServer,
1308
- healthCheck,
1309
- readinessCheck,
1310
- collectMetrics,
1311
- shutdown: async () => {
1312
- wakeupManager.stop()
1313
- await pg?.close()
1314
- },
1315
- staticDir,
1316
- triggerRun,
1317
- resumeRun,
1318
- listWorkflows,
1319
- injectWaitpoint,
1320
- scheduler,
1321
- listSchedules,
1322
- cancelRun,
1323
- chunkBus,
1324
- }
1325
- }
1326
-
1327
- async function assertReadableFile(path: string, label: string): Promise<void> {
1328
- let info: Awaited<ReturnType<typeof stat>>
1329
- try {
1330
- info = await stat(path)
1331
- } catch (err) {
1332
- throw new Error(`voyant workflows selfhost: ${label} not found at "${path}"`, { cause: err })
1333
- }
1334
- if (!info.isFile()) {
1335
- throw new Error(`voyant workflows selfhost: ${label} must be a file (got "${path}")`)
1336
- }
1337
- }
1338
-
1339
- async function assertReadableDirectory(path: string, label: string): Promise<void> {
1340
- let info: Awaited<ReturnType<typeof stat>>
1341
- try {
1342
- info = await stat(path)
1343
- } catch (err) {
1344
- throw new Error(`voyant workflows selfhost: ${label} not found at "${path}"`, { cause: err })
1345
- }
1346
- if (!info.isDirectory()) {
1347
- throw new Error(`voyant workflows selfhost: ${label} must be a directory (got "${path}")`)
1348
- }
1349
- }
1350
-
1351
- function mergeTags(...groups: ReadonlyArray<ReadonlyArray<string> | undefined>): string[] {
1352
- const tags = new Set<string>()
1353
- for (const group of groups) {
1354
- for (const tag of group ?? []) tags.add(tag)
1355
- }
1356
- return Array.from(tags)
1357
- }
1358
-
1359
- function requireExternalResumeFromStep(resumeFromStep: string | undefined): string {
1360
- if (!resumeFromStep) {
1361
- throw new Error(
1362
- "resumeFromStep is required when the parent run is not stored by this self-host server",
1363
- )
1364
- }
1365
- return resumeFromStep
1366
- }
1367
-
1368
- function requireExternalSeedResults(
1369
- seedResults: Record<string, unknown> | undefined,
1370
- ): Record<string, unknown> {
1371
- if (!seedResults) {
1372
- throw new Error(
1373
- "seedResults is required when the parent run is not stored by this self-host server",
1374
- )
1375
- }
1376
- return seedResults
1377
- }
1378
-
1379
- function isPlainObject(value: unknown): value is Record<string, unknown> {
1380
- return typeof value === "object" && value !== null && !Array.isArray(value)
1381
- }
1382
-
1383
- function isStringArray(value: unknown): value is string[] {
1384
- return Array.isArray(value) && value.every((item) => typeof item === "string")
1385
- }
1386
-
1387
- function json(status: number, body: unknown): HandlerResponse {
1388
- return {
1389
- status,
1390
- headers: {
1391
- "content-type": "application/json; charset=utf-8",
1392
- "access-control-allow-origin": "*",
1393
- "cache-control": "no-store",
1394
- },
1395
- body: JSON.stringify(body, null, 2),
1396
- }
1397
- }
1398
-
1399
- async function resolveHealthReport(
1400
- check: (() => Promise<HealthReport> | HealthReport) | undefined,
1401
- fallback: HealthReport,
1402
- ): Promise<HealthReport> {
1403
- if (!check) return fallback
1404
- try {
1405
- return await check()
1406
- } catch (err) {
1407
- return {
1408
- ok: false,
1409
- service: fallback.service,
1410
- checks: {
1411
- ...(fallback.checks ?? {}),
1412
- self: "error",
1413
- },
1414
- details: {
1415
- ...(fallback.details ?? {}),
1416
- error: err instanceof Error ? err.message : String(err),
1417
- },
1418
- }
1419
- }
1420
- }
1421
-
1422
- async function resolveMetricsBody(
1423
- collectMetrics: (() => Promise<string> | string) | undefined,
1424
- ): Promise<string> {
1425
- if (!collectMetrics) {
1426
- return renderMetrics({
1427
- workflowsRegistered: 0,
1428
- schedulesRegistered: 0,
1429
- runsTotal: 0,
1430
- wakeupsTotal: 0,
1431
- runsByStatus: {},
1432
- generatedAtMs: Date.now(),
1433
- })
1434
- }
1435
- try {
1436
- return await collectMetrics()
1437
- } catch (err) {
1438
- return [
1439
- "# HELP voyant_selfhost_metrics_error Metrics collection failure state.",
1440
- "# TYPE voyant_selfhost_metrics_error gauge",
1441
- "voyant_selfhost_metrics_error 1",
1442
- `# metrics_error ${escapeMetricLabelValue(err instanceof Error ? err.message : String(err))}`,
1443
- "",
1444
- ].join("\n")
1445
- }
1446
- }
1447
-
1448
- export function renderMetrics(snapshot: MetricsSnapshot): string {
1449
- const lines = [
1450
- "# HELP voyant_selfhost_up Self-host server availability.",
1451
- "# TYPE voyant_selfhost_up gauge",
1452
- "voyant_selfhost_up 1",
1453
- "# HELP voyant_selfhost_workflows_registered Registered workflow count.",
1454
- "# TYPE voyant_selfhost_workflows_registered gauge",
1455
- `voyant_selfhost_workflows_registered ${snapshot.workflowsRegistered}`,
1456
- "# HELP voyant_selfhost_schedules_registered Registered schedule count.",
1457
- "# TYPE voyant_selfhost_schedules_registered gauge",
1458
- `voyant_selfhost_schedules_registered ${snapshot.schedulesRegistered}`,
1459
- "# HELP voyant_selfhost_runs_total Persisted run count.",
1460
- "# TYPE voyant_selfhost_runs_total gauge",
1461
- `voyant_selfhost_runs_total ${snapshot.runsTotal}`,
1462
- "# HELP voyant_selfhost_runs_status Run count by status.",
1463
- "# TYPE voyant_selfhost_runs_status gauge",
1464
- ]
1465
- for (const [status, count] of Object.entries(snapshot.runsByStatus).sort(([a], [b]) =>
1466
- a.localeCompare(b),
1467
- )) {
1468
- lines.push(`voyant_selfhost_runs_status{status="${escapeMetricLabelValue(status)}"} ${count}`)
1469
- }
1470
- lines.push(
1471
- "# HELP voyant_selfhost_wakeups_total Persisted wakeup count.",
1472
- "# TYPE voyant_selfhost_wakeups_total gauge",
1473
- `voyant_selfhost_wakeups_total ${snapshot.wakeupsTotal}`,
1474
- "# HELP voyant_selfhost_metrics_generated_at_seconds Metrics generation timestamp.",
1475
- "# TYPE voyant_selfhost_metrics_generated_at_seconds gauge",
1476
- `voyant_selfhost_metrics_generated_at_seconds ${Math.floor(snapshot.generatedAtMs / 1000)}`,
1477
- "",
1478
- )
1479
- return lines.join("\n")
1480
- }
1481
-
1482
- function escapeMetricLabelValue(value: string): string {
1483
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")
1484
- }
1485
-
1486
- function mimeFor(path: string): string {
1487
- const ext = extname(path).toLowerCase()
1488
- switch (ext) {
1489
- case ".html":
1490
- return "text/html; charset=utf-8"
1491
- case ".js":
1492
- case ".mjs":
1493
- return "application/javascript; charset=utf-8"
1494
- case ".css":
1495
- return "text/css; charset=utf-8"
1496
- case ".json":
1497
- return "application/json; charset=utf-8"
1498
- case ".svg":
1499
- return "image/svg+xml"
1500
- case ".png":
1501
- return "image/png"
1502
- case ".map":
1503
- return "application/json"
1504
- default:
1505
- return "application/octet-stream"
1506
- }
1507
- }
1508
-
1509
- function urlPath(raw: string): string {
1510
- try {
1511
- return new URL(raw, "http://local").pathname
1512
- } catch {
1513
- return raw
1514
- }
1515
- }
1516
-
1517
- async function readRequestBody(req: IncomingMessage): Promise<string> {
1518
- const maxBytes = 1_000_000
1519
- return new Promise((resolve, reject) => {
1520
- let total = 0
1521
- const chunks: Uint8Array[] = []
1522
- req.on("data", (chunk: Uint8Array) => {
1523
- total += chunk.length
1524
- if (total > maxBytes) {
1525
- req.destroy(new Error("request body exceeds 1MB"))
1526
- return
1527
- }
1528
- chunks.push(chunk)
1529
- })
1530
- req.on("end", () => {
1531
- resolve(Buffer.concat(chunks).toString("utf8"))
1532
- })
1533
- req.on("error", reject)
1534
- })
1535
- }
1
+ export type { ChunkBus, ChunkEvent } from "./dashboard-chunks.js"
2
+ export { createChunkBus } from "./dashboard-chunks.js"
3
+ export { startServer } from "./dashboard-http-server.js"
4
+ export { renderMetrics } from "./dashboard-metrics.js"
5
+ export { handleRequest } from "./dashboard-request.js"
6
+ export { handleRunSseStream, handleSseStream } from "./dashboard-sse.js"
7
+ export { createStaticReader, findDashboardDir } from "./dashboard-static.js"
8
+ export type {
9
+ HandlerResponse,
10
+ HealthReport,
11
+ MetricsSnapshot,
12
+ NodeSelfHostServerOptions,
13
+ RequestHandlerDeps,
14
+ ServeDeps,
15
+ ServeHandle,
16
+ } from "./dashboard-types.js"
17
+ export { createNodeSelfHostDeps, startNodeSelfHostServer } from "./node-selfhost-deps.js"