@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,394 @@
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
+ import { ManifestNotRegisteredError, } from "@voyant-travel/workflows/driver";
15
+ import { deriveStableEventId } from "@voyant-travel/workflows/events";
16
+ import { handleStepRequest } from "@voyant-travel/workflows/handler";
17
+ import { createInProcessConcurrencyCoordinator, } from "./concurrency.js";
18
+ import { routeEvent } from "./event-router.js";
19
+ import { createInMemoryRunStore } from "./in-memory-store.js";
20
+ import { cancel as orchestratorCancel, trigger as orchestratorTrigger, resumeDueAlarms, } from "./orchestrator.js";
21
+ import { createScheduler, manifestScheduleSources } from "./schedule.js";
22
+ const DEFAULT_TENANT_META = {
23
+ tenantId: "default",
24
+ projectId: "default",
25
+ organizationId: "default",
26
+ };
27
+ /**
28
+ * Build an in-memory driver factory. The factory closes over its
29
+ * options and returns a fresh `WorkflowDriver` when `createApp()`
30
+ * (or a test) calls it with `DriverFactoryDeps`.
31
+ *
32
+ * Usage in tests:
33
+ *
34
+ * const driver = createInMemoryDriver()(testFactoryDeps())
35
+ * await driver.registerManifest({ environment: "production", manifest })
36
+ * await driver.trigger(myWorkflow, { … }, { idempotencyKey: "abc" })
37
+ */
38
+ export function createInMemoryDriver(opts = {}) {
39
+ return (deps) => {
40
+ const store = createInMemoryRunStore();
41
+ const manifests = new Map();
42
+ const scheduleRunners = new Map();
43
+ const now = opts.now ?? deps.now ?? (() => Date.now());
44
+ const tenantMeta = opts.tenantMeta ?? DEFAULT_TENANT_META;
45
+ const defaultEnv = opts.defaultEnvironment ?? "development";
46
+ // Wire the framework-supplied service container through to step bodies.
47
+ // The handler closes over `deps.services` so every step invocation
48
+ // surfaces it as `ctx.services` inside the workflow body.
49
+ const handler = opts.handler ??
50
+ (async (req, stepOpts) => handleStepRequest(req, { services: deps.services }, stepOpts));
51
+ let shuttingDown = false;
52
+ const concurrency = createInProcessConcurrencyCoordinator({
53
+ async cancelRun(runId, reason) {
54
+ const out = await orchestratorCancel({ runId, reason }, { store, handler, now });
55
+ if (out.ok && isTerminal(out.record.status)) {
56
+ concurrency.releaseRun(out.record);
57
+ }
58
+ },
59
+ });
60
+ // ---- WorkflowDriver implementation ----
61
+ async function registerManifest(args) {
62
+ assertNotShutdown(shuttingDown);
63
+ manifests.set(args.environment, {
64
+ manifest: args.manifest,
65
+ versionId: args.manifest.versionId,
66
+ });
67
+ startScheduleRunner(args.environment, args.manifest);
68
+ return { versionId: args.manifest.versionId };
69
+ }
70
+ async function getManifest(args) {
71
+ return manifests.get(args.environment)?.manifest ?? null;
72
+ }
73
+ async function trigger(workflow, input, triggerOpts) {
74
+ assertNotShutdown(shuttingDown);
75
+ const workflowId = typeof workflow === "string" ? workflow : workflow.id;
76
+ const env = triggerOpts?.environment ?? defaultEnv;
77
+ const policy = resolveConcurrencyPolicy(workflow, workflowId, env, manifests);
78
+ // The orchestrator core handles idempotencyKey natively (deterministic
79
+ // runId derivation from `(workflowId, idempotencyKey)`); the driver just
80
+ // forwards the field. Persistent stores like `voyant_snapshot_runs`
81
+ // additionally read `RunRecord.idempotencyKey` to populate their own
82
+ // dedup column.
83
+ const record = await triggerRecord({
84
+ workflowId,
85
+ workflowVersion: triggerOpts?.lockToVersion ?? "v1",
86
+ input: input,
87
+ tenantMeta,
88
+ environment: env,
89
+ tags: triggerOpts?.tags,
90
+ idempotencyKey: triggerOpts?.idempotencyKey,
91
+ delay: triggerOpts?.delay,
92
+ priority: triggerOpts?.priority,
93
+ }, policy);
94
+ scheduleDelayedRun(record);
95
+ return runRecordToRun(record);
96
+ }
97
+ async function ingestEvent(args) {
98
+ assertNotShutdown(shuttingDown);
99
+ const stored = manifests.get(args.environment);
100
+ if (!stored) {
101
+ return {
102
+ ok: false,
103
+ reason: "manifest_not_registered",
104
+ message: new ManifestNotRegisteredError(args.environment).message,
105
+ };
106
+ }
107
+ const eventId = await ensureEventId(args.envelope);
108
+ const routed = routeEvent({
109
+ manifest: stored.manifest,
110
+ envelope: args.envelope,
111
+ eventId,
112
+ idempotencyOverride: args.idempotencyKey,
113
+ });
114
+ const matches = [];
115
+ let anyTriggered = false;
116
+ let anyFailed = false;
117
+ for (const entry of routed) {
118
+ if (entry.status === "skipped") {
119
+ matches.push({
120
+ filterId: entry.filterId,
121
+ status: "skipped",
122
+ reason: entry.reason,
123
+ details: entry.details,
124
+ });
125
+ continue;
126
+ }
127
+ try {
128
+ const record = await triggerRecord({
129
+ workflowId: entry.targetWorkflowId,
130
+ workflowVersion: "v1",
131
+ input: entry.input,
132
+ tenantMeta,
133
+ environment: args.environment,
134
+ idempotencyKey: entry.idempotencyKey,
135
+ triggeredBy: {
136
+ kind: "event",
137
+ eventId,
138
+ eventType: args.envelope.name,
139
+ filterId: entry.filterId,
140
+ },
141
+ }, resolveConcurrencyPolicy(entry.targetWorkflowId, entry.targetWorkflowId, args.environment, manifests));
142
+ scheduleDelayedRun(record);
143
+ matches.push({
144
+ filterId: entry.filterId,
145
+ targetWorkflowId: entry.targetWorkflowId,
146
+ runId: record.id,
147
+ idempotencyKey: entry.idempotencyKey,
148
+ status: "queued",
149
+ });
150
+ anyTriggered = true;
151
+ }
152
+ catch (err) {
153
+ matches.push({
154
+ filterId: entry.filterId,
155
+ targetWorkflowId: entry.targetWorkflowId,
156
+ status: "error",
157
+ reason: err instanceof Error ? err.message : String(err),
158
+ });
159
+ anyFailed = true;
160
+ }
161
+ }
162
+ // Drivers return ok:true if at least one match queued; ok:false only if
163
+ // every match errored (per architecture doc §15.5).
164
+ if (matches.length > 0 && !anyTriggered && anyFailed) {
165
+ return {
166
+ ok: false,
167
+ reason: "trigger_failed_for_all_matches",
168
+ message: "every matched filter failed to trigger",
169
+ };
170
+ }
171
+ return { ok: true, eventId, matches };
172
+ }
173
+ async function shutdown() {
174
+ shuttingDown = true;
175
+ for (const runner of scheduleRunners.values()) {
176
+ runner.stop();
177
+ }
178
+ scheduleRunners.clear();
179
+ }
180
+ function startScheduleRunner(environment, manifest) {
181
+ const existing = scheduleRunners.get(environment);
182
+ existing?.stop();
183
+ scheduleRunners.delete(environment);
184
+ if (opts.disableScheduleRunner)
185
+ return;
186
+ const sources = manifestScheduleSources(manifest);
187
+ if (sources.length === 0)
188
+ return;
189
+ const runner = createScheduler({
190
+ sources,
191
+ environment,
192
+ now,
193
+ tickMs: opts.schedulePollIntervalMs,
194
+ onFire: async ({ workflowId, input, scheduleId, fireAt }) => {
195
+ assertNotShutdown(shuttingDown);
196
+ const record = await triggerRecord({
197
+ workflowId,
198
+ workflowVersion: "v1",
199
+ input,
200
+ tenantMeta,
201
+ environment,
202
+ idempotencyKey: `${scheduleId}:${fireAt}`,
203
+ triggeredBy: { kind: "schedule", scheduleId },
204
+ }, resolveConcurrencyPolicy(workflowId, workflowId, environment, manifests));
205
+ scheduleDelayedRun(record);
206
+ },
207
+ logger: (level, msg, data) => deps.logger(level, msg, data),
208
+ });
209
+ if (runner.sourceCount() === 0)
210
+ return;
211
+ runner.start();
212
+ scheduleRunners.set(environment, runner);
213
+ }
214
+ function scheduleDelayedRun(record) {
215
+ if (record.status !== "waiting")
216
+ return;
217
+ const wakeAt = earliestWakeAt(record);
218
+ if (wakeAt === undefined)
219
+ return;
220
+ const delayMs = Math.max(0, wakeAt - now());
221
+ const timer = setTimeout(() => {
222
+ void resumeDueAlarms({ runId: record.id }, { store, handler, now }).then(async (resumed) => {
223
+ if (!resumed) {
224
+ const latest = await store.get(record.id);
225
+ if (latest)
226
+ scheduleDelayedRun(latest);
227
+ return;
228
+ }
229
+ if (isTerminal(resumed.status)) {
230
+ concurrency.releaseRun(resumed);
231
+ }
232
+ scheduleDelayedRun(resumed);
233
+ });
234
+ }, delayMs);
235
+ timer.unref?.();
236
+ }
237
+ async function triggerRecord(args, policy) {
238
+ return concurrency.run({
239
+ workflowId: args.workflowId,
240
+ input: args.input,
241
+ policy,
242
+ holderId: concurrencyHolderId(args),
243
+ }, (hooks) => orchestratorTrigger({
244
+ ...args,
245
+ onRunRecordCreated: hooks.onRunRecordCreated,
246
+ }, { store, handler, now }));
247
+ }
248
+ // ---- WorkflowAdmin (partial; sufficient for compliance tests) ----
249
+ const admin = {
250
+ async listRuns(listOpts) {
251
+ const filterEnv = listOpts?.environment;
252
+ const filterStatus = normalizeStatusFilter(listOpts?.status);
253
+ const filterWorkflow = listOpts?.workflowId;
254
+ const filterTag = listOpts?.tag;
255
+ const filterSince = toEpoch(listOpts?.since);
256
+ const filterUntil = toEpoch(listOpts?.until);
257
+ const limit = listOpts?.limit ?? 100;
258
+ const results = [];
259
+ for (const rec of await store.list({})) {
260
+ if (filterEnv && rec.environment !== filterEnv)
261
+ continue;
262
+ if (filterStatus && !filterStatus.includes(rec.status))
263
+ continue;
264
+ if (filterWorkflow && rec.workflowId !== filterWorkflow)
265
+ continue;
266
+ if (filterTag && !rec.tags.includes(filterTag))
267
+ continue;
268
+ if (filterSince !== undefined && rec.startedAt < filterSince)
269
+ continue;
270
+ if (filterUntil !== undefined && rec.startedAt > filterUntil)
271
+ continue;
272
+ results.push(runRecordToSummary(rec));
273
+ }
274
+ results.sort((a, b) => b.startedAt - a.startedAt);
275
+ const page = results.slice(0, limit);
276
+ const nextCursor = results.length > limit ? String(limit) : undefined;
277
+ return { runs: page, nextCursor };
278
+ },
279
+ async getRun(runId) {
280
+ const rec = await store.get(runId);
281
+ return rec ? runRecordToDetail(rec) : null;
282
+ },
283
+ async cancelRun(runId, cancelOpts) {
284
+ // The orchestrator core's cancel() does NOT run compensations by default
285
+ // (per workflows-runtime-architecture.md §21.21). The `compensate` flag
286
+ // is accepted but no-ops in v1; honoring it would require an engine
287
+ // behavior change tracked separately.
288
+ void cancelOpts?.compensate;
289
+ const out = await orchestratorCancel({ runId, reason: cancelOpts?.reason }, { store, handler, now });
290
+ if (out.ok && isTerminal(out.record.status)) {
291
+ concurrency.releaseRun(out.record);
292
+ }
293
+ },
294
+ // streamRun is not implemented for InMemory in PR1. Dashboards that
295
+ // probe `driver.admin.streamRun` get `undefined` and fall back to
296
+ // their non-streaming view. Mode 2 + Mode 1 implement this in later
297
+ // PRs against their respective journal sources.
298
+ };
299
+ return {
300
+ registerManifest,
301
+ trigger,
302
+ ingestEvent,
303
+ getManifest,
304
+ shutdown,
305
+ admin,
306
+ };
307
+ };
308
+ }
309
+ function concurrencyHolderId(args) {
310
+ if (args.runId !== undefined)
311
+ return args.runId;
312
+ if (args.idempotencyKey !== undefined)
313
+ return `idem-${args.workflowId}-${args.idempotencyKey}`;
314
+ return undefined;
315
+ }
316
+ function earliestWakeAt(record) {
317
+ let earliest;
318
+ for (const waitpoint of record.pendingWaitpoints) {
319
+ if (waitpoint.kind !== "DATETIME")
320
+ continue;
321
+ const wakeAt = typeof waitpoint.meta.wakeAt === "number" ? waitpoint.meta.wakeAt : undefined;
322
+ if (wakeAt === undefined)
323
+ continue;
324
+ earliest = earliest === undefined ? wakeAt : Math.min(earliest, wakeAt);
325
+ }
326
+ return earliest;
327
+ }
328
+ // ---- Helpers ----
329
+ function assertNotShutdown(shuttingDown) {
330
+ if (shuttingDown) {
331
+ throw new Error("InMemoryDriver: shutdown() has been called; new operations are refused.");
332
+ }
333
+ }
334
+ function resolveConcurrencyPolicy(workflow, workflowId, environment, manifests) {
335
+ if (typeof workflow !== "string" && workflow.config?.concurrency) {
336
+ return workflow.config.concurrency;
337
+ }
338
+ const manifest = manifests.get(environment)?.manifest;
339
+ return manifest?.workflows.find((entry) => entry.id === workflowId)?.concurrency;
340
+ }
341
+ function isTerminal(status) {
342
+ return (status === "completed" ||
343
+ status === "failed" ||
344
+ status === "cancelled" ||
345
+ status === "compensated" ||
346
+ status === "compensation_failed");
347
+ }
348
+ async function ensureEventId(envelope) {
349
+ if (envelope.metadata?.eventId)
350
+ return envelope.metadata.eventId;
351
+ // Content-derived fallback per architecture doc §15.2. Two ingests
352
+ // with byte-equal envelopes produce the same id, so retries dedupe
353
+ // through the driver's `${filterId}:${eventId}` idempotency key.
354
+ return deriveStableEventId(envelope);
355
+ }
356
+ function runRecordToRun(rec) {
357
+ return {
358
+ id: rec.id,
359
+ workflowId: rec.workflowId,
360
+ status: rec.status,
361
+ startedAt: rec.startedAt,
362
+ };
363
+ }
364
+ function runRecordToSummary(rec) {
365
+ return {
366
+ id: rec.id,
367
+ workflowId: rec.workflowId,
368
+ status: rec.status,
369
+ startedAt: rec.startedAt,
370
+ completedAt: rec.completedAt,
371
+ tags: [...rec.tags],
372
+ environment: rec.environment,
373
+ };
374
+ }
375
+ function runRecordToDetail(rec) {
376
+ return {
377
+ ...runRecordToSummary(rec),
378
+ version: rec.workflowVersion,
379
+ input: rec.input,
380
+ output: rec.output,
381
+ error: rec.error,
382
+ durationMs: rec.completedAt !== undefined ? Math.max(0, rec.completedAt - rec.startedAt) : undefined,
383
+ };
384
+ }
385
+ function normalizeStatusFilter(s) {
386
+ if (s === undefined)
387
+ return undefined;
388
+ return Array.isArray(s) ? s : [s];
389
+ }
390
+ function toEpoch(v) {
391
+ if (v === undefined)
392
+ return undefined;
393
+ return typeof v === "number" ? v : v.getTime();
394
+ }
@@ -0,0 +1,51 @@
1
+ import { type PredicateEnvelope } from "@voyant-travel/workflows/events";
2
+ import type { WorkflowManifest } from "@voyant-travel/workflows/protocol";
3
+ /**
4
+ * Outcome of routing a single envelope through every filter in a manifest.
5
+ * Each match describes exactly enough for a driver to call `trigger()`:
6
+ * - `targetWorkflowId` and `input` are the trigger args.
7
+ * - `idempotencyKey` derives from `${filterId}:${eventId}` so retries of
8
+ * the same envelope produce a stable run regardless of which driver
9
+ * applies the trigger.
10
+ */
11
+ export type RouterMatch = {
12
+ filterId: string;
13
+ targetWorkflowId: string;
14
+ input: unknown;
15
+ idempotencyKey: string;
16
+ status: "matched";
17
+ } | {
18
+ filterId: string;
19
+ status: "skipped";
20
+ reason: "where_eval_error" | "input_projection_error";
21
+ details?: string;
22
+ };
23
+ export interface RouteEventArgs {
24
+ manifest: WorkflowManifest;
25
+ envelope: PredicateEnvelope;
26
+ /**
27
+ * Stable id for the envelope. Drivers derive this from
28
+ * `metadata.eventId` (when set) or fall back to a content hash; passing
29
+ * it in keeps the router pure.
30
+ */
31
+ eventId: string;
32
+ /**
33
+ * Optional caller-supplied idempotency override (per
34
+ * `IngestEventArgs.idempotencyKey`). When set, the per-match key
35
+ * becomes `${filterId}:${suppliedKey}` instead of
36
+ * `${filterId}:${eventId}`.
37
+ */
38
+ idempotencyOverride?: string;
39
+ }
40
+ /**
41
+ * Route an envelope through a manifest. Pure: no IO, no side effects.
42
+ * Returns one entry per filter that targets the envelope's eventType, in
43
+ * the order they appear in `manifest.eventFilters` (which is itself
44
+ * id-sorted from `buildManifest`).
45
+ *
46
+ * `where` evaluation errors and `input` projection errors are isolated to
47
+ * the offending filter — other filters still produce matches. Drivers
48
+ * surface skips as `IngestMatch.status === "skipped"` in the response.
49
+ */
50
+ export declare function routeEvent(args: RouteEventArgs): RouterMatch[];
51
+ //# sourceMappingURL=event-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-router.d.ts","sourceRoot":"","sources":["../src/event-router.ts"],"names":[],"mappings":"AAUA,OAAO,EAGL,KAAK,iBAAiB,EAGvB,MAAM,iCAAiC,CAAA;AACxC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAA;AAIzE;;;;;;;GAOG;AACH,MAAM,MAAM,WAAW,GACnB;IACE,QAAQ,EAAE,MAAM,CAAA;IAChB,gBAAgB,EAAE,MAAM,CAAA;IACxB,KAAK,EAAE,OAAO,CAAA;IACd,cAAc,EAAE,MAAM,CAAA;IACtB,MAAM,EAAE,SAAS,CAAA;CAClB,GACD;IACE,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,SAAS,CAAA;IACjB,MAAM,EAAE,kBAAkB,GAAG,wBAAwB,CAAA;IACrD,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB,CAAA;AAEL,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,QAAQ,EAAE,iBAAiB,CAAA;IAC3B;;;;OAIG;IACH,OAAO,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAA;CAC7B;AAID;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,WAAW,EAAE,CA6C9D"}
@@ -0,0 +1,68 @@
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
+ import { evaluatePredicate, projectInput, } from "@voyant-travel/workflows/events";
11
+ // ---- Public API ----
12
+ /**
13
+ * Route an envelope through a manifest. Pure: no IO, no side effects.
14
+ * Returns one entry per filter that targets the envelope's eventType, in
15
+ * the order they appear in `manifest.eventFilters` (which is itself
16
+ * id-sorted from `buildManifest`).
17
+ *
18
+ * `where` evaluation errors and `input` projection errors are isolated to
19
+ * the offending filter — other filters still produce matches. Drivers
20
+ * surface skips as `IngestMatch.status === "skipped"` in the response.
21
+ */
22
+ export function routeEvent(args) {
23
+ const out = [];
24
+ for (const filter of args.manifest.eventFilters) {
25
+ if (filter.eventType !== args.envelope.name)
26
+ continue;
27
+ // Predicate gate.
28
+ if (filter.where !== undefined) {
29
+ try {
30
+ const matched = evaluatePredicate(filter.where, args.envelope);
31
+ if (!matched)
32
+ continue;
33
+ }
34
+ catch (err) {
35
+ out.push({
36
+ filterId: filter.id,
37
+ status: "skipped",
38
+ reason: "where_eval_error",
39
+ details: err instanceof Error ? err.message : String(err),
40
+ });
41
+ continue;
42
+ }
43
+ }
44
+ // Input projection.
45
+ let input;
46
+ try {
47
+ input = projectInput(filter.input, args.envelope);
48
+ }
49
+ catch (err) {
50
+ out.push({
51
+ filterId: filter.id,
52
+ status: "skipped",
53
+ reason: "input_projection_error",
54
+ details: err instanceof Error ? err.message : String(err),
55
+ });
56
+ continue;
57
+ }
58
+ const baseKey = args.idempotencyOverride ?? args.eventId;
59
+ out.push({
60
+ filterId: filter.id,
61
+ targetWorkflowId: filter.targetWorkflowId,
62
+ input,
63
+ idempotencyKey: `${filter.id}:${baseKey}`,
64
+ status: "matched",
65
+ });
66
+ }
67
+ return out;
68
+ }
@@ -0,0 +1,25 @@
1
+ import type { StepHandler, WorkflowStepRequest } from "./types.js";
2
+ export interface HttpStepTarget {
3
+ url: string;
4
+ fetch(request: Request): Promise<Response>;
5
+ label?: string;
6
+ }
7
+ export interface HttpStepHandlerDeps {
8
+ /**
9
+ * Resolve the transport target for this workflow-step invocation.
10
+ * Adapters can map `tenantMeta.tenantScript` to a dispatch-namespace
11
+ * binding, a service URL, or a local proxy.
12
+ */
13
+ resolveTarget: (req: WorkflowStepRequest) => HttpStepTarget | Promise<HttpStepTarget>;
14
+ /** Optional HMAC signer for the X-Voyant-Dispatch-Auth header. */
15
+ sign?: (body: string, req: WorkflowStepRequest) => Promise<string> | string;
16
+ /** Optional logger for step-level observability. */
17
+ logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void;
18
+ }
19
+ /**
20
+ * Build a StepHandler that serializes WorkflowStepRequest over HTTP.
21
+ * The concrete transport target is adapter-specific; the request/response
22
+ * mapping is shared across Cloudflare, Node, and future adapters.
23
+ */
24
+ export declare function createHttpStepHandler(deps: HttpStepHandlerDeps): StepHandler;
25
+ //# sourceMappingURL=http-step-handler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http-step-handler.d.ts","sourceRoot":"","sources":["../src/http-step-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,mBAAmB,EAAwB,MAAM,YAAY,CAAA;AAQxF,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,aAAa,EAAE,CAAC,GAAG,EAAE,mBAAmB,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAA;IACrF,kEAAkE;IAClE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,mBAAmB,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAA;IAC3E,oDAAoD;IACpD,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;CAChF;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,WAAW,CA+D5E"}
@@ -0,0 +1,78 @@
1
+ import { PROTOCOL_VERSION } from "@voyant-travel/workflows/protocol";
2
+ /**
3
+ * Build a StepHandler that serializes WorkflowStepRequest over HTTP.
4
+ * The concrete transport target is adapter-specific; the request/response
5
+ * mapping is shared across Cloudflare, Node, and future adapters.
6
+ */
7
+ export function createHttpStepHandler(deps) {
8
+ return async (req, stepOpts) => {
9
+ const body = JSON.stringify(req);
10
+ const headers = {
11
+ "content-type": "application/json; charset=utf-8",
12
+ "x-voyant-protocol": String(PROTOCOL_VERSION),
13
+ };
14
+ if (deps.sign) {
15
+ headers["x-voyant-dispatch-auth"] = await deps.sign(body, req);
16
+ }
17
+ const target = await deps.resolveTarget(req);
18
+ deps.logger?.("info", "http-step: invoking tenant step", {
19
+ target: target.label ?? target.url,
20
+ runId: req.runId,
21
+ workflowId: req.workflowId,
22
+ invocation: req.invocationCount,
23
+ });
24
+ let response;
25
+ try {
26
+ response = await target.fetch(new Request(target.url, {
27
+ method: "POST",
28
+ headers,
29
+ body,
30
+ signal: stepOpts?.signal,
31
+ }));
32
+ }
33
+ catch (err) {
34
+ deps.logger?.("error", "http-step: tenant fetch threw", {
35
+ target: target.label ?? target.url,
36
+ runId: req.runId,
37
+ error: err instanceof Error ? err.message : String(err),
38
+ });
39
+ return {
40
+ status: 502,
41
+ body: {
42
+ error: "tenant_unreachable",
43
+ message: err instanceof Error ? err.message : String(err),
44
+ },
45
+ };
46
+ }
47
+ const text = await response.text();
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(text);
51
+ }
52
+ catch {
53
+ return {
54
+ status: 502,
55
+ body: {
56
+ error: "tenant_invalid_response",
57
+ message: `tenant returned non-JSON body (HTTP ${response.status})`,
58
+ },
59
+ };
60
+ }
61
+ if (response.status !== 200) {
62
+ return { status: response.status, body: toErrorBody(parsed, response.status) };
63
+ }
64
+ return { status: 200, body: parsed };
65
+ };
66
+ }
67
+ function toErrorBody(parsed, fallbackStatus) {
68
+ if (parsed !== null &&
69
+ typeof parsed === "object" &&
70
+ typeof parsed.error === "string" &&
71
+ typeof parsed.message === "string") {
72
+ return parsed;
73
+ }
74
+ return {
75
+ error: "tenant_error",
76
+ message: `tenant returned HTTP ${fallbackStatus}`,
77
+ };
78
+ }
@@ -0,0 +1,5 @@
1
+ import type { OrchestratorRunStatus, RunRecordStore } from "./types.js";
2
+ export declare function createInMemoryRunStore(): RunRecordStore;
3
+ /** Keep the unused import happy — guards against accidental type-only drift. */
4
+ export type { OrchestratorRunStatus };
5
+ //# sourceMappingURL=in-memory-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"in-memory-store.d.ts","sourceRoot":"","sources":["../src/in-memory-store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,qBAAqB,EAAa,cAAc,EAAE,MAAM,YAAY,CAAA;AAElF,wBAAgB,sBAAsB,IAAI,cAAc,CA8BvD;AAMD,gFAAgF;AAChF,YAAY,EAAE,qBAAqB,EAAE,CAAA"}
@@ -0,0 +1,41 @@
1
+ // A pure in-memory RunRecordStore. Useful for tests and local-only
2
+ // orchestrator harnesses. The production store is Postgres-backed
3
+ // and lives in voyant-cloud.
4
+ export function createInMemoryRunStore() {
5
+ const records = new Map();
6
+ return {
7
+ async get(id) {
8
+ const r = records.get(id);
9
+ return r ? clone(r) : undefined;
10
+ },
11
+ async save(record) {
12
+ records.set(record.id, clone(record));
13
+ return clone(record);
14
+ },
15
+ async tryInsert(record) {
16
+ // Atomic-by-construction: Map.has + Map.set inside a single
17
+ // microtask. Concurrent `tryInsert(idA)` calls all schedule on the
18
+ // same JS event loop and only the first one observes the slot
19
+ // empty — subsequent callers see the inserted record.
20
+ const existing = records.get(record.id);
21
+ if (existing)
22
+ return { record: clone(existing), created: false };
23
+ records.set(record.id, clone(record));
24
+ return { record: clone(record), created: true };
25
+ },
26
+ async list(filter = {}) {
27
+ let out = [...records.values()].map(clone);
28
+ if (filter.workflowId)
29
+ out = out.filter((r) => r.workflowId === filter.workflowId);
30
+ if (filter.status)
31
+ out = out.filter((r) => r.status === filter.status);
32
+ out.sort((a, b) => b.startedAt - a.startedAt);
33
+ if (filter.limit !== undefined)
34
+ out = out.slice(0, filter.limit);
35
+ return out;
36
+ },
37
+ };
38
+ }
39
+ function clone(value) {
40
+ return structuredClone(value);
41
+ }