@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
@@ -0,0 +1,495 @@
1
+ import { createServer } from "node:http"
2
+ import { resolve as resolvePath } from "node:path"
3
+ import { handleStepRequest } from "@voyantjs/workflows/handler"
4
+ import { createInMemoryRateLimiter } from "@voyantjs/workflows/rate-limit"
5
+ import {
6
+ createInMemoryRunStore,
7
+ type RunRecord,
8
+ resume,
9
+ resumeDueAlarms,
10
+ type StepHandler,
11
+ trigger,
12
+ } from "@voyantjs/workflows-orchestrator"
13
+ import { createChunkBus } from "./dashboard-chunks.js"
14
+ import { startServer } from "./dashboard-http-server.js"
15
+ import { renderMetrics } from "./dashboard-metrics.js"
16
+ import {
17
+ assertReadableDirectory,
18
+ assertReadableFile,
19
+ findDashboardDir,
20
+ } from "./dashboard-static.js"
21
+ import type {
22
+ HealthReport,
23
+ NodeSelfHostServerOptions,
24
+ ServeDeps,
25
+ ServeHandle,
26
+ } from "./dashboard-types.js"
27
+ import { loadEntryFile } from "./entry-loader.js"
28
+ import { durationToMs, generateLocalRunId } from "./local-runtime.js"
29
+ import { createDefaultWakeupLeaseOwner, localTenantMeta } from "./node-selfhost-defaults.js"
30
+ import {
31
+ mergeTags,
32
+ requireExternalResumeFromStep,
33
+ requireExternalSeedResults,
34
+ } from "./node-selfhost-resume-input.js"
35
+ import { nodeStepRunner } from "./node-step-runner.js"
36
+ import { createPersistentWakeupManager } from "./persistent-wakeup-manager.js"
37
+ import { createPostgresConnection } from "./postgres.js"
38
+ import { createPostgresSnapshotRunStore } from "./postgres-snapshot-run-store.js"
39
+ import { createPostgresWakeupStore } from "./postgres-wakeup-store.js"
40
+ import { buildResumeJournal, buildSeededResumeJournal } from "./resume-run.js"
41
+ import { recordToSnapshot, snapshotToRecord } from "./run-record-snapshot.js"
42
+ import { createScheduler, type SchedulerHandle, type ScheduleSource } from "./scheduler.js"
43
+ import { createFsSnapshotRunStore, type StoredRun } from "./snapshot-run-store.js"
44
+ import { createFsWakeupStore } from "./wakeup-store.js"
45
+
46
+ type WorkflowRegistryModule = Pick<
47
+ typeof import("@voyantjs/workflows"),
48
+ "__resetRegistry" | "__listRegisteredWorkflows"
49
+ >
50
+
51
+ export async function startNodeSelfHostServer(
52
+ opts: NodeSelfHostServerOptions,
53
+ ): Promise<ServeHandle> {
54
+ const deps = await createNodeSelfHostDeps(opts)
55
+ return startServer(
56
+ {
57
+ port: opts.port ?? 3232,
58
+ host: opts.host ?? "127.0.0.1",
59
+ },
60
+ deps,
61
+ )
62
+ }
63
+
64
+ export async function createNodeSelfHostDeps(
65
+ opts: Pick<
66
+ NodeSelfHostServerOptions,
67
+ | "entryFile"
68
+ | "staticDir"
69
+ | "cacheBustEntry"
70
+ | "services"
71
+ | "store"
72
+ | "databaseUrl"
73
+ | "wakeupPollIntervalMs"
74
+ | "wakeupLeaseMs"
75
+ | "wakeupLeaseOwner"
76
+ >,
77
+ ): Promise<ServeDeps> {
78
+ let staticDir = opts.staticDir
79
+ if (!staticDir) staticDir = await findDashboardDir(process.cwd())
80
+ if (!staticDir && typeof import.meta.url === "string") {
81
+ const here = resolvePath(new URL(".", import.meta.url).pathname)
82
+ staticDir = await findDashboardDir(here)
83
+ }
84
+ if (staticDir) {
85
+ await assertReadableDirectory(staticDir, "dashboard static dir")
86
+ }
87
+
88
+ const databaseUrl = opts.databaseUrl ?? process.env.DATABASE_URL
89
+ const pg = databaseUrl ? createPostgresConnection({ databaseUrl }) : undefined
90
+ const store =
91
+ opts.store ?? (pg ? createPostgresSnapshotRunStore({ db: pg.db }) : createFsSnapshotRunStore())
92
+ const wfMod: WorkflowRegistryModule = await import("@voyantjs/workflows")
93
+ wfMod.__resetRegistry()
94
+
95
+ const entryAbs = resolvePath(process.cwd(), opts.entryFile)
96
+ await assertReadableFile(entryAbs, "workflow entry")
97
+ await loadEntryFile(entryAbs, { cacheBust: opts.cacheBustEntry })
98
+
99
+ const _handlerMod = await import("@voyantjs/workflows/handler")
100
+ const rateLimiter = createInMemoryRateLimiter()
101
+ const chunkBus = createChunkBus()
102
+
103
+ const stepHandler: StepHandler = async (req, stepOpts) =>
104
+ handleStepRequest(req, { rateLimiter, nodeStepRunner, services: opts.services }, stepOpts)
105
+
106
+ const wakeupStore = pg ? createPostgresWakeupStore({ db: pg.db }) : createFsWakeupStore()
107
+ const leaseOwner = opts.wakeupLeaseOwner ?? createDefaultWakeupLeaseOwner()
108
+
109
+ const listWorkflows = () =>
110
+ wfMod.__listRegisteredWorkflows().map((workflow) => ({
111
+ id: workflow.id,
112
+ description: workflow.config.description,
113
+ }))
114
+ const registeredWorkflows = listWorkflows()
115
+ if (registeredWorkflows.length === 0) {
116
+ throw new Error(
117
+ "voyant workflows selfhost: workflow entry registered no workflows. " +
118
+ `Check "${entryAbs}" and ensure it calls workflow(...).`,
119
+ )
120
+ }
121
+
122
+ const healthCheck = (): HealthReport => ({
123
+ ok: true,
124
+ service: "voyant-workflows-selfhost",
125
+ })
126
+
127
+ const readinessCheck = async (): Promise<HealthReport> => {
128
+ const checks: Record<string, "ok" | "error"> = {
129
+ workflowEntry: "ok",
130
+ }
131
+ const details: Record<string, unknown> = {}
132
+
133
+ if (pg) {
134
+ try {
135
+ await pg.pool.query("select 1")
136
+ checks.database = "ok"
137
+ } catch (err) {
138
+ checks.database = "error"
139
+ details.database = err instanceof Error ? err.message : String(err)
140
+ }
141
+ }
142
+
143
+ return {
144
+ ok: Object.values(checks).every((status) => status === "ok"),
145
+ service: "voyant-workflows-selfhost",
146
+ checks,
147
+ details: Object.keys(details).length > 0 ? details : undefined,
148
+ }
149
+ }
150
+
151
+ const collectMetrics = async (): Promise<string> => {
152
+ const runs = await store.list()
153
+ const wakeups = await wakeupStore.list()
154
+ const runsByStatus = runs.reduce<Record<string, number>>((acc, run) => {
155
+ acc[run.status] = (acc[run.status] ?? 0) + 1
156
+ return acc
157
+ }, {})
158
+ return renderMetrics({
159
+ workflowsRegistered: listWorkflows().length,
160
+ schedulesRegistered: listSchedules ? listSchedules().length : 0,
161
+ runsTotal: runs.length,
162
+ wakeupsTotal: wakeups.length,
163
+ runsByStatus,
164
+ generatedAtMs: Date.now(),
165
+ })
166
+ }
167
+
168
+ const wakeupManager = createPersistentWakeupManager({
169
+ wakeupStore,
170
+ listRuns: () => store.list(),
171
+ getRun: (runId) => store.get(runId),
172
+ saveRun: async (stored) => {
173
+ if (!store.update) {
174
+ throw new Error("snapshot run store does not support update")
175
+ }
176
+ return store.update(stored)
177
+ },
178
+ toRecord: (stored) => snapshotToRecord(stored),
179
+ fromRecord: (record, base) => recordToSnapshot(record, base),
180
+ handler: stepHandler,
181
+ onStreamChunk: ({ runId, chunk }) => chunkBus.publish({ runId, chunk }),
182
+ logger: (level, message, data) => {
183
+ const error =
184
+ typeof data === "object" && data !== null && "error" in data ? data.error : undefined
185
+ const details = error ? `: ${String(error)}` : ""
186
+ if (level === "error") console.error(`[voyant] ${message}${details}`)
187
+ else console.warn(`[voyant] ${message}${details}`)
188
+ },
189
+ createRunStore: createInMemoryRunStore,
190
+ resumeDueAlarmsImpl: resumeDueAlarms,
191
+ leaseOwner,
192
+ intervalMs: opts.wakeupPollIntervalMs,
193
+ leaseMs: opts.wakeupLeaseMs,
194
+ })
195
+
196
+ const cancelRun: ServeDeps["cancelRun"] = async ({ runId }) => {
197
+ const existing = await store.get(runId)
198
+ if (!existing) return { ok: false, message: `run "${runId}" not found`, exitCode: 1 }
199
+ if (existing.status !== "waiting") {
200
+ return {
201
+ ok: false,
202
+ message: `run "${runId}" is not parked (status: ${existing.status})`,
203
+ exitCode: 2,
204
+ }
205
+ }
206
+ if (!store.update) {
207
+ return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
208
+ }
209
+ const now = Date.now()
210
+ const updated: StoredRun = {
211
+ ...existing,
212
+ status: "cancelled",
213
+ completedAt: now,
214
+ durationMs: now - existing.startedAt,
215
+ result: {
216
+ ...existing.result,
217
+ status: "cancelled",
218
+ cancelledAt: now,
219
+ },
220
+ }
221
+ const saved = await store.update(updated)
222
+ await wakeupManager.clear(runId)
223
+ return { ok: true, saved }
224
+ }
225
+
226
+ const triggerRun: ServeDeps["triggerRun"] = async ({
227
+ workflowId,
228
+ input,
229
+ runId,
230
+ tags,
231
+ triggeredByUserId,
232
+ }) => {
233
+ const workflow = wfMod.__listRegisteredWorkflows().find((entry) => entry.id === workflowId)
234
+ if (!workflow) {
235
+ return {
236
+ ok: false,
237
+ message: `workflow "${workflowId}" is not registered in ${entryAbs}.`,
238
+ exitCode: 2,
239
+ }
240
+ }
241
+ const nextRunId = runId ?? generateLocalRunId()
242
+ const memStore = createInMemoryRunStore()
243
+ let record: RunRecord
244
+ try {
245
+ record = await trigger(
246
+ {
247
+ runId: nextRunId,
248
+ workflowId,
249
+ workflowVersion: "local",
250
+ input,
251
+ tenantMeta: localTenantMeta,
252
+ tags,
253
+ triggeredBy:
254
+ triggeredByUserId === undefined || triggeredByUserId === null
255
+ ? { kind: "api" }
256
+ : { kind: "api", actor: triggeredByUserId },
257
+ timeoutMs: durationToMs(workflow.config.timeout),
258
+ },
259
+ {
260
+ store: memStore,
261
+ handler: stepHandler,
262
+ onStreamChunk: (chunk) => chunkBus.publish({ runId: nextRunId, chunk }),
263
+ },
264
+ )
265
+ } catch (err) {
266
+ return {
267
+ ok: false,
268
+ message: err instanceof Error ? err.message : String(err),
269
+ exitCode: 1,
270
+ }
271
+ }
272
+ if (!store.update) {
273
+ return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
274
+ }
275
+ const stored = recordToSnapshot(record)
276
+ stored.entryFile = entryAbs
277
+ const saved = await store.update(stored)
278
+ await wakeupManager.syncStoredRun(saved)
279
+ return { ok: true, saved }
280
+ }
281
+
282
+ const resumeRun: ServeDeps["resumeRun"] = async ({
283
+ parentRunId,
284
+ workflowId: requestedWorkflowId,
285
+ input,
286
+ resumeFromStep,
287
+ seedResults,
288
+ runId,
289
+ tags,
290
+ triggeredByUserId,
291
+ }) => {
292
+ const existing = await store.get(parentRunId)
293
+ let parent: RunRecord | undefined
294
+ if (existing) {
295
+ try {
296
+ parent = snapshotToRecord(existing)
297
+ } catch (err) {
298
+ return {
299
+ ok: false,
300
+ message: err instanceof Error ? err.message : String(err),
301
+ exitCode: 1,
302
+ }
303
+ }
304
+ } else if (!requestedWorkflowId) {
305
+ return {
306
+ ok: false,
307
+ message:
308
+ `parent run "${parentRunId}" not found; pass workflowId, resumeFromStep, ` +
309
+ "and seedResults to resume from an external workflow-runs parent",
310
+ exitCode: 1,
311
+ }
312
+ }
313
+
314
+ const workflowId = parent?.workflowId ?? requestedWorkflowId!
315
+ const workflow = wfMod.__listRegisteredWorkflows().find((entry) => entry.id === workflowId)
316
+ if (!workflow) {
317
+ return {
318
+ ok: false,
319
+ message: `workflow "${workflowId}" is not registered in ${entryAbs}.`,
320
+ exitCode: 2,
321
+ }
322
+ }
323
+
324
+ let resumeSeed: ReturnType<typeof buildResumeJournal>
325
+ try {
326
+ resumeSeed = parent
327
+ ? buildResumeJournal({
328
+ parent,
329
+ resumeFromStep,
330
+ seedResults,
331
+ })
332
+ : buildSeededResumeJournal({
333
+ parentRunId,
334
+ resumeFromStep: requireExternalResumeFromStep(resumeFromStep),
335
+ seedResults: requireExternalSeedResults(seedResults),
336
+ })
337
+ } catch (err) {
338
+ return {
339
+ ok: false,
340
+ message: err instanceof Error ? err.message : String(err),
341
+ exitCode: 2,
342
+ }
343
+ }
344
+
345
+ const memStore = createInMemoryRunStore()
346
+ const nextRunId = runId ?? generateLocalRunId()
347
+ let record: RunRecord
348
+ try {
349
+ record = await trigger(
350
+ {
351
+ runId: nextRunId,
352
+ workflowId,
353
+ workflowVersion: parent?.workflowVersion ?? "local",
354
+ input: input === undefined ? parent?.input : input,
355
+ tenantMeta: parent?.tenantMeta ?? localTenantMeta,
356
+ environment: parent?.environment,
357
+ triggeredBy:
358
+ triggeredByUserId === undefined || triggeredByUserId === null
359
+ ? { kind: "api" }
360
+ : { kind: "api", actor: triggeredByUserId },
361
+ tags: mergeTags(parent?.tags, [
362
+ "resume:true",
363
+ `parentRunId:${parent?.id ?? parentRunId}`,
364
+ ...(tags ?? []),
365
+ ]),
366
+ timeoutMs: durationToMs(workflow.config.timeout),
367
+ initialJournal: resumeSeed.journal,
368
+ initialMetadataAppliedCount: resumeSeed.metadataAppliedCount,
369
+ },
370
+ {
371
+ store: memStore,
372
+ handler: stepHandler,
373
+ onStreamChunk: (chunk) => chunkBus.publish({ runId: nextRunId, chunk }),
374
+ },
375
+ )
376
+ } catch (err) {
377
+ return {
378
+ ok: false,
379
+ message: err instanceof Error ? err.message : String(err),
380
+ exitCode: 1,
381
+ }
382
+ }
383
+
384
+ if (!store.update) {
385
+ return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
386
+ }
387
+ const stored = recordToSnapshot(record, {
388
+ entryFile: entryAbs,
389
+ replayOf: parent?.id ?? parentRunId,
390
+ })
391
+ const saved = await store.update(stored)
392
+ await wakeupManager.syncStoredRun(saved)
393
+ return {
394
+ ok: true,
395
+ saved,
396
+ parentRunId: parent?.id ?? parentRunId,
397
+ resumeFromStep: resumeSeed.resumeFromStep,
398
+ }
399
+ }
400
+
401
+ const injectWaitpoint: ServeDeps["injectWaitpoint"] = async ({ runId, injection }) => {
402
+ const existing = await store.get(runId)
403
+ if (!existing) {
404
+ return { ok: false, message: `run "${runId}" not found`, exitCode: 1 }
405
+ }
406
+ if (existing.status !== "waiting") {
407
+ return {
408
+ ok: false,
409
+ message: `run "${runId}" is not parked (status: ${existing.status})`,
410
+ exitCode: 2,
411
+ }
412
+ }
413
+ const record = snapshotToRecord(existing)
414
+ if (!record) {
415
+ return { ok: false, message: `run "${runId}" has no resumable snapshot`, exitCode: 1 }
416
+ }
417
+ const memStore = createInMemoryRunStore()
418
+ await memStore.save(record)
419
+ const out = await resume(
420
+ { runId, injection },
421
+ {
422
+ store: memStore,
423
+ handler: stepHandler,
424
+ onStreamChunk: (chunk) => chunkBus.publish({ runId, chunk }),
425
+ },
426
+ )
427
+ if (!out.ok) {
428
+ const exitCode = out.status === "no_match" || out.status === "not_parked" ? 2 : 1
429
+ return { ok: false, message: out.message, exitCode }
430
+ }
431
+ if (!store.update) {
432
+ return { ok: false, message: "snapshot run store does not support update", exitCode: 1 }
433
+ }
434
+ const saved = await store.update(recordToSnapshot(out.record, existing))
435
+ await wakeupManager.syncStoredRun(saved)
436
+ return { ok: true, saved }
437
+ }
438
+
439
+ try {
440
+ await wakeupManager.bootstrap()
441
+ } catch (err) {
442
+ console.warn(
443
+ `[voyant] failed to bootstrap wakeup leases from run store: ${
444
+ err instanceof Error ? err.message : String(err)
445
+ }`,
446
+ )
447
+ }
448
+ wakeupManager.start()
449
+
450
+ let scheduler: SchedulerHandle | undefined
451
+ let listSchedules: ServeDeps["listSchedules"]
452
+ const sources: ScheduleSource[] = []
453
+ for (const workflow of wfMod.__listRegisteredWorkflows()) {
454
+ const decl = workflow.config.schedule
455
+ if (!decl) continue
456
+ const decls = Array.isArray(decl) ? decl : [decl]
457
+ for (const source of decls) {
458
+ sources.push({ workflowId: workflow.id, decl: source })
459
+ }
460
+ }
461
+ if (sources.length > 0) {
462
+ scheduler = createScheduler({
463
+ sources,
464
+ onFire: async ({ workflowId, input }) => {
465
+ await triggerRun({ workflowId, input })
466
+ },
467
+ logger: (level, message) => {
468
+ if (level === "error") console.error(`[scheduler] ${message}`)
469
+ else if (level === "warn") console.warn(`[scheduler] ${message}`)
470
+ },
471
+ })
472
+ listSchedules = () => scheduler!.nextFirings()
473
+ }
474
+
475
+ return {
476
+ store,
477
+ createServer,
478
+ healthCheck,
479
+ readinessCheck,
480
+ collectMetrics,
481
+ shutdown: async () => {
482
+ wakeupManager.stop()
483
+ await pg?.close()
484
+ },
485
+ staticDir,
486
+ triggerRun,
487
+ resumeRun,
488
+ listWorkflows,
489
+ injectWaitpoint,
490
+ scheduler,
491
+ listSchedules,
492
+ cancelRun,
493
+ chunkBus,
494
+ }
495
+ }
@@ -0,0 +1,27 @@
1
+ export function mergeTags(...groups: ReadonlyArray<ReadonlyArray<string> | undefined>): string[] {
2
+ const tags = new Set<string>()
3
+ for (const group of groups) {
4
+ for (const tag of group ?? []) tags.add(tag)
5
+ }
6
+ return Array.from(tags)
7
+ }
8
+
9
+ export function requireExternalResumeFromStep(resumeFromStep: string | undefined): string {
10
+ if (!resumeFromStep) {
11
+ throw new Error(
12
+ "resumeFromStep is required when the parent run is not stored by this self-host server",
13
+ )
14
+ }
15
+ return resumeFromStep
16
+ }
17
+
18
+ export function requireExternalSeedResults(
19
+ seedResults: Record<string, unknown> | undefined,
20
+ ): Record<string, unknown> {
21
+ if (!seedResults) {
22
+ throw new Error(
23
+ "seedResults is required when the parent run is not stored by this self-host server",
24
+ )
25
+ }
26
+ return seedResults
27
+ }
@@ -1,4 +1,5 @@
1
1
  // Mode 2 driver — pure Node, Postgres-backed.
