@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,75 @@
1
+ // Deterministic clock and RNG used by the workflow body.
2
+ //
3
+ // `ctx.now()` must return the timestamp of the event currently being
4
+ // consumed from the journal during replay (§4.5 of docs/design.md).
5
+ // `ctx.random()` / `ctx.randomUUID()` are seeded from the run id so
6
+ // replays produce the same values.
7
+
8
+ export interface ClockState {
9
+ /** Base wall-clock time recorded at run start. */
10
+ readonly baseWallClock: number
11
+ /** Offset from baseWallClock at which ctx.now() should return — set by the executor when replaying journaled events. */
12
+ offset: number
13
+ }
14
+
15
+ export function createClock(runStartedAt: number): ClockState {
16
+ return { baseWallClock: runStartedAt, offset: 0 }
17
+ }
18
+
19
+ export function now(clock: ClockState): number {
20
+ return clock.baseWallClock + clock.offset
21
+ }
22
+
23
+ /** Advance the clock to the event currently being replayed. */
24
+ export function advanceClockTo(clock: ClockState, eventAt: number): void {
25
+ clock.offset = eventAt - clock.baseWallClock
26
+ }
27
+
28
+ /**
29
+ * Mulberry32 PRNG — fast, fine for workflow-determinism use. Seeded
30
+ * from a 32-bit hash of the run id. Not cryptographic.
31
+ */
32
+ export function seededRandom(seed: number): () => number {
33
+ let state = seed >>> 0
34
+ return () => {
35
+ state = (state + 0x6d2b79f5) >>> 0
36
+ let t = state
37
+ t = Math.imul(t ^ (t >>> 15), t | 1)
38
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
39
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
40
+ }
41
+ }
42
+
43
+ export function hashSeed(runId: string): number {
44
+ // FNV-1a 32-bit
45
+ let hash = 0x811c9dc5
46
+ for (let i = 0; i < runId.length; i++) {
47
+ hash ^= runId.charCodeAt(i)
48
+ hash = Math.imul(hash, 0x01000193)
49
+ }
50
+ return hash >>> 0
51
+ }
52
+
53
+ export function createRandom(runId: string): () => number {
54
+ return seededRandom(hashSeed(runId))
55
+ }
56
+
57
+ const HEX = "0123456789abcdef"
58
+
59
+ export function createRandomUUID(rng: () => number): () => string {
60
+ // v4-shaped deterministic UUID. Not cryptographically random; matches
61
+ // the style of crypto.randomUUID but is reproducible across replays.
62
+ return () => {
63
+ const bytes = new Uint8Array(16)
64
+ for (let i = 0; i < 16; i++) bytes[i] = Math.floor(rng() * 256)
65
+ bytes[6] = (bytes[6]! & 0x0f) | 0x40 // version 4 (high nibble of byte 6)
66
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80 // variant RFC 4122 (high bits of byte 8)
67
+ let s = ""
68
+ for (let i = 0; i < 16; i++) {
69
+ const b = bytes[i]!
70
+ s += HEX[b >>> 4]! + HEX[b & 0x0f]!
71
+ }
72
+ // s is 32 hex chars; slice into 8-4-4-4-12 groups.
73
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20, 32)}`
74
+ }
75
+ }
@@ -0,0 +1,58 @@
1
+ // Internal runtime signal errors. Distinct from user-facing `@voyant-travel/workflows/errors`:
2
+ // these are thrown inside the executor to unwind the workflow body on
3
+ // waitpoint yield or run cancellation. They must not be caught by user
4
+ // code (the executor re-throws if it observes one being swallowed).
5
+
6
+ const WAITPOINT_PENDING = Symbol.for("voyant.workflows.waitpointPending")
7
+ const RUN_CANCELLED = Symbol.for("voyant.workflows.runCancelled")
8
+ const COMPENSATE_REQUESTED = Symbol.for("voyant.workflows.compensateRequested")
9
+
10
+ export class WaitpointPendingSignal extends Error {
11
+ readonly [WAITPOINT_PENDING] = true as const
12
+ readonly waitpointId: string
13
+ constructor(waitpointId: string) {
14
+ super(`waitpoint pending: ${waitpointId}`)
15
+ this.name = "WaitpointPendingSignal"
16
+ this.waitpointId = waitpointId
17
+ }
18
+ }
19
+
20
+ export class RunCancelledSignal extends Error {
21
+ readonly [RUN_CANCELLED] = true as const
22
+ constructor(reason?: string) {
23
+ super(reason ?? "run cancelled")
24
+ this.name = "RunCancelledSignal"
25
+ }
26
+ }
27
+
28
+ export function isWaitpointPending(err: unknown): err is WaitpointPendingSignal {
29
+ return (
30
+ typeof err === "object" &&
31
+ err !== null &&
32
+ (err as { [WAITPOINT_PENDING]?: true })[WAITPOINT_PENDING] === true
33
+ )
34
+ }
35
+
36
+ export function isRunCancelled(err: unknown): err is RunCancelledSignal {
37
+ return (
38
+ typeof err === "object" &&
39
+ err !== null &&
40
+ (err as { [RUN_CANCELLED]?: true })[RUN_CANCELLED] === true
41
+ )
42
+ }
43
+
44
+ export class CompensateRequestedSignal extends Error {
45
+ readonly [COMPENSATE_REQUESTED] = true as const
46
+ constructor() {
47
+ super("compensate requested")
48
+ this.name = "CompensateRequestedSignal"
49
+ }
50
+ }
51
+
52
+ export function isCompensateRequested(err: unknown): err is CompensateRequestedSignal {
53
+ return (
54
+ typeof err === "object" &&
55
+ err !== null &&
56
+ (err as { [COMPENSATE_REQUESTED]?: true })[COMPENSATE_REQUESTED] === true
57
+ )
58
+ }
@@ -0,0 +1,442 @@
1
+ // The workflow-step executor. Takes a registered workflow + journal and
2
+ // runs the body exactly once, yielding a response envelope.
3
+ //
4
+ // Corresponds to the tenant-side handler of POST /__voyant/workflow-step
5
+ // described in docs/runtime-protocol.md §2.1.
6
+
7
+ import type { SerializedError } from "../protocol/index.js"
8
+ import { durationToMs, type RateLimiter } from "../rate-limit/index.js"
9
+ import type { RunStatus, RunTrigger, WaitpointKind } from "../types.js"
10
+ import type { StepOptions, WorkflowDefinition } from "../workflow.js"
11
+ import { buildCtx, type RuntimeCallbacks, type RuntimeEnvironment } from "./ctx.js"
12
+ import { createClock, createRandom } from "./determinism.js"
13
+ import {
14
+ isCompensateRequested,
15
+ isRunCancelled,
16
+ isWaitpointPending,
17
+ RunCancelledSignal,
18
+ WaitpointPendingSignal,
19
+ } from "./errors.js"
20
+ import type { JournalSlice, StepJournalEntry } from "./journal.js"
21
+
22
+ export type StepRunner = (
23
+ /**
24
+ * Executes a step body and returns the journal entry to record.
25
+ *
26
+ * In-process runners (the default edge runner, local-dev passthrough)
27
+ * call `fn(stepCtx)` directly. Dispatching runners (e.g. the CF
28
+ * Container runner) ignore `fn` — they can't serialize a closure —
29
+ * and use the run/workflow identity + step options to address the
30
+ * remote container and POST the required context.
31
+ */
32
+ args: {
33
+ stepId: string
34
+ attempt: number
35
+ input: unknown
36
+ fn: (stepCtx: import("../workflow.js").StepContext) => Promise<unknown>
37
+ stepCtx: import("../workflow.js").StepContext
38
+ /** Identity of the run — used by dispatching runners. */
39
+ runId: string
40
+ workflowId: string
41
+ workflowVersion: string
42
+ /** Project / organization id from the runtime environment — used by
43
+ * dispatching runners to resolve per-tenant bundle storage keys. */
44
+ projectId: string
45
+ organizationId: string
46
+ /** Merged step options (runtime, machine, timeout, …). */
47
+ options: import("../workflow.js").StepOptions<unknown>
48
+ /**
49
+ * Current journal slice at dispatch time — steps already completed,
50
+ * waitpoints already resolved, etc. Dispatching runners pass this
51
+ * to the remote executor so body replay there short-circuits on
52
+ * cached steps, and the container can stop cleanly after the
53
+ * target step runs.
54
+ */
55
+ journal: JournalSlice
56
+ },
57
+ ) => Promise<StepJournalEntry>
58
+
59
+ export interface WaitpointRegistration {
60
+ clientWaitpointId: string
61
+ kind: WaitpointKind
62
+ meta: Record<string, unknown>
63
+ timeoutMs?: number
64
+ }
65
+
66
+ export interface MetadataMutation {
67
+ op: "set" | "increment" | "append" | "remove"
68
+ key: string
69
+ value?: unknown
70
+ target?: "self" | "parent" | "root"
71
+ }
72
+
73
+ export interface CompensationReport {
74
+ stepId: string
75
+ status: "ok" | "err"
76
+ error?: SerializedError
77
+ durationMs: number
78
+ }
79
+
80
+ export interface StreamChunk {
81
+ streamId: string
82
+ seq: number
83
+ encoding: "text" | "json" | "base64"
84
+ chunk: unknown
85
+ final: boolean
86
+ at: number
87
+ }
88
+
89
+ export interface ExecuteWorkflowStepRequest {
90
+ runId: string
91
+ workflowId: string
92
+ workflowVersion: string
93
+ input: unknown
94
+ journal: JournalSlice
95
+ invocationCount: number
96
+ environment: RuntimeEnvironment
97
+ triggeredBy: RunTrigger
98
+ runStartedAt: number
99
+ tags: string[]
100
+ abortSignal?: AbortSignal
101
+ /**
102
+ * Default step executor (the "edge" runtime) — runs step bodies
103
+ * in-process. Used for any step whose `options.runtime` is unset or
104
+ * explicitly `"edge"`.
105
+ */
106
+ stepRunner: StepRunner
107
+ /**
108
+ * Optional runner for steps declared with `options.runtime === "node"`.
109
+ * Typical impl dispatches to a Cloudflare Container sized for the
110
+ * step (or, in local dev, an in-process passthrough).
111
+ *
112
+ * If a step requests `"node"` and this is unset, the step fails with
113
+ * `NODE_RUNTIME_UNAVAILABLE` — declaring a runtime and then silently
114
+ * falling back to edge would hide deployment bugs.
115
+ */
116
+ nodeStepRunner?: StepRunner
117
+ /**
118
+ * Optional rate limiter. When a step declares `options.rateLimit`,
119
+ * the executor calls `rateLimiter.acquire(...)` before invoking the
120
+ * step runner. Without a limiter, a step that declares `rateLimit`
121
+ * fails with `RATE_LIMITER_MISSING` — declaring a limit and not
122
+ * enforcing it would be silently dangerous.
123
+ */
124
+ rateLimiter?: RateLimiter
125
+ /** `() => number` used for compensation durations. Defaults to Date.now. */
126
+ now?: () => number
127
+ /**
128
+ * Optional per-chunk callback fired synchronously from
129
+ * `ctx.stream.*` as each chunk is produced. Enables live streaming
130
+ * (dashboards, queues) in-process before the invocation completes.
131
+ * Chunks are still accumulated in the response's `streamChunks`
132
+ * array so the at-end delivery keeps working.
133
+ */
134
+ onStreamChunk?: (chunk: StreamChunk) => void
135
+ /**
136
+ * Optional read-only service resolver, surfaced to the workflow body as
137
+ * `ctx.services`. Wired by the framework through
138
+ * `StepHandlerDeps.services` → here. When unset, `ctx.services.resolve(...)`
139
+ * throws with a clear message — see `runtime/ctx.ts`.
140
+ */
141
+ services?: import("../driver.js").ServiceResolver
142
+ }
143
+
144
+ export type ExecuteWorkflowStepResponse =
145
+ | {
146
+ status: "completed"
147
+ output: unknown
148
+ metadataUpdates: MetadataMutation[]
149
+ journal: JournalSlice
150
+ streamChunks: StreamChunk[]
151
+ }
152
+ | {
153
+ status: "failed"
154
+ error: SerializedError
155
+ metadataUpdates: MetadataMutation[]
156
+ journal: JournalSlice
157
+ streamChunks: StreamChunk[]
158
+ }
159
+ | {
160
+ status: "cancelled"
161
+ metadataUpdates: MetadataMutation[]
162
+ journal: JournalSlice
163
+ compensations: CompensationReport[]
164
+ streamChunks: StreamChunk[]
165
+ }
166
+ | {
167
+ status: "waiting"
168
+ waitpoints: WaitpointRegistration[]
169
+ metadataUpdates: MetadataMutation[]
170
+ journal: JournalSlice
171
+ streamChunks: StreamChunk[]
172
+ }
173
+ | {
174
+ status: "compensated"
175
+ /** Only set when compensation was triggered by an uncaught body error. */
176
+ error?: SerializedError
177
+ compensations: CompensationReport[]
178
+ metadataUpdates: MetadataMutation[]
179
+ journal: JournalSlice
180
+ streamChunks: StreamChunk[]
181
+ }
182
+ | {
183
+ status: "compensation_failed"
184
+ error?: SerializedError
185
+ compensations: CompensationReport[]
186
+ metadataUpdates: MetadataMutation[]
187
+ journal: JournalSlice
188
+ streamChunks: StreamChunk[]
189
+ }
190
+
191
+ interface Compensable {
192
+ stepId: string
193
+ output: unknown
194
+ compensate: (output: unknown) => Promise<void>
195
+ }
196
+
197
+ export async function executeWorkflowStep(
198
+ def: WorkflowDefinition,
199
+ req: ExecuteWorkflowStepRequest,
200
+ ): Promise<ExecuteWorkflowStepResponse> {
201
+ const abortSignal = req.abortSignal ?? new AbortController().signal
202
+ const now = req.now ?? (() => Date.now())
203
+ const clock = createClock(req.runStartedAt)
204
+ const random = createRandom(req.runId)
205
+ const waitpoints: WaitpointRegistration[] = []
206
+ const metadataUpdates: MetadataMutation[] = []
207
+ const compensable: Compensable[] = []
208
+ const streamChunks: StreamChunk[] = []
209
+ const retryOverride: { current: import("../types.js").RetryPolicy | undefined } = {
210
+ current: def.config.retry,
211
+ }
212
+
213
+ const callbacks: RuntimeCallbacks = {
214
+ invocationCount: req.invocationCount,
215
+ abortSignal,
216
+ async runStep(args) {
217
+ if (args.options.rateLimit) {
218
+ await acquireRateLimit({
219
+ spec: args.options.rateLimit,
220
+ stepId: args.stepId,
221
+ input: req.input,
222
+ runId: req.runId,
223
+ projectId: req.environment.project.id,
224
+ limiter: req.rateLimiter,
225
+ signal: abortSignal,
226
+ })
227
+ }
228
+ const runtime = args.options.runtime ?? "edge"
229
+ const runner = runtime === "node" ? req.nodeStepRunner : req.stepRunner
230
+ if (!runner) {
231
+ const e = new Error(
232
+ `step "${args.stepId}" declared runtime="node" but the handler has no nodeStepRunner wired; ` +
233
+ `pass { nodeStepRunner } to createStepHandler() or remove options.runtime`,
234
+ )
235
+ ;(e as Error & { code?: string }).code = "NODE_RUNTIME_UNAVAILABLE"
236
+ throw e
237
+ }
238
+ const entry = await runner({
239
+ stepId: args.stepId,
240
+ attempt: args.attempt,
241
+ input: args.input,
242
+ fn: args.fn,
243
+ stepCtx: args.stepCtx,
244
+ runId: req.runId,
245
+ workflowId: req.workflowId,
246
+ workflowVersion: req.workflowVersion,
247
+ projectId: req.environment.project.id,
248
+ organizationId: req.environment.organization.id,
249
+ options: args.options,
250
+ journal: req.journal,
251
+ })
252
+ // Stamp the runtime on the journal entry so downstream consumers
253
+ // (journal persistence, dashboard events) can report where each
254
+ // step actually ran.
255
+ entry.runtime = runtime
256
+ return entry
257
+ },
258
+ registerWaitpoint(args) {
259
+ waitpoints.push(args)
260
+ },
261
+ pushMetadata(op) {
262
+ metadataUpdates.push(op)
263
+ },
264
+ recordCompensable(args) {
265
+ compensable.push(args)
266
+ },
267
+ compensableLength(): number {
268
+ return compensable.length
269
+ },
270
+ spliceCompensable(fromIndex: number): Compensable[] {
271
+ return compensable.splice(fromIndex)
272
+ },
273
+ pushStreamChunk(args) {
274
+ const chunk = { ...args, at: now() }
275
+ streamChunks.push(chunk)
276
+ // Fire the live hook synchronously. Errors are swallowed — a
277
+ // misbehaving subscriber must not break the workflow body.
278
+ if (req.onStreamChunk) {
279
+ try {
280
+ req.onStreamChunk(chunk)
281
+ } catch {
282
+ /* ignore */
283
+ }
284
+ }
285
+ },
286
+ }
287
+
288
+ const ctx = buildCtx({
289
+ env: req.environment,
290
+ journal: req.journal,
291
+ callbacks,
292
+ clock,
293
+ random,
294
+ retryOverride,
295
+ services: req.services,
296
+ })
297
+
298
+ try {
299
+ const output = await def.config.run(req.input, ctx)
300
+ // If the body registered a waitpoint but a user try/catch swallowed
301
+ // the internal yield signal, honour the waitpoint — the body can't
302
+ // both register a waitpoint and complete in the same invocation.
303
+ if (waitpoints.length > 0) {
304
+ return { status: "waiting", waitpoints, metadataUpdates, journal: req.journal, streamChunks }
305
+ }
306
+ return { status: "completed", output, metadataUpdates, journal: req.journal, streamChunks }
307
+ } catch (err) {
308
+ if (isWaitpointPending(err)) {
309
+ return { status: "waiting", waitpoints, metadataUpdates, journal: req.journal, streamChunks }
310
+ }
311
+ // Same guard for the error path: a swallowed signal shouldn't let
312
+ // the body claim failure while waitpoints are pending.
313
+ if (waitpoints.length > 0 && !isRunCancelled(err) && !isCompensateRequested(err)) {
314
+ return { status: "waiting", waitpoints, metadataUpdates, journal: req.journal, streamChunks }
315
+ }
316
+ if (isRunCancelled(err)) {
317
+ // Default: compensate on cancel. Terminal status stays `cancelled`
318
+ // to reflect why the run ended.
319
+ const compensations = await runCompensations(compensable, now)
320
+ return {
321
+ status: "cancelled",
322
+ metadataUpdates,
323
+ journal: req.journal,
324
+ compensations,
325
+ streamChunks,
326
+ }
327
+ }
328
+ if (isCompensateRequested(err)) {
329
+ const compensations = await runCompensations(compensable, now)
330
+ const anyErr = compensations.some((c) => c.status === "err")
331
+ return {
332
+ status: anyErr ? "compensation_failed" : "compensated",
333
+ compensations,
334
+ metadataUpdates,
335
+ journal: req.journal,
336
+ streamChunks,
337
+ }
338
+ }
339
+
340
+ // Uncaught user error. Run compensations LIFO; adjust terminal status
341
+ // based on whether compensations were registered and all succeeded.
342
+ const compensations = await runCompensations(compensable, now)
343
+ const serialized = serializeError(err)
344
+
345
+ if (compensations.length === 0) {
346
+ return {
347
+ status: "failed",
348
+ error: serialized,
349
+ metadataUpdates,
350
+ journal: req.journal,
351
+ streamChunks,
352
+ }
353
+ }
354
+
355
+ const anyErr = compensations.some((c) => c.status === "err")
356
+ return {
357
+ status: anyErr ? "compensation_failed" : "compensated",
358
+ error: serialized,
359
+ compensations,
360
+ metadataUpdates,
361
+ journal: req.journal,
362
+ streamChunks,
363
+ }
364
+ }
365
+ }
366
+
367
+ async function runCompensations(
368
+ compensable: readonly Compensable[],
369
+ now: () => number,
370
+ ): Promise<CompensationReport[]> {
371
+ const reports: CompensationReport[] = []
372
+ // Reverse order: last-completed compensates first.
373
+ for (let i = compensable.length - 1; i >= 0; i--) {
374
+ const c = compensable[i]!
375
+ const startedAt = now()
376
+ try {
377
+ await c.compensate(c.output)
378
+ reports.push({ stepId: c.stepId, status: "ok", durationMs: now() - startedAt })
379
+ } catch (err) {
380
+ reports.push({
381
+ stepId: c.stepId,
382
+ status: "err",
383
+ error: serializeError(err),
384
+ durationMs: now() - startedAt,
385
+ })
386
+ // Continue with remaining compensations; don't abort on one failure.
387
+ }
388
+ }
389
+ return reports
390
+ }
391
+
392
+ function serializeError(err: unknown): SerializedError {
393
+ if (err instanceof Error) {
394
+ const code = (err as { code?: string }).code
395
+ const cause = (err as { cause?: unknown }).cause
396
+ return {
397
+ category: "USER_ERROR",
398
+ code: typeof code === "string" ? code : "UNKNOWN",
399
+ message: err.message,
400
+ name: err.name,
401
+ stack: err.stack,
402
+ cause: cause !== undefined ? serializeError(cause) : undefined,
403
+ }
404
+ }
405
+ return { category: "USER_ERROR", code: "UNKNOWN", message: String(err) }
406
+ }
407
+
408
+ async function acquireRateLimit(args: {
409
+ spec: NonNullable<StepOptions<unknown>["rateLimit"]>
410
+ stepId: string
411
+ input: unknown
412
+ runId: string
413
+ projectId: string
414
+ limiter: RateLimiter | undefined
415
+ signal: AbortSignal
416
+ }): Promise<void> {
417
+ const ctx = { run: { id: args.runId }, project: { id: args.projectId } }
418
+ const key = typeof args.spec.key === "function" ? args.spec.key(args.input, ctx) : args.spec.key
419
+ const limit =
420
+ typeof args.spec.limit === "function" ? args.spec.limit(args.input) : args.spec.limit
421
+ const units =
422
+ args.spec.units === undefined
423
+ ? 1
424
+ : typeof args.spec.units === "function"
425
+ ? args.spec.units(args.input)
426
+ : args.spec.units
427
+ const windowMs = durationToMs(args.spec.window)
428
+ const onLimit = args.spec.onLimit ?? "queue"
429
+
430
+ if (!args.limiter) {
431
+ const e = new Error(
432
+ `step "${args.stepId}" declared options.rateLimit but the handler has no rateLimiter wired; ` +
433
+ `pass { rateLimiter } to createStepHandler()`,
434
+ )
435
+ ;(e as Error & { code?: string }).code = "RATE_LIMITER_MISSING"
436
+ throw e
437
+ }
438
+ await args.limiter.acquire({ key, limit, units, windowMs, onLimit, signal: args.signal })
439
+ }
440
+
441
+ export type { RunStatus }
442
+ export { RunCancelledSignal, WaitpointPendingSignal }
@@ -0,0 +1,80 @@
1
+ // Tenant-side view of the journal sent by the orchestrator on every
2
+ // `/__voyant/workflow-step` invocation.
3
+ //
4
+ // Wire shape defined in docs/runtime-protocol.md §2.1 (JournalSlice).
5
+
6
+ import type { SerializedError, WorkflowPayloadReference } from "../protocol/index.js"
7
+ import type { WaitpointKind } from "../types.js"
8
+
9
+ export interface StepJournalEntry {
10
+ attempt: number
11
+ status: "ok" | "err"
12
+ output?: unknown
13
+ error?: SerializedError
14
+ startedAt: number
15
+ finishedAt: number
16
+ /**
17
+ * Which runtime actually executed the step. Set by the executor when
18
+ * routing between `stepRunner` (edge) and `nodeStepRunner`.
19
+ * Informational only — doesn't affect replay.
20
+ */
21
+ runtime?: "edge" | "node"
22
+ }
23
+
24
+ export interface WaitpointResolutionEntry {
25
+ kind: WaitpointKind
26
+ resolvedAt: number
27
+ matchedEventId?: string
28
+ payload?: unknown
29
+ payloadRef?: WorkflowPayloadReference
30
+ source: "live" | "inbox" | "replay"
31
+ /** Populated for RUN waitpoints when the child run ended in a failure state. */
32
+ error?: SerializedError
33
+ }
34
+
35
+ export interface CompensationJournalEntry {
36
+ status: "ok" | "err"
37
+ finishedAt: number
38
+ error?: SerializedError
39
+ }
40
+
41
+ export interface JournalSlice {
42
+ stepResults: Record<string, StepJournalEntry>
43
+ waitpointsResolved: Record<string, WaitpointResolutionEntry>
44
+ compensationsRun: Record<string, CompensationJournalEntry>
45
+ metadataState: Record<string, unknown>
46
+ /**
47
+ * Stream ids whose generator has already been consumed in a prior
48
+ * invocation. The orchestrator already has the chunks on the run
49
+ * record; replaying the body must skip re-iterating the source
50
+ * (generators often have side effects — LLM calls, file reads,
51
+ * billable API usage).
52
+ */
53
+ streamsCompleted: Record<string, { chunkCount: number }>
54
+ }
55
+
56
+ export function emptyJournal(): JournalSlice {
57
+ return {
58
+ stepResults: {},
59
+ waitpointsResolved: {},
60
+ compensationsRun: {},
61
+ metadataState: {},
62
+ streamsCompleted: {},
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Clone the journal into a slice that the body sees. Each step /
68
+ * waitpoint the body replays is *consumed* from the slice so we can
69
+ * detect leftover journal entries that the code no longer produces
70
+ * (which is a versioning hazard — see §6.7 of design.md).
71
+ */
72
+ export function forkJournal(j: JournalSlice): JournalSlice {
73
+ return {
74
+ stepResults: { ...j.stepResults },
75
+ waitpointsResolved: { ...j.waitpointsResolved },
76
+ compensationsRun: { ...j.compensationsRun },
77
+ metadataState: { ...j.metadataState },
78
+ streamsCompleted: { ...j.streamsCompleted },
79
+ }
80
+ }