@voyant-travel/workflows-orchestrator 0.107.10

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 (61) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +52 -0
  3. package/README.md +76 -0
  4. package/dist/abort-registry.d.ts +6 -0
  5. package/dist/abort-registry.d.ts.map +1 -0
  6. package/dist/abort-registry.js +37 -0
  7. package/dist/concurrency.d.ts +31 -0
  8. package/dist/concurrency.d.ts.map +1 -0
  9. package/dist/concurrency.js +145 -0
  10. package/dist/drive.d.ts +67 -0
  11. package/dist/drive.d.ts.map +1 -0
  12. package/dist/drive.js +373 -0
  13. package/dist/driver-inmemory.d.ts +30 -0
  14. package/dist/driver-inmemory.d.ts.map +1 -0
  15. package/dist/driver-inmemory.js +394 -0
  16. package/dist/event-router.d.ts +51 -0
  17. package/dist/event-router.d.ts.map +1 -0
  18. package/dist/event-router.js +68 -0
  19. package/dist/http-step-handler.d.ts +25 -0
  20. package/dist/http-step-handler.d.ts.map +1 -0
  21. package/dist/http-step-handler.js +78 -0
  22. package/dist/in-memory-store.d.ts +5 -0
  23. package/dist/in-memory-store.d.ts.map +1 -0
  24. package/dist/in-memory-store.js +41 -0
  25. package/dist/index.d.ts +13 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +22 -0
  28. package/dist/journal-helpers.d.ts +3 -0
  29. package/dist/journal-helpers.d.ts.map +1 -0
  30. package/dist/journal-helpers.js +9 -0
  31. package/dist/orchestrator.d.ts +116 -0
  32. package/dist/orchestrator.d.ts.map +1 -0
  33. package/dist/orchestrator.js +411 -0
  34. package/dist/resume-run.d.ts +40 -0
  35. package/dist/resume-run.d.ts.map +1 -0
  36. package/dist/resume-run.js +119 -0
  37. package/dist/schedule.d.ts +51 -0
  38. package/dist/schedule.d.ts.map +1 -0
  39. package/dist/schedule.js +243 -0
  40. package/dist/testing/driver-compliance.d.ts +58 -0
  41. package/dist/testing/driver-compliance.d.ts.map +1 -0
  42. package/dist/testing/driver-compliance.js +667 -0
  43. package/dist/types.d.ts +182 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +4 -0
  46. package/package.json +51 -0
  47. package/src/__tests__/orchestrator-test-support.ts +18 -0
  48. package/src/abort-registry.ts +41 -0
  49. package/src/concurrency.ts +217 -0
  50. package/src/drive.ts +477 -0
  51. package/src/driver-inmemory.ts +511 -0
  52. package/src/event-router.ts +120 -0
  53. package/src/http-step-handler.ts +112 -0
  54. package/src/in-memory-store.ts +44 -0
  55. package/src/index.ts +73 -0
  56. package/src/journal-helpers.ts +11 -0
  57. package/src/orchestrator.ts +527 -0
  58. package/src/resume-run.ts +162 -0
  59. package/src/schedule.ts +310 -0
  60. package/src/testing/driver-compliance.ts +800 -0
  61. package/src/types.ts +201 -0