2
+ // agent-quality: file-size exception -- Public driver factory currently owns manifest, trigger, event-ingest, schedule, and admin wiring; split only with a dedicated driver-surface refactor.
2
3
  //
3
4
  // Returns a `DriverFactory` (per architecture doc §6.3) that the framework
4
5
  // invokes after `createApp()` has assembled its `ModuleContainer`. Composes:
@@ -125,6 +126,61 @@ const DEFAULT_TENANT_META: RunRecord["tenantMeta"] = {
125
126
 
126
127
  const DEFAULT_MANIFEST_KEEP = 3
127
128
 
129
+ function serializeWorkflowManifest(manifest: WorkflowManifest): Record<string, unknown> {
130
+ return { ...manifest }
131
+ }
132
+
133
+ function deserializeWorkflowManifest(manifest: Record<string, unknown>): WorkflowManifest {
134
+ const {
135
+ schemaVersion,
136
+ projectId,
137
+ versionId,
138
+ builtAt,
139
+ builderVersion,
140
+ capabilities,
141
+ workflows,
142
+ eventFilters,
143
+ bindings,
144
+ environments,
145
+ } = manifest
146
+
147
+ if (
148
+ schemaVersion !== 1 ||
149
+ typeof projectId !== "string" ||
150
+ typeof versionId !== "string" ||
151
+ typeof builtAt !== "number" ||
152
+ typeof builderVersion !== "string" ||
153
+ !isStringArray(capabilities) ||
154
+ !Array.isArray(workflows) ||
155
+ !Array.isArray(eventFilters) ||
156
+ !isRecord(bindings) ||
157
+ !isRecord(environments)
158
+ ) {
159
+ throw new Error("stored workflow manifest has an invalid shape")
160
+ }
161
+
162
+ return {
163
+ schemaVersion,
164
+ projectId,
165
+ versionId,
166
+ builtAt,
167
+ builderVersion,
168
+ capabilities,
169
+ workflows: workflows as WorkflowManifest["workflows"],
170
+ eventFilters: eventFilters as WorkflowManifest["eventFilters"],
171
+ bindings: bindings as WorkflowManifest["bindings"],
172
+ environments: environments as WorkflowManifest["environments"],
173
+ }
174
+ }
175
+
176
+ function isRecord(value: unknown): value is Record<string, unknown> {
177
+ return typeof value === "object" && value !== null && !Array.isArray(value)
178
+ }
179
+
180
+ function isStringArray(value: unknown): value is string[] {
181
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
182
+ }
183
+
128
184
  /**
129
185
  * Build the Mode 2 driver factory. The factory closes over its options
130
186
  * and returns a fresh `WorkflowDriver` when `createApp()` (or a test)
@@ -224,7 +280,7 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
224
280
  const result = await manifestStore.registerManifest({
225
281
  environment: args.environment as string,
226
282
  versionId: args.manifest.versionId,
227
- manifest: args.manifest as unknown as Record<string, unknown>,
283
+ manifest: serializeWorkflowManifest(args.manifest),
228
284
  })
229
285
  // Best-effort prune; failures here shouldn't fail boot.
230
286
  try {
@@ -245,7 +301,7 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
245
301
  async function getManifest(args: { environment: string }): Promise<WorkflowManifest | null> {
246
302
  const envelope = await manifestStore.getCurrent(args.environment)
247
303
  if (!envelope) return null
248
- return envelope.manifest as unknown as WorkflowManifest
304
+ return deserializeWorkflowManifest(envelope.manifest)
249
305
  }
250
306
 
251
307
  async function trigger<TIn, TOut>(
@@ -289,7 +345,7 @@ export function createNodeStandaloneDriver(opts: NodeStandaloneDriverOptions): D
289
345
  }
290
346
  }
291
347
  const eventId = await ensureEventId(args.envelope)
292
- const manifest = stored.manifest as unknown as WorkflowManifest
348
+ const manifest = deserializeWorkflowManifest(stored.manifest)
293
349
  const routed = routeEvent({
294
350
  manifest,
295
351
  envelope: args.envelope,
@@ -0,0 +1,28 @@
1
+ import type { StepRunner } from "@voyantjs/workflows/handler"
2
+
3
+ export const nodeStepRunner: StepRunner = async ({ attempt, fn, stepCtx }) => {
4
+ const startedAt = Date.now()
5
+ try {
6
+ const output = await fn(stepCtx)
7
+ return { attempt, status: "ok", output, startedAt, finishedAt: Date.now() }
8
+ } catch (err) {
9
+ const error = err as Error
10
+ const code =
11
+ typeof (err as { code?: unknown }).code === "string"
12
+ ? (err as { code: string }).code
13
+ : "UNKNOWN"
14
+ return {
15
+ attempt,
16
+ status: "err",
17
+ error: {
18
+ category: "USER_ERROR",
19
+ code,
20
+ message: error?.message ?? String(err),
21
+ name: error?.name,
22
+ stack: error?.stack,
23
+ },
24
+ startedAt,
25
+ finishedAt: Date.now(),
26
+ }
27
+ }
28
+ }
@@ -131,9 +131,11 @@ export function createPostgresManifestStore(opts: PostgresManifestStoreOptions):
131
131
  if (keepIds.length === 0) return { deleted: 0 }
132
132
 
133
133
  const result = await db.execute(
134
+ // agent-quality: raw-sql reviewed -- owner: workflows-orchestrator-node; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
134
135
  sql`DELETE FROM ${workflowManifestsTable}
135
136
  WHERE environment = ${environment}
136
137
  AND version_id NOT IN (${sql.join(
138
+ // agent-quality: raw-sql reviewed -- owner: workflows-orchestrator-node; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
137
139
  keepIds.map((id) => sql`${id}`),
138
140
  sql`, `,
139
141
  )})`,
@@ -153,7 +153,7 @@ function recordToValues(record: RunRecord) {
153
153
  completedAt: record.completedAt,
154
154
  }),
155
155
  input: normalizeJson(record.input),
156
- runRecord: normalizeRequiredJson(record as unknown as Record<string, unknown>),
156
+ runRecord: normalizeRequiredJson({ ...record }),
157
157
  entryFile: null,
158
158
  replayOf: null,
159
159
  idempotencyKey: record.idempotencyKey ?? null,
@@ -52,6 +52,7 @@ export const snapshotRunsTable = pgTable(
52
52
  */
53
53
  idempotencyIdx: uniqueIndex("voyant_snapshot_runs_idempotency_idx")
54
54
  .on(table.workflowId, table.idempotencyKey)
55
+ // agent-quality: raw-sql reviewed -- owner: workflows-orchestrator-node; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
55
56
  .where(sql`${table.idempotencyKey} IS NOT NULL`),
56
57
  }),
57
58
  )
@@ -95,6 +96,7 @@ export const workflowManifestsTable = pgTable(
95
96
  pk: primaryKey({ columns: [table.environment, table.versionId] }),
96
97
  currentIdx: uniqueIndex("voyant_workflow_manifests_current_idx")
97
98
  .on(table.environment)
99
+ // agent-quality: raw-sql reviewed -- owner: workflows-orchestrator-node; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
98
100
  .where(sql`${table.isCurrent}`),
99
101
  }),
100
102
  )