@voyantjs/workflows 0.0.0 → 0.6.8

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