@voyant-travel/workflows 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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +52 -0
  3. package/README.md +79 -0
  4. package/dist/auth/index.d.ts +125 -0
  5. package/dist/auth/index.d.ts.map +1 -0
  6. package/dist/auth/index.js +352 -0
  7. package/dist/bindings.d.ts +119 -0
  8. package/dist/bindings.d.ts.map +1 -0
  9. package/dist/bindings.js +19 -0
  10. package/dist/client.d.ts +135 -0
  11. package/dist/client.d.ts.map +1 -0
  12. package/dist/client.js +305 -0
  13. package/dist/conditions.d.ts +29 -0
  14. package/dist/conditions.d.ts.map +1 -0
  15. package/dist/conditions.js +5 -0
  16. package/dist/config.d.ts +93 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/config.js +7 -0
  19. package/dist/driver.d.ts +237 -0
  20. package/dist/driver.d.ts.map +1 -0
  21. package/dist/driver.js +53 -0
  22. package/dist/errors.d.ts +58 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +76 -0
  25. package/dist/events/compile.d.ts +34 -0
  26. package/dist/events/compile.d.ts.map +1 -0
  27. package/dist/events/compile.js +204 -0
  28. package/dist/events/index.d.ts +8 -0
  29. package/dist/events/index.d.ts.map +1 -0
  30. package/dist/events/index.js +11 -0
  31. package/dist/events/input-mapper.d.ts +24 -0
  32. package/dist/events/input-mapper.d.ts.map +1 -0
  33. package/dist/events/input-mapper.js +169 -0
  34. package/dist/events/manifest-builder.d.ts +42 -0
  35. package/dist/events/manifest-builder.d.ts.map +1 -0
  36. package/dist/events/manifest-builder.js +313 -0
  37. package/dist/events/payload-hash.d.ts +46 -0
  38. package/dist/events/payload-hash.d.ts.map +1 -0
  39. package/dist/events/payload-hash.js +98 -0
  40. package/dist/events/predicate.d.ts +77 -0
  41. package/dist/events/predicate.d.ts.map +1 -0
  42. package/dist/events/predicate.js +347 -0
  43. package/dist/events/registry.d.ts +37 -0
  44. package/dist/events/registry.d.ts.map +1 -0
  45. package/dist/events/registry.js +47 -0
  46. package/dist/handler/index.d.ts +114 -0
  47. package/dist/handler/index.d.ts.map +1 -0
  48. package/dist/handler/index.js +267 -0
  49. package/dist/handler/resume.d.ts +41 -0
  50. package/dist/handler/resume.d.ts.map +1 -0
  51. package/dist/handler/resume.js +44 -0
  52. package/dist/http-ingest.d.ts +54 -0
  53. package/dist/http-ingest.d.ts.map +1 -0
  54. package/dist/http-ingest.js +214 -0
  55. package/dist/index.d.ts +6 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +10 -0
  58. package/dist/protocol/index.d.ts +345 -0
  59. package/dist/protocol/index.d.ts.map +1 -0
  60. package/dist/protocol/index.js +110 -0
  61. package/dist/rate-limit/index.d.ts +40 -0
  62. package/dist/rate-limit/index.d.ts.map +1 -0
  63. package/dist/rate-limit/index.js +139 -0
  64. package/dist/runtime/ctx.d.ts +111 -0
  65. package/dist/runtime/ctx.d.ts.map +1 -0
  66. package/dist/runtime/ctx.js +624 -0
  67. package/dist/runtime/determinism.d.ts +19 -0
  68. package/dist/runtime/determinism.d.ts.map +1 -0
  69. package/dist/runtime/determinism.js +61 -0
  70. package/dist/runtime/errors.d.ts +21 -0
  71. package/dist/runtime/errors.d.ts.map +1 -0
  72. package/dist/runtime/errors.js +45 -0
  73. package/dist/runtime/executor.d.ts +166 -0
  74. package/dist/runtime/executor.d.ts.map +1 -0
  75. package/dist/runtime/executor.js +226 -0
  76. package/dist/runtime/journal.d.ts +56 -0
  77. package/dist/runtime/journal.d.ts.map +1 -0
  78. package/dist/runtime/journal.js +28 -0
  79. package/dist/testing/index.d.ts +117 -0
  80. package/dist/testing/index.d.ts.map +1 -0
  81. package/dist/testing/index.js +599 -0
  82. package/dist/trigger.d.ts +37 -0
  83. package/dist/trigger.d.ts.map +1 -0
  84. package/dist/trigger.js +11 -0
  85. package/dist/types.d.ts +63 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +3 -0
  88. package/dist/workflow.d.ts +222 -0
  89. package/dist/workflow.d.ts.map +1 -0
  90. package/dist/workflow.js +55 -0
  91. package/package.json +120 -0
  92. package/src/auth/index.ts +398 -0
  93. package/src/bindings.ts +135 -0
  94. package/src/client.ts +498 -0
  95. package/src/conditions.ts +43 -0
  96. package/src/config.ts +114 -0
  97. package/src/driver.ts +277 -0
  98. package/src/errors.ts +109 -0
  99. package/src/events/compile.ts +268 -0
  100. package/src/events/index.ts +42 -0
  101. package/src/events/input-mapper.ts +201 -0
  102. package/src/events/manifest-builder.ts +372 -0
  103. package/src/events/payload-hash.ts +110 -0
  104. package/src/events/predicate.ts +390 -0
  105. package/src/events/registry.ts +86 -0
  106. package/src/handler/index.ts +413 -0
  107. package/src/handler/resume.ts +100 -0
  108. package/src/http-ingest.ts +299 -0
  109. package/src/index.ts +18 -0
  110. package/src/protocol/index.ts +483 -0
  111. package/src/rate-limit/index.ts +181 -0
  112. package/src/runtime/ctx.ts +876 -0
  113. package/src/runtime/determinism.ts +75 -0
  114. package/src/runtime/errors.ts +58 -0
  115. package/src/runtime/executor.ts +442 -0
  116. package/src/runtime/journal.ts +80 -0
  117. package/src/testing/index.ts +796 -0
  118. package/src/trigger.ts +63 -0
  119. package/src/types.ts +80 -0
  120. package/src/workflow.ts +328 -0
