@mokronos/wfkit 0.1.0

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/dist/authoring.d.ts +7 -0
  4. package/dist/authoring.d.ts.map +1 -0
  5. package/dist/authoring.js +8 -0
  6. package/dist/authoring.js.map +1 -0
  7. package/dist/cli/integrations.d.ts +3 -0
  8. package/dist/cli/integrations.d.ts.map +1 -0
  9. package/dist/cli/integrations.js +40 -0
  10. package/dist/cli/integrations.js.map +1 -0
  11. package/dist/cli/main.d.ts +3 -0
  12. package/dist/cli/main.d.ts.map +1 -0
  13. package/dist/cli/main.js +591 -0
  14. package/dist/cli/main.js.map +1 -0
  15. package/dist/core.d.ts +198 -0
  16. package/dist/core.d.ts.map +1 -0
  17. package/dist/core.js +1061 -0
  18. package/dist/core.js.map +1 -0
  19. package/dist/errors.d.ts +3 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +13 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/events.d.ts +154 -0
  24. package/dist/events.d.ts.map +1 -0
  25. package/dist/events.js +24 -0
  26. package/dist/events.js.map +1 -0
  27. package/dist/index.d.ts +15 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +11 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/replay.d.ts +3 -0
  32. package/dist/replay.d.ts.map +1 -0
  33. package/dist/replay.js +28 -0
  34. package/dist/replay.js.map +1 -0
  35. package/dist/runtime.d.ts +70 -0
  36. package/dist/runtime.d.ts.map +1 -0
  37. package/dist/runtime.js +183 -0
  38. package/dist/runtime.js.map +1 -0
  39. package/dist/schema.d.ts +18 -0
  40. package/dist/schema.d.ts.map +1 -0
  41. package/dist/schema.js +19 -0
  42. package/dist/schema.js.map +1 -0
  43. package/dist/schemas.d.ts +1198 -0
  44. package/dist/schemas.d.ts.map +1 -0
  45. package/dist/schemas.js +383 -0
  46. package/dist/schemas.js.map +1 -0
  47. package/dist/sdk/artifact.d.ts +41 -0
  48. package/dist/sdk/artifact.d.ts.map +1 -0
  49. package/dist/sdk/artifact.js +94 -0
  50. package/dist/sdk/artifact.js.map +1 -0
  51. package/dist/sdk/graph.d.ts +15 -0
  52. package/dist/sdk/graph.d.ts.map +1 -0
  53. package/dist/sdk/graph.js +388 -0
  54. package/dist/sdk/graph.js.map +1 -0
  55. package/dist/sdk/index.d.ts +14 -0
  56. package/dist/sdk/index.d.ts.map +1 -0
  57. package/dist/sdk/index.js +8 -0
  58. package/dist/sdk/index.js.map +1 -0
  59. package/dist/sdk/integrations.d.ts +18 -0
  60. package/dist/sdk/integrations.d.ts.map +1 -0
  61. package/dist/sdk/integrations.js +20 -0
  62. package/dist/sdk/integrations.js.map +1 -0
  63. package/dist/sdk/json.d.ts +4 -0
  64. package/dist/sdk/json.d.ts.map +1 -0
  65. package/dist/sdk/json.js +31 -0
  66. package/dist/sdk/json.js.map +1 -0
  67. package/dist/sdk/loader.d.ts +10 -0
  68. package/dist/sdk/loader.d.ts.map +1 -0
  69. package/dist/sdk/loader.js +75 -0
  70. package/dist/sdk/loader.js.map +1 -0
  71. package/dist/sdk/sdk.d.ts +102 -0
  72. package/dist/sdk/sdk.d.ts.map +1 -0
  73. package/dist/sdk/sdk.js +669 -0
  74. package/dist/sdk/sdk.js.map +1 -0
  75. package/dist/sdk/sqlite.d.ts +8 -0
  76. package/dist/sdk/sqlite.d.ts.map +1 -0
  77. package/dist/sdk/sqlite.js +288 -0
  78. package/dist/sdk/sqlite.js.map +1 -0
  79. package/dist/signal.d.ts +17 -0
  80. package/dist/signal.d.ts.map +1 -0
  81. package/dist/signal.js +78 -0
  82. package/dist/signal.js.map +1 -0
  83. package/dist/testing/index.d.ts +35 -0
  84. package/dist/testing/index.d.ts.map +1 -0
  85. package/dist/testing/index.js +244 -0
  86. package/dist/testing/index.js.map +1 -0
  87. package/package.json +67 -0
  88. package/src/authoring.ts +32 -0
  89. package/src/cli/integrations.ts +75 -0
  90. package/src/cli/main.ts +793 -0
  91. package/src/core.ts +1515 -0
  92. package/src/errors.ts +19 -0
  93. package/src/events.ts +37 -0
  94. package/src/index.ts +109 -0
  95. package/src/replay.ts +29 -0
  96. package/src/runtime.ts +314 -0
  97. package/src/schema.ts +34 -0
  98. package/src/schemas.ts +473 -0
  99. package/src/sdk/artifact.ts +176 -0
  100. package/src/sdk/graph.ts +486 -0
  101. package/src/sdk/index.ts +64 -0
  102. package/src/sdk/integrations.ts +49 -0
  103. package/src/sdk/json.ts +40 -0
  104. package/src/sdk/loader.ts +99 -0
  105. package/src/sdk/sdk.ts +911 -0
  106. package/src/sdk/sqlite.ts +405 -0
  107. package/src/signal.ts +116 -0
  108. package/src/testing/index.ts +341 -0
