@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
package/dist/drive.js ADDED
@@ -0,0 +1,373 @@
1
+ // The orchestrator's core loop.
2
+ //
3
+ // `driveUntilPaused` calls the tenant step handler repeatedly,
4
+ // merging each response into the run's journal, until the run is
5
+ // either terminal or parked on a waitpoint. It is deliberately free
6
+ // of persistence and transport — callers compose it with a
7
+ // `RunRecordStore` and a `StepHandler` (in-process or HTTP).
8
+ //
9
+ // See docs/runtime-protocol.md §2 + §5 for the wire semantics.
10
+ import { PROTOCOL_VERSION } from "@voyant-travel/workflows/protocol";
11
+ /**
12
+ * Drive a run forward. The passed-in record is mutated in place and
13
+ * also returned so callers can write it back to the store in one
14
+ * line: `await store.save(await driveUntilPaused(rec, opts))`.
15
+ */
16
+ export async function driveUntilPaused(rec, opts) {
17
+ const maxInvocations = opts.maxInvocations ?? 128;
18
+ const now = opts.now ?? (() => Date.now());
19
+ while (rec.invocationCount < maxInvocations) {
20
+ if (isTerminal(rec.status))
21
+ break;
22
+ // Workflow-level timeout check. Compute-time-only: we compare
23
+ // cumulative invocation duration, not wall-clock, so parked runs
24
+ // don't starve their own budget while waiting on waitpoints.
25
+ if (rec.timeoutMs !== undefined && rec.timeoutMs > 0 && rec.computeTimeMs >= rec.timeoutMs) {
26
+ rec.status = "failed";
27
+ rec.error = {
28
+ category: "RUNTIME_ERROR",
29
+ code: "WORKFLOW_TIMEOUT",
30
+ message: `workflow exceeded its ${rec.timeoutMs}ms compute-time budget (${rec.computeTimeMs}ms used)`,
31
+ };
32
+ rec.completedAt = now();
33
+ rec.pendingWaitpoints = [];
34
+ break;
35
+ }
36
+ if (opts.beforeInvocation) {
37
+ const go = await opts.beforeInvocation(rec);
38
+ if (!go)
39
+ break;
40
+ if (isTerminal(rec.status))
41
+ break;
42
+ }
43
+ rec.invocationCount += 1;
44
+ const invocationStartedAt = now();
45
+ const req = buildStepRequest(rec);
46
+ const out = await opts.handler(req, {
47
+ signal: opts.signal,
48
+ onStreamChunk: opts.onStreamChunk,
49
+ });
50
+ rec.computeTimeMs += Math.max(0, now() - invocationStartedAt);
51
+ if (out.status !== 200) {
52
+ rec.status = "failed";
53
+ rec.error = {
54
+ category: "RUNTIME_ERROR",
55
+ code: "handler_error",
56
+ message: "message" in out.body ? out.body.message : `handler returned HTTP ${out.status}`,
57
+ };
58
+ rec.completedAt = now();
59
+ break;
60
+ }
61
+ const response = out.body;
62
+ opts.onStepResponse?.({ runRecord: rec, response });
63
+ applyResponse(rec, response, now);
64
+ // Waiting with no pending waitpoints (all auto-resolved) is a
65
+ // protocol error; we still break rather than loop forever.
66
+ if (response.status === "waiting" && rec.pendingWaitpoints.length === 0) {
67
+ rec.status = "failed";
68
+ rec.error = {
69
+ category: "RUNTIME_ERROR",
70
+ code: "empty_waitpoint_list",
71
+ message: "tenant returned status=waiting without any registered waitpoints",
72
+ };
73
+ rec.completedAt = now();
74
+ break;
75
+ }
76
+ // RUN waitpoints are resolvable inline via the triggerChild hook:
77
+ // run each child to completion, write the result back on the
78
+ // parent's journal, drop the RUN waitpoint, then loop.
79
+ if (response.status === "waiting") {
80
+ const runWaitpoints = rec.pendingWaitpoints.filter((w) => w.kind === "RUN");
81
+ if (runWaitpoints.length > 0) {
82
+ if (!opts.triggerChild) {
83
+ rec.status = "failed";
84
+ rec.error = {
85
+ category: "RUNTIME_ERROR",
86
+ code: "child_runs_unsupported",
87
+ message: "workflow used ctx.invoke but the driver has no triggerChild hook wired. " +
88
+ "Use orchestrator.trigger() from @voyant-travel/workflows-orchestrator, which wires children automatically.",
89
+ };
90
+ rec.completedAt = now();
91
+ break;
92
+ }
93
+ const resolvedRunIds = new Set();
94
+ try {
95
+ for (const wp of runWaitpoints) {
96
+ const childResolution = await resolveChildRun(rec, wp, opts.triggerChild, now);
97
+ if (childResolution.kind === "resolved") {
98
+ rec.journal.waitpointsResolved[wp.clientWaitpointId] = childResolution.entry;
99
+ resolvedRunIds.add(wp.clientWaitpointId);
100
+ }
101
+ // deferred → leave the RUN waitpoint pending; the child
102
+ // will cascade-resume the parent on its terminal transition.
103
+ }
104
+ }
105
+ catch (err) {
106
+ rec.status = "failed";
107
+ rec.error = {
108
+ category: "RUNTIME_ERROR",
109
+ code: "child_run_unresolvable",
110
+ message: err instanceof Error ? err.message : String(err),
111
+ };
112
+ rec.completedAt = now();
113
+ break;
114
+ }
115
+ // Keep RUN waitpoints that are still deferred (child parked).
116
+ rec.pendingWaitpoints = rec.pendingWaitpoints.filter((w) => w.kind !== "RUN" || !resolvedRunIds.has(w.clientWaitpointId));
117
+ if (rec.pendingWaitpoints.length === 0) {
118
+ rec.status = "running";
119
+ // Loop continues → re-invoke with the resolved waitpoints in the journal.
120
+ continue;
121
+ }
122
+ // Still parked (non-RUN or deferred RUN); fall through to break.
123
+ }
124
+ }
125
+ if (rec.status !== "running")
126
+ break;
127
+ }
128
+ if (rec.invocationCount >= maxInvocations && rec.status === "running") {
129
+ rec.status = "failed";
130
+ rec.error = {
131
+ category: "RUNTIME_ERROR",
132
+ code: "max_invocations_exceeded",
133
+ message: `orchestrator drove the run ${maxInvocations} times without reaching a terminal or waiting state`,
134
+ };
135
+ rec.completedAt = now();
136
+ }
137
+ return rec;
138
+ }
139
+ /**
140
+ * Accept a waitpoint injection for a parked run: match it against
141
+ * one of the pending waitpoints, write the resolution into the
142
+ * journal, flip the run to "running", and leave it ready to be
143
+ * re-driven by `driveUntilPaused`.
144
+ */
145
+ export function applyWaitpointInjection(rec, injection, now = () => Date.now()) {
146
+ if (rec.status !== "waiting") {
147
+ return { ok: false, message: `run ${rec.id} is not parked (status: ${rec.status})` };
148
+ }
149
+ const matched = matchWaitpoint(rec.pendingWaitpoints, injection);
150
+ if (!matched) {
151
+ return {
152
+ ok: false,
153
+ message: `no pending waitpoint matches kind=${injection.kind}, key=${injectionKey(injection)}`,
154
+ };
155
+ }
156
+ rec.journal.waitpointsResolved[matched.clientWaitpointId] = {
157
+ kind: matched.kind,
158
+ resolvedAt: now(),
159
+ payload: injection.payload,
160
+ source: "live",
161
+ matchedEventId: injection.kind === "EVENT" ? `evt_live_${injection.eventType}` : undefined,
162
+ };
163
+ rec.pendingWaitpoints = rec.pendingWaitpoints.filter((w) => w.clientWaitpointId !== matched.clientWaitpointId);
164
+ rec.status = "running";
165
+ return { ok: true };
166
+ }
167
+ // ---- Internals ----
168
+ function buildStepRequest(rec) {
169
+ return {
170
+ protocolVersion: PROTOCOL_VERSION,
171
+ runId: rec.id,
172
+ workflowId: rec.workflowId,
173
+ workflowVersion: rec.workflowVersion,
174
+ invocationCount: rec.invocationCount,
175
+ input: rec.input,
176
+ journal: rec.journal,
177
+ environment: rec.environment,
178
+ // Deadlines aren't enforced yet in the reference orchestrator; the
179
+ // handler accepts the field for forward-compat.
180
+ deadline: Number.MAX_SAFE_INTEGER,
181
+ tenantMeta: rec.tenantMeta,
182
+ runMeta: {
183
+ number: rec.runMeta.number,
184
+ attempt: rec.runMeta.attempt,
185
+ triggeredBy: rec.triggeredBy,
186
+ tags: rec.tags,
187
+ startedAt: rec.startedAt,
188
+ },
189
+ };
190
+ }
191
+ function applyResponse(rec, response, now) {
192
+ // Snapshot the metadata state from the prior invocation. The
193
+ // response journal is a clone of what we sent in, so its
194
+ // metadataState field won't reflect mutations the body just made —
195
+ // those come in `metadataUpdates`. We keep the prior state, apply
196
+ // the delta, then swap in the new journal shape.
197
+ const priorMetadata = rec.journal.metadataState;
198
+ // The handler returned the executor's journal post-invocation —
199
+ // trust it as the new source of truth for steps / waitpoints /
200
+ // compensations. We deep-clone to isolate from future executor
201
+ // mutations.
202
+ rec.journal = structuredClone(response.journal);
203
+ rec.journal.metadataState = { ...priorMetadata };
204
+ // Apply only the delta of metadata mutations. Each invocation's
205
+ // response re-emits every mutation the body made — including those
206
+ // from prior invocations, since the body replays from the start.
207
+ // The positional cursor on rec.metadataAppliedCount dedups them.
208
+ const newMutations = response.metadataUpdates.slice(rec.metadataAppliedCount);
209
+ applyMetadataUpdates(rec.journal.metadataState, newMutations);
210
+ rec.metadataAppliedCount = response.metadataUpdates.length;
211
+ // Accumulate stream chunks across invocations, grouped by streamId.
212
+ // Each response carries only chunks emitted in that invocation.
213
+ for (const chunk of response.streamChunks) {
214
+ const bucket = rec.streams[chunk.streamId] ?? [];
215
+ rec.streams[chunk.streamId] = bucket;
216
+ bucket.push({ ...chunk });
217
+ }
218
+ if (response.status === "completed") {
219
+ rec.status = "completed";
220
+ rec.output = response.output;
221
+ rec.completedAt = now();
222
+ rec.pendingWaitpoints = [];
223
+ return;
224
+ }
225
+ if (response.status === "failed") {
226
+ rec.status = "failed";
227
+ rec.error = {
228
+ category: response.error.category,
229
+ code: response.error.code,
230
+ message: response.error.message,
231
+ };
232
+ rec.completedAt = now();
233
+ rec.pendingWaitpoints = [];
234
+ return;
235
+ }
236
+ if (response.status === "cancelled") {
237
+ rec.status = "cancelled";
238
+ rec.completedAt = now();
239
+ rec.pendingWaitpoints = [];
240
+ return;
241
+ }
242
+ if (response.status === "compensated" || response.status === "compensation_failed") {
243
+ rec.status = response.status;
244
+ if (response.error) {
245
+ rec.error = {
246
+ category: response.error.category,
247
+ code: response.error.code,
248
+ message: response.error.message,
249
+ };
250
+ }
251
+ rec.completedAt = now();
252
+ rec.pendingWaitpoints = [];
253
+ return;
254
+ }
255
+ // "waiting"
256
+ rec.status = "waiting";
257
+ const parkedAt = now();
258
+ rec.pendingWaitpoints = response.waitpoints.map((w) => {
259
+ const meta = { ...w.meta };
260
+ // Stamp wall-clock wake times on DATETIME waitpoints at park time,
261
+ // so alarm loops (local serve + CF DO) can fire at the right moment
262
+ // without re-deriving wall-clock from wherever the run is stored.
263
+ if (w.kind === "DATETIME" && typeof meta.wakeAt !== "number") {
264
+ const durationMs = w.timeoutMs ?? (typeof meta.durationMs === "number" ? meta.durationMs : 0);
265
+ meta.wakeAt = parkedAt + durationMs;
266
+ }
267
+ return {
268
+ clientWaitpointId: w.clientWaitpointId,
269
+ kind: w.kind,
270
+ meta,
271
+ timeoutMs: w.timeoutMs,
272
+ };
273
+ });
274
+ }
275
+ function isTerminal(status) {
276
+ return (status === "completed" ||
277
+ status === "failed" ||
278
+ status === "cancelled" ||
279
+ status === "compensated" ||
280
+ status === "compensation_failed");
281
+ }
282
+ function matchWaitpoint(pending, inj) {
283
+ for (const wp of pending) {
284
+ if (wp.kind !== inj.kind)
285
+ continue;
286
+ if (inj.kind === "EVENT" && wp.meta.eventType === inj.eventType)
287
+ return wp;
288
+ if (inj.kind === "SIGNAL" && wp.meta.signalName === inj.name)
289
+ return wp;
290
+ if (inj.kind === "MANUAL" && wp.meta.tokenId === inj.tokenId)
291
+ return wp;
292
+ }
293
+ return undefined;
294
+ }
295
+ function injectionKey(inj) {
296
+ if (inj.kind === "EVENT")
297
+ return inj.eventType;
298
+ if (inj.kind === "SIGNAL")
299
+ return inj.name;
300
+ return inj.tokenId;
301
+ }
302
+ async function resolveChildRun(parent, wp, triggerChild, now) {
303
+ const childRecord = await triggerChild({ parent, waitpoint: wp });
304
+ const at = now();
305
+ if (wp.meta.detach === true) {
306
+ return {
307
+ kind: "resolved",
308
+ entry: {
309
+ kind: "RUN",
310
+ resolvedAt: at,
311
+ payload: undefined,
312
+ source: "replay",
313
+ },
314
+ };
315
+ }
316
+ if (childRecord.status === "completed") {
317
+ return {
318
+ kind: "resolved",
319
+ entry: {
320
+ kind: "RUN",
321
+ resolvedAt: at,
322
+ payload: childRecord.output,
323
+ source: "replay",
324
+ },
325
+ };
326
+ }
327
+ if (childRecord.status === "waiting") {
328
+ // Child parked on its own waitpoint(s). The parent parks too; the
329
+ // child's parent pointer (set by trigger's driveOptionsFor) will
330
+ // cascade-resume the parent when the child later reaches a
331
+ // terminal state via resume/cancel/alarm.
332
+ return { kind: "deferred" };
333
+ }
334
+ // Failed / cancelled / compensated / compensation_failed → surface as error.
335
+ const errMsg = childRecord.error?.message ?? `child run ended with status ${childRecord.status}`;
336
+ const errCode = childRecord.error?.code ?? "CHILD_RUN_ENDED";
337
+ return {
338
+ kind: "resolved",
339
+ entry: {
340
+ kind: "RUN",
341
+ resolvedAt: at,
342
+ source: "replay",
343
+ error: {
344
+ category: childRecord.error?.category ??
345
+ "USER_ERROR",
346
+ code: errCode,
347
+ message: errMsg,
348
+ },
349
+ },
350
+ };
351
+ }
352
+ function applyMetadataUpdates(state, updates) {
353
+ for (const u of updates) {
354
+ switch (u.op) {
355
+ case "set":
356
+ state[u.key] = u.value;
357
+ break;
358
+ case "increment": {
359
+ const cur = typeof state[u.key] === "number" ? state[u.key] : 0;
360
+ state[u.key] = cur + (u.value ?? 1);
361
+ break;
362
+ }
363
+ case "append": {
364
+ const cur = Array.isArray(state[u.key]) ? state[u.key] : [];
365
+ state[u.key] = [...cur, u.value];
366
+ break;
367
+ }
368
+ case "remove":
369
+ delete state[u.key];
370
+ break;
371
+ }
372
+ }
373
+ }
@@ -0,0 +1,30 @@
1
+ import type { EnvironmentName } from "@voyant-travel/workflows";
2
+ import { type DriverFactory } from "@voyant-travel/workflows/driver";
3
+ import type { RunRecord, StepHandler } from "./types.js";
4
+ export interface InMemoryDriverOptions {
5
+ /** Default environment for `trigger()` calls that don't specify one. */
6
+ defaultEnvironment?: EnvironmentName;
7
+ /** Tenant metadata stamped onto every triggered run. */
8
+ tenantMeta?: RunRecord["tenantMeta"];
9
+ /** Injectable clock; defaults to `Date.now`. */
10
+ now?: () => number;
11
+ /** Step handler override — defaults to in-process `handleStepRequest`. */
12
+ handler?: StepHandler;
13
+ /** Schedule runner tick interval. Defaults to 1_000 ms. */
14
+ schedulePollIntervalMs?: number;
15
+ /** Disable automatic firing for schedules registered through manifests. */
16
+ disableScheduleRunner?: boolean;
17
+ }
18
+ /**
19
+ * Build an in-memory driver factory. The factory closes over its
20
+ * options and returns a fresh `WorkflowDriver` when `createApp()`
21
+ * (or a test) calls it with `DriverFactoryDeps`.
22
+ *
23
+ * Usage in tests:
24
+ *
25
+ * const driver = createInMemoryDriver()(testFactoryDeps())
26
+ * await driver.registerManifest({ environment: "production", manifest })
27
+ * await driver.trigger(myWorkflow, { … }, { idempotencyKey: "abc" })
28
+ */
29
+ export declare function createInMemoryDriver(opts?: InMemoryDriverOptions): DriverFactory;
30
+ //# sourceMappingURL=driver-inmemory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"driver-inmemory.d.ts","sourceRoot":"","sources":["../src/driver-inmemory.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EACV,eAAe,EAMhB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EACL,KAAK,aAAa,EAQnB,MAAM,iCAAiC,CAAA;AAkBxC,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAIxD,MAAM,WAAW,qBAAqB;IACpC,wEAAwE;IACxE,kBAAkB,CAAC,EAAE,eAAe,CAAA;IACpC,wDAAwD;IACxD,UAAU,CAAC,EAAE,SAAS,CAAC,YAAY,CAAC,CAAA;IACpC,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,WAAW,CAAA;IACrB,2DAA2D;IAC3D,sBAAsB,CAAC,EAAE,MAAM,CAAA;IAC/B,2EAA2E;IAC3E,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC;AAQD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,qBAA0B,GAAG,aAAa,CA+TpF"}