@@ -0,0 +1,796 @@
1
+ // @voyant-travel/workflows/testing
2
+ //
3
+ // In-process test harness with mocked steps, waits, and invokes.
4
+ // Contract in docs/sdk-surface.md §11.
5
+ // agent-quality: file-size exception -- legacy test harness stays co-located until pause/resume helpers are split.
6
+ //
7
+ // Drives `executeWorkflowStep` across resumptions. Steps resolve
8
+ // from a user-supplied stub map; waitpoints resolve from fixtures.
9
+
10
+ import type { SerializedError } from "../protocol/index.js"
11
+ import {
12
+ type CompensationReport,
13
+ type ExecuteWorkflowStepResponse,
14
+ executeWorkflowStep,
15
+ type MetadataMutation,
16
+ type StreamChunk,
17
+ type WaitpointRegistration,
18
+ } from "../runtime/executor.js"
19
+ import type {
20
+ JournalSlice,
21
+ StepJournalEntry,
22
+ WaitpointResolutionEntry,
23
+ } from "../runtime/journal.js"
24
+ import { emptyJournal } from "../runtime/journal.js"
25
+ import type { RunStatus } from "../types.js"
26
+ import type {
27
+ EnvironmentContext,
28
+ MetadataValue,
29
+ StepContext,
30
+ WorkflowDefinition,
31
+ WorkflowHandle,
32
+ } from "../workflow.js"
33
+ import { getWorkflow } from "../workflow.js"
34
+
35
+ function workflowForExecutor<TIn, TOut>(def: WorkflowDefinition<TIn, TOut>): WorkflowDefinition {
36
+ return def as WorkflowDefinition
37
+ }
38
+
39
+ export interface TestOptions<_TIn> {
40
+ /** Map of stepId → function run in place of the real step body. */
41
+ steps?: Record<string, (stepCtx: StepContext) => unknown | Promise<unknown>>
42
+ /**
43
+ * Map of eventType → payload (or payload array). First
44
+ * match-per-eventType wins.
45
+ */
46
+ waitForEvent?: Record<string, unknown | unknown[]>
47
+ /** Map of signalName → payload. */
48
+ waitForSignal?: Record<string, unknown>
49
+ /** Map of tokenId → payload. */
50
+ waitForToken?: Record<string, unknown>
51
+ /** Workflow invoke stubs keyed by child workflow id. */
52
+ invoke?: Record<string, unknown>
53
+ /** Fake env bindings, passed through to `ctx.environment` for tests. */
54
+ env?: Record<string, unknown>
55
+ environment?: Partial<EnvironmentContext>
56
+ /** Fixed wall-clock basis for the run. Defaults to Date.now(). */
57
+ now?: () => number
58
+ random?: () => number
59
+ /** Max resumption cycles. Defaults to 16 — guards runaway loops. */
60
+ maxInvocations?: number
61
+ /**
62
+ * When true, the harness stops and returns a "waiting" TestResult as
63
+ * soon as any registered waitpoint has no fixture resolution (instead
64
+ * of throwing). Callers can then persist the run and resume it later
65
+ * via `resumeWorkflowForTest` once the waitpoint is resolved from a
66
+ * live source (e.g. a dashboard HTTP injection).
67
+ *
68
+ * DATETIME waitpoints (sleeps) are always auto-resolved regardless of
69
+ * this flag, since a local dev loop is not the right place to
70
+ * synthesize wall-clock delays.
71
+ */
72
+ pauseOnWait?: boolean
73
+ }
74
+
75
+ export interface TestResult<TOut> {
76
+ status: Extract<
77
+ RunStatus,
78
+ "completed" | "failed" | "cancelled" | "compensated" | "compensation_failed" | "waiting"
79
+ >
80
+ output?: TOut
81
+ error?: { category: SerializedError["category"]; code: string; message: string }
82
+ steps: { id: string; status: "ok" | "err" | "skipped"; duration: number; output?: unknown }[]
83
+ events: { type: string; at: number; data: unknown }[]
84
+ metadata: Record<string, MetadataValue>
85
+ compensations: CompensationReport[]
86
+ /** Chunks emitted via `ctx.stream()` / `ctx.stream.{text,json,bytes}`, grouped by streamId in emission order. */
87
+ streams: Record<string, StreamChunk[]>
88
+ invocations: number
89
+ /**
90
+ * Populated when `status === "waiting"`. Holds the persisted executor
91
+ * state needed to resume the run via `resumeWorkflowForTest`.
92
+ */
93
+ pause?: {
94
+ journal: JournalSlice
95
+ pendingWaitpoints: WaitpointRegistration[]
96
+ startedAt: number
97
+ invocationCount: number
98
+ metadataAppliedCount: number
99
+ fixtureCursors: { event: Record<string, number>; signal: Record<string, number> }
100
+ }
101
+ }
102
+
103
+ export async function runWorkflowForTest<TIn, TOut>(
104
+ workflow: WorkflowHandle<TIn, TOut>,
105
+ input: TIn,
106
+ opts: TestOptions<TIn> = {},
107
+ ): Promise<TestResult<TOut>> {
108
+ const def = workflow as WorkflowDefinition<TIn, TOut>
109
+ const now = opts.now ?? (() => Date.now())
110
+ const startedAt = now()
111
+ const journal: JournalSlice = emptyJournal()
112
+ const metadata: Record<string, MetadataValue> = {}
113
+ const events: TestResult<TOut>["events"] = []
114
+ const streams: Record<string, StreamChunk[]> = {}
115
+ const maxInvocations = opts.maxInvocations ?? 16
116
+
117
+ const environment: EnvironmentContext = {
118
+ name: opts.environment?.name ?? "development",
119
+ git: opts.environment?.git,
120
+ }
121
+
122
+ let invocationCount = 0
123
+ let last: ExecuteWorkflowStepResponse | undefined
124
+ // Track how many metadata mutations have already been applied so we
125
+ // only apply the delta on each invocation. Otherwise replays
126
+ // double-count `increment` / duplicate `append` values. (Positional
127
+ // dedup; mirrors what the real orchestrator does with journaled ids.)
128
+ let metadataAppliedCount = 0
129
+ // Per-fixture cursors into iterable event/signal arrays, persisted
130
+ // across invocations so replay doesn't restart consumption.
131
+ const cursors: FixtureCursors = { event: new Map(), signal: new Map() }
132
+
133
+ while (invocationCount < maxInvocations) {
134
+ invocationCount += 1
135
+
136
+ const response = await executeWorkflowStep(workflowForExecutor(def), {
137
+ runId: `run_test_${def.id}`,
138
+ workflowId: def.id,
139
+ workflowVersion: "test",
140
+ input,
141
+ journal,
142
+ invocationCount,
143
+ environment: {
144
+ run: {
145
+ id: `run_test_${def.id}`,
146
+ number: 1,
147
+ attempt: 1,
148
+ triggeredBy: { kind: "api" },
149
+ tags: [],
150
+ startedAt,
151
+ },
152
+ workflow: { id: def.id, version: "test" },
153
+ environment,
154
+ project: { id: "prj_test", slug: "test" },
155
+ organization: { id: "org_test", slug: "test" },
156
+ },
157
+ triggeredBy: { kind: "api" },
158
+ runStartedAt: startedAt,
159
+ tags: [],
160
+ stepRunner: createStepRunner(opts, events, now),
161
+ })
162
+
163
+ last = response
164
+
165
+ const newMutations = response.metadataUpdates.slice(metadataAppliedCount)
166
+ applyMetadata(metadata, newMutations)
167
+ metadataAppliedCount = response.metadataUpdates.length
168
+
169
+ for (const chunk of response.streamChunks) {
170
+ const bucket = streams[chunk.streamId] ?? []
171
+ streams[chunk.streamId] = bucket
172
+ bucket.push(chunk)
173
+ }
174
+
175
+ if (response.status === "completed") {
176
+ return {
177
+ status: "completed",
178
+ output: response.output as TOut,
179
+ steps: stepsFromJournal(journal),
180
+ events,
181
+ metadata,
182
+ compensations: [],
183
+ streams,
184
+ invocations: invocationCount,
185
+ }
186
+ }
187
+
188
+ if (response.status === "failed") {
189
+ return {
190
+ status: "failed",
191
+ error: {
192
+ category: response.error.category,
193
+ code: response.error.code,
194
+ message: response.error.message,
195
+ },
196
+ steps: stepsFromJournal(journal),
197
+ events,
198
+ metadata,
199
+ compensations: [],
200
+ streams,
201
+ invocations: invocationCount,
202
+ }
203
+ }
204
+
205
+ if (response.status === "cancelled") {
206
+ return {
207
+ status: "cancelled",
208
+ steps: stepsFromJournal(journal),
209
+ events,
210
+ metadata,
211
+ compensations: response.compensations,
212
+ streams,
213
+ invocations: invocationCount,
214
+ }
215
+ }
216
+
217
+ if (response.status === "compensated" || response.status === "compensation_failed") {
218
+ return {
219
+ status: response.status,
220
+ error: response.error
221
+ ? {
222
+ category: response.error.category,
223
+ code: response.error.code,
224
+ message: response.error.message,
225
+ }
226
+ : undefined,
227
+ steps: stepsFromJournal(journal),
228
+ events,
229
+ metadata,
230
+ compensations: response.compensations,
231
+ streams,
232
+ invocations: invocationCount,
233
+ }
234
+ }
235
+
236
+ // Waiting: resolve waitpoints from fixtures and loop. Waitpoints
237
+ // that have no fixture match either throw (default) or, when
238
+ // `pauseOnWait` is set, park the run and return a "waiting" result.
239
+ const stillPending: WaitpointRegistration[] = []
240
+ for (const wp of response.waitpoints) {
241
+ const resolved = await resolveWaitpoint(wp, opts, now, events, cursors)
242
+ if (!resolved) {
243
+ if (opts.pauseOnWait) {
244
+ stillPending.push(wp)
245
+ continue
246
+ }
247
+ throw new Error(
248
+ `test harness: waitpoint ${wp.clientWaitpointId} (${wp.kind}) has no fixture resolution. ` +
249
+ `Provide one via TestOptions.waitForEvent / waitForSignal / (sleeps auto-resolve), ` +
250
+ `or set TestOptions.pauseOnWait to park the run.`,
251
+ )
252
+ }
253
+ journal.waitpointsResolved[wp.clientWaitpointId] = resolved
254
+ events.push({
255
+ type: `waitpoint.resolved:${wp.kind}`,
256
+ at: resolved.resolvedAt,
257
+ data: resolved.payload ?? null,
258
+ })
259
+ }
260
+ if (stillPending.length > 0) {
261
+ return {
262
+ status: "waiting",
263
+ steps: stepsFromJournal(journal),
264
+ events,
265
+ metadata,
266
+ compensations: [],
267
+ streams,
268
+ invocations: invocationCount,
269
+ pause: {
270
+ journal,
271
+ pendingWaitpoints: stillPending,
272
+ startedAt,
273
+ invocationCount,
274
+ metadataAppliedCount,
275
+ fixtureCursors: {
276
+ event: Object.fromEntries(cursors.event.entries()),
277
+ signal: Object.fromEntries(cursors.signal.entries()),
278
+ },
279
+ },
280
+ }
281
+ }
282
+ }
283
+
284
+ throw new Error(
285
+ `test harness exceeded maxInvocations (${maxInvocations}). ` +
286
+ `Last status: ${last?.status ?? "<none>"}. Possible infinite waitpoint loop.`,
287
+ )
288
+ }
289
+
290
+ /**
291
+ * A single injected waitpoint resolution — the tenant-side analogue of
292
+ * the orchestrator delivering an event, signal, or token payload to a
293
+ * parked run. The matcher (`kind` + `key`) identifies which pending
294
+ * waitpoint to resolve; `payload` is the value surfaced to the body.
295
+ */
296
+ export type WaitpointInjection =
297
+ | { kind: "EVENT"; eventType: string; payload?: unknown }
298
+ | { kind: "SIGNAL"; name: string; payload?: unknown }
299
+ | { kind: "MANUAL"; tokenId: string; payload?: unknown }
300
+
301
+ export interface ResumeOptions<TIn> extends TestOptions<TIn> {
302
+ /** Persisted pause state returned from a previous `runWorkflowForTest` or `resumeWorkflowForTest`. */
303
+ pause: NonNullable<TestResult<unknown>["pause"]>
304
+ /** The single waitpoint resolution to inject on resume. */
305
+ injection: WaitpointInjection
306
+ }
307
+
308
+ /**
309
+ * Resume a parked run. Matches the injection against one of the
310
+ * `pause.pendingWaitpoints`, records it in the journal, and re-enters
311
+ * the executor loop until the next pause or terminal state.
312
+ */
313
+ export async function resumeWorkflowForTest<TIn, TOut>(
314
+ workflow: WorkflowHandle<TIn, TOut>,
315
+ input: TIn,
316
+ opts: ResumeOptions<TIn>,
317
+ ): Promise<TestResult<TOut>> {
318
+ const def = workflow as WorkflowDefinition<TIn, TOut>
319
+ const now = opts.now ?? (() => Date.now())
320
+ const maxInvocations = opts.maxInvocations ?? 16
321
+
322
+ const matched = matchWaitpoint(opts.pause.pendingWaitpoints, opts.injection)
323
+ if (!matched) {
324
+ throw new Error(
325
+ `resume: no pending waitpoint matches injection kind=${opts.injection.kind}, ` +
326
+ `key=${injectionKey(opts.injection)}`,
327
+ )
328
+ }
329
+
330
+ const journal = cloneJournal(opts.pause.journal)
331
+ journal.waitpointsResolved[matched.clientWaitpointId] = {
332
+ kind: matched.kind,
333
+ resolvedAt: now(),
334
+ payload: opts.injection.payload,
335
+ source: "live",
336
+ matchedEventId:
337
+ opts.injection.kind === "EVENT" ? `evt_live_${opts.injection.eventType}` : undefined,
338
+ }
339
+
340
+ const events: TestResult<TOut>["events"] = [
341
+ {
342
+ type: `waitpoint.resolved:${matched.kind}`,
343
+ at: now(),
344
+ data: opts.injection.payload ?? null,
345
+ },
346
+ ]
347
+ const metadata: Record<string, MetadataValue> = {}
348
+ const streams: Record<string, StreamChunk[]> = {}
349
+ const cursors: FixtureCursors = {
350
+ event: new Map(Object.entries(opts.pause.fixtureCursors.event)),
351
+ signal: new Map(Object.entries(opts.pause.fixtureCursors.signal)),
352
+ }
353
+
354
+ const environment: EnvironmentContext = {
355
+ name: opts.environment?.name ?? "development",
356
+ git: opts.environment?.git,
357
+ }
358
+
359
+ let invocationCount = opts.pause.invocationCount
360
+ let metadataAppliedCount = opts.pause.metadataAppliedCount
361
+ let last: ExecuteWorkflowStepResponse | undefined
362
+
363
+ // Remaining pending waitpoints from the previous pause (still parked).
364
+ let carryover = opts.pause.pendingWaitpoints.filter(
365
+ (w) => w.clientWaitpointId !== matched.clientWaitpointId,
366
+ )
367
+
368
+ while (invocationCount < maxInvocations) {
369
+ invocationCount += 1
370
+
371
+ const response = await executeWorkflowStep(workflowForExecutor(def), {
372
+ runId: `run_test_${def.id}`,
373
+ workflowId: def.id,
374
+ workflowVersion: "test",
375
+ input,
376
+ journal,
377
+ invocationCount,
378
+ environment: {
379
+ run: {
380
+ id: `run_test_${def.id}`,
381
+ number: 1,
382
+ attempt: 1,
383
+ triggeredBy: { kind: "api" },
384
+ tags: [],
385
+ startedAt: opts.pause.startedAt,
386
+ },
387
+ workflow: { id: def.id, version: "test" },
388
+ environment,
389
+ project: { id: "prj_test", slug: "test" },
390
+ organization: { id: "org_test", slug: "test" },
391
+ },
392
+ triggeredBy: { kind: "api" },
393
+ runStartedAt: opts.pause.startedAt,
394
+ tags: [],
395
+ stepRunner: createStepRunner(opts, events, now),
396
+ })
397
+
398
+ last = response
399
+
400
+ const newMutations = response.metadataUpdates.slice(metadataAppliedCount)
401
+ applyMetadata(metadata, newMutations)
402
+ metadataAppliedCount = response.metadataUpdates.length
403
+
404
+ for (const chunk of response.streamChunks) {
405
+ const bucket = streams[chunk.streamId] ?? []
406
+ streams[chunk.streamId] = bucket
407
+ bucket.push(chunk)
408
+ }
409
+
410
+ if (response.status === "completed") {
411
+ return {
412
+ status: "completed",
413
+ output: response.output as TOut,
414
+ steps: stepsFromJournal(journal),
415
+ events,
416
+ metadata,
417
+ compensations: [],
418
+ streams,
419
+ invocations: invocationCount,
420
+ }
421
+ }
422
+ if (response.status === "failed") {
423
+ return {
424
+ status: "failed",
425
+ error: {
426
+ category: response.error.category,
427
+ code: response.error.code,
428
+ message: response.error.message,
429
+ },
430
+ steps: stepsFromJournal(journal),
431
+ events,
432
+ metadata,
433
+ compensations: [],
434
+ streams,
435
+ invocations: invocationCount,
436
+ }
437
+ }
438
+ if (response.status === "cancelled") {
439
+ return {
440
+ status: "cancelled",
441
+ steps: stepsFromJournal(journal),
442
+ events,
443
+ metadata,
444
+ compensations: response.compensations,
445
+ streams,
446
+ invocations: invocationCount,
447
+ }
448
+ }
449
+ if (response.status === "compensated" || response.status === "compensation_failed") {
450
+ return {
451
+ status: response.status,
452
+ error: response.error
453
+ ? {
454
+ category: response.error.category,
455
+ code: response.error.code,
456
+ message: response.error.message,
457
+ }
458
+ : undefined,
459
+ steps: stepsFromJournal(journal),
460
+ events,
461
+ metadata,
462
+ compensations: response.compensations,
463
+ streams,
464
+ invocations: invocationCount,
465
+ }
466
+ }
467
+
468
+ const stillPending: WaitpointRegistration[] = [...carryover]
469
+ for (const wp of response.waitpoints) {
470
+ const resolved = await resolveWaitpoint(wp, opts, now, events, cursors)
471
+ if (!resolved) {
472
+ if (opts.pauseOnWait) {
473
+ stillPending.push(wp)
474
+ continue
475
+ }
476
+ throw new Error(
477
+ `resume: waitpoint ${wp.clientWaitpointId} (${wp.kind}) has no fixture resolution. ` +
478
+ `Provide one via TestOptions.waitForEvent / waitForSignal, or set pauseOnWait.`,
479
+ )
480
+ }
481
+ journal.waitpointsResolved[wp.clientWaitpointId] = resolved
482
+ events.push({
483
+ type: `waitpoint.resolved:${wp.kind}`,
484
+ at: resolved.resolvedAt,
485
+ data: resolved.payload ?? null,
486
+ })
487
+ }
488
+ carryover = [] // consumed into stillPending
489
+
490
+ if (stillPending.length > 0) {
491
+ return {
492
+ status: "waiting",
493
+ steps: stepsFromJournal(journal),
494
+ events,
495
+ metadata,
496
+ compensations: [],
497
+ streams,
498
+ invocations: invocationCount,
499
+ pause: {
500
+ journal,
501
+ pendingWaitpoints: stillPending,
502
+ startedAt: opts.pause.startedAt,
503
+ invocationCount,
504
+ metadataAppliedCount,
505
+ fixtureCursors: {
506
+ event: Object.fromEntries(cursors.event.entries()),
507
+ signal: Object.fromEntries(cursors.signal.entries()),
508
+ },
509
+ },
510
+ }
511
+ }
512
+ }
513
+
514
+ throw new Error(
515
+ `resume: exceeded maxInvocations (${maxInvocations}). Last status: ${last?.status ?? "<none>"}.`,
516
+ )
517
+ }
518
+
519
+ function matchWaitpoint(
520
+ pending: readonly WaitpointRegistration[],
521
+ inj: WaitpointInjection,
522
+ ): WaitpointRegistration | undefined {
523
+ for (const wp of pending) {
524
+ if (wp.kind !== inj.kind) continue
525
+ if (inj.kind === "EVENT" && wp.meta.eventType === inj.eventType) return wp
526
+ if (inj.kind === "SIGNAL" && wp.meta.signalName === inj.name) return wp
527
+ if (inj.kind === "MANUAL" && wp.meta.tokenId === inj.tokenId) return wp
528
+ }
529
+ return undefined
530
+ }
531
+
532
+ function injectionKey(inj: WaitpointInjection): string {
533
+ if (inj.kind === "EVENT") return inj.eventType
534
+ if (inj.kind === "SIGNAL") return inj.name
535
+ return inj.tokenId
536
+ }
537
+
538
+ function cloneJournal(j: JournalSlice): JournalSlice {
539
+ return {
540
+ stepResults: { ...j.stepResults },
541
+ waitpointsResolved: { ...j.waitpointsResolved },
542
+ compensationsRun: { ...j.compensationsRun },
543
+ metadataState: { ...j.metadataState },
544
+ streamsCompleted: { ...j.streamsCompleted },
545
+ }
546
+ }
547
+
548
+ function createStepRunner(
549
+ opts: TestOptions<unknown>,
550
+ events: TestResult<unknown>["events"],
551
+ now: () => number,
552
+ ) {
553
+ return async (args: {
554
+ stepId: string
555
+ attempt: number
556
+ input: unknown
557
+ fn: (stepCtx: StepContext) => Promise<unknown>
558
+ stepCtx: StepContext
559
+ }): Promise<StepJournalEntry> => {
560
+ const startedAt = now()
561
+ const mock = opts.steps?.[args.stepId]
562
+ try {
563
+ const output = mock ? await mock(args.stepCtx) : await args.fn(args.stepCtx)
564
+ const finishedAt = now()
565
+ events.push({ type: "step.ok", at: finishedAt, data: { stepId: args.stepId, output } })
566
+ return {
567
+ attempt: args.attempt,
568
+ status: "ok",
569
+ output,
570
+ startedAt,
571
+ finishedAt,
572
+ }
573
+ } catch (err) {
574
+ const finishedAt = now()
575
+ const e = err as Error
576
+ const code = (err as { code?: string }).code ?? "UNKNOWN"
577
+ events.push({
578
+ type: "step.err",
579
+ at: finishedAt,
580
+ data: { stepId: args.stepId, message: e.message, code },
581
+ })
582
+ const retryAfter = (err as { retryAfter?: unknown }).retryAfter
583
+ return {
584
+ attempt: args.attempt,
585
+ status: "err",
586
+ error: {
587
+ category: "USER_ERROR",
588
+ code,
589
+ message: e.message,
590
+ name: e.name,
591
+ stack: e.stack,
592
+ data: retryAfter !== undefined ? { retryAfter } : undefined,
593
+ },
594
+ startedAt,
595
+ finishedAt,
596
+ }
597
+ }
598
+ }
599
+ }
600
+
601
+ async function resolveWaitpoint(
602
+ wp: WaitpointRegistration,
603
+ opts: TestOptions<unknown>,
604
+ now: () => number,
605
+ parentEvents: TestResult<unknown>["events"],
606
+ cursors: FixtureCursors,
607
+ ): Promise<WaitpointResolutionEntry | null> {
608
+ const at = now()
609
+ if (wp.kind === "DATETIME") {
610
+ return { kind: "DATETIME", resolvedAt: at, source: "replay" }
611
+ }
612
+ if (wp.kind === "EVENT") {
613
+ const eventType = wp.meta.eventType as string
614
+ const isIter = wp.meta.iter === true
615
+ const fixture = opts.waitForEvent?.[eventType]
616
+ if (fixture === undefined) return null
617
+ if (isIter) {
618
+ return resolveIterableFixture(
619
+ fixture,
620
+ cursors.event,
621
+ eventType,
622
+ at,
623
+ "EVENT",
624
+ `evt_test_${eventType}`,
625
+ )
626
+ }
627
+ const payload = Array.isArray(fixture) ? fixture[0] : fixture
628
+ return {
629
+ kind: "EVENT",
630
+ resolvedAt: at,
631
+ matchedEventId: `evt_test_${eventType}`,
632
+ payload,
633
+ source: "live",
634
+ }
635
+ }
636
+ if (wp.kind === "SIGNAL") {
637
+ const name = wp.meta.signalName as string
638
+ const isIter = wp.meta.iter === true
639
+ const fixture = opts.waitForSignal?.[name]
640
+ if (fixture === undefined) return null
641
+ if (isIter) {
642
+ return resolveIterableFixture(fixture, cursors.signal, name, at, "SIGNAL")
643
+ }
644
+ return { kind: "SIGNAL", resolvedAt: at, payload: fixture, source: "live" }
645
+ }
646
+ if (wp.kind === "MANUAL") {
647
+ const tokenId = wp.meta.tokenId as string
648
+ const fixture = opts.waitForToken?.[tokenId]
649
+ if (fixture === undefined) return null
650
+ return { kind: "MANUAL", resolvedAt: at, payload: fixture, source: "live" }
651
+ }
652
+ if (wp.kind === "RUN") {
653
+ const childWorkflowId = wp.meta.childWorkflowId as string
654
+ const childInput = wp.meta.childInput
655
+ const detach = wp.meta.detach === true
656
+ // Allow test-level override: `invoke: { [childId]: value | (input) => value }`.
657
+ const override = opts.invoke?.[childWorkflowId]
658
+ if (override !== undefined) {
659
+ const payload =
660
+ typeof override === "function"
661
+ ? await (override as (input: unknown) => unknown | Promise<unknown>)(childInput)
662
+ : override
663
+ parentEvents.push({
664
+ type: "child.resolved-from-fixture",
665
+ at: now(),
666
+ data: { childWorkflowId, payload, detach },
667
+ })
668
+ return {
669
+ kind: "RUN",
670
+ resolvedAt: now(),
671
+ payload: detach ? undefined : payload,
672
+ source: "replay",
673
+ }
674
+ }
675
+
676
+ // Otherwise run the child workflow in-process.
677
+ const child = getWorkflow(childWorkflowId)
678
+ if (!child) {
679
+ throw new Error(
680
+ `test harness: ctx.invoke target "${childWorkflowId}" is not registered. ` +
681
+ `Import the child workflow module, or provide a stub via TestOptions.invoke.`,
682
+ )
683
+ }
684
+ parentEvents.push({
685
+ type: "child.started",
686
+ at: now(),
687
+ data: { childWorkflowId, input: childInput },
688
+ })
689
+ const childResult = await runWorkflowForTest(child, childInput, {
690
+ steps: opts.steps,
691
+ waitForEvent: opts.waitForEvent,
692
+ waitForSignal: opts.waitForSignal,
693
+ waitForToken: opts.waitForToken,
694
+ invoke: opts.invoke,
695
+ env: opts.env,
696
+ environment: opts.environment,
697
+ now: opts.now,
698
+ random: opts.random,
699
+ maxInvocations: opts.maxInvocations,
700
+ pauseOnWait: detach || opts.pauseOnWait,
701
+ })
702
+ parentEvents.push({
703
+ type: "child.finished",
704
+ at: now(),
705
+ data: { childWorkflowId, status: childResult.status, output: childResult.output, detach },
706
+ })
707
+ if (detach) {
708
+ return { kind: "RUN", resolvedAt: now(), payload: undefined, source: "replay" }
709
+ }
710
+ if (childResult.status === "completed") {
711
+ return { kind: "RUN", resolvedAt: now(), payload: childResult.output, source: "replay" }
712
+ }
713
+ // Child failed / cancelled / compensated with error / compensation_failed.
714
+ const err = childResult.error ?? {
715
+ category: "USER_ERROR" as const,
716
+ code: "CHILD_RUN_ENDED",
717
+ message: `child run ended with status ${childResult.status}`,
718
+ }
719
+ return {
720
+ kind: "RUN",
721
+ resolvedAt: now(),
722
+ source: "replay",
723
+ error: err,
724
+ }
725
+ }
726
+ return null
727
+ }
728
+
729
+ export interface FixtureCursors {
730
+ event: Map<string, number>
731
+ signal: Map<string, number>
732
+ }
733
+
734
+ const STREAM_END_MARKER = { __voyantStreamEnd: true } as const
735
+
736
+ function resolveIterableFixture(
737
+ fixture: unknown,
738
+ cursors: Map<string, number>,
739
+ key: string,
740
+ at: number,
741
+ kind: "EVENT" | "SIGNAL",
742
+ matchedEventId?: string,
743
+ ): WaitpointResolutionEntry {
744
+ const array = Array.isArray(fixture) ? fixture : [fixture]
745
+ const idx = cursors.get(key) ?? 0
746
+ cursors.set(key, idx + 1)
747
+ if (idx >= array.length) {
748
+ // Stream is exhausted — signal the tenant-side iterator to terminate.
749
+ return {
750
+ kind,
751
+ resolvedAt: at,
752
+ payload: STREAM_END_MARKER,
753
+ source: "replay",
754
+ matchedEventId,
755
+ }
756
+ }
757
+ return {
758
+ kind,
759
+ resolvedAt: at,
760
+ payload: array[idx],
761
+ source: "live",
762
+ matchedEventId,
763
+ }
764
+ }
765
+
766
+ function applyMetadata(state: Record<string, MetadataValue>, updates: MetadataMutation[]): void {
767
+ for (const u of updates) {
768
+ switch (u.op) {
769
+ case "set":
770
+ state[u.key] = u.value as MetadataValue
771
+ break
772
+ case "increment": {
773
+ const cur = typeof state[u.key] === "number" ? (state[u.key] as number) : 0
774
+ state[u.key] = cur + ((u.value as number) ?? 1)
775
+ break
776
+ }
777
+ case "append": {
778
+ const cur = Array.isArray(state[u.key]) ? (state[u.key] as MetadataValue[]) : []
779
+ state[u.key] = [...cur, u.value as MetadataValue]
780
+ break
781
+ }
782
+ case "remove":
783
+ delete state[u.key]
784
+ break
785
+ }
786
+ }
787
+ }
788
+
789
+ function stepsFromJournal(j: JournalSlice): TestResult<unknown>["steps"] {
790
+ return Object.entries(j.stepResults).map(([id, entry]) => ({
791
+ id,
792
+ status: entry.status,
793
+ duration: entry.finishedAt - entry.startedAt,
794
+ output: entry.output,
795
+ }))
796
+ }