package/src/core.ts ADDED
@@ -0,0 +1,1515 @@
1
+ import { createHash } from "node:crypto"
2
+ import { Activity, DurableClock, DurableDeferred, Workflow, WorkflowEngine } from "effect/unstable/workflow"
3
+ import { Cause, Context, Effect, Exit, Option, Schedule, Schema } from "effect"
4
+ import type * as Duration from "effect/Duration"
5
+ import { currentWorkflowEventSink, emitWorkflowEvent } from "./events.ts"
6
+ import type { WorkflowEvent } from "./schemas.ts"
7
+ import { ExecutionId, jsonSchemaOf } from "./schemas.ts"
8
+ import { awaitSignal, registerSignalSchema, SignalDeliveryError, takeBufferedSignal } from "./signal.ts"
9
+
10
+ type AnySchema<A = any> = Schema.Codec<A, any, any, any>
11
+
12
+ const TerminalFailureTypeId: unique symbol = Symbol.for("wf/TerminalFailure")
13
+
14
+ export interface TerminalFailure<E> {
15
+ readonly [TerminalFailureTypeId]: typeof TerminalFailureTypeId
16
+ readonly error: E
17
+ }
18
+
19
+ export interface StepContext<E> {
20
+ fail(error: E): TerminalFailure<E>
21
+ readonly attempt: number
22
+ readonly executionId: string
23
+ }
24
+
25
+ export interface StepRetryPolicy {
26
+ readonly attempts: number
27
+ readonly backoff: "exponential" | "none"
28
+ }
29
+
30
+ export interface StepConcurrency<I> {
31
+ readonly key?: (input: I) => string
32
+ readonly limit: number
33
+ }
34
+
35
+ export interface Step<I, O, E = never> {
36
+ readonly name: string
37
+ readonly input: AnySchema<I>
38
+ readonly output: AnySchema<O>
39
+ readonly errors: AnySchema<E>
40
+ readonly execute: (input: I, step: StepContext<E>) => Promise<O | TerminalFailure<E>>
41
+ readonly compensate?: (result: O, input: I, reason: unknown) => unknown | Promise<unknown>
42
+ readonly retry?: StepRetryPolicy
43
+ readonly concurrency?: StepConcurrency<I>
44
+ }
45
+
46
+ export interface DefineStepConfig<I, O, E> {
47
+ readonly name: string
48
+ readonly input: AnySchema<I>
49
+ readonly output: AnySchema<O>
50
+ readonly errors?: AnySchema<E>
51
+ readonly execute: (input: I, step: StepContext<E>) => Promise<O | TerminalFailure<E>>
52
+ readonly compensate?: (result: O, input: I, reason: unknown) => unknown | Promise<unknown>
53
+ readonly retry?: StepRetryPolicy
54
+ readonly concurrency?: StepConcurrency<I>
55
+ }
56
+
57
+ export const defineStep = <
58
+ const Input extends AnySchema,
59
+ const Output extends AnySchema,
60
+ const Errors extends AnySchema = typeof Schema.Never
61
+ >(config: {
62
+ readonly name: string
63
+ readonly input: Input
64
+ readonly output: Output
65
+ readonly errors?: Errors
66
+ readonly execute: (
67
+ input: Schema.Schema.Type<Input>,
68
+ step: StepContext<Schema.Schema.Type<Errors>>
69
+ ) => Promise<Schema.Schema.Type<Output> | TerminalFailure<Schema.Schema.Type<Errors>>>
70
+ readonly compensate?: (
71
+ result: Schema.Schema.Type<Output>,
72
+ input: Schema.Schema.Type<Input>,
73
+ reason: unknown
74
+ ) => unknown | Promise<unknown>
75
+ readonly retry?: StepRetryPolicy
76
+ readonly concurrency?: StepConcurrency<Schema.Schema.Type<Input>>
77
+ }): Step<Schema.Schema.Type<Input>, Schema.Schema.Type<Output>, Schema.Schema.Type<Errors>> => ({
78
+ ...config,
79
+ errors: config.errors ?? Schema.Never
80
+ })
81
+
82
+ const SecretRefPrefix = "secret:"
83
+
84
+ export const SecretRef = Schema.declare<string>(
85
+ (value): value is string => typeof value === "string" && value.startsWith(SecretRefPrefix)
86
+ ).pipe(Schema.brand("SecretRef"))
87
+
88
+ export type SecretRef = typeof SecretRef.Type
89
+
90
+ export interface SecretResolver {
91
+ resolve(name: string): string | Promise<string>
92
+ }
93
+
94
+ export const secret = (name: string): SecretRef => SecretRef.make(`${SecretRefPrefix}${name}`)
95
+
96
+ const defaultSecretEnvName = (name: string): string =>
97
+ name.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toUpperCase()
98
+
99
+ export const envSecretResolver = (options: {
100
+ readonly mapping?: Record<string, string>
101
+ readonly fallback?: string
102
+ } = {}): SecretResolver => ({
103
+ resolve: (name) => {
104
+ const envName = options.mapping?.[name] ?? defaultSecretEnvName(name)
105
+ const value = process.env[envName]
106
+ if (value !== undefined) {
107
+ return value
108
+ }
109
+ if (options.fallback !== undefined) {
110
+ return options.fallback
111
+ }
112
+ throw new Error(`Secret "${name}" not found: set env var ${envName}`)
113
+ }
114
+ })
115
+
116
+ export const isSecretRef = (value: unknown): value is SecretRef =>
117
+ Schema.is(SecretRef)(value)
118
+
119
+ const secretName = (value: SecretRef): string => value.slice(SecretRefPrefix.length)
120
+
121
+ export const currentSecretResolver = Context.Reference<SecretResolver | undefined>(
122
+ "wf/currentSecretResolver",
123
+ { defaultValue: () => undefined }
124
+ )
125
+
126
+ // Durable executions run on engine entity fibers that don't inherit the
127
+ // caller's context reference, so the runtime also registers resolvers per execution.
128
+ const executionSecretResolvers = new Map<string, SecretResolver>()
129
+
130
+ export const setExecutionSecretResolver = (executionId: string, resolver: SecretResolver): void => {
131
+ executionSecretResolvers.set(executionId, resolver)
132
+ }
133
+
134
+ export const removeExecutionSecretResolver = (executionId: string): void => {
135
+ executionSecretResolvers.delete(executionId)
136
+ }
137
+
138
+ export const getExecutionSecretResolver = (executionId: string): SecretResolver | undefined =>
139
+ executionSecretResolvers.get(executionId)
140
+
141
+ export type WorkflowValue<A, E = never> = Effect.Effect<A, E, any>
142
+
143
+ export type OrchestrationKind = "step" | "sleep" | "signal" | "now" | "random" | "code" | "all"
144
+
145
+ export interface OrchestrationCall {
146
+ readonly kind: OrchestrationKind
147
+ readonly name: string
148
+ readonly counter: number
149
+ readonly branches?: number
150
+ }
151
+
152
+ type WorkflowValueSuccess<EffectValue> =
153
+ EffectValue extends WorkflowValue<infer A, any> ? A : never
154
+
155
+ type WorkflowValueError<EffectValue> =
156
+ EffectValue extends WorkflowValue<any, infer E> ? E : never
157
+
158
+ type WorkflowAllSuccess<Effects extends ReadonlyArray<WorkflowValue<any, any>>> = {
159
+ -readonly [K in keyof Effects]: WorkflowValueSuccess<Effects[K]>
160
+ }
161
+
162
+ type WorkflowAllError<Effects extends ReadonlyArray<WorkflowValue<any, any>>> =
163
+ WorkflowValueError<Effects[number]>
164
+
165
+ export type SignalOutcome<T> =
166
+ | { readonly type: "signal"; readonly value: T }
167
+ | { readonly type: "timeout" }
168
+
169
+ export class Cancelled extends Error {
170
+ readonly _tag = "Cancelled"
171
+ readonly _wfSkipCompensation?: true
172
+
173
+ constructor(options: { readonly compensate: boolean }) {
174
+ super("Workflow execution cancelled")
175
+ this.name = "Cancelled"
176
+ if (!options.compensate) {
177
+ this._wfSkipCompensation = true
178
+ }
179
+ }
180
+ }
181
+
182
+ /** Reserved per-execution deferred the durable client completes to request
183
+ * cancellation. Every suspension point races against it. */
184
+ export const cancellationDeferredName = "wf:cancel"
185
+
186
+ const CancellationRequestSchema = Schema.Struct({
187
+ compensate: Schema.Boolean,
188
+ actor: Schema.optional(Schema.String)
189
+ })
190
+
191
+ export class NonDeterminismError extends Error {
192
+ readonly _tag = "NonDeterminismError"
193
+ readonly expected: OrchestrationCall
194
+ readonly actual: OrchestrationCall
195
+
196
+ constructor(options: { readonly expected: OrchestrationCall; readonly actual: OrchestrationCall }) {
197
+ super(
198
+ `Non-deterministic workflow replay: expected ${formatCall(options.expected)} but saw ${formatCall(options.actual)}`
199
+ )
200
+ this.name = "NonDeterminismError"
201
+ this.expected = options.expected
202
+ this.actual = options.actual
203
+ }
204
+ }
205
+
206
+ export interface WorkflowContext<WErrors> {
207
+ readonly executionId: string
208
+ run<I, O, E>(step: Step<I, O, E>, input: I): WorkflowValue<O, E | NonDeterminismError>
209
+ sleep(duration: Duration.Input, name?: string): WorkflowValue<void, NonDeterminismError>
210
+ waitForSignal<T>(
211
+ name: string,
212
+ schema: AnySchema<T>,
213
+ opts?: { readonly timeout?: Duration.Input }
214
+ ): WorkflowValue<SignalOutcome<T>, NonDeterminismError | SignalDeliveryError>
215
+ now(): WorkflowValue<Date, NonDeterminismError>
216
+ random(): WorkflowValue<number, NonDeterminismError>
217
+ code<T>(name: string, options: {
218
+ readonly reason?: string
219
+ readonly run: () => T | Promise<T>
220
+ }): WorkflowValue<T, NonDeterminismError>
221
+ all<const Effects extends ReadonlyArray<WorkflowValue<any, any>>>(
222
+ effects: Effects,
223
+ options?: { readonly name?: string; readonly concurrency?: number | "unbounded" }
224
+ ): WorkflowValue<WorkflowAllSuccess<Effects>, WorkflowAllError<Effects> | NonDeterminismError>
225
+ fail(error: WErrors): WorkflowValue<never, WErrors>
226
+ effect<A, E, R>(effect: Effect.Effect<A, E, R>): WorkflowValue<A, E>
227
+ }
228
+
229
+ export interface DefineWorkflowConfig<I, O, WErrors = never> {
230
+ readonly name: string
231
+ readonly version: number
232
+ readonly input: AnySchema<I>
233
+ readonly output: AnySchema<O>
234
+ readonly errors?: AnySchema<WErrors>
235
+ readonly run: (input: I, ctx: WorkflowContext<WErrors>) => Generator<any, O, any>
236
+ }
237
+
238
+ export const DefinedWorkflowTypeId = Symbol.for("wf/DefinedWorkflow")
239
+
240
+ export interface DefinedWorkflow<I = any, O = any, WErrors = any> {
241
+ readonly [DefinedWorkflowTypeId]: typeof DefinedWorkflowTypeId
242
+ readonly name: string
243
+ readonly version: number
244
+ readonly engineName: string
245
+ readonly sourceHash: string
246
+ readonly input: AnySchema<I>
247
+ readonly output: AnySchema<O>
248
+ readonly errors: AnySchema<WErrors>
249
+ readonly workflow: any
250
+ readonly layer: any
251
+ readonly execute: (payload: I) => Effect.Effect<O, WErrors | unknown, any>
252
+ readonly executeInMemory: (payload: I, options?: InMemoryExecutionOptions) => Promise<O>
253
+ }
254
+
255
+ export interface InMemoryExecutionOptions {
256
+ readonly executionId?: string
257
+ readonly determinism?: InMemoryDeterminismState
258
+ readonly onEvent?: (event: WorkflowEvent) => void | Promise<void>
259
+ readonly stepExecutors?: ReadonlyMap<Step<any, any, any>, Step<any, any, any>["execute"]>
260
+ readonly stepExecutor?: (options: {
261
+ readonly step: Step<any, any, any>
262
+ readonly input: unknown
263
+ readonly invocation: number
264
+ readonly activityName: string
265
+ readonly context: StepContext<any>
266
+ }) => unknown | Promise<unknown>
267
+ readonly sleep?: (options: {
268
+ readonly executionId: string
269
+ readonly name: string
270
+ readonly duration: Duration.Input
271
+ }) => Promise<void>
272
+ readonly signalTimeout?: (options: {
273
+ readonly executionId: string
274
+ readonly name: string
275
+ readonly duration: Duration.Input
276
+ }) => Promise<void>
277
+ readonly signalValue?: (options: {
278
+ readonly executionId: string
279
+ readonly name: string
280
+ readonly schema: AnySchema
281
+ }) => unknown | Promise<unknown>
282
+ readonly secrets?: SecretResolver
283
+ }
284
+
285
+ export interface InMemoryDeterminismState {
286
+ readonly calls: OrchestrationCall[]
287
+ readonly blocks: Array<{
288
+ readonly call: OrchestrationCall
289
+ readonly branches: ReadonlyArray<ReadonlyArray<OrchestrationCall>>
290
+ }>
291
+ readonly values: Map<string, unknown>
292
+ }
293
+
294
+ interface CompensationEntry {
295
+ readonly stepName: string
296
+ readonly invocation: number
297
+ readonly result: unknown
298
+ readonly input: unknown
299
+ readonly compensate: (result: unknown, input: unknown, reason: unknown) => unknown | Promise<unknown>
300
+ }
301
+
302
+ type ActivityFailure =
303
+ | { readonly _wfFailureType: "terminal"; readonly error: unknown }
304
+ | { readonly _wfFailureType: "transient"; readonly error: unknown }
305
+
306
+ class AsyncFailure extends Error {
307
+ readonly _tag = "AsyncFailure"
308
+ readonly error: unknown
309
+
310
+ constructor(error: unknown) {
311
+ super(error instanceof Error ? error.message : "Async operation failed")
312
+ this.name = "AsyncFailure"
313
+ this.error = error
314
+ }
315
+ }
316
+
317
+ const OrchestrationCallSchema: AnySchema = Schema.Struct({
318
+ kind: Schema.Union([
319
+ Schema.Literal("step"),
320
+ Schema.Literal("sleep"),
321
+ Schema.Literal("signal"),
322
+ Schema.Literal("now"),
323
+ Schema.Literal("random"),
324
+ Schema.Literal("code"),
325
+ Schema.Literal("all")
326
+ ]),
327
+ name: Schema.String,
328
+ counter: Schema.Number,
329
+ branches: Schema.optional(Schema.Number)
330
+ })
331
+
332
+ export const createInMemoryDeterminismState = (): InMemoryDeterminismState => ({
333
+ calls: [],
334
+ blocks: [],
335
+ values: new Map()
336
+ })
337
+
338
+ const formatCall = (call: OrchestrationCall): string =>
339
+ `${call.kind}:${call.name}#${call.counter}${call.branches === undefined ? "" : ` branches=${call.branches}`}`
340
+
341
+ const callsEqual = (left: OrchestrationCall, right: OrchestrationCall): boolean =>
342
+ left.kind === right.kind &&
343
+ left.name === right.name &&
344
+ left.counter === right.counter &&
345
+ left.branches === right.branches
346
+
347
+ const verifyCall = (expected: OrchestrationCall, actual: OrchestrationCall) => {
348
+ if (!callsEqual(expected, actual)) {
349
+ throw new NonDeterminismError({ expected, actual })
350
+ }
351
+ }
352
+
353
+ const valueKey = (call: OrchestrationCall): string => formatCall(call)
354
+
355
+ const skipsCompensation = (error: unknown): boolean =>
356
+ typeof error === "object" &&
357
+ error !== null &&
358
+ (error as { readonly _wfSkipCompensation?: unknown })._wfSkipCompensation === true
359
+
360
+ const isTerminalFailure = <E>(value: unknown): value is TerminalFailure<E> =>
361
+ typeof value === "object" &&
362
+ value !== null &&
363
+ (value as { readonly [TerminalFailureTypeId]?: unknown })[TerminalFailureTypeId] ===
364
+ TerminalFailureTypeId
365
+
366
+ const isActivityFailure = (value: unknown): value is ActivityFailure =>
367
+ typeof value === "object" &&
368
+ value !== null &&
369
+ ((value as { readonly _wfFailureType?: unknown })._wfFailureType === "terminal" ||
370
+ (value as { readonly _wfFailureType?: unknown })._wfFailureType === "transient")
371
+
372
+ const unwrapActivityFailure = (error: unknown): unknown =>
373
+ isActivityFailure(error) ? error.error : error
374
+
375
+ const unwrapAsyncFailure = (error: unknown): unknown =>
376
+ error instanceof AsyncFailure ? error.error : error
377
+
378
+ const makeStepContext = <E>(executionId: string, attempt: number): StepContext<E> => ({
379
+ attempt,
380
+ executionId,
381
+ fail: (error) => ({ [TerminalFailureTypeId]: TerminalFailureTypeId, error })
382
+ })
383
+
384
+ const nextInvocation = (counters: Map<string, number>, name: string): number => {
385
+ const invocation = (counters.get(name) ?? 0) + 1
386
+ counters.set(name, invocation)
387
+ return invocation
388
+ }
389
+
390
+ const decodeSync = <A>(schema: AnySchema<A>, value: unknown): A =>
391
+ Schema.decodeUnknownSync(schema as any)(value) as A
392
+
393
+ const encodeSync = <A>(schema: AnySchema<A>, value: A): unknown =>
394
+ Schema.encodeSync(schema as any)(value)
395
+
396
+ const resolveSecretRefs = async <A>(value: A, resolver: SecretResolver | undefined): Promise<A> => {
397
+ if (isSecretRef(value)) {
398
+ if (resolver === undefined) {
399
+ throw new Error(`No secret resolver configured for ${secretName(value)}`)
400
+ }
401
+ return await resolver.resolve(secretName(value)) as A
402
+ }
403
+
404
+ if (Array.isArray(value)) {
405
+ return await Promise.all(value.map((item) => resolveSecretRefs(item, resolver))) as A
406
+ }
407
+
408
+ if (value instanceof Date || typeof value !== "object" || value === null) {
409
+ return value
410
+ }
411
+
412
+ const entries = await Promise.all(
413
+ Object.entries(value).map(async ([key, entry]) => [key, await resolveSecretRefs(entry, resolver)] as const)
414
+ )
415
+ return Object.fromEntries(entries) as A
416
+ }
417
+
418
+ interface SemaphoreState {
419
+ active: number
420
+ readonly queue: Array<() => void>
421
+ }
422
+
423
+ const concurrencySemaphores = new Map<string, SemaphoreState>()
424
+
425
+ const acquireConcurrency = async <I>(step: Step<I, any, any>, input: I): Promise<() => void> => {
426
+ const limit = step.concurrency?.limit
427
+ if (limit === undefined) {
428
+ return () => undefined
429
+ }
430
+ if (!Number.isInteger(limit) || limit < 1) {
431
+ throw new Error(`Invalid concurrency limit for step ${step.name}: ${limit}`)
432
+ }
433
+
434
+ const key = step.concurrency?.key?.(input) ?? step.name
435
+ const semaphoreKey = `${step.name}\0${key}`
436
+ const state = concurrencySemaphores.get(semaphoreKey) ?? { active: 0, queue: [] }
437
+ concurrencySemaphores.set(semaphoreKey, state)
438
+
439
+ if (state.active >= limit) {
440
+ await new Promise<void>((resolve) => {
441
+ state.queue.push(resolve)
442
+ })
443
+ }
444
+
445
+ state.active++
446
+ let released = false
447
+ return () => {
448
+ if (released) {
449
+ return
450
+ }
451
+ released = true
452
+ state.active--
453
+ const next = state.queue.shift()
454
+ if (next !== undefined) {
455
+ next()
456
+ }
457
+ if (state.active === 0 && state.queue.length === 0) {
458
+ concurrencySemaphores.delete(semaphoreKey)
459
+ }
460
+ }
461
+ }
462
+
463
+ // Durable race with a persisted winner. This deliberately does NOT use
464
+ // DurableDeferred.raceAll: its replay path runs `Effect.flatten(exit)` over
465
+ // the stored winner, which dies with "Not a valid effect" for plain (non-
466
+ // Effect) winner values. We store/unwrap the same way DurableDeferred.await
467
+ // does — a single `yield*` of the persisted exit.
468
+ const raceDurable = (
469
+ name: string,
470
+ effects: ReadonlyArray<Effect.Effect<any, any, any>>
471
+ ): Effect.Effect<any, any, any> =>
472
+ Effect.gen(function* () {
473
+ const deferred = DurableDeferred.make(name, { success: Schema.Unknown })
474
+ const engine = yield* WorkflowEngine.WorkflowEngine
475
+ const exit = yield* Workflow.wrapActivityResult(
476
+ engine.deferredResult(deferred),
477
+ Option.isNone
478
+ )
479
+ if (Option.isSome(exit)) {
480
+ return yield* exit.value as Exit.Exit<any, any>
481
+ }
482
+ return yield* DurableDeferred.into(Effect.raceAll(effects) as any, deferred as any)
483
+ })
484
+
485
+ const retrySchedule = (retry: StepRetryPolicy | undefined) => {
486
+ const attempts = Math.max(1, retry?.attempts ?? 1)
487
+ const recurs = Schedule.recurs(attempts - 1)
488
+ return retry?.backoff === "exponential"
489
+ ? Schedule.exponential("10 millis").pipe(Schedule.both(recurs))
490
+ : recurs
491
+ }
492
+
493
+ const transientAttempts = (retry: StepRetryPolicy | undefined): number =>
494
+ Math.max(1, retry?.attempts ?? 1)
495
+
496
+ const makeCtx = <WErrors>(
497
+ wf: any,
498
+ executionId: ExecutionId,
499
+ workflowErrors: AnySchema<WErrors>
500
+ ): WorkflowContext<WErrors> => {
501
+ const counters = new Map<string, number>()
502
+ let journalPosition = 0
503
+ let parallelDepth = 0
504
+
505
+ const recordCall = (actual: OrchestrationCall): Effect.Effect<void, NonDeterminismError, any> => {
506
+ const position = ++journalPosition
507
+ const activityName = parallelDepth > 0
508
+ ? `determinism:${actual.kind}:${actual.name}#${actual.counter}`
509
+ : `determinism#${position}`
510
+ return Activity.make({
511
+ name: activityName,
512
+ success: OrchestrationCallSchema,
513
+ execute: Effect.succeed(actual)
514
+ }).pipe(
515
+ Effect.flatMap((expected) =>
516
+ callsEqual(expected, actual)
517
+ ? Effect.void
518
+ : Effect.fail(new NonDeterminismError({ expected, actual }))
519
+ )
520
+ )
521
+ }
522
+
523
+ const cancellationDeferred = DurableDeferred.make(cancellationDeferredName, {
524
+ success: CancellationRequestSchema
525
+ })
526
+
527
+ // A suspension point races its own durable operation against the reserved
528
+ // cancellation deferred, so a cancel request wakes the execution and unwinds
529
+ // it instead of leaving it parked forever.
530
+ const cancellationBranch = DurableDeferred.await(cancellationDeferred).pipe(
531
+ Effect.map((request) => ({
532
+ type: "cancelled" as const,
533
+ compensate: request.compensate,
534
+ actor: request.actor
535
+ }))
536
+ )
537
+
538
+ const failCancelled = (outcome: { compensate: boolean; actor?: string }) =>
539
+ Effect.gen(function* () {
540
+ yield* emitWorkflowEvent({
541
+ type: "cancellation.received",
542
+ executionId,
543
+ compensate: outcome.compensate,
544
+ ...(outcome.actor === undefined ? {} : { actor: outcome.actor })
545
+ })
546
+ // A plain failure exit: withCompensation finalizers run for compensate:
547
+ // true. compensate: false never reaches here (the client interrupts the
548
+ // engine directly), but failing is still the safe fallback.
549
+ return yield* Effect.fail(new Cancelled({ compensate: outcome.compensate }))
550
+ })
551
+
552
+ return {
553
+ executionId,
554
+
555
+ run(step, rawInput) {
556
+ const invocation = nextInvocation(counters, step.name)
557
+ const activityName = `${step.name}#${invocation}`
558
+ const call: OrchestrationCall = { kind: "step", name: step.name, counter: invocation }
559
+ const input = decodeSync(step.input, rawInput)
560
+
561
+ const execute = Effect.gen(function* () {
562
+ const attempt = yield* Activity.CurrentAttempt
563
+ yield* emitWorkflowEvent({
564
+ type: "step.started",
565
+ executionId,
566
+ stepName: step.name,
567
+ invocation,
568
+ activityName,
569
+ attempt,
570
+ input
571
+ })
572
+
573
+ const contextResolver = yield* currentSecretResolver
574
+ const resolver = getExecutionSecretResolver(executionId) ?? contextResolver
575
+ const result = yield* Effect.tryPromise({
576
+ try: async () => {
577
+ const release = await acquireConcurrency(step, input)
578
+ try {
579
+ const executeInput = await resolveSecretRefs(input, resolver)
580
+ const value = await step.execute(executeInput, makeStepContext(executionId, attempt))
581
+ if (isTerminalFailure(value)) {
582
+ throw value
583
+ }
584
+ return decodeSync(step.output, value)
585
+ } finally {
586
+ release()
587
+ }
588
+ },
589
+ catch: (error) => {
590
+ if (isTerminalFailure(error)) {
591
+ return {
592
+ _wfFailureType: "terminal",
593
+ error: decodeSync(step.errors, error.error)
594
+ } satisfies ActivityFailure
595
+ }
596
+ return { _wfFailureType: "transient", error } satisfies ActivityFailure
597
+ }
598
+ })
599
+
600
+ yield* emitWorkflowEvent({
601
+ type: "step.completed",
602
+ executionId,
603
+ stepName: step.name,
604
+ invocation,
605
+ activityName,
606
+ attempt,
607
+ result
608
+ })
609
+
610
+ return result
611
+ }).pipe(
612
+ Effect.tapError((error) =>
613
+ emitWorkflowEvent({
614
+ type: "step.failed",
615
+ executionId,
616
+ stepName: step.name,
617
+ invocation,
618
+ activityName,
619
+ error: unwrapActivityFailure(error)
620
+ })
621
+ )
622
+ )
623
+
624
+ let activity: Effect.Effect<unknown, unknown, any> = Activity.make({
625
+ name: activityName,
626
+ success: step.output,
627
+ error: Schema.Unknown,
628
+ execute
629
+ })
630
+
631
+ activity = activity.pipe(
632
+ Activity.retry({
633
+ schedule: retrySchedule(step.retry),
634
+ while: (error: unknown) =>
635
+ isActivityFailure(error) && error._wfFailureType === "transient"
636
+ } as any),
637
+ Effect.mapError(unwrapActivityFailure)
638
+ )
639
+
640
+ if (step.compensate !== undefined) {
641
+ activity = activity.pipe(
642
+ wf.withCompensation((value: unknown, cause: Cause.Cause<unknown>) =>
643
+ Effect.gen(function* () {
644
+ yield* emitWorkflowEvent({
645
+ type: "compensation.started",
646
+ executionId,
647
+ stepName: step.name,
648
+ invocation,
649
+ activityName,
650
+ result: value,
651
+ input,
652
+ reason: cause
653
+ })
654
+ yield* Effect.tryPromise({
655
+ try: () => Promise.resolve(step.compensate!(value as any, input, cause)),
656
+ catch: (error) => new AsyncFailure(error)
657
+ }).pipe(
658
+ Effect.tapError((error) =>
659
+ emitWorkflowEvent({
660
+ type: "compensation.failed",
661
+ executionId,
662
+ stepName: step.name,
663
+ invocation,
664
+ activityName,
665
+ error: unwrapAsyncFailure(error)
666
+ })
667
+ ),
668
+ Effect.orDie
669
+ )
670
+ yield* emitWorkflowEvent({
671
+ type: "compensation.completed",
672
+ executionId,
673
+ stepName: step.name,
674
+ invocation,
675
+ activityName
676
+ })
677
+ })
678
+ )
679
+ )
680
+ }
681
+
682
+ return Effect.gen(function* () {
683
+ yield* recordCall(call)
684
+ return yield* activity
685
+ }) as Effect.Effect<any, any, any>
686
+ },
687
+
688
+ all(effects, options) {
689
+ const name = options?.name ?? "all"
690
+ const invocation = nextInvocation(counters, name)
691
+ const activityName = `${name}#${invocation}`
692
+ const branches = effects.length
693
+ const call: OrchestrationCall = { kind: "all", name, counter: invocation, branches }
694
+ return Effect.gen(function* () {
695
+ yield* recordCall(call)
696
+ yield* emitWorkflowEvent({
697
+ type: "all.started",
698
+ executionId,
699
+ name,
700
+ invocation,
701
+ activityName,
702
+ branches
703
+ })
704
+ yield* Effect.sync(() => {
705
+ parallelDepth++
706
+ })
707
+ const combined = Effect.all(effects, {
708
+ concurrency: options?.concurrency ?? "unbounded"
709
+ }) as Effect.Effect<any, any, any>
710
+ return yield* combined.pipe(
711
+ Effect.ensuring(Effect.sync(() => {
712
+ parallelDepth--
713
+ })),
714
+ Effect.tap(() => emitWorkflowEvent({
715
+ type: "all.completed",
716
+ executionId,
717
+ name,
718
+ invocation,
719
+ activityName,
720
+ branches
721
+ })),
722
+ Effect.tapError((error) => emitWorkflowEvent({
723
+ type: "all.failed",
724
+ executionId,
725
+ name,
726
+ invocation,
727
+ activityName,
728
+ branches,
729
+ error
730
+ }))
731
+ ) as Effect.Effect<any, any, any>
732
+ }) as Effect.Effect<any, any, any>
733
+ },
734
+
735
+ sleep(duration, name) {
736
+ const baseName = name ?? `sleep:${String(duration)}`
737
+ const invocation = nextInvocation(counters, baseName)
738
+ const sleepName = `${baseName}#${invocation}`
739
+ const call: OrchestrationCall = { kind: "sleep", name: baseName, counter: invocation }
740
+ return Effect.gen(function* () {
741
+ yield* recordCall(call)
742
+ yield* emitWorkflowEvent({
743
+ type: "sleep.started",
744
+ executionId,
745
+ name: baseName,
746
+ invocation,
747
+ activityName: sleepName,
748
+ duration
749
+ })
750
+ const outcome = (yield* raceDurable(`race:${sleepName}`, [
751
+ // Sleeps under the engine's in-memory threshold (60s) run inside
752
+ // an activity that holds the entity mailbox, so a cancellation
753
+ // delivered mid-sleep is consumed at the NEXT suspension point,
754
+ // not instantly — bounded by the threshold. Longer sleeps go
755
+ // durable and wake immediately on cancellation.
756
+ DurableClock.sleep({ name: sleepName, duration }).pipe(
757
+ Effect.map(() => ({ type: "slept" as const }))
758
+ ),
759
+ cancellationBranch
760
+ ])) as { type: "slept" } | { type: "cancelled"; compensate: boolean; actor?: string }
761
+ if (outcome.type === "cancelled") {
762
+ return yield* failCancelled(outcome)
763
+ }
764
+ yield* emitWorkflowEvent({
765
+ type: "sleep.completed",
766
+ executionId,
767
+ name: baseName,
768
+ invocation,
769
+ activityName: sleepName,
770
+ duration
771
+ })
772
+ }) as Effect.Effect<any, any, any>
773
+ },
774
+
775
+ waitForSignal(name, schema, opts) {
776
+ const invocation = nextInvocation(counters, name)
777
+ const waitName = `${name}#${invocation}`
778
+ const call: OrchestrationCall = { kind: "signal", name, counter: invocation }
779
+ const payloadSchema = jsonSchemaOf(schema)
780
+
781
+ return Effect.gen(function* () {
782
+ yield* recordCall(call)
783
+ // Delivery-side validation needs the schema of the wait the run is
784
+ // parked at; replay re-registers it in a fresh process.
785
+ registerSignalSchema(executionId, name, schema)
786
+ yield* emitWorkflowEvent({
787
+ type: "signal.waiting",
788
+ executionId,
789
+ name,
790
+ invocation,
791
+ activityName: waitName,
792
+ timeout: opts?.timeout,
793
+ ...(payloadSchema === undefined ? {} : { payloadSchema })
794
+ })
795
+
796
+ const deferredName = `signal:${waitName}`
797
+ const deferred = DurableDeferred.make(deferredName, { success: schema })
798
+
799
+ // The race winner is persisted, so the signal value crosses replay as
800
+ // its encoded form and is re-decoded below.
801
+ const signalBranch = DurableDeferred.await(deferred).pipe(
802
+ Effect.map((value) => ({
803
+ type: "signal" as const,
804
+ encoded: encodeSync(schema, value as any)
805
+ }))
806
+ )
807
+ const timeoutBranch = opts?.timeout === undefined
808
+ ? []
809
+ : [
810
+ DurableClock.sleep({
811
+ name: `signal-timeout:${waitName}`,
812
+ duration: opts.timeout,
813
+ inMemoryThreshold: "1 milli"
814
+ }).pipe(Effect.map(() => ({ type: "timeout" as const })))
815
+ ]
816
+
817
+ const outcome = (yield* raceDurable(`race:${waitName}`, [
818
+ signalBranch,
819
+ ...timeoutBranch,
820
+ cancellationBranch
821
+ ])) as
822
+ | { type: "signal"; encoded: unknown }
823
+ | { type: "timeout" }
824
+ | { type: "cancelled"; compensate: boolean; actor?: string }
825
+
826
+ if (outcome.type === "cancelled") {
827
+ return yield* failCancelled(outcome)
828
+ }
829
+
830
+ if (outcome.type === "timeout") {
831
+ yield* emitWorkflowEvent({
832
+ type: "signal.timeout",
833
+ executionId,
834
+ name,
835
+ invocation,
836
+ activityName: waitName,
837
+ timeout: opts?.timeout
838
+ })
839
+ return { type: "timeout" } as const
840
+ }
841
+
842
+ const value = decodeSync(schema, outcome.encoded)
843
+ yield* emitWorkflowEvent({
844
+ type: "signal.received",
845
+ executionId,
846
+ name,
847
+ invocation,
848
+ activityName: waitName,
849
+ payload: value
850
+ })
851
+ return { type: "signal", value } as const
852
+ }) as Effect.Effect<any, any, any>
853
+ },
854
+
855
+ now() {
856
+ const invocation = nextInvocation(counters, "now")
857
+ const activityName = `now#${invocation}`
858
+ const call: OrchestrationCall = { kind: "now", name: "now", counter: invocation }
859
+ const activity = Activity.make({
860
+ name: activityName,
861
+ success: Schema.Date,
862
+ execute: Effect.sync(() => new Date())
863
+ })
864
+ return Effect.gen(function* () {
865
+ yield* recordCall(call)
866
+ return yield* activity
867
+ })
868
+ },
869
+
870
+ random() {
871
+ const invocation = nextInvocation(counters, "random")
872
+ const activityName = `random#${invocation}`
873
+ const call: OrchestrationCall = { kind: "random", name: "random", counter: invocation }
874
+ const activity = Activity.make({
875
+ name: activityName,
876
+ success: Schema.Number,
877
+ execute: Effect.sync(() => Math.random())
878
+ })
879
+ return Effect.gen(function* () {
880
+ yield* recordCall(call)
881
+ return yield* activity
882
+ })
883
+ },
884
+
885
+ code(name, options) {
886
+ const invocation = nextInvocation(counters, name)
887
+ const activityName = `${name}#${invocation}`
888
+ const call: OrchestrationCall = { kind: "code", name, counter: invocation }
889
+ const execute = Effect.gen(function* () {
890
+ yield* emitWorkflowEvent({
891
+ type: "code.started",
892
+ executionId,
893
+ name,
894
+ invocation,
895
+ activityName,
896
+ ...(options.reason === undefined ? {} : { reason: options.reason })
897
+ })
898
+ const result = yield* Effect.tryPromise({
899
+ try: async () => options.run(),
900
+ catch: (error) => new AsyncFailure(error)
901
+ })
902
+ yield* emitWorkflowEvent({
903
+ type: "code.completed",
904
+ executionId,
905
+ name,
906
+ invocation,
907
+ activityName,
908
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
909
+ result
910
+ })
911
+ return result
912
+ }).pipe(
913
+ Effect.tapError((error) =>
914
+ emitWorkflowEvent({
915
+ type: "code.failed",
916
+ executionId,
917
+ name,
918
+ invocation,
919
+ activityName,
920
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
921
+ error: unwrapAsyncFailure(error)
922
+ })
923
+ )
924
+ )
925
+ const activity = Activity.make({
926
+ name: activityName,
927
+ success: Schema.Unknown,
928
+ error: Schema.Unknown,
929
+ execute
930
+ }).pipe(Effect.mapError(unwrapAsyncFailure))
931
+ return Effect.gen(function* () {
932
+ yield* recordCall(call)
933
+ return yield* activity
934
+ }) as Effect.Effect<any, any, any>
935
+ },
936
+
937
+ fail(error) {
938
+ return Effect.fail(decodeSync(workflowErrors, error))
939
+ },
940
+
941
+ effect(effect) {
942
+ return effect as Effect.Effect<any, any, any>
943
+ }
944
+ }
945
+ }
946
+
947
+ const makeInMemoryCtx = <WErrors>(
948
+ executionId: ExecutionId,
949
+ workflowErrors: AnySchema<WErrors>,
950
+ compensations: CompensationEntry[],
951
+ determinism: InMemoryDeterminismState,
952
+ emit: (event: WorkflowEvent) => Promise<void>,
953
+ options: Pick<
954
+ InMemoryExecutionOptions,
955
+ "stepExecutors" | "stepExecutor" | "sleep" | "signalTimeout" | "signalValue" | "secrets"
956
+ > = {}
957
+ ): WorkflowContext<WErrors> => {
958
+ const counters = new Map<string, number>()
959
+ let journalPosition = 0
960
+ let blockPosition = 0
961
+ const branchCollectors: Array<OrchestrationCall[]> = []
962
+
963
+ const recordCall = async (actual: OrchestrationCall): Promise<void> => {
964
+ const index = journalPosition++
965
+ const expected = determinism.calls[index]
966
+ if (expected === undefined) {
967
+ determinism.calls.push(actual)
968
+ } else {
969
+ verifyCall(expected, actual)
970
+ }
971
+ branchCollectors[branchCollectors.length - 1]?.push(actual)
972
+ }
973
+
974
+ return {
975
+ executionId,
976
+
977
+ run(step, rawInput) {
978
+ const invocation = nextInvocation(counters, step.name)
979
+ const activityName = `${step.name}#${invocation}`
980
+ const input = decodeSync(step.input, rawInput)
981
+ return Effect.tryPromise({
982
+ try: async () => {
983
+ await recordCall({ kind: "step", name: step.name, counter: invocation })
984
+ const attempts = transientAttempts(step.retry)
985
+ let lastTransient: unknown
986
+
987
+ for (let attempt = 1; attempt <= attempts; attempt++) {
988
+ await emit({
989
+ type: "step.started",
990
+ executionId,
991
+ stepName: step.name,
992
+ invocation,
993
+ activityName,
994
+ attempt,
995
+ input
996
+ })
997
+
998
+ try {
999
+ const stepContext = makeStepContext(executionId, attempt)
1000
+ const executeStep = options.stepExecutors?.get(step)
1001
+ const release = await acquireConcurrency(step, input)
1002
+ try {
1003
+ const executeInput = await resolveSecretRefs(input, options.secrets)
1004
+ const value = await (
1005
+ options.stepExecutor?.({ step, input: executeInput, invocation, activityName, context: stepContext }) ??
1006
+ executeStep?.(executeInput, stepContext as any) ??
1007
+ step.execute(executeInput, stepContext as any)
1008
+ )
1009
+ if (isTerminalFailure(value)) {
1010
+ const terminal = decodeSync(step.errors, value.error)
1011
+ throw terminal
1012
+ }
1013
+
1014
+ const result = decodeSync(step.output, value)
1015
+ encodeSync(step.output, result)
1016
+ await emit({
1017
+ type: "step.completed",
1018
+ executionId,
1019
+ stepName: step.name,
1020
+ invocation,
1021
+ activityName,
1022
+ attempt,
1023
+ result
1024
+ })
1025
+
1026
+ if (step.compensate !== undefined) {
1027
+ compensations.push({
1028
+ stepName: step.name,
1029
+ invocation,
1030
+ result,
1031
+ input,
1032
+ compensate: step.compensate as CompensationEntry["compensate"]
1033
+ })
1034
+ }
1035
+
1036
+ return result
1037
+ } finally {
1038
+ release()
1039
+ }
1040
+ } catch (error) {
1041
+ if (attempt === attempts || isDeclaredTerminal(step.errors, error)) {
1042
+ await emit({
1043
+ type: "step.failed",
1044
+ executionId,
1045
+ stepName: step.name,
1046
+ invocation,
1047
+ activityName,
1048
+ error
1049
+ })
1050
+ throw error
1051
+ }
1052
+ lastTransient = error
1053
+ }
1054
+ }
1055
+
1056
+ throw lastTransient
1057
+ },
1058
+ catch: (error) => new AsyncFailure(error)
1059
+ }).pipe(Effect.mapError(unwrapAsyncFailure)) as Effect.Effect<any, any, any>
1060
+ },
1061
+
1062
+ sleep(duration, name) {
1063
+ const baseName = name ?? `sleep:${String(duration)}`
1064
+ const invocation = nextInvocation(counters, baseName)
1065
+ const activityName = `${baseName}#${invocation}`
1066
+ return Effect.promise(async () => {
1067
+ await recordCall({ kind: "sleep", name: baseName, counter: invocation })
1068
+ await emit({
1069
+ type: "sleep.started",
1070
+ executionId,
1071
+ name: baseName,
1072
+ invocation,
1073
+ activityName,
1074
+ duration
1075
+ })
1076
+ await options.sleep?.({ executionId, name: activityName, duration })
1077
+ await emit({
1078
+ type: "sleep.completed",
1079
+ executionId,
1080
+ name: baseName,
1081
+ invocation,
1082
+ activityName,
1083
+ duration
1084
+ })
1085
+ })
1086
+ },
1087
+
1088
+ waitForSignal(name, schema, opts) {
1089
+ const invocation = nextInvocation(counters, name)
1090
+ const activityName = `${name}#${invocation}`
1091
+ const payloadSchema = jsonSchemaOf(schema)
1092
+ return Effect.tryPromise({
1093
+ try: async () => {
1094
+ await recordCall({ kind: "signal", name, counter: invocation })
1095
+ registerSignalSchema(executionId, name, schema)
1096
+ await emit({
1097
+ type: "signal.waiting",
1098
+ executionId,
1099
+ name,
1100
+ invocation,
1101
+ activityName,
1102
+ timeout: opts?.timeout,
1103
+ ...(payloadSchema === undefined ? {} : { payloadSchema })
1104
+ })
1105
+
1106
+ const buffered = takeBufferedSignal(executionId, name, schema)
1107
+ if (buffered !== undefined) {
1108
+ await emit({
1109
+ type: "signal.received",
1110
+ executionId,
1111
+ name,
1112
+ invocation,
1113
+ activityName,
1114
+ payload: buffered
1115
+ })
1116
+ return { type: "signal", value: buffered } as const
1117
+ }
1118
+
1119
+ if (options.signalValue !== undefined) {
1120
+ const value = decodeSync(schema, await options.signalValue({ executionId, name, schema }))
1121
+ await emit({
1122
+ type: "signal.received",
1123
+ executionId,
1124
+ name,
1125
+ invocation,
1126
+ activityName,
1127
+ payload: value
1128
+ })
1129
+ return { type: "signal", value } as const
1130
+ }
1131
+
1132
+ if (opts?.timeout !== undefined) {
1133
+ if (options.signalTimeout !== undefined) {
1134
+ const outcome = await Promise.race([
1135
+ awaitSignal(executionId, name, schema).then((value) => ({ type: "signal", value }) as const),
1136
+ options.signalTimeout({ executionId, name: activityName, duration: opts.timeout })
1137
+ .then(() => ({ type: "timeout" }) as const)
1138
+ ])
1139
+ if (outcome.type === "signal") {
1140
+ await emit({
1141
+ type: "signal.received",
1142
+ executionId,
1143
+ name,
1144
+ invocation,
1145
+ activityName,
1146
+ payload: outcome.value
1147
+ })
1148
+ } else {
1149
+ await emit({
1150
+ type: "signal.timeout",
1151
+ executionId,
1152
+ name,
1153
+ invocation,
1154
+ activityName,
1155
+ timeout: opts.timeout
1156
+ })
1157
+ }
1158
+ return outcome
1159
+ }
1160
+ await emit({
1161
+ type: "signal.timeout",
1162
+ executionId,
1163
+ name,
1164
+ invocation,
1165
+ activityName,
1166
+ timeout: opts.timeout
1167
+ })
1168
+ return { type: "timeout" } as const
1169
+ }
1170
+
1171
+ const value = await awaitSignal(executionId, name, schema)
1172
+ await emit({
1173
+ type: "signal.received",
1174
+ executionId,
1175
+ name,
1176
+ invocation,
1177
+ activityName,
1178
+ payload: value
1179
+ })
1180
+ return { type: "signal", value } as const
1181
+ },
1182
+ catch: (error) => new AsyncFailure(error)
1183
+ }).pipe(Effect.mapError(unwrapAsyncFailure)) as Effect.Effect<any, any, any>
1184
+ },
1185
+
1186
+ now() {
1187
+ const invocation = nextInvocation(counters, "now")
1188
+ const call: OrchestrationCall = { kind: "now", name: "now", counter: invocation }
1189
+ return Effect.promise(async () => {
1190
+ await recordCall(call)
1191
+ const key = valueKey(call)
1192
+ const existing = determinism.values.get(key)
1193
+ if (existing instanceof Date) {
1194
+ return existing
1195
+ }
1196
+ const value = new Date()
1197
+ determinism.values.set(key, value)
1198
+ return value
1199
+ })
1200
+ },
1201
+
1202
+ random() {
1203
+ const invocation = nextInvocation(counters, "random")
1204
+ const call: OrchestrationCall = { kind: "random", name: "random", counter: invocation }
1205
+ return Effect.promise(async () => {
1206
+ await recordCall(call)
1207
+ const key = valueKey(call)
1208
+ const existing = determinism.values.get(key)
1209
+ if (typeof existing === "number") {
1210
+ return existing
1211
+ }
1212
+ const value = Math.random()
1213
+ determinism.values.set(key, value)
1214
+ return value
1215
+ })
1216
+ },
1217
+
1218
+ code(name, options) {
1219
+ const invocation = nextInvocation(counters, name)
1220
+ const activityName = `${name}#${invocation}`
1221
+ const call: OrchestrationCall = { kind: "code", name, counter: invocation }
1222
+ return Effect.tryPromise({
1223
+ try: async () => {
1224
+ await recordCall(call)
1225
+ await emit({
1226
+ type: "code.started",
1227
+ executionId,
1228
+ name,
1229
+ invocation,
1230
+ activityName,
1231
+ ...(options.reason === undefined ? {} : { reason: options.reason })
1232
+ })
1233
+
1234
+ const key = valueKey(call)
1235
+ if (determinism.values.has(key)) {
1236
+ const result = determinism.values.get(key)
1237
+ await emit({
1238
+ type: "code.completed",
1239
+ executionId,
1240
+ name,
1241
+ invocation,
1242
+ activityName,
1243
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
1244
+ result
1245
+ })
1246
+ return result as Awaited<ReturnType<typeof options.run>>
1247
+ }
1248
+
1249
+ try {
1250
+ const result = await options.run()
1251
+ determinism.values.set(key, result)
1252
+ await emit({
1253
+ type: "code.completed",
1254
+ executionId,
1255
+ name,
1256
+ invocation,
1257
+ activityName,
1258
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
1259
+ result
1260
+ })
1261
+ return result
1262
+ } catch (error) {
1263
+ await emit({
1264
+ type: "code.failed",
1265
+ executionId,
1266
+ name,
1267
+ invocation,
1268
+ activityName,
1269
+ ...(options.reason === undefined ? {} : { reason: options.reason }),
1270
+ error
1271
+ })
1272
+ throw error
1273
+ }
1274
+ },
1275
+ catch: (error) => new AsyncFailure(error)
1276
+ }).pipe(Effect.mapError(unwrapAsyncFailure)) as Effect.Effect<any, any, any>
1277
+ },
1278
+
1279
+ all(effects, options) {
1280
+ const name = options?.name ?? "all"
1281
+ const invocation = nextInvocation(counters, name)
1282
+ const activityName = `${name}#${invocation}`
1283
+ const branches = effects.length
1284
+ const call: OrchestrationCall = { kind: "all", name, counter: invocation, branches }
1285
+ const record = Effect.tryPromise({
1286
+ try: () => recordCall(call),
1287
+ catch: (error): NonDeterminismError => error as NonDeterminismError
1288
+ })
1289
+ const emitEvent = (event: WorkflowEvent) => Effect.promise(() => emit(event))
1290
+ const persistBlock = (branchCalls: OrchestrationCall[][]) =>
1291
+ Effect.sync(() => {
1292
+ if (determinism.blocks[blockPosition++] === undefined) {
1293
+ determinism.blocks.push({ call, branches: branchCalls })
1294
+ }
1295
+ })
1296
+ return Effect.gen(function* () {
1297
+ yield* record
1298
+ yield* emitEvent({
1299
+ type: "all.started",
1300
+ executionId,
1301
+ name,
1302
+ invocation,
1303
+ activityName,
1304
+ branches
1305
+ })
1306
+ const branchCalls: OrchestrationCall[][] = []
1307
+ const wrapped = effects.map((effect) =>
1308
+ Effect.acquireUseRelease(
1309
+ Effect.sync(() => {
1310
+ const calls: OrchestrationCall[] = []
1311
+ branchCalls.push(calls)
1312
+ branchCollectors.push(calls)
1313
+ }),
1314
+ () => effect as Effect.Effect<any, any, any>,
1315
+ () => Effect.sync(() => {
1316
+ branchCollectors.pop()
1317
+ })
1318
+ )
1319
+ )
1320
+ return yield* (Effect.all(wrapped, { concurrency: 1 }) as Effect.Effect<any, any, any>).pipe(
1321
+ Effect.tap(() =>
1322
+ Effect.gen(function* () {
1323
+ yield* persistBlock(branchCalls)
1324
+ yield* emitEvent({
1325
+ type: "all.completed",
1326
+ executionId,
1327
+ name,
1328
+ invocation,
1329
+ activityName,
1330
+ branches
1331
+ })
1332
+ })
1333
+ ),
1334
+ Effect.tapError((error) =>
1335
+ Effect.gen(function* () {
1336
+ yield* persistBlock(branchCalls)
1337
+ yield* emitEvent({
1338
+ type: "all.failed",
1339
+ executionId,
1340
+ name,
1341
+ invocation,
1342
+ activityName,
1343
+ branches,
1344
+ error
1345
+ })
1346
+ })
1347
+ )
1348
+ )
1349
+ }) as Effect.Effect<any, any, any>
1350
+ },
1351
+
1352
+ fail(error) {
1353
+ return Effect.fail(decodeSync(workflowErrors, error))
1354
+ },
1355
+
1356
+ effect(effect) {
1357
+ return effect as Effect.Effect<any, any, any>
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ const isDeclaredTerminal = <E>(schema: AnySchema<E>, error: unknown): boolean => {
1363
+ try {
1364
+ decodeSync(schema, error)
1365
+ return true
1366
+ } catch {
1367
+ return false
1368
+ }
1369
+ }
1370
+
1371
+ export const defineWorkflow = <
1372
+ const Input extends AnySchema,
1373
+ const Output extends AnySchema,
1374
+ const Errors extends AnySchema = typeof Schema.Never
1375
+ >(config: {
1376
+ readonly name: string
1377
+ readonly version: number
1378
+ readonly input: Input
1379
+ readonly output: Output
1380
+ readonly errors?: Errors
1381
+ readonly run: (
1382
+ input: Schema.Schema.Type<Input>,
1383
+ ctx: WorkflowContext<Schema.Schema.Type<Errors>>
1384
+ ) => Generator<any, Schema.Schema.Type<Output>, any>
1385
+ }): DefinedWorkflow<Schema.Schema.Type<Input>, Schema.Schema.Type<Output>, Schema.Schema.Type<Errors>> => {
1386
+ const errors = config.errors ?? Schema.Never
1387
+ const engineName = `${config.name}@v${config.version}`
1388
+ const sourceHash = createHash("sha256")
1389
+ .update(config.name)
1390
+ .update("\0")
1391
+ .update(String(config.version))
1392
+ .update("\0")
1393
+ .update(config.run.toString())
1394
+ .digest("hex")
1395
+
1396
+ const workflow = Workflow.make(engineName, {
1397
+ payload: config.input as any,
1398
+ idempotencyKey: (payload: any) => JSON.stringify(payload),
1399
+ success: config.output,
1400
+ error: Schema.Unknown
1401
+ })
1402
+
1403
+ const layer = workflow.toLayer(
1404
+ Effect.fn(function* (payload: Schema.Schema.Type<Input>, executionId: string) {
1405
+ const input = decodeSync(config.input, payload)
1406
+ const result = yield* config.run(input, makeCtx(workflow, ExecutionId.make(executionId), errors)) as any
1407
+ return decodeSync(config.output, result)
1408
+ }) as any
1409
+ )
1410
+
1411
+ const executeInMemory = async (
1412
+ payload: Schema.Schema.Type<Input>,
1413
+ options: InMemoryExecutionOptions = {}
1414
+ ): Promise<Schema.Schema.Type<Output>> => {
1415
+ const executionId = ExecutionId.make(options.executionId ?? `memory-${crypto.randomUUID()}`)
1416
+ const compensations: CompensationEntry[] = []
1417
+ const determinism = options.determinism ?? createInMemoryDeterminismState()
1418
+ const input = decodeSync(config.input, payload)
1419
+ const emit = async (event: WorkflowEvent) => {
1420
+ await options.onEvent?.(event)
1421
+ }
1422
+ const ctx = makeInMemoryCtx(executionId, errors, compensations, determinism, emit, {
1423
+ ...(options.stepExecutors === undefined ? {} : { stepExecutors: options.stepExecutors }),
1424
+ ...(options.stepExecutor === undefined ? {} : { stepExecutor: options.stepExecutor }),
1425
+ ...(options.sleep === undefined ? {} : { sleep: options.sleep }),
1426
+ ...(options.signalTimeout === undefined ? {} : { signalTimeout: options.signalTimeout }),
1427
+ ...(options.signalValue === undefined ? {} : { signalValue: options.signalValue }),
1428
+ ...(options.secrets === undefined ? {} : { secrets: options.secrets })
1429
+ })
1430
+
1431
+ const effect = Effect.gen(function* () {
1432
+ return yield* config.run(input, ctx) as any
1433
+ }).pipe(
1434
+ Effect.map((result) => decodeSync(config.output, result)),
1435
+ Effect.catch((error) =>
1436
+ Effect.gen(function* () {
1437
+ if (skipsCompensation(error)) {
1438
+ return yield* Effect.fail(error)
1439
+ }
1440
+ for (const compensation of compensations.slice().reverse()) {
1441
+ yield* emitWorkflowEvent({
1442
+ type: "compensation.started",
1443
+ executionId,
1444
+ stepName: compensation.stepName,
1445
+ invocation: compensation.invocation,
1446
+ activityName: `${compensation.stepName}#${compensation.invocation}`,
1447
+ result: compensation.result,
1448
+ input: compensation.input,
1449
+ reason: error
1450
+ })
1451
+ yield* Effect.promise(() =>
1452
+ Promise.resolve(
1453
+ compensation.compensate(compensation.result, compensation.input, error)
1454
+ )
1455
+ ).pipe(
1456
+ Effect.tapError((compensationError) =>
1457
+ emitWorkflowEvent({
1458
+ type: "compensation.failed",
1459
+ executionId,
1460
+ stepName: compensation.stepName,
1461
+ invocation: compensation.invocation,
1462
+ activityName: `${compensation.stepName}#${compensation.invocation}`,
1463
+ error: compensationError
1464
+ })
1465
+ ),
1466
+ Effect.orDie
1467
+ )
1468
+ yield* emitWorkflowEvent({
1469
+ type: "compensation.completed",
1470
+ executionId,
1471
+ stepName: compensation.stepName,
1472
+ invocation: compensation.invocation,
1473
+ activityName: `${compensation.stepName}#${compensation.invocation}`
1474
+ })
1475
+ }
1476
+ return yield* Effect.fail(error)
1477
+ })
1478
+ )
1479
+ )
1480
+
1481
+ const exit = await Effect.runPromiseExit(
1482
+ effect.pipe(
1483
+ Effect.provideService(
1484
+ currentWorkflowEventSink,
1485
+ options.onEvent as any
1486
+ )
1487
+ ) as Effect.Effect<Schema.Schema.Type<Output>, unknown, never>
1488
+ )
1489
+ if (Exit.isSuccess(exit)) {
1490
+ return exit.value
1491
+ }
1492
+ const failure = Option.getOrUndefined(Cause.findErrorOption(exit.cause))
1493
+ throw failure ?? Cause.squash(exit.cause)
1494
+ }
1495
+
1496
+ return {
1497
+ [DefinedWorkflowTypeId]: DefinedWorkflowTypeId,
1498
+ name: config.name,
1499
+ version: config.version,
1500
+ engineName,
1501
+ sourceHash,
1502
+ input: config.input,
1503
+ output: config.output,
1504
+ errors,
1505
+ workflow,
1506
+ layer,
1507
+ execute: (payload) =>
1508
+ workflow.execute(payload as any) as Effect.Effect<
1509
+ Schema.Schema.Type<Output>,
1510
+ Schema.Schema.Type<Errors> | unknown,
1511
+ any
1512
+ >,
1513
+ executeInMemory
1514
+ }
1515
+ }