@yolk-sdk/vercel-workflows 0.1.0-canary.100

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.
@@ -0,0 +1,503 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type {
3
+ VercelWorkflowsSdkClient,
4
+ VercelWorkflowsSdkRun,
5
+ VercelWorkflowReadableOptions,
6
+ VercelWorkflowReadableStream,
7
+ VercelWorkflowRunStatus
8
+ } from '../effect.ts'
9
+
10
+ // Behavioral emulator of the Vercel Workflow platform for tests: run lifecycle,
11
+ // append-only durable streams with close-once -> 409 write conflicts, a step
12
+ // executor honoring `fn.maxRetries` + `getStepMetadata().attempt`, hooks/resume,
13
+ // and cancellation. Host code under test runs unmodified against it; tests mock
14
+ // only the `workflow` module's ambient APIs, delegating to the active world
15
+ // (see `testWorkflowModule`).
16
+ //
17
+ // The single most important semantic: writing to (or re-closing) a closed
18
+ // durable stream fails with the platform's HTTP 409 "already completed"
19
+ // conflict shape. Any code path that performs a terminal stream effect from a
20
+ // retryable step attempt poisons every subsequent platform retry — exactly the
21
+ // #252 production bug. The emulator enforces that invariant permanently.
22
+
23
+ export type TestWorkflowRunStatus = VercelWorkflowRunStatus
24
+
25
+ // Matches the platform's conflict contract: `EntityConflictError.is(value)`
26
+ // checks `value.name === 'EntityConflictError'`, and the Vercel world maps
27
+ // HTTP 409 responses to that class.
28
+ export class TestWorkflowStreamConflictError extends Error {
29
+ override readonly name = 'EntityConflictError'
30
+ readonly status = 409
31
+
32
+ constructor(runId: string) {
33
+ super(`Workflow run "${runId}" stream already completed`)
34
+ }
35
+ }
36
+
37
+ export class TestWorkflowRunConflictError extends Error {
38
+ override readonly name = 'EntityConflictError'
39
+ readonly status = 409
40
+
41
+ constructor(message: string) {
42
+ super(message)
43
+ }
44
+ }
45
+
46
+ export class TestWorkflowHookNotFoundError extends Error {
47
+ override readonly name = 'HookNotFoundError'
48
+
49
+ constructor(token: string) {
50
+ super(`Workflow hook "${token}" was not found`)
51
+ }
52
+ }
53
+
54
+ export class TestWorkflowHookConflictError extends Error {
55
+ override readonly name = 'HookConflictError'
56
+
57
+ constructor(token: string) {
58
+ super(`Workflow hook "${token}" was already resumed`)
59
+ }
60
+ }
61
+
62
+ export class TestWorkflowRunNotFoundError extends Error {
63
+ override readonly name = 'WorkflowRunNotFoundError'
64
+
65
+ constructor(runId: string) {
66
+ super(`Workflow run "${runId}" was not found`)
67
+ }
68
+ }
69
+
70
+ export class TestWorkflowRunCancelledError extends Error {
71
+ override readonly name = 'WorkflowRunCancelledError'
72
+
73
+ constructor(runId: string) {
74
+ super(`Workflow run "${runId}" was cancelled`)
75
+ }
76
+ }
77
+
78
+ export type TestWorkflowMetadata = { readonly workflowRunId: string }
79
+
80
+ export type TestWorkflowStepMetadata = {
81
+ readonly attempt: number
82
+ readonly stepId: string
83
+ readonly stepName: string
84
+ }
85
+
86
+ export type TestWorkflowStepFunction<TArgs extends ReadonlyArray<unknown>, TResult> = ((
87
+ ...args: TArgs
88
+ ) => Promise<TResult>) & { readonly maxRetries?: number }
89
+
90
+ // The platform default: retries after the first attempt; total attempts = maxRetries + 1.
91
+ export const defaultTestWorkflowStepMaxRetries = 3
92
+
93
+ type HookRecord = {
94
+ readonly resolve: (payload: unknown) => void
95
+ readonly reject: (error: TestWorkflowRunCancelledError) => void
96
+ }
97
+
98
+ type TestWorkflowRunRecord = {
99
+ readonly runId: string
100
+ status: TestWorkflowRunStatus
101
+ returnValue: unknown
102
+ runError: unknown
103
+ settled: Promise<void>
104
+ readonly chunks: Array<unknown>
105
+ streamClosed: boolean
106
+ streamCloseCount: number
107
+ writeAfterCloseAttempts: number
108
+ readonly changeListeners: Set<() => void>
109
+ readonly hooks: Map<string, HookRecord>
110
+ readonly resumedHookTokens: Set<string>
111
+ stepSequence: number
112
+ readonly stepAttempts: Map<string, number>
113
+ }
114
+
115
+ type AmbientContext = {
116
+ readonly world: TestWorkflowWorld
117
+ readonly run: TestWorkflowRunRecord
118
+ readonly stepMetadata: TestWorkflowStepMetadata | undefined
119
+ }
120
+
121
+ const ambientStorage = new AsyncLocalStorage<AmbientContext>()
122
+
123
+ const requireAmbient = (): AmbientContext => {
124
+ const ambient = ambientStorage.getStore()
125
+
126
+ if (ambient === undefined) {
127
+ throw new Error('Workflow ambient API called outside a TestWorkflowWorld run context')
128
+ }
129
+
130
+ return ambient
131
+ }
132
+
133
+ export type TestWorkflowRunInspection = {
134
+ readonly runId: string
135
+ readonly status: TestWorkflowRunStatus
136
+ readonly chunks: ReadonlyArray<unknown>
137
+ readonly streamClosed: boolean
138
+ readonly streamCloseCount: number
139
+ readonly writeAfterCloseAttempts: number
140
+ readonly stepAttempts: ReadonlyMap<string, number>
141
+ readonly runError: unknown
142
+ }
143
+
144
+ export type TestWorkflowHook<T> = Promise<T> & { readonly [Symbol.dispose]: () => void }
145
+
146
+ type TestWorkflowWorldStartResult = { readonly runId: string }
147
+
148
+ export class TestWorkflowWorld {
149
+ private readonly runs = new Map<string, TestWorkflowRunRecord>()
150
+ private runSequence = 0
151
+
152
+ start<TArgs extends ReadonlyArray<unknown>, TResult>(
153
+ workflowFn: (...args: TArgs) => Promise<TResult>,
154
+ args: TArgs
155
+ ): TestWorkflowWorldStartResult {
156
+ this.runSequence += 1
157
+ const runId = `twr_${this.runSequence}`
158
+
159
+ const run: TestWorkflowRunRecord = {
160
+ runId,
161
+ status: 'pending',
162
+ returnValue: undefined,
163
+ runError: undefined,
164
+ settled: Promise.resolve(),
165
+ chunks: [],
166
+ streamClosed: false,
167
+ streamCloseCount: 0,
168
+ writeAfterCloseAttempts: 0,
169
+ changeListeners: new Set(),
170
+ hooks: new Map(),
171
+ resumedHookTokens: new Set(),
172
+ stepSequence: 0,
173
+ stepAttempts: new Map()
174
+ }
175
+
176
+ this.runs.set(runId, run)
177
+
178
+ run.settled = ambientStorage.run(
179
+ { world: this, run, stepMetadata: undefined },
180
+ async (): Promise<void> => {
181
+ // Yield once so callers observe `pending` before execution begins,
182
+ // mirroring the platform's enqueue-then-run lifecycle.
183
+ await Promise.resolve()
184
+
185
+ if (run.status === 'cancelled') return
186
+
187
+ run.status = 'running'
188
+
189
+ try {
190
+ run.returnValue = await workflowFn(...args)
191
+
192
+ if (run.status === 'running') run.status = 'completed'
193
+ } catch (error) {
194
+ run.runError = error
195
+
196
+ if (run.status === 'running') run.status = 'failed'
197
+ } finally {
198
+ this.notifyChange(run)
199
+ }
200
+ }
201
+ )
202
+
203
+ return { runId }
204
+ }
205
+
206
+ async settled(runId: string): Promise<void> {
207
+ await this.requireRun(runId).settled
208
+ }
209
+
210
+ inspect(runId: string): TestWorkflowRunInspection {
211
+ const run = this.requireRun(runId)
212
+
213
+ return {
214
+ runId: run.runId,
215
+ status: run.status,
216
+ chunks: [...run.chunks],
217
+ streamClosed: run.streamClosed,
218
+ streamCloseCount: run.streamCloseCount,
219
+ writeAfterCloseAttempts: run.writeAfterCloseAttempts,
220
+ stepAttempts: new Map(run.stepAttempts),
221
+ runError: run.runError
222
+ }
223
+ }
224
+
225
+ status(runId: string): TestWorkflowRunStatus {
226
+ return this.requireRun(runId).status
227
+ }
228
+
229
+ async cancel(runId: string): Promise<void> {
230
+ const run = this.requireRun(runId)
231
+
232
+ if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
233
+ throw new TestWorkflowRunConflictError(
234
+ `Workflow run "${runId}" is already ${run.status} and cannot be cancelled`
235
+ )
236
+ }
237
+
238
+ run.status = 'cancelled'
239
+
240
+ for (const [token, hook] of run.hooks) {
241
+ run.hooks.delete(token)
242
+ hook.reject(new TestWorkflowRunCancelledError(runId))
243
+ }
244
+
245
+ this.notifyChange(run)
246
+ }
247
+
248
+ async resumeHook(token: string, payload: unknown): Promise<void> {
249
+ for (const run of this.runs.values()) {
250
+ const hook = run.hooks.get(token)
251
+
252
+ if (hook !== undefined) {
253
+ run.hooks.delete(token)
254
+ run.resumedHookTokens.add(token)
255
+ hook.resolve(payload)
256
+
257
+ return
258
+ }
259
+
260
+ if (run.resumedHookTokens.has(token)) {
261
+ throw new TestWorkflowHookConflictError(token)
262
+ }
263
+ }
264
+
265
+ throw new TestWorkflowHookNotFoundError(token)
266
+ }
267
+
268
+ hasResumedHook(token: string): boolean {
269
+ for (const run of this.runs.values()) {
270
+ if (run.resumedHookTokens.has(token)) return true
271
+ }
272
+
273
+ return false
274
+ }
275
+
276
+ // --- Step executor (platform retry semantics) ---
277
+
278
+ // Runs a step body with the platform's retry contract: `fn.maxRetries`
279
+ // retries after the first attempt (total attempts = maxRetries + 1) and
280
+ // 1-based `getStepMetadata().attempt` visible to the body.
281
+ async runStep<TArgs extends ReadonlyArray<unknown>, TResult>(
282
+ stepFn: TestWorkflowStepFunction<TArgs, TResult>,
283
+ args: TArgs,
284
+ stepName = stepFn.name === '' ? 'step' : stepFn.name
285
+ ): Promise<TResult> {
286
+ const ambient = requireAmbient()
287
+ const run = ambient.run
288
+ run.stepSequence += 1
289
+ const stepId = `${run.runId}:step:${run.stepSequence}:${stepName}`
290
+ const maxRetries = stepFn.maxRetries ?? defaultTestWorkflowStepMaxRetries
291
+ const maxAttempts = maxRetries + 1
292
+
293
+ let attempt = 1
294
+
295
+ for (;;) {
296
+ run.stepAttempts.set(stepName, (run.stepAttempts.get(stepName) ?? 0) + 1)
297
+
298
+ try {
299
+ return await ambientStorage.run(
300
+ { world: this, run, stepMetadata: { attempt, stepId, stepName } },
301
+ () => stepFn(...args)
302
+ )
303
+ } catch (error) {
304
+ if (attempt >= maxAttempts) throw error
305
+ attempt += 1
306
+ }
307
+ }
308
+ }
309
+
310
+ // Wraps a step function so orchestration code can invoke it exactly the way
311
+ // the platform invokes `use step` functions: through the retry executor.
312
+ step<TArgs extends ReadonlyArray<unknown>, TResult>(
313
+ stepFn: TestWorkflowStepFunction<TArgs, TResult>,
314
+ stepName?: string
315
+ ): (...args: TArgs) => Promise<TResult> {
316
+ return (...args: TArgs) => this.runStep(stepFn, args, stepName)
317
+ }
318
+
319
+ // --- Ambient platform APIs (exposed through `testWorkflowModule`) ---
320
+
321
+ static getWorkflowMetadata(): TestWorkflowMetadata {
322
+ return { workflowRunId: requireAmbient().run.runId }
323
+ }
324
+
325
+ static getStepMetadata(): TestWorkflowStepMetadata {
326
+ const ambient = requireAmbient()
327
+
328
+ if (ambient.stepMetadata === undefined) {
329
+ throw new Error('getStepMetadata called outside a step context')
330
+ }
331
+
332
+ return ambient.stepMetadata
333
+ }
334
+
335
+ static getWritable<T>(): WritableStream<T> {
336
+ const ambient = requireAmbient()
337
+
338
+ return ambient.world.makeWritable(ambient.run)
339
+ }
340
+
341
+ static createHook<T>(input: { readonly token: string }): TestWorkflowHook<T> {
342
+ const ambient = requireAmbient()
343
+
344
+ return ambient.world.makeHook<T>(ambient.run, input.token)
345
+ }
346
+
347
+ // --- Durable streams ---
348
+
349
+ private makeWritable<T>(run: TestWorkflowRunRecord): WritableStream<T> {
350
+ return new WritableStream<T>({
351
+ write: chunk => {
352
+ if (run.streamClosed || run.status === 'cancelled') {
353
+ run.writeAfterCloseAttempts += 1
354
+ throw new TestWorkflowStreamConflictError(run.runId)
355
+ }
356
+
357
+ run.chunks.push(chunk)
358
+ this.notifyChange(run)
359
+ },
360
+ close: () => {
361
+ if (run.streamClosed) {
362
+ run.streamCloseCount += 1
363
+ throw new TestWorkflowStreamConflictError(run.runId)
364
+ }
365
+
366
+ run.streamClosed = true
367
+ run.streamCloseCount += 1
368
+ this.notifyChange(run)
369
+ }
370
+ })
371
+ }
372
+
373
+ private makeHook<T>(run: TestWorkflowRunRecord, token: string): TestWorkflowHook<T> {
374
+ let resolveHook: (payload: unknown) => void = () => {}
375
+
376
+ let rejectHook: (error: TestWorkflowRunCancelledError) => void = () => {}
377
+
378
+ const promise = new Promise<T>((resolve, reject) => {
379
+ // SAFETY: Hook payloads are caller-typed on the platform (`createHook<T>`).
380
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
381
+ resolveHook = payload => resolve(payload as T)
382
+ rejectHook = reject
383
+ })
384
+
385
+ run.hooks.set(token, { resolve: resolveHook, reject: rejectHook })
386
+
387
+ return Object.assign(promise, {
388
+ [Symbol.dispose]: () => {
389
+ run.hooks.delete(token)
390
+ }
391
+ })
392
+ }
393
+
394
+ getReadable<T>(
395
+ runId: string,
396
+ options?: VercelWorkflowReadableOptions
397
+ ): VercelWorkflowReadableStream<T> {
398
+ const run = this.requireRun(runId)
399
+ const requestedStart = options?.startIndex ?? 0
400
+
401
+ let index =
402
+ requestedStart < 0 ? Math.max(0, run.chunks.length + requestedStart) : requestedStart
403
+
404
+ const stream = new ReadableStream<T>({
405
+ pull: async controller => {
406
+ while (index >= run.chunks.length && !this.streamEnded(run)) {
407
+ await this.nextChange(run)
408
+ }
409
+
410
+ if (index < run.chunks.length) {
411
+ // SAFETY: The platform readable is caller-typed (`WorkflowReadableStream<R = any>`);
412
+ // the durable log stores opaque chunks, so this coercion mirrors the
413
+ // platform contract exactly.
414
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
415
+ controller.enqueue(run.chunks[index] as T)
416
+ index += 1
417
+
418
+ return
419
+ }
420
+
421
+ controller.close()
422
+ }
423
+ })
424
+
425
+ return Object.assign(stream, {
426
+ getTailIndex: () => Promise.resolve(run.chunks.length - 1)
427
+ })
428
+ }
429
+
430
+ private streamEnded(run: TestWorkflowRunRecord): boolean {
431
+ return run.streamClosed || run.status === 'cancelled'
432
+ }
433
+
434
+ private notifyChange(run: TestWorkflowRunRecord): void {
435
+ const listeners = [...run.changeListeners]
436
+ run.changeListeners.clear()
437
+
438
+ for (const listener of listeners) listener()
439
+ }
440
+
441
+ private nextChange(run: TestWorkflowRunRecord): Promise<void> {
442
+ return new Promise(resolve => {
443
+ run.changeListeners.add(resolve)
444
+ })
445
+ }
446
+
447
+ private requireRun(runId: string): TestWorkflowRunRecord {
448
+ const run = this.runs.get(runId)
449
+
450
+ if (run === undefined) throw new TestWorkflowRunNotFoundError(runId)
451
+
452
+ return run
453
+ }
454
+
455
+ // --- SDK adapter for `VercelWorkflows.layerFromSdk` ---
456
+
457
+ private sdkRun<TResult>(run: TestWorkflowRunRecord): VercelWorkflowsSdkRun<TResult> {
458
+ const getReadable = <Chunk>(options?: VercelWorkflowReadableOptions) =>
459
+ this.getReadable<Chunk>(run.runId, options)
460
+
461
+ const cancel = () => this.cancel(run.runId)
462
+
463
+ return {
464
+ runId: run.runId,
465
+ getReadable,
466
+ cancel,
467
+ get status() {
468
+ return Promise.resolve(run.status)
469
+ },
470
+ get returnValue() {
471
+ return run.settled.then(() => {
472
+ if (run.status === 'completed') {
473
+ // SAFETY: Return values are caller-typed on the platform SDK as well.
474
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
475
+ return run.returnValue as TResult
476
+ }
477
+
478
+ throw run.runError ?? new TestWorkflowRunCancelledError(run.runId)
479
+ })
480
+ }
481
+ }
482
+ }
483
+
484
+ readonly sdk: VercelWorkflowsSdkClient = {
485
+ start: async (workflowFn, args) => {
486
+ const { runId } = this.start(workflowFn, args)
487
+
488
+ return this.sdkRun(this.requireRun(runId))
489
+ },
490
+ getRun: <TResult>(runId: string) => this.sdkRun<TResult>(this.requireRun(runId)),
491
+ resumeHook: (token, payload) => this.resumeHook(token, payload)
492
+ }
493
+ }
494
+
495
+ // Factory for `vi.mock('workflow', () => testWorkflowModule)`: the mocked
496
+ // module surface delegates every ambient platform API to the active world's
497
+ // AsyncLocalStorage context, so production code under test runs unmodified.
498
+ export const testWorkflowModule = {
499
+ getWorkflowMetadata: () => TestWorkflowWorld.getWorkflowMetadata(),
500
+ getStepMetadata: () => TestWorkflowWorld.getStepMetadata(),
501
+ getWritable: <T>() => TestWorkflowWorld.getWritable<T>(),
502
+ createHook: <T>(input: { readonly token: string }) => TestWorkflowWorld.createHook<T>(input)
503
+ }
@@ -0,0 +1,81 @@
1
+ /** Workflow orchestration only. Callbacks must be durable steps (sleep must be Workflow sleep).
2
+ * A read is short-lived; never put a long returnValue poll inside a step.
3
+ * Both callbacks receive a deterministic zero-based attempt for host budgets/backoff.
4
+ * Defaults remain unbounded: hosts end observation at their ceiling by returning done + value.
5
+ */
6
+ export async function awaitWorkflowChild<A>(input: {
7
+ readonly read: (
8
+ attempt: number
9
+ ) => Promise<{ readonly done: false } | { readonly done: true; readonly value: A }>
10
+ readonly sleep: (attempt: number) => Promise<void>
11
+ }): Promise<A> {
12
+ const { read, sleep } = input
13
+
14
+ for (let attempt = 0; ; attempt++) {
15
+ const state = await read(attempt)
16
+
17
+ if (state.done) return state.value
18
+ await sleep(attempt)
19
+ }
20
+ }
21
+
22
+ /** Preflight fences the entire batch. On rejection, stop dispatching new work, settle already
23
+ * active siblings, and return their ordered progress alongside failures. Hosts must handle failures.
24
+ */
25
+ export async function orchestrateWorkflowToolBatch<Call, Result, Pause>(input: {
26
+ readonly calls: ReadonlyArray<Call>
27
+ readonly concurrency: number
28
+ readonly preflight: () => Promise<
29
+ { readonly ready: true } | { readonly ready: false; readonly value: Pause }
30
+ >
31
+ readonly execute: (call: Call, index: number) => Promise<Result>
32
+ }): Promise<
33
+ | {
34
+ readonly ready: true
35
+ readonly results: ReadonlyArray<Result>
36
+ readonly failures?: ReadonlyArray<{ readonly index: number; readonly error: unknown }>
37
+ }
38
+ | { readonly ready: false; readonly value: Pause }
39
+ > {
40
+ const { calls, concurrency, preflight, execute } = input
41
+ const prepared = await preflight()
42
+
43
+ if (!prepared.ready) return prepared
44
+ const width = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 1
45
+ const entries = calls.map((call, index) => ({ call, index }))
46
+ let next = 0
47
+ const results = new Map<number, Result>()
48
+ const failures: Array<{ readonly index: number; readonly error: unknown }> = []
49
+ await Promise.all(
50
+ Array.from({ length: Math.min(width, calls.length) }, async () => {
51
+ while (failures.length === 0) {
52
+ const entry = entries[next++]
53
+
54
+ if (entry === undefined) return
55
+
56
+ try {
57
+ results.set(entry.index, await execute(entry.call, entry.index))
58
+ } catch (error) {
59
+ failures.push({ index: entry.index, error })
60
+ }
61
+ }
62
+ })
63
+ )
64
+
65
+ type ReadyWorkflowToolBatch = {
66
+ ready: true
67
+ results: Array<Result>
68
+ failures?: Array<{ readonly index: number; readonly error: unknown }>
69
+ }
70
+
71
+ const ready: ReadyWorkflowToolBatch = {
72
+ ready: true,
73
+ results: [...results.entries()].sort(([a], [b]) => a - b).map(([, result]) => result)
74
+ }
75
+
76
+ if (failures.length !== 0) {
77
+ ready.failures = failures.sort((a, b) => a.index - b.index)
78
+ }
79
+
80
+ return ready
81
+ }