@@ -0,0 +1,511 @@
1
+ // agent-quality: file-size exception -- owner: workflows-orchestrator; existing module stays co-located until a dedicated split preserves behavior and tests.
2
+ // In-memory WorkflowDriver — primarily for tests, also for short-lived
3
+ // scripts and the parameterized compliance suite.
4
+ //
5
+ // Wraps the existing pure orchestrator functions (trigger / resume / cancel)
6
+ // with `createInMemoryRunStore` for state and an in-process `StepHandler`
7
+ // glue (`handleStepRequest` from `@voyant-travel/workflows/handler`). Manifests
8
+ // live in a `Map<environment, { manifest, versionId }>`.
9
+ //
10
+ // State lives in the closure returned by `createInMemoryDriver` — every
11
+ // call to the factory yields a fresh, isolated driver. No global state.
12
+ //
13
+ // Authoritative architecture: docs/architecture/workflows-runtime-architecture.md §6.
14
+
15
+ import type {
16
+ EnvironmentName,
17
+ ListRunsOptions,
18
+ Run,
19
+ RunDetail,
20
+ RunSummary,
21
+ TriggerOptions,
22
+ } from "@voyant-travel/workflows"
23
+ import {
24
+ type DriverFactory,
25
+ type DriverFactoryDeps,
26
+ type IngestEventArgs,
27
+ type IngestEventResponse,
28
+ type IngestMatch,
29
+ ManifestNotRegisteredError,
30
+ type WorkflowAdmin,
31
+ type WorkflowDriver,
32
+ } from "@voyant-travel/workflows/driver"
33
+ import { deriveStableEventId } from "@voyant-travel/workflows/events"
34
+ import { handleStepRequest, type WorkflowStepRequest } from "@voyant-travel/workflows/handler"
35
+ import type { WorkflowManifest } from "@voyant-travel/workflows/protocol"
36
+
37
+ import {
38
+ createInProcessConcurrencyCoordinator,
39
+ type RuntimeConcurrencyPolicy,
40
+ } from "./concurrency.js"
41
+ import { routeEvent } from "./event-router.js"
42
+ import { createInMemoryRunStore } from "./in-memory-store.js"
43
+ import {
44
+ cancel as orchestratorCancel,
45
+ trigger as orchestratorTrigger,
46
+ resumeDueAlarms,
47
+ type TriggerArgs,
48
+ } from "./orchestrator.js"
49
+ import { createScheduler, manifestScheduleSources, type SchedulerHandle } from "./schedule.js"
50
+ import type { RunRecord, StepHandler } from "./types.js"
51
+
52
+ // ---- Public factory options ----
53
+
54
+ export interface InMemoryDriverOptions {
55
+ /** Default environment for `trigger()` calls that don't specify one. */
56
+ defaultEnvironment?: EnvironmentName
57
+ /** Tenant metadata stamped onto every triggered run. */
58
+ tenantMeta?: RunRecord["tenantMeta"]
59
+ /** Injectable clock; defaults to `Date.now`. */
60
+ now?: () => number
61
+ /** Step handler override — defaults to in-process `handleStepRequest`. */
62
+ handler?: StepHandler
63
+ /** Schedule runner tick interval. Defaults to 1_000 ms. */
64
+ schedulePollIntervalMs?: number
65
+ /** Disable automatic firing for schedules registered through manifests. */
66
+ disableScheduleRunner?: boolean
67
+ }
68
+
69
+ const DEFAULT_TENANT_META: RunRecord["tenantMeta"] = {
70
+ tenantId: "default",
71
+ projectId: "default",
72
+ organizationId: "default",
73
+ }
74
+
75
+ /**
76
+ * Build an in-memory driver factory. The factory closes over its
77
+ * options and returns a fresh `WorkflowDriver` when `createApp()`
78
+ * (or a test) calls it with `DriverFactoryDeps`.
79
+ *
80
+ * Usage in tests:
81
+ *
82
+ * const driver = createInMemoryDriver()(testFactoryDeps())
83
+ * await driver.registerManifest({ environment: "production", manifest })
84
+ * await driver.trigger(myWorkflow, { … }, { idempotencyKey: "abc" })
85
+ */
86
+ export function createInMemoryDriver(opts: InMemoryDriverOptions = {}): DriverFactory {
87
+ return (deps: DriverFactoryDeps): WorkflowDriver => {
88
+ const store = createInMemoryRunStore()
89
+ const manifests = new Map<EnvironmentName, { manifest: WorkflowManifest; versionId: string }>()
90
+ const scheduleRunners = new Map<EnvironmentName, SchedulerHandle>()
91
+ const now = opts.now ?? deps.now ?? (() => Date.now())
92
+ const tenantMeta = opts.tenantMeta ?? DEFAULT_TENANT_META
93
+ const defaultEnv: EnvironmentName = opts.defaultEnvironment ?? "development"
94
+ // Wire the framework-supplied service container through to step bodies.
95
+ // The handler closes over `deps.services` so every step invocation
96
+ // surfaces it as `ctx.services` inside the workflow body.
97
+ const handler: StepHandler =
98
+ opts.handler ??
99
+ (async (req: WorkflowStepRequest, stepOpts) =>
100
+ handleStepRequest(req, { services: deps.services }, stepOpts))
101
+
102
+ let shuttingDown = false
103
+ const concurrency = createInProcessConcurrencyCoordinator({
104
+ async cancelRun(runId, reason) {
105
+ const out = await orchestratorCancel({ runId, reason }, { store, handler, now })
106
+ if (out.ok && isTerminal(out.record.status)) {
107
+ concurrency.releaseRun(out.record)
108
+ }
109
+ },
110
+ })
111
+
112
+ // ---- WorkflowDriver implementation ----
113
+
114
+ async function registerManifest(args: {
115
+ environment: EnvironmentName
116
+ manifest: WorkflowManifest
117
+ }): Promise<{ versionId: string }> {
118
+ assertNotShutdown(shuttingDown)
119
+ manifests.set(args.environment, {
120
+ manifest: args.manifest,
121
+ versionId: args.manifest.versionId,
122
+ })
123
+ startScheduleRunner(args.environment, args.manifest)
124
+ return { versionId: args.manifest.versionId }
125
+ }
126
+
127
+ async function getManifest(args: {
128
+ environment: EnvironmentName
129
+ }): Promise<WorkflowManifest | null> {
130
+ return manifests.get(args.environment)?.manifest ?? null
131
+ }
132
+
133
+ async function trigger<TIn, TOut>(
134
+ workflow: { id: string } | string,
135
+ input: TIn,
136
+ triggerOpts?: TriggerOptions,
137
+ ): Promise<Run<TOut>> {
138
+ assertNotShutdown(shuttingDown)
139
+ const workflowId = typeof workflow === "string" ? workflow : workflow.id
140
+ const env = triggerOpts?.environment ?? defaultEnv
141
+ const policy = resolveConcurrencyPolicy(workflow, workflowId, env, manifests)
142
+
143
+ // The orchestrator core handles idempotencyKey natively (deterministic
144
+ // runId derivation from `(workflowId, idempotencyKey)`); the driver just
145
+ // forwards the field. Persistent stores like `voyant_snapshot_runs`
146
+ // additionally read `RunRecord.idempotencyKey` to populate their own
147
+ // dedup column.
148
+ const record = await triggerRecord(
149
+ {
150
+ workflowId,
151
+ workflowVersion: triggerOpts?.lockToVersion ?? "v1",
152
+ input: input as unknown,
153
+ tenantMeta,
154
+ environment: env,
155
+ tags: triggerOpts?.tags,
156
+ idempotencyKey: triggerOpts?.idempotencyKey,
157
+ delay: triggerOpts?.delay,
158
+ priority: triggerOpts?.priority,
159
+ },
160
+ policy,
161
+ )
162
+ scheduleDelayedRun(record)
163
+ return runRecordToRun<TOut>(record)
164
+ }
165
+
166
+ async function ingestEvent(args: IngestEventArgs): Promise<IngestEventResponse> {
167
+ assertNotShutdown(shuttingDown)
168
+ const stored = manifests.get(args.environment)
169
+ if (!stored) {
170
+ return {
171
+ ok: false,
172
+ reason: "manifest_not_registered",
173
+ message: new ManifestNotRegisteredError(args.environment).message,
174
+ }
175
+ }
176
+ const eventId = await ensureEventId(args.envelope)
177
+ const routed = routeEvent({
178
+ manifest: stored.manifest,
179
+ envelope: args.envelope,
180
+ eventId,
181
+ idempotencyOverride: args.idempotencyKey,
182
+ })
183
+
184
+ const matches: IngestMatch[] = []
185
+ let anyTriggered = false
186
+ let anyFailed = false
187
+ for (const entry of routed) {
188
+ if (entry.status === "skipped") {
189
+ matches.push({
190
+ filterId: entry.filterId,
191
+ status: "skipped",
192
+ reason: entry.reason,
193
+ details: entry.details,
194
+ })
195
+ continue
196
+ }
197
+ try {
198
+ const record = await triggerRecord(
199
+ {
200
+ workflowId: entry.targetWorkflowId,
201
+ workflowVersion: "v1",
202
+ input: entry.input,
203
+ tenantMeta,
204
+ environment: args.environment,
205
+ idempotencyKey: entry.idempotencyKey,
206
+ triggeredBy: {
207
+ kind: "event",
208
+ eventId,
209
+ eventType: args.envelope.name,
210
+ filterId: entry.filterId,
211
+ },
212
+ },
213
+ resolveConcurrencyPolicy(
214
+ entry.targetWorkflowId,
215
+ entry.targetWorkflowId,
216
+ args.environment,
217
+ manifests,
218
+ ),
219
+ )
220
+ scheduleDelayedRun(record)
221
+ matches.push({
222
+ filterId: entry.filterId,
223
+ targetWorkflowId: entry.targetWorkflowId,
224
+ runId: record.id,
225
+ idempotencyKey: entry.idempotencyKey,
226
+ status: "queued",
227
+ })
228
+ anyTriggered = true
229
+ } catch (err) {
230
+ matches.push({
231
+ filterId: entry.filterId,
232
+ targetWorkflowId: entry.targetWorkflowId,
233
+ status: "error",
234
+ reason: err instanceof Error ? err.message : String(err),
235
+ })
236
+ anyFailed = true
237
+ }
238
+ }
239
+
240
+ // Drivers return ok:true if at least one match queued; ok:false only if
241
+ // every match errored (per architecture doc §15.5).
242
+ if (matches.length > 0 && !anyTriggered && anyFailed) {
243
+ return {
244
+ ok: false,
245
+ reason: "trigger_failed_for_all_matches",
246
+ message: "every matched filter failed to trigger",
247
+ }
248
+ }
249
+ return { ok: true, eventId, matches }
250
+ }
251
+
252
+ async function shutdown(): Promise<void> {
253
+ shuttingDown = true
254
+ for (const runner of scheduleRunners.values()) {
255
+ runner.stop()
256
+ }
257
+ scheduleRunners.clear()
258
+ }
259
+
260
+ function startScheduleRunner(environment: EnvironmentName, manifest: WorkflowManifest): void {
261
+ const existing = scheduleRunners.get(environment)
262
+ existing?.stop()
263
+ scheduleRunners.delete(environment)
264
+ if (opts.disableScheduleRunner) return
265
+
266
+ const sources = manifestScheduleSources(manifest)
267
+ if (sources.length === 0) return
268
+
269
+ const runner = createScheduler({
270
+ sources,
271
+ environment,
272
+ now,
273
+ tickMs: opts.schedulePollIntervalMs,
274
+ onFire: async ({ workflowId, input, scheduleId, fireAt }) => {
275
+ assertNotShutdown(shuttingDown)
276
+ const record = await triggerRecord(
277
+ {
278
+ workflowId,
279
+ workflowVersion: "v1",
280
+ input,
281
+ tenantMeta,
282
+ environment,
283
+ idempotencyKey: `${scheduleId}:${fireAt}`,
284
+ triggeredBy: { kind: "schedule", scheduleId },
285
+ },
286
+ resolveConcurrencyPolicy(workflowId, workflowId, environment, manifests),
287
+ )
288
+ scheduleDelayedRun(record)
289
+ },
290
+ logger: (level, msg, data) => deps.logger(level, msg, data),
291
+ })
292
+ if (runner.sourceCount() === 0) return
293
+ runner.start()
294
+ scheduleRunners.set(environment, runner)
295
+ }
296
+
297
+ function scheduleDelayedRun(record: RunRecord): void {
298
+ if (record.status !== "waiting") return
299
+ const wakeAt = earliestWakeAt(record)
300
+ if (wakeAt === undefined) return
301
+ const delayMs = Math.max(0, wakeAt - now())
302
+ const timer = setTimeout(() => {
303
+ void resumeDueAlarms({ runId: record.id }, { store, handler, now }).then(
304
+ async (resumed) => {
305
+ if (!resumed) {
306
+ const latest = await store.get(record.id)
307
+ if (latest) scheduleDelayedRun(latest)
308
+ return
309
+ }
310
+ if (isTerminal(resumed.status)) {
311
+ concurrency.releaseRun(resumed)
312
+ }
313
+ scheduleDelayedRun(resumed)
314
+ },
315
+ )
316
+ }, delayMs)
317
+ ;(timer as { unref?: () => void }).unref?.()
318
+ }
319
+
320
+ async function triggerRecord(
321
+ args: TriggerArgs,
322
+ policy: RuntimeConcurrencyPolicy | undefined,
323
+ ): Promise<RunRecord> {
324
+ return concurrency.run(
325
+ {
326
+ workflowId: args.workflowId,
327
+ input: args.input,
328
+ policy,
329
+ holderId: concurrencyHolderId(args),
330
+ },
331
+ (hooks) =>
332
+ orchestratorTrigger(
333
+ {
334
+ ...args,
335
+ onRunRecordCreated: hooks.onRunRecordCreated,
336
+ },
337
+ { store, handler, now },
338
+ ),
339
+ )
340
+ }
341
+
342
+ // ---- WorkflowAdmin (partial; sufficient for compliance tests) ----
343
+
344
+ const admin: Partial<WorkflowAdmin> = {
345
+ async listRuns(listOpts?: ListRunsOptions) {
346
+ const filterEnv = listOpts?.environment
347
+ const filterStatus = normalizeStatusFilter(listOpts?.status)
348
+ const filterWorkflow = listOpts?.workflowId
349
+ const filterTag = listOpts?.tag
350
+ const filterSince = toEpoch(listOpts?.since)
351
+ const filterUntil = toEpoch(listOpts?.until)
352
+ const limit = listOpts?.limit ?? 100
353
+
354
+ const results: RunSummary[] = []
355
+ for (const rec of await store.list({})) {
356
+ if (filterEnv && rec.environment !== filterEnv) continue
357
+ if (filterStatus && !filterStatus.includes(rec.status as never)) continue
358
+ if (filterWorkflow && rec.workflowId !== filterWorkflow) continue
359
+ if (filterTag && !rec.tags.includes(filterTag)) continue
360
+ if (filterSince !== undefined && rec.startedAt < filterSince) continue
361
+ if (filterUntil !== undefined && rec.startedAt > filterUntil) continue
362
+ results.push(runRecordToSummary(rec))
363
+ }
364
+ results.sort((a, b) => b.startedAt - a.startedAt)
365
+ const page = results.slice(0, limit)
366
+ const nextCursor = results.length > limit ? String(limit) : undefined
367
+ return { runs: page, nextCursor }
368
+ },
369
+
370
+ async getRun(runId: string): Promise<RunDetail | null> {
371
+ const rec = await store.get(runId)
372
+ return rec ? runRecordToDetail(rec) : null
373
+ },
374
+
375
+ async cancelRun(runId: string, cancelOpts?: { reason?: string; compensate?: boolean }) {
376
+ // The orchestrator core's cancel() does NOT run compensations by default
377
+ // (per workflows-runtime-architecture.md §21.21). The `compensate` flag
378
+ // is accepted but no-ops in v1; honoring it would require an engine
379
+ // behavior change tracked separately.
380
+ void cancelOpts?.compensate
381
+ const out = await orchestratorCancel(
382
+ { runId, reason: cancelOpts?.reason },
383
+ { store, handler, now },
384
+ )
385
+ if (out.ok && isTerminal(out.record.status)) {
386
+ concurrency.releaseRun(out.record)
387
+ }
388
+ },
389
+
390
+ // streamRun is not implemented for InMemory in PR1. Dashboards that
391
+ // probe `driver.admin.streamRun` get `undefined` and fall back to
392
+ // their non-streaming view. Mode 2 + Mode 1 implement this in later
393
+ // PRs against their respective journal sources.
394
+ }
395
+
396
+ return {
397
+ registerManifest,
398
+ trigger,
399
+ ingestEvent,
400
+ getManifest,
401
+ shutdown,
402
+ admin,
403
+ }
404
+ }
405
+ }
406
+
407
+ function concurrencyHolderId(args: TriggerArgs): string | undefined {
408
+ if (args.runId !== undefined) return args.runId
409
+ if (args.idempotencyKey !== undefined) return `idem-${args.workflowId}-${args.idempotencyKey}`
410
+ return undefined
411
+ }
412
+
413
+ function earliestWakeAt(record: RunRecord): number | undefined {
414
+ let earliest: number | undefined
415
+ for (const waitpoint of record.pendingWaitpoints) {
416
+ if (waitpoint.kind !== "DATETIME") continue
417
+ const wakeAt = typeof waitpoint.meta.wakeAt === "number" ? waitpoint.meta.wakeAt : undefined
418
+ if (wakeAt === undefined) continue
419
+ earliest = earliest === undefined ? wakeAt : Math.min(earliest, wakeAt)
420
+ }
421
+ return earliest
422
+ }
423
+
424
+ // ---- Helpers ----
425
+
426
+ function assertNotShutdown(shuttingDown: boolean): void {
427
+ if (shuttingDown) {
428
+ throw new Error("InMemoryDriver: shutdown() has been called; new operations are refused.")
429
+ }
430
+ }
431
+
432
+ function resolveConcurrencyPolicy(
433
+ workflow: { id: string; config?: { concurrency?: RuntimeConcurrencyPolicy } } | string,
434
+ workflowId: string,
435
+ environment: EnvironmentName,
436
+ manifests: Map<EnvironmentName, { manifest: WorkflowManifest; versionId: string }>,
437
+ ): RuntimeConcurrencyPolicy | undefined {
438
+ if (typeof workflow !== "string" && workflow.config?.concurrency) {
439
+ return workflow.config.concurrency
440
+ }
441
+ const manifest = manifests.get(environment)?.manifest
442
+ return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency
443
+ }
444
+
445
+ function isTerminal(status: RunRecord["status"]): boolean {
446
+ return (
447
+ status === "completed" ||
448
+ status === "failed" ||
449
+ status === "cancelled" ||
450
+ status === "compensated" ||
451
+ status === "compensation_failed"
452
+ )
453
+ }
454
+
455
+ async function ensureEventId(envelope: {
456
+ name: string
457
+ data: unknown
458
+ metadata?: { eventId?: string }
459
+ emittedAt: string
460
+ }): Promise<string> {
461
+ if (envelope.metadata?.eventId) return envelope.metadata.eventId
462
+ // Content-derived fallback per architecture doc §15.2. Two ingests
463
+ // with byte-equal envelopes produce the same id, so retries dedupe
464
+ // through the driver's `${filterId}:${eventId}` idempotency key.
465
+ return deriveStableEventId(envelope)
466
+ }
467
+
468
+ function runRecordToRun<TOut>(rec: RunRecord): Run<TOut> {
469
+ return {
470
+ id: rec.id,
471
+ workflowId: rec.workflowId,
472
+ status: rec.status as Run["status"],
473
+ startedAt: rec.startedAt,
474
+ }
475
+ }
476
+
477
+ function runRecordToSummary(rec: RunRecord): RunSummary {
478
+ return {
479
+ id: rec.id,
480
+ workflowId: rec.workflowId,
481
+ status: rec.status as RunSummary["status"],
482
+ startedAt: rec.startedAt,
483
+ completedAt: rec.completedAt,
484
+ tags: [...rec.tags],
485
+ environment: rec.environment,
486
+ }
487
+ }
488
+
489
+ function runRecordToDetail(rec: RunRecord): RunDetail {
490
+ return {
491
+ ...runRecordToSummary(rec),
492
+ version: rec.workflowVersion,
493
+ input: rec.input,
494
+ output: rec.output,
495
+ error: rec.error,
496
+ durationMs:
497
+ rec.completedAt !== undefined ? Math.max(0, rec.completedAt - rec.startedAt) : undefined,
498
+ }
499
+ }
500
+
501
+ function normalizeStatusFilter(
502
+ s: ListRunsOptions["status"] | undefined,
503
+ ): readonly string[] | undefined {
504
+ if (s === undefined) return undefined
505
+ return Array.isArray(s) ? s : [s]
506
+ }
507
+
508
+ function toEpoch(v: number | Date | undefined): number | undefined {
509
+ if (v === undefined) return undefined
510
+ return typeof v === "number" ? v : v.getTime()
511
+ }
@@ -0,0 +1,120 @@
1
+ // Pure event router — given a manifest and an envelope, decide which
2
+ // filters match and produce the per-match descriptors drivers feed into
3
+ // `trigger()`.
4
+ //
5
+ // Drivers (InMemory, Mode 2 / Postgres, Mode 1 / CF edge) wrap this in
6
+ // their own `ingestEvent` impl: they fetch the manifest from their store,
7
+ // call `routeEvent(...)`, then invoke `trigger()` per match.
8
+ //
9
+ // Architecture: docs/architecture/workflows-runtime-architecture.md §15.
10
+
11
+ import {
12
+ evaluatePredicate,
13
+ type InputMapper,
14
+ type PredicateEnvelope,
15
+ type PredicateExpr,
16
+ projectInput,
17
+ } from "@voyant-travel/workflows/events"
18
+ import type { WorkflowManifest } from "@voyant-travel/workflows/protocol"
19
+
20
+ // ---- Public types ----
21
+
22
+ /**
23
+ * Outcome of routing a single envelope through every filter in a manifest.
24
+ * Each match describes exactly enough for a driver to call `trigger()`:
25
+ * - `targetWorkflowId` and `input` are the trigger args.
26
+ * - `idempotencyKey` derives from `${filterId}:${eventId}` so retries of
27
+ * the same envelope produce a stable run regardless of which driver
28
+ * applies the trigger.
29
+ */
30
+ export type RouterMatch =
31
+ | {
32
+ filterId: string
33
+ targetWorkflowId: string
34
+ input: unknown
35
+ idempotencyKey: string
36
+ status: "matched"
37
+ }
38
+ | {
39
+ filterId: string
40
+ status: "skipped"
41
+ reason: "where_eval_error" | "input_projection_error"
42
+ details?: string
43
+ }
44
+
45
+ export interface RouteEventArgs {
46
+ manifest: WorkflowManifest
47
+ envelope: PredicateEnvelope
48
+ /**
49
+ * Stable id for the envelope. Drivers derive this from
50
+ * `metadata.eventId` (when set) or fall back to a content hash; passing
51
+ * it in keeps the router pure.
52
+ */
53
+ eventId: string
54
+ /**
55
+ * Optional caller-supplied idempotency override (per
56
+ * `IngestEventArgs.idempotencyKey`). When set, the per-match key
57
+ * becomes `${filterId}:${suppliedKey}` instead of
58
+ * `${filterId}:${eventId}`.
59
+ */
60
+ idempotencyOverride?: string
61
+ }
62
+
63
+ // ---- Public API ----
64
+
65
+ /**
66
+ * Route an envelope through a manifest. Pure: no IO, no side effects.
67
+ * Returns one entry per filter that targets the envelope's eventType, in
68
+ * the order they appear in `manifest.eventFilters` (which is itself
69
+ * id-sorted from `buildManifest`).
70
+ *
71
+ * `where` evaluation errors and `input` projection errors are isolated to
72
+ * the offending filter — other filters still produce matches. Drivers
73
+ * surface skips as `IngestMatch.status === "skipped"` in the response.
74
+ */
75
+ export function routeEvent(args: RouteEventArgs): RouterMatch[] {
76
+ const out: RouterMatch[] = []
77
+ for (const filter of args.manifest.eventFilters) {
78
+ if (filter.eventType !== args.envelope.name) continue
79
+
80
+ // Predicate gate.
81
+ if (filter.where !== undefined) {
82
+ try {
83
+ const matched = evaluatePredicate(filter.where as PredicateExpr, args.envelope)
84
+ if (!matched) continue
85
+ } catch (err) {
86
+ out.push({
87
+ filterId: filter.id,
88
+ status: "skipped",
89
+ reason: "where_eval_error",
90
+ details: err instanceof Error ? err.message : String(err),
91
+ })
92
+ continue
93
+ }
94
+ }
95
+
96
+ // Input projection.
97
+ let input: unknown
98
+ try {
99
+ input = projectInput(filter.input as InputMapper, args.envelope)
100
+ } catch (err) {
101
+ out.push({
102
+ filterId: filter.id,
103
+ status: "skipped",
104
+ reason: "input_projection_error",
105
+ details: err instanceof Error ? err.message : String(err),
106
+ })
107
+ continue
108
+ }
109
+
110
+ const baseKey = args.idempotencyOverride ?? args.eventId
111
+ out.push({
112
+ filterId: filter.id,
113
+ targetWorkflowId: filter.targetWorkflowId,
114
+ input,
115
+ idempotencyKey: `${filter.id}:${baseKey}`,
116
+ status: "matched",
117
+ })
118
+ }
119
+ return out
120
+ }