@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.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/authoring.d.ts +7 -0
- package/dist/authoring.d.ts.map +1 -0
- package/dist/authoring.js +8 -0
- package/dist/authoring.js.map +1 -0
- package/dist/cli/integrations.d.ts +3 -0
- package/dist/cli/integrations.d.ts.map +1 -0
- package/dist/cli/integrations.js +40 -0
- package/dist/cli/integrations.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +591 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/core.d.ts +198 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +1061 -0
- package/dist/core.js.map +1 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +154 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +24 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/replay.d.ts +3 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +28 -0
- package/dist/replay.js.map +1 -0
- package/dist/runtime.d.ts +70 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +183 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +18 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +19 -0
- package/dist/schema.js.map +1 -0
- package/dist/schemas.d.ts +1198 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +383 -0
- package/dist/schemas.js.map +1 -0
- package/dist/sdk/artifact.d.ts +41 -0
- package/dist/sdk/artifact.d.ts.map +1 -0
- package/dist/sdk/artifact.js +94 -0
- package/dist/sdk/artifact.js.map +1 -0
- package/dist/sdk/graph.d.ts +15 -0
- package/dist/sdk/graph.d.ts.map +1 -0
- package/dist/sdk/graph.js +388 -0
- package/dist/sdk/graph.js.map +1 -0
- package/dist/sdk/index.d.ts +14 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +8 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/integrations.d.ts +18 -0
- package/dist/sdk/integrations.d.ts.map +1 -0
- package/dist/sdk/integrations.js +20 -0
- package/dist/sdk/integrations.js.map +1 -0
- package/dist/sdk/json.d.ts +4 -0
- package/dist/sdk/json.d.ts.map +1 -0
- package/dist/sdk/json.js +31 -0
- package/dist/sdk/json.js.map +1 -0
- package/dist/sdk/loader.d.ts +10 -0
- package/dist/sdk/loader.d.ts.map +1 -0
- package/dist/sdk/loader.js +75 -0
- package/dist/sdk/loader.js.map +1 -0
- package/dist/sdk/sdk.d.ts +102 -0
- package/dist/sdk/sdk.d.ts.map +1 -0
- package/dist/sdk/sdk.js +669 -0
- package/dist/sdk/sdk.js.map +1 -0
- package/dist/sdk/sqlite.d.ts +8 -0
- package/dist/sdk/sqlite.d.ts.map +1 -0
- package/dist/sdk/sqlite.js +288 -0
- package/dist/sdk/sqlite.js.map +1 -0
- package/dist/signal.d.ts +17 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +78 -0
- package/dist/signal.js.map +1 -0
- package/dist/testing/index.d.ts +35 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +244 -0
- package/dist/testing/index.js.map +1 -0
- package/package.json +67 -0
- package/src/authoring.ts +32 -0
- package/src/cli/integrations.ts +75 -0
- package/src/cli/main.ts +793 -0
- package/src/core.ts +1515 -0
- package/src/errors.ts +19 -0
- package/src/events.ts +37 -0
- package/src/index.ts +109 -0
- package/src/replay.ts +29 -0
- package/src/runtime.ts +314 -0
- package/src/schema.ts +34 -0
- package/src/schemas.ts +473 -0
- package/src/sdk/artifact.ts +176 -0
- package/src/sdk/graph.ts +486 -0
- package/src/sdk/index.ts +64 -0
- package/src/sdk/integrations.ts +49 -0
- package/src/sdk/json.ts +40 -0
- package/src/sdk/loader.ts +99 -0
- package/src/sdk/sdk.ts +911 -0
- package/src/sdk/sqlite.ts +405 -0
- package/src/signal.ts +116 -0
- 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
|
+
}
|