ai-workflows 2.1.3 → 2.3.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +8 -1
- package/README.md +2 -0
- package/dist/barrier.d.ts +6 -0
- package/dist/barrier.d.ts.map +1 -1
- package/dist/barrier.js +45 -7
- package/dist/barrier.js.map +1 -1
- package/dist/cascade-context.d.ts.map +1 -1
- package/dist/cascade-context.js +25 -25
- package/dist/cascade-context.js.map +1 -1
- package/dist/cascade-executor.d.ts.map +1 -1
- package/dist/cascade-executor.js +1 -1
- package/dist/cascade-executor.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +23 -7
- package/dist/context.js.map +1 -1
- package/dist/cron-parser.d.ts +65 -0
- package/dist/cron-parser.d.ts.map +1 -0
- package/dist/cron-parser.js +294 -0
- package/dist/cron-parser.js.map +1 -0
- package/dist/cron-scheduler.d.ts +117 -0
- package/dist/cron-scheduler.d.ts.map +1 -0
- package/dist/cron-scheduler.js +176 -0
- package/dist/cron-scheduler.js.map +1 -0
- package/dist/database-context.d.ts +184 -0
- package/dist/database-context.d.ts.map +1 -0
- package/dist/database-context.js +428 -0
- package/dist/database-context.js.map +1 -0
- package/dist/digital-objects-adapter.d.ts +159 -0
- package/dist/digital-objects-adapter.d.ts.map +1 -0
- package/dist/digital-objects-adapter.js +229 -0
- package/dist/digital-objects-adapter.js.map +1 -0
- package/dist/durable-execution-cloudflare.d.ts +427 -0
- package/dist/durable-execution-cloudflare.d.ts.map +1 -0
- package/dist/durable-execution-cloudflare.js +510 -0
- package/dist/durable-execution-cloudflare.js.map +1 -0
- package/dist/durable-execution.d.ts +482 -0
- package/dist/durable-execution.d.ts.map +1 -0
- package/dist/durable-execution.js +594 -0
- package/dist/durable-execution.js.map +1 -0
- package/dist/durable-workflow.d.ts +176 -0
- package/dist/durable-workflow.d.ts.map +1 -0
- package/dist/durable-workflow.js +552 -0
- package/dist/durable-workflow.js.map +1 -0
- package/dist/graph/topological-sort.d.ts.map +1 -1
- package/dist/graph/topological-sort.js +5 -5
- package/dist/graph/topological-sort.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/logger.d.ts +101 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +115 -0
- package/dist/logger.js.map +1 -0
- package/dist/on.d.ts.map +1 -1
- package/dist/on.js +3 -3
- package/dist/on.js.map +1 -1
- package/dist/runtime.d.ts +169 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +275 -0
- package/dist/runtime.js.map +1 -0
- package/dist/send.d.ts.map +1 -1
- package/dist/send.js +4 -3
- package/dist/send.js.map +1 -1
- package/dist/telemetry.d.ts +150 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +388 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/timer-registry.d.ts +25 -0
- package/dist/timer-registry.d.ts.map +1 -1
- package/dist/timer-registry.js +42 -8
- package/dist/timer-registry.js.map +1 -1
- package/dist/types.d.ts +17 -6
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/dist/worker/durable-step.d.ts +481 -0
- package/dist/worker/durable-step.d.ts.map +1 -0
- package/dist/worker/durable-step.js +606 -0
- package/dist/worker/durable-step.js.map +1 -0
- package/dist/worker/index.d.ts +106 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +124 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/state-adapter.d.ts +230 -0
- package/dist/worker/state-adapter.d.ts.map +1 -0
- package/dist/worker/state-adapter.js +409 -0
- package/dist/worker/state-adapter.js.map +1 -0
- package/dist/worker/topological-executor.d.ts +282 -0
- package/dist/worker/topological-executor.d.ts.map +1 -0
- package/dist/worker/topological-executor.js +396 -0
- package/dist/worker/topological-executor.js.map +1 -0
- package/dist/worker/workflow-builder.d.ts +286 -0
- package/dist/worker/workflow-builder.d.ts.map +1 -0
- package/dist/worker/workflow-builder.js +565 -0
- package/dist/worker/workflow-builder.js.map +1 -0
- package/dist/worker.d.ts +800 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +2428 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflow-builder.d.ts +287 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +762 -0
- package/dist/workflow-builder.js.map +1 -0
- package/dist/workflow.d.ts +14 -30
- package/dist/workflow.d.ts.map +1 -1
- package/dist/workflow.js +132 -292
- package/dist/workflow.js.map +1 -1
- package/examples/01-ecommerce-order-pipeline.ts +358 -0
- package/examples/02-content-moderation-cascade.ts +454 -0
- package/examples/03-scheduled-reporting-dependencies.ts +479 -0
- package/examples/04-database-persistence.ts +518 -0
- package/examples/README.md +173 -0
- package/package.json +30 -13
- package/src/__tests__/digital-objects-adapter.test.ts +274 -0
- package/src/__tests__/durable-workflow.test.ts +297 -0
- package/src/barrier.ts +48 -7
- package/src/cascade-context.ts +36 -29
- package/src/cascade-executor.ts +3 -2
- package/src/context.ts +41 -12
- package/src/cron-parser.ts +347 -0
- package/src/cron-scheduler.ts +239 -0
- package/src/database-context.ts +658 -0
- package/src/digital-objects-adapter.ts +351 -0
- package/src/durable-execution-cloudflare.ts +855 -0
- package/src/durable-execution.ts +1042 -0
- package/src/durable-workflow.ts +717 -0
- package/src/graph/topological-sort.ts +6 -8
- package/src/index.ts +69 -0
- package/src/logger.ts +148 -0
- package/src/on.ts +8 -9
- package/src/runtime.ts +436 -0
- package/src/send.ts +4 -5
- package/src/telemetry.ts +577 -0
- package/src/timer-registry.ts +44 -10
- package/src/types.ts +32 -17
- package/src/worker/durable-step.ts +976 -0
- package/src/worker/index.ts +216 -0
- package/src/worker/state-adapter.ts +589 -0
- package/src/worker/topological-executor.ts +625 -0
- package/src/worker/workflow-builder.ts +871 -0
- package/src/worker.ts +2906 -0
- package/src/workflow-builder.ts +1068 -0
- package/src/workflow.ts +188 -351
- package/test/barrier-join.test.ts +32 -24
- package/test/cascade-executor.test.ts +9 -16
- package/test/cron-parser.test.ts +314 -0
- package/test/cron-scheduler.test.ts +291 -0
- package/test/database-context.test.ts +770 -0
- package/test/db-provider-adapter.test.ts +862 -0
- package/test/durable-execution-cloudflare.test.ts +606 -0
- package/test/durable-execution-in-process.test.ts +286 -0
- package/test/durable-execution.test.ts +247 -0
- package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
- package/test/integration.test.ts +442 -0
- package/test/rpc-surface.test.ts +946 -0
- package/test/runtime.test.ts +262 -0
- package/test/schedule-timer-cleanup.test.ts +30 -21
- package/test/send-race-conditions.test.ts +30 -40
- package/test/worker/durable-cascade.test.ts +1117 -0
- package/test/worker/durable-step.test.ts +723 -0
- package/test/worker/topological-executor.test.ts +1240 -0
- package/test/worker/workflow-builder.test.ts +1067 -0
- package/test/worker.test.ts +608 -0
- package/test/workflow-builder.test.ts +1670 -0
- package/test/workflow-cron.test.ts +256 -0
- package/test/workflow-state-adapter.test.ts +923 -0
- package/test/workflow.test.ts +25 -22
- package/tsconfig.json +3 -1
- package/vitest.config.ts +38 -1
- package/vitest.workers.config.ts +44 -0
- package/wrangler.jsonc +22 -0
- package/.turbo/turbo-test.log +0 -169
- package/LICENSE +0 -21
- package/src/context.js +0 -83
- package/src/every.js +0 -267
- package/src/index.js +0 -71
- package/src/on.js +0 -79
- package/src/send.js +0 -111
- package/src/types.js +0 -4
- package/src/workflow.js +0 -455
- package/test/context.test.js +0 -116
- package/test/every.test.js +0 -282
- package/test/on.test.js +0 -80
- package/test/send.test.js +0 -89
- package/test/workflow.test.js +0 -224
- package/vitest.config.js +0 -7
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DurableExecutionAdapter - Port for durable workflow execution backends.
|
|
3
|
+
*
|
|
4
|
+
* Defines a small, backend-agnostic interface that callers (notably
|
|
5
|
+
* `ai-database`'s cascade orchestration in ADR-0003) can program against
|
|
6
|
+
* without knowing which durable execution backend is wired underneath.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this port exists
|
|
9
|
+
*
|
|
10
|
+
* `ai-workflows`'s {@link createWorkflowRuntime} owns the `$` runtime
|
|
11
|
+
* contract — handlers, dispatch, cascade-context, database-context. It runs
|
|
12
|
+
* in-process and lives only as long as the host process. For long-running
|
|
13
|
+
* cascades, scheduled jobs, and orchestration spanning hours-to-days, an
|
|
14
|
+
* external durable execution backend is required.
|
|
15
|
+
*
|
|
16
|
+
* Two production candidates (per ADR-0004):
|
|
17
|
+
*
|
|
18
|
+
* - **Cloudflare Workflows** (default backend; Workers-only). Hibernation
|
|
19
|
+
* while waiting; no per-step billing; 25K steps default; 365-day max
|
|
20
|
+
* sleep. State must flow through step returns — in-memory variables do
|
|
21
|
+
* not survive hibernation.
|
|
22
|
+
*
|
|
23
|
+
* - **Vercel Workflow Development Kit (WDK)** (alternate backend; Vercel
|
|
24
|
+
* or self-hosted). Apache-2.0; pluggable "Worlds"; per-step billing;
|
|
25
|
+
* event-sourced replay; unlimited sleep.
|
|
26
|
+
*
|
|
27
|
+
* Plus an in-process stub (this file's {@link createInMemoryDurableExecution})
|
|
28
|
+
* for tests and local development that does not need durability across
|
|
29
|
+
* process restarts. Phase 1 (bead `aip-fgq5`) will land a richer in-process
|
|
30
|
+
* adapter that bridges this port to the existing
|
|
31
|
+
* {@link createWorkflowRuntime}.
|
|
32
|
+
*
|
|
33
|
+
* The port is modeled after WDK's "Worlds" abstraction — the prior art for
|
|
34
|
+
* exactly this pattern: a small, backend-portable surface for `step`,
|
|
35
|
+
* `sleep`, `waitForEvent`, and scheduling, with each adapter translating to
|
|
36
|
+
* its native primitive (CF's hibernation, WDK's event-sourced replay,
|
|
37
|
+
* in-process's plain async).
|
|
38
|
+
*
|
|
39
|
+
* ## Programming model: Rules of Workflows
|
|
40
|
+
*
|
|
41
|
+
* These rules are universal to durable execution and are not specific to
|
|
42
|
+
* any one backend. They originate in CF's "Rules of Workflows" guidance and
|
|
43
|
+
* apply equally to WDK's deterministic replay model.
|
|
44
|
+
*
|
|
45
|
+
* 1. **Steps must be idempotent.** A step body may be re-executed across
|
|
46
|
+
* a hibernation/restart boundary or after a transient failure. Wrap
|
|
47
|
+
* external side-effects (writes, payments, emails) so a duplicate
|
|
48
|
+
* invocation is observably equivalent to a single invocation.
|
|
49
|
+
*
|
|
50
|
+
* 2. **Step names must be deterministic.** Adapters use the step name as
|
|
51
|
+
* a stable identity for memoization and event-sourced replay. Do not
|
|
52
|
+
* include random ids, timestamps, or run-specific values in the name.
|
|
53
|
+
*
|
|
54
|
+
* 3. **State flows through step returns.** Variables defined inside a
|
|
55
|
+
* workflow body but outside a step are not guaranteed to survive a
|
|
56
|
+
* hibernation boundary. Read inputs at the top of a step; return only
|
|
57
|
+
* what you need; pass results forward through subsequent steps.
|
|
58
|
+
*
|
|
59
|
+
* 4. **Workflow bodies must be deterministic.** Two replays of the same
|
|
60
|
+
* input must take the same control-flow path. Push non-determinism
|
|
61
|
+
* (clocks, randomness, network reads) into steps so the adapter can
|
|
62
|
+
* memoize the result.
|
|
63
|
+
*
|
|
64
|
+
* 5. **Sleeps and waits are first-class.** Use
|
|
65
|
+
* {@link DurableExecutionAdapter.sleep},
|
|
66
|
+
* {@link DurableExecutionAdapter.sleepUntil}, and
|
|
67
|
+
* {@link DurableExecutionAdapter.waitForEvent} rather than `setTimeout`
|
|
68
|
+
* or polling — only the adapter knows how to suspend and resume the
|
|
69
|
+
* workflow correctly.
|
|
70
|
+
*
|
|
71
|
+
* ## Error semantics
|
|
72
|
+
*
|
|
73
|
+
* - {@link DurableStepError} (and its subclasses) are thrown by step
|
|
74
|
+
* execution failures. The {@link DurableStepError.retryable} flag tells
|
|
75
|
+
* the caller whether the adapter considered the failure transient. Real
|
|
76
|
+
* adapters surface their backend's retry decisions through this flag.
|
|
77
|
+
*
|
|
78
|
+
* - {@link WaitForEventTimeoutError} is thrown by
|
|
79
|
+
* {@link DurableExecutionAdapter.waitForEvent} when a timeout elapses
|
|
80
|
+
* without the named event arriving. Callers should catch this explicitly
|
|
81
|
+
* to model "happens within N" branches.
|
|
82
|
+
*
|
|
83
|
+
* - All other thrown errors propagate as-is. Callers must classify
|
|
84
|
+
* non-retryable business errors themselves; adapters do not introspect
|
|
85
|
+
* user errors.
|
|
86
|
+
*
|
|
87
|
+
* ## Subpath export
|
|
88
|
+
*
|
|
89
|
+
* This module ships at the `ai-workflows/durable-execution` subpath so the
|
|
90
|
+
* cascade orchestrator (in `ai-database`) can depend on the port without
|
|
91
|
+
* pulling in the full `ai-workflows` runtime — keeping the static dependency
|
|
92
|
+
* graph one-way (`ai-database` → `ai-workflows/durable-execution`).
|
|
93
|
+
*
|
|
94
|
+
* @example In-memory test usage
|
|
95
|
+
* ```ts
|
|
96
|
+
* import { createInMemoryDurableExecution } from 'ai-workflows/durable-execution'
|
|
97
|
+
*
|
|
98
|
+
* const dx = createInMemoryDurableExecution()
|
|
99
|
+
* const result = await dx.run('charge-customer', async (ctx) => {
|
|
100
|
+
* const charged = await ctx.step('charge-card', async () => {
|
|
101
|
+
* return chargeStripe(ctx.input)
|
|
102
|
+
* })
|
|
103
|
+
* await ctx.sleep('1 second')
|
|
104
|
+
* return { id: charged.id }
|
|
105
|
+
* }, { customerId: 'c-1', amount: 1000 })
|
|
106
|
+
* ```
|
|
107
|
+
*
|
|
108
|
+
* @example Wiring a real adapter (future Phase 1; bead aip-fgq5/aip-i456)
|
|
109
|
+
* ```ts
|
|
110
|
+
* import type { DurableExecutionAdapter } from 'ai-workflows/durable-execution'
|
|
111
|
+
*
|
|
112
|
+
* function makeCascadeRunner(dx: DurableExecutionAdapter) {
|
|
113
|
+
* return (input: CascadeInput) => dx.run('cascade', async (ctx) => {
|
|
114
|
+
* const plan = await ctx.step('plan', () => generatePlan(ctx.input))
|
|
115
|
+
* return ctx.step('write', () => writeAll(plan))
|
|
116
|
+
* }, input)
|
|
117
|
+
* }
|
|
118
|
+
* ```
|
|
119
|
+
*
|
|
120
|
+
* @packageDocumentation
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
import { recordStep } from './cascade-context.js'
|
|
124
|
+
import { getLogger } from './logger.js'
|
|
125
|
+
import type { WorkflowRuntime } from './runtime.js'
|
|
126
|
+
|
|
127
|
+
// =============================================================================
|
|
128
|
+
// Workflow function shape
|
|
129
|
+
// =============================================================================
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Context passed to a {@link WorkflowFn}. Provides the same step / sleep /
|
|
133
|
+
* wait primitives as the top-level {@link DurableExecutionAdapter}, plus the
|
|
134
|
+
* input the workflow was invoked with.
|
|
135
|
+
*
|
|
136
|
+
* The context is bound to one workflow run; calls on it are routed to the
|
|
137
|
+
* adapter that started the run.
|
|
138
|
+
*/
|
|
139
|
+
export interface WorkflowContext<TInput = unknown> {
|
|
140
|
+
/** The input value the workflow was invoked with. */
|
|
141
|
+
readonly input: TInput
|
|
142
|
+
|
|
143
|
+
/** Stable instance id assigned by the adapter at run start. */
|
|
144
|
+
readonly instanceId: string
|
|
145
|
+
|
|
146
|
+
/** Logical workflow name supplied at {@link DurableExecutionAdapter.run}. */
|
|
147
|
+
readonly name: string
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Execute a named, durable step. See
|
|
151
|
+
* {@link DurableExecutionAdapter.step} for semantics.
|
|
152
|
+
*/
|
|
153
|
+
step<T>(name: string, fn: () => Promise<T>): Promise<T>
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Execute a named, durable step with explicit configuration. See
|
|
157
|
+
* {@link DurableExecutionAdapter.step}.
|
|
158
|
+
*/
|
|
159
|
+
step<T>(name: string, config: StepConfig, fn: () => Promise<T>): Promise<T>
|
|
160
|
+
|
|
161
|
+
/** Suspend the workflow for {@link DurableExecutionAdapter.sleep}. */
|
|
162
|
+
sleep(duration: string | number): Promise<void>
|
|
163
|
+
|
|
164
|
+
/** Suspend until {@link DurableExecutionAdapter.sleepUntil}. */
|
|
165
|
+
sleepUntil(date: Date): Promise<void>
|
|
166
|
+
|
|
167
|
+
/** Wait for a named event. See {@link DurableExecutionAdapter.waitForEvent}. */
|
|
168
|
+
waitForEvent<T = unknown>(name: string, timeout?: number | string): Promise<T>
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* A user-authored workflow body.
|
|
173
|
+
*
|
|
174
|
+
* The body must be deterministic across replays — see the "Rules of
|
|
175
|
+
* Workflows" in the module-level docs. The function is invoked once per
|
|
176
|
+
* run-or-replay; the adapter memoizes step results by step name so repeated
|
|
177
|
+
* executions converge on the same outcome.
|
|
178
|
+
*/
|
|
179
|
+
export type WorkflowFn<TResult = unknown, TInput = unknown> = (
|
|
180
|
+
ctx: WorkflowContext<TInput>
|
|
181
|
+
) => Promise<TResult>
|
|
182
|
+
|
|
183
|
+
// =============================================================================
|
|
184
|
+
// Step configuration
|
|
185
|
+
// =============================================================================
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Per-step configuration. Adapters that do not support a given option treat
|
|
189
|
+
* it as advisory. CF Workflows and WDK both support timeout and retries;
|
|
190
|
+
* the in-memory stub honours retries and ignores timeout (since steps are
|
|
191
|
+
* synchronous from the adapter's perspective).
|
|
192
|
+
*/
|
|
193
|
+
export interface StepConfig {
|
|
194
|
+
/** Maximum total attempts including the first. Default: adapter-specific (typically 1). */
|
|
195
|
+
retries?: {
|
|
196
|
+
/** Total attempts (>= 1). */
|
|
197
|
+
limit: number
|
|
198
|
+
/** Delay between attempts. String forms: '1 second', '500ms'. */
|
|
199
|
+
delay?: string | number
|
|
200
|
+
/** Backoff curve. Default: 'constant'. */
|
|
201
|
+
backoff?: 'constant' | 'linear' | 'exponential'
|
|
202
|
+
}
|
|
203
|
+
/** Per-attempt timeout. String or ms. */
|
|
204
|
+
timeout?: string | number
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// =============================================================================
|
|
208
|
+
// Subscription / scheduling
|
|
209
|
+
// =============================================================================
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Returned by {@link DurableExecutionAdapter.schedule}. Calling
|
|
213
|
+
* {@link Subscription.unsubscribe} cancels the schedule; idempotent.
|
|
214
|
+
*/
|
|
215
|
+
export interface Subscription {
|
|
216
|
+
/** Stable id for the schedule (adapter-defined format). */
|
|
217
|
+
readonly id: string
|
|
218
|
+
/** Cancel the schedule. Safe to call more than once. */
|
|
219
|
+
unsubscribe(): void
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// =============================================================================
|
|
223
|
+
// Error types
|
|
224
|
+
// =============================================================================
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Thrown when a step's body fails after all retries (or immediately, when
|
|
228
|
+
* retries are not configured).
|
|
229
|
+
*
|
|
230
|
+
* The {@link DurableStepError.retryable} flag reflects the adapter's
|
|
231
|
+
* judgement about the underlying failure: `true` means the adapter believed
|
|
232
|
+
* the failure was transient (network, rate-limit, server error) and would
|
|
233
|
+
* have retried again given more attempts; `false` means the adapter
|
|
234
|
+
* classified it as terminal (programmer error, invalid input). Callers can
|
|
235
|
+
* use this to decide whether to escalate or fall through to a different
|
|
236
|
+
* code path.
|
|
237
|
+
*/
|
|
238
|
+
export class DurableStepError extends Error {
|
|
239
|
+
/** Step name that failed. */
|
|
240
|
+
readonly stepName: string
|
|
241
|
+
/** Number of attempts the adapter actually made (>= 1). */
|
|
242
|
+
readonly attempts: number
|
|
243
|
+
/** Whether the adapter considered the cause transient. */
|
|
244
|
+
readonly retryable: boolean
|
|
245
|
+
/** The error that ultimately caused the failure. */
|
|
246
|
+
override readonly cause: unknown
|
|
247
|
+
|
|
248
|
+
constructor(
|
|
249
|
+
message: string,
|
|
250
|
+
options: {
|
|
251
|
+
stepName: string
|
|
252
|
+
attempts: number
|
|
253
|
+
retryable: boolean
|
|
254
|
+
cause: unknown
|
|
255
|
+
}
|
|
256
|
+
) {
|
|
257
|
+
super(message)
|
|
258
|
+
this.name = 'DurableStepError'
|
|
259
|
+
this.stepName = options.stepName
|
|
260
|
+
this.attempts = options.attempts
|
|
261
|
+
this.retryable = options.retryable
|
|
262
|
+
this.cause = options.cause
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Thrown when {@link DurableExecutionAdapter.waitForEvent} elapses without
|
|
268
|
+
* the named event arriving.
|
|
269
|
+
*/
|
|
270
|
+
export class WaitForEventTimeoutError extends Error {
|
|
271
|
+
readonly eventName: string
|
|
272
|
+
readonly timeout: number | string
|
|
273
|
+
constructor(eventName: string, timeout: number | string) {
|
|
274
|
+
super(`Timed out waiting for event "${eventName}" after ${String(timeout)}`)
|
|
275
|
+
this.name = 'WaitForEventTimeoutError'
|
|
276
|
+
this.eventName = eventName
|
|
277
|
+
this.timeout = timeout
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// =============================================================================
|
|
282
|
+
// The port
|
|
283
|
+
// =============================================================================
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Concrete adapter kind. Used only as a discriminant for callers that want
|
|
287
|
+
* to log or branch on the active backend; should never be relied on for
|
|
288
|
+
* correctness.
|
|
289
|
+
*/
|
|
290
|
+
export type DurableExecutionKind = 'in-process' | 'cloudflare' | 'vercel-wdk'
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The port. A small, backend-agnostic interface over `run`, `step`, `sleep`,
|
|
294
|
+
* `waitForEvent`, and `schedule`.
|
|
295
|
+
*
|
|
296
|
+
* Real adapters live alongside this file but ship behind feature flags or
|
|
297
|
+
* separate subpath modules so importing the port itself does not pull in
|
|
298
|
+
* any backend dependencies.
|
|
299
|
+
*/
|
|
300
|
+
export interface DurableExecutionAdapter {
|
|
301
|
+
/** Discriminant tag for the active backend. */
|
|
302
|
+
readonly kind: DurableExecutionKind
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Run a workflow body to completion. The adapter assigns the run a
|
|
306
|
+
* stable `instanceId`, persists it (where applicable), and invokes `fn`
|
|
307
|
+
* with a {@link WorkflowContext}.
|
|
308
|
+
*
|
|
309
|
+
* Resolves with the workflow's return value. Rejects with whatever the
|
|
310
|
+
* body or its steps reject with — callers must handle both
|
|
311
|
+
* {@link DurableStepError} and arbitrary user errors.
|
|
312
|
+
*
|
|
313
|
+
* @param name Logical name of the workflow (used for telemetry and, in
|
|
314
|
+
* some backends, for routing). Should be stable across deployments.
|
|
315
|
+
* @param fn The deterministic workflow body. See module docs for rules.
|
|
316
|
+
* @param input Arbitrary input value passed through to `ctx.input`. Must
|
|
317
|
+
* be JSON-serializable for adapters that persist state (CF, WDK).
|
|
318
|
+
*/
|
|
319
|
+
run<TResult = unknown, TInput = unknown>(
|
|
320
|
+
name: string,
|
|
321
|
+
fn: WorkflowFn<TResult, TInput>,
|
|
322
|
+
input: TInput
|
|
323
|
+
): Promise<TResult>
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Execute a named, idempotent step.
|
|
327
|
+
*
|
|
328
|
+
* Step names must be stable across replays — the adapter uses the name
|
|
329
|
+
* as the memoization key. On replay, a step that has already succeeded
|
|
330
|
+
* returns its memoized result without re-invoking `fn`. On a transient
|
|
331
|
+
* failure, the adapter retries per {@link StepConfig.retries} (or its
|
|
332
|
+
* default policy).
|
|
333
|
+
*
|
|
334
|
+
* Outside of `run()` (e.g. when the adapter is called directly without a
|
|
335
|
+
* surrounding workflow body) implementations may either: (a) treat
|
|
336
|
+
* `step` as a thin wrapper that calls `fn` once with no replay, or (b)
|
|
337
|
+
* throw. The in-memory stub picks (a) because it is convenient for tests.
|
|
338
|
+
*
|
|
339
|
+
* Step bodies must be idempotent — see the Rules of Workflows.
|
|
340
|
+
*/
|
|
341
|
+
step<T>(name: string, fn: () => Promise<T>): Promise<T>
|
|
342
|
+
step<T>(name: string, config: StepConfig, fn: () => Promise<T>): Promise<T>
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Suspend execution for a duration. The adapter's backend may hibernate
|
|
346
|
+
* the workflow during the sleep window; in-memory the stub merely awaits.
|
|
347
|
+
*
|
|
348
|
+
* @param duration Number of milliseconds, or a human string accepted by
|
|
349
|
+
* the backend (e.g. `'1 second'`, `'5 minutes'`, `'1 day'`). Adapters
|
|
350
|
+
* that do not understand a string form must throw.
|
|
351
|
+
*/
|
|
352
|
+
sleep(duration: string | number): Promise<void>
|
|
353
|
+
|
|
354
|
+
/** Suspend execution until the given absolute timestamp. */
|
|
355
|
+
sleepUntil(date: Date): Promise<void>
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Wait for an externally-signalled event by name.
|
|
359
|
+
*
|
|
360
|
+
* @param name Event name. Convention: `Noun.event` (matching
|
|
361
|
+
* `ai-workflows`'s dispatch shape) but adapters do not enforce this.
|
|
362
|
+
* @param timeout Optional max wait. Number = milliseconds. String = a
|
|
363
|
+
* backend-recognised duration. When `undefined` the adapter waits
|
|
364
|
+
* forever (or until its hard backend limit, e.g. 365 days for CF).
|
|
365
|
+
*
|
|
366
|
+
* Rejects with {@link WaitForEventTimeoutError} if the timeout elapses.
|
|
367
|
+
*/
|
|
368
|
+
waitForEvent<T = unknown>(name: string, timeout?: number | string): Promise<T>
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Register a workflow body to run on a cron schedule.
|
|
372
|
+
*
|
|
373
|
+
* The schedule itself is durable — the adapter is responsible for firing
|
|
374
|
+
* at the given cron expression even across process restarts. The
|
|
375
|
+
* returned {@link Subscription} cancels the schedule when unsubscribed.
|
|
376
|
+
*
|
|
377
|
+
* Fired runs receive an empty input (`undefined`). To pass per-run data,
|
|
378
|
+
* read it from external state inside the workflow body.
|
|
379
|
+
*/
|
|
380
|
+
schedule<TResult = unknown>(
|
|
381
|
+
name: string,
|
|
382
|
+
cron: string,
|
|
383
|
+
fn: WorkflowFn<TResult, undefined>
|
|
384
|
+
): Subscription
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// =============================================================================
|
|
388
|
+
// In-memory stub for tests
|
|
389
|
+
// =============================================================================
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Options for {@link createInMemoryDurableExecution}.
|
|
393
|
+
*/
|
|
394
|
+
export interface InMemoryDurableExecutionOptions {
|
|
395
|
+
/**
|
|
396
|
+
* Override the clock. Used by tests to control sleep without waiting in
|
|
397
|
+
* real time. When provided, `sleep`/`sleepUntil` resolve as soon as the
|
|
398
|
+
* clock returns a value `>= deadline`.
|
|
399
|
+
*/
|
|
400
|
+
now?: () => number
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Override the sleep primitive. Defaults to a real `setTimeout`. Tests
|
|
404
|
+
* that want to fast-forward time can replace this with a no-op.
|
|
405
|
+
*/
|
|
406
|
+
delay?: (ms: number) => Promise<void>
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Internal: parse human-readable durations like `'1 second'`, `'500ms'`,
|
|
411
|
+
* `'5 minutes'` into milliseconds. The supported grammar is small on
|
|
412
|
+
* purpose; real backends define their own. Numbers pass through.
|
|
413
|
+
*/
|
|
414
|
+
function parseDuration(input: string | number): number {
|
|
415
|
+
if (typeof input === 'number') return input
|
|
416
|
+
const trimmed = input.trim().toLowerCase()
|
|
417
|
+
const match = trimmed.match(
|
|
418
|
+
/^(\d+(?:\.\d+)?)\s*(ms|millisecond|milliseconds|s|sec|second|seconds|m|min|minute|minutes|h|hr|hour|hours|d|day|days)$/
|
|
419
|
+
)
|
|
420
|
+
if (!match) {
|
|
421
|
+
throw new Error(`Unrecognised duration: "${input}"`)
|
|
422
|
+
}
|
|
423
|
+
const value = parseFloat(match[1]!)
|
|
424
|
+
const unit = match[2]!
|
|
425
|
+
switch (unit) {
|
|
426
|
+
case 'ms':
|
|
427
|
+
case 'millisecond':
|
|
428
|
+
case 'milliseconds':
|
|
429
|
+
return value
|
|
430
|
+
case 's':
|
|
431
|
+
case 'sec':
|
|
432
|
+
case 'second':
|
|
433
|
+
case 'seconds':
|
|
434
|
+
return value * 1000
|
|
435
|
+
case 'm':
|
|
436
|
+
case 'min':
|
|
437
|
+
case 'minute':
|
|
438
|
+
case 'minutes':
|
|
439
|
+
return value * 60_000
|
|
440
|
+
case 'h':
|
|
441
|
+
case 'hr':
|
|
442
|
+
case 'hour':
|
|
443
|
+
case 'hours':
|
|
444
|
+
return value * 3_600_000
|
|
445
|
+
case 'd':
|
|
446
|
+
case 'day':
|
|
447
|
+
case 'days':
|
|
448
|
+
return value * 86_400_000
|
|
449
|
+
/* istanbul ignore next */
|
|
450
|
+
default:
|
|
451
|
+
throw new Error(`Unrecognised duration unit: "${unit}"`)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Internal: compute backoff delay for retry attempt n (1-based). */
|
|
456
|
+
function computeBackoff(config: NonNullable<StepConfig['retries']>, attempt: number): number {
|
|
457
|
+
const baseMs = config.delay !== undefined ? parseDuration(config.delay) : 0
|
|
458
|
+
const strategy = config.backoff ?? 'constant'
|
|
459
|
+
switch (strategy) {
|
|
460
|
+
case 'constant':
|
|
461
|
+
return baseMs
|
|
462
|
+
case 'linear':
|
|
463
|
+
return baseMs * attempt
|
|
464
|
+
case 'exponential':
|
|
465
|
+
return baseMs * Math.pow(2, attempt - 1)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Construct an in-memory {@link DurableExecutionAdapter} suitable for tests
|
|
471
|
+
* and validating the port shape. **Not durable** — state lives only in
|
|
472
|
+
* closure for the lifetime of the process.
|
|
473
|
+
*
|
|
474
|
+
* Behaviour:
|
|
475
|
+
*
|
|
476
|
+
* - `run`: invokes the body once with a fresh context. No replay.
|
|
477
|
+
* - `step`: memoizes the result by step name within a single `run`
|
|
478
|
+
* invocation; outside a `run` it executes once and returns. Honours
|
|
479
|
+
* retries (constant/linear/exponential backoff).
|
|
480
|
+
* - `sleep` / `sleepUntil`: awaits real time by default, overridable via
|
|
481
|
+
* {@link InMemoryDurableExecutionOptions.delay}.
|
|
482
|
+
* - `waitForEvent`: pairs with {@link InMemoryDurableExecution.emit} on the
|
|
483
|
+
* returned object. If `timeout` elapses first, rejects with
|
|
484
|
+
* {@link WaitForEventTimeoutError}.
|
|
485
|
+
* - `schedule`: installs a `setInterval` that fires the workflow body each
|
|
486
|
+
* `cron` tick. The cron expression itself is **not** parsed in the stub —
|
|
487
|
+
* it is treated as opaque metadata. Tests that want time-driven schedules
|
|
488
|
+
* should drive emissions manually via the returned helpers.
|
|
489
|
+
*
|
|
490
|
+
* The stub satisfies the full {@link DurableExecutionAdapter} surface so
|
|
491
|
+
* tests can validate the port shape without writing a real adapter.
|
|
492
|
+
*/
|
|
493
|
+
export interface InMemoryDurableExecution extends DurableExecutionAdapter {
|
|
494
|
+
/**
|
|
495
|
+
* Manually deliver an event to the next pending {@link waitForEvent}
|
|
496
|
+
* call with the matching name. Returns `true` if a waiter was resolved,
|
|
497
|
+
* `false` otherwise.
|
|
498
|
+
*/
|
|
499
|
+
emit<T = unknown>(name: string, value: T): boolean
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface PendingWaiter {
|
|
503
|
+
resolve: (value: unknown) => void
|
|
504
|
+
reject: (reason: unknown) => void
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @see InMemoryDurableExecution
|
|
509
|
+
*/
|
|
510
|
+
export function createInMemoryDurableExecution(
|
|
511
|
+
options: InMemoryDurableExecutionOptions = {}
|
|
512
|
+
): InMemoryDurableExecution {
|
|
513
|
+
const now = options.now ?? (() => Date.now())
|
|
514
|
+
const delay =
|
|
515
|
+
options.delay ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
|
|
516
|
+
|
|
517
|
+
// Per-run memoization is keyed by the surrounding run's instanceId. Calls
|
|
518
|
+
// outside of a run get an `undefined` key (single shared bucket).
|
|
519
|
+
const runMemo = new Map<string | undefined, Map<string, unknown>>()
|
|
520
|
+
|
|
521
|
+
// Active event waiters, FIFO per event name.
|
|
522
|
+
const waiters = new Map<string, PendingWaiter[]>()
|
|
523
|
+
|
|
524
|
+
// Active scheduled intervals, by subscription id.
|
|
525
|
+
const schedules = new Map<string, ReturnType<typeof setInterval>>()
|
|
526
|
+
let scheduleSeq = 0
|
|
527
|
+
let runSeq = 0
|
|
528
|
+
|
|
529
|
+
// Tracks the active run so step() can attribute results to the right
|
|
530
|
+
// memo bucket. We only support a single concurrent run for memo purposes;
|
|
531
|
+
// concurrent runs do not share state regardless.
|
|
532
|
+
type RunFrame = { instanceId: string }
|
|
533
|
+
const runStack: RunFrame[] = []
|
|
534
|
+
const currentRun = (): RunFrame | undefined => runStack[runStack.length - 1]
|
|
535
|
+
|
|
536
|
+
async function executeStep<T>(
|
|
537
|
+
name: string,
|
|
538
|
+
config: StepConfig | undefined,
|
|
539
|
+
fn: () => Promise<T>
|
|
540
|
+
): Promise<T> {
|
|
541
|
+
const frame = currentRun()
|
|
542
|
+
const bucketKey = frame?.instanceId
|
|
543
|
+
let bucket = runMemo.get(bucketKey)
|
|
544
|
+
if (!bucket) {
|
|
545
|
+
bucket = new Map()
|
|
546
|
+
runMemo.set(bucketKey, bucket)
|
|
547
|
+
}
|
|
548
|
+
if (bucket.has(name)) {
|
|
549
|
+
return bucket.get(name) as T
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const retries = config?.retries
|
|
553
|
+
const maxAttempts = retries?.limit ?? 1
|
|
554
|
+
let lastErr: unknown
|
|
555
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
556
|
+
try {
|
|
557
|
+
const result = await fn()
|
|
558
|
+
bucket.set(name, result)
|
|
559
|
+
return result
|
|
560
|
+
} catch (err) {
|
|
561
|
+
lastErr = err
|
|
562
|
+
if (attempt < maxAttempts && retries) {
|
|
563
|
+
const wait = computeBackoff(retries, attempt)
|
|
564
|
+
if (wait > 0) await delay(wait)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
throw new DurableStepError(`Step "${name}" failed after ${maxAttempts} attempt(s)`, {
|
|
569
|
+
stepName: name,
|
|
570
|
+
attempts: maxAttempts,
|
|
571
|
+
retryable: true,
|
|
572
|
+
cause: lastErr,
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function sleep(duration: string | number): Promise<void> {
|
|
577
|
+
const ms = parseDuration(duration)
|
|
578
|
+
if (ms > 0) await delay(ms)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function sleepUntil(date: Date): Promise<void> {
|
|
582
|
+
const ms = date.getTime() - now()
|
|
583
|
+
if (ms > 0) await delay(ms)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function waitForEvent<T = unknown>(name: string, timeout?: number | string): Promise<T> {
|
|
587
|
+
return new Promise<T>((resolve, reject) => {
|
|
588
|
+
const queue = waiters.get(name) ?? []
|
|
589
|
+
const waiter: PendingWaiter = {
|
|
590
|
+
resolve: (v) => resolve(v as T),
|
|
591
|
+
reject,
|
|
592
|
+
}
|
|
593
|
+
queue.push(waiter)
|
|
594
|
+
waiters.set(name, queue)
|
|
595
|
+
|
|
596
|
+
if (timeout !== undefined) {
|
|
597
|
+
const ms = parseDuration(timeout)
|
|
598
|
+
const timer = setTimeout(() => {
|
|
599
|
+
const q = waiters.get(name)
|
|
600
|
+
if (!q) return
|
|
601
|
+
const idx = q.indexOf(waiter)
|
|
602
|
+
if (idx >= 0) {
|
|
603
|
+
q.splice(idx, 1)
|
|
604
|
+
if (q.length === 0) waiters.delete(name)
|
|
605
|
+
reject(new WaitForEventTimeoutError(name, timeout))
|
|
606
|
+
}
|
|
607
|
+
}, ms)
|
|
608
|
+
// Don't keep the process alive solely for a wait timeout.
|
|
609
|
+
if (typeof (timer as { unref?: () => void }).unref === 'function') {
|
|
610
|
+
;(timer as { unref?: () => void }).unref!()
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function emit<T = unknown>(name: string, value: T): boolean {
|
|
617
|
+
const queue = waiters.get(name)
|
|
618
|
+
if (!queue || queue.length === 0) return false
|
|
619
|
+
const waiter = queue.shift()!
|
|
620
|
+
if (queue.length === 0) waiters.delete(name)
|
|
621
|
+
waiter.resolve(value)
|
|
622
|
+
return true
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function schedule<TResult = unknown>(
|
|
626
|
+
name: string,
|
|
627
|
+
cron: string,
|
|
628
|
+
fn: WorkflowFn<TResult, undefined>
|
|
629
|
+
): Subscription {
|
|
630
|
+
const id = `sched-${++scheduleSeq}`
|
|
631
|
+
// The stub does not parse cron — it fires every second. Tests that
|
|
632
|
+
// need precise timing should drive runs manually rather than rely on
|
|
633
|
+
// the stub's scheduler. Real adapters honour the cron expression.
|
|
634
|
+
void cron
|
|
635
|
+
void name
|
|
636
|
+
const handle = setInterval(() => {
|
|
637
|
+
void adapter.run(name, fn, undefined).catch(() => {
|
|
638
|
+
// Schedule errors are swallowed; the stub has nowhere to surface
|
|
639
|
+
// them. Real adapters log/persist failures.
|
|
640
|
+
})
|
|
641
|
+
}, 1000)
|
|
642
|
+
if (typeof (handle as unknown as { unref?: () => void }).unref === 'function') {
|
|
643
|
+
;(handle as unknown as { unref: () => void }).unref()
|
|
644
|
+
}
|
|
645
|
+
schedules.set(id, handle)
|
|
646
|
+
return {
|
|
647
|
+
id,
|
|
648
|
+
unsubscribe(): void {
|
|
649
|
+
const h = schedules.get(id)
|
|
650
|
+
if (h) {
|
|
651
|
+
clearInterval(h)
|
|
652
|
+
schedules.delete(id)
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function run<TResult = unknown, TInput = unknown>(
|
|
659
|
+
name: string,
|
|
660
|
+
fn: WorkflowFn<TResult, TInput>,
|
|
661
|
+
input: TInput
|
|
662
|
+
): Promise<TResult> {
|
|
663
|
+
const instanceId = `run-${++runSeq}`
|
|
664
|
+
const frame: RunFrame = { instanceId }
|
|
665
|
+
runStack.push(frame)
|
|
666
|
+
const ctx: WorkflowContext<TInput> = {
|
|
667
|
+
input,
|
|
668
|
+
instanceId,
|
|
669
|
+
name,
|
|
670
|
+
step: ((
|
|
671
|
+
stepName: string,
|
|
672
|
+
configOrFn: StepConfig | (() => Promise<unknown>),
|
|
673
|
+
maybeFn?: () => Promise<unknown>
|
|
674
|
+
) => {
|
|
675
|
+
if (typeof configOrFn === 'function') {
|
|
676
|
+
return executeStep(stepName, undefined, configOrFn) as Promise<unknown>
|
|
677
|
+
}
|
|
678
|
+
return executeStep(stepName, configOrFn, maybeFn!) as Promise<unknown>
|
|
679
|
+
}) as WorkflowContext<TInput>['step'],
|
|
680
|
+
sleep,
|
|
681
|
+
sleepUntil,
|
|
682
|
+
waitForEvent,
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
return await fn(ctx)
|
|
686
|
+
} finally {
|
|
687
|
+
runStack.pop()
|
|
688
|
+
runMemo.delete(instanceId)
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const adapter: InMemoryDurableExecution = {
|
|
693
|
+
kind: 'in-process',
|
|
694
|
+
run,
|
|
695
|
+
step: ((
|
|
696
|
+
name: string,
|
|
697
|
+
configOrFn: StepConfig | (() => Promise<unknown>),
|
|
698
|
+
maybeFn?: () => Promise<unknown>
|
|
699
|
+
) => {
|
|
700
|
+
if (typeof configOrFn === 'function') {
|
|
701
|
+
return executeStep(name, undefined, configOrFn) as Promise<unknown>
|
|
702
|
+
}
|
|
703
|
+
return executeStep(name, configOrFn, maybeFn!) as Promise<unknown>
|
|
704
|
+
}) as DurableExecutionAdapter['step'],
|
|
705
|
+
sleep,
|
|
706
|
+
sleepUntil,
|
|
707
|
+
waitForEvent,
|
|
708
|
+
schedule,
|
|
709
|
+
emit,
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return adapter
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// =============================================================================
|
|
716
|
+
// In-process production adapter (wraps WorkflowRuntime)
|
|
717
|
+
// =============================================================================
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Options for {@link createInProcessDurableExecution}.
|
|
721
|
+
*
|
|
722
|
+
* When a `runtime` is supplied the adapter records each durable run/step into
|
|
723
|
+
* the runtime's cascade context, history, and (when configured) database. When
|
|
724
|
+
* no runtime is supplied the adapter behaves identically to
|
|
725
|
+
* {@link createInMemoryDurableExecution} but uses production logging instead of
|
|
726
|
+
* silently swallowing schedule errors — making it suitable for local dev and
|
|
727
|
+
* single-process callers that want durable-execution semantics without
|
|
728
|
+
* crash-recovery.
|
|
729
|
+
*
|
|
730
|
+
* `now` and `delay` are forwarded to the underlying machinery for tests that
|
|
731
|
+
* need to fast-forward time. Real callers should leave them at the defaults.
|
|
732
|
+
*/
|
|
733
|
+
export interface InProcessDurableExecutionOptions extends InMemoryDurableExecutionOptions {
|
|
734
|
+
/**
|
|
735
|
+
* Optional {@link WorkflowRuntime} to integrate with. When supplied:
|
|
736
|
+
*
|
|
737
|
+
* - Each durable `run` pushes an `event`-typed history entry on start and
|
|
738
|
+
* completion through `runtime.state.history`.
|
|
739
|
+
* - Each durable `step` records a {@link CascadeStep} on
|
|
740
|
+
* `runtime.cascade` via {@link recordStep}; the step is marked
|
|
741
|
+
* completed/failed when the body resolves/rejects.
|
|
742
|
+
* - When `runtime.$.db` is present, each durable step creates an action
|
|
743
|
+
* via `db.createAction({ actor: 'workflow', object: name, action: 'step' })`
|
|
744
|
+
* before invoking the body, and completes (or records failure) on resolution.
|
|
745
|
+
* - The workflow context handed to the body exposes the runtime's `$` as
|
|
746
|
+
* {@link InProcessWorkflowContext.runtime} so handlers can dispatch
|
|
747
|
+
* events through the same registry the rest of the app uses.
|
|
748
|
+
*
|
|
749
|
+
* Pass `null`/omitted to opt out — the adapter still satisfies the port and
|
|
750
|
+
* runs durably in-process, just without the runtime integration.
|
|
751
|
+
*/
|
|
752
|
+
runtime?: WorkflowRuntime
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Workflow context handed to bodies invoked through
|
|
757
|
+
* {@link createInProcessDurableExecution}. Identical to {@link WorkflowContext}
|
|
758
|
+
* but with an optional `runtime` reference exposing the wrapping
|
|
759
|
+
* {@link WorkflowRuntime} (when one was supplied at construction time).
|
|
760
|
+
*
|
|
761
|
+
* `runtime` is `undefined` when the adapter was constructed without a runtime.
|
|
762
|
+
*/
|
|
763
|
+
export interface InProcessWorkflowContext<TInput = unknown> extends WorkflowContext<TInput> {
|
|
764
|
+
/** The `WorkflowRuntime` wrapped by the adapter, if any. */
|
|
765
|
+
readonly runtime?: WorkflowRuntime
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The adapter returned by {@link createInProcessDurableExecution}.
|
|
770
|
+
*
|
|
771
|
+
* Extends {@link DurableExecutionAdapter} with `emit` (for paired
|
|
772
|
+
* `waitForEvent` testing) and exposes the wrapped runtime when one was
|
|
773
|
+
* supplied. The `runtime` reference is the same object passed in at
|
|
774
|
+
* construction; callers can use it to register handlers, inspect history, or
|
|
775
|
+
* read cascade traces emitted during durable runs.
|
|
776
|
+
*/
|
|
777
|
+
export interface InProcessDurableExecution extends DurableExecutionAdapter {
|
|
778
|
+
/**
|
|
779
|
+
* Manually deliver an event to the next pending {@link waitForEvent} call
|
|
780
|
+
* with the matching name. Mirrors {@link InMemoryDurableExecution.emit}.
|
|
781
|
+
*/
|
|
782
|
+
emit<T = unknown>(name: string, value: T): boolean
|
|
783
|
+
/** The runtime wrapped by the adapter, if one was supplied. */
|
|
784
|
+
readonly runtime?: WorkflowRuntime
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Construct the production in-process {@link DurableExecutionAdapter}.
|
|
789
|
+
*
|
|
790
|
+
* Differs from {@link createInMemoryDurableExecution} in three ways:
|
|
791
|
+
*
|
|
792
|
+
* 1. **Runtime integration.** When a {@link WorkflowRuntime} is supplied,
|
|
793
|
+
* each run/step is reflected in the runtime's cascade context, history,
|
|
794
|
+
* and (when `runtime.$.db` is wired) database adapter — making durable
|
|
795
|
+
* runs observable through the same surfaces as event-driven dispatch.
|
|
796
|
+
*
|
|
797
|
+
* 2. **Production-shape logging.** Schedule failures and step failures with
|
|
798
|
+
* a configured runtime are routed through the package logger
|
|
799
|
+
* ({@link getLogger}). The in-memory stub silently swallows these because
|
|
800
|
+
* tests do not want noise in the console.
|
|
801
|
+
*
|
|
802
|
+
* 3. **Workflow context exposes the runtime.** Bodies receive an
|
|
803
|
+
* {@link InProcessWorkflowContext} with `ctx.runtime` set, so durable
|
|
804
|
+
* workflows can dispatch events through the same `$.on`/`$.send`
|
|
805
|
+
* registry the rest of the app uses.
|
|
806
|
+
*
|
|
807
|
+
* The adapter is *not* durable across process restarts — that is the
|
|
808
|
+
* Cloudflare Workflows adapter's job (separate bead `aip-i456`). It exists
|
|
809
|
+
* for test environments, local development, and single-process callers that
|
|
810
|
+
* want the port semantics without a real backend.
|
|
811
|
+
*
|
|
812
|
+
* @example No runtime — equivalent to the in-memory stub but production-shape
|
|
813
|
+
* ```ts
|
|
814
|
+
* const dx = createInProcessDurableExecution()
|
|
815
|
+
* await dx.run('hello', async (ctx) => ctx.input.name, { name: 'world' })
|
|
816
|
+
* ```
|
|
817
|
+
*
|
|
818
|
+
* @example With runtime — durable runs participate in the `$` ecosystem
|
|
819
|
+
* ```ts
|
|
820
|
+
* const runtime = createWorkflowRuntime({ db: myDb })
|
|
821
|
+
* const dx = createInProcessDurableExecution({ runtime })
|
|
822
|
+
*
|
|
823
|
+
* await dx.run('cascade', async (ctx) => {
|
|
824
|
+
* const plan = await ctx.step('plan', async () => generatePlan(ctx.input))
|
|
825
|
+
* // The runtime's $.send delivers to handlers registered on the same runtime.
|
|
826
|
+
* ctx.runtime!.$.send('Plan.generated', plan)
|
|
827
|
+
* return plan
|
|
828
|
+
* }, { customerId: 'c-1' })
|
|
829
|
+
*
|
|
830
|
+
* // Cascade trace, history, and db actions all reflect the run.
|
|
831
|
+
* console.log(runtime.cascade.steps.length) // >= 1 (the 'plan' step)
|
|
832
|
+
* ```
|
|
833
|
+
*/
|
|
834
|
+
export function createInProcessDurableExecution(
|
|
835
|
+
options: InProcessDurableExecutionOptions = {}
|
|
836
|
+
): InProcessDurableExecution {
|
|
837
|
+
// The in-process adapter delegates step/sleep/wait/schedule mechanics to
|
|
838
|
+
// the in-memory factory and wraps run/step with runtime-integration hooks.
|
|
839
|
+
// This keeps the two factories from drifting in their core behaviour and
|
|
840
|
+
// lets the in-memory stub remain the canonical reference for the port's
|
|
841
|
+
// baseline semantics.
|
|
842
|
+
const inner = createInMemoryDurableExecution({
|
|
843
|
+
...(options.now !== undefined && { now: options.now }),
|
|
844
|
+
...(options.delay !== undefined && { delay: options.delay }),
|
|
845
|
+
})
|
|
846
|
+
const runtime = options.runtime
|
|
847
|
+
|
|
848
|
+
function recordRunHistory(
|
|
849
|
+
name: string,
|
|
850
|
+
phase: 'start' | 'finish' | 'error',
|
|
851
|
+
data?: unknown
|
|
852
|
+
): void {
|
|
853
|
+
if (!runtime) return
|
|
854
|
+
runtime.state.history.push({
|
|
855
|
+
timestamp: Date.now(),
|
|
856
|
+
type: 'event',
|
|
857
|
+
name: `durable-run:${phase}:${name}`,
|
|
858
|
+
...(data !== undefined && { data }),
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function instrumentedStep<T>(
|
|
863
|
+
stepName: string,
|
|
864
|
+
config: StepConfig | undefined,
|
|
865
|
+
fn: () => Promise<T>,
|
|
866
|
+
innerStep: DurableExecutionAdapter['step']
|
|
867
|
+
): Promise<T> {
|
|
868
|
+
if (!runtime) {
|
|
869
|
+
// No runtime — defer to the inner mechanics unchanged.
|
|
870
|
+
return config !== undefined ? innerStep<T>(stepName, config, fn) : innerStep<T>(stepName, fn)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const cascadeStep = recordStep(runtime.cascade, stepName)
|
|
874
|
+
const db = runtime.$.db
|
|
875
|
+
let actionRecorded = false
|
|
876
|
+
if (db) {
|
|
877
|
+
try {
|
|
878
|
+
await db.createAction({
|
|
879
|
+
actor: 'workflow',
|
|
880
|
+
object: stepName,
|
|
881
|
+
action: 'step',
|
|
882
|
+
status: 'active',
|
|
883
|
+
})
|
|
884
|
+
actionRecorded = true
|
|
885
|
+
} catch (err) {
|
|
886
|
+
// Do not fail the step because action recording failed; surface the
|
|
887
|
+
// error through the package logger so it is observable.
|
|
888
|
+
getLogger().error(
|
|
889
|
+
`[durable-execution] Failed to record action for step "${stepName}":`,
|
|
890
|
+
err
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const wrapped = async (): Promise<T> => fn()
|
|
896
|
+
try {
|
|
897
|
+
const result = await (config !== undefined
|
|
898
|
+
? innerStep<T>(stepName, config, wrapped)
|
|
899
|
+
: innerStep<T>(stepName, wrapped))
|
|
900
|
+
cascadeStep.complete()
|
|
901
|
+
if (actionRecorded && db) {
|
|
902
|
+
try {
|
|
903
|
+
await db.completeAction(stepName, result as unknown)
|
|
904
|
+
} catch (err) {
|
|
905
|
+
getLogger().error(
|
|
906
|
+
`[durable-execution] Failed to complete action for step "${stepName}":`,
|
|
907
|
+
err
|
|
908
|
+
)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return result
|
|
912
|
+
} catch (err) {
|
|
913
|
+
cascadeStep.fail(err instanceof Error ? err : new Error(String(err)))
|
|
914
|
+
getLogger().error(`[durable-execution] Step "${stepName}" failed:`, err)
|
|
915
|
+
throw err
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function run<TResult = unknown, TInput = unknown>(
|
|
920
|
+
name: string,
|
|
921
|
+
fn: (ctx: InProcessWorkflowContext<TInput>) => Promise<TResult>,
|
|
922
|
+
input: TInput
|
|
923
|
+
): Promise<TResult> {
|
|
924
|
+
recordRunHistory(name, 'start', { input })
|
|
925
|
+
try {
|
|
926
|
+
const result = await inner.run<TResult, TInput>(
|
|
927
|
+
name,
|
|
928
|
+
async (innerCtx) => {
|
|
929
|
+
const ctx: InProcessWorkflowContext<TInput> = {
|
|
930
|
+
input: innerCtx.input,
|
|
931
|
+
instanceId: innerCtx.instanceId,
|
|
932
|
+
name: innerCtx.name,
|
|
933
|
+
sleep: innerCtx.sleep,
|
|
934
|
+
sleepUntil: innerCtx.sleepUntil,
|
|
935
|
+
waitForEvent: innerCtx.waitForEvent,
|
|
936
|
+
step: ((
|
|
937
|
+
stepName: string,
|
|
938
|
+
configOrFn: StepConfig | (() => Promise<unknown>),
|
|
939
|
+
maybeFn?: () => Promise<unknown>
|
|
940
|
+
) => {
|
|
941
|
+
if (typeof configOrFn === 'function') {
|
|
942
|
+
return instrumentedStep(
|
|
943
|
+
stepName,
|
|
944
|
+
undefined,
|
|
945
|
+
configOrFn,
|
|
946
|
+
innerCtx.step
|
|
947
|
+
) as Promise<unknown>
|
|
948
|
+
}
|
|
949
|
+
return instrumentedStep(
|
|
950
|
+
stepName,
|
|
951
|
+
configOrFn,
|
|
952
|
+
maybeFn!,
|
|
953
|
+
innerCtx.step
|
|
954
|
+
) as Promise<unknown>
|
|
955
|
+
}) as InProcessWorkflowContext<TInput>['step'],
|
|
956
|
+
...(runtime !== undefined && { runtime }),
|
|
957
|
+
}
|
|
958
|
+
return fn(ctx)
|
|
959
|
+
},
|
|
960
|
+
input
|
|
961
|
+
)
|
|
962
|
+
recordRunHistory(name, 'finish', { result })
|
|
963
|
+
return result
|
|
964
|
+
} catch (err) {
|
|
965
|
+
recordRunHistory(name, 'error', { error: err instanceof Error ? err.message : String(err) })
|
|
966
|
+
throw err
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function topLevelStep<T>(
|
|
971
|
+
name: string,
|
|
972
|
+
configOrFn: StepConfig | (() => Promise<T>),
|
|
973
|
+
maybeFn?: () => Promise<T>
|
|
974
|
+
): Promise<T> {
|
|
975
|
+
if (typeof configOrFn === 'function') {
|
|
976
|
+
return instrumentedStep(name, undefined, configOrFn, inner.step)
|
|
977
|
+
}
|
|
978
|
+
return instrumentedStep(name, configOrFn, maybeFn!, inner.step)
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function instrumentedSchedule<TResult = unknown>(
|
|
982
|
+
name: string,
|
|
983
|
+
cron: string,
|
|
984
|
+
fn: WorkflowFn<TResult, undefined>
|
|
985
|
+
): Subscription {
|
|
986
|
+
if (!runtime) {
|
|
987
|
+
return inner.schedule(name, cron, fn)
|
|
988
|
+
}
|
|
989
|
+
// When a runtime is wired, surface schedule failures through the logger
|
|
990
|
+
// (the in-memory stub silently swallows them). We do this by wrapping the
|
|
991
|
+
// body to log; the inner schedule itself continues to swallow rejections
|
|
992
|
+
// so the interval keeps firing.
|
|
993
|
+
const wrapped: WorkflowFn<TResult, undefined> = async (ctx) => {
|
|
994
|
+
try {
|
|
995
|
+
return await fn(ctx)
|
|
996
|
+
} catch (err) {
|
|
997
|
+
getLogger().error(`[durable-execution] Scheduled run "${name}" failed:`, err)
|
|
998
|
+
throw err
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return inner.schedule(name, cron, wrapped)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const adapter: InProcessDurableExecution = {
|
|
1005
|
+
kind: 'in-process',
|
|
1006
|
+
run: run as DurableExecutionAdapter['run'],
|
|
1007
|
+
step: topLevelStep as DurableExecutionAdapter['step'],
|
|
1008
|
+
sleep: inner.sleep,
|
|
1009
|
+
sleepUntil: inner.sleepUntil,
|
|
1010
|
+
waitForEvent: inner.waitForEvent,
|
|
1011
|
+
schedule: instrumentedSchedule,
|
|
1012
|
+
emit: inner.emit,
|
|
1013
|
+
...(runtime !== undefined && { runtime }),
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
return adapter
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// =============================================================================
|
|
1020
|
+
// Cloudflare Workflows adapter (re-export)
|
|
1021
|
+
// =============================================================================
|
|
1022
|
+
|
|
1023
|
+
// Re-export the Cloudflare Workflows adapter so callers using the
|
|
1024
|
+
// `ai-workflows/durable-execution` subpath can reach both the port and its CF
|
|
1025
|
+
// implementation through a single import. See `./durable-execution-cloudflare.ts`
|
|
1026
|
+
// for the bridge logic and `docs/adr/0004-durable-execution-cf-workflows-default.md`
|
|
1027
|
+
// for the rationale.
|
|
1028
|
+
export {
|
|
1029
|
+
createCloudflareWorkflowsDurableExecution,
|
|
1030
|
+
createWorkflowEntrypoint,
|
|
1031
|
+
type CloudflareWorkflowsDurableExecution,
|
|
1032
|
+
type CloudflareWorkflowsDurableExecutionOptions,
|
|
1033
|
+
type CloudflareWorkflowsLimits,
|
|
1034
|
+
type WorkflowEnvelope,
|
|
1035
|
+
type WorkflowEntrypointConstructor,
|
|
1036
|
+
type WorkflowEntrypointLike,
|
|
1037
|
+
type WorkflowEventLike,
|
|
1038
|
+
type WorkflowInstanceLike,
|
|
1039
|
+
type WorkflowsBindingLike,
|
|
1040
|
+
type WorkflowStepConfigLike,
|
|
1041
|
+
type WorkflowStepLike,
|
|
1042
|
+
} from './durable-execution-cloudflare.js'
|