@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,876 @@
1
+ // Builds the `ctx` object passed to the workflow body.
2
+ // agent-quality: file-size exception -- Central ctx API wiring remains together until wait/stream/group runtime slices can be extracted safely.
3
+ //
4
+ // The executor owns the waitpoint-pending queue and the callbacks
5
+ // into the orchestrator; ctx is a thin shell that delegates.
6
+
7
+ import type { ServiceResolver } from "../driver.js"
8
+ import type { SerializedError } from "../protocol/index.js"
9
+ import type { Duration, RetryPolicy, WaitpointKind } from "../types.js"
10
+ import type {
11
+ EnvironmentContext,
12
+ GroupApi,
13
+ GroupScope,
14
+ InvokeApi,
15
+ InvokeOptions,
16
+ MetadataApi,
17
+ MetadataValue,
18
+ ParallelApi,
19
+ RunContext,
20
+ StepApi,
21
+ StepContext,
22
+ StepFn,
23
+ StepOptions,
24
+ StreamApi,
25
+ TokenWait,
26
+ Waitable,
27
+ WaitForEventApi,
28
+ WaitForSignalApi,
29
+ WaitForTokenApi,
30
+ WorkflowContext,
31
+ WorkflowHandle,
32
+ } from "../workflow.js"
33
+ import { advanceClockTo, type ClockState, createRandomUUID, now } from "./determinism.js"
34
+ import {
35
+ CompensateRequestedSignal,
36
+ isCompensateRequested,
37
+ isRunCancelled,
38
+ isWaitpointPending,
39
+ RunCancelledSignal,
40
+ WaitpointPendingSignal,
41
+ } from "./errors.js"
42
+ import type { JournalSlice, StepJournalEntry, WaitpointResolutionEntry } from "./journal.js"
43
+
44
+ /**
45
+ * Callbacks the executor provides for operations that must reach the
46
+ * orchestrator (over HTTP in production, in-memory in tests).
47
+ */
48
+ export interface RuntimeCallbacks {
49
+ /** Run a new step and journal the result. Called only for steps not already in the journal. */
50
+ runStep(args: {
51
+ stepId: string
52
+ attempt: number
53
+ input: unknown
54
+ options: StepOptions<unknown>
55
+ fn: StepFn<unknown>
56
+ stepCtx: StepContext
57
+ }): Promise<StepJournalEntry>
58
+
59
+ /**
60
+ * Called when a step completes successfully and had a `compensate`
61
+ * function declared. The executor collects these in completion order
62
+ * and runs them in reverse if the body throws or `ctx.compensate()`
63
+ * is invoked.
64
+ */
65
+ recordCompensable(args: {
66
+ stepId: string
67
+ output: unknown
68
+ compensate: (output: unknown) => Promise<void>
69
+ }): void
70
+
71
+ /** Current length of the compensable list. Used by `ctx.group` checkpoints. */
72
+ compensableLength(): number
73
+
74
+ /**
75
+ * Remove and return compensables added since `fromIndex`. Used by
76
+ * `ctx.group` to run scoped rollback without touching outer
77
+ * compensables.
78
+ */
79
+ spliceCompensable(fromIndex: number): Array<{
80
+ stepId: string
81
+ output: unknown
82
+ compensate: (output: unknown) => Promise<void>
83
+ }>
84
+
85
+ /**
86
+ * Called by `ctx.stream()` for each chunk produced by the source.
87
+ * In production this emits a `stream.chunk` WebSocket event and
88
+ * journals the chunk; in tests the harness collects chunks.
89
+ */
90
+ pushStreamChunk(args: {
91
+ streamId: string
92
+ seq: number
93
+ encoding: "text" | "json" | "base64"
94
+ chunk: unknown
95
+ final: boolean
96
+ }): void
97
+
98
+ /** Register a new waitpoint; execution will yield after this returns. */
99
+ registerWaitpoint(args: {
100
+ clientWaitpointId: string
101
+ kind: WaitpointKind
102
+ meta: Record<string, unknown>
103
+ timeoutMs?: number
104
+ }): void
105
+
106
+ /** Push a metadata mutation; flushed on waitpoint yield and run completion. */
107
+ pushMetadata(op: {
108
+ op: "set" | "increment" | "append" | "remove"
109
+ key: string
110
+ value?: unknown
111
+ target?: "self" | "parent" | "root"
112
+ }): void
113
+
114
+ /** Increment invocation counter when the body resumes after eviction. */
115
+ readonly invocationCount: number
116
+
117
+ /** Cancellation signal exposed as `ctx.signal`. */
118
+ readonly abortSignal: AbortSignal
119
+ }
120
+
121
+ export interface RuntimeEnvironment {
122
+ readonly run: RunContext
123
+ readonly workflow: { id: string; version: string }
124
+ readonly environment: EnvironmentContext
125
+ readonly project: { id: string; slug: string }
126
+ readonly organization: { id: string; slug: string }
127
+ }
128
+
129
+ export interface CtxBuildArgs {
130
+ env: RuntimeEnvironment
131
+ journal: JournalSlice
132
+ callbacks: RuntimeCallbacks
133
+ clock: ClockState
134
+ random: () => number
135
+ /** Mutated as ctx.setRetry is called; each step option inherits. */
136
+ retryOverride: { current: RetryPolicy | undefined }
137
+ /**
138
+ * Read-only service resolver exposed as `ctx.services`. When unset,
139
+ * `ctx.services.resolve(...)` throws with a clear message — workflows
140
+ * that don't need shared services keep working without configuration.
141
+ * Wired by the framework through `StepHandlerDeps.services` →
142
+ * `ExecuteWorkflowStepRequest.services` → here.
143
+ */
144
+ services?: ServiceResolver
145
+ }
146
+
147
+ /**
148
+ * Default resolver used when no container is plumbed through. Throws on
149
+ * `resolve(...)` so failures are visible at the call site instead of
150
+ * returning undefined; `has(...)` returns `false` so optional-dep
151
+ * patterns work cleanly.
152
+ */
153
+ const NO_OP_SERVICE_RESOLVER: ServiceResolver = {
154
+ resolve<T>(name: string): T {
155
+ throw new Error(
156
+ `ctx.services.resolve("${name}"): no service container is wired into this workflow runtime. ` +
157
+ `Pass { services } to the driver factory (e.g. via createApp({ workflows: { driver } }))`,
158
+ )
159
+ },
160
+ has() {
161
+ return false
162
+ },
163
+ }
164
+
165
+ export function buildCtx(args: CtxBuildArgs): WorkflowContext<unknown> {
166
+ const { env, journal, callbacks, clock, random, retryOverride } = args
167
+ const services = args.services ?? NO_OP_SERVICE_RESOLVER
168
+
169
+ // Per-ctx client-id counter. Reset on each ctx (= each invocation),
170
+ // which means ids are stable relative to body execution order.
171
+ let clientIdSeq = 0
172
+ const nextClientId = (): number => ++clientIdSeq
173
+
174
+ function checkCancel(): void {
175
+ if (callbacks.abortSignal.aborted) {
176
+ throw new RunCancelledSignal()
177
+ }
178
+ }
179
+
180
+ // ---- step ----
181
+
182
+ const step: StepApi = (async (
183
+ id: string,
184
+ optsOrFn: StepOptions<unknown> | StepFn<unknown>,
185
+ maybeFn?: StepFn<unknown>,
186
+ ) => {
187
+ checkCancel()
188
+ const opts: StepOptions<unknown> = typeof optsOrFn === "function" ? {} : optsOrFn
189
+ const fn: StepFn<unknown> =
190
+ typeof optsOrFn === "function" ? optsOrFn : (maybeFn as StepFn<unknown>)
191
+
192
+ // Journal hit? Return cached.
193
+ const cached = journal.stepResults[id]
194
+ if (cached) {
195
+ advanceClockTo(clock, cached.finishedAt)
196
+ if (cached.status === "ok") {
197
+ // Re-register compensable on replay so compensations are available
198
+ // if this invocation ends up rolling back.
199
+ if (opts.compensate) {
200
+ callbacks.recordCompensable({
201
+ stepId: id,
202
+ output: cached.output,
203
+ compensate: opts.compensate as (output: unknown) => Promise<void>,
204
+ })
205
+ }
206
+ return cached.output
207
+ }
208
+ // Journaled error rethrows on replay so catch blocks behave consistently.
209
+ const e = new Error(cached.error?.message ?? "step failed")
210
+ ;(e as { code?: string }).code = cached.error?.code
211
+ throw e
212
+ }
213
+
214
+ // Execute a new step via the callback, with the retry loop.
215
+ const mergedOpts: StepOptions<unknown> = {
216
+ ...opts,
217
+ retry: opts.retry ?? retryOverride.current,
218
+ }
219
+ const policy = normalizeRetry(mergedOpts.retry)
220
+ let attempt = 0
221
+ let lastEntry: StepJournalEntry | undefined
222
+
223
+ // Per-step timeout: compose the run-level abort signal with a
224
+ // per-call AbortSignal.timeout so cooperative step bodies (fetch,
225
+ // setTimeout wrappers, custom AbortSignal observers) stop early
226
+ // on timeout. Hard enforcement for uncooperative bodies is done
227
+ // below by racing the wrapped fn against a timeout rejection.
228
+ const timeoutMs = mergedOpts.timeout !== undefined ? toMs(mergedOpts.timeout) : undefined
229
+ const fnWithTimeout: StepFn<unknown> =
230
+ timeoutMs !== undefined
231
+ ? async (stepCtx) => {
232
+ let timer: ReturnType<typeof setTimeout> | undefined
233
+ try {
234
+ return await Promise.race([
235
+ fn(stepCtx),
236
+ new Promise<never>((_, reject) => {
237
+ timer = setTimeout(() => {
238
+ const e = new Error(`step "${id}" timed out after ${timeoutMs}ms`)
239
+ ;(e as Error & { code?: string }).code = "TIMEOUT"
240
+ reject(e)
241
+ }, timeoutMs)
242
+ }),
243
+ ])
244
+ } finally {
245
+ if (timer !== undefined) clearTimeout(timer)
246
+ }
247
+ }
248
+ : fn
249
+
250
+ while (attempt < policy.max) {
251
+ attempt += 1
252
+ const stepCtx: StepContext = {
253
+ signal:
254
+ timeoutMs !== undefined
255
+ ? AbortSignal.any([callbacks.abortSignal, AbortSignal.timeout(timeoutMs)])
256
+ : callbacks.abortSignal,
257
+ attempt,
258
+ log: (level, msg, data) => {
259
+ console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](
260
+ `[${id}]`,
261
+ msg,
262
+ data ?? "",
263
+ )
264
+ },
265
+ }
266
+ const entry = await callbacks.runStep({
267
+ stepId: id,
268
+ attempt,
269
+ input: undefined,
270
+ options: mergedOpts,
271
+ fn: fnWithTimeout,
272
+ stepCtx,
273
+ })
274
+ lastEntry = entry
275
+
276
+ if (entry.status === "ok") {
277
+ journal.stepResults[id] = entry
278
+ advanceClockTo(clock, entry.finishedAt)
279
+ if (opts.compensate) {
280
+ callbacks.recordCompensable({
281
+ stepId: id,
282
+ output: entry.output,
283
+ compensate: opts.compensate as (output: unknown) => Promise<void>,
284
+ })
285
+ }
286
+ return entry.output
287
+ }
288
+
289
+ // Failed attempt. Check if we should stop retrying.
290
+ if (entry.error?.code === "FATAL") break
291
+ if (attempt >= policy.max) break
292
+
293
+ // In production the step handler returns { retryAfter } to the DO
294
+ // which sets an alarm; here the spike/test harness continues
295
+ // immediately. retryAfter from RetryableError wins over the policy
296
+ // backoff when set.
297
+ const retryAfter = readRetryAfter(entry.error)
298
+ await maybeDelay(retryAfter ?? backoffDelay(policy, attempt))
299
+ }
300
+
301
+ // Retries exhausted (or never retried).
302
+ const finalEntry = lastEntry!
303
+ journal.stepResults[id] = finalEntry
304
+ advanceClockTo(clock, finalEntry.finishedAt)
305
+ const e = new Error(finalEntry.error?.message ?? "step failed")
306
+ ;(e as { code?: string }).code = finalEntry.error?.code
307
+ throw e
308
+ }) as StepApi
309
+
310
+ // ---- waits ----
311
+
312
+ function yieldWaitpoint(
313
+ clientWaitpointId: string,
314
+ kind: WaitpointKind,
315
+ meta: Record<string, unknown>,
316
+ timeoutMs?: number,
317
+ ): never {
318
+ callbacks.registerWaitpoint({ clientWaitpointId, kind, meta, timeoutMs })
319
+ throw new WaitpointPendingSignal(clientWaitpointId)
320
+ }
321
+
322
+ function lookupWaitpoint(id: string): WaitpointResolutionEntry | undefined {
323
+ return journal.waitpointsResolved[id]
324
+ }
325
+
326
+ const sleep = async (duration: Duration): Promise<void> => {
327
+ checkCancel()
328
+ const id = `sleep:${nextClientId()}`
329
+ const resolved = lookupWaitpoint(id)
330
+ if (resolved) {
331
+ advanceClockTo(clock, resolved.resolvedAt)
332
+ return
333
+ }
334
+ const ms = toMs(duration)
335
+ yieldWaitpoint(id, "DATETIME", { durationMs: ms }, ms)
336
+ }
337
+
338
+ function makeWaitable<T>(
339
+ kind: WaitpointKind,
340
+ clientWaitpointId: string,
341
+ iterIdPrefix: string,
342
+ meta: Record<string, unknown>,
343
+ timeoutMs?: number,
344
+ onTimeout: "null" | "throw" = "null",
345
+ ): Waitable<T> {
346
+ // --- thenable: single first-match-wins resolution ---
347
+ const resolve = (): T | null => {
348
+ const resolved = lookupWaitpoint(clientWaitpointId)
349
+ if (!resolved) {
350
+ yieldWaitpoint(clientWaitpointId, kind, meta, timeoutMs)
351
+ }
352
+ advanceClockTo(clock, resolved.resolvedAt)
353
+ if (resolved.payload === undefined && onTimeout === "throw") {
354
+ throw new Error(`waitpoint ${clientWaitpointId} timed out`)
355
+ }
356
+ return (resolved.payload ?? null) as T | null
357
+ }
358
+
359
+ // --- iterable: fresh waitpoint per .next() call ---
360
+ function makeIterator(): AsyncIterableIterator<T> {
361
+ let closed = false
362
+ return {
363
+ async next(): Promise<IteratorResult<T>> {
364
+ if (closed) return { value: undefined, done: true }
365
+ checkCancel()
366
+ const iterId = `${iterIdPrefix}:iter:${nextClientId()}`
367
+ const resolvedIter = lookupWaitpoint(iterId)
368
+ if (!resolvedIter) {
369
+ yieldWaitpoint(iterId, kind, { ...meta, iter: true }, timeoutMs)
370
+ }
371
+ advanceClockTo(clock, resolvedIter.resolvedAt)
372
+ // End-of-stream marker. Harness / orchestrator writes this to
373
+ // tell the iterator the source has no more events.
374
+ const payload = resolvedIter.payload as unknown
375
+ if (isStreamEnd(payload)) {
376
+ closed = true
377
+ return { value: undefined, done: true }
378
+ }
379
+ if (payload === undefined && onTimeout === "throw") {
380
+ throw new Error(`waitpoint ${iterId} timed out`)
381
+ }
382
+ return { value: payload as T, done: false }
383
+ },
384
+ async return(): Promise<IteratorResult<T>> {
385
+ closed = true
386
+ return { value: undefined, done: true }
387
+ },
388
+ [Symbol.asyncIterator]() {
389
+ return this
390
+ },
391
+ }
392
+ }
393
+
394
+ const thenable: Waitable<T> = {
395
+ // biome-ignore lint/suspicious/noThenProperty: Waitable intentionally implements PromiseLike for `await`.
396
+ then(onFulfilled, onRejected) {
397
+ try {
398
+ const r = resolve()
399
+ return Promise.resolve(r).then(onFulfilled, onRejected)
400
+ } catch (e) {
401
+ return Promise.reject(e).then(onFulfilled, onRejected)
402
+ }
403
+ },
404
+ [Symbol.asyncIterator]() {
405
+ return makeIterator()
406
+ },
407
+ close() {
408
+ // no-op; `return()` on the iterator handles early break.
409
+ },
410
+ }
411
+ return thenable
412
+ }
413
+
414
+ function isStreamEnd(payload: unknown): boolean {
415
+ return (
416
+ typeof payload === "object" &&
417
+ payload !== null &&
418
+ (payload as { __voyantStreamEnd?: boolean }).__voyantStreamEnd === true
419
+ )
420
+ }
421
+
422
+ const waitForEvent: WaitForEventApi = ((
423
+ eventType: string,
424
+ opts?: { timeout?: Duration; onTimeout?: "null" | "throw" },
425
+ ) => {
426
+ checkCancel()
427
+ const thenableId = `event:${eventType}:${nextClientId()}`
428
+ const iterPrefix = `event:${eventType}`
429
+ return makeWaitable(
430
+ "EVENT",
431
+ thenableId,
432
+ iterPrefix,
433
+ { eventType },
434
+ opts?.timeout ? toMs(opts.timeout) : undefined,
435
+ opts?.onTimeout,
436
+ )
437
+ }) as WaitForEventApi
438
+
439
+ const waitForSignal: WaitForSignalApi = ((
440
+ name: string,
441
+ opts?: { timeout?: Duration; onTimeout?: "null" | "throw" },
442
+ ) => {
443
+ checkCancel()
444
+ const thenableId = `signal:${name}:${nextClientId()}`
445
+ const iterPrefix = `signal:${name}`
446
+ return makeWaitable(
447
+ "SIGNAL",
448
+ thenableId,
449
+ iterPrefix,
450
+ { signalName: name },
451
+ opts?.timeout ? toMs(opts.timeout) : undefined,
452
+ opts?.onTimeout,
453
+ )
454
+ }) as WaitForSignalApi
455
+
456
+ const waitForToken: WaitForTokenApi = (async (opts?: {
457
+ tokenId?: string
458
+ timeout?: Duration
459
+ onTimeout?: "null" | "throw"
460
+ }) => {
461
+ checkCancel()
462
+ // Allocate a stable id per call. User-supplied `tokenId` is kept
463
+ // verbatim so external systems can reference the same value.
464
+ const tokenId = opts?.tokenId ?? `tok_${nextClientId()}`
465
+ const waitpointId = `token:${tokenId}`
466
+ const timeoutMs = opts?.timeout ? toMs(opts.timeout) : undefined
467
+ const onTimeout = opts?.onTimeout ?? "null"
468
+
469
+ return {
470
+ tokenId,
471
+ url: `/__voyant/tokens/${tokenId}`,
472
+ wait: async (): Promise<unknown> => {
473
+ checkCancel()
474
+ const resolved = lookupWaitpoint(waitpointId)
475
+ if (resolved) {
476
+ advanceClockTo(clock, resolved.resolvedAt)
477
+ if (resolved.payload === undefined && onTimeout === "throw") {
478
+ throw new Error(`token ${tokenId} timed out`)
479
+ }
480
+ return resolved.payload ?? null
481
+ }
482
+ yieldWaitpoint(waitpointId, "MANUAL", { tokenId }, timeoutMs)
483
+ },
484
+ } as TokenWait<unknown>
485
+ }) as WaitForTokenApi
486
+
487
+ // ---- invoke / parallel ----
488
+
489
+ const invoke: InvokeApi = (async <TIn, TOut>(
490
+ wf: WorkflowHandle<TIn, TOut>,
491
+ input: TIn,
492
+ opts?: InvokeOptions,
493
+ ): Promise<TOut> => {
494
+ checkCancel()
495
+ const id = `invoke:${wf.id}:${nextClientId()}`
496
+ const resolved = journal.waitpointsResolved[id]
497
+ if (resolved) {
498
+ advanceClockTo(clock, resolved.resolvedAt)
499
+ if (resolved.error) {
500
+ const e = new Error(resolved.error.message)
501
+ ;(e as { code?: string }).code = resolved.error.code
502
+ throw e
503
+ }
504
+ return resolved.payload as TOut
505
+ }
506
+ yieldWaitpoint(id, "RUN", {
507
+ childWorkflowId: wf.id,
508
+ childInput: input,
509
+ detach: opts?.detach ?? false,
510
+ tags: opts?.tags ?? [],
511
+ lockToVersion: opts?.lockToVersion,
512
+ idempotencyKey: opts?.idempotencyKey,
513
+ })
514
+ }) as InvokeApi
515
+
516
+ const parallel: ParallelApi = async <T, R>(
517
+ items: readonly T[],
518
+ fn: (item: T, index: number) => Promise<R>,
519
+ opts?: { concurrency?: number; settle?: boolean },
520
+ ): Promise<R[]> => {
521
+ checkCancel()
522
+ const total = items.length
523
+ if (total === 0) return []
524
+ const concurrency = Math.max(1, opts?.concurrency ?? total)
525
+ const settle = opts?.settle ?? false
526
+
527
+ const results: R[] = new Array(total)
528
+ const errors: { index: number; error: unknown }[] = []
529
+ let cursor = 0
530
+ let aborted = false
531
+
532
+ async function worker(): Promise<void> {
533
+ while (!aborted) {
534
+ const i = cursor++
535
+ if (i >= total) return
536
+ try {
537
+ results[i] = await fn(items[i]!, i)
538
+ } catch (err) {
539
+ if (settle) {
540
+ errors.push({ index: i, error: err })
541
+ } else {
542
+ aborted = true
543
+ throw err
544
+ }
545
+ }
546
+ }
547
+ }
548
+
549
+ const workerCount = Math.min(concurrency, total)
550
+ const workers = Array.from({ length: workerCount }, () => worker())
551
+
552
+ if (settle) {
553
+ await Promise.all(workers)
554
+ if (errors.length > 0) {
555
+ // Attach details so callers can inspect which items failed.
556
+ const agg = new AggregateError(
557
+ errors.map((e) => (e.error instanceof Error ? e.error : new Error(String(e.error)))),
558
+ `ctx.parallel: ${errors.length}/${total} iteration${errors.length === 1 ? "" : "s"} failed`,
559
+ )
560
+ ;(agg as { failedIndices?: number[] }).failedIndices = errors.map((e) => e.index)
561
+ throw agg
562
+ }
563
+ return results
564
+ }
565
+
566
+ await Promise.all(workers)
567
+ return results
568
+ }
569
+
570
+ // ---- streams ----
571
+
572
+ const activeStreamIds = new Set<string>()
573
+
574
+ async function consumeStream(
575
+ streamId: string,
576
+ source: AsyncIterable<unknown>,
577
+ encoding: "text" | "json" | "base64",
578
+ ): Promise<void> {
579
+ checkCancel()
580
+ if (activeStreamIds.has(streamId)) {
581
+ throw new Error(`ctx.stream: duplicate streamId "${streamId}" within the same run`)
582
+ }
583
+ activeStreamIds.add(streamId)
584
+ // Replay skip: the prior invocation already drained this source
585
+ // and the orchestrator has the chunks. Re-iterating would double
586
+ // any side effects (LLM calls, billable APIs, file reads).
587
+ if (journal.streamsCompleted[streamId]) {
588
+ return
589
+ }
590
+ let seq = 0
591
+ const iter = source[Symbol.asyncIterator]()
592
+ try {
593
+ while (true) {
594
+ checkCancel()
595
+ const { value, done } = await iter.next()
596
+ if (done) {
597
+ callbacks.pushStreamChunk({ streamId, seq: seq + 1, encoding, chunk: null, final: true })
598
+ journal.streamsCompleted[streamId] = { chunkCount: seq + 1 }
599
+ return
600
+ }
601
+ seq += 1
602
+ const chunk = normalizeChunk(value, encoding)
603
+ callbacks.pushStreamChunk({ streamId, seq, encoding, chunk, final: false })
604
+ }
605
+ } catch (err) {
606
+ // Emit a final frame so consumers know the stream closed, then
607
+ // propagate so the workflow body's error handling kicks in. No
608
+ // journal entry — a failed stream should re-iterate on replay
609
+ // (so the error surfaces deterministically).
610
+ callbacks.pushStreamChunk({ streamId, seq: seq + 1, encoding, chunk: null, final: true })
611
+ throw err
612
+ }
613
+ }
614
+
615
+ const streamImpl = async <T>(
616
+ streamId: string,
617
+ sourceFn: () => AsyncGenerator<T>,
618
+ ): Promise<void> => {
619
+ const source = sourceFn()
620
+ await consumeStream(streamId, source, inferEncoding(source))
621
+ }
622
+
623
+ const stream: StreamApi = Object.assign(streamImpl, {
624
+ text: async (id, source) => {
625
+ await consumeStream(id, source, "text")
626
+ },
627
+ json: async (id, source) => {
628
+ await consumeStream(id, source, "json")
629
+ },
630
+ bytes: async (id, source) => {
631
+ await consumeStream(id, source, "base64")
632
+ },
633
+ } satisfies Pick<StreamApi, "text" | "json" | "bytes">)
634
+
635
+ // ---- groups ----
636
+
637
+ // `ctx.group(name, fn)` creates a compensation scope. Implementation
638
+ // strategy: the outer compensable list is the single source of truth;
639
+ // each group tracks a checkpoint index. If the scope's body throws or
640
+ // explicitly calls `scope.compensate()`, we splice off compensables
641
+ // added since the checkpoint and run them LIFO, leaving outer
642
+ // compensables untouched.
643
+ //
644
+ // If the scope body completes normally, compensables stay in the
645
+ // outer list — they'll still be rolled back if the enclosing workflow
646
+ // later throws.
647
+ const runScopedCompensations = async (fromIndex: number): Promise<void> => {
648
+ const scopeEntries = callbacks.spliceCompensable(fromIndex)
649
+ for (let i = scopeEntries.length - 1; i >= 0; i--) {
650
+ const c = scopeEntries[i]!
651
+ try {
652
+ await c.compensate(c.output)
653
+ } catch {
654
+ // One bad compensation in a scope does not abort the others.
655
+ // Errors here don't surface to the executor — the outer rollback
656
+ // machinery only sees the user error that triggered the scope
657
+ // unwind.
658
+ }
659
+ }
660
+ }
661
+
662
+ const group: GroupApi = async <T>(
663
+ _name: string,
664
+ fn: (scope: GroupScope) => Promise<T>,
665
+ ): Promise<T> => {
666
+ checkCancel()
667
+ const checkpointStart = callbacks.compensableLength()
668
+ try {
669
+ return await fn({
670
+ step,
671
+ compensate: async (): Promise<never> => {
672
+ await runScopedCompensations(checkpointStart)
673
+ throw new CompensateRequestedSignal()
674
+ },
675
+ })
676
+ } catch (err) {
677
+ // Only run scoped compensations for real user errors — internal
678
+ // signals (waitpoint yield, cancellation, compensate-requested)
679
+ // are re-thrown unchanged so the executor can route them.
680
+ if (!isWaitpointPending(err) && !isRunCancelled(err) && !isCompensateRequested(err)) {
681
+ await runScopedCompensations(checkpointStart)
682
+ }
683
+ throw err
684
+ }
685
+ }
686
+
687
+ // ---- metadata ----
688
+
689
+ const metadata: MetadataApi = {
690
+ set(key, value) {
691
+ callbacks.pushMetadata({ op: "set", key, value })
692
+ },
693
+ increment(key, by = 1) {
694
+ callbacks.pushMetadata({ op: "increment", key, value: by })
695
+ },
696
+ append(key, value) {
697
+ callbacks.pushMetadata({ op: "append", key, value })
698
+ },
699
+ remove(key) {
700
+ callbacks.pushMetadata({ op: "remove", key })
701
+ },
702
+ // Mutations are pushed immediately via `callbacks.pushMetadata`
703
+ // and collected on the response envelope; no explicit flush is
704
+ // needed.
705
+ flush: async () => {},
706
+ }
707
+
708
+ // ---- retry override ----
709
+
710
+ function setRetry(policy: RetryPolicy): void {
711
+ retryOverride.current = policy
712
+ }
713
+
714
+ return {
715
+ run: env.run,
716
+ workflow: env.workflow,
717
+ environment: env.environment,
718
+ project: env.project,
719
+ organization: env.organization,
720
+ invocationCount: callbacks.invocationCount,
721
+ signal: callbacks.abortSignal,
722
+ services,
723
+ step,
724
+ sleep,
725
+ waitForEvent,
726
+ waitForSignal,
727
+ waitForToken,
728
+ invoke,
729
+ parallel,
730
+ stream,
731
+ group,
732
+ metadata,
733
+ now: () => now(clock),
734
+ random,
735
+ randomUUID: createRandomUUID(random),
736
+ setRetry,
737
+ compensate: async (): Promise<never> => {
738
+ checkCancel()
739
+ throw new CompensateRequestedSignal()
740
+ },
741
+ } satisfies WorkflowContext<unknown>
742
+ }
743
+
744
+ // ---- helpers ----
745
+
746
+ function inferEncoding(source: unknown): "text" | "json" | "base64" {
747
+ // Default to json for the generic ctx.stream(id, generator) call. The
748
+ // typed variants (text/json/bytes) override this.
749
+ void source
750
+ return "json"
751
+ }
752
+
753
+ function normalizeChunk(value: unknown, encoding: "text" | "json" | "base64"): unknown {
754
+ if (encoding === "text") {
755
+ return typeof value === "string" ? value : String(value)
756
+ }
757
+ if (encoding === "base64") {
758
+ if (value instanceof Uint8Array) {
759
+ return toBase64(value)
760
+ }
761
+ throw new Error("ctx.stream.bytes: expected Uint8Array chunks")
762
+ }
763
+ return value // json — pass through
764
+ }
765
+
766
+ function toBase64(bytes: Uint8Array): string {
767
+ // Node + modern runtimes provide Buffer or btoa. Use Buffer when
768
+ // available for efficiency; fall back to manual encode for isolates.
769
+ const g = globalThis as typeof globalThis & {
770
+ Buffer?: { from(b: Uint8Array): { toString(enc: "base64"): string } }
771
+ btoa?: (s: string) => string
772
+ }
773
+ if (g.Buffer) return g.Buffer.from(bytes).toString("base64")
774
+ if (g.btoa) {
775
+ let s = ""
776
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!)
777
+ return g.btoa(s)
778
+ }
779
+ // Manual fallback (rare).
780
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
781
+ let out = ""
782
+ let i = 0
783
+ while (i < bytes.length) {
784
+ const b1 = bytes[i++]!
785
+ const b2 = i < bytes.length ? bytes[i++]! : 0
786
+ const b3 = i < bytes.length ? bytes[i++]! : 0
787
+ out += chars[b1 >> 2]!
788
+ out += chars[((b1 & 3) << 4) | (b2 >> 4)]!
789
+ out += i - 1 > bytes.length ? "=" : chars[((b2 & 15) << 2) | (b3 >> 6)]!
790
+ out += i > bytes.length ? "=" : chars[b3 & 63]!
791
+ }
792
+ return out
793
+ }
794
+
795
+ interface ResolvedRetryPolicy {
796
+ max: number
797
+ backoff: "exponential" | "linear" | "fixed"
798
+ initial: number // ms
799
+ maxDelay: number // ms
800
+ }
801
+
802
+ function normalizeRetry(input: RetryPolicy | { max: 0 } | undefined): ResolvedRetryPolicy {
803
+ if (!input) return { max: 1, backoff: "exponential", initial: 1000, maxDelay: 60_000 }
804
+ const max = input.max ?? 3
805
+ const policy = input as RetryPolicy
806
+ return {
807
+ max: Math.max(1, max),
808
+ backoff: policy.backoff ?? "exponential",
809
+ initial: policy.initial !== undefined ? toMs(policy.initial) : 1000,
810
+ maxDelay: policy.maxDelay !== undefined ? toMs(policy.maxDelay) : 60_000,
811
+ }
812
+ }
813
+
814
+ function backoffDelay(policy: ResolvedRetryPolicy, attempt: number): number {
815
+ // `attempt` is 1-indexed; delay applies *before* the next attempt.
816
+ if (policy.backoff === "fixed") return Math.min(policy.initial, policy.maxDelay)
817
+ if (policy.backoff === "linear") return Math.min(policy.initial * attempt, policy.maxDelay)
818
+ // exponential
819
+ return Math.min(policy.initial * 2 ** (attempt - 1), policy.maxDelay)
820
+ }
821
+
822
+ function readRetryAfter(err: SerializedError | undefined): number | undefined {
823
+ if (!err) return undefined
824
+ if (err.code !== "RETRYABLE") return undefined
825
+ const raw = (err.data as { retryAfter?: unknown } | undefined)?.retryAfter
826
+ if (raw === undefined) return undefined
827
+ if (typeof raw === "number") return raw
828
+ if (raw instanceof Date) return raw.getTime() - Date.now()
829
+ if (typeof raw === "string") {
830
+ try {
831
+ return toMs(raw as Duration)
832
+ } catch {
833
+ return undefined
834
+ }
835
+ }
836
+ return undefined
837
+ }
838
+
839
+ /**
840
+ * In the real runtime, retry delay is expressed to the orchestrator as a
841
+ * `retryAfter` field on the step callback response, and the DO sets an
842
+ * alarm — no worker sits idle. In tests we skip the delay (pass it
843
+ * through `setTimeout(0)` at most) so the suite stays fast.
844
+ */
845
+ async function maybeDelay(ms: number): Promise<void> {
846
+ if (ms <= 0) return
847
+ // Cap at 10ms in-process regardless of declared delay. Test harness
848
+ // doesn't model real time; production replaces this with a DO alarm.
849
+ await new Promise((resolve) => setTimeout(resolve, Math.min(ms, 10)))
850
+ }
851
+
852
+ function toMs(d: Duration): number {
853
+ if (typeof d === "number") return d
854
+ const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(d)
855
+ if (!m) throw new Error(`invalid duration: ${String(d)}`)
856
+ const n = Number(m[1])
857
+ switch (m[2]) {
858
+ case "ms":
859
+ return n
860
+ case "s":
861
+ return n * 1000
862
+ case "m":
863
+ return n * 60_000
864
+ case "h":
865
+ return n * 3_600_000
866
+ case "d":
867
+ return n * 86_400_000
868
+ case "w":
869
+ return n * 604_800_000
870
+ default:
871
+ throw new Error(`invalid duration unit: ${m[2]}`)
872
+ }
873
+ }
874
+
875
+ // Re-exports used by the executor for metadata type checking.
876
+ export type { MetadataValue }