From 9b1b8c711104fd507327aafc8cb965738f315e29 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 21 Aug 2026 11:31:42 -0700 Subject: [PATCH 01/10] [core] Pin correlation-id draw order to event-log order (#3700) --- .changeset/log-order-draws.md | 6 + .gitignore | 4 + .../docs/v5/configuration/runtime-tuning.mdx | 10 + .../src/delivery-barrier-coverage.test.ts | 97 +++++- .../src/delivery-barrier-dispenser.test.ts | 12 +- packages/core/src/log-order-draws.test.ts | 319 ++++++++++++++++++ packages/core/src/private.ts | 146 +++++++- packages/core/src/storm-log-replay.test.ts | 12 +- packages/core/src/storm-log-sweep.test.ts | 12 +- .../src/test-support/orchestrator-context.ts | 12 +- packages/core/src/workflow.ts | 16 +- 11 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 .changeset/log-order-draws.md create mode 100644 packages/core/src/log-order-draws.test.ts diff --git a/.changeset/log-order-draws.md b/.changeset/log-order-draws.md new file mode 100644 index 0000000000..e6c5104630 --- /dev/null +++ b/.changeset/log-order-draws.md @@ -0,0 +1,6 @@ +--- +'workflow': minor +'@workflow/core': minor +--- + +Pin correlation-ID draw order to event-log order (Node.js VM engine), so two concurrent replays of the same run assign the same IDs even when one loaded a shorter event-log prefix. Set `WORKFLOW_LOG_ORDER_DRAWS=0` to opt back into arrival-order delivery resolution. diff --git a/.gitignore b/.gitignore index a3f813e31a..f33d334fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ event-log-race-repro-results.json event-log-race-repro-summary.md event-log-race-repro-previous-comment.md event-log-race-repro-server.log + +# Per-run e2e diagnostics sidecars written to the repo root by the harness +# (writeDiagnosticsSidecar in packages/core/e2e/utils.ts) +e2e-diagnostics-*.json diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index c6810d3d57..1b8a8970fb 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -140,6 +140,16 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Delay before a re-invocation caused by a rejected event creation. - Unlike an in-process restart, which re-reads immediately, a re-invocation only happens once the in-process budget failed to catch up — so the delay gives the other writers a moment to quiesce. +### `WORKFLOW_LOG_ORDER_DRAWS` + +- Default: enabled +- Experimental. Pins correlation-ID draw order to event-log order: a branch-deciding delivery (a step result, hook payload, or wait completion) resolves to the workflow only after every earlier-in-log delivery's continuation has fully quiesced, and never ahead of a lower-slot delivery that is committed to happening. +- Without it, a delivery that resolves while an earlier delivery's continuation is still a few microtask hops from its next step/hook/wait call can overtake it on the run's shared correlation-ID sequence. Draw order — and therefore correlation IDs — then depends on how much of the event log a replay had loaded, and two concurrent replays holding different-length prefixes can bind one ID to two different entities, failing the run with `CORRUPTED_EVENT_LOG`. +- Costs one event-loop turn (roughly 15-20 microseconds via `setImmediate`) per branch-deciding delivery during replay, more when continuations genuinely overlap. Measured on a 100-step sequential replay: about 2ms added end to end. +- Only applies to the default Node.js VM engine. `WORKFLOW_VM=quickjs` has its own event feed and correlation-ID sequence and is unaffected by this setting. +- Correlation IDs of runs created before the setting changed are not affected on platforms where a run keeps replaying on the deployment it started on. Elsewhere, only change it while no runs are in flight. +- Set `0` to opt back into arrival-order delivery resolution. Only the literal value `0` opts out; `false` or `off` leave it enabled. + ## Inline execution ### `WORKFLOW_V2_TIMEOUT_MS` diff --git a/packages/core/src/delivery-barrier-coverage.test.ts b/packages/core/src/delivery-barrier-coverage.test.ts index ee113b8ff0..25a2486a2b 100644 --- a/packages/core/src/delivery-barrier-coverage.test.ts +++ b/packages/core/src/delivery-barrier-coverage.test.ts @@ -80,6 +80,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { }); const ulid = monotonicFactory(() => context.globalThis.Math.random()); const workflowStartedAt = context.globalThis.Date.now(); + // Real-session parity: the log-order-draws quiescence fixpoint keys its + // progress metric on `mintCount`; without it the loop degrades to a single + // turn and this suite would only exercise a degraded variant. + let mintCount = 0; const promiseQueueHolder = { current: Promise.resolve() }; const ctxRef: { current?: WorkflowOrchestratorContext } = {}; const ctx: WorkflowOrchestratorContext = { @@ -99,7 +103,13 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { getPromiseQueue: () => promiseQueueHolder.current, }), invocationsQueue: new Map(), - generateUlid: () => ulid(workflowStartedAt), + generateUlid: () => { + mintCount += 1; + return ulid(workflowStartedAt); + }, + get mintCount() { + return mintCount; + }, generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * context.globalThis.Math.random()) ), @@ -831,3 +841,88 @@ describe('suspension timing against parked step deliveries', () => { expectSuspensionSnapshotSteps(error, ['followUp']); }); }); + +// ─── step result above a wait parked behind an unclaimed payload ──────────── +// +// The log-order-draws turnstile (`quiesceEarlierCascades`) refuses to resolve +// a delivery while a LOWER-index ARMED barrier is still registered. An armed +// wait can itself be parked behind an unclaimed buffered hook payload — a +// chain only the idle-gated safety net can move (lowest-first retirement). +// This test pins the termination argument for that shape: the spinning step +// delivery must not count as a parked committed delivery (`resolvesOnItsOwn` +// excludes it — it gates on the parked wait), so `canRetireAbandonedBarriers` +// stays reachable, the net retires the payload, the wait delivers, and the +// turnstile opens. A regression that makes the turnstile wait on parked +// chains directly, or counts the spinner as self-resolving, deadlocks this +// replay instead of suspending it. +describe('log-order draws turnstile above a parked chain', () => { + const scenario = async () => { + const resumeAt = new Date(FIXED_TIMESTAMP + 5_000); + const ops: Promise[] = []; + const [payload, stepAResult] = await Promise.all([ + dehydrateStepReturnValue({ poke: 1 }, 'wrun_test', undefined, ops), + dehydrateStepReturnValue('a', 'wrun_test', undefined, ops), + ]); + + const events: Event[] = [ + event('evnt_0', 'hook_created', `hook_${ULIDS[0]}`, { + token: 'parked-token', + isWebhook: false, + }), + event('evnt_1', 'wait_created', `wait_${ULIDS[1]}`, { resumeAt }), + event('evnt_2', 'step_created', `step_${ULIDS[2]}`, { + stepName: 'stepA', + }), + event('evnt_3', 'step_started', `step_${ULIDS[2]}`, { + stepName: 'stepA', + }), + event('evnt_4', 'hook_received', `hook_${ULIDS[0]}`, { payload }), + event('evnt_5', 'wait_completed', `wait_${ULIDS[1]}`, { resumeAt }), + event('evnt_6', 'step_completed', `step_${ULIDS[2]}`, { + stepName: 'stepA', + result: stepAResult, + }), + event('evnt_7', 'step_created', `step_${ULIDS[3]}`, { + stepName: 'afterBoth', + }), + ]; + + const ctx = setupWorkflowContext(events); + const useStep = createUseStep(ctx); + const sleep = createSleep(ctx); + const createHook = createCreateHook(ctx); + + const error = await replay(ctx, async () => { + const stepA = useStep('stepA'); + const afterBoth = useStep('afterBoth'); + // Fire-and-forget hook: its payload (evnt_4) is consumed but never + // claimed, so its barrier stays unarmed and parks the wait behind it. + createHook({ token: 'parked-token' }); + await Promise.all([sleep('5s'), stepA()]); + await afterBoth(); + }); + + expectSuspendedWithPendingSteps(ctx, error, ['afterBoth']); + }; + + it('terminates and suspends with log-order draws on', async () => { + // Pin the flag rather than inherit the ambient environment: a suite-wide + // WORKFLOW_LOG_ORDER_DRAWS=0 sweep would otherwise silently run the off + // path twice and this test would prove nothing about the turnstile. + vi.stubEnv('WORKFLOW_LOG_ORDER_DRAWS', '1'); + try { + await scenario(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('terminates and suspends with log-order draws off', async () => { + vi.stubEnv('WORKFLOW_LOG_ORDER_DRAWS', '0'); + try { + await scenario(); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/core/src/delivery-barrier-dispenser.test.ts b/packages/core/src/delivery-barrier-dispenser.test.ts index bb714f1d73..b979b178da 100644 --- a/packages/core/src/delivery-barrier-dispenser.test.ts +++ b/packages/core/src/delivery-barrier-dispenser.test.ts @@ -50,6 +50,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { }); const ulid = monotonicFactory(() => context.globalThis.Math.random()); const workflowStartedAt = context.globalThis.Date.now(); + // Real-session parity: the log-order-draws quiescence fixpoint keys its + // progress metric on `mintCount`; without it the loop degrades to a single + // turn and this suite would only exercise a degraded variant. + let mintCount = 0; const promiseQueueHolder = { current: Promise.resolve() }; const ctxRef: { current?: WorkflowOrchestratorContext } = {}; const ctx: WorkflowOrchestratorContext = { @@ -71,7 +75,13 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { getPromiseQueue: () => promiseQueueHolder.current, }), invocationsQueue: new Map(), - generateUlid: () => ulid(workflowStartedAt), + generateUlid: () => { + mintCount += 1; + return ulid(workflowStartedAt); + }, + get mintCount() { + return mintCount; + }, generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * context.globalThis.Math.random()) ), diff --git a/packages/core/src/log-order-draws.test.ts b/packages/core/src/log-order-draws.test.ts new file mode 100644 index 0000000000..1a998e4eb9 --- /dev/null +++ b/packages/core/src/log-order-draws.test.ts @@ -0,0 +1,319 @@ +import type { Event, WorkflowRun } from '@workflow/world'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { WorkflowSuspension } from './global.js'; +import { + dehydrateStepReturnValue, + dehydrateWorkflowArguments, +} from './serialization.js'; +import { runWorkflow } from './workflow.js'; + +/** + * Offline regression coverage for `WORKFLOW_LOG_ORDER_DRAWS=1`: correlation-id + * draw order pinned to event-log order, so draw bindings are stable under + * dense-prefix extension and concurrent replays of different-length prefixes + * mint compatible ids. + * + * The shape is the 2026-08-20 production corruption (five runs on + * 5.0.0-beta.43): a fan-out where each branch launches a step, then races a + * hook against a watchdog sleep. A replay whose dense prefix ends just before + * a sibling branch's launch completion sees that branch parked at its `await` + * minting nothing, so the woken branch's finalize takes the ordinal a fresher + * replay gives the sibling's wait. With draws pinned to log order, the + * finalize is minted inside the cascade of the delivery that enabled it, and + * extending the log can only append draws, never renumber them. + */ + +const RUN_ID = 'wrun_log_order_draws'; + +async function makeRun(): Promise { + const ops: Promise[] = []; + const input = await dehydrateWorkflowArguments([], RUN_ID, undefined, ops); + await Promise.all(ops); + return { + runId: RUN_ID, + workflowName: 'workflow', + status: 'running', + input, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-01T00:00:00.000Z'), + startedAt: new Date('2024-01-01T00:00:00.000Z'), + deploymentId: 'test-deployment', + }; +} + +const TRANSFORM = `;globalThis.__private_workflows = new Map(); + globalThis.__private_workflows.set("workflow", workflow);`; + +/** + * The 2026-08-20 production shape (five corrupted runs on 5.0.0-beta.43): a + * fan-out where each branch launches a step, then races a hook against a + * watchdog sleep, and finalizes when the hook wins. A replay whose dense + * prefix ends just before a sibling branch's launch completion sees that + * branch parked at its `await` — the branch mints nothing, not even its + * watchdog wait — so the woken branch's `finalizeTask` draws the ordinal a + * fresher replay gives the sibling's wait. One correlation id then names both + * a step and a wait, and every later replay fails with an unconsumable + * `step_created` (CORRUPTED_EVENT_LOG). + * + * The two branches' pre-race hops differ on purpose: the woken branch reaches + * its mint through the `Promise.race` resolution (two hops after delivery) + * while the unblocked sibling mints its wait one hop after its own delivery, + * which is how the sibling overtakes it on the shared counter. + */ +const BLOCKED_BRANCH_CODE = ` + const useStep = globalThis[Symbol.for("WORKFLOW_USE_STEP")]; + const createHook = globalThis[Symbol.for("WORKFLOW_CREATE_HOOK")]; + const sleep = globalThis[Symbol.for("WORKFLOW_SLEEP")]; + const launchTask = useStep("launchTask"); + const finalizeTask = useStep("finalizeTask"); + const WATCHDOG = "watchdog"; + async function workflow() { + await Promise.all([0, 1, 2].map(async (task) => { + const hook = createHook({ token: "task-done-" + task }); + await launchTask(task); + const winner = await Promise.race([ + hook, + sleep("1h").then(() => WATCHDOG), + ]); + if (winner !== WATCHDOG) { + await finalizeTask(task); + } + })); + }${TRANSFORM}`; + +/** Replays the blocked-branch workflow and returns every pending entity. */ +async function blockedBranchEntities( + events: Event[] +): Promise<{ type: string; correlationId: string; stepName?: string }[]> { + const run = await makeRun(); + try { + await runWorkflow(BLOCKED_BRANCH_CODE, run, events, undefined); + } catch (error) { + const suspension = error as WorkflowSuspension; + if (suspension.name !== 'WorkflowSuspension') { + throw error; + } + return suspension.steps.map((item) => ({ + type: item.type, + correlationId: item.correlationId, + stepName: (item as { stepName?: string }).stepName, + })); + } + throw new Error('expected the replay to suspend'); +} + +/** + * Builds the two dense prefixes of the production log. The shorter one ends + * before the second branch's launch completion; the longer one appends it. + */ +async function blockedBranchPrefixes(): Promise<{ + shorter: Event[]; + longer: Event[]; +}> { + const initial = await blockedBranchEntities([]); + const hooks = initial.filter((item) => item.type === 'hook'); + const launches = initial.filter((item) => item.stepName === 'launchTask'); + expect(hooks).toHaveLength(3); + expect(launches).toHaveLength(3); + + let at = 0; + const stamp = () => new Date(Date.parse('2024-01-01T00:00:00.000Z') + ++at); + const event = ( + eventType: Event['eventType'], + correlationId: string, + eventData: object + ): Event => ({ + eventId: `event-${at + 1}`, + runId: RUN_ID, + eventType, + correlationId, + eventData: eventData as Event['eventData'], + createdAt: stamp(), + }); + + const ops: Promise[] = []; + const launchResult = await dehydrateStepReturnValue( + 'launched', + RUN_ID, + undefined, + ops + ); + const hookPayload = await dehydrateStepReturnValue( + { done: true }, + RUN_ID, + undefined, + ops + ); + await Promise.all(ops); + + // Mirrors the production slot order: every branch's hook and launch created, + // the first two launches completed, both of their hooks received, and the + // third branch's launch completion as the extension event. + const shorter: Event[] = [ + ...hooks.map((hook, task) => + event('hook_created', hook.correlationId, { + token: `task-done-${task}`, + isWebhook: false, + }) + ), + ...launches.map((launch) => + event('step_created', launch.correlationId, { stepName: 'launchTask' }) + ), + event('step_completed', launches[0]!.correlationId, { + stepName: 'launchTask', + result: launchResult, + }), + event('step_completed', launches[1]!.correlationId, { + stepName: 'launchTask', + result: launchResult, + }), + event('hook_received', hooks[0]!.correlationId, { + payload: hookPayload, + }), + event('hook_received', hooks[1]!.correlationId, { + payload: hookPayload, + }), + ]; + const longer: Event[] = [ + ...shorter, + event('step_completed', launches[2]!.correlationId, { + stepName: 'launchTask', + result: launchResult, + }), + ]; + return { shorter, longer }; +} + +function suspensionBindings( + items: { type: string; correlationId: string; stepName?: string }[] +) { + return items + .map((item) => `${item.correlationId}=${item.type}:${item.stepName ?? ''}`) + .sort(); +} + +describe('arrival-order draws (opt-out, WORKFLOW_LOG_ORDER_DRAWS=0)', () => { + const original = process.env.WORKFLOW_LOG_ORDER_DRAWS; + + beforeEach(() => { + process.env.WORKFLOW_LOG_ORDER_DRAWS = '0'; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.WORKFLOW_LOG_ORDER_DRAWS; + } else { + process.env.WORKFLOW_LOG_ORDER_DRAWS = original; + } + }); + + // Deliberate CONTROL: asserts the arrival-order BUG still reproduces with + // the flag off. If this stops failing-to-bind (i.e. `rebound` comes back + // empty), the control is obsolete, not broken — most likely because + // positional rebinding was fixed independently of draw scheduling, e.g. + // call-site-addressed correlation ids (vercel/workflow#3179) landing, which + // removes the rebinding in BOTH modes. Delete this block then; do not chase + // it as a regression. + it('rebinds an ordinal from a step to a wait under extension (the control)', async () => { + const { shorter, longer } = await blockedBranchPrefixes(); + const stale = await blockedBranchEntities(shorter); + const fresh = await blockedBranchEntities(longer); + const staleFinalizes = stale + .filter((item) => item.stepName === 'finalizeTask') + .map((item) => item.correlationId); + const freshIds = new Set(fresh.map((item) => item.correlationId)); + const rebound = staleFinalizes.filter((id) => !freshIds.has(id)); + expect(rebound.length).toBeGreaterThan(0); + }); +}); + +describe('log-order draws (the default)', () => { + const original = process.env.WORKFLOW_LOG_ORDER_DRAWS; + + beforeEach(() => { + delete process.env.WORKFLOW_LOG_ORDER_DRAWS; + }); + + afterEach(() => { + if (original !== undefined) { + process.env.WORKFLOW_LOG_ORDER_DRAWS = original; + } + }); + + it('keeps every binding of the shorter prefix under extension', async () => { + const { shorter, longer } = await blockedBranchPrefixes(); + const stale = await blockedBranchEntities(shorter); + const fresh = await blockedBranchEntities(longer); + // Every entity the shorter replay would create must exist, under the SAME + // correlation id and kind, in the longer replay: extension appends draws, + // never renumbers them. + // The corruption signature is one correlation id bound to two different + // entities across the two replays. Compare bindings on shared ids: an id + // present in both pending sets must name the same entity. Ids only in the + // shorter set are entities the extension consumed (the sibling's launch); + // ids only in the longer set are the extension's appended draws. + const byId = ( + items: { type: string; correlationId: string; stepName?: string }[] + ) => + new Map( + items.map((item) => [ + item.correlationId, + `${item.type}:${item.stepName ?? ''}`, + ]) + ); + const staleById = byId(stale); + const freshById = byId(fresh); + const rebound: string[] = []; + for (const [id, binding] of staleById) { + const extended = freshById.get(id); + if (extended !== undefined && extended !== binding) { + rebound.push(`${id}: ${binding} -> ${extended}`); + } + } + expect(rebound).toEqual([]); + // And specifically: the woken branches' finalize steps keep their ids. + const staleFinalizes = stale + .filter((item) => item.stepName === 'finalizeTask') + .map((item) => item.correlationId) + .sort(); + const freshFinalizes = fresh + .filter((item) => item.stepName === 'finalizeTask') + .map((item) => item.correlationId) + .sort(); + expect(freshFinalizes).toEqual(staleFinalizes); + }); + + it('is stable across every dense prefix of the log', async () => { + // The pairwise test above targets the production window; this sweeps all + // of them: no shared correlation id may change entity between any two + // consecutive dense prefixes. + const { longer } = await blockedBranchPrefixes(); + let previous: Map | undefined; + for (let length = 1; length <= longer.length; length++) { + const entities = await blockedBranchEntities(longer.slice(0, length)); + const current = new Map( + entities.map((item) => [ + item.correlationId, + `${item.type}:${item.stepName ?? ''}`, + ]) + ); + if (previous) { + for (const [id, binding] of previous) { + const extended = current.get(id); + expect( + extended === undefined || extended === binding, + `prefix ${length}: ${id} rebound ${binding} -> ${extended}` + ).toBe(true); + } + } + previous = current; + } + }); + + it('is deterministic per prefix', async () => { + const { longer } = await blockedBranchPrefixes(); + const a = suspensionBindings(await blockedBranchEntities(longer)); + const b = suspensionBindings(await blockedBranchEntities(longer)); + expect(b).toEqual(a); + }); +}); diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts index 1e13b195b3..2c78ff1685 100644 --- a/packages/core/src/private.ts +++ b/packages/core/src/private.ts @@ -158,6 +158,14 @@ export interface WorkflowOrchestratorContext { * whole run and both replays of a run must draw in the same order. */ generateUlid: () => string; + /** + * Monotone count of correlation-id draws this replay has made. Progress + * metric for {@link quiesceEarlierCascades}: a macrotask turn in which it + * does not move (and no hydration is in flight) means every woken branch has + * run as far as it can without another delivery. Optional so lightweight + * test contexts degrade to the single-yield behavior. + */ + readonly mintCount?: number; generateNanoid: () => string; /** * Sequential promise queue that ensures all event-driven promise resolutions @@ -290,6 +298,11 @@ const DEFER_BEHIND: Record = { * Those two MUST agree exactly, and the doc block on * {@link awaitEarlierDeliveries} stakes deadlock-freedom on it, so the * condition lives here rather than being spelled out twice. + * + * One deliberate exception: the log-order-draws turnstile in + * {@link quiesceEarlierCascades} waits on ANY lower armed entry, a strictly + * wider relation than this one. Why that width cannot deadlock the + * safety-net dispenser is argued at the turnstile itself. */ function gatesOn( kind: DeliveryKind, @@ -431,17 +444,127 @@ function computeResolvesOnItsOwn( * loaded. storm-log-replay.test.ts replays a production log corrupted exactly * that way.) */ +/** + * Whether correlation-id draw order is pinned to event-log order. Default ON: + * only the literal string `WORKFLOW_LOG_ORDER_DRAWS=0` opts out — `=false`, + * `=off`, and every other value keep it enabled. Read per call so tests can + * flip it. + * + * Off, a delivery that had to defer yields ONE macrotask after its + * predecessors resolve — enough for short consumers, but a woken branch whose + * path to its next draw crosses more hops (a `Promise.race` resolution, a + * user-level semaphore, an async-iterator read) can still be overtaken by a + * later-in-log delivery's shorter cascade, so the run's draw order — and + * therefore its correlation ids — depends on how much log this replay loaded. + * On, the yield becomes a fixpoint: the delivery resolves only once every + * earlier cascade has quiesced, making the draw sequence a pure function of + * the dense log, stable under prefix extension, and concurrent writers' + * duplicate creates identical (deduped) instead of colliding. + */ +function isLogOrderDrawsEnabled(): boolean { + return process.env.WORKFLOW_LOG_ORDER_DRAWS !== '0'; +} + +/** + * One quiescence turn: lets the entire pending microtask queue drain, then + * yields to the event loop once. `setImmediate` (check phase) is used where + * available because Node clamps `setTimeout(0)` to ~1ms while `setImmediate` + * costs ~20µs — and every branch-deciding delivery pays this turn at least + * once, so on a sequential replay the clamp is the whole cost. Timers still + * run between consecutive turns (the loop re-enters the event loop each + * iteration, passing through the timers phase), so chains parked on the + * safety-net dispenser's `setTimeout` cadence are not starved. + */ +function quiescenceTurn(delayMs: number): Promise { + if (delayMs === 0 && typeof setImmediate === 'function') { + return new Promise((resolve) => setImmediate(resolve)); + } + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +/** + * Waits until the workflow can make no further progress without another + * delivery: repeated (promise-queue drain + event-loop turn)s until a full + * turn passes with no new ULID draws and no hydration in flight. + * + * Termination: each extra iteration requires a new ULID draw or a hydration + * started in the previous turn. `mintCount` counts EVERY draw from the run's + * sequence — correlation ids and the serialization-driven draws (stream ids + * minted through the `STABLE_ULID` global while dehydrating) — which is + * conservative in the safe direction: serialization draws only extend the + * wait, and both body progress and the serialization work one cascade can + * schedule are finite between deliveries, so the fixpoint is reached. The + * loop holds this delivery's own barrier registered (its `markDelivered` has + * not run), so `isDeliveryIdle` stays false and no suspension can preempt the + * cascade being waited out. + * + * A rejected `promiseQueue` settles immediately and forever, so looping at + * the normal cadence on a failed run would degenerate into a busy loop (the + * same hazard {@link ensureBarrierSafetyNet} documents). Iterations that + * observe the queue rejected back off to a 50ms tick instead. + */ +async function quiesceEarlierCascades( + ctx: WorkflowOrchestratorContext, + eventIndex: number +): Promise { + for (;;) { + const mintsBefore = ctx.mintCount ?? 0; + const pendingBefore = ctx.pendingDeliveries; + // Settled or rejected, the queue snapshot only orders us behind work + // already chained; a rejection is the run failing elsewhere. + const queueRejected = await ctx.promiseQueue.then( + () => false, + () => true + ); + await quiescenceTurn(queueRejected ? 50 : 0); + if ( + (ctx.mintCount ?? 0) !== mintsBefore || + pendingBefore !== 0 || + ctx.pendingDeliveries !== 0 + ) { + continue; + } + // Quiet is not enough on its own: several delivery chains can be sitting + // in this loop at once, and letting the first quiet observation resolve + // would break the tie by timer arrival — the arrival-order dependence this + // mode removes. A lower-index ARMED barrier is a delivery committed to + // happening that has not happened yet, so this one keeps waiting. Unarmed + // entries (buffered payloads nobody has claimed) do not block, exactly as + // in `gatesOn`: their handover is claim-driven, which is body-position + // determined and therefore already a function of the prefix. + // + // This is deliberately a WIDER waits-for relation than `gatesOn` (which, + // e.g., excludes wait→wait): under log-order draws EVERY branch-deciding + // delivery must resolve in log order, kinds included. The width is safe + // against the dispenser deadlock that `resolvesOnItsOwn` guards, because + // the one edge the wider relation adds — waiting on an armed entry that + // gatesOn does not model — always bottoms out at the same unarmed payload: + // a lower armed WAIT is non-self-resolving only when it is (transitively) + // parked behind an unclaimed buffered payload, and a wait gates on every + // lower hook and step directly, so the spinner here also gates on that + // payload through `gatesOn` and is itself reported non-self-resolving. + // The dispenser therefore stays unblocked and retires the chain head; see + // the parked-chain test in delivery-barrier-coverage.test.ts. + let lowerArmed = false; + for (const [index, entry] of ctx.pendingDeliveryBarriers ?? []) { + if (index < eventIndex && entry.armed) { + lowerArmed = true; + break; + } + } + if (!lowerArmed) { + return; + } + } +} + export async function awaitEarlierDeliveries( ctx: WorkflowOrchestratorContext, eventIndex: number | undefined, kind: DeliveryKind ): Promise { // Defensive: tolerate contexts that predate this field (test harnesses). - if ( - eventIndex === undefined || - !ctx.pendingDeliveryBarriers || - ctx.pendingDeliveryBarriers.size === 0 - ) { + if (eventIndex === undefined || !ctx.pendingDeliveryBarriers) { return; } const barriers = ctx.pendingDeliveryBarriers; @@ -454,6 +577,19 @@ export async function awaitEarlierDeliveries( } if (earlier.length > 0) { await Promise.all(earlier); + } + if (isLogOrderDrawsEnabled()) { + // Unconditional, not just when a barrier was still registered: an earlier + // delivery's barrier deregisters when its resolve() runs, but the branch + // it woke may still be hops away from its next draw. A later delivery + // consumed after that deregistration sees an empty gate set, and without + // this it would resolve mid-cascade and overtake the draw — the exact + // arrival-order dependence this mode exists to remove. Costs one quiet + // macrotask turn when nothing is in flight. + await quiesceEarlierCascades(ctx, eventIndex); + return; + } + if (earlier.length > 0) { // An earlier delivery being "delivered" only means its `resolve()` ran. // The branch it woke may need an arbitrary number of further microtask // hops before it reaches its next `useStep` call and draws a ULID — a diff --git a/packages/core/src/storm-log-replay.test.ts b/packages/core/src/storm-log-replay.test.ts index 4e7a14c7d6..94420be277 100644 --- a/packages/core/src/storm-log-replay.test.ts +++ b/packages/core/src/storm-log-replay.test.ts @@ -60,6 +60,10 @@ function setupWorkflowContext( const context = createContext({ seed: SEED, fixedTimestamp: FIXED_TS }); const ulid = monotonicFactory(() => context.globalThis.Math.random()); const workflowStartedAt = context.globalThis.Date.now(); + // Real-session parity: the log-order-draws quiescence fixpoint keys its + // progress metric on `mintCount`; without it the loop degrades to a single + // turn and this suite would only exercise a degraded variant. + let mintCount = 0; const promiseQueueHolder = { current: Promise.resolve() }; const ctxRef: { current?: WorkflowOrchestratorContext } = {}; const ctx: WorkflowOrchestratorContext = { @@ -81,7 +85,13 @@ function setupWorkflowContext( getPromiseQueue: () => promiseQueueHolder.current, }), invocationsQueue: new Map(), - generateUlid: () => ulid(workflowStartedAt), + generateUlid: () => { + mintCount += 1; + return ulid(workflowStartedAt); + }, + get mintCount() { + return mintCount; + }, generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * context.globalThis.Math.random()) ), diff --git a/packages/core/src/storm-log-sweep.test.ts b/packages/core/src/storm-log-sweep.test.ts index 822621e516..b3d3ab81f0 100644 --- a/packages/core/src/storm-log-sweep.test.ts +++ b/packages/core/src/storm-log-sweep.test.ts @@ -36,6 +36,10 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { const context = createContext({ seed: SEED, fixedTimestamp: FIXED_TS }); const ulid = monotonicFactory(() => context.globalThis.Math.random()); const workflowStartedAt = context.globalThis.Date.now(); + // Real-session parity: the log-order-draws quiescence fixpoint keys its + // progress metric on `mintCount`; without it the loop degrades to a single + // turn and this suite would only exercise a degraded variant. + let mintCount = 0; const promiseQueueHolder = { current: Promise.resolve() }; const ctxRef: { current?: WorkflowOrchestratorContext } = {}; const ctx: WorkflowOrchestratorContext = { @@ -57,7 +61,13 @@ function setupWorkflowContext(events: Event[]): WorkflowOrchestratorContext { getPromiseQueue: () => promiseQueueHolder.current, }), invocationsQueue: new Map(), - generateUlid: () => ulid(workflowStartedAt), + generateUlid: () => { + mintCount += 1; + return ulid(workflowStartedAt); + }, + get mintCount() { + return mintCount; + }, generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * context.globalThis.Math.random()) ), diff --git a/packages/core/src/test-support/orchestrator-context.ts b/packages/core/src/test-support/orchestrator-context.ts index 1ae7a78204..0b68b46bb3 100644 --- a/packages/core/src/test-support/orchestrator-context.ts +++ b/packages/core/src/test-support/orchestrator-context.ts @@ -23,6 +23,10 @@ export function setupWorkflowContext( fixedTimestamp: 1753481739458, }); const ulid = monotonicFactory(() => context.globalThis.Math.random()); + // Real-session parity: the log-order-draws quiescence fixpoint keys its + // progress metric on `mintCount`. Without it the loop degrades to a single + // turn and these suites would only exercise a degraded variant. + let mintCount = 0; const workflowStartedAt = context.globalThis.Date.now(); const promiseQueueHolder = { current: Promise.resolve() }; // Forward onUnconsumedEvent through ctx.onWorkflowError so tests that wire @@ -50,7 +54,13 @@ export function setupWorkflowContext( getPromiseQueue: () => promiseQueueHolder.current, }), invocationsQueue: new Map(), - generateUlid: () => ulid(workflowStartedAt), + generateUlid: () => { + mintCount += 1; + return ulid(workflowStartedAt); + }, + get mintCount() { + return mintCount; + }, generateNanoid: nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * context.globalThis.Math.random()) ), diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 98b8728269..5e71b3926d 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -391,7 +391,18 @@ async function createWorkflowSession({ const ulid = monotonicFactory(() => vmGlobalThis.Math.random()); // Correlation IDs must be replay-stable. `startedAt` differs between a turbo // delivery and a later server-backed replay, so use fixedTimestamp. - const generateUlid = () => ulid(fixedTimestamp); + // The draw counter is the progress metric for `quiesceEarlierCascades` + // (WORKFLOW_LOG_ORDER_DRAWS): a quiet turn is one that drew nothing. It + // counts EVERY draw from this sequence — this same function is installed as + // the `STABLE_ULID` global below, which serialization draws stream ids + // from during dehydration — deliberately: quiescence must also wait out + // serialization-driven draws, and counting extra draws only extends the + // wait (see the termination note on `quiesceEarlierCascades`). + let mintCount = 0; + const generateUlid = () => { + mintCount += 1; + return ulid(fixedTimestamp); + }; const generateNanoid = nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * vmGlobalThis.Math.random()) ); @@ -480,6 +491,9 @@ async function createWorkflowSession({ eventsConsumer, generateUlid, generateNanoid, + get mintCount() { + return mintCount; + }, invocationsQueue: new Map(), // Use getter/setter so the EventsConsumer's getPromiseQueue() always // sees the latest queue state as it's mutated by step/hook/sleep callbacks. From d5d19fc8d51cd0ea51ae237a3f3447141c9fb992 Mon Sep 17 00:00:00 2001 From: Rich Haines Date: Fri, 21 Aug 2026 20:43:41 +0200 Subject: [PATCH 02/10] docs: update Geistdocs to 1.20.4 (#3654) --- docs/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/package.json b/docs/package.json index 30903753ed..00d646f190 100644 --- a/docs/package.json +++ b/docs/package.json @@ -28,7 +28,7 @@ "@types/react-dom": "^19.1.9", "@vercel/analytics": "^1.6.1", "@vercel/edge-config": "^1.4.0", - "@vercel/geistdocs": "1.19.6", + "@vercel/geistdocs": "1.20.4", "@vercel/speed-insights": "1.3.1", "@workflow/ai": "workspace:*", "@workflow/cli": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 359d65290f..78d7644f48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,8 +146,8 @@ importers: specifier: ^1.4.0 version: 1.4.0(@opentelemetry/api@1.9.1) '@vercel/geistdocs': - specifier: 1.19.6 - version: 1.19.6(d48d3c3bcf728cafc1b82e46fd1e67c3) + specifier: 1.20.4 + version: 1.20.4(d48d3c3bcf728cafc1b82e46fd1e67c3) '@vercel/speed-insights': specifier: 1.3.1 version: 1.3.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(next@16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vue-router@4.6.3(vue@3.5.35(typescript@6.0.3)))(vue@3.5.35(typescript@6.0.3)) @@ -9673,8 +9673,8 @@ packages: peerDependencies: vue: '>=3.5.18' - '@vercel/agent-readability@0.5.0': - resolution: {integrity: sha512-ZY2i+p2/YCu4kAMGeGCq7Dn22XFWmLwQ1+sa2PrvWdeGCKqcx+CNp+4RF022QI+/e8RgPRjwQurjpmafiZlhTA==} + '@vercel/agent-readability@0.5.1': + resolution: {integrity: sha512-iQih444RJMoAREej/ccTmAUc+PiSogRffqQZMvGdcfF/5GN/4VilXrMHYQWRIq75c70D2AIOxX5sh9EG23ZT3w==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: @@ -9794,8 +9794,8 @@ packages: ws: optional: true - '@vercel/geistdocs@1.19.6': - resolution: {integrity: sha512-R5e5OTnXWTZzYUhuqc0PiLNKmN1FGPeFnin+zzbB83Ca3iGLv9AeZoYKTaf34buwkjpQP/qvKslB3byBy2ct3g==} + '@vercel/geistdocs@1.20.4': + resolution: {integrity: sha512-W+4jWaH+9bgI9dJ5U6A7dkhhTzUURuiLjMzv8hKDre++fSZpDmsBNOkZowz4kFZBFgZgr4w3N8OWhXUIzGnVDA==} hasBin: true peerDependencies: next: ^16.2.11 @@ -26110,7 +26110,7 @@ snapshots: unhead: 2.1.15 vue: 3.5.35(typescript@6.0.3) - '@vercel/agent-readability@0.5.0(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(h3@1.15.11)(next@16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': + '@vercel/agent-readability@0.5.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(h3@1.15.11)(next@16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))': optionalDependencies: '@sveltejs/kit': 2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) '@vercel/functions': 3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0) @@ -26190,7 +26190,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.972.49 ws: 8.20.0 - '@vercel/geistdocs@1.19.6(d48d3c3bcf728cafc1b82e46fd1e67c3)': + '@vercel/geistdocs@1.20.4(d48d3c3bcf728cafc1b82e46fd1e67c3)': dependencies: '@ai-sdk/react': 3.0.221(react@19.2.4)(zod@4.4.3) '@clack/prompts': 0.11.0 @@ -26198,7 +26198,7 @@ snapshots: '@orama/tokenizers': 3.1.18 '@streamdown/cjk': 1.0.2(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(react@19.2.4)(unified@11.0.5) '@streamdown/code': 1.0.2(react@19.2.4) - '@vercel/agent-readability': 0.5.0(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(h3@1.15.11)(next@16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + '@vercel/agent-readability': 0.5.1(@sveltejs/kit@2.69.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)))(@vercel/functions@3.8.0(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.20.0))(h3@1.15.11)(next@16.2.11(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@vercel/oidc': 3.8.2 ai: 6.0.219(zod@4.4.3) class-variance-authority: 0.7.1 From d012bf0fe3b2a1ebdb77c8066b9272ecd23e9523 Mon Sep 17 00:00:00 2001 From: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:59:04 -0700 Subject: [PATCH 03/10] Preserve run-key HTTP errors (#3599) --- .changeset/preserve-run-key-status.md | 5 +++++ packages/world-vercel/src/encryption.test.ts | 5 ++++- packages/world-vercel/src/encryption.ts | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) create mode 100644 .changeset/preserve-run-key-status.md diff --git a/.changeset/preserve-run-key-status.md b/.changeset/preserve-run-key-status.md new file mode 100644 index 0000000000..8839646ab5 --- /dev/null +++ b/.changeset/preserve-run-key-status.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': patch +--- + +Preserve upstream HTTP status codes when fetching Workflow run encryption keys. diff --git a/packages/world-vercel/src/encryption.test.ts b/packages/world-vercel/src/encryption.test.ts index ff3df32976..4f399a6247 100644 --- a/packages/world-vercel/src/encryption.test.ts +++ b/packages/world-vercel/src/encryption.test.ts @@ -139,7 +139,10 @@ describe('fetchRunKey', () => { fetchRunKey(deploymentId, testProjectId, testRunId, { token: 'test-token', }) - ).rejects.toThrow('HTTP 404'); + ).rejects.toMatchObject({ + message: expect.stringContaining('HTTP 404'), + status: 404, + }); }); }); diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 6a31ee5186..5a3d223322 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -18,6 +18,16 @@ import { instrumentedFetch, resolveVercelApiToken } from './http-core.js'; const KEY_BYTES = 32; // 256 bits = 32 bytes (AES-256) +class RunKeyFetchError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'RunKeyFetchError'; + this.status = status; + } +} + /** * Derive a per-run AES-256 encryption key using HKDF-SHA256. * @@ -131,8 +141,9 @@ export async function fetchRunKey( } catch { body = ''; } - return new Error( - `Failed to fetch run key for ${runId} (deployment ${deploymentId}): HTTP ${res.status} ${res.statusText}${body ? ` — ${body}` : ''}` + return new RunKeyFetchError( + `Failed to fetch run key for ${runId} (deployment ${deploymentId}): HTTP ${res.status} ${res.statusText}${body ? ` — ${body}` : ''}`, + res.status ); }, }); From b3dbc6d2643bb3020c1099c3efc611c9292f69f3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 21 Aug 2026 12:45:05 -0700 Subject: [PATCH 04/10] [docs] v5 changes docs: what's new, world upgrade guide, migration skills (#3100) --- .changeset/docs-v5-whats-new-pages.md | 2 + AGENTS.md | 2 +- .../[lang]/docs/{ => [[...slug]]}/layout.tsx | 11 +- .../v5/docs/{ => [[...slug]]}/layout.tsx | 11 +- .../v5/worlds/building-a-world/page.tsx | 10 +- .../[lang]/v5/worlds/upgrading-to-v5/page.tsx | 13 + .../[lang]/worlds/building-a-world/page.tsx | 10 +- docs/components/geistdocs/docs-layout.tsx | 40 ++- ...a-world-page.tsx => worlds-guide-page.tsx} | 24 +- docs/content/docs/v4/deploying.mdx | 2 +- docs/content/docs/v4/getting-started/next.mdx | 2 +- docs/content/docs/v5/ai/defining-tools.mdx | 1 - docs/content/docs/v5/ai/meta.json | 1 + .../v5/api-reference/workflow-api/start.mdx | 1 + .../precondition-failed-error.mdx | 2 +- .../v5/api-reference/workflow-globals.mdx | 1 + .../workflow-runtime/create-world.mdx | 6 +- .../workflow-runtime/set-world.mdx | 22 +- .../workflow-runtime/world/index.mdx | 2 + .../workflow-runtime/world/queue.mdx | 4 +- .../workflow-runtime/world/storage.mdx | 2 +- .../v5/changelog/step-message-ownership.mdx | 4 +- .../workflow-sdk-vs-aws-step-functions.mdx | 8 +- .../workflow-sdk-vs-cloudflare-workflows.mdx | 4 +- .../comparisons/workflow-sdk-vs-inngest.mdx | 4 +- .../comparisons/workflow-sdk-vs-temporal.mdx | 8 +- .../workflow-sdk-vs-trigger-dev.mdx | 3 +- .../docs/v5/configuration/runtime-tuning.mdx | 22 +- docs/content/docs/v5/configuration/worlds.mdx | 12 + docs/content/docs/v5/deploying.mdx | 2 +- .../webhook-invalid-respond-with-value.mdx | 14 +- .../v5/errors/webhook-response-not-sent.mdx | 14 +- .../docs/v5/foundations/cancellation.mdx | 2 +- .../v5/foundations/errors-and-retries.mdx | 10 +- .../v5/foundations/starting-workflows.mdx | 1 + .../content/docs/v5/foundations/streaming.mdx | 6 +- .../v5/foundations/workflows-and-steps.mdx | 2 +- .../docs/v5/getting-started/nestjs.mdx | 2 +- docs/content/docs/v5/getting-started/next.mdx | 2 +- .../docs/v5/getting-started/python.mdx | 2 +- .../v5/getting-started/react-router/v7.mdx | 2 +- .../v5/getting-started/react-router/v8.mdx | 2 +- .../docs/v5/how-it-works/code-transform.mdx | 78 +++-- .../docs/v5/how-it-works/encryption.mdx | 4 + .../how-it-works/framework-integrations.mdx | 6 +- .../how-it-works/understanding-directives.mdx | 2 +- docs/content/docs/v5/internal/index.mdx | 2 +- .../content/docs/v5/internal/nitro-web-ui.mdx | 2 +- docs/content/docs/v5/observability/index.mdx | 4 + .../content/docs/v5/observability/tracing.mdx | 2 +- docs/content/docs/v5/testing/server-based.mdx | 2 +- docs/content/docs/v5/whats-new.mdx | 185 ++++++++++++ docs/content/worlds/v4/vercel.mdx | 4 +- docs/content/worlds/v5/building-a-world.mdx | 77 ++++- docs/content/worlds/v5/meta.json | 8 +- docs/content/worlds/v5/postgres.mdx | 10 +- docs/content/worlds/v5/upgrading-to-v5.mdx | 159 ++++++++++ docs/content/worlds/v5/vercel.mdx | 8 +- docs/scripts/check-docs-smoke.mjs | 4 + packages/docs-typecheck/src/type-checker.ts | 3 + skills/migrating-workflow-v4-to-v5/SKILL.md | 285 ++++++++++++++++++ skills/migrating-world-v4-to-v5/SKILL.md | 218 ++++++++++++++ 62 files changed, 1216 insertions(+), 142 deletions(-) create mode 100644 .changeset/docs-v5-whats-new-pages.md rename docs/app/[lang]/docs/{ => [[...slug]]}/layout.tsx (56%) rename docs/app/[lang]/v5/docs/{ => [[...slug]]}/layout.tsx (63%) create mode 100644 docs/app/[lang]/v5/worlds/upgrading-to-v5/page.tsx rename docs/components/worlds/{building-a-world-page.tsx => worlds-guide-page.tsx} (87%) create mode 100644 docs/content/docs/v5/whats-new.mdx create mode 100644 docs/content/worlds/v5/upgrading-to-v5.mdx create mode 100644 skills/migrating-workflow-v4-to-v5/SKILL.md create mode 100644 skills/migrating-world-v4-to-v5/SKILL.md diff --git a/.changeset/docs-v5-whats-new-pages.md b/.changeset/docs-v5-whats-new-pages.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/docs-v5-whats-new-pages.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/AGENTS.md b/AGENTS.md index 1119e1ed8e..1a1d564191 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -333,7 +333,7 @@ Linting, formatting, and typechecking (`pnpm lint`, `pnpm format`, `pnpm typeche When a PR adds or updates docs pages (anything under `docs/content/`), add a "Docs Preview" section to the PR description with direct links to each changed page on the `workflow-docs` preview deployment: - Get the preview base URL from the `vercel[bot]` comment on the PR — use the Preview link from the `workflow-docs` project row (e.g. `https://workflow-docs-git-.vercel.sh`). Don't construct the URL by hand; Vercel's branch-slug normalization is not a simple substitution. -- Map content paths to routes: `docs/content/docs/v4/.mdx` is served at `/docs/` (v4 is the default/latest version) and `docs/content/docs/v5/.mdx` at `/v5/docs/`. +- Map content paths to routes: `docs/content/docs/v5/.mdx` is served at `/docs/` (v5 is the default/latest version) and `docs/content/docs/v4/.mdx` at `/v4/docs/` (v4 is the maintenance version). - When a change is scoped to a specific section of a page, link to its heading anchor (e.g. `/docs/foundations/hooks#checking-for-token-conflicts`) and verify the anchor matches a real heading in the MDX. - A simple table with one row per page (and one column per docs version, when both v4 and v5 were updated) works well. - The preview deployment sits behind deployment protection, so the links require Vercel team access — that's expected; include them anyway for reviewers. diff --git a/docs/app/[lang]/docs/layout.tsx b/docs/app/[lang]/docs/[[...slug]]/layout.tsx similarity index 56% rename from docs/app/[lang]/docs/layout.tsx rename to docs/app/[lang]/docs/[[...slug]]/layout.tsx index e5754b4c0a..fc829553a0 100644 --- a/docs/app/[lang]/docs/layout.tsx +++ b/docs/app/[lang]/docs/[[...slug]]/layout.tsx @@ -2,12 +2,19 @@ import { DocsLayout } from '@/components/geistdocs/docs-layout'; import { getDocsTreeForVersion } from '@/lib/geistdocs/version-source'; import { LATEST_VERSION } from '@/lib/geistdocs/versions'; -const Layout = async ({ children, params }: LayoutProps<'/[lang]/docs'>) => { - const { lang } = await params; +// This layout lives inside `[[...slug]]` rather than next to it so that +// `params.slug` is available: the sidebar needs the active page to decide +// whether to drill into a section. See `DocsLayout`. +const Layout = async ({ + children, + params, +}: LayoutProps<'/[lang]/docs/[[...slug]]'>) => { + const { lang, slug } = await params; return (
) => { - const { lang } = await params; +// This layout lives inside `[[...slug]]` rather than next to it so that +// `params.slug` is available: the sidebar needs the active page to decide +// whether to drill into a section. See `DocsLayout`. +const Layout = async ({ + children, + params, +}: LayoutProps<'/[lang]/v5/docs/[[...slug]]'>) => { + const { lang, slug } = await params; return (
{ - return generateBuildingAWorldMetadata('v5'); + return generateWorldsGuideMetadata('building-a-world', 'v5'); } export default function Page() { - return ; + return ; } diff --git a/docs/app/[lang]/v5/worlds/upgrading-to-v5/page.tsx b/docs/app/[lang]/v5/worlds/upgrading-to-v5/page.tsx new file mode 100644 index 0000000000..dddb5546c7 --- /dev/null +++ b/docs/app/[lang]/v5/worlds/upgrading-to-v5/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; +import { + generateWorldsGuideMetadata, + WorldsGuidePage, +} from '@/components/worlds/worlds-guide-page'; + +export function generateMetadata(): Promise { + return generateWorldsGuideMetadata('upgrading-to-v5', 'v5'); +} + +export default function Page() { + return ; +} diff --git a/docs/app/[lang]/worlds/building-a-world/page.tsx b/docs/app/[lang]/worlds/building-a-world/page.tsx index 4753b26b5e..c078387c20 100644 --- a/docs/app/[lang]/worlds/building-a-world/page.tsx +++ b/docs/app/[lang]/worlds/building-a-world/page.tsx @@ -1,13 +1,13 @@ import type { Metadata } from 'next'; import { - BuildingAWorldPage, - generateBuildingAWorldMetadata, -} from '@/components/worlds/building-a-world-page'; + generateWorldsGuideMetadata, + WorldsGuidePage, +} from '@/components/worlds/worlds-guide-page'; export function generateMetadata(): Promise { - return generateBuildingAWorldMetadata('v4'); + return generateWorldsGuideMetadata('building-a-world', 'v4'); } export default function Page() { - return ; + return ; } diff --git a/docs/components/geistdocs/docs-layout.tsx b/docs/components/geistdocs/docs-layout.tsx index 5c68b159bf..bcd1c85530 100644 --- a/docs/components/geistdocs/docs-layout.tsx +++ b/docs/components/geistdocs/docs-layout.tsx @@ -88,12 +88,49 @@ const withFallbackFolderIndex = (nodes: DocsTreeNode[]): DocsTreeNode[] => }; }); +// `/docs` permanently redirects here, so this is the page every bare link to +// the documentation lands on. +const DOCS_HOME_SECTION = 'getting-started'; + +/** + * geistdocs' sidebar has two panes: the top-level menu, and a section pane it + * drills into for the first root folder containing the active page. On the docs + * home that drill-in is unhelpful — arriving from a `/docs` link would replace + * the top-level menu with the framework list, hiding the rest of the docs. + * + * `findActiveRootSection` only matches folders that have children, so emptying + * the section's children on its own landing page keeps the root menu visible + * with the row highlighted as the current page. Nothing is lost: the page body + * is a card grid of exactly those children, and every other page in the section + * still drills in normally. + */ +const collapseDocsHomeSection = ( + tree: DocsTree, + activeSlug?: string[] +): DocsTree => { + if (activeSlug?.join('/') !== DOCS_HOME_SECTION) { + return tree; + } + + return { + ...tree, + children: tree.children.map((node) => + node.type === 'folder' && + node.index?.url.endsWith(`/docs/${DOCS_HOME_SECTION}`) + ? { ...node, children: [] } + : node + ), + }; +}; + const addSidebarBadgesToTree = (tree: DocsTree): DocsTree => ({ ...tree, children: addSidebarBadges(withFallbackFolderIndex(tree.children)), }); interface DocsLayoutProps { + /** Slug of the active page, used to tune sidebar drill-in behavior. */ + activeSlug?: string[]; children: ReactNode; currentVersion?: string; lang: string; @@ -101,6 +138,7 @@ interface DocsLayoutProps { } export const DocsLayout = ({ + activeSlug, tree, currentVersion = config.versions?.current, lang, @@ -123,7 +161,7 @@ export const DocsLayout = ({ /> ) : null } - tree={addSidebarBadgesToTree(tree)} + tree={collapseDocsHomeSection(addSidebarBadgesToTree(tree), activeSlug)} > {children} diff --git a/docs/components/worlds/building-a-world-page.tsx b/docs/components/worlds/worlds-guide-page.tsx similarity index 87% rename from docs/components/worlds/building-a-world-page.tsx rename to docs/components/worlds/worlds-guide-page.tsx index 6f8b44bc58..79398496df 100644 --- a/docs/components/worlds/building-a-world-page.tsx +++ b/docs/components/worlds/worlds-guide-page.tsx @@ -10,8 +10,6 @@ import type { DocsVersionId } from '@/lib/geistdocs/versions'; import { WorldDetailToc } from './WorldDetailToc'; import { WorldVersionSelect } from './WorldVersionSelect'; -const PAGE_SLUGS = ['building-a-world']; - const VERSION_SOURCES = { v4: worldsSource, v5: v5WorldsSource, @@ -22,13 +20,19 @@ const VERSION_PREFIXES = { v5: '/v5', } as const; -export async function generateBuildingAWorldMetadata( +/** + * Standalone guide pages in the worlds tree — the ones that are not a world + * detail page. They render outside the docs sidebar, so each is a bespoke + * route passing its own slug. + */ +export async function generateWorldsGuideMetadata( + slug: string, version: DocsVersionId ): Promise { - const page = VERSION_SOURCES[version].getPage(PAGE_SLUGS); + const page = VERSION_SOURCES[version].getPage([slug]); if (!page) { - return { title: 'Building a World | Workflow SDK' }; + return { title: 'Worlds | Workflow SDK' }; } const versionPrefix = VERSION_PREFIXES[version]; @@ -41,9 +45,9 @@ export async function generateBuildingAWorldMetadata( images: ['/og/worlds'], }, alternates: { - canonical: '/worlds/building-a-world', + canonical: `/worlds/${slug}`, types: { - 'text/markdown': `${versionPrefix}/worlds/building-a-world.md`, + 'text/markdown': `${versionPrefix}/worlds/${slug}.md`, }, }, ...(isPreRelease @@ -57,14 +61,16 @@ export async function generateBuildingAWorldMetadata( }; } -export async function BuildingAWorldPage({ +export async function WorldsGuidePage({ + slug, version, }: { + slug: string; version: DocsVersionId; }) { const source = VERSION_SOURCES[version]; const versionPrefix = VERSION_PREFIXES[version]; - const page = source.getPage(PAGE_SLUGS); + const page = source.getPage([slug]); if (!page) { notFound(); diff --git a/docs/content/docs/v4/deploying.mdx b/docs/content/docs/v4/deploying.mdx index 7c40df1e72..17074c9ae9 100644 --- a/docs/content/docs/v4/deploying.mdx +++ b/docs/content/docs/v4/deploying.mdx @@ -55,7 +55,7 @@ vercel deploy - Starting with `workflow` version 5.0.0-beta.33, the Vercel World supports + Starting with `workflow` version 5.0.0, the Vercel World supports **multi-region**: runs are pinned to the region that creates them, keeping workflow data, queuing, and streaming close to your users. See [Multi-region](/v5/worlds/vercel#multi-region). diff --git a/docs/content/docs/v4/getting-started/next.mdx b/docs/content/docs/v4/getting-started/next.mdx index d0aa159219..0cf3ccdeac 100644 --- a/docs/content/docs/v4/getting-started/next.mdx +++ b/docs/content/docs/v4/getting-started/next.mdx @@ -281,7 +281,7 @@ Build error occurred Error: Cannot find module 'next/dist/lib/server-external-packages.json' ``` -Upgrade to `workflow@4.0.1-beta.26` or later: +Upgrade to `workflow@4.0.1` or later: ```package-install workflow@latest diff --git a/docs/content/docs/v5/ai/defining-tools.mdx b/docs/content/docs/v5/ai/defining-tools.mdx index 083d01ad6a..23027d1996 100644 --- a/docs/content/docs/v5/ai/defining-tools.mdx +++ b/docs/content/docs/v5/ai/defining-tools.mdx @@ -23,7 +23,6 @@ Just like in regular AI SDK tool definitions, tool in WorkflowAgent are called w When you tool needs access to the full message history, you can access it via the `messages` property of the tool call context: ```typescript title="tools.ts" lineNumbers -import { Experimental_Agent as Agent } from "ai"; import type { ModelMessage } from "ai"; async function getWeather( diff --git a/docs/content/docs/v5/ai/meta.json b/docs/content/docs/v5/ai/meta.json index 9c577158e5..133d01a51e 100644 --- a/docs/content/docs/v5/ai/meta.json +++ b/docs/content/docs/v5/ai/meta.json @@ -7,6 +7,7 @@ "sleep-and-delays", "human-in-the-loop", "defining-tools", + "message-queueing", "chat-session-modeling" ], "defaultOpen": true diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx index 556e803189..611db4723c 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx @@ -60,6 +60,7 @@ Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow- * All arguments must be [serializable](/docs/foundations/serialization). * When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments. * `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name. +* `region` pins the new run to a specific region on Worlds with a regional dimension. On the [Vercel World](/worlds/vercel#explicit-region-selection) the run's storage, queue dispatch, and streams are then served from that region; when omitted, the run is pinned to the region it was created in. Worlds without regions ignore the option. If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function). diff --git a/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx index 77edb53eae..2b32d365aa 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx @@ -12,7 +12,7 @@ related: No world in this repository throws it. A stale replay does not need to be refused: its log is a prefix rather than a prefix with a hole in it, replay is deterministic on a prefix, and the write it makes next comes back carrying the events it was pushed past (see [Stale reads](/docs/configuration/runtime-tuning#stale-reads-and-why-nothing-has-to-be-rejected)). The error and the runtime's handling of it remain for a world that would rather refuse than report — one that allocates positions somewhere other than the commit, and so cannot report a gap reliably. Event creations that carry no position are never rejected with it. -A world rejects only on evidence and accepts the creation whenever it cannot decide, so this error always means the snapshot really was stale — but not receiving it does not prove the snapshot was current. +A World rejects only on evidence and accepts the creation whenever it cannot decide, so this error always means the snapshot really was stale — but not receiving it does not prove the snapshot was current. The Workflow runtime handles this error automatically: it restarts the replay in the same invocation from a corrected event log, and re-invokes the run for a fresh replay only once its in-process restart budget is spent. It never retries the rejected creation as-is, because a replay working from a corrected log derives different events. You will only encounter it when interacting with world storage APIs directly. diff --git a/docs/content/docs/v5/api-reference/workflow-globals.mdx b/docs/content/docs/v5/api-reference/workflow-globals.mdx index 6faa7e3df6..bcc725b5c0 100644 --- a/docs/content/docs/v5/api-reference/workflow-globals.mdx +++ b/docs/content/docs/v5/api-reference/workflow-globals.mdx @@ -43,6 +43,7 @@ These standard Web APIs are available in workflow functions: - [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console) - [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone) - [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) / [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) +- [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) / [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) — a durable, serializable implementation whose abort state survives replay and can be passed into steps to cancel in-flight work. See [Cancellation](/docs/foundations/cancellation). ## Environment Variables diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx index afbe0339fb..f3eb714d78 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx @@ -2,14 +2,14 @@ title: createWorld description: Create a new World instance from environment configuration. type: reference -summary: Use createWorld to instantiate a World from WORKFLOW_TARGET_WORLD environment configuration, bypassing the cached instance. +summary: Use createWorld to construct a fresh instance of the build-injected World, bypassing the cached instance. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: - /docs/api-reference/workflow-runtime/set-world --- -Creates a new [World](/docs/api-reference/workflow-runtime/world) instance based on environment configuration. The `WORKFLOW_TARGET_WORLD` environment variable determines which World implementation is instantiated (for example the local development World or the Vercel production World). +Creates a new [World](/docs/api-reference/workflow-runtime/world) instance by invoking the World factory that was statically injected into the bundle at build time. Which implementation that is (for example the local development World or the Vercel production World) is decided when the app is built, via the `WORKFLOW_TARGET_WORLD` environment variable — changing the variable at runtime has no effect. Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself. @@ -23,7 +23,7 @@ const world = await createWorld(); // [!code highlight] ### Parameters -This function does not accept any parameters. Configuration is read from environment variables. +This function does not accept any parameters. Configuration comes from the World that was injected at build time (World implementations typically read their own settings from environment variables when constructed). ### Returns diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx index 6dcc03d8bd..0a78b0a6b4 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx @@ -2,14 +2,14 @@ title: setWorld description: Override or reset the cached World instance used by the workflow runtime. type: reference -summary: Use setWorld to inject a custom World instance or reset the cache after environment configuration changes. +summary: Use setWorld to inject a custom World instance or reset the cache to the build-injected World. prerequisites: - /docs/api-reference/workflow-runtime/get-world related: - /docs/api-reference/workflow-runtime/create-world --- -Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) returns. Use it to inject a World constructed with explicit configuration (rather than environment variables), or pass `undefined` to clear the cache so the next `getWorld()` call reinitializes from the current environment. +Overrides the cached [World](/docs/api-reference/workflow-runtime/world) instance that [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) returns. Use it to inject a World constructed with explicit configuration, or pass `undefined` to clear the cache so the next `getWorld()` call reconstructs the World that was statically injected into the bundle at build time. ```typescript lineNumbers import { setWorld, getWorld } from "workflow/runtime"; @@ -26,24 +26,26 @@ const world = await getWorld(); // resolves customWorld | Parameter | Type | Description | |-----------|------|-------------| -| `world` | `World \| undefined` | The World instance to use, or `undefined` to reset the cache and reinitialize from environment variables on next access | +| `world` | `World \| undefined` | The World instance to use, or `undefined` to reset the cache so the next access reconstructs the build-injected World | ### Returns This function does not return a value. -## Example: Reset After Environment Changes +## Example: Inject a Specific World -```typescript lineNumbers -import { setWorld, getWorld } from "workflow/runtime"; +The target World is selected at build time (via `WORKFLOW_TARGET_WORLD` when the app was built) and statically injected into the bundle — changing the environment variable at runtime has no effect. To use a different World at runtime, construct it explicitly with the World package's `createWorld()` factory and inject it: -process.env.WORKFLOW_TARGET_WORLD = "@workflow/world-local"; -setWorld(undefined); // clear the cached instance // [!code highlight] +```typescript lineNumbers +import { setWorld } from "workflow/runtime"; +import { createWorld } from "@workflow/world-local"; -const world = await getWorld(); // reinitialized with new configuration +setWorld(createWorld({ dataDir: "/tmp/workflow-test" })); // [!code highlight] ``` +Calling `setWorld(undefined)` afterwards restores the build-injected World on the next `getWorld()` call. + ## Related Functions - [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance. -- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh World from environment configuration. +- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh instance of the build-injected World. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx index aa9d3f4f5e..21a7ea5731 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx @@ -43,6 +43,8 @@ const world = await getWorld(); // [!code highlight] The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide. +Beyond these namespaces, the `World` interface carries a handful of top-level members aimed at World authors — `specVersion`, `capabilities`, lifecycle hooks (`start()`/`close()`), `getEncryptionKeyForRun()`, and the optional `createRunId()` / `describeRun()` hooks behind regional run placement and world-specific `inspect` output. Those are documented in [Building a World](/worlds/building-a-world). + ## Data Hydration Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`: diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx index 62af952df4..596836dd5b 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx @@ -58,7 +58,7 @@ const { messageId } = await world.queue(queueName, payload, opts); // [!code hig |-----------|------|-------------| | `queueName` | `ValidQueueName` | The queue name (branded string) | | `message` | `QueuePayload` | Internal SDK payload | -| `opts` | `QueueOptions` | Optional — `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers` | +| `opts` | `QueueOptions` | Optional — `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers`, `region` (regional routing hint), `specVersion` | **Returns:** `{ messageId: MessageId | null }` @@ -79,7 +79,7 @@ const handler = world.createQueueHandler(prefix, callback); // [!code highlight] **Returns:** `(req: Request) => Promise` -`meta.messageId` should be stable across redeliveries of the same message (one ID per enqueued message, reused on every delivery attempt). The runtime records it on inline `step_started` events as a liveness lease so that only a redelivery of the owning message re-executes a crashed inline step before the lease expires (see [Inline step message ownership](/v5/docs/changelog/step-message-ownership)). A World whose queue mints a fresh ID per delivery degrades gracefully — crashed inline steps recover via the delayed backstop instead of immediately on redelivery — but never wedges or duplicates. +`meta.messageId` should be stable across redeliveries of the same message (one ID per enqueued message, reused on every delivery attempt). The runtime records it on inline `step_started` events as a liveness lease so that only a redelivery of the owning message re-executes a crashed inline step before the lease expires (see [Inline step message ownership](/docs/changelog/step-message-ownership)). A World whose queue mints a fresh ID per delivery degrades gracefully — crashed inline steps recover via the delayed backstop instead of immediately on redelivery — but never wedges or duplicates. ## Related diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx index ac2c6ab5da..4bd1114fbd 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx @@ -245,7 +245,7 @@ const step = await world.steps.get(runId, stepId); // [!code highlight] | Parameter | Type | Description | |-----------|------|-------------| -| `runId` | `string \| undefined` | The workflow run ID | +| `runId` | `string` | The workflow run ID that owns the step | | `stepId` | `string` | The step ID | | `params.resolveData` | `'all' \| 'none'` | Whether to include input/output data. Default: `'all'` | diff --git a/docs/content/docs/v5/changelog/step-message-ownership.mdx b/docs/content/docs/v5/changelog/step-message-ownership.mdx index a626691bd9..8dcdd639ae 100644 --- a/docs/content/docs/v5/changelog/step-message-ownership.mdx +++ b/docs/content/docs/v5/changelog/step-message-ownership.mdx @@ -11,7 +11,7 @@ description: Why inline step_started now records its owning queue message ID, wh > made, and why the alternatives were rejected. Kill switch: > `WORKFLOW_INLINE_OWNERSHIP=0`; lease tuning: > `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS` (see -> [Runtime tuning](/v5/docs/configuration/runtime-tuning)). +> [Runtime tuning](/docs/configuration/runtime-tuning)). ## What this change does @@ -74,7 +74,7 @@ steady-state writes beyond one field on an event we already write. ID across redelivery attempts. That stability is exactly the property recovery needs: "a delivery whose ID matches the stamp" means "the queue redelivered the work that crashed" — permission to re-execute. The requirement is now documented in the -[Queue contract](/v5/docs/api-reference/workflow-runtime/world/queue); a World whose queue +[Queue contract](/docs/api-reference/workflow-runtime/world/queue); a World whose queue mints fresh IDs per delivery degrades gracefully (the owner check never matches, so crashed steps recover via the delayed backstop instead of immediately) — it never wedges and never duplicates. diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx index eccadae01c..0138304e3f 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx @@ -53,10 +53,10 @@ This guide assumes **Standard** workflows. Express workflows have different sema | Choice state | `if` / `else` / `switch` | Native control flow. | | Wait state | `sleep()` | `sleep('1m')` or `sleep(date)`. | | Parallel state | `Promise.all()` | Standard concurrency. | -| Map state | `for` loop / bounded `Promise.all` (e.g. `p-limit`) / step-wrapped `start()` per item for large fan-out | Match the original concurrency mode. | -| Retry / Catch | `maxRetries`, `RetryableError`, `FatalError`; `try/catch` for compensation | Retry logic moves to step boundaries. | +| Map state | `for` loop / bounded `Promise.all` (e.g. `p-limit`) / [`start()`](/docs/foundations/starting-workflows) per item for large fan-out | Match the original concurrency mode. | +| Retry / Catch (`MaxAttempts`, `BackoffRate`, `IntervalSeconds`) | `maxRetries`, `RetryableError`, `FatalError`; `try/catch` for compensation | Retry logic moves to step boundaries; backoff curves via `retryAfter` — see [Errors & Retrying](/docs/foundations/errors-and-retries#advanced-example). | | `.waitForTaskToken` | `createHook()` / `createWebhook()` | Hooks for typed signals; webhooks for HTTP. | -| Child state machine (`StartExecution`) | `"use step"` wrapper around `start()` / `getRun()` | Return the `Run` object for deep-linking. | +| Child state machine (`StartExecution`) | Call [`start()`](/docs/foundations/starting-workflows) directly from the workflow | Child runs are tagged with `$parentRunId` / `$rootRunId` automatically. | A single `Task` state and its Lambda collapse into two directive-tagged functions: @@ -103,7 +103,7 @@ Each row is a Step Functions capability the Workflow SDK does not replicate one- | Step Functions feature | How to cover it with the Workflow SDK | | --- | --- | | Express workflows | At-least-once and 5-minute duration fit the durable-replay model poorly; keep them on Step Functions or move to a queue consumer | -| Distributed Map (up to 10,000 concurrent children, S3 item sources) | Fan out with step-wrapped `start()` per item, then bound concurrency with `p-limit` | +| Distributed Map (up to 10,000 concurrent children, S3 item sources) | Fan out with `start()` per item directly from the workflow, then bound concurrency with `p-limit` | | Optimized AWS service integrations | Become ordinary SDK calls inside steps; `.sync` waits become explicit polling or hooks | | Per-state IAM roles | Steps share the deployment's credentials; scope secrets at deploy time | diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx index 9db90611b3..e117e6c9df 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx @@ -50,7 +50,7 @@ On portability, Cloudflare Workflows is the most locked-in of the tools in this ## Where Cloudflare leads -Credit where due: Cloudflare's V8-isolate model gives **near-zero cold starts**, and code runs across its global Anycast network with no region selection. For latency-sensitive, globally-distributed workloads on Cloudflare's platform, that's a genuine strength. The Workflow SDK's performance depends on the World it runs on; on Vercel it benefits from Fluid Compute and a 100K concurrency ceiling, with multi-region rolling out. +Credit where due: Cloudflare's V8-isolate model gives **near-zero cold starts**, and code runs across its global Anycast network with no region selection. For latency-sensitive, globally-distributed workloads on Cloudflare's platform, that's a genuine strength. The Workflow SDK's performance depends on the World it runs on; on Vercel it benefits from Fluid Compute, a 100K concurrency ceiling, and [multi-region support](/worlds/vercel#multi-region). ## Moving from Cloudflare Workflows @@ -62,7 +62,7 @@ There's no automated migration skill for Cloudflare specifically, but the mappin | `step.do(name, cb)` | `"use step"` function called with `await` | | `step.sleep` / `step.sleepUntil` | `sleep('1h')` / `sleep(date)` | | `step.waitForEvent` | `createHook()` / `createWebhook()` | -| Per-step `retries` config | `maxRetries`, `RetryableError`, `FatalError` | +| Per-step `retries` config (`limit`, `delay`, `backoff`) | `maxRetries` caps attempts; any backoff curve via [`RetryableError`](/docs/api-reference/workflow/retryable-error)'s `retryAfter` derived from [`getStepMetadata().attempt`](/docs/api-reference/workflow/get-step-metadata); `FatalError` stops retries — see [Errors & Retrying](/docs/foundations/errors-and-retries#advanced-example) | | `env.MY_WORKFLOW.create(...)` binding | `start(workflow, [args])` from your app | Side effects that lived in `step.do` callbacks move into named `"use step"` functions; the orchestration becomes plain `await` / `if` / `Promise.all` instead of the `WorkflowEntrypoint` class. diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx index 3d1404cd9e..fc7aac370d 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx @@ -59,9 +59,9 @@ Both are strong here. Inngest offers `step.ai.wrap()` (wrap Vercel AI SDK calls | `step.run()` | `"use step"` function | Named async function with Node.js access. | | `step.sleep()` / `step.sleepUntil()` | `sleep('5m')` / `sleep(date)` | Import from `workflow`. | | `step.waitForEvent()` | `createHook()` / `createWebhook()` | Token encodes the routing; no event schema. | -| `step.invoke()` | `"use step"` wrappers around `start()` / `getRun()` | Spawn a child run. | +| `step.invoke()` | [`start()`](/docs/foundations/starting-workflows) called directly from the workflow | Spawn a child run. | | `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly. | -| Retry config (`retries`) | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary. | +| Retry config (`retries`) / `RetryAfterError` | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary; custom backoff via `retryAfter` — see [Errors & Retrying](/docs/foundations/errors-and-retries#customize-retry-behavior). | | `step.realtime.publish()` / Realtime | `getWritable()` / named streams | Clients read from the stream. | The `createFunction` factory collapses into a plain exported function: diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx index 65a1e3b24c..1cbdf12b37 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx @@ -66,8 +66,9 @@ The model maps closely. Keep your orchestration logic; drop the workers, task qu | Worker + Task Queue | Managed execution | No worker fleet or polling loop to operate. | | Signal | `createHook()` / `createWebhook()` | Hooks for typed resume signals; webhooks for HTTP callbacks. | | Query | `getWritable({ namespace: 'status' })` stream | Stream status durably; clients read the stream instead of polling. | -| Child Workflow | `"use step"` wrapper around `start()` / `getRun()` | Return the `Run` object so observability can deep-link. | -| Activity retry policy | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary. | +| Child Workflow | Call [`start()`](/docs/foundations/starting-workflows) directly from the workflow | Child runs are tagged with `$parentRunId` / `$rootRunId` automatically. | +| Activity retry policy | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary — see [Errors & Retrying](/docs/foundations/errors-and-retries). | +| Search attributes / visibility queries | [`setAttributes()`](/docs/api-reference/workflow/set-attributes) / `attributes` option on `start()` | Filter runs by `key=value` — see [Attributes](/docs/observability/attributes). | | Event History | Workflow event log / run timeline | Same durable replay; built-in observability UI. | A minimal translation — the orchestrator loses `proxyActivities` and becomes plain TypeScript: @@ -114,9 +115,8 @@ Each row is a Temporal capability the Workflow SDK does not replicate one-to-one | Temporal feature | How to cover it with the Workflow SDK | | --- | --- | -| Search attributes / visibility queries | Filter runs by status and timestamps via `getRun()` and the observability UI | | Per-activity timeouts (`startToCloseTimeout`, etc.) | Enforce deadlines inside a step with `AbortSignal.timeout(ms)`, or wrap a call in `Promise.race(step(), sleep('5m'))` | -| Rich retry policy (`backoffCoefficient`, `nonRetryableErrorTypes`) | Only `maxRetries` is configurable; classify with `RetryableError` / `FatalError` and set delay via `new RetryableError(msg, { retryAfter: '5s' })` | +| Declarative retry policy (`backoffCoefficient`, `nonRetryableErrorTypes`, `maximumAttempts`) | The Workflow SDK expresses the same policies idiomatically in code instead of a config object: cap attempts with [`maxRetries`](/docs/foundations/errors-and-retries#default-retrying), mark errors non-retryable with [`FatalError`](/docs/api-reference/workflow/fatal-error), and derive any backoff curve from [`getStepMetadata().attempt`](/docs/api-reference/workflow/get-step-metadata) via [`RetryableError`](/docs/api-reference/workflow/retryable-error)'s `retryAfter` — see the [exponential backoff example](/docs/foundations/errors-and-retries#advanced-example) | | Polyglot workers | The Workflow SDK is TypeScript-first (Python in beta); for Go/Java/etc. in the same orchestrator, Temporal remains the better fit | --- diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx index 2087a9854c..dd5f406653 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx @@ -56,9 +56,10 @@ Both invest heavily in AI. trigger.dev offers AI SDK tool wrapping, a native `us | Inline `run` body | `"use step"` functions | Side effects move into named steps. | | `wait.for` / `wait.until` | `sleep('5m')` / `sleep(date)` | Import from `workflow`. | | `wait.forToken({ timeout })` | `createHook()` + `Promise.race` with `sleep()` | Hooks carry a typed token. | -| `triggerAndWait()` | `"use step"` wrappers around `start()` / `getRun()` | Spawn + collect. | +| `triggerAndWait()` | [`start()`](/docs/foundations/starting-workflows) called directly from the workflow, then await the returned `Run` | Spawn + collect. | | `batch.triggerAndWait()` | `Promise.all` over collected `Run` handles | Standard concurrency. | | `metadata.stream()` / Realtime | `getWritable()` / named streams | Clients read from the stream. | +| Run tags / `metadata.set()` | [`setAttributes()`](/docs/api-reference/workflow/set-attributes) / `attributes` option on `start()` | Filter runs by `key=value` — see [Attributes](/docs/observability/attributes). | | `AbortTaskRunError` | `FatalError` | Stops retries immediately. | The `task()` factory collapses into a plain function — and because the workflow body is replayed, move side effects into steps: diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 1b8a8970fb..2c03c5155b 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -154,9 +154,10 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### `WORKFLOW_V2_TIMEOUT_MS` -- Default: `120000` +- Default: derived from the runtime deadline — `600000` when the invocation has 25 minutes or more left, `300000` when it has 10 minutes or more, otherwise `120000` - Wall-clock guard for the inline replay loop. - Once elapsed, the handler requeues the workflow instead of continuing to run more inline work in the same invocation. +- The default comes from `World.getRuntimeDeadline()`, so raising a function's `maxDuration` widens the inline budget without configuration. Worlds that do not report a deadline get the flat `120000`. Setting this variable to a finite positive number overrides the tiering entirely. ### `WORKFLOW_MAX_INLINE_STEPS` @@ -189,7 +190,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### `WORKFLOW_INLINE_OWNERSHIP` - Default: enabled -- Records which queue message owns each inline step execution, so a wake (hook resume, elapsed wait) that replays the run mid-step schedules a delayed backstop instead of immediately re-dispatching — and re-executing — the step. See [Inline step message ownership](/v5/docs/changelog/step-message-ownership). +- Records which queue message owns each inline step execution, so a wake (hook resume, elapsed wait) that replays the run mid-step schedules a delayed backstop instead of immediately re-dispatching — and re-executing — the step. See [Inline step message ownership](/docs/changelog/step-message-ownership). - Set `0` or `false` to revert to the previous unconditional immediate re-dispatch. ### `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS` @@ -216,6 +217,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - The engine choice is stamped into the run's `executionContext` when the run starts, so a run keeps executing on the engine it started on even if the deployment's `WORKFLOW_VM` changes. Runs without a stamped engine use the handler's `WORKFLOW_VM` value. - Unknown values throw at startup. +### `WORKFLOW_QUICKJS_BASELINE_SNAPSHOT` + +- Default: enabled +- Only read when `WORKFLOW_VM=quickjs`. +- Evaluates the workflow bundle once per function instance, snapshots the resulting VM, and restores that snapshot at the start of every invocation instead of re-evaluating the bundle. This is the dominant share of QuickJS VM startup: roughly 77ms to 3ms to first suspension for a 1.3MB bundle. +- A bundle whose module scope consumes randomness, reads the clock, or replaces a serialization intrinsic cannot be snapshotted safely. Those are detected when the snapshot is prepared and fall back to per-invocation evaluation automatically. +- Set `0` or `false` to always evaluate the bundle per invocation. + ## Compression and tracing ### `WORKFLOW_DISABLE_COMPRESSION` @@ -338,3 +347,12 @@ These variables are primarily for tests, debugging, or unusual deployments. - Default: `10` - Poll interval for detecting stream lock release. + +## Limits + +### `WORKFLOW_MAX_EVENTS_OVERRIDE` + +- Default: unset +- Lowers the per-run event ceiling supplied by the World. A run whose event log reaches the ceiling fails with `MAX_EVENTS_EXCEEDED`, which stops a runaway loop from growing its log without bound. +- Clamp-down only: it never raises the World's limit, and it applies even when the World supplies none. With no World limit and no override, nothing is enforced. +- The Local and Vercel Worlds both supply a limit; the Local World defaults to 25,000 and is configurable with [`WORKFLOW_MAX_EVENTS`](/docs/configuration/worlds#workflow_max_events). diff --git a/docs/content/docs/v5/configuration/worlds.mdx b/docs/content/docs/v5/configuration/worlds.mdx index 2b4e67b925..2c195e9f09 100644 --- a/docs/content/docs/v5/configuration/worlds.mdx +++ b/docs/content/docs/v5/configuration/worlds.mdx @@ -123,6 +123,11 @@ The Local World is the default outside Vercel and is intended for development. - Default: `0` (dispatch the leading chunk of an idle stream immediately) - Group-commit window for the leading chunk of an idle stream; a positive value trades first-chunk latency for larger groups. The `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable, when set, overrides this option; otherwise the World option governs, including the very first chunk. +### `WORKFLOW_MAX_EVENTS` + +- Default: `25000` +- Per-run event ceiling reported to the runtime. A run whose event log reaches it fails with `MAX_EVENTS_EXCEEDED`, bounding a runaway loop. See [`WORKFLOW_MAX_EVENTS_OVERRIDE`](/docs/configuration/runtime-tuning#workflow_max_events_override) for the runtime-side clamp. + ## Postgres World The Postgres World is a self-hosted durable backend for long-running server processes. @@ -276,6 +281,13 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an - Default: `1000` - Maximum stream chunks written in one Vercel World request. Larger batches are split. +### `WORKFLOW_DISABLE_ANALYTICS_READS` + +- Factory option: none +- CLI flag: none +- Default: disabled +- Set `1` to turn off the World's metadata-only `analytics` read namespace, forcing `workflow inspect` and web UI list views onto strongly consistent primary storage. Intended for tests and tooling that read entities immediately after writing them. + ### `WORKFLOW_BATCH_TRANSITIONS` - Surface: environment variable diff --git a/docs/content/docs/v5/deploying.mdx b/docs/content/docs/v5/deploying.mdx index 327a7575e8..7e3f42d130 100644 --- a/docs/content/docs/v5/deploying.mdx +++ b/docs/content/docs/v5/deploying.mdx @@ -41,7 +41,7 @@ The Vercel World provides: - **Managed queuing** - Steps are processed reliably with automatic retries - **Automatic scaling** - Workflows scale with your application - **Built-in observability** - View workflow runs in the Vercel dashboard -- **Multi-region** - Runs are pinned to the region that creates them, keeping workflow data, queuing, and streaming close to your users (requires `workflow` 5.0.0-beta.33 or later) +- **Multi-region** - Runs are pinned to the region that creates them, keeping workflow data, queuing, and streaming close to your users Simply deploy your application: diff --git a/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx b/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx index dc341d324d..473fc41c70 100644 --- a/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx +++ b/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx @@ -34,7 +34,7 @@ When creating a webhook with `createWebhook()`, you can specify how the webhook export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "automatic", // Error! // [!code highlight] }); } @@ -49,7 +49,7 @@ import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", // [!code highlight] }); @@ -67,7 +67,7 @@ export async function webhookWorkflow() { export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: { status: 200, body: "OK" }, // Error! // [!code highlight] }); } @@ -82,7 +82,7 @@ import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: new Response("OK", { status: 200 }), // [!code highlight] }); } @@ -96,7 +96,7 @@ export async function webhookWorkflow() { import { createWebhook } from "workflow"; // Returns 202 Accepted automatically -const webhook = await createWebhook(); +const webhook = createWebhook(); const request = await webhook; // No need to send a response ``` @@ -107,7 +107,7 @@ const request = await webhook; import { createWebhook } from "workflow"; // Manual response control -const webhook = await createWebhook({ +const webhook = createWebhook({ respondWith: "manual", }); @@ -131,7 +131,7 @@ await request.respondWith( import { createWebhook } from "workflow"; // Immediate response -const webhook = await createWebhook({ +const webhook = createWebhook({ respondWith: new Response("Request received", { status: 200 }), }); diff --git a/docs/content/docs/v5/errors/webhook-response-not-sent.mdx b/docs/content/docs/v5/errors/webhook-response-not-sent.mdx index 8d449ba851..4b59ec6d40 100644 --- a/docs/content/docs/v5/errors/webhook-response-not-sent.mdx +++ b/docs/content/docs/v5/errors/webhook-response-not-sent.mdx @@ -36,7 +36,7 @@ The webhook infrastructure waits for a response to be sent, and if none is provi export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", }); @@ -59,7 +59,7 @@ import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", }); @@ -81,7 +81,7 @@ export async function webhookWorkflow() { export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", }); @@ -104,7 +104,7 @@ import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", }); @@ -126,7 +126,7 @@ export async function webhookWorkflow() { export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", }); @@ -149,7 +149,7 @@ import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook({ + const webhook = createWebhook({ respondWith: "manual", }); @@ -179,7 +179,7 @@ import { createWebhook } from "workflow"; export async function webhookWorkflow() { "use workflow"; - const webhook = await createWebhook(); // [!code highlight] + const webhook = createWebhook(); // [!code highlight] const request = await webhook; // Process request asynchronously diff --git a/docs/content/docs/v5/foundations/cancellation.mdx b/docs/content/docs/v5/foundations/cancellation.mdx index bc8867a699..6e67cedbe4 100644 --- a/docs/content/docs/v5/foundations/cancellation.mdx +++ b/docs/content/docs/v5/foundations/cancellation.mdx @@ -454,6 +454,6 @@ This is safe even if both steps have already completed — aborting a finished o - [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals - [Serialization](/docs/foundations/serialization) — Understanding serializable types -- [Cookbook](/v5/cookbook) — Timeout, race, and other reliability patterns +- [Cookbook](/cookbook) — Timeout, race, and other reliability patterns - [Hooks](/docs/foundations/hooks) — Pausing workflows for external events - [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures diff --git a/docs/content/docs/v5/foundations/errors-and-retries.mdx b/docs/content/docs/v5/foundations/errors-and-retries.mdx index 3186083e2f..c079b53d0a 100644 --- a/docs/content/docs/v5/foundations/errors-and-retries.mdx +++ b/docs/content/docs/v5/foundations/errors-and-retries.mdx @@ -178,7 +178,7 @@ try { const result = await run.returnValue; } catch (err) { if (WorkflowRunFailedError.is(err)) { - console.log(err.errorCode); // "USER_ERROR", "RUNTIME_ERROR", or undefined + console.log(err.errorCode); // e.g. "USER_ERROR", "MAX_EVENTS_EXCEEDED", or undefined // `cause` is the original thrown value, hydrated through the workflow // serialization pipeline. It can be any thrown value, so check shape. if (err.cause instanceof Error) { @@ -191,7 +191,13 @@ try { | Code | Meaning | | --- | --- | | `USER_ERROR` | An error thrown in your workflow or step code (including propagated step failures like `FatalError`) | -| `RUNTIME_ERROR` | An internal runtime error such as a corrupted event log or missing data. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) | +| `MAX_EVENTS_EXCEEDED` | The run reached the World's per-run event ceiling (25,000 on the Local and Vercel Worlds). Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows); see [Limits](/docs/configuration/runtime-tuning#limits) | +| `MAX_DELIVERIES_EXCEEDED` | The run exceeded the maximum number of queue deliveries | +| `REPLAY_TIMEOUT` | A workflow replay exceeded the maximum allowed duration | +| `REPLAY_DIVERGENCE` | A replay could not consume the event log deterministically — usually non-deterministic workflow code | +| `CORRUPTED_EVENT_LOG` | The event log contains orphaned or mismatched events and cannot be replayed. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) | +| `WORLD_CONTRACT_ERROR` | A World response violated the SDK contract; points at a World implementation bug | +| `RUNTIME_ERROR` | An internal runtime error. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) | The error code is also available on the run entity via the CLI (`npx workflow inspect runs `) in the `error.code` field, and as an OTEL span attribute (`workflow.error.code`) for observability. diff --git a/docs/content/docs/v5/foundations/starting-workflows.mdx b/docs/content/docs/v5/foundations/starting-workflows.mdx index 6750404b35..63db2b813a 100644 --- a/docs/content/docs/v5/foundations/starting-workflows.mdx +++ b/docs/content/docs/v5/foundations/starting-workflows.mdx @@ -38,6 +38,7 @@ export async function POST(request: Request) { - The first argument is your workflow function - The second argument is an array of arguments to pass to the workflow (optional if the workflow takes no arguments) - All arguments must be [serializable](/docs/foundations/serialization) +- On Worlds with a regional dimension, the optional `region` option pins the new run's storage, queuing, and streams to a specific region — see [Multi-region on the Vercel World](/worlds/vercel#multi-region) **Learn more**: [`start()` API Reference](/docs/api-reference/workflow-api/start) diff --git a/docs/content/docs/v5/foundations/streaming.mdx b/docs/content/docs/v5/foundations/streaming.mdx index 060f49bbd9..046bab40e9 100644 --- a/docs/content/docs/v5/foundations/streaming.mdx +++ b/docs/content/docs/v5/foundations/streaming.mdx @@ -514,6 +514,10 @@ async function uploadResult(stream: ReadableStream) { ## Best Practices +**Batching and first-chunk latency:** + +Writes are flushed immediately by default — the leading chunk of an idle stream dispatches as soon as it is written, and chunks arriving while a flush is in flight coalesce into the next batch. If you write bursts of many tiny chunks and prefer fewer round trips over first-chunk latency, set a group-commit window with the World's `streamFlushIntervalMs` option or the `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable (see [Worlds configuration](/docs/configuration/worlds#streamflushintervalms)). + **Release locks properly:** ```typescript lineNumbers @@ -530,7 +534,7 @@ Stream locks acquired in a step only apply within that step, not across other st -If a lock is not released, the step function's HTTP request cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out—wasting compute resources unnecessarily. +If a lock is not released, the invocation that ran the step cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out—wasting compute resources unnecessarily. **Close streams when done:** diff --git a/docs/content/docs/v5/foundations/workflows-and-steps.mdx b/docs/content/docs/v5/foundations/workflows-and-steps.mdx index cb57e8e988..2e43f71b41 100644 --- a/docs/content/docs/v5/foundations/workflows-and-steps.mdx +++ b/docs/content/docs/v5/foundations/workflows-and-steps.mdx @@ -126,7 +126,7 @@ Workflow functions have the ability to automatically suspend while they wait on There are multiple ways a workflow can suspend: -- Waiting on a step function: the workflow yields while the step runs in the step runtime. +- Waiting on a step function: the workflow yields while the step body runs. The step usually executes inline in the same invocation; when the invocation's inline budget is exhausted or its timeout approaches, the step is handed to the queue and the workflow resumes in a later invocation. - Using `sleep()` to pause for some fixed duration. - Awaiting on a promise returned by [`createWebhook()`](/docs/api-reference/workflow/create-webhook), which resumes the workflow when an external system passes data into the workflow. diff --git a/docs/content/docs/v5/getting-started/nestjs.mdx b/docs/content/docs/v5/getting-started/nestjs.mdx index a7f18d6081..a3eaddd937 100644 --- a/docs/content/docs/v5/getting-started/nestjs.mdx +++ b/docs/content/docs/v5/getting-started/nestjs.mdx @@ -120,7 +120,7 @@ Run the init command to generate the SWC configuration: npx @workflow/nest init ``` -This creates a `.swcrc` file configured with the Workflow SWC plugin for client-mode transformations. +This creates a `.swcrc` file configured with the Workflow SWC plugin for step-mode transformations. Add `.swcrc` to your `.gitignore` as it contains machine-specific absolute paths that shouldn't be committed. diff --git a/docs/content/docs/v5/getting-started/next.mdx b/docs/content/docs/v5/getting-started/next.mdx index d39f45cec9..d1308d08b1 100644 --- a/docs/content/docs/v5/getting-started/next.mdx +++ b/docs/content/docs/v5/getting-started/next.mdx @@ -281,7 +281,7 @@ Build error occurred Error: Cannot find module 'next/dist/lib/server-external-packages.json' ``` -Upgrade to `workflow@4.0.1-beta.26` or later: +Upgrade `workflow` to the latest release: ```package-install workflow@latest diff --git a/docs/content/docs/v5/getting-started/python.mdx b/docs/content/docs/v5/getting-started/python.mdx index e6ffb0e8e0..b381831a53 100644 --- a/docs/content/docs/v5/getting-started/python.mdx +++ b/docs/content/docs/v5/getting-started/python.mdx @@ -93,7 +93,7 @@ async def summarize_draft(*, draft: str): return summary ``` -Each step compiles into an isolated route. While the step executes, the workflow suspends without consuming resources. When the step completes, the workflow resumes automatically where it left off. +Each step executes separately from the workflow orchestrator. While the step executes, the workflow suspends without consuming resources. When the step completes, the workflow resumes automatically where it left off. ## Sleep diff --git a/docs/content/docs/v5/getting-started/react-router/v7.mdx b/docs/content/docs/v5/getting-started/react-router/v7.mdx index b49789c8b4..e57bab0a74 100644 --- a/docs/content/docs/v5/getting-started/react-router/v7.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v7.mdx @@ -234,4 +234,4 @@ Use `workflow({ dirs: ["workflows"] })`, remove the existing `build` directory o ### `vite build` finishes output but does not exit -Use `workflow@5.0.0-beta.33` or later with Nitro v3. +Use `workflow@5.0.0` or later with Nitro v3. diff --git a/docs/content/docs/v5/getting-started/react-router/v8.mdx b/docs/content/docs/v5/getting-started/react-router/v8.mdx index 1407e39da1..6a3cc92cba 100644 --- a/docs/content/docs/v5/getting-started/react-router/v8.mdx +++ b/docs/content/docs/v5/getting-started/react-router/v8.mdx @@ -229,4 +229,4 @@ Use `workflow({ dirs: ["workflows"] })`, remove the existing `build` directory o ### `vite build` finishes output but does not exit -Use `workflow@5.0.0-beta.33` or later with Nitro v3. +Use `workflow@5.0.0` or later with Nitro v3. diff --git a/docs/content/docs/v5/how-it-works/code-transform.mdx b/docs/content/docs/v5/how-it-works/code-transform.mdx index 81f173a7bc..32e9315861 100644 --- a/docs/content/docs/v5/how-it-works/code-transform.mdx +++ b/docs/content/docs/v5/how-it-works/code-transform.mdx @@ -19,7 +19,6 @@ Workflows use special directives to mark code for transformation by the Workflow Workflows use two directives to mark functions for special handling: -{/* @skip-typecheck: incomplete code sample */} ```typescript export async function handleUserSignup(email: string) { "use workflow"; // [!code highlight] @@ -52,39 +51,47 @@ The compiler operates in three distinct modes, transforming the same source code flowchart LR A["Source Code
with directives"] --> B["Step Mode"] A --> C["Workflow Mode"] - A --> D["Client Mode"] + A --> D["Detect Mode"] B --> E["Step registration bundle"] + B --> G["Your App Code
(Enables `start`)"] C --> F["Workflow bundle"] E --> H["Combined flow handler"] F --> H - D --> G["Your App Code
(Enables `start`)"] + D --> I["Build manifest
(discovery)"] ``` ### Comparison Table | Mode | Used In | Purpose | Runtime role | Required? | |----------|------------|--------------------------------|--------------|-----------| -| Step | Build time | Registers executable step functions | Imported by the combined flow handler | Yes | +| Step | Build time + your app code | Registers executable step functions; gives app code workflow IDs for `start()` | Imported by the combined flow handler, and applied to application code by the framework loader | Yes | | Workflow | Build time | Bundles workflow orchestrators | Executed by `.well-known/workflow/v1/flow` | Yes | -| Client | Build/Runtime | Provides workflow IDs and types to `start` | Your application code | Optional* | +| Detect | Build time | Discovers workflows, steps, and serialization classes without transforming code | Feeds the build's discovery phase and manifest | Yes (build-internal) | -\* Client mode is **recommended** for better developer experience—it provides automatic ID generation and type safety. Without it, you must manually construct workflow IDs or use the build manifest. + + Earlier releases had a separate **client mode** for application code. In 5.0 it merged into step mode, which produces the same app-code behavior (workflow functions throw on direct calls and carry `workflowId` for `start()`) while also registering step functions. Build integrations that passed `mode: "client"` now pass `mode: "step"`. + ## Detailed Transformation Examples - + -**Step Mode** creates a registration bundle that the combined flow handler imports. It is not an HTTP route. +**Step Mode** creates the registration bundle that the combined flow handler imports (it is not an HTTP route), and is also the transform framework loaders apply to your application code. **Input:** -{/* @skip-typecheck: incomplete code sample */} ```typescript export async function createUser(email: string) { "use step"; return { id: crypto.randomUUID(), email }; } + +export async function handleUserSignup(email: string) { + "use workflow"; + const user = await createUser(email); + return { userId: user.id }; +} ``` **Output:** @@ -99,16 +106,23 @@ export async function createUser(email: string) { __wf_reg.set(__wf_id, __wf_fn); // [!code highlight] __wf_fn.stepId = __wf_id; // [!code highlight] })(createUser, "step//workflows/user.js//createUser"); // [!code highlight] + +export async function handleUserSignup(email: string) { + throw new Error("You attempted to execute ..."); // [!code highlight] +} +handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; // [!code highlight] ``` **What happens:** - The `"use step"` directive is removed -- The function body is kept completely intact (no transformation) -- The function is registered with the runtime via an inline IIFE (no imports needed) +- Step function bodies are kept completely intact (no transformation) +- Each step function is registered with the runtime via an inline IIFE (no imports needed) - Step functions run with full Node.js/Deno/Bun access +- Workflow function bodies are **replaced** with an error throw, and a `workflowId` property is attached — workflow functions must be launched with [`start()`](/docs/api-reference/workflow-api/start), never called directly, and the ID is what `start()` uses to identify the workflow +- A dead-code-elimination pass removes code reachable only from the replaced workflow bodies -**Why no transformation?** Step functions execute in your main runtime with full access to Node.js APIs, file system, databases, etc. They don't need any special handling—they just run normally. +**Why no step transformation?** Step functions execute in your main runtime with full access to Node.js APIs, file system, databases, etc. They don't need any special handling—they just run normally. **ID Format:** Step IDs follow the pattern `step//{filepath}//{functionName}`, where the filepath is relative to your project root. @@ -119,7 +133,6 @@ export async function createUser(email: string) { **Input:** -{/* @skip-typecheck: incomplete code sample */} ```typescript export async function createUser(email: string) { "use step"; @@ -159,14 +172,14 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; / 1. Checks if the step has already been executed (in the event log) 2. If yes: Returns the cached result -3. If no: Triggers a suspension and enqueues the step for background execution +3. If no: Suspends the replay and executes the step — usually inline in the same invocation, with the run only handed back to the queue when the invocation's inline budget is exhausted or its timeout approaches **ID Format:** Workflow IDs follow the pattern `workflow//{filepath}//{functionName}`. The `workflowId` property is attached to the function to allow [`start()`](/docs/api-reference/workflow-api/start) to work at runtime. - + -**Client Mode** transforms workflow functions in your application code to prevent direct execution. +**Detect Mode** is a lightweight, non-transforming mode used during the build's discovery phase. **Input:** @@ -183,29 +196,24 @@ export async function handleUserSignup(email: string) { {/* @skip-typecheck: incomplete code sample */} ```typescript +/**__internal_workflows{"workflows":{"user.js":{"handleUserSignup":{"workflowId":"workflow//workflows/user.js//handleUserSignup"}}}}*/; // [!code highlight] export async function handleUserSignup(email: string) { - throw new Error("You attempted to execute ..."); // [!code highlight] + "use workflow"; + const user = await createUser(email); + return { userId: user.id }; } -handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; // [!code highlight] ``` **What happens:** -- Workflow function bodies are **replaced** with an error throw -- The `workflowId` property is added (same as workflow mode) -- Step functions are not transformed in client mode - -**Why this transformation?** Workflow functions cannot be called directly from application code—they must be started using [`start()`](/docs/api-reference/workflow-api/start). The error prevents accidental direct execution while the `workflowId` property allows the `start()` function to identify which workflow to launch. +- The code is **not modified** — detect mode only walks the AST +- Discovered workflows, steps, and custom-serialization classes are emitted as a JSON manifest comment +- The build uses this to decide which files feed the step and workflow bundles -The IDs are generated exactly like in workflow mode to ensure they can be directly referenced at runtime. +**Why a separate mode?** The build system first runs a fast regexp pre-scan to find candidate files containing directive-like strings, then runs detect mode on those candidates to validate at the AST level. False positives — for example, a directive-like string inside a template literal — are eliminated because the plugin only recognizes genuine directive statements. - **Client mode is optional:** While recommended for better developer experience (automatic IDs and type safety), you can skip client mode and instead: - - Manually construct workflow IDs using the pattern `workflow//{filepath}//{functionName}` - - Use the workflow manifest file generated during build to lookup IDs - - Pass IDs directly to `start()` as strings - - All framework integrations include client mode as a loader by default. + **Working without the app-code loader:** Frameworks apply the step-mode transform to application code by default, which is what gives `start(handleUserSignup)` its automatic IDs and type safety. If your setup can't run the loader, you can instead construct workflow IDs manually using the pattern `workflow//{filepath}//{functionName}`, look them up in the build manifest, and pass them to `start()` as strings. @@ -259,6 +267,10 @@ Contains all step functions transformed in **step mode**. The combined flow hand This module must not be exposed as an HTTP endpoint. + + **Changed in 5.0:** in 4.x the step bundle was served as its own HTTP route at `POST /.well-known/workflow/v1/step`, with step messages delivered on a separate `__wkf_step_*` queue topic. v5 merged both into the combined flow handler — the step bundle became a registration module imported by `flow.js`, and step messages arrive on the shared workflow queue. See the v4 version of this page, reachable from the version picker, for the old layout. + + ### `webhook.js` Contains webhook handling logic for delivering external data to running workflows via [`createWebhook()`](/docs/api-reference/workflow/create-webhook). @@ -275,16 +287,16 @@ Contains webhook handling logic for delivering external data to running workflow The multi-mode transformation enables the Workflow SDK's durable execution model: -1. **Step Mode** (required) - Bundles executable step functions that can access the full runtime +1. **Step Mode** (required) - Bundles executable step functions that can access the full runtime, and doubles as the app-code transform that prevents direct workflow execution and enables type-safe `start()` references 2. **Workflow Mode** (required) - Creates orchestration logic that can replay from event logs -3. **Client Mode** (optional) - Prevents direct execution and enables type-safe workflow references +3. **Detect Mode** (build-internal) - Discovers directive-marked functions for the build without touching the code This separation allows: - **Deterministic replay**: Workflows can be safely replayed from event logs without re-executing side effects - **Sandboxed orchestration**: Workflow logic runs in a controlled VM without direct runtime access - **Stateless execution**: Your compute can scale to zero and resume from any point in the workflow -- **Type safety**: TypeScript works seamlessly with workflow references (when using client mode) +- **Type safety**: TypeScript works seamlessly with workflow references passed to `start()` ## Determinism and Replay diff --git a/docs/content/docs/v5/how-it-works/encryption.mdx b/docs/content/docs/v5/how-it-works/encryption.mdx index ff4cb97fa7..dc165c27bc 100644 --- a/docs/content/docs/v5/how-it-works/encryption.mdx +++ b/docs/content/docs/v5/how-it-works/encryption.mdx @@ -34,6 +34,10 @@ Metadata such as workflow names, step names, entity IDs, timestamps, and lifecyc ## How It Works +### Compression + +Payloads are compressed before they are encrypted. A format prefix on the stored value records the compression codec (gzip, with zstd support in the format), and the inner payload keeps its own serialization format prefix after decompression. Repetitive payloads compress heavily — AI token streams average around 80% smaller — which means less stored data and less to move over the network. Like encryption itself, this is automatic and requires no code changes. + ### Key Management Each workflow run is encrypted with its own unique key, provided by the `World` implementation via `getEncryptionKeyForRun()`. How the key is generated and stored is up to the `World`. diff --git a/docs/content/docs/v5/how-it-works/framework-integrations.mdx b/docs/content/docs/v5/how-it-works/framework-integrations.mdx index a9cb4ef16f..8f3e119be0 100644 --- a/docs/content/docs/v5/how-it-works/framework-integrations.mdx +++ b/docs/content/docs/v5/how-it-works/framework-integrations.mdx @@ -68,9 +68,9 @@ The default output is: Production integrations should extend `BaseBuilder` from `@workflow/builders` so they can participate in the framework's build, watch, and routing lifecycle.
-### 2. Add the client transform +### 2. Add the app-code transform -Client mode gives application code the workflow IDs used by `start()` and prevents accidental direct workflow execution. +Applying the step-mode transform to application code gives it the workflow IDs used by `start()` and prevents accidental direct workflow execution. (Earlier releases used a separate `client` mode for this; it merged into `step` in 5.0.) {/* @skip-typecheck: incomplete code sample */} ```typescript title="workflow-plugin.ts" lineNumbers @@ -91,7 +91,7 @@ plugin({ jsc: { experimental: { plugins: [ - [require.resolve("@workflow/swc-plugin"), { mode: "client" }], + [require.resolve("@workflow/swc-plugin"), { mode: "step" }], ], }, }, diff --git a/docs/content/docs/v5/how-it-works/understanding-directives.mdx b/docs/content/docs/v5/how-it-works/understanding-directives.mdx index c37e21d97c..5595e977c0 100644 --- a/docs/content/docs/v5/how-it-works/understanding-directives.mdx +++ b/docs/content/docs/v5/how-it-works/understanding-directives.mdx @@ -58,7 +58,7 @@ export async function onboardUser(userId: string) { **The key insight:** Workflows resume from suspension by replaying their code using cached step results from the [event log](/docs/how-it-works/event-sourcing). When a step like `await fetchUserData(userId)` is called: - **If already executed:** Returns the cached result immediately from the event log -- **If not yet executed:** Suspends the workflow, enqueues the step for background execution, and resumes later with the result +- **If not yet executed:** Suspends the workflow and executes the step — usually inline in the same invocation, falling back to the queue when the invocation runs out of inline budget or nears its timeout — then resumes with the result This replay mechanism requires deterministic code. If `Math.random()` weren't seeded, the first execution might return `0.7` (sending the email) but replay might return `0.3` (skipping it), thus breaking resumption. The Workflow SDK sandbox provides seeded `Math.random()` and `Date` to ensure consistent behavior across replays. diff --git a/docs/content/docs/v5/internal/index.mdx b/docs/content/docs/v5/internal/index.mdx index ed151747d8..a05c3ae4f4 100644 --- a/docs/content/docs/v5/internal/index.mdx +++ b/docs/content/docs/v5/internal/index.mdx @@ -16,6 +16,6 @@ This page is only visible on preview deployments and local development. It does Changelog entries staged here for review before publishing to the Vercel website. -- [Local web UI in Nitro dev](/docs/internal/nitro-web-ui) — unreleased (next beta) +- [Local web UI in Nitro dev](/docs/internal/nitro-web-ui) — unreleased (ships in 5.0.0) - [Native Nitro v3 bundling for workflows](/docs/internal/nitro-native-build) — May 22, 2026 - [Serializable AbortController and AbortSignal](/docs/internal/serializable-abort-controller) — March 12, 2026 diff --git a/docs/content/docs/v5/internal/nitro-web-ui.mdx b/docs/content/docs/v5/internal/nitro-web-ui.mdx index f62a746793..5ef0fbd89f 100644 --- a/docs/content/docs/v5/internal/nitro-web-ui.mdx +++ b/docs/content/docs/v5/internal/nitro-web-ui.mdx @@ -6,7 +6,7 @@ type: overview # Local web UI in Nitro dev -{/* TODO: unreleased — changeset .changeset/nitro-dashboard-route.md is pending; ships in the next @workflow/nitro beta (5.0.0-beta.12). Update this date on publish. */} +{/* TODO: unreleased — changeset .changeset/nitro-dashboard-route.md is pending; ships in @workflow/nitro 5.0.0. Update this date on publish. */} June 2, 2026 The Workflow SDK web UI is now built into the Nitro dev server. During development, open `/_workflow` in your browser to inspect, monitor, and debug your workflow runs. diff --git a/docs/content/docs/v5/observability/index.mdx b/docs/content/docs/v5/observability/index.mdx index 139a71a907..207581b848 100644 --- a/docs/content/docs/v5/observability/index.mdx +++ b/docs/content/docs/v5/observability/index.mdx @@ -42,6 +42,10 @@ npx workflow inspect runs --web ![Workflow SDK Web UI](/o11y-ui.png) +On [Nitro](/docs/getting-started/nitro), the dev server has the web UI built +in: open `/_workflow` while `nitro dev` is running — no separate command +needed. + In the runs table, select one or more runs and choose **Cancel** to cancel the batch in a single request. Runs that fail with a retryable error stay selected so you can retry them. To share a link to a specific run without opening a browser, use the `--url` diff --git a/docs/content/docs/v5/observability/tracing.mdx b/docs/content/docs/v5/observability/tracing.mdx index f0813d5aba..4b424e2b15 100644 --- a/docs/content/docs/v5/observability/tracing.mdx +++ b/docs/content/docs/v5/observability/tracing.mdx @@ -70,7 +70,7 @@ Stream spans are emitted by the SDK's world backend on the client that writes or ## Trace shape: one trace per invocation -A single workflow run can span hours or days across many separate function invocations: every step completion, `sleep()` wake-up, and retry is a new queue delivery. Stitching all of that into one trace produces giant, slow-loading traces that most tracing backends truncate. +A single workflow run can span hours or days across many separate function invocations: every `sleep()` wake-up, hook resume, retry, and queued step continuation is a new queue delivery. Stitching all of that into one trace produces giant, slow-loading traces that most tracing backends truncate. Instead, the SDK creates **one bounded trace per invocation**. Each `workflow.execute` (or background `step.execute`) span starts a new trace root and attaches two **span links**: diff --git a/docs/content/docs/v5/testing/server-based.mdx b/docs/content/docs/v5/testing/server-based.mdx index ef803376bc..2fa3369462 100644 --- a/docs/content/docs/v5/testing/server-based.mdx +++ b/docs/content/docs/v5/testing/server-based.mdx @@ -148,7 +148,7 @@ export async function teardown() { // [!code highlight] These JSON log lines are intentional. They give CI jobs, local tooling, and agents stable events to watch for (`server_starting`, `server_stdout`, `server_stderr`, `server_ready`, `server_exit`), and the thrown timeout error includes the command, expected `WORKFLOW_LOCAL_BASE_URL`, and buffered stdout/stderr so a failed setup is actionable without interactive debugging. -The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends step execution requests to the running server. +The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends flow-route requests (workflow orchestration and step execution) to the running server. You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server. diff --git a/docs/content/docs/v5/whats-new.mdx b/docs/content/docs/v5/whats-new.mdx new file mode 100644 index 0000000000..617dfb203b --- /dev/null +++ b/docs/content/docs/v5/whats-new.mdx @@ -0,0 +1,185 @@ +--- +title: What's new in v5? +description: Workflow SDK 5.0 highlights, breaking changes, and how to upgrade from 4.x. +type: guide +summary: See what changed in Workflow SDK 5.0 and how to move an app from 4.x. +related: + - /docs/getting-started + - /docs/configuration + - /docs/foundations/cancellation +--- + +We recommend upgrading to v5 to get all of the performance, cost, and feature improvements listed below. It's as simple as installing the migration skill and telling your agent to migrate your app from Workflow SDK v4 to v5. + +```bash +npm install workflow@latest +npx skills add https://github.com/vercel/workflow --skill migrating-workflow-v4-to-v5 +``` + + + + + Workflow SDK v4 remains installable as `workflow@4` and receives stability + fixes. Switch to its documentation with the version picker in the sidebar. + + +## Highlights + +### Faster and cheaper runs + +The largest change in v5 has no API surface: the runtime does far less work per unit of progress. The time between calling `start()` and your first step body executing is now less than half of what it was in v4. This is made possible by many smaller optimizations: + +**A workflow invocation now does as much as it can in a single pass.** In 4.x, progress was largely deferred to the queue: an invocation would execute a step, hand back to the queue, and let a fresh invocation pick up the next one. v5 creates and executes steps inline — several per suspension, in parallel — and only uses the queue when it really has to: a wait, a hook, or the function is approaching its timeout. + +**The runtime avoids waiting on the persistence layer where it can determine that is safe for your workload.** Many API calls are now simply skipped when not needed, like requesting the event-log on a run's first invocation. Step creation is folded into step execution rather than being its own round trip. The inline loop consumes the event-log delta from the previous step's write instead of re-listing events. Each optimization is gated on specific runtime conditions, and can be turned off individually — see [Runtime tuning](/docs/configuration/runtime-tuning). + +**The workflow VM is kept alive across inline steps.** Within one invocation, a step-only suspension keeps the live VM and hydrated state, so the next iteration appends only the newly written events instead of rebuilding the sandbox and replaying the whole log. Step inputs made of plain data or standard built-ins keep this fast path; see [`WORKFLOW_RETAINED_VM`](/docs/configuration/runtime-tuning#workflow_retained_vm). + +**Resuming a hook takes one round trip instead of two.** `resumeHook()` writes the `hook_received` event and dispatches the queue message concurrently, with a `(runId, resumeId)` dedup constraint keeping the two writers converging on exactly one event. See [Resilient hook resumption](/docs/changelog/resilient-resume). + +**Payloads are compressed** before they are encrypted and sent to the API. Repetitive payloads compress heavily; AI token streams average around 80% smaller. That is less stored data and less to move over the network. + +On [Vercel Workflows](/worlds/vercel) these benefits compound to reduce compute costs by up to 80% for workflows made of many small steps, and storage cost by up to 70%, depending on the workload. Depending on your setup, you may see similar gains using self-hosted or third-party Worlds. + +How much work fits in one invocation now scales with the function's own limit: the inline budget is derived from the runtime deadline the World reports, so raising `maxDuration` widens it without configuration. See [`WORKFLOW_V2_TIMEOUT_MS`](/docs/configuration/runtime-tuning#workflow_v2_timeout_ms). + +### Multi-region support + +Workflow run IDs are now tagged with the compute region they were created in, or the region specified when calling `start()`. Worlds can use this to pin execution to that region. +This is now the default when deploying to Vercel, and run data will automatically be co-located with the compute region that the run was started in. + +See [Multi-region on the Vercel World](/worlds/vercel#multi-region) for how automatic pinning works and how to [select a region explicitly](/worlds/vercel#explicit-region-selection), and [Building a World](/worlds/building-a-world) for adding regional placement to a custom World. + +### Inflight cancellation + +`AbortController` and `AbortSignal` are now serializable, and signals can be passed into steps, so a workflow can abandon in-flight work without waiting for a step to finish. + +```typescript lineNumbers +import { sleep } from "workflow"; + +export async function raceWithTimeout() { + "use workflow"; + + const controller = new AbortController(); // [!code highlight] + + const result = await Promise.race([ + fetchData(controller.signal), // [!code highlight] + sleep("10s").then(() => null), + ]); + + if (result === null) { + controller.abort(); // [!code highlight] + } + + return result; +} + +async function fetchData(signal: AbortSignal) { + "use step"; + const response = await fetch("https://api.example.com/data", { signal }); + return response.json(); +} +``` + +The abort reaches a step that is already executing, not just the next one to start, so a `fetch()` in flight when the controller aborts is torn down. Cancellation stays cooperative: a step that ignores its signal still runs to completion. + +Cancelling from the outside gained detail too: [`run.cancel()`](/docs/api-reference/workflow-api/get-run) accepts a `cancelReason`, recorded on the cancellation event and shown in the run detail view. And cancellation now works in bulk: `workflow cancel` takes `--status pending|running` and `--workflowName` to cancel a batch in one operation, and the runs table in the web UI cancels every selected run in a single request. See [CLI and web UI](/docs/configuration/cli-and-web-ui). + +See [Cancellation](/docs/foundations/cancellation), [How cancellation works](/docs/how-it-works/cancellation), and the [in-flight cancellation changelog](https://vercel.com/changelog/workflow-sdk-now-supports-inflight-cancellation). + +### Run attributes + +Attach string metadata to a run with [`setAttributes()`](/docs/api-reference/workflow/set-attributes), or seed it at creation with the `attributes` option of [`start()`](/docs/api-reference/workflow-api/start), then search and filter runs by `key=value`. Attributes were available in beta and are generally available in v5 under their final names: the `experimental_setAttributes` alias is gone. + +Some attributes are set for you: a run started from inside another workflow or step is automatically tagged with the reserved `$parentRunId` and `$rootRunId` attributes, so a whole chain or fan-out of related runs can be found with a single attribute filter. + +Vercel Observability can search runs by attribute. `workflow inspect` and the local web UI read them from `world.analytics` on any World that implements it. + +See [Attributes](/docs/observability/attributes). + +### Richer serialization + +Everything that crosses a workflow/step boundary is serialized, and v5 widens what survives the trip with its identity intact. Errors — including your own classes and built-ins like `TypeError` — keep their class and `cause` chain through `WorkflowRunFailedError.cause`: + +```typescript lineNumbers +import { WorkflowRunFailedError } from "workflow/errors"; +import { getRun } from "workflow/api"; +declare class PaymentDeclinedError extends Error {} // @setup + +try { + await getRun(runId).returnValue; +} catch (error) { + if ( + error instanceof WorkflowRunFailedError && + error.cause instanceof PaymentDeclinedError // [!code highlight] + ) { + // your class, not a flattened generic Error + } +} +``` + +Workflow function references and [`Run`](/docs/api-reference/workflow-api/get-run) handles are serializable too, so a step can receive a workflow function to `start()` or a run handle to await. And when a value cannot cross a boundary, the failure is precise instead of generic: dedicated [`SerializationError`](/docs/api-reference/workflow-errors) and structured context-violation errors name the offending value and where it was used. + +See [Serialization](/docs/foundations/serialization). + +### A redesigned trace viewer + +The trace viewer has been rebuilt from the ground up, with an easily visible timeline, a minimap, pan, zoom, debug functionality, a new JSON viewer, keyboard navigation, and more. + +Vercel Observability uses this trace viewer for all runs, v4 included, but with v5, you get the same new design for self-hosted UI and local debugging. See [Observability](/docs/observability). + +The local tooling around it grew as well: on [Nitro](/docs/getting-started/nitro) the dev server has the web UI built in at `/_workflow`, `workflow inspect runs` accepts `--since`/`--until` listing windows, run lookups by name search past the backend's default 24-hour window, and Worlds can surface their own run fields in `inspect` output — the Vercel World shows each run's region. On any other framework, `createWorkflowWebHandler()` from `@workflow/web/handler` serves the same UI as one `Request` to `Response` handler under a base path of your choosing. + +Each run also carries more of the infrastructure it ran on. A step attempt records the compute instance that executed it, surfaced as **Compute Instance ID** in the run sidebar and as a `faas.instance` span attribute on flow and step spans, so a run that behaves oddly can be correlated with one warm instance. The sidebar also shows a copyable **Request ID** for looking the invocation up in your platform's logs. + +### Custom hook token retention + +A [Hook](/docs/foundations/hooks) token is normally reserved only while its workflow is running. Pass `experimental_minRetention` to [`createHook()`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) to keep the token unavailable for at least a given duration after the Hook is created, so a duplicate request arriving after the original run finished still collides instead of starting fresh work. This allows for durable [run idempotency](/docs/foundations/idempotency#run-idempotency) with custom durations. + +All three first-party Worlds now implement it: Vercel accepts up to 30 days, and the Local and Postgres Worlds default to the same ceiling (see [`WORKFLOW_LOCAL_HOOK_RETENTION_LIMIT_DAYS`](/docs/configuration/worlds) and its Postgres equivalent). A retained Hook remains readable with [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) after its run ends, but cannot be resumed. + +### Also new in 5.0 + +- **`start()` from inside a workflow.** Spawn a child run or hand off to a new run directly in a workflow function, without wrapping it in a step. See [Starting workflows](/docs/foundations/starting-workflows). +- **Stronger hook coordination.** `hook.getConflict()` resolves with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run) rather than a bare `{ runId }`, so a duplicate can `await conflict.status`, `await conflict.returnValue`, or `await conflict.cancel()` directly. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). +- **Support for more frameworks.** [React Router](/docs/getting-started/react-router) (v7 and v8, via Nitro) and [NestJS](/docs/getting-started/nestjs) are now supported. +- **A misrouted delivery no longer fails a run.** Runs are pinned to the deployment that created them. A delivery that arrives at a different deployment is now re-routed to the pinned one with backoff instead of failing, and only gives up with the new [`DEPLOYMENT_MISMATCH`](/docs/errors/deployment-mismatch) error once the recovery budget is spent. Nothing executes on the wrong deployment while this happens. In 4.x the same situation surfaced as an unexplained decryption failure. +- **A run cannot be forked across environments.** `start()` stamps the environment it was called from onto the queue message, and a deployment refuses a delivery whose run was created in a different environment. Previously a preview client and a production deployment could each hold half of one run ID. +- **An experimental QuickJS VM engine.** Set [`WORKFLOW_VM=quickjs`](/docs/configuration/runtime-tuning#workflow_vm) to run workflow functions in a QuickJS VM compiled to WebAssembly instead of `node:vm`, for platforms that do not provide `node:vm`. Replay semantics are identical, but the available globals are not: check the differences before switching an existing deployment. +- **An opt-in WebSocket transport for event writes** on the Vercel World, via [`WORKFLOW_EVENTS_TRANSPORT=ws`](/docs/configuration/worlds). HTTP remains the default. +- **An event arriving mid-replay no longer fails the run.** A hook resume or step completion landing while a replay is in flight used to be able to fail it with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). Writes now come back with the events the replay had not seen, and the event is held for whichever part of the workflow awaits it. A run only fails when the log is genuinely missing a position. + +## Breaking changes + +### Application code + +| Change | What to do | +| --- | --- | +| `runStep` removed from `workflow/api` | Call your step function directly; the compiler routes it through the step runtime. | +| `hook.getConflict()` resolves with a `Run` | Replace `conflict.runId` round trips through `getRun()` inside a step with the accessors on `conflict` directly. `conflict.runId` still works. | +| `experimental_setAttributes` removed | Import `setAttributes` instead, and `SetAttributesOptions` in place of `ExperimentalSetAttributesOptions`. The deprecated aliases are gone. | +| [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers), and [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) are async | They resolve a `Promise` now, so await the call before reaching for anything on it: `const world = await getWorld();` then `await world.start?.();`. This mainly affects the `instrumentation.ts` bootstrap that starts a World with background workers, such as the [Postgres World](/worlds/postgres#starting-the-world). Under TypeScript the old shape fails the build; in plain JavaScript it does not, and `.start` reads as `undefined` on a promise, so the worker never starts and nothing is logged. Writing `await getWorld()` is also valid on 4.x, so the change can be made before upgrading. | +| Duplicate step or workflow IDs fail the build | 4.x resolved collisions across non-exported workspace files last-write-wins. If you start encountering build failures after upgrading, rename the colliding functions. | +| The generated bundles are renamed, and the step route is gone | Only affects apps that wire the output of `workflow build` into their own server instead of using a framework integration. `flow.js` and `webhook.js` are now `flow.mjs` and `webhook.mjs` with named exports only, so a default import resolves to `undefined`. `step.js` became `__step_registrations.mjs`, an internal module that `flow.mjs` imports: delete the `POST /.well-known/workflow/v1/step` route rather than repointing it, because the flow handler now serves step deliveries too. See [Framework integrations](/docs/how-it-works/framework-integrations). | +| Default trace mode is `linked` | Update dashboards that assume one trace per run, or set `WORKFLOW_TRACE_MODE=continuous`. | +| The event-creation precondition guard is gone | `WORKFLOW_PRECONDITION_GUARD` no longer exists, and no World in the SDK rejects a write for a stale snapshot. Remove the variable if you set it. A replay that is behind now learns what it missed from the write it makes next instead of from a rejection, and [`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error) remains only for a custom World that would still rather refuse. | +| Event IDs are slot numbers, not ULIDs | An event ID is now its 1-based position in the run's log (`evnt_00000000000000000000000042`). It is unique only within a run, so pair it with the `runId` as a key, and it carries no timestamp: decoding one yields the Unix epoch rather than a creation time, so read `createdAt` off the event instead. Other entity IDs are unchanged. See [Event IDs](/docs/how-it-works/event-sourcing#event-ids). | +| A per-run event limit is enforced | The World supplies the ceiling — 25,000 events on the Local and Vercel Worlds — and a run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows). As a fallback, the ceiling can be tuned — see [Limits](/docs/configuration/runtime-tuning#limits). | +| Stream writes flush the first chunk immediately | The leading-edge flush window defaults to `0` instead of 10ms. Restore a window with `streamFlushIntervalMs` or `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`. | +| The workflow sandbox is stricter about nondeterminism | `WeakRef`, `FinalizationRegistry`, `Atomics.waitAsync`, and async `WebAssembly` compilation are no longer available inside workflow functions, and `crypto.subtle.digest` computes synchronously (same results, deterministic timing). Move code that needs them into a step. | +| `Date()` without `new` returns a string inside workflow functions | This matches the language spec, and 4.x returned a `Date` object. Use `new Date()` where you need the object. Subclassing `Date` now works, so libraries like `TZDate` keep their identity across the sandbox boundary. | +| `NestLocalBuilder` moved out of `@workflow/nest` root | Import it from `workflow/nest/builder`, so `WorkflowModule` no longer pulls the build toolchain into the runtime bundle. `NestVercelBuilder` lives at `workflow/nest/vercel-builder`. | +| `workflow/internal/private` and `@workflow/core/private` removed | These were never public API. The compiler no longer emits imports from them, so regenerate build output rather than importing them yourself. | +| The legacy trace viewer is gone from `@workflow/web-shared` | Only affects apps embedding the observability UI. `RunTraceView` and `WorkflowTraceViewer` are removed, and `NewTraceViewer` is now `TraceViewer` (module path `trace-viewer`). `Span`, `SpanEvent`, and `Trace` are still exported from the package root. | + +Runs created on 4.x keep executing on the deployment that created them, so upgrading a deployment does not migrate in-flight runs. One storage caveat is worth knowing about: failed runs stored by `@workflow/world-postgres` before the upgrade read back with `error: undefined`, because the payload lives in the legacy `error` text column rather than `errorJson`. + +## If you maintain a World + +The World interface — the storage, queue, streaming, and analytics contract that a Workflow SDK deployment runs against — also changed in v5, and those changes are not visible from application code. If you implement `World` yourself, or maintain a build integration that compiles workflow files, upgrade it alongside the SDK: see [Upgrading a World to v5](/worlds/upgrading-to-v5) for the full interface delta and the contract changes that affect existing implementations. There is a separate skill for that job, since none of it applies to application code: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5 +``` + +Applications on the [Vercel](/worlds/vercel), [Local](/worlds/local), and [Postgres](/worlds/postgres) Worlds need nothing from that page: those implementations ship with the SDK and are already on the v5 spec. diff --git a/docs/content/worlds/v4/vercel.mdx b/docs/content/worlds/v4/vercel.mdx index f2352126d9..749c225df5 100644 --- a/docs/content/worlds/v4/vercel.mdx +++ b/docs/content/worlds/v4/vercel.mdx @@ -46,14 +46,14 @@ For self-hosted deployments, use the [Postgres World](/worlds/postgres). For loc **Multi-region support is available starting with `workflow` version - 5.0.0-beta.33.** On 5.x, workflow runs are pinned to the region that creates them — + 5.0.0.** On 5.x, workflow runs are pinned to the region that creates them — storage, queuing, and streams are served region-locally instead of routing through `iad1`. See [Multi-region on the v5 version of this page](/v5/worlds/vercel#multi-region). The limitations below apply to the 4.x release line, which will not support multi-region. -- **Single-region deployment** - On the 4.x release line, the backend infrastructure is used only in `iad1`. Applications in other regions will route workflow requests to `iad1`, which may result in higher latency. For best performance, deploy your Vercel apps using Workflow to `iad1` — or upgrade to `workflow` 5.0.0-beta.33+ for multi-region support. +- **Single-region deployment** - On the 4.x release line, the backend infrastructure is used only in `iad1`. Applications in other regions will route workflow requests to `iad1`, which may result in higher latency. For best performance, deploy your Vercel apps using Workflow to `iad1` — or upgrade to `workflow` 5.0.0+ for multi-region support. - **Data residency** - On the 4.x release line, independently of the deployment location of your application, the data for your workflows will be stored in the `iad1` region. diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index 8660fd204c..398c0425ca 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -7,6 +7,7 @@ prerequisites: - /docs/deploying - /docs/foundations/workflows-and-steps related: + - /worlds/upgrading-to-v5 - /worlds/local - /worlds/postgres - /worlds/vercel @@ -22,6 +23,10 @@ A **World** is the abstraction that allows workflows to run on any infrastructur **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of how to implement the World interface with a database backend and graphile-worker for queuing. + + Already have a World on the 4.x spec? See [Upgrading a World to v5](/worlds/upgrading-to-v5) for the interface delta and the contract changes, rather than reading this guide top to bottom. + + ## What is a World? A World connects workflows to the infrastructure that powers them. The World interface abstracts three core responsibilities: @@ -36,21 +41,37 @@ interface WorldCapabilities { hookRetention?: { active: boolean; }; + maxConcurrency?: boolean; } interface World extends Storage, Queue, Streamer { specVersion: number; capabilities?: WorldCapabilities; + analytics?: Analytics; start?(): Promise; close?(): Promise; getEncryptionKeyForRun?(run: WorkflowRun): Promise; getEncryptionKeyForRun?(runId: string, context?: Record): Promise; + createRunId?(options?: Readonly>): string; + describeRun?(run: Readonly>): Record | null | Promise | null>; + processExitTriggersQueueRedelivery?: boolean; } ``` `specVersion` is required. See [Declaring the spec version](#declaring-the-spec-version). -The optional `capabilities` object advertises additional behavior. Set `hookRetention.active` to `true` only when the World implements Hook token retention. Note what is *not* in there: [slot-numbered event IDs](#event-id-allocation) are a requirement of this contract, not a capability to opt into. The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled. +The optional `capabilities` object advertises additional behavior, and every capability **fails closed**: a missing member means "unsupported", and the runtime keeps its conservative behavior. Set `hookRetention.active` to `true` only when the World implements Hook token retention. Set `maxConcurrency` only when the World's queue supports `maxConcurrency`-limited consumption (used by `WORKFLOW_SEQUENTIAL_REPLAYS=1`). + +Note what is deliberately *not* in there. [Slot-numbered event IDs](#event-id-allocation) are a requirement of this contract rather than a capability, so there is no flag to set and no fallback path if you skip them. + +The remaining optional members: + +- `analytics` provides metadata-only listings of runs, steps, events, hooks, waits, and attributes for observability surfaces — `workflow inspect` and the local web UI read from it, including attribute search. See [Analytics Interface](#analytics-interface-optional). +- `start()` initializes background tasks (for example, queue polling); `close()` releases resources like connection pools and listeners. +- `getEncryptionKeyForRun()` returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled. +- `createRunId()` mints the ID for a new run. Implementations may embed world-specific metadata as long as the result stays a valid ULID — this is how [multi-region placement](/worlds/vercel#multi-region) works: `@workflow/world-vercel` reads `options.region` (the `start()` options bag) and embeds a region tag. When omitted, the runtime generates a standard monotonic ULID. +- `describeRun()` returns world-specific display fields for a run (for example, a region decoded from its ID) that tooling like `workflow inspect` renders as extra columns. It must be cheap, tolerate missing fields, and never throw. +- `processExitTriggersQueueRedelivery` tells the runtime how to handle an exhausted replay budget: `true` means it exits and relies on queue redelivery; `false` (or absent) means it writes `run_failed` best-effort and returns. ### Declaring the spec version @@ -88,13 +109,16 @@ interface Storage { runs: { get(id: string, params?: GetWorkflowRunParams): Promise; list(params?: ListWorkflowRunsParams): Promise>; + // Optional: backs setAttributes(). Omitting it means run attributes are + // unsupported on this World — the SDK helper no-ops with a warning. + experimentalSetAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise; // Optional: long poll for a terminal status (see below) waitForTerminalStatus?(id: string, params?: WaitForTerminalRunStatusParams): Promise; }; steps: { - get(runId: string | undefined, stepId: string, params?: GetStepParams): Promise; + get(runId: string, stepId: string, params?: GetStepParams): Promise; list(params: ListWorkflowRunStepsParams): Promise>; }; @@ -232,6 +256,8 @@ The SDK also sends an internal `HealthCheckPayload` through the same workflow qu - Support configurable retry policies - Track attempt counts for observability - Implement idempotency using the `idempotencyKey` option when provided +- Honor the `delaySeconds` option — waits (`sleep()`) are delivered as ordinary delayed continuations on the workflow queue, so a World that ignores `delaySeconds` redelivers immediately and busy-loops every sleeping run +- Optionally honor the `region` option, a routing hint naming the region a message should be dispatched in; Worlds without a regional dimension ignore it ## Streamer Interface @@ -291,6 +317,53 @@ Streams are identified by a combination of `runId` and `name`. Each workflow run `getChunks` returns a paginated snapshot of currently available chunks (unlike `get` which returns a live `ReadableStream` that waits for new chunks). `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete — useful for resolving negative `startIndex` values into absolute positions. +## Analytics Interface (Optional) + +The optional `analytics` namespace provides **metadata-only** access to runs and their related records. It is intended for observability and discovery surfaces such as dashboards, `workflow inspect`, and the local web UI. Implementations can optimize these queries independently of payload storage. + +Tooling feature-detects this namespace. When `world.analytics` is available, tooling prefers it for listings and attribute search; otherwise, it uses the Storage APIs. Among the first-party Worlds, only the [Vercel World](/worlds/vercel) currently implements it. The Local and Postgres Worlds leave it `undefined`. + +{/* @skip-typecheck - interface definition, not runnable code */} +```typescript +interface Analytics { + runs: { + get(runId: string): Promise; + list(params?: AnalyticsListRunsParams): Promise>; + }; + attributes: { + // Distinct attribute keys observed on runs in the window, with run + // counts and first/last-seen timestamps, ordered alphabetically. + list(params?: AnalyticsListAttributesParams): Promise>; + }; + steps: { + get(runId: string, stepId: string): Promise; + list(params: AnalyticsListRunScopedParams): Promise>; + }; + events: { + get(runId: string, eventId: string): Promise; + list(params: AnalyticsListEventsParams): Promise>; + listByCorrelationId(params: AnalyticsListEventsByCorrelationIdParams): Promise>; + }; + hooks: { + get(hookId: string, params?: { runId?: string }): Promise; + list(params: AnalyticsListHooksParams): Promise>; + }; + waits: { + get(runId: string, waitId: string): Promise; + list(params: AnalyticsListWaitsParams): Promise>; + }; +} +``` + +If you implement this namespace, observe the following requirements: + +- **Metadata only.** Analytics responses must not include run inputs or outputs, step data, hook tokens, or other payload data. The `Analytics*` schemas exported by `@workflow/world` define the complete set of permitted fields. Payload retrieval remains exclusively available through the Storage APIs. +- **Attribute filters use the latest value.** `runs.list({ attributes })` evaluates each filter against the run's most recently written value for that key. A request may contain up to eight key-value pairs. Reserved `$`-prefixed attributes are valid filters, although users cannot write them directly. +- **Time boundaries must be paired.** `startTime` and `endTime` may either both be omitted or both be supplied. Responses may include `pageInfo` describing retention and the available query window. Implementations with retention limits should return this information so tooling can present valid date ranges. +- **Results may be eventually consistent.** Analytics records may lag live workflow state. Consumers use this namespace for discovery and listing; Storage remains the authoritative interface for current workflow state and payload access. + +See the [Analytics API reference](/docs/api-reference/workflow-runtime/world/analytics) for per-method parameters, row shapes, and `pageInfo` semantics. + ## Reference Implementations Study these implementations for guidance: diff --git a/docs/content/worlds/v5/meta.json b/docs/content/worlds/v5/meta.json index 041f6328fa..657f978b77 100644 --- a/docs/content/worlds/v5/meta.json +++ b/docs/content/worlds/v5/meta.json @@ -1,4 +1,10 @@ { "title": "Worlds", - "pages": ["local", "vercel", "postgres", "building-a-world"] + "pages": [ + "local", + "vercel", + "postgres", + "building-a-world", + "upgrading-to-v5" + ] } diff --git a/docs/content/worlds/v5/postgres.mdx b/docs/content/worlds/v5/postgres.mdx index f8b205467a..7aedfe8d63 100644 --- a/docs/content/worlds/v5/postgres.mdx +++ b/docs/content/worlds/v5/postgres.mdx @@ -23,12 +23,10 @@ Install the Postgres World package in your workflow project: ``` -Use the same release channel for `workflow` and `@workflow/world-postgres`. If -your app uses a beta or other prerelease Workflow version, install the matching -prerelease Postgres World package, such as -`npm install @workflow/world-postgres@beta`. Mismatched versions fail before -starting a run with an error that names the spec versions the runtime supports -and the one the World declares. +Keep `workflow` and `@workflow/world-postgres` on the same major version and release +cycle. If your app uses a prerelease Workflow version, install the matching prerelease Postgres +World package. Mismatched versions fail before starting a run with an error that names the +spec versions the runtime supports and the one the World declares. Configure the required environment variables to use the world and point it to your PostgreSQL database: diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx new file mode 100644 index 0000000000..3ce2d8b0e8 --- /dev/null +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -0,0 +1,159 @@ +--- +title: Upgrading a World to v5 +description: Port a custom World implementation from the v4 spec to v5, including the interface delta, the contract changes, and the new event ID allocation rules. +type: guide +summary: What changed in the World spec between v4 and v5, and how to update a custom World. +prerequisites: + - /worlds/building-a-world +related: + - /docs/whats-new + - /worlds/building-a-world + - /worlds/postgres + - /worlds/vercel +--- + +This page is for people who implement the `World` interface themselves, or who maintain a build integration that compiles workflow files. It covers what changed between the v4 and v5 spec, which of those changes break an existing implementation, and what new surface is worth adopting. + +Three things are required to be a v5 World: the [interface changes](#interface-changes), the [contract changes](#contract-changes), and [event ID allocation](#event-id-allocation). The last one is the largest piece of work and the only one that is not visible from the type signatures. Everything under [new optional surface](#new-optional-surface) can wait. + +If your application runs on the [Vercel](/worlds/vercel), [Local](/worlds/local), or [Postgres](/worlds/postgres) World, you need nothing from this page. Those implementations ship with the SDK and are already on the v5 spec. For the application-facing changes, see [What's new in v5](/docs/whats-new). + +The fastest way to start is to install the World migration skill and hand the job to an agent: + +```bash +npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5 +``` + + + +This is a different skill from `migrating-workflow-v4-to-v5`, which upgrades the application code. If the same repository does both, run the application one first. + +If you would rather work from the diff directly: + + + +We're working on bringing back World compatibility tests and reporting on the [Worlds page](/worlds), to make it easier to see which Workflow versions each World is compatible with. + +## Spec versions + +A World declares the protocol version it speaks on `specVersion`, and that number is stamped on every run it creates. Declare `SPEC_VERSION_CURRENT` from `@workflow/world`, not a literal: + +{/* @skip-typecheck - partial World, the other members are elided */} +```typescript +import { SPEC_VERSION_CURRENT } from '@workflow/world'; + +export function createWorld(): World { + return { + specVersion: SPEC_VERSION_CURRENT, + // ... + }; +} +``` + +In v4 the runtime required that number to equal its own current version exactly. In v5 it checks the declaration against a range, `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]`, before it creates or replays anything, and refuses a World outside it with an error naming both the range and what your World declared. + +The two bounds are the same version today, so exactly one is accepted. That is a consequence of [event ID allocation](#event-id-allocation) being a requirement rather than an option: a World declaring anything lower allocates IDs the runtime cannot read positions out of, and admitting it would only move the failure from startup into the middle of a run. The check is written as a range because the constants answer different questions and come apart while a version bump is staged — the ceiling rises when the runtime learns to read the next version, the floor when that version becomes the one Worlds stamp. + +Using the constant is what keeps the check passing across upgrades. It moves with the `@workflow/world` version your package resolves, so a bump raises your declaration and the runtime's floor together, while a hard-coded number leaves your World a version behind the next bump and gets it rejected by the runtime it ships alongside. This is worth re-checking if you followed earlier guidance: `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` names the version that introduced slot-numbered IDs and is equal to `SPEC_VERSION_CURRENT` today, but declaring it pins you to a literal by another name. `@workflow/world-vercel` declared it and now declares the current version instead. Keep `@workflow/world` in the same release channel as the `workflow` version your users install. + +Runs carry a spec version too, and a run keeps the version it was created under for its whole life. Read the stamped version off the run rather than assuming every run matches what your World declares today. Bumping the constant does not reach runs already in your store: their version is persisted, every version test in the runtime is a lower bound, and a run's event ID scheme is resolved from what is stored. + +## Interface changes + +These break an existing v4 implementation. Each one is a signature or module-shape change your World has to follow. + +| Change | What to do | +| --- | --- | +| `getWorld()` and `createWorld()` are async | `await getWorld()`. This already worked in 4.x, so it is safe to write before upgrading. See [`getWorld`](/docs/api-reference/workflow-runtime/get-world). | +| Stream methods moved to `world.streams.*`, with `runId` first | `writeToStream(name, runId, chunk)` becomes `streams.write(runId, name, chunk)`; likewise `writeToStreamMulti` → `streams.writeMulti`, `closeStream` → `streams.close`, `readFromStream` → `streams.get`, `getStreamChunks` → `streams.getChunks`, `listStreamsByRunId` → `streams.list`. | +| `world.steps.get()` requires `runId` | The first argument is no longer `string \| undefined` — pass the run ID that owns the step. | +| `events.listByCorrelationId()` requires `runId` | A correlation ID identifies a step, hook or wait within its run, not across runs, so the lookup is scoped to one run — pass the run that owns the correlation ID. Same for `analytics.events.listByCorrelationId()`. A World that paginates by event ID needs the scope in its cursor comparison too, since two runs can hold the same correlation ID. | +| `createLocalWorld()` and `createVercelWorld()` removed | Export a `createWorld()` factory from your package instead, matching the first-party Worlds. The arguments are unchanged. | +| Worlds are injected into host bundles at build time | Selection is static rather than resolved dynamically at runtime. Verify your World still resolves after the upgrade, and that its module graph survives bundling. | +| `@workflow/world-local` stream chunks moved | Chunks live at `streams/chunks//`. Files written in the old flat layout are not read back, so local development state from 4.x can be deleted. Only relevant if your World inherited that layout. | + +## Contract changes + +These do not change any signature, so an implementation ported by types alone will compile and then behave incorrectly. + +**Suspension and dispatch.** The asymmetric `{ timeoutSeconds }` wait-return contract is gone. A wait is now an ordinary queue continuation with `delaySeconds`, and a suspension dispatches its waits and its steps as one parallel batch. A queue that assumed one message per suspension needs to handle the batch. + +**Step queue topics are retired.** The `'step'` queue kind no longer exists. Queued steps travel on the workflow topic, carrying `stepId` and `stepName` in the payload, and execute in the combined flow handler. A World that provisioned separate `__wkf_step_*` topics can drop them. + +**Capabilities fail closed.** The optional `capabilities` object advertises behavior the runtime otherwise assumes is absent. An unadvertised capability costs performance, never correctness, so a partial World stays correct while it catches up. The reverse is not true: advertising something you do not enforce removes a guard the runtime was relying on. Only set a flag once the behavior is implemented. + +**A stale replay no longer has to be refused.** v5 shipped with a `preconditionGuard` capability for a World that rejected an event creation whose snapshot was behind the log. It is gone, and nothing replaced it: allocating positions at the commit means a reader's log is a prefix rather than a prefix with a hole, replay is deterministic on a prefix, and a write reports the events it was pushed past — so a stale replay costs a merge instead of a rejection. If you implemented the guard, you can delete it. `PreconditionFailedError` and the runtime's handling of it remain for a World that allocates positions away from the commit (see [Event ID allocation](#event-id-allocation)); no World in the SDK throws it. + +**Event creation can return a delta.** `events.create()` may return events alongside the one it created, in `events` with a matching `cursor` and `hasMore`. The runtime uses this to skip a follow-up `events.list` round trip on `run_started`, on step-terminal writes that carried a `sinceCursor`, and on `hook_received` writes that carried `preloadEvents`. All three are advisory: a World that returns only the created event stays correct and pays one more round trip. + +## Event ID allocation + +This is the largest change for a World implementation, and it is required. + +In v4 an event ID was a ULID your World minted however it liked. In v5 an event ID is its **slot**: `evnt_` followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so a run's first event is `evnt_00000000000000000000000001`. Format one with `slotToEventId()` from `@workflow/world`. + +There is no capability to declare and no fallback path. The runtime reads a position out of every ID it loads and fails the run when it cannot, so a World whose IDs are not positions cannot replay a single workflow — it will pass a type check, start runs, and fail on the first replay with `Event id is not slot-numbered`. + +The scheme exists for what a reader can conclude from a log it just fetched: positions are dense, so a truncated log is distinguishable from a complete one by its length alone. The runtime relies on that, and it fails a run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log) rather than replay across a hole, so three rules bind an implementation: + +- **Uniqueness.** Settle a race for a position where the store settles it, with a unique constraint on `(runId, eventId)` or a conditional write, not by reading the maximum in your own process and adding one. +- **Density.** Positions run from 1 with no holes. A writer that loses a race re-derives its position from the store instead of incrementing a local number, which would leave a permanent hole. +- **Bump and report.** `events.create()` params carry `eventCount`, so the expected position is `eventCount + 1`. When it is taken, do not reject the write: commit at the next free position and return the events you skipped on the success response. A stale count is the normal case for a parallel fan-out, and rejecting it would serialize writes the runtime deliberately issues concurrently. +- **Allocate at the commit.** Take the position in the same operation that appends the event, not earlier. This is what makes a reader's log a prefix of the run's log rather than a prefix with a hole in it: nothing can land behind a position a reader has already passed. A World that mints a position in a request handler and commits later breaks the property every replay depends on, and is the only kind that still has a use for a stale-write rejection. + +[Event ID Allocation](/worlds/building-a-world#event-id-allocation) carries the full rules, and [Event IDs](/docs/how-it-works/event-sourcing#event-ids) covers what the format means for anything that reads an ID back. + +One consequence is specific to an upgrade, and it is the thing to plan around. + + + **Runs already in your store cannot be replayed by the new code.** A ULID-numbered run is not readable as positions, and the runtime refuses it rather than guessing, so there is no mixed-scheme mode and no per-run fallback. Drain those runs on your 4.x build before deploying a v5 World, or accept that the ones still in flight will fail. On a platform where a run executes on the deployment that created it — Vercel, for instance — this resolves itself: those runs finish on the build that started them and never meet the new code. Anywhere a single deployment serves every run, sequencing matters. + + +## New optional surface + +None of this is required. Each entry is a hook the runtime uses if your World provides it, and routes around if it does not. + +| Member | What it buys | +| --- | --- | +| `capabilities` | Advertises `hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, and `maxConcurrency`. See the contract note above about failing closed. Event ID allocation is *not* in here: it is a requirement, not a capability. | +| `analytics` | A metadata-only read namespace for observability surfaces. Payload-bearing reads stay on `runs`, `steps`, `events`, and `hooks`. | +| `runs.experimentalSetAttributes` | Backs `setAttributes()` from application code. Without it, run attributes are unavailable. | +| `runs.cancelMany` | Bulk cancellation: up to 500 unique run IDs per request (`BULK_CANCEL_MAX_RUN_IDS`), an optional `cancelReason` of at most 512 characters, and a per-run outcome for every ID. Without it, the runtime falls back to bounded-concurrency individual cancels. | +| `getRuntimeDeadline()` | The absolute time the current invocation will be terminated. The runtime derives its inline replay budget from this, so a host with a long function timeout gets more work per invocation. Without it, the budget is a flat two minutes. See [`WORKFLOW_V2_TIMEOUT_MS`](/docs/configuration/runtime-tuning#workflow_v2_timeout_ms). | +| `getEnvironment()` | The environment your World's writes are attributed to. Must be synchronous, side-effect free, and match what the backend will actually apply. A wrong answer is worse than `undefined`, because callers use it to detect cross-environment mismatches. Worlds with a single tenant should omit it. | +| `createRunId(options)` | Mints the bare run ID; the core adds the `wrun_` prefix. Return a valid ULID. You may embed World-specific metadata in it, as `@workflow/world-vercel` does with a region identifier. Read only the option keys you recognize and ignore the rest. | +| `describeRun(run)` | World-specific display fields for observability surfaces. | +| `getEncryptionKeyForRun()` | Returns a ready-to-use 32-byte AES-256 key. Without it, data is stored unencrypted. Two overloads: pass the `WorkflowRun` when you have it, or a `runId` plus opaque World context when the entity is not available locally. | +| `resolveLatestDeploymentId()` | Resolves `deploymentId: 'latest'`. Only meaningful for Worlds where deployment routing exists. | +| `close()` | Releases connection pools and listeners so CLI commands and short-lived processes can exit without `process.exit()`. | +| `streams.streamFlushIntervalMs` | Sets the stream flush window. The v5 default is `0`, so the first chunk flushes immediately; set a value to coalesce writes again. | +| Hook token retention | `Hook.tokenRetentionUntil` marks the earliest time a token may become available after its run ends. Keep the owning run readable at least that long, and honor `hook_disposed` as an immediate release. Declare `capabilities.hookRetention.active` only once this is implemented, since the runtime otherwise rejects retained hooks before registration. | +| Hook resume dedup | `resumeHook()` writes `hook_received` and dispatches the queue message in parallel when the backend collapses concurrent writes carrying the same `(runId, resumeId)` onto one committed event. Declare `capabilities.hookResumeDedup` only if you enforce that constraint. A World that accepts `resumeId` without enforcing it must leave the flag unset, which keeps the sequential path. See [Resilient hook resumption](/docs/changelog/resilient-resume). | + +## If you also maintain a build integration + +Compiling workflow files changed independently of the storage contract. + +| Change | What to do | +| --- | --- | +| The `client` SWC transform mode was removed | It merged into `step` mode. Integrations passing `mode: 'client'` pass `mode: 'step'`. | +| `stepEntrypoint` removed from `workflow/runtime` | Steps execute through the combined workflow handler the framework integrations generate. Custom hosts use `getWorldHandlers()`. | +| Step, workflow and webhook bundles are ESM | Generated output moved from CJS to ESM, with a `createRequire` banner for CJS dependencies. The VM-executed workflow bundle stays CJS. The CLI's standalone output is renamed to match: `flow.mjs`, `webhook.mjs`, and `__step_registrations.mjs` in place of `flow.js`, `webhook.js`, and `step.js`. Consumers import the namespace rather than a default. | +| `workflow/internal/private` and `@workflow/core/private` removed | These were never public API. The compiler no longer emits imports from them, so regenerate build output rather than importing them yourself. | +| Duplicate step or workflow IDs fail the build | 4.x resolved collisions across non-exported workspace files last-write-wins. A build integration that derived IDs from a partial path may now produce build failures. | + +## Verifying the upgrade + +Run a workflow end to end against your World, not just the type checker. The contract changes above compile cleanly and fail at runtime. + +The cases worth covering explicitly: + +- A run that suspends on a step and one that suspends on a wait, to exercise the batched dispatch. +- A parallel fan-out, so concurrent `events.create()` calls race on the same position or the same precondition. +- A hook resumed after its run has already progressed, and a hook whose token is retained past the end of its run. +- A stream written and read back, including a stream closed before the reader attaches. +- A run created under an older spec version, if your World has any, read back by the new code. + +`@workflow/world-testing` is the shared suite the first-party Worlds run, and it now covers event ID allocation directly: `numbers events by position` fails a World whose IDs do not decode to slots, whose run is not dense from 1, or whose IDs are not in canonical form — a World padding to a different width sorts its own log wrongly past ten events. Run it against your World before the end-to-end cases above; it turns the failure that would otherwise appear on a first replay into one line of test output. + +The first-party implementations in `packages/world-local` and `packages/world-postgres` are the reference for everything else, and their test suites are the closest thing to full conformance while the compatibility tests are being rebuilt. diff --git a/docs/content/worlds/v5/vercel.mdx b/docs/content/worlds/v5/vercel.mdx index 49ab389883..c92b862f15 100644 --- a/docs/content/worlds/v5/vercel.mdx +++ b/docs/content/worlds/v5/vercel.mdx @@ -47,9 +47,9 @@ For self-hosted deployments, use the [Postgres World](/worlds/postgres). For loc The Vercel World runs in every [Vercel Function region](https://vercel.com/docs/regions). Each workflow run is pinned to a single region at creation time: its stored state, queue dispatch, and streams are all served from that region — no cross-region round trips on the hot path. When your application is deployed in the run's region (the automatic case below), step execution is region-local too. - Multi-region requires `workflow` version **5.0.0-beta.33** or later. - The 4.x release line does not support region pinning — runs created by - 4.x always live in `iad1`. + Multi-region requires `workflow` version **5.0.0** or later. The 4.x + release line does not support region pinning — runs created by 4.x always + live in `iad1`. ### Automatic region pinning @@ -97,6 +97,8 @@ const run = await start(myWorkflow, [input], { region: "sfo1" }); Workflow observability is built into the Vercel dashboard on your project page. It respects your existing authentication and project permission settings. +The Vercel World implements the optional [`world.analytics`](/docs/api-reference/workflow-runtime/world/analytics) interface, backed by the same observability data pipeline as the dashboard. Two behaviors are specific to this implementation: listings scan significantly faster when bounded with a `startTime`/`endTime` window, and the queryable window is capped by your plan's observability lookback — requesting an older window fails with `observability-upgrade-required`, and responses carry `pageInfo` (current and maximum lookback) so tools can size date ranges. + The `workflow` CLI commands open a browser window deeplinked to the Vercel dashboard: ```bash diff --git a/docs/scripts/check-docs-smoke.mjs b/docs/scripts/check-docs-smoke.mjs index cfce8c4d54..32291f445f 100644 --- a/docs/scripts/check-docs-smoke.mjs +++ b/docs/scripts/check-docs-smoke.mjs @@ -210,6 +210,10 @@ const checks = [ name: 'HTML meta - worlds building-a-world', run: () => assertHtmlMeta('/worlds/building-a-world', '/og/worlds'), }, + { + name: 'HTML meta - worlds upgrading-to-v5 (v5)', + run: () => assertHtmlMeta('/v5/worlds/upgrading-to-v5', '/og/worlds'), + }, { name: 'HTML meta - world vercel (v5)', run: () => assertHtmlMeta('/v5/worlds/vercel', '/og/worlds/vercel'), diff --git a/packages/docs-typecheck/src/type-checker.ts b/packages/docs-typecheck/src/type-checker.ts index e4967d8bbb..2cbe343f62 100644 --- a/packages/docs-typecheck/src/type-checker.ts +++ b/packages/docs-typecheck/src/type-checker.ts @@ -100,6 +100,9 @@ const compilerOptions: ts.CompilerOptions = { '@workflow/serde': [path.join(repoRoot, 'packages/serde/dist/index')], '@workflow/vitest': [path.join(repoRoot, 'packages/vitest/dist/index')], '@workflow/world': [path.join(repoRoot, 'packages/world/dist/index')], + '@workflow/world-local': [ + path.join(repoRoot, 'packages/world-local/dist/index'), + ], '@workflow/world-sim': [ path.join(repoRoot, 'packages/world-sim/dist/index'), ], diff --git a/skills/migrating-workflow-v4-to-v5/SKILL.md b/skills/migrating-workflow-v4-to-v5/SKILL.md new file mode 100644 index 0000000000..9387ee1196 --- /dev/null +++ b/skills/migrating-workflow-v4-to-v5/SKILL.md @@ -0,0 +1,285 @@ +--- +name: migrating-workflow-v4-to-v5 +description: Upgrades an app from Workflow SDK 4.x to 5.0. Use when bumping the `workflow` / `@workflow/*` dependencies to v5, or when hitting removed v4 APIs — `runStep`, `stepEntrypoint`, `workflow/internal/private`, `@workflow/core/private`, `writeToStream` / `closeStream` / `readFromStream` on a World, `world.steps.get` without a runId, `hook.getConflict()` returning `{ runId }`, `experimental_setAttributes`, `createLocalWorld` / `createVercelWorld`, `NestLocalBuilder` imported from `@workflow/nest`, or an SWC transform invoked with `mode: 'client'`. +metadata: + author: Vercel Inc. + version: '0.2.9' +--- + +# Migrating Workflow SDK 4.x to 5.0 + +Workflow SDK 5.0 keeps the programming model from 4.x. `"use workflow"` / `"use step"`, `start()`, `getRun()`, hooks, webhooks, streams, `sleep()`, retries, and the event log are unchanged, so most application code compiles as-is. + +The breaking changes are concentrated in three places: + +1. **Runtime entrypoints** (`workflow/api`, `workflow/runtime`) — two exports removed. +2. **The `World` interface** — only relevant if the app implements a custom World or calls `getWorld()` directly. +3. **Build integrations** — `@workflow/nest` subpaths, and private compiler subpaths that were never public. + +Do not rewrite workflow or step bodies. If you find yourself restructuring business logic, you have gone outside this migration. + +## Intake + +Before editing, establish: + +1. **Which packages are installed.** Read `package.json` for `workflow` and every `@workflow/*` dependency. +2. **Whether the app touches the runtime.** Grep for `getWorld`, `createWorld`, `getWorldHandlers`, `writeToStream`, `readFromStream`, `closeStream`, `listStreamsByRunId`, `getStreamChunks`, `world.steps`, `listByCorrelationId`, `runStep`, `stepEntrypoint`, `internal/private`, `core/private`. +3. **Whether the app implements a custom World.** Grep for `implements World`, `: World`, `createLocalWorld`, `createVercelWorld`, `startWorkflowWorld`. +4. **Which framework integration is in use.** `@workflow/next`, `@workflow/nest`, `@workflow/nitro`, `@workflow/sveltekit`, `@workflow/vite`, `@workflow/nuxt`, `@workflow/astro`, or the CLI. +5. **Whether `hook.getConflict()` is used.** Grep for `getConflict`. +6. **Whether the app calls the compiler directly.** Grep for `mode: 'client'`, `transformSync`, `swc-plugin-workflow`. Only custom build integrations do this. +7. **Whether `experimental_setAttributes` is used.** Grep for `experimental_setAttributes`. + +Report anything in 2–7 that the app does not use as "not applicable" rather than silently skipping it. + +## Step 1 — bump the dependencies + +Move every `workflow` and `@workflow/*` dependency to `^5.0.0`. They are released together and must not be mixed across majors — a 4.x `@workflow/next` against a 5.x `workflow` will fail at build time. + +```json +{ + "dependencies": { + "workflow": "^5.0.0", + "@workflow/next": "^5.0.0" + } +} +``` + +Then reinstall and rebuild so the compiler regenerates the workflow/step bundles and the generated routes under `.well-known/workflow/v1/`. Never hand-edit generated output. + +Node requirements are unchanged: `^18 || ^20 || ^22 || ^24`. + +## Step 2 — apply the mechanical rewrites + +Apply each rule only where the pattern actually appears. + +### `getWorld()` and `createWorld()` are async + +```ts +// v4 +const world = getWorld(); + +// v5 +const world = await getWorld(); +``` + +This also applies to `getWorldHandlers()`. Awaiting was already correct in 4.x, so this edit is safe to make before the dependency bump. Propagate `async` up the call chain rather than wrapping in `.then()` chains. + +### `createLocalWorld()` and `createVercelWorld()` removed + +First-party World packages now expose a single `createWorld()` factory. The arguments are unchanged — this is a rename only. + +```ts +// v4 +import { createLocalWorld } from '@workflow/world-local'; +const world = createLocalWorld({ dataDir }); + +// v5 +import { createWorld } from '@workflow/world-local'; +const world = createWorld({ dataDir }); +``` + +The same applies to `createVercelWorld` from `@workflow/world-vercel`. + +### `runStep` removed from `workflow/api` + +Call the step function directly. The compiler routes the call through the step runtime. + +```ts +// v4 +import { runStep } from 'workflow/api'; +const result = await runStep(chargeCard, [orderId]); + +// v5 +const result = await chargeCard(orderId); +``` + +### `stepEntrypoint` removed from `workflow/runtime` + +Framework integrations generate step routes themselves — delete hand-written step routes that existed only to call `stepEntrypoint`. For a custom host, serve the handlers from `getWorldHandlers()` instead. + +### `workflow/internal/private` and `@workflow/core/private` removed + +These subpaths were never public API. Remove the imports; if generated build output still references them, it is stale — reinstall and rebuild rather than restoring the imports. + +### Stream methods moved to `world.streams.*` with `runId` first + +| v4 | v5 | +| --- | --- | +| `world.writeToStream(name, runId, chunk)` | `world.streams.write(runId, name, chunk)` | +| `world.writeToStreamMulti(name, runId, chunks)` | `world.streams.writeMulti(runId, name, chunks)` | +| `world.closeStream(name, runId)` | `world.streams.close(runId, name)` | +| `world.readFromStream(name, startIndex?)` | `world.streams.get(runId, name, startIndex?)` | +| `world.getStreamChunks(name, runId, options?)` | `world.streams.getChunks(runId, name, options?)` | +| `world.listStreamsByRunId(runId)` | `world.streams.list(runId)` | + +The argument order flipped, so a rename alone silently passes a stream name where a run ID is expected. Swap the arguments at every call site. `readFromStream` had no `runId` parameter at all — `streams.get` requires one, so thread the owning run ID through to the call. + +Application code that uses `getWritable()` inside a workflow or reads `run.readable` is unaffected; this rule is only for direct `World` access. + +### `world.steps.get()` requires a `runId` + +The first parameter was `string | undefined` and is now `string`. Pass the run ID that owns the step. + +```ts +// v4 +const step = await world.steps.get(undefined, stepId); + +// v5 +const step = await world.steps.get(runId, stepId); +``` + +### `events.listByCorrelationId()` requires a `runId` + +A correlation ID identifies a step, hook or wait within its run, not across runs. The lookup is scoped to one run, so pass the run that owns the ID. The same applies to `analytics.events.listByCorrelationId()`. + +```ts +// v4 +const events = await world.events.listByCorrelationId({ correlationId }); + +// v5 +const events = await world.events.listByCorrelationId({ runId, correlationId }); +``` + +### `hook.getConflict()` resolves with a `Run` + +The resolved value is now the conflicting run handle rather than `{ runId }`. `conflict.runId` still reads the same, so existing code keeps working — but the round trip through a step to fetch the run can be deleted. + +```ts +// v4 +const conflict = await hook.getConflict(); +if (conflict) { + const run = await fetchRun(conflict.runId); // "use step" wrapper around getRun() + return { dedupedTo: await run.returnValue }; +} + +// v5 +const conflict = await hook.getConflict(); +if (conflict) { + return { dedupedTo: await conflict.returnValue }; +} +``` + +`await conflict.status` and `await conflict.cancel()` are available on the same handle. Do not remove the `if (conflict)` null check — `getConflict()` still resolves `null` when the token was claimed cleanly. + +### `experimental_setAttributes` renamed to `setAttributes` + +```ts +// v4 +import { experimental_setAttributes } from 'workflow'; + +// v5 +import { setAttributes } from 'workflow'; +``` + +The old name was removed in v5, so this rewrite is required. `ExperimentalSetAttributesOptions` is likewise now `SetAttributesOptions`. The `attributes` option on `start()` is unchanged. + +### `mode: 'client'` removed from the SWC transform + +Only relevant to a custom build integration that calls the compiler itself. The `client` mode merged into `step`, which now absorbs hoisted variable references and dead-code elimination. Pass `mode: 'step'`. + +### `NestLocalBuilder` moved out of the `@workflow/nest` root + +```ts +// v4 +import { NestLocalBuilder } from '@workflow/nest'; + +// v5 +import { NestLocalBuilder } from 'workflow/nest/builder'; +``` + +`NestVercelBuilder` lives at `workflow/nest/vercel-builder`. `WorkflowModule` still comes from `@workflow/nest`; the split keeps the build toolchain out of the runtime bundle, so do not re-export the builder from a module that runtime code imports. + +## Step 3 — flag the behavior changes that need a decision + +These are not code edits. Report each one that applies, and do not "fix" them silently. + +- **Tracing defaults to `linked`.** Each workflow and step invocation is its own trace root with span links to the enqueue site and the run origin, instead of one trace per run. If the app has dashboards, saved queries, or alerts keyed on a single trace ID per run, either move them to the `workflow.run.id` attribute or set `WORKFLOW_TRACE_MODE=continuous` to restore the 4.x shape. +- **The event-creation precondition guard is gone.** `WORKFLOW_PRECONDITION_GUARD` no longer exists; remove it from the app's environment and deployment config if it is set. No World in the SDK rejects a write for a stale snapshot any more — a replay that is behind learns what it missed from the write it makes next. +- **Turbo mode is on by default.** The first invocation of a run backgrounds `run_started` and skips the initial event-log load. `WORKFLOW_TURBO=0` disables it. +- **Errors keep their type.** `WorkflowRunFailedError.cause` now preserves the original class identity and cause chain. Code that pattern-matched on `error.message` because the class was flattened in 4.x can use `instanceof` — but flag it rather than rewriting error handling unprompted. +- **Event IDs are slot numbers, not ULIDs.** An event ID is now its 1-based position in the run's log (`evnt_00000000000000000000000042`), unique only within a run and carrying no timestamp. Report any app code that uses an event ID as a global key (pair it with the `runId`) or decodes a time out of one (read `createdAt` off the event). Other entity IDs are unchanged. +- **A per-run event limit is enforced.** The World supplies the ceiling (25,000 events on the Local and Vercel Worlds) and a run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Flag any workflow with an unbounded loop; the fix is a child run per batch, which is a design change, not a migration edit. +- **Stream writes flush the leading chunk immediately.** The flush window default went from 10ms to 0. An app that relied on the window to coalesce a burst of tiny chunks can set `streamFlushIntervalMs` or `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`. +- **Generated step, workflow and webhook bundles are ESM** (the VM-executed workflow bundle stays CJS). Only matters for a host that post-processes build output. +- **Duplicate step or workflow IDs now fail the build.** In 4.x, two identically named non-exported functions across workspace files collided last-write-wins. If the build fails on this, rename one of them — do not suppress the check. +- **`world-postgres` rows written before the upgrade.** Failed runs stored by 4.x read back with `error: undefined`, because the payload lives in the legacy `error` text column rather than `errorJson`. There is no data migration; recent-history dashboards may show blank errors for pre-upgrade failures. +- **`world-local` stream chunks moved** to `streams/chunks//`. Files in the old flat layout are not read back and stale files are left in place — local development state, so deleting the data directory is fine. +- **The workflow sandbox is stricter about nondeterminism.** `WeakRef`, `FinalizationRegistry`, `Atomics.waitAsync`, and async `WebAssembly` compilation are no longer available inside workflow functions, and `crypto.subtle.digest` computes synchronously (same results, deterministic timing). Grep `"use workflow"` files for these APIs; the fix is moving that code into a step, which is a design change — flag it, do not restructure unprompted. +- **In-flight runs do not migrate.** Runs created on a 4.x deployment keep executing on that deployment. Let them finish where they started; do not add code to "drain" or re-target them. + +## Step 4 — custom `World` implementations + +Only if the app implements `World` itself. **Use the `migrating-world-v4-to-v5` skill for this part** (`npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5`); it carries the full World migration, including the event ID allocation work summarized below. What follows is enough to scope the job and to know when it has not been done. + +Beyond the stream and step signatures above, the interface gained: + +- `streams` as a namespace (see the table in step 2). +- `analytics` — metadata-only listings for runs, steps, events, hooks, waits, and attributes. +- attribute support on runs, including `experimentalSetAttributes`. +- capability advertisement, so the runtime can gate optimizations. Unadvertised capabilities fail closed, which means an incomplete World stays correct but slower — advertise a capability only once it is genuinely implemented. Event ID allocation is not among them: it is a requirement, not a capability. A World that rejected stale writes behind the old `preconditionGuard` flag can delete that code, since the flag is gone and nothing replaced it. +- an optional per-run event ceiling returned on run reads, which the runtime enforces. +- optional `createRunId()` and a `region` on queue options, for worlds that place run state regionally. + +Four contract changes affect existing implementations. The first is required and is not visible from the type signatures: + +- **Event IDs are slots, and allocating them is mandatory.** An event ID is no longer a ULID the World mints freely: it is `evnt_` followed by the event's 1-based position in that run's log, zero-padded to 26 characters (`slotToEventId()` from `@workflow/world` formats one). Positions must be unique and dense with no holes, so the race has to be settled in the store — a unique constraint on `(runId, eventId)` or a conditional write — rather than by reading the maximum in process and adding one. `events.create()` params carry `eventCount` to place the write; when that position is taken the World must not reject the create, it advances to the next free position, commits there, and returns the events occupying the skipped positions on the success response. Take the position *at the commit*, in the same operation that appends the event, since that is what stops an event landing behind a position a reader has already passed. There is no capability to declare and no fallback: the runtime reads a position out of every ID it loads and fails the run when it cannot, so a World whose IDs are not positions type-checks, starts runs, and fails on the first replay with `Event id is not slot-numbered`. + + Warn the user that **runs already in their store cannot be replayed by the new code** — a ULID-numbered run is not readable as positions and the runtime refuses it, so those runs must be drained on 4.x first, unless the platform pins a run to its creating deployment (Vercel does, which resolves it). Point them at `@workflow/world-testing`'s `numbers events by position`, which catches a wrong ID scheme in the conformance suite instead of at replay time, and at the "Upgrading a World to v5" guide for the full rules. + +- **Suspension and dispatch.** The asymmetric `{ timeoutSeconds }` return contract for waits is gone. Waits are ordinary queue continuations carrying `delaySeconds`, and wait plus step dispatch is unified into one parallel batch per suspension. A World that special-cased the old wait return needs rewriting against the current interface. +- **Step queue topics are retired.** The `'step'` queue kind no longer exists: queued steps travel on the workflow topic (carrying `stepId`/`stepName` in the payload) and execute in the combined flow handler. A World that provisioned or routed separate `__wkf_step_*` topics can drop them. +- **World resolution happens at build time.** Worlds are statically injected into host bundles rather than selected dynamically at runtime, and first-party World packages expose a `createWorld()` factory. A custom or community World must be resolvable by the build; verify the app still boots against it rather than assuming a runtime lookup. + +Optional methods may be omitted; the runtime falls back. Point the user at the `migrating-world-v4-to-v5` skill and the "Upgrading a World to v5" guide, which carry the full interface delta and the contract changes above, rather than inventing method bodies. + +## Required output shape + +Return the migration in this structure: + +```md +## Summary +## Dependency Changes +## Code Changes +## Behavior Changes To Review +## Verification +## Open Questions +``` + +- `## Code Changes` lists one entry per rule applied, with the file paths touched. Rules that did not apply are listed once as not applicable — do not omit them silently. +- `## Behavior Changes To Review` carries the step 3 items that apply to this app, each with the concrete follow-up (which dashboard, which env var). +- `## Open Questions` carries anything that could not be decided from the code, especially a custom World that needs interface work. + +## Verification + +Run, in order, and report actual output: + +1. Install and build. The build must regenerate workflow/step bundles without errors. +2. Typecheck. The async `getWorld()` change and the `steps.get` signature surface here. +3. The app's test suite. +4. Start the app and execute one real run end to end, confirming the first step runs and the run reaches a terminal state. + +Fail the migration if any of these are true: + +- [ ] `workflow` and `@workflow/*` versions span both 4.x and 5.x +- [ ] `getWorld()` / `createWorld()` / `getWorldHandlers()` is called without `await` +- [ ] `runStep` or `stepEntrypoint` is still imported +- [ ] `createLocalWorld` or `createVercelWorld` is still imported +- [ ] `workflow/internal/private` or `@workflow/core/private` is still imported +- [ ] a `world.streams.*` call kept the v4 argument order (name before runId) +- [ ] `streams.get` was called without a `runId` +- [ ] `world.steps.get` was called with `undefined` as its first argument +- [ ] `NestLocalBuilder` is imported from `@workflow/nest` instead of `workflow/nest/builder` +- [ ] a compiler call still passes `mode: 'client'` +- [ ] a behavior change from step 3 was silently "fixed" instead of reported +- [ ] workflow or step bodies were restructured beyond the rules above +- [ ] generated output under `.well-known/workflow/v1/` was hand-edited +- [ ] the build, typecheck, or test results were not actually run and reported + +## Reference + +- What's new in v5: +- Configuration and runtime tuning: +- Upgrading a World to v5: +- Building a World: +- v4 documentation: diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md new file mode 100644 index 0000000000..63eb338539 --- /dev/null +++ b/skills/migrating-world-v4-to-v5/SKILL.md @@ -0,0 +1,218 @@ +--- +name: migrating-world-v4-to-v5 +description: Upgrades a custom Workflow SDK World implementation from the v4 spec to v5. Use when a package implements the `World` interface from `@workflow/world` and is moving to 5.x — event IDs that are ULIDs rather than slot positions, `Event id is not slot-numbered` at replay time, a `specVersion` the runtime refuses, `writeToStream` / `closeStream` / `readFromStream` as top-level World methods, `steps.get` or `events.listByCorrelationId` without a `runId`, a `'step'` queue kind or `__wkf_step_*` topics, a `preconditionGuard` capability, or a `createLocalWorld` / `createVercelWorld` factory. +metadata: + author: Vercel Inc. + version: '0.1.0' +--- + +# Migrating a World from the v4 spec to v5 + +This skill is for a package that implements `World` from `@workflow/world`: a storage, queue and stream backend the Workflow runtime talks to. It is not for application code. If the task is bumping an app's `workflow` dependency, use the `migrating-workflow-v4-to-v5` skill instead; if the app both uses Workflow and ships its own World, run that skill first and this one second. + +An app on the Vercel, Local or Postgres World needs nothing from this skill. Those ship with the SDK and are already on the v5 spec. + +One change dominates the work. **Event ID allocation is required, is not visible from the type signatures, and a World that skips it type-checks, starts runs, and fails on the first replay.** Do that part first, then the mechanical rewrites. Do not begin with the type errors: they are the small half, and finishing them produces a World that looks migrated and is not. + +## Intake + +Before editing, establish and report each of these: + +1. **Where the World is.** Grep for `implements World`, `: World`, `World>` and `from '@workflow/world'`. Read the factory it exports. +2. **How event IDs are minted today.** Grep for `eventId`, `ulid`, `uuid`, `nanoid`, `nextval`, `AUTO_INCREMENT`, `IDENTITY`. Find the exact line that produces the ID written to storage. +3. **What settles a write race.** Read the `events.create` implementation. Note whether the ID or ordering is decided in process (read-then-write, an in-memory counter, a `Math.max` over loaded events) or in the store (unique constraint, conditional write, `INSERT ... ON CONFLICT`, a transaction). +4. **Which `specVersion` it declares.** Grep for `specVersion`. Note whether it is a literal or an imported constant. +5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`, `experimentalSetAttributes`. +6. **Whether it provisions step topics.** Grep for `'step'`, `__wkf_step`, `stepQueue`. +7. **Whether it rejects stale writes.** Grep for `PreconditionFailedError`, `preconditionGuard`, `stateUpdatedAt`, `stateEventCount`, `stateCursor`, `412`. +8. **How it is tested.** Grep for `@workflow/world-testing` and `createTestSuite`. A World without the conformance suite wired up gets it in this migration. +9. **Where its runs live.** Ask, or determine from the deployment model, whether a single deployment serves every run or a run is pinned to the deployment that created it. This decides the rollout in step 6 and cannot be read out of the code. + +Report anything not applicable rather than skipping it silently. + +## Step 1 — event ID allocation + +In v4 an event ID was a ULID the World minted however it liked. In v5 an event ID is the event's **position in its run's log**: `evnt_` followed by a 1-based slot, zero-padded to 26 characters, so a run's first event is `evnt_00000000000000000000000001`. + +```ts +import { slotToEventId, eventIdToSlot, FIRST_EVENT_SLOT } from '@workflow/world'; + +slotToEventId(1); // 'evnt_00000000000000000000000001' +eventIdToSlot('evnt_00000000000000000000000042'); // 42 +eventIdToSlot('evnt_01JQ...'); // null +``` + +Format IDs with `slotToEventId()`. Do not hand-roll the padding. The fixed width is what makes lexicographic order the same as positional order, so a World padding to a different width sorts its own log wrongly past ten events. + +There is no capability to declare and no fallback path. The runtime calls `requireEventSlot()` on IDs it loads, which throws `Event id is not slot-numbered: . This World allocates event positions the runtime cannot read.` + +Four rules bind the implementation. Check each against the code found in intake items 2 and 3: + +- **Uniqueness.** Two concurrent appends must not both take a slot. Settle it where the store settles it: a unique constraint on `(runId, eventId)`, a conditional write, or a serializable transaction. Reading the maximum slot and adding one in process is the failure mode this rule exists for, and it survives light testing because it only breaks under concurrency. +- **Density.** Slots run from 1 with no holes. A writer that loses a race re-derives its slot from the store and takes the next free one. Incrementing a local number after a loss leaves a permanent hole, and the runtime fails the run with `CORRUPTED_EVENT_LOG` rather than replay across one. +- **Bump and report.** `events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, so the slot it expects is `eventCount + 1`. When that slot is taken, **do not reject the write.** Commit at the next free slot, and return the events occupying the slots you skipped on the success response, in `events` with a matching `cursor` and `hasMore`. A stale count is the normal case for a parallel fan-out; rejecting it would serialize writes the runtime deliberately issues concurrently. A create that arrives with no `eventCount` came from a caller with no loaded log (a queued step body, an out-of-band writer) and is always accepted. +- **Allocate at the commit.** Take the slot in the same operation that appends the event, never earlier. This is what makes a reader's log a *prefix* of the run's log rather than a prefix with a hole in it: nothing can land behind a slot a reader has already passed. A World that hands out a slot in a request handler and commits later breaks the property every replay depends on. + +The shape that satisfies all four, for a SQL store with a unique key on `(run_id, event_id)`, is to compute the ID *inside* the insert and let the constraint arbitrate: + +```sql +INSERT INTO events (run_id, event_id, event_type, data) +SELECT $1, + 'evnt_' || lpad((coalesce( + (SELECT cast(substring(prev.event_id from 6) AS bigint) + FROM events prev WHERE prev.run_id = $1 + ORDER BY prev.event_id DESC LIMIT 1), 0) + 1)::text, 26, '0'), + $2, $3 +ON CONFLICT (run_id, event_id) DO NOTHING +RETURNING event_id; +``` + +No row returned means another writer took that slot. Retry the same statement: it re-reads the maximum from a store that has already advanced, which is the bump. `@workflow/world-postgres` does exactly this, with a bounded retry count and jittered backoff after the first few immediate attempts, and absorbs the conflict with `DO NOTHING` rather than raising, because these inserts run inside a transaction an error would poison. + +The exact statement matters less than the property: the slot is computed and the row is inserted in one atomic operation against the rows the constraint protects, so a loser retries against the store rather than against a number it remembered. A store without conditional writes needs a serializable transaction instead, not an in-process lock, which only orders the writers inside one process. + +Then return the skipped span whenever the committed slot exceeds `eventCount + 1`. Understating `eventCount` is safe and overstating is not: a count below the writer's true position only widens the reported span, which the writer discards where its log already holds the events; a count above it makes the World report less than the writer is missing, which is a hole the writer never learns about. + +## Step 2 — declare the spec version + +`specVersion` is the protocol version the World implements, and the number stamped on every run it creates. Import the constant: + +```ts +import { SPEC_VERSION_CURRENT } from '@workflow/world'; + +export function createWorld(): World { + return { + specVersion: SPEC_VERSION_CURRENT, + // ... + }; +} +``` + +The runtime checks this against `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]` before it creates or replays anything and refuses a World outside that range, naming both the range and what the World declared. The floor sits where it does because slot-numbered IDs are required: a World declaring less allocates IDs the runtime cannot read positions out of. + +Replace a literal with the constant even when the numbers currently agree. A literal leaves the World a version behind the next bump and gets it rejected by the runtime it ships alongside. `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` is a literal by another name for this purpose: it names the version that introduced slots rather than the version to declare. + +Runs carry their own spec version, persisted at creation, and keep it for life. Read it off the run rather than assuming every run matches what the World declares today. + +## Step 3 — apply the mechanical rewrites + +These are signature and module-shape changes. Apply each only where the pattern appears. + +### Streams moved to a `streams` namespace, with `runId` first + +| v4 | v5 | +| --- | --- | +| `writeToStream(name, runId, chunk)` | `streams.write(runId, name, chunk)` | +| `writeToStreamMulti(name, runId, chunks)` | `streams.writeMulti(runId, name, chunks)` | +| `closeStream(name, runId)` | `streams.close(runId, name)` | +| `readFromStream(name, startIndex?)` | `streams.get(runId, name, startIndex?)` | +| `getStreamChunks(name, runId, options?)` | `streams.getChunks(runId, name, options?)` | +| `listStreamsByRunId(runId)` | `streams.list(runId)` | + +The argument order flipped, so moving the methods without swapping arguments passes a stream name where a run ID is expected and type-checks whenever both are `string`. `readFromStream` had no `runId` at all; `streams.get` requires one, so thread the owning run through. + +`streams.streamFlushIntervalMs` sets the flush window. The v5 default is `0`, so the first chunk flushes immediately. Set a value only to deliberately coalesce writes. + +### `steps.get()` requires a `runId` + +The first parameter was `string | undefined` and is now `string`. A World that looked a step up by ID alone needs the run in its key or its index. + +### `events.listByCorrelationId()` requires a `runId` + +A correlation ID identifies a step, hook or wait within its run, not across runs. Scope the lookup to one run. A World that paginates by event ID needs the run in its cursor comparison too, since two runs can now hold the same correlation ID. The same applies to `analytics.events.listByCorrelationId()`. + +### Export a `createWorld()` factory + +`createLocalWorld()` and `createVercelWorld()` are gone from the first-party packages, which now export `createWorld()`. Match that shape. The arguments are unchanged; this is a rename. + +### Worlds are injected at build time + +World selection is static, resolved into host bundles by the build rather than looked up dynamically at runtime. Verify the World still resolves after the upgrade and that its module graph survives bundling. A World that relied on a runtime `require` of a path computed from an environment variable will not be found. + +## Step 4 — the contract changes + +These change no signature. A World ported by types alone compiles and then behaves incorrectly. + +- **Suspension dispatch is batched.** The asymmetric `{ timeoutSeconds }` wait-return contract is gone. A wait is an ordinary queue continuation carrying `delaySeconds`, and a suspension dispatches its waits and its steps as one parallel batch. A queue that assumed one message per suspension needs to handle the batch. +- **Step queue topics are retired.** The `'step'` queue kind no longer exists. Queued steps travel on the workflow topic carrying `stepId` and `stepName` in the payload, and execute in the combined flow handler. Drop any `__wkf_step_*` topic provisioning. +- **The `preconditionGuard` capability is gone, and so is the reason for it.** A World that rejected an event creation whose snapshot was behind the log can delete that code and its `stateUpdatedAt` / `stateEventCount` / `stateCursor` plumbing. Bump-and-report replaced it: a stale replay costs a merge instead of a rejection. `PreconditionFailedError` still exists for a World that allocates slots away from the commit and would rather refuse than report, which a World following step 1 is not. +- **Capabilities fail closed.** An unadvertised capability costs performance, never correctness, so a partial World stays correct while it catches up. The reverse is not true: advertising something not enforced removes a guard the runtime was relying on. Set a flag only once the behavior is implemented. +- **Event creation may return a delta.** `events.create()` may return events alongside the one it created, in `events` / `cursor` / `hasMore`. Beyond the bump-and-report case in step 1, the runtime uses this to skip a follow-up `events.list` on `run_started`, on step-terminal writes carrying `sinceCursor`, and on `hook_received` writes carrying `preloadEvents`. All three are advisory: returning only the created event stays correct and pays one more round trip. + +## Step 5 — optional surface worth adopting + +None of this is required, and the runtime routes around each absence. Report what the World is missing rather than implementing everything unprompted. + +`capabilities` (`hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, `maxConcurrency`), `analytics`, `runs.experimentalSetAttributes`, `runs.cancelMany`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. + +Two are worth raising unprompted because their absence is felt rather than reported. Without `getRuntimeDeadline()` the inline replay budget is a flat two minutes, so a host with a long function timeout does less work per invocation than it could. Without `close()`, CLI commands and short-lived processes cannot exit cleanly without `process.exit()`. + +## Step 6 — the rollout + +Warn the user, in the migration report, before they deploy: + +**Runs already in the store cannot be replayed by the new code.** A ULID-numbered run is not readable as positions, and the runtime refuses it rather than guessing. There is no mixed-scheme mode and no per-run fallback. + +Which follows depends on intake item 9. Where a run executes on the deployment that created it, this resolves itself: those runs finish on the build that started them and never meet the new code. Where a single deployment serves every run, the in-flight ones must be drained on the 4.x build before the v5 World is deployed, or they will fail. + +## Verification + +Wire up the conformance suite first. It is the cheapest way to catch the step 1 work being wrong: + +```ts +import { createTestSuite } from '@workflow/world-testing'; + +createTestSuite('@my-org/my-world'); // or a path to the built entrypoint +``` + +The suite spawns a server with `WORKFLOW_TARGET_WORLD` set to that value and runs real workflows against it. Its `numbers events by position` test fails a World whose IDs do not decode to slots, whose run is not dense from 1, or whose IDs are not in canonical form. That turns the failure that would otherwise appear on a first replay into one line of test output. + +Then run, in order, and report actual output: + +1. Build and typecheck the World package. +2. The conformance suite. +3. The World's own test suite. +4. An end-to-end run against a real app, covering: a run that suspends on a step and one that suspends on a wait; a parallel fan-out, so concurrent `events.create()` calls race for the same slot; a hook resumed after its run has progressed; a stream written and read back, including one closed before the reader attaches. + +Concurrency is the part that light testing misses. If the World's tests never issue two `events.create()` calls for the same run at once, add one that does before calling the migration done. + +Fail the migration if any of these are true: + +- [ ] an event ID is produced by anything other than `slotToEventId()` +- [ ] the slot is chosen by reading a maximum, or a counter, outside the operation that commits the event +- [ ] the slot is handed out before the commit +- [ ] `events.create()` rejects, throws or retries a write whose `eventCount + 1` slot was taken, instead of bumping to the next free slot +- [ ] a bumped write returns without the skipped events on `events` / `cursor` / `hasMore` +- [ ] a create carrying no `eventCount` is rejected +- [ ] `specVersion` is a literal, or `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY`, rather than `SPEC_VERSION_CURRENT` +- [ ] a `streams.*` call kept the v4 argument order (name before runId) +- [ ] `steps.get` or `listByCorrelationId` is reachable without a run ID +- [ ] a capability is advertised whose behavior is not implemented +- [ ] `@workflow/world-testing` is not wired up, or its results were not reported +- [ ] the rollout warning in step 6 was not given +- [ ] the build, typecheck or test results were not actually run and reported + +## Required output shape + +```md +## Summary +## Event ID Allocation +## Interface Changes +## Contract Changes +## Optional Surface Not Implemented +## Rollout +## Verification +## Open Questions +``` + +- `## Event ID Allocation` states where the slot is now computed, what settles a race for it, and how a bumped write reports the span it skipped. Quote the code. +- `## Optional Surface Not Implemented` lists what was left out and what each absence costs, so the user can decide. +- `## Rollout` carries the step 6 warning and which of its two cases applies to this deployment. + +## Reference + +- Upgrading a World to v5: +- Building a World: +- Event IDs: +- What's new in v5 (application-facing): +- Reference implementations: `packages/world-local` and `packages/world-postgres` in From 7b79ba37cc97e858ceb8b2474e03bbc404b555a0 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Fri, 21 Aug 2026 12:53:59 -0700 Subject: [PATCH 05/10] Add support for 'noop' event type - spec version 7 (#3634) Co-authored-by: Peter Wielander --- .../sealed-log-noop-shared-predicate.md | 5 + .changeset/sealed-log-spec-seven.md | 9 + .../docs/v5/configuration/runtime-tuning.mdx | 10 + .../docs/v5/how-it-works/event-sourcing.mdx | 20 ++ docs/content/worlds/v5/building-a-world.mdx | 8 + packages/core/src/events-consumer.test.ts | 161 ++++++++++++++++ packages/core/src/events-consumer.ts | 29 ++- packages/core/src/runtime.ts | 34 +++- .../core/src/runtime/quickjs-runtime.test.ts | 69 +++++++ packages/core/src/runtime/quickjs-runtime.ts | 23 ++- packages/core/src/runtime/start.test.ts | 42 ++++- .../core/src/runtime/world-compatibility.ts | 35 ++-- .../core/src/step-delivery-ordering.test.ts | 175 ++++++++++++++++++ .../src/components/event-list-view.tsx | 32 +++- .../src/components/sidebar/events-list.tsx | 18 +- .../components/ui/duplicate-event-tooltip.tsx | 41 +++- .../workflow-traces/event-colors.ts | 11 ++ packages/web-shared/src/index.ts | 4 + packages/web-shared/src/lib/sealed-events.ts | 32 ++++ .../web-shared/src/lib/trace-builder.test.ts | 30 ++- packages/web-shared/src/lib/trace-builder.ts | 15 +- packages/world-local/src/index.ts | 4 +- .../src/storage/slot-identity.test.ts | 44 +++++ packages/world-postgres/src/index.ts | 4 +- packages/world-postgres/test/spec.test.ts | 77 +++++++- packages/world-vercel/src/event-retry.ts | 6 + packages/world-vercel/src/events-v4.ts | 3 + packages/world-vercel/src/index.ts | 4 +- .../src/trace-propagation.test.ts | 149 ++++++++------- packages/world-vercel/src/utils.test.ts | 172 +++++++++-------- packages/world/src/events.test.ts | 36 ++++ packages/world/src/events.ts | 41 ++++ packages/world/src/index.ts | 4 + packages/world/src/spec-version.test.ts | 54 ++++-- packages/world/src/spec-version.ts | 93 ++++++++-- 35 files changed, 1251 insertions(+), 243 deletions(-) create mode 100644 .changeset/sealed-log-noop-shared-predicate.md create mode 100644 .changeset/sealed-log-spec-seven.md create mode 100644 packages/web-shared/src/lib/sealed-events.ts diff --git a/.changeset/sealed-log-noop-shared-predicate.md b/.changeset/sealed-log-noop-shared-predicate.md new file mode 100644 index 0000000000..396816dce5 --- /dev/null +++ b/.changeset/sealed-log-noop-shared-predicate.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Render sealed log positions (`noop` events) as the log rows they are: shown in event lists, excluded from span geometry and trace duration, since a seal's timestamp belongs to whichever reader wrote it rather than to the run. diff --git a/.changeset/sealed-log-spec-seven.md b/.changeset/sealed-log-spec-seven.md new file mode 100644 index 0000000000..8c67fb6325 --- /dev/null +++ b/.changeset/sealed-log-spec-seven.md @@ -0,0 +1,9 @@ +--- +'@workflow/world': minor +'@workflow/world-vercel': minor +'@workflow/world-local': minor +'@workflow/world-postgres': minor +'@workflow/core': minor +--- + +New runs are created with the sealed-log event identity (specVersion 7). A sealed-log run's event positions are assigned by the backend before each write commits, so concurrent writers never contend for a position. A position whose writer dies is closed by the backend with a `noop` event; replay steps over those without delivering them or advancing the deterministic clock. Set `WORKFLOW_SEALED_LOG=0` to put a deployment back on the previous scheme. Every runtime reads sealed logs either way, and a run's version is fixed at creation, so the setting only affects new runs. diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 2c03c5155b..afa02e0d9e 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -122,6 +122,16 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - The check trades one failure for another. Most holes stand for an event that never happened, and replaying past those is correct. A hole standing for an event that did happen looks identical, and replaying past that one produces a run whose result is silently wrong. Failing is the recoverable side of that trade. - Set `0` to replay across holes instead. +### `WORKFLOW_SEALED_LOG` + +- Default: enabled +- New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap. +- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow and without advancing the deterministic clock — its timestamp belongs to whichever reader sealed it, not to the run. +- Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems. +- Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting. +- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That is every runtime on this release train, but a runtime that pins its own accepted spec range separately — the Python runtime, for one — has to have caught up before it can read these runs. Switch this off in an environment where it has not. +- Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec. + ### `WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS` - Default: `3` diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 14c3bcf321..0a7b870ce9 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -199,6 +199,12 @@ Events are categorized by the entity type they affect. Each event contains metad | `wait_created` | Creates a new wait in `waiting` state. Contains the timestamp when the wait should complete. | | `wait_completed` | Transitions the wait to `completed` state when the delay period has elapsed. | +### System Events + +| Event | Description | +|-------|-------------| +| `noop` | Seals an abandoned log position (specVersion 7 and above). Written only by the backend, never by workflow code — the create endpoints reject it. See [Sealed positions](#sealed-positions-noop-events). | + ## Terminal States Terminal states represent the end of an entity's lifecycle. Once an entity reaches a terminal state, no further events can transition it to another state. @@ -263,6 +269,20 @@ Both kinds of skip are logged at `debug`, so neither reaches the console unless The observability UI greys out the events it can identify this way, with the reason on hover. Its set is narrower than the runtime's: it reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log. +## Sealed Positions (noop events) + +Runs at specVersion 7 and above live in a *sealed log*: the backend hands each write its position from a per-run sequencer **before** the write commits, so concurrent writers hold distinct positions and never race one another for a slot. This is how new runs are created by default; [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) puts a deployment back on the previous scheme. Every runtime *reads* a sealed log regardless, and a run's version is fixed at creation, so changing the setting never affects a run already in flight. The trade is that a writer can claim a position and then die — a crashed process, a cancelled transaction — leaving a hole that no writer will ever fill, and a hole reads exactly like an event the reader failed to load. + +The backend restores the dense log at read time by **sealing** such positions: once a hole is provably abandoned (bounded by the commit time of later positions — positions are handed out in order, so a committed later position proves how long the hole has been open), the backend writes a `noop` event into it. A `noop` occupies its position — length-based completeness checks, cursors, and pagination all count it — and means nothing: + +- It is **never offered to any consumer** during replay. The walk steps over it in the same synchronous pass that delivers the events around it, so its presence cannot perturb delivery order, promise scheduling, or which branch of a `Promise.all` resumes first. +- It **never advances the deterministic clock**. A `noop`'s `createdAt` is the *sealer's* wall clock — it can even postdate events at higher positions — and letting it feed the replay clock would make a log containing a seal replay differently from one whose hole was filled by its original writer. Same rule, and same mechanism, as skipped duplicates above. +- Its `correlationId` is `noop_` followed by the sealed position's zero-padded digits — deterministic, so any two sealers racing for the same hole mint the identical event, and recognizable at a glance in the log. + +A sealed position races its original writer at the same uniqueness fence as every other write, and losing that race is the good outcome: the real event landed first, and readers get it instead. A live writer that gets sealed over simply re-derives a fresh position and commits there — the same recovery as losing any other write race — so sealing can cost a retry, never a wrong log. + +`noop` is not user-creatable: it does not exist in the create schemas, and backends reject it on every create endpoint. Only a backend's own read path writes one. + ## Event Correlation Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance: diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index 398c0425ca..854e4e921e 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -186,6 +186,14 @@ Two properties have to hold, and both are about what a reader can conclude from Allocate the position **at the commit**, in the same operation that appends the event. That is what makes a reader's log a *prefix* of the run's log rather than a prefix with a hole in it: nothing can land behind a position a reader has already passed. Handing a position out earlier — in a request handler, say — and committing later breaks the property every replay depends on, and is the one case where you may need [a stale-write rejection](#optional-rejecting-a-stale-write) to compensate. +#### Optional: pre-assigned positions and `noop` sealing + +Spec version 7 legitimizes one alternative to allocate-at-commit, for Worlds whose store makes commit-time allocation a contention bottleneck: hand positions out from a per-run atomic counter **before** the commit, and restore density at read time. Pre-assignment means concurrent writers hold distinct positions and never race for one — but a writer that claims a position and dies leaves a permanent hole. A World that allocates this way MUST therefore **seal** provably abandoned positions by writing a `noop` event into them (racing the original writer at the same uniqueness fence — losing that race means the real event landed, which is success), and MUST NOT return a page with an interior hole: return the dense prefix below the hole and let the caller's next page pick up past it once the position resolves to an event or a seal. + +The runtime's side of the contract: it skips `noop` events during replay — never delivered to a consumer, never advancing the deterministic clock — so a sealed log replays identically to one whose holes were filled by their writers. `noop` is not user-creatable and is never sent to `events.create()`; only your own read path may write one. Worlds that allocate at the commit (a synchronous counter, a unique-constraint append) keep perfect density by construction and never need any of this — `world-local` and `world-postgres` never seal, and a World that allocates at the commit is spec-7 compliant with no work. + +Note the version a World stamps comes from `mintedSpecVersion()`: 7 by default, and the slot-identity version when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) switches it off. Declare `mintedSpecVersion()` rather than a literal so your World moves with the fleet, and note that a run created at spec 7 may be read by a runtime other than the one that created it — the reader has to understand `noop` before anything stamps 7 in that environment. + `events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt `eventCount + 1`. When that position is taken, **do not reject the write**. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in `events` with a matching `cursor` and `hasMore`. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel. ### Optional: Rejecting a Stale Write diff --git a/packages/core/src/events-consumer.test.ts b/packages/core/src/events-consumer.test.ts index 943fa2ef84..1ad7b339f8 100644 --- a/packages/core/src/events-consumer.test.ts +++ b/packages/core/src/events-consumer.test.ts @@ -1167,3 +1167,164 @@ describe('EventsConsumer', () => { }); }); }); + +describe('sealed-log noop events (specVersion 7)', () => { + function logEvent(eventType: Event['eventType'], id: string): Event { + return createMockEvent({ id, eventId: id, eventType } as Partial); + } + + function consumerFor(ids: string[]) { + const seen: string[] = []; + const callback = (event: Event | null) => { + if (event && ids.includes(event.id) && !seen.includes(event.id)) { + seen.push(event.id); + return EventConsumerResult.Consumed; + } + return EventConsumerResult.NotConsumed; + }; + return { seen, callback }; + } + + it('steps over a noop without offering it to any consumer', async () => { + // The backend sealed an abandoned slot between two real events. The walk + // must pass through it as if the position never had a writer: both real + // events land, nothing is reported unconsumed, and the callback is never + // even offered the noop. + const noop = logEvent('noop' as Event['eventType'], 'noop-1'); + const before = logEvent('wait_created', 'wait-1'); + const after = logEvent('wait_completed', 'wait-2'); + const onUnconsumedEvent = vi.fn(); + const offered: (string | null)[] = []; + const consumer = new EventsConsumer([before, noop, after], { + onUnconsumedEvent, + getPromiseQueue: () => Promise.resolve(), + isDeliveryIdle: () => true, + }); + const reals = consumerFor(['wait-1', 'wait-2']); + consumer.subscribe((event) => { + offered.push(event === null ? null : event.id); + return reals.callback(event); + }); + + await vi.waitFor(() => { + expect(reals.seen).toEqual(['wait-1', 'wait-2']); + }); + expect(consumer.eventIndex).toBe(3); + expect(onUnconsumedEvent).not.toHaveBeenCalled(); + expect(offered).not.toContain('noop-1'); + }); + + it('never advances the deterministic clock off a noop', async () => { + // A noop's createdAt is the SEALER's wall clock — it can postdate every + // real event around it. Letting it reach onConsumedEvent would leak that + // timestamp into replay Date.now() and diverge from a log whose hole was + // filled by the real writer instead. + const noop = createMockEvent({ + id: 'noop-1', + eventId: 'noop-1', + eventType: 'noop', + createdAt: new Date(Date.now() + 60_000), + } as Partial); + const real = logEvent('wait_created', 'wait-1'); + const onConsumedEvent = vi.fn(); + const consumer = new EventsConsumer([noop, real], { + ...defaultOptions, + onConsumedEvent, + }); + const reals = consumerFor(['wait-1']); + consumer.subscribe(reals.callback); + + await vi.waitFor(() => { + expect(reals.seen).toEqual(['wait-1']); + }); + expect(onConsumedEvent).toHaveBeenCalledTimes(1); + expect(onConsumedEvent).toHaveBeenCalledWith(real); + }); + + it('is scheduling-neutral: same offers, same tick, as the log without noops', async () => { + // The skip is a synchronous `continue` inside the walk pass — it consumes + // no extra micro- or macrotask. This pins that: a log with noops + // interleaved at the head, middle, and tail is fully consumed after the + // SAME single tick as its noop-free twin, and the sequence of events + // offered to consumers is byte-for-byte identical. Deterministic + // scheduling is what keeps replay ULID draws (and therefore correlation + // ids) stable across branches racing in Promise.all. + async function offersAfterOneTick(events: Event[]) { + const offered: (string | null)[] = []; + const consumer = new EventsConsumer(events, { + onUnconsumedEvent: vi.fn(), + getPromiseQueue: () => Promise.resolve(), + isDeliveryIdle: () => true, + }); + consumer.subscribe((event) => { + offered.push(event === null ? null : event.id); + return event === null + ? EventConsumerResult.NotConsumed + : EventConsumerResult.Consumed; + }); + // subscribe() schedules exactly one nextTick; the walk drains + // synchronously inside it. One tick must therefore finish either log. + await waitForNextTick(); + return { offered, index: consumer.eventIndex, total: events.length }; + } + + const clean = await offersAfterOneTick([ + logEvent('wait_created', 'w1'), + logEvent('wait_completed', 'w2'), + ]); + const sealed = await offersAfterOneTick([ + logEvent('noop' as Event['eventType'], 'n0'), + logEvent('wait_created', 'w1'), + logEvent('noop' as Event['eventType'], 'n1'), + logEvent('noop' as Event['eventType'], 'n2'), + logEvent('wait_completed', 'w2'), + logEvent('noop' as Event['eventType'], 'n3'), + ]); + + expect(clean.index).toBe(clean.total); + expect(sealed.index).toBe(sealed.total); + // Identical offer sequences — the noops were never offered at all, and + // both logs finished inside the same single tick. + expect(sealed.offered).toEqual(clean.offered); + }); + + it('consumes an all-noop log to the end without divergence', async () => { + const onUnconsumedEvent = vi.fn(); + const consumer = new EventsConsumer( + [ + logEvent('noop' as Event['eventType'], 'n1'), + logEvent('noop' as Event['eventType'], 'n2'), + ], + { + onUnconsumedEvent, + getPromiseQueue: () => Promise.resolve(), + isDeliveryIdle: () => true, + } + ); + consumer.subscribe(() => EventConsumerResult.NotConsumed); + + await vi.waitFor(() => { + expect(consumer.eventIndex).toBe(2); + }); + expect(onUnconsumedEvent).not.toHaveBeenCalled(); + }); + + it('handles a log that ends on a noop', async () => { + const real = logEvent('wait_created', 'wait-1'); + const noop = logEvent('noop' as Event['eventType'], 'noop-1'); + const onUnconsumedEvent = vi.fn(); + const consumer = new EventsConsumer([real, noop], { + onUnconsumedEvent, + getPromiseQueue: () => Promise.resolve(), + isDeliveryIdle: () => true, + }); + const reals = consumerFor(['wait-1']); + consumer.subscribe(reals.callback); + + await vi.waitFor(() => { + expect(reals.seen).toEqual(['wait-1']); + }); + expect(consumer.eventIndex).toBe(2); + expect(onUnconsumedEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/events-consumer.ts b/packages/core/src/events-consumer.ts index a6686006b9..b8d11622c2 100644 --- a/packages/core/src/events-consumer.ts +++ b/packages/core/src/events-consumer.ts @@ -1,4 +1,9 @@ -import { type Event, entityEventClass, envNumber } from '@workflow/world'; +import { + type Event, + entityEventClass, + envNumber, + isSealedNoopEvent, +} from '@workflow/world'; import { eventsLogger } from './logger.js'; /** @@ -331,6 +336,10 @@ export class EventsConsumer { // event's by the index it holds. this.drainParked(); const currentEvent = this.events[this.eventIndex] ?? null; + if (currentEvent !== null && isSealedNoopEvent(currentEvent)) { + this.skipSealedNoop(currentEvent); + continue; + } const consumed = this.offer(currentEvent); if (consumed) { this.eventIndex++; @@ -540,6 +549,24 @@ export class EventsConsumer { return key === undefined ? undefined : this.seenEventClasses.get(key); } + /** + * Steps the walk over a sealed-log `noop` (specVersion >= 7): the World's + * backend wrote it to occupy a slot whose writer allocated the position and + * died, so the log's density arithmetic holds. It is invisible to the + * workflow: no consumer is offered it, no event class is recorded, and — + * exactly as with {@link skipDuplicateEvent} — the deterministic clock does + * not advance, so a log that happens to contain one produces the same + * timestamps as a log that does not. (Its `createdAt` is the seal time, + * which can even postdate later slots' events; letting it touch the clock + * would leak the sealer's wall clock into replay.) + */ + private skipSealedNoop(event: Event) { + this.eventIndex++; + eventsLogger.debug('Skipping sealed-log noop event', { + eventId: event.eventId, + }); + } + /** Steps the walk over a repeat of an already-consumed class. */ private skipDuplicateEvent(event: Event, firstType: Event['eventType']) { this.eventIndex++; diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 11667984f4..5fa19d63ea 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -26,6 +26,7 @@ import { type EventResult, getQueueTopicPrefix, isLegacySpecVersion, + isSealedNoopEvent, isTerminalRunEventType, ROOT_RUN_ID_ATTRIBUTE, type RunInput, @@ -2906,14 +2907,24 @@ export function workflowEntrypoint( // reaches the server-supplied ceiling (undefined ⇒ no // enforcement). The throw is caught below and written as // run_failed / MAX_EVENTS_EXCEEDED. - if ( - maxEventsLimit !== undefined && - eventLog.events.length >= maxEventsLimit - ) { - throw new MaxEventsExceededError( - eventLog.events.length, - maxEventsLimit + // Sealed-log noops are excluded: the ceiling exists to + // stop a runaway WORKFLOW, and a noop is written by the + // backend to seal a position whose writer died. Counting + // them would spend the user's event budget on the + // backend's bookkeeping, and would make the limit a run + // hits depend on how much write contention it happened + // to see. + if (maxEventsLimit !== undefined) { + const workflowEventCount = eventLog.events.reduce( + (n, e) => (isSealedNoopEvent(e) ? n : n + 1), + 0 ); + if (workflowEventCount >= maxEventsLimit) { + throw new MaxEventsExceededError( + workflowEventCount, + maxEventsLimit + ); + } } // Latency telemetry: judge TTFS eligibility against the @@ -2924,11 +2935,18 @@ export function workflowEntrypoint( // committed pre-step attr_set, and the detour it marks // is subtracted via preStepAttrStartMs regardless of // which invocation wrote it (see runtime/step-latency.ts). + // noop is permitted for the same reason attr_set is: it + // is not evidence the run had already made progress. A + // seal says a concurrent writer died, which says nothing + // about this invocation, and excluding it would silently + // drop every contended run out of the TTFS dataset -- + // exactly the runs worth measuring. invocationStartedClean ??= eventLog.events.every( (e) => e.eventType === 'run_created' || e.eventType === 'run_started' || - e.eventType === 'attr_set' + e.eventType === 'attr_set' || + isSealedNoopEvent(e) ); runtimeLogger.debug('Starting workflow execution', { diff --git a/packages/core/src/runtime/quickjs-runtime.test.ts b/packages/core/src/runtime/quickjs-runtime.test.ts index a5232d1e0f..958cdf42ca 100644 --- a/packages/core/src/runtime/quickjs-runtime.test.ts +++ b/packages/core/src/runtime/quickjs-runtime.test.ts @@ -670,6 +670,75 @@ describe('deterministic replay clock', () => { }); expect(unwrapResult(r3.completed!.result)).toEqual(result); }); + + it('does not let a sealed-log noop move the clock', async () => { + // A noop's createdAt is the SEALER's wall clock, and a seal can happen + // long after the events at higher positions committed. Feeding it to the + // clock would make a log whose hole was sealed replay differently from + // the same log whose hole its own writer filled — and, because the clock + // is monotonic, would poison every later Date.now() in the run. The + // node:vm engine gets this from EventsConsumer's noop skip; this pins the + // same rule for the QuickJS event loop, which advances the clock in its + // own pass over the log. + const run = makeRun(); + + const probe = await runQuickJSWorkflow({ + workflowCode: sleepTimingWorkflow, + workflowId: 'workflow//test//workflow', + workflowRun: run, + events: [], + }); + const waitCid = probe.suspended!.pendingOperations[0].correlationId; + + const waitCreatedAt = new Date('2025-01-01T00:00:01Z'); + const waitCompletedAt = new Date('2025-01-01T00:00:11Z'); + const sealedAt = new Date('2025-01-01T01:00:00Z'); + + // The hole sits BETWEEN wait_created and wait_completed, which is where a + // fanout leaves one: the position was handed out, its writer died, and the + // events above it committed on their own clocks. + const withSeal = [ + runCreatedEvent(run), + { + eventId: 'evnt_002', + runId: run.runId, + eventType: 'wait_created' as const, + correlationId: waitCid, + eventData: { resumeAt: waitCompletedAt }, + createdAt: waitCreatedAt, + }, + { + eventId: 'evnt_003', + runId: run.runId, + eventType: 'noop' as const, + correlationId: 'noop_00000000000000000000000003', + eventData: { sealed: true }, + createdAt: sealedAt, + }, + { + eventId: 'evnt_004', + runId: run.runId, + eventType: 'wait_completed' as const, + correlationId: waitCid, + createdAt: waitCompletedAt, + }, + ]; + + const sealed = await runQuickJSWorkflow({ + workflowCode: sleepTimingWorkflow, + workflowId: 'workflow//test//workflow', + workflowRun: run, + events: withSeal as never, + }); + const result = unwrapResult(sealed.completed!.result) as { + startTime: number; + endTime: number; + }; + + // The wait completed at its own timestamp, not the seal's. + expect(result.endTime).toBe(+waitCompletedAt); + expect(result.endTime).toBeLessThan(+sealedAt); + }); }); describe('AbortController (hook-backed)', () => { diff --git a/packages/core/src/runtime/quickjs-runtime.ts b/packages/core/src/runtime/quickjs-runtime.ts index fdc46157f5..bc1cf5f80e 100644 --- a/packages/core/src/runtime/quickjs-runtime.ts +++ b/packages/core/src/runtime/quickjs-runtime.ts @@ -30,11 +30,12 @@ */ import { SerializationError } from '@workflow/errors'; -import type { - Event, - RunInput, - WorkflowRun, - WorldCapabilities, +import { + type Event, + isSealedNoopEvent, + type RunInput, + type WorkflowRun, + type WorldCapabilities, } from '@workflow/world'; import * as nanoid from 'nanoid'; import { @@ -1817,6 +1818,18 @@ async function processEvents( ): Promise { let resolved = false; for (const event of events) { + // A sealed-log noop occupies a slot whose writer died; the run never + // observed it. Step over it BEFORE the clock line below, not at the + // switch: its `createdAt` is the sealer's wall clock and can postdate + // every real event around it, so advancing to it would leak the sealer's + // schedule into replay — and because the clock is monotonic, every later + // Date.now() in the run with it. That would make a log whose hole was + // sealed replay differently from the same log whose hole its own writer + // filled, and differently from this log on the node:vm engine, which + // skips noops in `EventsConsumer` before `onConsumedEvent` feeds the + // clock. Same rule, both engines, one predicate. + if (isSealedNoopEvent(event)) continue; + // Advance the VM's deterministic clock to this event's creation time // BEFORE resolving anything, so workflow code unblocked by this event // observes Date.now() at (or after — the clock is monotonic) the time diff --git a/packages/core/src/runtime/start.test.ts b/packages/core/src/runtime/start.test.ts index 28a62755ba..048606d40c 100644 --- a/packages/core/src/runtime/start.test.ts +++ b/packages/core/src/runtime/start.test.ts @@ -201,12 +201,13 @@ describe('start', () => { expect(mockQueue).not.toHaveBeenCalled(); }); - it('accepts a world that opts into a spec version above the default', async () => { - // `world-vercel` declares the slot-identity version so its new runs are - // created with slot event ids. An equality check against the default - // would make the runtime refuse the adapter shipped alongside it, and - // the failure surfaces only in e2e against that World. - const validWorkflow = Object.assign(() => Promise.resolve('result'), { + it('accepts a world switched back to the pre-sealed-log version', async () => { + // What `WORKFLOW_SEALED_LOG=0` produces: `mintedSpecVersion()` answers + // the slot-identity version, so the World declares one BELOW the version + // this runtime stamps by default. The runtime has to admit it, or the + // kill switch would reject the very World it selects and a rollback + // would surface as a startup failure instead. + const rolledBack = Object.assign(() => Promise.resolve('result'), { workflowId: 'test-workflow', }); @@ -217,6 +218,33 @@ describe('start', () => { queue: mockQueue, } as any); + await start(rolledBack, []); + + expect(mockEventsCreate).toHaveBeenCalledWith( + expect.stringMatching(/^wrun_/), + expect.objectContaining({ + eventType: 'run_created', + specVersion: SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, + }), + expect.anything() + ); + }); + + it('accepts a world that declares the ceiling version', async () => { + // The default and the ceiling coincide at the sealed log, so what this + // pins is that a World declaring the ceiling is admitted and its own + // declaration is what gets stamped, not this runtime's default. + const validWorkflow = Object.assign(() => Promise.resolve('result'), { + workflowId: 'test-workflow', + }); + + setWorld({ + specVersion: SPEC_VERSION_MAX_SUPPORTED, + getDeploymentId: vi.fn().mockResolvedValue('deploy_123'), + events: { create: mockEventsCreate }, + queue: mockQueue, + } as any); + await start(validWorkflow, []); // The declared version is what gets stamped on `run_created`, which is @@ -225,7 +253,7 @@ describe('start', () => { expect.stringMatching(/^wrun_/), expect.objectContaining({ eventType: 'run_created', - specVersion: SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, + specVersion: SPEC_VERSION_MAX_SUPPORTED, }), expect.anything() ); diff --git a/packages/core/src/runtime/world-compatibility.ts b/packages/core/src/runtime/world-compatibility.ts index 6409a64c6b..f1afce9a29 100644 --- a/packages/core/src/runtime/world-compatibility.ts +++ b/packages/core/src/runtime/world-compatibility.ts @@ -1,8 +1,8 @@ import { WorkflowRuntimeError } from '@workflow/errors'; import type { World } from '@workflow/world'; import { - SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED, + SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, } from '@workflow/world'; type WorldSpecVersionMetadata = Pick; @@ -10,18 +10,29 @@ type WorldSpecVersionMetadata = Pick; /** * Rejects a World this runtime cannot speak to. * - * The accepted range is `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]`. - * Below the current version means an old World package paired with a new - * runtime, which cannot serve the protocol this runtime speaks. Above the + * The accepted range is + * `[SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, SPEC_VERSION_MAX_SUPPORTED]`. Below + * the floor means an old World package paired with a new runtime, which cannot + * serve the protocol this runtime speaks — a World that does not number events + * by position allocates ids the runtime cannot read positions out of. Above the * ceiling means a World built against a newer spec than this runtime knows how * to read. * - * Both bounds are the same version today, so this currently admits exactly one. - * It stays written as a range because the two constants answer different - * questions and come apart while a spec bump is staged: the ceiling rises when - * this runtime learns to read the next version, the floor when that version - * becomes the one Worlds stamp. An equality check against either constant alone - * would reject a World during that window. + * The floor is deliberately the slot-identity version rather than + * `SPEC_VERSION_CURRENT`, which now sits one above it at the sealed log. Two + * reasons, and both are about the window a spec bump is staged over: + * + * - `WORKFLOW_SEALED_LOG=0` puts a deployment back on slot identity, so its + * World declares the lower version. Flooring at the version we stamp by + * default would make that kill switch reject the very World it selects, + * turning a rollback into a startup failure. + * - A World package one version behind the runtime it ships alongside is the + * normal state mid-bump, and it can still serve the protocol: slot identity + * is what the runtime actually requires, and sealed logs are a capability on + * top of it that only the backend implements. + * + * The range narrows again when the sealed log becomes mandatory and the flag + * goes away, exactly as slot identity's own floor did. */ export function assertWorldSupportsRuntimeProtocol( world: WorldSpecVersionMetadata @@ -30,7 +41,7 @@ export function assertWorldSupportsRuntimeProtocol( if ( declared !== undefined && declared !== null && - declared >= SPEC_VERSION_CURRENT && + declared >= SPEC_VERSION_SUPPORTS_SLOT_IDENTITY && declared <= SPEC_VERSION_MAX_SUPPORTED ) { return; @@ -38,7 +49,7 @@ export function assertWorldSupportsRuntimeProtocol( const supportedVersion = declared ?? 'none'; throw new WorkflowRuntimeError( - `This Workflow runtime supports Worlds with spec version ${SPEC_VERSION_CURRENT} ` + + `This Workflow runtime supports Worlds with spec version ${SPEC_VERSION_SUPPORTS_SLOT_IDENTITY} ` + `through ${SPEC_VERSION_MAX_SUPPORTED}, ` + `but the configured World declares spec version ${supportedVersion}. ` + 'Install a World package version compatible with the current Workflow runtime.' diff --git a/packages/core/src/step-delivery-ordering.test.ts b/packages/core/src/step-delivery-ordering.test.ts index 300cb6dba4..93a21be71e 100644 --- a/packages/core/src/step-delivery-ordering.test.ts +++ b/packages/core/src/step-delivery-ordering.test.ts @@ -800,3 +800,178 @@ describe('step result delivery ordering across replays', () => { }); }); }); + +/** + * Sealed-log noops (specVersion 7) injected into the exact scenario above — + * the one this file exists for, where delivery ORDER between adjacent events + * decides which branch draws which correlation id. If skipping a noop cost an + * extra microtask hop, or shifted the walk relative to the promise queue, it + * would surface here as the same divergence the production incident produced. + * The noops are deliberately placed in the hop-count-sensitive gap (between + * `wait_completed` and `step_completed`) as well as at the head and tail. + */ +describe('sealed-log noop events in a scheduling-sensitive replay', () => { + const resumeAt = new Date('2026-07-27T12:00:05.000Z'); + + function noopAt(id: string): Event { + return { + eventId: id, + runId: 'wrun_test', + eventType: 'noop', + eventData: { sealed: true }, + // Deliberately far in the future: a noop's createdAt is the sealer's + // wall clock. It must not leak into the replay clock (asserted by the + // run completing identically; the clock rule itself is pinned in + // events-consumer.test.ts). + createdAt: new Date('2030-01-01T00:00:00.000Z'), + } as unknown as Event; + } + + async function buildEventLog(): Promise { + const ops: Promise[] = []; + const stepAResult = await dehydrateStepReturnValue( + 'ok', + 'wrun_test', + undefined, + ops + ); + + return [ + noopAt('evnt_n0'), + { + eventId: 'evnt_0', + runId: 'wrun_test', + eventType: 'step_created', + correlationId: `step_${CORR_IDS[0]}`, + eventData: { stepName: 'stepA' }, + createdAt: new Date(), + }, + { + eventId: 'evnt_1', + runId: 'wrun_test', + eventType: 'wait_created', + correlationId: `wait_${CORR_IDS[1]}`, + eventData: { resumeAt }, + createdAt: new Date(), + }, + { + eventId: 'evnt_2', + runId: 'wrun_test', + eventType: 'step_started', + correlationId: `step_${CORR_IDS[0]}`, + eventData: { stepName: 'stepA' }, + createdAt: new Date(), + }, + { + eventId: 'evnt_3', + runId: 'wrun_test', + eventType: 'wait_completed', + correlationId: `wait_${CORR_IDS[1]}`, + eventData: { resumeAt }, + createdAt: new Date(), + }, + // The sensitive gap: the wait branch's resume and the step branch's + // resume race on microtask hops from exactly this adjacency. + noopAt('evnt_n1'), + noopAt('evnt_n2'), + { + eventId: 'evnt_4', + runId: 'wrun_test', + eventType: 'step_completed', + correlationId: `step_${CORR_IDS[0]}`, + eventData: { stepName: 'stepA', result: stepAResult }, + createdAt: new Date(), + }, + { + eventId: 'evnt_5', + runId: 'wrun_test', + eventType: 'step_created', + correlationId: `step_${CORR_IDS[2]}`, + eventData: { stepName: 'afterSleep' }, + createdAt: new Date(), + }, + noopAt('evnt_n3'), + { + eventId: 'evnt_6', + runId: 'wrun_test', + eventType: 'step_created', + correlationId: `step_${CORR_IDS[3]}`, + eventData: { stepName: 'afterStep' }, + createdAt: new Date(), + }, + noopAt('evnt_n4'), + ]; + } + + function workflowBody(ctx: WorkflowOrchestratorContext) { + const useStep = createUseStep(ctx); + const sleep = createSleep(ctx); + + return async () => { + const stepA = useStep('stepA'); + const afterStep = useStep('afterStep'); + const afterSleep = useStep('afterSleep'); + + const branchStep = (async () => { + await stepA(); + await afterStep(); + })(); + const branchSleep = (async () => { + await sleep(resumeAt); + await afterSleep(); + })(); + + await Promise.all([branchStep, branchSleep]); + }; + } + + it('replays the noop-bearing log with the ordering the noop-free log encodes', async () => { + const hydration = delayHydration(); + const spy = await hydration.install(); + try { + const events = await buildEventLog(); + const ctx = setupWorkflowContext(events); + const { error } = await runWithDiscontinuation(ctx, workflowBody(ctx)); + + expect(error).toBeDefined(); + if (!WorkflowSuspension.is(error)) { + throw error; + } + // Identical outcome to the noop-free scenario above: the sleep branch + // resumed first and drew CORR_IDS[2], both follow-up steps are pending, + // and every event — noops included — was walked to the end. + expect(pendingStepNames(ctx).sort()).toEqual(['afterSleep', 'afterStep']); + expect(ctx.eventsConsumer.eventIndex).toBe(events.length); + } finally { + spy.mockRestore(); + } + }); + + it('keeps that ordering on a later replay sharing the payload cache', async () => { + const hydration = delayHydration(); + const spy = await hydration.install(); + try { + const cache = new ReplayPayloadCache(undefined); + + const first = setupWorkflowContext(await buildEventLog(), cache); + const firstRun = await runWithDiscontinuation(first, workflowBody(first)); + expect(WorkflowSuspension.is(firstRun.error)).toBe(true); + + // The second replay resolves the memoized primitive step result in + // fewer hops — the exact asymmetry the incident exploited. The noops + // must not tip it. + const second = setupWorkflowContext(await buildEventLog(), cache); + const secondRun = await runWithDiscontinuation( + second, + workflowBody(second) + ); + expect(WorkflowSuspension.is(secondRun.error)).toBe(true); + expect(pendingStepNames(second).sort()).toEqual([ + 'afterSleep', + 'afterStep', + ]); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 19d57c418d..8bd874382d 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -10,7 +10,10 @@ import type { } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'; -import { findDuplicateEventIds } from '../lib/duplicate-events'; +import { + DUPLICATE_EVENT_MESSAGE, + findDuplicateEventIds, +} from '../lib/duplicate-events'; import { type ExactIdSearchResult, type ExactWorkflowSearchIdKind, @@ -18,13 +21,14 @@ import { parseExactWorkflowSearchId, } from '../lib/exact-event-search-id'; import { isEncryptedMarker } from '../lib/hydration'; +import { isSealedNoopEvent, SEALED_EVENT_MESSAGE } from '../lib/sealed-events'; import { useToast } from '../lib/toast'; import { formatDuration } from '../lib/utils'; import { AttrSetEventBlock } from './sidebar/attributes-block'; import { ContextCardProvider } from './ui/context-card'; import { DataInspector, DecryptClickContext } from './ui/data-inspector'; import { DecryptButton } from './ui/decrypt-button'; -import { DuplicateEventTooltip } from './ui/duplicate-event-tooltip'; +import { EventNoticeTooltip } from './ui/duplicate-event-tooltip'; import { ErrorStackBlock, isStructuredError, @@ -142,6 +146,11 @@ function getStatusDotColor(eventType: string): string { ) { return 'var(--ds-blue-700)'; } + // Sealed positions → dim gray, one step quieter than pending: the row is + // log filler the run never observed. + if (eventType === 'noop') { + return 'var(--ds-gray-500)'; + } // Created/pending → gray return 'var(--ds-gray-600)'; } @@ -297,9 +306,10 @@ function isRunLevel(eventType: string): boolean { eventType === 'workflow_started' || eventType === 'workflow_completed' || eventType === 'workflow_failed' || - // attr_set carries a dedup correlationId rather than a child entity ID, - // so it groups and labels with the run itself. - eventType === 'attr_set' + // attr_set and noop carry a dedup/positional correlationId rather than a + // child entity ID, so they group and label with the run itself. + eventType === 'attr_set' || + eventType === 'noop' ); } @@ -898,6 +908,12 @@ export function EventRow({ ? '__run__' : (event.correlationId ?? undefined); + const isSealed = isSealedNoopEvent(event); + const rowNotice = isDuplicate + ? DUPLICATE_EVENT_MESSAGE + : isSealed + ? SEALED_EVENT_MESSAGE + : undefined; const statusDotColor = getStatusDotColor(event.eventType); const createdAt = new Date(event.createdAt); const occurredAt = parseEventDate(event.occurredAt); @@ -1111,11 +1127,11 @@ export function EventRow({ {/* Event Type */}
- + {formatEventType(event.eventType)} - +
{/* Name */} diff --git a/packages/web-shared/src/components/sidebar/events-list.tsx b/packages/web-shared/src/components/sidebar/events-list.tsx index 7fd65bc10d..c6254b2493 100644 --- a/packages/web-shared/src/components/sidebar/events-list.tsx +++ b/packages/web-shared/src/components/sidebar/events-list.tsx @@ -2,7 +2,12 @@ import { type Event, getEventDataRefFields } from '@workflow/world'; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { DUPLICATE_EVENT_MESSAGE } from '../../lib/duplicate-events'; import { hasEncryptedFields, isExpiredMarker } from '../../lib/hydration'; +import { + isSealedNoopEvent, + SEALED_EVENT_MESSAGE, +} from '../../lib/sealed-events'; import { Collapsible, CollapsibleContent, @@ -10,7 +15,7 @@ import { CollapsibleTrigger, } from '../ui/collapsible'; import { RunClickContext, StreamClickContext } from '../ui/data-inspector'; -import { DuplicateEventTooltip } from '../ui/duplicate-event-tooltip'; +import { EventNoticeTooltip } from '../ui/duplicate-event-tooltip'; import { ErrorCard } from '../ui/error-card'; import { ErrorStackBlock, isStructuredError } from '../ui/error-stack-block'; import { Skeleton } from '../ui/skeleton'; @@ -110,6 +115,11 @@ function EventItem({ void loadEventData(true); }, [encryptionKey, loadEventData]); + const rowNotice = isDuplicate + ? DUPLICATE_EVENT_MESSAGE + : isSealedNoopEvent(event) + ? SEALED_EVENT_MESSAGE + : undefined; const createdAt = new Date(event.createdAt); const occurredAt = parseDateValue(event.occurredAt); const displayedCreatedAt = showSeparateEventOccurrenceTimestamps @@ -139,15 +149,15 @@ function EventItem({ >
- + {event.eventType} - + {displayedCreatedAtTime} diff --git a/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx b/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx index 920576ae44..1487167b09 100644 --- a/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx +++ b/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx @@ -10,22 +10,23 @@ import { } from './tooltip'; /** - * Explains why an event is shown greyed out: it repeats a class the log - * already records for the same entity, after that entity finished. + * Explains why an event row is shown greyed out — a repeat the runtime read + * past ({@link DUPLICATE_EVENT_MESSAGE}), a backend seal for an abandoned + * position (`SEALED_EVENT_MESSAGE`), or any other notice a list attaches. * - * Renders `children` untouched when `isDuplicate` is false, so a call site can + * Renders `children` untouched when `notice` is absent, so a call site can * wrap an event label unconditionally. Mounts its own {@link TooltipProvider} - * so it works in the sidebar and the events table alike; nesting one inside an - * existing provider is harmless. + * so it works in the sidebar and the events table alike; nesting one inside + * an existing provider is harmless. */ -export function DuplicateEventTooltip({ - isDuplicate = false, +export function EventNoticeTooltip({ + notice, children, }: { - isDuplicate?: boolean; + notice?: string; children: ReactNode; }): ReactNode { - if (!isDuplicate) return children; + if (!notice) return children; return ( @@ -36,9 +37,29 @@ export function DuplicateEventTooltip({ collisionPadding={8} side="top" > - {DUPLICATE_EVENT_MESSAGE} + {notice} ); } + +/** + * The duplicate-specific wrapper kept for existing call sites: greys the + * event out as a repeat the runtime read past. + */ +export function DuplicateEventTooltip({ + isDuplicate = false, + children, +}: { + isDuplicate?: boolean; + children: ReactNode; +}): ReactNode { + return ( + + {children} + + ); +} diff --git a/packages/web-shared/src/components/workflow-traces/event-colors.ts b/packages/web-shared/src/components/workflow-traces/event-colors.ts index 3dd5243e3c..e8b02b871d 100644 --- a/packages/web-shared/src/components/workflow-traces/event-colors.ts +++ b/packages/web-shared/src/components/workflow-traces/event-colors.ts @@ -72,6 +72,17 @@ export function getEventColor( }; } + // Sealed positions - neutral gray: backend log filler, not run activity + if (eventType === 'noop') { + return { + color: 'var(--ds-gray-500)', + background: 'var(--ds-gray-100)', + border: 'var(--ds-gray-400)', + text: 'var(--ds-gray-900)', + secondary: 'var(--ds-gray-700)', + }; + } + // Default - Blue return { color: 'var(--ds-blue-600)', diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index cbfd776f5b..65d71ba4e7 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -59,6 +59,10 @@ export { STREAM_REF_TYPE, truncateId, } from './lib/hydration'; +export { + isSealedNoopEvent, + SEALED_EVENT_MESSAGE, +} from './lib/sealed-events'; export type { DecodedStreamChunkSource } from './lib/stream-display'; export type { ToastAdapter } from './lib/toast'; export { ToastProvider, useToast } from './lib/toast'; diff --git a/packages/web-shared/src/lib/sealed-events.ts b/packages/web-shared/src/lib/sealed-events.ts new file mode 100644 index 0000000000..d54b42ef9d --- /dev/null +++ b/packages/web-shared/src/lib/sealed-events.ts @@ -0,0 +1,32 @@ +import { type Event, isSealedNoopEvent as isSealedNoop } from '@workflow/world'; + +/** + * Sealed-position `noop` events (specVersion 7). + * + * A sealed-log backend hands each write its position before the write + * commits, so a writer that dies after claiming a position leaves a hole. + * The backend closes a provably abandoned hole by writing a `noop` event + * into it — a log-only row the run itself never observes: replay steps over + * it without offering it to any consumer and without advancing the + * deterministic clock. + * + * The observability UI mirrors that treatment. A `noop` appears in event + * lists (greyed, with {@link SEALED_EVENT_MESSAGE} on hover) because it is a + * real row of the log, but it is excluded from span geometry and from + * trace-duration bounds: its `createdAt` is the *sealer's* wall clock, which + * can postdate every real event around it, and letting it stretch a span or + * the trace's known duration would chart the sealer's schedule rather than + * the run's. + */ +export const SEALED_EVENT_MESSAGE = + 'No-op events are written by the backend to seal an abandoned log position'; + +/** + * Whether `event` is a backend-written seal for an abandoned position. + * + * Delegates to `@workflow/world` so the UI, the `node:vm` engine and the + * QuickJS engine cannot drift on what a seal is. + */ +export function isSealedNoopEvent(event: Pick): boolean { + return isSealedNoop(event); +} diff --git a/packages/web-shared/src/lib/trace-builder.test.ts b/packages/web-shared/src/lib/trace-builder.test.ts index 70ad41339a..8de5282d24 100644 --- a/packages/web-shared/src/lib/trace-builder.test.ts +++ b/packages/web-shared/src/lib/trace-builder.test.ts @@ -1,7 +1,7 @@ import type { Event, EventType, WorkflowRun } from '@workflow/world'; import { describe, expect, it } from 'vitest'; import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils'; -import { buildTrace } from './trace-builder'; +import { buildTrace, filterSpanRawEvents } from './trace-builder'; const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z'); @@ -71,4 +71,32 @@ describe('buildTrace', () => { expect(otelTimeToMs(stepSpan?.endTime ?? [0, 0])).toBe(BASE_TIME + 20_000); expect(trace.duplicateEventIds.size).toBe(0); }); + + it('keeps sealed-position noops out of the geometry and its time bounds', () => { + const events = [ + event('run_created', { at: 0 }), + event('run_started', { at: 0 }), + event('step_created', { correlationId: 'step_a', at: 1 }), + event('step_started', { correlationId: 'step_a', at: 1 }), + event('step_completed', { correlationId: 'step_a', at: 4 }), + // A hole sealed by a reader long after the run went quiet. Its + // createdAt is the SEALER's clock — letting it into the geometry would + // stretch the trace to the sealer's schedule. + event('noop', { correlationId: 'noop_5', at: 300 }), + ]; + + const trace = buildTrace(run, events, new Date(BASE_TIME + 400_000), { + isCompleteHistory: true, + }); + + // No span for the noop's correlationId, and the trace's known duration + // ends at the last real event, not at the seal. + expect(trace.spans.some((span) => span.spanId === 'noop_5')).toBe(false); + expect(trace.knownDurationMs).toBe(4000); + + // The run span's raw event list still shows the seal (greyed in the UI), + // exactly like duplicate rows: real log rows, marked with the reason. + const runRaw = filterSpanRawEvents(events, 'run', 'run_1'); + expect(runRaw.some((e) => e.eventType === 'noop')).toBe(true); + }); }); diff --git a/packages/web-shared/src/lib/trace-builder.ts b/packages/web-shared/src/lib/trace-builder.ts index b8d1186efd..ec671ebc66 100644 --- a/packages/web-shared/src/lib/trace-builder.ts +++ b/packages/web-shared/src/lib/trace-builder.ts @@ -23,6 +23,7 @@ import { } from '../components/workflow-traces/trace-span-construction'; import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils'; import { findDuplicateEventIds } from './duplicate-events'; +import { isSealedNoopEvent } from './sealed-events'; import type { Span } from './trace-types'; /** @@ -212,14 +213,18 @@ export function buildTrace( // Span geometry comes from what the run acted on. A repeat of a class the // log already records is read past by every replay, and letting one through // here would stretch a span to whenever a concurrent replay committed it. - // The event lists still show them, marked as repeats. + // Sealed-position noops are excluded for the same reason with a different + // clock: a noop's createdAt is the sealer's wall time, which can postdate + // every real event around it, so feeding it into span grouping or the + // latest-known-time bound would chart the sealer's schedule instead of the + // run's. The event lists still show both, marked with the reason. const duplicateEventIds = findDuplicateEventIds(events, { isCompleteHistory, }); - const actedOnEvents = - duplicateEventIds.size === 0 - ? events - : events.filter((event) => !duplicateEventIds.has(event.eventId)); + const actedOnEvents = events.filter( + (event) => + !duplicateEventIds.has(event.eventId) && !isSealedNoopEvent(event) + ); const groupedEvents = groupEventsByCorrelation(actedOnEvents); const latestKnownTime = computeLatestKnownTime(actedOnEvents, run); diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index dfa56584df..764b43afc5 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import { rm } from 'node:fs/promises'; import path from 'node:path'; import type { QueuePrefix, World } from '@workflow/world'; -import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; +import { mintedSpecVersion, reenqueueActiveRuns } from '@workflow/world'; import { warnIfRunningInVercelDeployment } from './build-target-mismatch.js'; import type { Config } from './config.js'; import { config, resolveRecoverActiveRuns } from './config.js'; @@ -72,7 +72,7 @@ export function createWorld(args?: Partial): LocalWorld { ); const recoverActiveRuns = resolveRecoverActiveRuns(mergedConfig); return { - specVersion: SPEC_VERSION_CURRENT, + specVersion: mintedSpecVersion(), capabilities: { hookRetention: { active: true }, // world-local deduplicates concurrent `hook_received` writes sharing a diff --git a/packages/world-local/src/storage/slot-identity.test.ts b/packages/world-local/src/storage/slot-identity.test.ts index 7ef644e724..38d8afc099 100644 --- a/packages/world-local/src/storage/slot-identity.test.ts +++ b/packages/world-local/src/storage/slot-identity.test.ts @@ -467,3 +467,47 @@ describe('skipped-slot report', () => { } }); }); + +describe('sealed-log noop events', () => { + // world-local allocates positions synchronously from its own counter, so it + // never needs to seal a hole itself — but spec 7 makes `noop` a legal + // resident of any slot log, and the storage layer must round-trip one: + // store it at its slot, list it back in order, and keep numbering past it. + // (Creating one through `events.create` stands in for a backend sealer; the + // public CreateEventSchema excludes `noop`, which is asserted in + // @workflow/world's own tests.) + it('stores, lists, and numbers past a noop event', async () => { + const runId = await startRun(); + + await storage.events.create(runId, { + eventType: 'noop', + specVersion: SPEC_VERSION_CURRENT, + eventData: { sealed: true }, + } as any); + await storage.events.create(runId, { + eventType: 'step_created', + correlationId: 'step_after_noop', + specVersion: SPEC_VERSION_CURRENT, + eventData: { stepName: 'afterNoop', input: serialized([]) }, + } as any); + + const result = await storage.events.list({ + runId, + pagination: { limit: 100 }, + }); + const types = result.data.map((event) => event.eventType); + expect(types).toEqual([ + 'run_created', + 'run_started', + 'noop', + 'step_created', + ]); + // The noop occupies a real position: slots stay dense through it. + expect(result.data.map((event) => event.eventId)).toEqual([ + slotId(FIRST_EVENT_SLOT), + slotId(FIRST_EVENT_SLOT + 1), + slotId(FIRST_EVENT_SLOT + 2), + slotId(FIRST_EVENT_SLOT + 3), + ]); + }); +}); diff --git a/packages/world-postgres/src/index.ts b/packages/world-postgres/src/index.ts index 4310f62864..336daad58e 100644 --- a/packages/world-postgres/src/index.ts +++ b/packages/world-postgres/src/index.ts @@ -1,5 +1,5 @@ import type { Storage, World } from '@workflow/world'; -import { reenqueueActiveRuns, SPEC_VERSION_CURRENT } from '@workflow/world'; +import { mintedSpecVersion, reenqueueActiveRuns } from '@workflow/world'; import { Pool } from 'pg'; import type { PostgresWorldConfig } from './config.js'; import { createClient, type Drizzle } from './drizzle/index.js'; @@ -73,7 +73,7 @@ export function createWorld( const streamer = createStreamer(pool, drizzle); return { - specVersion: SPEC_VERSION_CURRENT, + specVersion: mintedSpecVersion(), capabilities: { hookRetention: { active: true }, }, diff --git a/packages/world-postgres/test/spec.test.ts b/packages/world-postgres/test/spec.test.ts index 1be4cb2636..39eeeb08e7 100644 --- a/packages/world-postgres/test/spec.test.ts +++ b/packages/world-postgres/test/spec.test.ts @@ -1,7 +1,12 @@ import { execSync } from 'node:child_process'; import { PostgreSqlContainer } from '@testcontainers/postgresql'; +import { + eventIdToSlot, + FIRST_EVENT_SLOT, + SPEC_VERSION_CURRENT, +} from '@workflow/world'; import { createTestSuite } from '@workflow/world-testing'; -import { afterAll, beforeAll, test } from 'vitest'; +import { afterAll, beforeAll, expect, test } from 'vitest'; // Skip these tests on Windows since it relies on a docker container if (process.platform === 'win32') { @@ -29,5 +34,75 @@ if (process.platform === 'win32') { }); test('smoke', () => {}); + + // Sealed-log noop tolerance (specVersion 7): world-postgres allocates + // positions from its own counter and never seals holes itself, but a + // `noop` is a legal resident of any spec-7 slot log, and the storage layer + // must round-trip one — store it at its slot, list it back in order, and + // keep numbering past it. Direct storage access (not the conformance + // server): only a backend sealer would ever write one, and the public + // CreateEventSchema excludes it. + test('stores, lists, and numbers past a noop event', async () => { + // Storage layer only — createWorld would also spin up the queue and the + // streamer's dedicated LISTEN client, which have no shutdown hook here + // and would die noisily when afterAll stops the container. + const { createClient } = await import('../dist/drizzle/index.js'); + const { createEventsStorage } = await import('../dist/storage.js'); + const { Pool } = await import('pg'); + const pool = new Pool({ + connectionString: process.env.WORKFLOW_POSTGRES_URL, + max: 2, + }); + const world = { events: createEventsStorage(createClient(pool)) }; + + const serialized = (value: unknown) => + ({ data: JSON.stringify(value), encoding: 'json' }) as any; + const created = await world.events.create('', { + eventType: 'run_created', + specVersion: SPEC_VERSION_CURRENT, + eventData: { + deploymentId: 'dpl_noop', + workflowName: 'noopWorkflow', + input: serialized([]), + }, + } as any); + const runId = created.event!.runId; + await world.events.create(runId, { + eventType: 'run_started', + specVersion: SPEC_VERSION_CURRENT, + } as any); + await world.events.create(runId, { + eventType: 'noop', + specVersion: SPEC_VERSION_CURRENT, + eventData: { sealed: true }, + } as any); + await world.events.create(runId, { + eventType: 'step_created', + correlationId: 'step_after_noop', + specVersion: SPEC_VERSION_CURRENT, + eventData: { stepName: 'afterNoop', input: serialized([]) }, + } as any); + + const result = await world.events.list({ + runId, + pagination: { limit: 100 }, + }); + expect(result.data.map((event: any) => event.eventType)).toEqual([ + 'run_created', + 'run_started', + 'noop', + 'step_created', + ]); + expect( + result.data.map((event: any) => eventIdToSlot(event.eventId)) + ).toEqual([ + FIRST_EVENT_SLOT, + FIRST_EVENT_SLOT + 1, + FIRST_EVENT_SLOT + 2, + FIRST_EVENT_SLOT + 3, + ]); + await pool.end(); + }, 60_000); + createTestSuite('./dist/index.js'); } diff --git a/packages/world-vercel/src/event-retry.ts b/packages/world-vercel/src/event-retry.ts index 3228bd45b8..0310309e6f 100644 --- a/packages/world-vercel/src/event-retry.ts +++ b/packages/world-vercel/src/event-retry.ts @@ -172,6 +172,12 @@ export const EVENT_RETRY_ELIGIBILITY = { retryable: false, reason: 'server-originated; never POSTed by the SDK', }, + // Server-originated sealed-log filler (specVersion 7); the SDK never + // POSTs it — the server's read path writes it to seal an abandoned slot. + noop: { + retryable: false, + reason: 'server-originated; never POSTed by the SDK', + }, } satisfies Record; /** Up to this many retries after the initial attempt (3 attempts total). */ diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 2b9f8f0fe2..4cd71ba695 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -361,6 +361,9 @@ const CreateEventV4BodySchemas: { hook_conflict: CreateEventV4BodySchema, wait_created: CreateEventV4BodySchema, wait_completed: CreateEventV4BodySchema, + // Never POSTed by the SDK (server-originated sealed-log filler); present + // only because the map is exhaustive over EventType. + noop: CreateEventV4BodySchema, }; const MaxEventsHeaderSchema = z.coerce.number().int().positive(); diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 224f3091be..7ac12ec34e 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -1,5 +1,5 @@ import type { World } from '@workflow/world'; -import { SPEC_VERSION_CURRENT } from '@workflow/world'; +import { mintedSpecVersion } from '@workflow/world'; import { createAnalytics } from './analytics.js'; import { createRunId, describeRun } from './create-run-id.js'; import { createGetEncryptionKeyForRun } from './encryption.js'; @@ -39,7 +39,7 @@ export function createWorld(config?: APIConfig): World { // version that introduced slots: a bump has to move this declaration with // it, or the runtime's compatibility floor rises past the adapter shipped // alongside it and rejects it (see `assertWorldSupportsRuntimeProtocol`). - specVersion: SPEC_VERSION_CURRENT, + specVersion: mintedSpecVersion(), capabilities: { hookRetention: { active: true }, // Vercel Queues supports maxConcurrency-limited consumers, which diff --git a/packages/world-vercel/src/trace-propagation.test.ts b/packages/world-vercel/src/trace-propagation.test.ts index b22b2d0ec6..9966e3c641 100644 --- a/packages/world-vercel/src/trace-propagation.test.ts +++ b/packages/world-vercel/src/trace-propagation.test.ts @@ -393,84 +393,95 @@ describe('ws events transport upgrade trace propagation', () => { // MockAgent, so none of them would notice if the node:http client dropped the // injection. This one puts a real origin on loopback and reads the header off // the wire. -describe('node:http mode trace propagation', () => { - let server: Server | undefined; +// These run against a loopback origin, which they select through +// `VERCEL_WORKFLOW_SERVER_URL`. The inline `WORKFLOW_SERVER_URL_OVERRIDE` +// constant WINS over that env var by design, so while a branch-testing +// override is pinned there is no way for these to reach their own server and +// every request leaves the machine. Skipped in that case rather than left to +// fail confusingly; they run again the moment the override goes back to ''. +describe.skipIf(WORKFLOW_SERVER_URL_OVERRIDE !== '')( + 'node:http mode trace propagation', + () => { + let server: Server | undefined; + + beforeEach(() => { + vi.stubEnv(NODE_HTTP_ENV_VAR, '1'); + }); - beforeEach(() => { - vi.stubEnv(NODE_HTTP_ENV_VAR, '1'); - }); + afterEach(async () => { + const toClose = server; + server = undefined; + if (toClose) { + toClose.closeAllConnections(); + await new Promise((resolve) => toClose.close(resolve)); + } + }); - afterEach(async () => { - const toClose = server; - server = undefined; - if (toClose) { - toClose.closeAllConnections(); - await new Promise((resolve) => toClose.close(resolve)); - } - }); + it('sends traceparent on a request that never touches undici, parented to the client span', async () => { + const schema = z.object({ value: z.string() }); + let sentTraceparent: string | undefined; - it('sends traceparent on a request that never touches undici, parented to the client span', async () => { - const schema = z.object({ value: z.string() }); - let sentTraceparent: string | undefined; + server = createServer((request, response) => { + sentTraceparent = request.headers.traceparent as string | undefined; + request.resume(); + response.setHeader('content-type', 'application/cbor'); + response.end(encode({ value: 'ok' })); + }); + await new Promise((resolve) => + server?.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + vi.stubEnv('VERCEL_WORKFLOW_SERVER_URL', `http://127.0.0.1:${port}`); + + const tracer = otelTrace.getTracer('test'); + let traceId = ''; + let spanId = ''; + await tracer.startActiveSpan('flow-invocation', async (span) => { + traceId = span.spanContext().traceId; + spanId = span.spanContext().spanId; + const result = await makeRequest({ + endpoint: '/v3/runs/wrun_test/events', + options: { method: 'GET' }, + schema, + }); + expect(result).toEqual({ value: 'ok' }); + span.end(); + }); - server = createServer((request, response) => { - sentTraceparent = request.headers.traceparent as string | undefined; - request.resume(); - response.setHeader('content-type', 'application/cbor'); - response.end(encode({ value: 'ok' })); + expect(sentTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/); + const clientSpan = exporter + .getFinishedSpans() + .find((s) => s.name === 'http GET'); + expect(clientSpan?.spanContext().traceId).toBe(traceId); + expect(clientSpan?.parentSpanId).toBe(spanId); + expect(sentTraceparent).toBe( + `00-${traceId}-${clientSpan?.spanContext().spanId}-01` + ); + // Both transports emit `http GET` against the same `url.full`, so this + // attribute is the only thing in a trace that names which one ran. + expect(clientSpan?.attributes['workflow.http.transport']).toBe( + 'node-http' + ); }); - await new Promise((resolve) => - server?.listen(0, '127.0.0.1', resolve) - ); - const { port } = server.address() as AddressInfo; - vi.stubEnv('VERCEL_WORKFLOW_SERVER_URL', `http://127.0.0.1:${port}`); - const tracer = otelTrace.getTracer('test'); - let traceId = ''; - let spanId = ''; - await tracer.startActiveSpan('flow-invocation', async (span) => { - traceId = span.spanContext().traceId; - spanId = span.spanContext().spanId; - const result = await makeRequest({ + it('marks the undici path with the same attribute', async () => { + vi.stubEnv(NODE_HTTP_ENV_VAR, '0'); + const schema = z.object({ value: z.string() }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => cborResponse({ value: 'ok' })) + ); + + await makeRequest({ endpoint: '/v3/runs/wrun_test/events', options: { method: 'GET' }, schema, }); - expect(result).toEqual({ value: 'ok' }); - span.end(); - }); - expect(sentTraceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$/); - const clientSpan = exporter - .getFinishedSpans() - .find((s) => s.name === 'http GET'); - expect(clientSpan?.spanContext().traceId).toBe(traceId); - expect(clientSpan?.parentSpanId).toBe(spanId); - expect(sentTraceparent).toBe( - `00-${traceId}-${clientSpan?.spanContext().spanId}-01` - ); - // Both transports emit `http GET` against the same `url.full`, so this - // attribute is the only thing in a trace that names which one ran. - expect(clientSpan?.attributes['workflow.http.transport']).toBe('node-http'); - }); - - it('marks the undici path with the same attribute', async () => { - vi.stubEnv(NODE_HTTP_ENV_VAR, '0'); - const schema = z.object({ value: z.string() }); - vi.stubGlobal( - 'fetch', - vi.fn(async () => cborResponse({ value: 'ok' })) - ); - - await makeRequest({ - endpoint: '/v3/runs/wrun_test/events', - options: { method: 'GET' }, - schema, + const clientSpan = exporter + .getFinishedSpans() + .find((s) => s.name === 'http GET'); + expect(clientSpan?.attributes['workflow.http.transport']).toBe('undici'); }); - - const clientSpan = exporter - .getFinishedSpans() - .find((s) => s.name === 'http GET'); - expect(clientSpan?.attributes['workflow.http.transport']).toBe('undici'); - }); -}); + } +); diff --git a/packages/world-vercel/src/utils.test.ts b/packages/world-vercel/src/utils.test.ts index 18273e3deb..4dec341201 100644 --- a/packages/world-vercel/src/utils.test.ts +++ b/packages/world-vercel/src/utils.test.ts @@ -722,98 +722,110 @@ describe('makeRequest transport errors', () => { // origin instead, covering the two contracts the runtime branches on: a // failed request has to stay retryable, and a typed error status has to keep // producing the same typed error whichever transport carried it. -describe('makeRequest over node:http', () => { - const schema = z.object({ value: z.string() }); - const originalEnv = process.env; - let server: Server | undefined; +// These run against a loopback origin, which they select through +// `VERCEL_WORKFLOW_SERVER_URL`. The inline `WORKFLOW_SERVER_URL_OVERRIDE` +// constant WINS over that env var by design, so while a branch-testing +// override is pinned there is no way for these to reach their own server and +// every request leaves the machine. Skipped in that case rather than left to +// fail confusingly; they run again the moment the override goes back to ''. +describe.skipIf(WORKFLOW_SERVER_URL_OVERRIDE !== '')( + 'makeRequest over node:http', + () => { + const schema = z.object({ value: z.string() }); + const originalEnv = process.env; + let server: Server | undefined; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.VERCEL_OIDC_TOKEN; + process.env[NODE_HTTP_ENV_VAR] = '1'; + }); - beforeEach(() => { - process.env = { ...originalEnv }; - delete process.env.VERCEL_OIDC_TOKEN; - process.env[NODE_HTTP_ENV_VAR] = '1'; - }); + afterEach(async () => { + process.env = originalEnv; + const toClose = server; + server = undefined; + if (toClose) { + toClose.closeAllConnections(); + await new Promise((resolve) => toClose.close(resolve)); + } + }); - afterEach(async () => { - process.env = originalEnv; - const toClose = server; - server = undefined; - if (toClose) { - toClose.closeAllConnections(); - await new Promise((resolve) => toClose.close(resolve)); + /** Start a loopback origin and point the client at it. */ + async function listen(handler: RequestListener): Promise { + server = createServer(handler); + await new Promise((resolve) => + server?.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + process.env.VERCEL_WORKFLOW_SERVER_URL = `http://127.0.0.1:${port}`; } - }); - /** Start a loopback origin and point the client at it. */ - async function listen(handler: RequestListener): Promise { - server = createServer(handler); - await new Promise((resolve) => - server?.listen(0, '127.0.0.1', resolve) - ); - const { port } = server.address() as AddressInfo; - process.env.VERCEL_WORKFLOW_SERVER_URL = `http://127.0.0.1:${port}`; - } - - it('maps a dropped socket to a retryable TRANSPORT error', async () => { - await listen((request) => request.socket.destroy()); + it('maps a dropped socket to a retryable TRANSPORT error', async () => { + await listen((request) => request.socket.destroy()); - // Node raises ECONNRESET on the error itself rather than on a `cause`, so - // this only passes if getTransientTransportCode reads the top-level code. - await expect( - makeRequest({ - endpoint: '/v3/runs/wrun_test/events', - options: { method: 'GET' }, - schema, - }) - ).rejects.toMatchObject({ name: 'WorkflowWorldError', code: 'TRANSPORT' }); - }); + // Node raises ECONNRESET on the error itself rather than on a `cause`, so + // this only passes if getTransientTransportCode reads the top-level code. + await expect( + makeRequest({ + endpoint: '/v3/runs/wrun_test/events', + options: { method: 'GET' }, + schema, + }) + ).rejects.toMatchObject({ + name: 'WorkflowWorldError', + code: 'TRANSPORT', + }); + }); - it('maps a 412 response to PreconditionFailedError', async () => { - await listen((request, response) => { - request.resume(); - response.statusCode = 412; - response.setHeader('content-type', 'application/cbor'); - response.end( - encode({ - success: false, - error: 'precondition-failed', - code: 'precondition-failed', - message: 'precondition-failed', + it('maps a 412 response to PreconditionFailedError', async () => { + await listen((request, response) => { + request.resume(); + response.statusCode = 412; + response.setHeader('content-type', 'application/cbor'); + response.end( + encode({ + success: false, + error: 'precondition-failed', + code: 'precondition-failed', + message: 'precondition-failed', + }) + ); + }); + + await expect( + makeRequest({ + endpoint: '/v3/runs/wrun_test/events', + options: { method: 'POST' }, + data: { eventType: 'run_completed' }, + schema, }) - ); + ).rejects.toBeInstanceOf(PreconditionFailedError); }); - await expect( - makeRequest({ + it('round-trips a CBOR POST body to the origin', async () => { + let seen: { method?: string; length?: string } = {}; + await listen((request, response) => { + seen = { + method: request.method, + length: request.headers['content-length'], + }; + request.resume(); + response.setHeader('content-type', 'application/cbor'); + response.end(encode({ value: 'ok' })); + }); + + const result = await makeRequest({ endpoint: '/v3/runs/wrun_test/events', options: { method: 'POST' }, data: { eventType: 'run_completed' }, schema, - }) - ).rejects.toBeInstanceOf(PreconditionFailedError); - }); + }); - it('round-trips a CBOR POST body to the origin', async () => { - let seen: { method?: string; length?: string } = {}; - await listen((request, response) => { - seen = { - method: request.method, - length: request.headers['content-length'], - }; - request.resume(); - response.setHeader('content-type', 'application/cbor'); - response.end(encode({ value: 'ok' })); + expect(result).toEqual({ value: 'ok' }); + expect(seen.method).toBe('POST'); + // A declared length, not a chunked body: some origins reject the latter. + expect(Number(seen.length)).toBeGreaterThan(0); }); - - const result = await makeRequest({ - endpoint: '/v3/runs/wrun_test/events', - options: { method: 'POST' }, - data: { eventType: 'run_completed' }, - schema, - }); - - expect(result).toEqual({ value: 'ok' }); - expect(seen.method).toBe('POST'); - // A declared length, not a chunked body: some origins reject the latter. - expect(Number(seen.length)).toBeGreaterThan(0); - }); -}); + } +); diff --git a/packages/world/src/events.test.ts b/packages/world/src/events.test.ts index 587d48535b..491c0d9e54 100644 --- a/packages/world/src/events.test.ts +++ b/packages/world/src/events.test.ts @@ -109,3 +109,39 @@ describe('run_cancelled cancelReason', () => { ).toBe('operator cancelled'); }); }); + +describe('sealed-log noop events', () => { + it('parses a noop event from the read union', () => { + // Written only by the World's backend when it seals an abandoned slot + // (specVersion >= 7); readers must accept it wherever events are parsed. + const parsed = EventSchema.parse({ + eventType: 'noop', + runId: 'wrun_123', + eventId: 'evnt_00000000000000000000000003', + createdAt: new Date().toISOString(), + specVersion: 7, + eventData: { sealed: true }, + }); + expect(parsed.eventType).toBe('noop'); + }); + + it('parses a noop with no eventData at all', () => { + const parsed = EventSchema.parse({ + eventType: 'noop', + runId: 'wrun_123', + eventId: 'evnt_00000000000000000000000003', + createdAt: new Date().toISOString(), + }); + expect(parsed.eventType).toBe('noop'); + }); + + it('is not user-creatable', () => { + // A client-minted noop would burn a slot it never allocated; only the + // backend's sealer writes them. + const result = CreateEventSchema.safeParse({ + eventType: 'noop', + eventData: { sealed: true }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 344462ea22..8d1cfd0ee2 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -31,6 +31,10 @@ export const EventTypeSchema = z.enum([ // Wait lifecycle events 'wait_created', 'wait_completed', + // Sealed-log filler (specVersion >= 7): written ONLY by the World's backend + // to occupy a slot whose writer allocated it and died. Carries no workflow + // meaning; replay skips it (see EventsConsumer). Never user-creatable. + 'noop', ]); export type EventType = z.infer; @@ -189,6 +193,23 @@ export function isWaitEventType(eventType: string): eventType is WaitEventType { return WAIT_EVENT_TYPES.includes(eventType as WaitEventType); } +/** + * Whether an event is a sealed-log filler occupying an abandoned slot. + * + * The single home for this test, deliberately: a noop is invisible to the run + * but it is a real row of the log, so *every* pass over a log has to decide + * whether it is walking positions (count it) or reconstructing what happened + * (skip it). The two replay engines and the observability trace builder each + * make that decision independently, and the one thing they must agree on is + * that a noop's `createdAt` — the sealer's wall clock, which can postdate + * every real event around it — never becomes a time the run observed. + */ +export function isSealedNoopEvent( + event: Pick | { eventType: string } +): boolean { + return event.eventType === 'noop'; +} + const ChildEntityCreationEventTypeSchema = EventTypeSchema.extract([ 'step_created', 'hook_created', @@ -496,6 +517,25 @@ const HookConflictEventSchema = BaseEventSchema.extend({ }), }); +/** + * Sealed-log filler event (specVersion >= 7). Written ONLY by the World's + * backend when it seals a slot whose writer allocated the position and died + * before committing (see `SPEC_VERSION_SUPPORTS_SEALED_LOG`). It occupies its + * slot — so density arithmetic and cursors count it — but carries no workflow + * meaning: replay steps over it without delivering it to any consumer and + * without advancing the deterministic clock. NOT user-creatable, and absent + * from `CreateEventSchema` for that reason. + */ +const NoopEventSchema = BaseEventSchema.extend({ + eventType: z.literal('noop'), + eventData: z + .object({ + sealed: z.boolean().optional(), + }) + .passthrough() + .optional(), +}); + const WaitCreatedEventSchema = BaseEventSchema.extend({ eventType: z.literal('wait_created'), correlationId: z.string(), @@ -690,6 +730,7 @@ const AllEventsSchema = z.discriminatedUnion('eventType', [ // Wait lifecycle events WaitCreatedEventSchema, WaitCompletedEventSchema, + NoopEventSchema, // World-only: sealed-log filler for an abandoned slot ]); // Server response includes runId, eventId, and createdAt diff --git a/packages/world/src/index.ts b/packages/world/src/index.ts index 701acb4ea3..d6efe39fac 100644 --- a/packages/world/src/index.ts +++ b/packages/world/src/index.ts @@ -51,6 +51,7 @@ export { isHookEventRequiringExistence, isHookLifecycleEventType, isRunEventType, + isSealedNoopEvent, isStepEventType, isTerminalRunEventType, isTerminalStepEventType, @@ -136,7 +137,9 @@ export { export type { SpecVersion } from './spec-version.js'; export { isLegacySpecVersion, + mintedSpecVersion, requiresNewerWorld, + SEALED_LOG_ENV_VAR, SPEC_VERSION_CURRENT, SPEC_VERSION_LEGACY, SPEC_VERSION_MAX_SUPPORTED, @@ -144,6 +147,7 @@ export { SPEC_VERSION_SUPPORTS_CBOR_QUEUE_TRANSPORT, SPEC_VERSION_SUPPORTS_COMPRESSION, SPEC_VERSION_SUPPORTS_EVENT_SOURCING, + SPEC_VERSION_SUPPORTS_SEALED_LOG, SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, } from './spec-version.js'; export type * from './steps.js'; diff --git a/packages/world/src/spec-version.test.ts b/packages/world/src/spec-version.test.ts index da9d9a3f79..e03e735b01 100644 --- a/packages/world/src/spec-version.test.ts +++ b/packages/world/src/spec-version.test.ts @@ -1,29 +1,61 @@ import { describe, expect, it } from 'vitest'; import { isLegacySpecVersion, + mintedSpecVersion, requiresNewerWorld, + SEALED_LOG_ENV_VAR, SPEC_VERSION_CURRENT, SPEC_VERSION_LEGACY, SPEC_VERSION_MAX_SUPPORTED, SPEC_VERSION_SUPPORTS_ATTRIBUTES, SPEC_VERSION_SUPPORTS_COMPRESSION, + SPEC_VERSION_SUPPORTS_SEALED_LOG, SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, } from './spec-version.js'; describe('spec version constants', () => { - it('current spec version is the compression version', () => { - expect(SPEC_VERSION_CURRENT).toBe(SPEC_VERSION_SUPPORTS_COMPRESSION); - expect(SPEC_VERSION_SUPPORTS_COMPRESSION).toBe(5); + it('current spec version is the sealed-log version', () => { + expect(SPEC_VERSION_SUPPORTS_SLOT_IDENTITY).toBe(6); + expect(SPEC_VERSION_SUPPORTS_SEALED_LOG).toBe(7); + expect(SPEC_VERSION_CURRENT).toBe(SPEC_VERSION_SUPPORTS_SEALED_LOG); }); - it('the readable ceiling is the slot-identity version', () => { - // The default a World stamps and the highest version this SDK can read - // are separate dials. Slot identity is above the default on purpose: only - // a World that actually allocates slots opts into it. - expect(SPEC_VERSION_SUPPORTS_SLOT_IDENTITY).toBe(6); - expect(SPEC_VERSION_MAX_SUPPORTED).toBe( - SPEC_VERSION_SUPPORTS_SLOT_IDENTITY - ); + describe('mintedSpecVersion', () => { + it('stamps the sealed-log version by default', () => { + expect(mintedSpecVersion({})).toBe(SPEC_VERSION_CURRENT); + expect(mintedSpecVersion({})).toBe(SPEC_VERSION_SUPPORTS_SEALED_LOG); + }); + + it('falls back to slot identity when switched off', () => { + for (const off of ['0', 'false']) { + expect(mintedSpecVersion({ [SEALED_LOG_ENV_VAR]: off })).toBe( + SPEC_VERSION_SUPPORTS_SLOT_IDENTITY + ); + } + }); + + it('stays on by default for an unset or malformed value', () => { + // A flag is an escape hatch, not a hard requirement: a typo must not + // silently move a deployment onto the older identity scheme. + for (const raw of ['', '1', 'true', 'yes-please']) { + expect(mintedSpecVersion({ [SEALED_LOG_ENV_VAR]: raw })).toBe( + SPEC_VERSION_CURRENT + ); + } + }); + + it('never stamps a version this build cannot read back', () => { + expect(mintedSpecVersion({})).toBeLessThanOrEqual( + SPEC_VERSION_MAX_SUPPORTED + ); + }); + }); + + it('the readable ceiling moves with the version we stamp', () => { + // "What do we write?" and "what can we still read?" are separate dials, + // and the ceiling must never sit below the default: an SDK that stamps a + // version it cannot read back would reject its own runs. + expect(SPEC_VERSION_MAX_SUPPORTED).toBe(SPEC_VERSION_SUPPORTS_SEALED_LOG); expect(SPEC_VERSION_MAX_SUPPORTED).toBeGreaterThanOrEqual( SPEC_VERSION_CURRENT ); diff --git a/packages/world/src/spec-version.ts b/packages/world/src/spec-version.ts index b6dab76964..a4263a9a31 100644 --- a/packages/world/src/spec-version.ts +++ b/packages/world/src/spec-version.ts @@ -5,6 +5,8 @@ * from @workflow/world rather than using arbitrary numbers. */ +import { envFlag } from './env-config.js'; + declare const SpecVersionBrand: unique symbol; /** @@ -50,17 +52,42 @@ export const SPEC_VERSION_SUPPORTS_COMPRESSION = 5 as SpecVersion; */ export const SPEC_VERSION_SUPPORTS_SLOT_IDENTITY = 6 as SpecVersion; +/** + * Runs at this spec version or later live in a "sealed log": their slot + * positions are pre-assigned by a per-run sequencer on the World's backend, + * so concurrent writers never race each other for a position — and a position + * whose writer died is filled ("sealed") by the backend with a `noop` event. + * What the version gates is the READER contract that makes that safe: a + * reader at this version knows a `noop` occupies its slot and carries no + * workflow meaning, and skips it during replay without advancing the + * deterministic clock (see `EventsConsumer`). A reader below this version + * would fail to parse the unknown event type, which is exactly what + * `requiresNewerWorld` exists to catch. + * + * Note this is the READER contract only, so a World is spec-7 compliant by + * construction if it allocates each position at the commit that occupies it: + * no write can then leave a position empty, so it has no holes to seal and + * will never emit a `noop`. Pre-assigning positions ahead of the commit is + * what creates the obligation (see `building-a-world.mdx`), and only a World + * that does so needs the sealing half. + */ +export const SPEC_VERSION_SUPPORTS_SEALED_LOG = 7 as SpecVersion; + /** * Current spec version: event-sourced architecture with native attributes, - * compressed payloads and slot-numbered event ids. + * compressed payloads, slot-numbered event ids, and sealed-log sequencing. * * This is both the version a World stamps on the runs it creates and the * *lowest* one this runtime accepts from a World (see - * `assertWorldSupportsRuntimeProtocol`). The two coincide because slot - * numbering is a requirement of the World contract rather than a capability to - * opt into: a World declaring anything below this allocates event ids the - * runtime cannot read positions out of, so admitting it would only move the - * failure from startup to the middle of a run. + * `assertWorldSupportsRuntimeProtocol`). Slot numbering is a requirement of + * the World contract rather than a capability to opt into: a World declaring + * anything below this allocates event ids the runtime cannot read positions + * out of, so admitting it would only move the failure from startup to the + * middle of a run. + * + * This is the FLOOR, not necessarily what gets stamped. Sealed-log runs sit + * one version above it and are opt-in, so what a World actually stamps comes + * from {@link mintedSpecVersion}; this is what that falls back to. * * A World therefore declares this constant rather than a literal, so a bump * moves the declaration and the floor together. Pinning a literal would leave @@ -72,21 +99,57 @@ export const SPEC_VERSION_SUPPORTS_SLOT_IDENTITY = 6 as SpecVersion; * run's identity scheme from what is stored rather than from this constant. */ export const SPEC_VERSION_CURRENT = - SPEC_VERSION_SUPPORTS_SLOT_IDENTITY as SpecVersion; + SPEC_VERSION_SUPPORTS_SEALED_LOG as SpecVersion; + +/** + * Environment variable that opts new runs OUT of the sealed log. + * + * Read per `createWorld()` call rather than at module load, so a test or a + * single process can create worlds in both modes. + */ +export const SEALED_LOG_ENV_VAR = 'WORKFLOW_SEALED_LOG'; + +/** + * The spec version a World should stamp on the runs it creates: the sealed log + * unless {@link SEALED_LOG_ENV_VAR} switches it off, in which case the + * slot-identity version it supersedes. + * + * Same shape, and the same reasoning, as the flag slot identity itself shipped + * behind before going unconditional: default on, with one env var to put a + * deployment back on the previous scheme without a release. + * + * The fallback is a real fallback, not a formality. Turning this off has to + * leave a World the runtime still admits, which is why + * `assertWorldSupportsRuntimeProtocol` floors at the slot-identity version + * rather than at {@link SPEC_VERSION_CURRENT} — a kill switch that made the + * runtime reject its own World would be no kill switch at all. + * + * Every World reads runs up to {@link SPEC_VERSION_MAX_SUPPORTED} whatever + * this returns, so switching it off here does not make runs another process + * created unreadable. + */ +export function mintedSpecVersion( + env: Record = process.env +): SpecVersion { + return envFlag(SEALED_LOG_ENV_VAR, true, env) + ? SPEC_VERSION_CURRENT + : SPEC_VERSION_SUPPORTS_SLOT_IDENTITY; +} /** * The highest spec version this SDK can read. * - * Kept distinct from `SPEC_VERSION_CURRENT` even though the two are equal - * today. They answer different questions, "what do we write?" versus "what can - * we still read?", and they come apart in the release order a spec bump - * follows: a reader that can already handle the next version raises this - * ceiling first, and `SPEC_VERSION_CURRENT` follows only once the version is - * safe to stamp. Collapsing them into one constant would make that staging - * impossible to express. + * Kept distinct from `SPEC_VERSION_CURRENT`, and right now they genuinely + * differ. They answer different questions, "what do we write?" versus "what + * can we still read?", and they come apart in exactly the release order a spec + * bump follows: a reader that can already handle the next version raises this + * ceiling first, and stamping follows only once the version is safe to mint + * everywhere. Sealed-log support is at that first stage — every build reads + * spec 7 and skips `noop`, while {@link mintedSpecVersion} still has to be + * turned on before anything creates a spec-7 run. */ export const SPEC_VERSION_MAX_SUPPORTED = - SPEC_VERSION_SUPPORTS_SLOT_IDENTITY as SpecVersion; + SPEC_VERSION_SUPPORTS_SEALED_LOG as SpecVersion; /** * Check if a spec version is legacy (<= SPEC_VERSION_LEGACY or undefined). From 252a292f18ba7d69fc4a0311456a2dac18bfc907 Mon Sep 17 00:00:00 2001 From: Shalabh Chaturvedi Date: Fri, 21 Aug 2026 13:18:49 -0700 Subject: [PATCH 06/10] [ci] Keep workflow-server override formatting stable (#3713) Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> --- packages/world-vercel/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index 33450c6fee..08c7445f92 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -39,6 +39,7 @@ import { version } from './version.js'; * `main` — rewritten by external CI for branch-deployment testing. * Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration. */ +// biome-ignore format: External CI replaces only this line with a deployment URL that may exceed the formatter width. export const WORKFLOW_SERVER_URL_OVERRIDE = ''; /** From e1e64e3de30e10cba6803907b789699e851d33e2 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 21 Aug 2026 14:24:31 -0700 Subject: [PATCH 07/10] docs: apply Vercel technical writing standards (#3704) * docs: apply Vercel technical writing standards Audit the complete documentation corpus, package READMEs, skills, and source TSDoc/comments against the vercel-technical-writing skill and style-rules.md. Normalize sentence-case headings without changing published anchors, remove prose em dashes and filler wording, improve active voice and self-contained phrasing, standardize product/brand capitalization, American English, list punctuation, units, and code fence languages, and preserve exact runtime strings/table placeholders. All executable code is unchanged. Modified skills have their metadata versions bumped. * docs: extend writing audit to repository Markdown Apply the same technical-writing rules to design documents, compiler specifications, workbench guides, package changelogs, and the remaining tracked Markdown outside the deployed docs corpus. Preserve historical meaning, commands, output literals, table placeholders, and heading anchors. * docs: exclude generated package changelogs from audit --- .changeset/technical-writing-audit.md | 2 + .claude/agents/docs-writer.md | 10 +- AGENTS.md | 133 +++--- docs/README.md | 2 +- .../docs/v4/ai/chat-session-modeling.mdx | 47 +- docs/content/docs/v4/ai/defining-tools.mdx | 10 +- docs/content/docs/v4/ai/human-in-the-loop.mdx | 22 +- docs/content/docs/v4/ai/index.mdx | 40 +- docs/content/docs/v4/ai/message-queueing.mdx | 12 +- docs/content/docs/v4/ai/resumable-streams.mdx | 56 +-- docs/content/docs/v4/ai/sleep-and-delays.mdx | 18 +- .../v4/ai/streaming-updates-from-tools.mdx | 8 +- .../docs/v4/api-reference/vitest/index.mdx | 16 +- .../workflow-ai/durable-agent.mdx | 84 ++-- .../v4/api-reference/workflow-ai/index.mdx | 6 +- .../workflow-ai/workflow-chat-transport.mdx | 56 +-- .../workflow-api/get-hook-by-token.mdx | 22 +- .../v4/api-reference/workflow-api/get-run.mdx | 18 +- .../v4/api-reference/workflow-api/index.mdx | 6 +- .../workflow-api/resume-hook.mdx | 26 +- .../workflow-api/resume-webhook.mdx | 6 +- .../v4/api-reference/workflow-api/start.mdx | 24 +- .../api-reference/workflow-astro/workflow.mdx | 4 +- .../workflow-errors/entity-conflict-error.mdx | 8 +- .../workflow-errors/hook-conflict-error.mdx | 8 +- .../workflow-errors/hook-not-found-error.mdx | 16 +- .../api-reference/workflow-errors/index.mdx | 12 +- .../workflow-errors/run-expired-error.mdx | 4 +- .../run-not-supported-error.mdx | 8 +- .../step-not-registered-error.mdx | 10 +- .../workflow-errors/throttle-error.mdx | 4 +- .../workflow-errors/too-early-error.mdx | 4 +- .../workflow-errors/workflow-error.mdx | 8 +- .../workflow-not-registered-error.mdx | 11 +- .../workflow-run-cancelled-error.mdx | 12 +- .../workflow-run-failed-error.mdx | 10 +- .../workflow-run-not-completed-error.mdx | 8 +- .../workflow-run-not-found-error.mdx | 8 +- .../workflow-runtime-error.mdx | 4 +- .../workflow-errors/workflow-world-error.mdx | 16 +- .../v4/api-reference/workflow-globals.mdx | 18 +- .../configure-workflow-controller.mdx | 4 +- .../workflow-nest/nest-local-builder.mdx | 8 +- .../workflow-nest/workflow-controller.mdx | 2 +- .../workflow-nest/workflow-module.mdx | 8 +- .../workflow-next/with-workflow.mdx | 18 +- .../v4/api-reference/workflow-nitro/index.mdx | 10 +- .../v4/api-reference/workflow-nuxt/index.mdx | 8 +- .../workflow-observability/hydrate-data.mdx | 10 +- .../hydrate-resource-io.mdx | 8 +- .../workflow-observability/index.mdx | 14 +- .../observability-revivers.mdx | 4 +- .../parse-class-name.mdx | 6 +- .../parse-step-name.mdx | 8 +- .../parse-workflow-name.mdx | 8 +- .../workflow-runtime/create-world.mdx | 10 +- .../workflow-runtime/get-world-handlers.mdx | 10 +- .../workflow-runtime/get-world.mdx | 8 +- .../workflow-runtime/health-check.mdx | 2 +- .../api-reference/workflow-runtime/index.mdx | 6 +- .../workflow-runtime/set-world.mdx | 6 +- .../workflow-runtime/step-entrypoint.mdx | 8 +- .../workflow-runtime/workflow-entrypoint.mdx | 16 +- .../workflow-runtime/world/index.mdx | 6 +- .../workflow-runtime/world/queue.mdx | 22 +- .../workflow-runtime/world/storage.mdx | 54 +-- .../workflow-runtime/world/streams.mdx | 14 +- .../v4/api-reference/workflow-serde/index.mdx | 2 +- .../workflow-serde/workflow-deserialize.mdx | 4 +- .../workflow-serde/workflow-serialize.mdx | 4 +- .../workflow-sveltekit/workflow-plugin.mdx | 4 +- .../api-reference/workflow-vite/workflow.mdx | 8 +- .../v4/api-reference/workflow/create-hook.mdx | 22 +- .../api-reference/workflow/create-webhook.mdx | 32 +- .../v4/api-reference/workflow/define-hook.mdx | 14 +- .../v4/api-reference/workflow/fatal-error.mdx | 2 +- .../docs/v4/api-reference/workflow/fetch.mdx | 12 +- .../workflow/get-step-metadata.mdx | 4 +- .../workflow/get-workflow-metadata.mdx | 6 +- .../api-reference/workflow/get-writable.mdx | 14 +- .../docs/v4/api-reference/workflow/index.mdx | 2 +- .../workflow/retryable-error.mdx | 2 +- .../docs/v4/api-reference/workflow/sleep.mdx | 6 +- docs/content/docs/v4/changelog/index.mdx | 4 +- .../docs/v4/changelog/resilient-start.mdx | 28 +- docs/content/docs/v4/comparisons/index.mdx | 26 +- .../workflow-sdk-vs-aws-agentcore.mdx | 30 +- .../workflow-sdk-vs-aws-step-functions.mdx | 24 +- .../workflow-sdk-vs-cloudflare-workflows.mdx | 28 +- .../comparisons/workflow-sdk-vs-inngest.mdx | 34 +- .../comparisons/workflow-sdk-vs-temporal.mdx | 38 +- .../workflow-sdk-vs-trigger-dev.mdx | 32 +- .../v4/cookbook/advanced/child-workflows.mdx | 58 +-- .../advanced/distributed-abort-controller.mdx | 68 +-- .../advanced/publishing-libraries.mdx | 80 ++-- .../cookbook/advanced/serializable-steps.mdx | 42 +- .../cookbook/advanced/upgrading-workflows.mdx | 70 +-- .../agent-patterns/agent-cancellation.mdx | 72 ++-- .../agent-patterns/human-in-the-loop.mdx | 44 +- .../v4/cookbook/common-patterns/batching.mdx | 28 +- .../cookbook/common-patterns/idempotency.mdx | 18 +- .../docs/v4/cookbook/common-patterns/saga.mdx | 38 +- .../cookbook/common-patterns/scheduling.mdx | 46 +- .../sequential-and-parallel.mdx | 52 +-- .../v4/cookbook/common-patterns/timeouts.mdx | 46 +- .../v4/cookbook/common-patterns/webhooks.mdx | 12 +- .../common-patterns/workflow-composition.mdx | 36 +- docs/content/docs/v4/cookbook/index.mdx | 46 +- .../docs/v4/cookbook/integrations/ai-sdk.mdx | 84 ++-- .../v4/cookbook/integrations/chat-sdk.mdx | 68 +-- .../docs/v4/cookbook/integrations/sandbox.mdx | 92 ++-- docs/content/docs/v4/deploying.mdx | 30 +- .../docs/v4/errors/corrupted-event-log.mdx | 30 +- .../docs/v4/errors/deployment-mismatch.mdx | 28 +- .../docs/v4/errors/fetch-in-workflow.mdx | 16 +- docs/content/docs/v4/errors/hook-conflict.mdx | 22 +- docs/content/docs/v4/errors/index.mdx | 2 +- .../v4/errors/node-js-module-in-workflow.mdx | 10 +- .../docs/v4/errors/replay-divergence.mdx | 4 +- .../v4/errors/runtime-decryption-failed.mdx | 24 +- .../docs/v4/errors/serialization-failed.mdx | 18 +- .../start-invalid-workflow-function.mdx | 10 +- .../errors/step-executed-multiple-times.mdx | 4 +- .../docs/v4/errors/step-not-registered.mdx | 10 +- .../docs/v4/errors/timeout-in-workflow.mdx | 16 +- .../webhook-invalid-respond-with-value.mdx | 22 +- .../v4/errors/webhook-response-not-sent.mdx | 18 +- .../v4/errors/workflow-not-registered.mdx | 10 +- .../v4/foundations/errors-and-retries.mdx | 16 +- docs/content/docs/v4/foundations/hooks.mdx | 94 ++-- .../docs/v4/foundations/idempotency.mdx | 38 +- .../docs/v4/foundations/serialization.mdx | 59 ++- .../v4/foundations/starting-workflows.mdx | 16 +- .../content/docs/v4/foundations/streaming.mdx | 42 +- .../docs/v4/foundations/versioning.mdx | 10 +- .../v4/foundations/workflows-and-steps.mdx | 16 +- .../content/docs/v4/getting-started/astro.mdx | 46 +- .../docs/v4/getting-started/express.mdx | 16 +- .../docs/v4/getting-started/fastify.mdx | 16 +- docs/content/docs/v4/getting-started/hono.mdx | 16 +- .../docs/v4/getting-started/nestjs.mdx | 24 +- docs/content/docs/v4/getting-started/next.mdx | 20 +- .../content/docs/v4/getting-started/nitro.mdx | 38 +- docs/content/docs/v4/getting-started/nuxt.mdx | 16 +- .../docs/v4/getting-started/python.mdx | 6 +- .../docs/v4/getting-started/sveltekit.mdx | 44 +- .../v4/getting-started/tanstack-start.mdx | 38 +- docs/content/docs/v4/getting-started/vite.mdx | 16 +- .../docs/v4/how-it-works/code-transform.mdx | 96 ++--- .../docs/v4/how-it-works/encryption.mdx | 50 +-- .../docs/v4/how-it-works/event-sourcing.mdx | 42 +- .../how-it-works/framework-integrations.mdx | 78 ++-- .../how-it-works/understanding-directives.mdx | 40 +- docs/content/docs/v4/internal/index.mdx | 4 +- docs/content/docs/v4/observability/index.mdx | 18 +- docs/content/docs/v4/testing/index.mdx | 94 ++-- docs/content/docs/v4/testing/server-based.mdx | 18 +- .../docs/v5/ai/chat-session-modeling.mdx | 47 +- docs/content/docs/v5/ai/defining-tools.mdx | 10 +- docs/content/docs/v5/ai/human-in-the-loop.mdx | 22 +- docs/content/docs/v5/ai/index.mdx | 40 +- docs/content/docs/v5/ai/message-queueing.mdx | 12 +- docs/content/docs/v5/ai/resumable-streams.mdx | 56 +-- docs/content/docs/v5/ai/sleep-and-delays.mdx | 18 +- .../v5/ai/streaming-updates-from-tools.mdx | 8 +- .../docs/v5/api-reference/vitest/index.mdx | 16 +- .../workflow-ai/durable-agent.mdx | 84 ++-- .../v5/api-reference/workflow-ai/index.mdx | 6 +- .../workflow-ai/workflow-chat-transport.mdx | 56 +-- .../workflow-api/get-hook-by-token.mdx | 22 +- .../v5/api-reference/workflow-api/get-run.mdx | 20 +- .../v5/api-reference/workflow-api/index.mdx | 6 +- .../workflow-api/resume-hook.mdx | 28 +- .../workflow-api/resume-webhook.mdx | 6 +- .../v5/api-reference/workflow-api/start.mdx | 32 +- .../api-reference/workflow-astro/workflow.mdx | 6 +- .../workflow-errors/entity-conflict-error.mdx | 8 +- .../workflow-errors/hook-conflict-error.mdx | 8 +- .../workflow-errors/hook-not-found-error.mdx | 16 +- .../api-reference/workflow-errors/index.mdx | 12 +- .../precondition-failed-error.mdx | 18 +- .../workflow-errors/run-expired-error.mdx | 4 +- .../run-not-supported-error.mdx | 8 +- .../step-not-registered-error.mdx | 10 +- .../workflow-errors/throttle-error.mdx | 4 +- .../workflow-errors/too-early-error.mdx | 4 +- .../workflow-errors/workflow-error.mdx | 8 +- .../workflow-not-registered-error.mdx | 11 +- .../workflow-run-cancelled-error.mdx | 12 +- .../workflow-run-failed-error.mdx | 10 +- .../workflow-run-not-completed-error.mdx | 8 +- .../workflow-run-not-found-error.mdx | 8 +- .../workflow-runtime-error.mdx | 4 +- .../workflow-errors/workflow-world-error.mdx | 16 +- .../v5/api-reference/workflow-globals.mdx | 24 +- .../configure-workflow-controller.mdx | 4 +- .../workflow-nest/nest-local-builder.mdx | 8 +- .../workflow-nest/workflow-controller.mdx | 2 +- .../workflow-nest/workflow-module.mdx | 8 +- .../workflow-next/with-workflow.mdx | 32 +- .../v5/api-reference/workflow-nitro/index.mdx | 12 +- .../v5/api-reference/workflow-nuxt/index.mdx | 8 +- .../workflow-observability/hydrate-data.mdx | 10 +- .../hydrate-resource-io.mdx | 8 +- .../workflow-observability/index.mdx | 14 +- .../observability-revivers.mdx | 4 +- .../parse-class-name.mdx | 6 +- .../parse-step-name.mdx | 8 +- .../parse-workflow-name.mdx | 8 +- .../workflow-runtime/create-world.mdx | 12 +- .../workflow-runtime/get-world-handlers.mdx | 10 +- .../workflow-runtime/get-world.mdx | 8 +- .../workflow-runtime/health-check.mdx | 2 +- .../api-reference/workflow-runtime/index.mdx | 6 +- .../workflow-runtime/set-world.mdx | 12 +- .../workflow-runtime/workflow-entrypoint.mdx | 10 +- .../workflow-runtime/world/analytics.mdx | 18 +- .../workflow-runtime/world/index.mdx | 8 +- .../workflow-runtime/world/queue.mdx | 22 +- .../workflow-runtime/world/storage.mdx | 62 +-- .../workflow-runtime/world/streams.mdx | 14 +- .../v5/api-reference/workflow-serde/index.mdx | 2 +- .../workflow-serde/workflow-deserialize.mdx | 4 +- .../workflow-serde/workflow-serialize.mdx | 14 +- .../workflow-sveltekit/workflow-plugin.mdx | 6 +- .../api-reference/workflow-vite/workflow.mdx | 10 +- .../v5/api-reference/workflow/create-hook.mdx | 30 +- .../api-reference/workflow/create-webhook.mdx | 30 +- .../v5/api-reference/workflow/define-hook.mdx | 20 +- .../v5/api-reference/workflow/fatal-error.mdx | 4 +- .../docs/v5/api-reference/workflow/fetch.mdx | 14 +- .../workflow/get-step-metadata.mdx | 4 +- .../workflow/get-workflow-metadata.mdx | 6 +- .../api-reference/workflow/get-writable.mdx | 14 +- .../docs/v5/api-reference/workflow/index.mdx | 2 +- .../workflow/retryable-error.mdx | 2 +- .../api-reference/workflow/set-attributes.mdx | 4 +- .../docs/v5/api-reference/workflow/sleep.mdx | 6 +- .../docs/v5/changelog/attributes-mvp.mdx | 78 ++-- .../v5/changelog/batched-event-writes.mdx | 24 +- .../docs/v5/changelog/eager-processing.mdx | 126 +++--- docs/content/docs/v5/changelog/index.mdx | 6 +- .../docs/v5/changelog/lazy-event-creation.md | 54 +-- .../docs/v5/changelog/resilient-resume.mdx | 10 +- .../docs/v5/changelog/resilient-start.mdx | 28 +- .../v5/changelog/step-message-ownership.mdx | 90 ++-- docs/content/docs/v5/changelog/turbo-mode.md | 40 +- docs/content/docs/v5/comparisons/index.mdx | 26 +- .../workflow-sdk-vs-aws-agentcore.mdx | 30 +- .../workflow-sdk-vs-aws-step-functions.mdx | 18 +- .../workflow-sdk-vs-cloudflare-workflows.mdx | 20 +- .../comparisons/workflow-sdk-vs-inngest.mdx | 36 +- .../comparisons/workflow-sdk-vs-temporal.mdx | 44 +- .../workflow-sdk-vs-trigger-dev.mdx | 34 +- .../configuration/build-and-diagnostics.mdx | 10 +- .../docs/v5/configuration/cli-and-web-ui.mdx | 8 +- .../docs/v5/configuration/runtime-tuning.mdx | 60 +-- docs/content/docs/v5/configuration/worlds.mdx | 24 +- .../v5/cookbook/advanced/child-workflows.mdx | 50 +-- .../advanced/publishing-libraries.mdx | 80 ++-- .../cookbook/advanced/serializable-steps.mdx | 42 +- .../cookbook/advanced/upgrading-workflows.mdx | 62 +-- .../agent-patterns/agent-cancellation.mdx | 40 +- .../agent-patterns/human-in-the-loop.mdx | 44 +- .../v5/cookbook/common-patterns/batching.mdx | 28 +- .../cookbook/common-patterns/idempotency.mdx | 18 +- .../common-patterns/rate-limiting.mdx | 6 +- .../docs/v5/cookbook/common-patterns/saga.mdx | 38 +- .../cookbook/common-patterns/scheduling.mdx | 46 +- .../sequential-and-parallel.mdx | 52 +-- .../v5/cookbook/common-patterns/timeouts.mdx | 46 +- .../v5/cookbook/common-patterns/webhooks.mdx | 12 +- .../common-patterns/workflow-composition.mdx | 38 +- docs/content/docs/v5/cookbook/index.mdx | 44 +- .../docs/v5/cookbook/integrations/ai-sdk.mdx | 84 ++-- .../v5/cookbook/integrations/chat-sdk.mdx | 68 +-- .../docs/v5/cookbook/integrations/sandbox.mdx | 92 ++-- docs/content/docs/v5/deploying.mdx | 30 +- .../abort-signal-timeout-in-workflow.mdx | 24 +- .../docs/v5/errors/corrupted-event-log.mdx | 22 +- .../docs/v5/errors/deployment-mismatch.mdx | 28 +- .../docs/v5/errors/fetch-in-workflow.mdx | 16 +- docs/content/docs/v5/errors/hook-conflict.mdx | 22 +- docs/content/docs/v5/errors/index.mdx | 2 +- .../v5/errors/node-js-module-in-workflow.mdx | 10 +- .../docs/v5/errors/replay-divergence.mdx | 4 +- .../v5/errors/runtime-decryption-failed.mdx | 24 +- .../docs/v5/errors/serialization-failed.mdx | 34 +- .../start-invalid-workflow-function.mdx | 10 +- .../errors/step-executed-multiple-times.mdx | 4 +- .../docs/v5/errors/step-not-registered.mdx | 10 +- .../docs/v5/errors/timeout-in-workflow.mdx | 16 +- .../webhook-invalid-respond-with-value.mdx | 22 +- .../v5/errors/webhook-response-not-sent.mdx | 18 +- .../v5/errors/workflow-not-registered.mdx | 10 +- .../docs/v5/foundations/cancellation.mdx | 62 +-- .../v5/foundations/errors-and-retries.mdx | 24 +- docs/content/docs/v5/foundations/hooks.mdx | 70 +-- .../docs/v5/foundations/idempotency.mdx | 18 +- .../docs/v5/foundations/serialization.mdx | 43 +- .../v5/foundations/starting-workflows.mdx | 74 ++-- .../content/docs/v5/foundations/streaming.mdx | 85 ++-- .../docs/v5/foundations/versioning.mdx | 6 +- .../v5/foundations/workflows-and-steps.mdx | 16 +- .../content/docs/v5/getting-started/astro.mdx | 32 +- .../docs/v5/getting-started/express.mdx | 16 +- .../docs/v5/getting-started/fastify.mdx | 16 +- docs/content/docs/v5/getting-started/hono.mdx | 16 +- .../docs/v5/getting-started/nestjs.mdx | 33 +- docs/content/docs/v5/getting-started/next.mdx | 20 +- .../content/docs/v5/getting-started/nitro.mdx | 32 +- docs/content/docs/v5/getting-started/nuxt.mdx | 16 +- .../docs/v5/getting-started/python.mdx | 6 +- .../docs/v5/getting-started/sveltekit.mdx | 28 +- .../v5/getting-started/tanstack-start.mdx | 24 +- docs/content/docs/v5/getting-started/vite.mdx | 16 +- .../docs/v5/how-it-works/cancellation.mdx | 124 +++--- .../docs/v5/how-it-works/code-transform.mdx | 60 +-- .../docs/v5/how-it-works/encryption.mdx | 44 +- .../docs/v5/how-it-works/event-sourcing.mdx | 84 ++-- .../how-it-works/framework-integrations.mdx | 18 +- .../how-it-works/understanding-directives.mdx | 42 +- docs/content/docs/v5/internal/index.mdx | 12 +- .../docs/v5/internal/nitro-native-build.mdx | 4 +- .../content/docs/v5/internal/nitro-web-ui.mdx | 8 +- .../serializable-abort-controller.mdx | 14 +- .../docs/v5/observability/attributes.mdx | 6 +- docs/content/docs/v5/observability/index.mdx | 22 +- .../content/docs/v5/observability/tracing.mdx | 18 +- docs/content/docs/v5/testing/index.mdx | 66 +-- docs/content/docs/v5/testing/server-based.mdx | 18 +- docs/content/docs/v5/whats-new.mdx | 16 +- docs/content/worlds/v4/building-a-world.mdx | 91 ++-- docs/content/worlds/v4/local.mdx | 2 +- docs/content/worlds/v4/postgres.mdx | 38 +- docs/content/worlds/v4/vercel.mdx | 34 +- docs/content/worlds/v5/building-a-world.mdx | 125 +++--- docs/content/worlds/v5/local.mdx | 28 +- docs/content/worlds/v5/postgres.mdx | 38 +- docs/content/worlds/v5/upgrading-to-v5.mdx | 16 +- docs/content/worlds/v5/vercel.mdx | 56 +-- packages/ai/README.md | 10 +- packages/ai/src/agent/do-stream-step.ts | 6 +- packages/ai/src/agent/durable-agent.ts | 8 +- packages/ai/src/agent/telemetry.ts | 6 +- packages/ai/src/agent/types.ts | 2 +- .../ai/src/normalize-ui-message-stream.ts | 2 +- packages/ai/src/providers/mock.ts | 4 +- packages/ai/src/stream-iterator.ts | 2 +- packages/ai/src/workflow-chat-transport.ts | 8 +- packages/builders/README.md | 2 +- packages/builders/src/base-builder.ts | 20 +- packages/builders/src/constants.ts | 4 +- packages/builders/src/fast-discovery.ts | 2 +- packages/builders/src/module-specifier.ts | 2 +- .../src/node-module-esbuild-plugin.ts | 4 +- packages/builders/src/optional-otel-api.ts | 2 +- packages/builders/src/optional-typescript.ts | 2 +- packages/builders/src/optional-ws-native.ts | 2 +- packages/builders/src/swc-esbuild-plugin.ts | 10 +- packages/builders/src/types.ts | 8 +- .../builders/src/vercel-build-output-api.ts | 2 +- packages/cli/src/lib/bulk-cancel.ts | 6 +- .../cli/src/lib/config/workflow-config.ts | 2 +- packages/cli/src/lib/inspect/auth.ts | 2 +- packages/cli/src/lib/inspect/env.ts | 6 +- packages/cli/src/lib/inspect/hydration.ts | 10 +- packages/cli/src/lib/inspect/output.ts | 24 +- packages/cli/src/lib/inspect/run.ts | 2 +- packages/cli/src/lib/inspect/vercel-link.ts | 4 +- packages/cli/src/lib/inspect/web.ts | 7 +- packages/core/scripts/README.md | 53 +-- packages/core/src/capabilities.ts | 10 +- packages/core/src/capture-stack.ts | 2 +- packages/core/src/class-serialization.ts | 16 +- packages/core/src/classify-error.ts | 8 +- packages/core/src/context-violation-error.ts | 10 +- packages/core/src/create-hook.ts | 4 +- packages/core/src/define-hook.ts | 2 +- packages/core/src/describe-error.ts | 18 +- packages/core/src/encryption.ts | 14 +- packages/core/src/events-consumer.ts | 30 +- packages/core/src/flushable-stream.ts | 22 +- packages/core/src/global.ts | 2 +- packages/core/src/index.ts | 2 +- packages/core/src/log-format.ts | 8 +- packages/core/src/logger.ts | 6 +- packages/core/src/private.ts | 104 ++--- packages/core/src/replay-payload-cache.ts | 6 +- packages/core/src/runtime.ts | 401 +++++++++--------- packages/core/src/runtime/compute-instance.ts | 7 +- packages/core/src/runtime/constants.ts | 58 +-- .../src/runtime/count-step-started-events.ts | 14 +- packages/core/src/runtime/deployment-guard.ts | 20 +- packages/core/src/runtime/get-port-lazy.ts | 8 +- packages/core/src/runtime/get-world-lazy.ts | 4 +- packages/core/src/runtime/helpers.ts | 60 +-- .../core/src/runtime/quickjs-entrypoint.ts | 174 ++++---- packages/core/src/runtime/quickjs-runtime.ts | 140 +++--- packages/core/src/runtime/quickjs-serde.ts | 42 +- packages/core/src/runtime/replay-budget.ts | 8 +- .../src/runtime/replay-recovery-reporter.ts | 2 +- packages/core/src/runtime/resume-hook.ts | 56 +-- packages/core/src/runtime/resume-latency.ts | 52 +-- packages/core/src/runtime/run-id-time.ts | 8 +- packages/core/src/runtime/run.ts | 28 +- packages/core/src/runtime/runs.ts | 8 +- packages/core/src/runtime/start.ts | 41 +- packages/core/src/runtime/step-executor.ts | 102 ++--- packages/core/src/runtime/step-latency.ts | 46 +- packages/core/src/runtime/step-ownership.ts | 14 +- .../core/src/runtime/step-single-flight.ts | 12 +- .../core/src/runtime/suspension-handler.ts | 178 ++++---- .../core/src/runtime/unserializable-step.ts | 8 +- packages/core/src/runtime/vm-mode.ts | 2 +- .../core/src/runtime/wait-continuation.ts | 12 +- packages/core/src/runtime/wait-until.ts | 2 +- .../core/src/runtime/world-compatibility.ts | 2 +- packages/core/src/runtime/world-init.ts | 10 +- packages/core/src/runtime/world.ts | 4 +- packages/core/src/sealed-box.ts | 30 +- packages/core/src/serialization-format.ts | 36 +- packages/core/src/serialization.ts | 198 ++++----- packages/core/src/serialization/client.ts | 2 +- .../src/serialization/codec-devalue-vm.ts | 6 +- .../core/src/serialization/codec-devalue.ts | 2 +- packages/core/src/serialization/codec.ts | 8 +- .../core/src/serialization/compression.ts | 33 +- packages/core/src/serialization/encryption.ts | 22 +- packages/core/src/serialization/errors.ts | 2 +- packages/core/src/serialization/format.ts | 12 +- packages/core/src/serialization/hardened.ts | 60 +-- packages/core/src/serialization/index.ts | 2 +- .../src/serialization/reducers/class-vm.ts | 2 +- .../core/src/serialization/reducers/class.ts | 2 +- .../src/serialization/reducers/common-vm.ts | 22 +- .../core/src/serialization/reducers/common.ts | 24 +- .../reducers/step-function-vm.ts | 2 +- .../serialization/reducers/step-function.ts | 2 +- packages/core/src/serialization/step.ts | 2 +- packages/core/src/serialization/types.ts | 10 +- .../core/src/serialization/workflow-vm.ts | 2 +- packages/core/src/set-attributes.ts | 2 +- packages/core/src/source-map.ts | 6 +- packages/core/src/step.ts | 22 +- packages/core/src/step/context-storage.ts | 10 +- packages/core/src/step/writable-stream.ts | 12 +- packages/core/src/symbols.ts | 4 +- packages/core/src/telemetry.ts | 8 +- .../src/telemetry/semantic-conventions.ts | 30 +- packages/core/src/vm/index.ts | 6 +- packages/core/src/vm/script-cache.ts | 12 +- packages/core/src/vm/uint8array-base64.ts | 2 +- packages/core/src/workflow.ts | 20 +- .../core/src/workflow/abort-controller.ts | 22 +- packages/core/src/workflow/create-hook.ts | 2 +- .../src/workflow/get-workflow-metadata.ts | 2 +- packages/core/src/workflow/hook.ts | 30 +- packages/core/src/workflow/set-attributes.ts | 2 +- packages/core/src/workflow/sleep.ts | 2 +- packages/core/src/workflow/world-init-stub.ts | 2 +- packages/docs-typecheck/src/docs-globals.d.ts | 2 +- packages/docs-typecheck/src/type-checker.ts | 2 +- packages/errors/src/ansi.ts | 10 +- packages/errors/src/index.ts | 50 +-- packages/errors/src/internal-chalk.ts | 4 +- packages/nest/README.md | 50 +-- packages/nest/src/cjs-rewrite.ts | 6 +- packages/nest/src/vercel-builder.ts | 12 +- packages/nest/src/workflow.module.ts | 4 +- packages/next/src/builder-eager.ts | 2 +- packages/next/src/watch-ignore.ts | 4 +- packages/next/src/watch-rebuild.ts | 14 +- packages/nitro/src/builders.ts | 10 +- packages/nitro/src/index.ts | 20 +- packages/nitro/src/types.ts | 10 +- packages/rollup/src/index.ts | 4 +- packages/sveltekit/src/plugin.ts | 2 +- packages/swc-plugin-workflow/spec.md | 122 +++--- packages/utils/src/parse-name.ts | 8 +- packages/utils/src/world-target.ts | 2 +- packages/vite/src/hot-update.ts | 2 +- packages/web-shared/README.md | 8 +- .../src/components/event-list-view.tsx | 22 +- .../components/sidebar/attribute-panel.tsx | 14 +- .../sidebar/entity-detail-panel.tsx | 2 +- .../src/components/sidebar/events-list.tsx | 4 +- .../components/sidebar/resolve-hook-modal.tsx | 4 +- .../src/components/stream-viewer.tsx | 2 +- .../components/detail-panel-width.ts | 8 +- .../trace-viewer/components/detail-panel.tsx | 2 +- .../components/draggable-border.tsx | 2 +- .../trace-viewer/components/minimap.tsx | 6 +- .../trace-viewer/components/span-markers.tsx | 8 +- .../trace-viewer/components/timeline.tsx | 8 +- .../trace-viewer/components/use-row-window.ts | 2 +- .../components/trace-viewer/trace-viewer.tsx | 4 +- .../src/components/trace-viewer/utils.ts | 16 +- .../src/components/ui/data-inspector.tsx | 2 +- .../components/ui/duplicate-event-tooltip.tsx | 7 +- .../src/components/ui/error-stack-block.tsx | 6 +- .../src/components/ui/timestamp-tooltip.tsx | 4 +- .../trace-span-construction.ts | 4 +- .../web-shared/src/lib/duplicate-events.ts | 2 +- packages/web-shared/src/lib/hydration.ts | 19 +- packages/web-shared/src/lib/sealed-events.ts | 4 +- packages/web-shared/src/lib/utils.ts | 2 +- .../src/lib/zstd-browser-decoder.ts | 2 +- packages/workflow/README.md | 10 +- packages/workflow/src/internal/builtins.ts | 10 +- packages/world-local/src/config.ts | 18 +- packages/world-local/src/fs.ts | 30 +- packages/world-local/src/index.ts | 6 +- packages/world-local/src/init.ts | 2 +- packages/world-local/src/queue.ts | 16 +- .../world-local/src/storage/events-storage.ts | 151 +++---- packages/world-local/src/storage/helpers.ts | 30 +- .../world-local/src/storage/hook-index.ts | 8 +- .../world-local/src/storage/hooks-storage.ts | 6 +- packages/world-local/src/storage/legacy.ts | 4 +- .../src/storage/run-status-signal.ts | 8 +- .../world-local/src/storage/runs-storage.ts | 8 +- packages/world-local/src/streamer.ts | 13 +- packages/world-local/src/telemetry.ts | 2 +- packages/world-postgres/README.md | 56 +-- packages/world-postgres/src/drizzle/schema.ts | 20 +- packages/world-postgres/src/queue.ts | 9 +- packages/world-postgres/src/run-status.ts | 18 +- packages/world-postgres/src/storage.ts | 62 +-- packages/world-sim/DESIGN.md | 171 ++++---- packages/world-sim/README.md | 110 ++--- packages/world-sim/src/build.ts | 8 +- packages/world-sim/src/clock.ts | 6 +- packages/world-sim/src/drive.ts | 4 +- packages/world-sim/src/ids.ts | 8 +- packages/world-sim/src/index.ts | 6 +- packages/world-sim/src/invariants.ts | 6 +- packages/world-sim/src/load.ts | 8 +- packages/world-sim/src/queue.ts | 6 +- packages/world-sim/src/replay.ts | 19 +- packages/world-sim/src/report.ts | 40 +- packages/world-sim/src/scenario.ts | 41 +- packages/world-sim/src/store.ts | 40 +- packages/world-sim/src/streams.ts | 2 +- packages/world-sim/src/tempo.ts | 14 +- packages/world-sim/src/types.ts | 62 +-- packages/world-sim/src/world.ts | 26 +- packages/world-sim/src/writers.ts | 22 +- packages/world-vercel/README.md | 2 +- packages/world-vercel/src/analytics.ts | 2 +- packages/world-vercel/src/create-run-id.ts | 22 +- packages/world-vercel/src/encryption.ts | 11 +- packages/world-vercel/src/event-retry.ts | 67 +-- packages/world-vercel/src/events-v4.ts | 113 ++--- packages/world-vercel/src/events.ts | 50 +-- packages/world-vercel/src/frames.ts | 8 +- packages/world-vercel/src/http-client.ts | 85 ++-- packages/world-vercel/src/http-core.ts | 41 +- packages/world-vercel/src/index.ts | 4 +- packages/world-vercel/src/instrumentObject.ts | 6 +- packages/world-vercel/src/queue.ts | 38 +- packages/world-vercel/src/run-id/codec.ts | 6 +- packages/world-vercel/src/run-id/index.ts | 10 +- packages/world-vercel/src/run-id/regions.ts | 10 +- packages/world-vercel/src/runs.ts | 24 +- packages/world-vercel/src/steps.ts | 4 +- packages/world-vercel/src/streamer.ts | 19 +- packages/world-vercel/src/telemetry.ts | 18 +- packages/world-vercel/src/utils.ts | 32 +- .../world-vercel/src/ws-transport-enabled.ts | 6 +- packages/world-vercel/src/ws-transport.ts | 83 ++-- packages/world/src/analytics.ts | 8 +- packages/world/src/attributes.ts | 8 +- packages/world/src/env-config.ts | 10 +- packages/world/src/events.ts | 66 +-- packages/world/src/hooks.ts | 16 +- packages/world/src/interfaces.ts | 72 ++-- packages/world/src/node-http.ts | 2 +- packages/world/src/queue.ts | 54 +-- packages/world/src/runs.ts | 18 +- packages/world/src/slot-identity.ts | 8 +- packages/world/src/spec-version.ts | 12 +- packages/world/src/steps.ts | 2 +- packages/world/src/ulid.ts | 4 +- skills/internal-dev-workbench/SKILL.md | 34 +- skills/migrating-to-workflow-sdk/SKILL.md | 12 +- .../references/aws-step-functions.md | 2 +- .../references/inngest.md | 2 +- .../references/resume-routing.md | 2 + .../references/retries.md | 14 +- .../references/temporal.md | 4 +- .../references/trigger-dev.md | 4 +- skills/workflow-init/SKILL.md | 4 +- skills/workflow/SKILL.md | 116 ++--- tarballs/README.md | 2 +- workbench/python/README.md | 84 ++-- workbench/sim-world/README.md | 77 ++-- workbench/vitest/MOCKING.md | 16 +- workbench/vitest/README.md | 14 +- 599 files changed, 7074 insertions(+), 7065 deletions(-) create mode 100644 .changeset/technical-writing-audit.md diff --git a/.changeset/technical-writing-audit.md b/.changeset/technical-writing-audit.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/technical-writing-audit.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/agents/docs-writer.md b/.claude/agents/docs-writer.md index ebd84201ba..d22f603a6c 100644 --- a/.claude/agents/docs-writer.md +++ b/.claude/agents/docs-writer.md @@ -31,10 +31,10 @@ You are an expert technical writer specializing in developer documentation for t - Highlight only the most relevant code to the concept being taught - In examples showing workflows calling steps, put workflow code before step code - Use proper type annotations to encourage best practices (e.g., `getWritable()`) - - Remove type annotations when not needed (e.g., when just calling `.close()`) + - Remove type annotations when not needed (e.g., when calling `.close()`) 6. **Example-Driven Teaching**: Support explanations with working code examples that: - - Start simple and build incrementally + - Start with the minimum required code and build incrementally - Show real-world use cases - Include terse, focused comments that add value - Use meaningful variable names that self-document intent @@ -74,7 +74,7 @@ You are an expert technical writer specializing in developer documentation for t - Use pipe syntax with double quotes for edge labels: `A -->|"label"| B` - Highlight terminal states or key components with purple: `style NodeId fill:#a78bfa,stroke:#8b5cf6,color:#000` - Place all `style` declarations at the end of the diagram - - Keep diagrams simple and readable - split into multiple diagrams if needed + - Keep diagrams focused and readable - split them into multiple diagrams if needed - Add a legend or callout explaining highlighted nodes when appropriate **When Creating New Documentation:** @@ -98,10 +98,10 @@ You are an expert technical writer specializing in developer documentation for t - Reference real implementation code when showing how features work internally **Quality Checklist Before Finalizing:** -- Can a developer understand and use this feature after reading just the first example? +- Can a developer understand and use this feature after reading the first example? - Is every technical term defined or linked to its definition? - Are code examples syntactically correct and following project conventions? -- Does the explanation flow logically from simple to complex? +- Does the explanation flow logically from basic to complex? - Have you eliminated all emojis and em-dashes? - Is the writing concise without sacrificing clarity? - Does the tone match canonical documentation like the directives guide? diff --git a/AGENTS.md b/AGENTS.md index 1a1d564191..dd328b66cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Agent Instructions +# Agent instructions **CRITICAL RULES:** - NEVER push directly to the `main` or `stable` branches @@ -12,7 +12,7 @@ This repository contains the client-side SDK code for workflows, along with exam ## Architecture -### Core Components +### Core components - **packages/core**: Core workflow runtime and primitives (`@workflow/core`) - **packages/next**: Next.js integration (`@workflow/next`) @@ -24,7 +24,7 @@ This repository contains the client-side SDK code for workflows, along with exam - **workbench/example**: Basic workflow examples using the CLI (aka "standalone mode") - **workbench/nextjs-turbopack**: Workflow examples using the Next.js integration -### Workflow Execution Model +### Workflow execution model Workflows consist of two types of functions: @@ -33,9 +33,9 @@ Workflows consist of two types of functions: The framework uses compiler transformations to split workflow files into separate bundles for client, workflow, and step execution contexts. -## Development Commands +## Development commands -### Workspace-level Commands +### Workspace-level commands ```bash # Build all packages @@ -60,7 +60,7 @@ pnpm typecheck pnpm clean ``` -### Core Package Testing +### Core package testing ```bash # Test core functionality @@ -127,18 +127,18 @@ VERCEL_OIDC_TOKEN="$(grep VERCEL_OIDC_TOKEN workbench/nextjs-turbopack/.env.loca pnpm run test:e2e ``` -### Event Log Race Repro +### Event log race repro `packages/core/e2e/event-log-race-repro.test.ts` is a dedicated harness for `CORRUPTED_EVENT_LOG`. It drives three scenarios against one deployment: `step-storm` and `hook-storm` (concurrent replays of a single run racing the -per-branch watchdog — `hook-storm` is the production shape), plus a `hook-sleep` +per-branch watchdog; `hook-storm` is the production shape), plus a `hook-sleep` control that provides the calibration baseline. Any outcome other than `completed` fails the run, except `infra`, which means the harness could not reach the deployment. -Run it locally against a locally started workbench app — no Vercel deployment, -no credentials: +Run it against a locally started workbench app. No Vercel deployment or +credentials are required: ```bash pnpm run test:e2e:event-log-race-repro:local # world-postgres @@ -156,14 +156,14 @@ the container flags (`--skip-db-setup`, `--no-docker`, `--teardown`) do nothing under `--world local`, whose only state is a data directory the script clears before each run. -Both worlds are worth running, and neither subsumes the other: world-postgres +Run both worlds because neither subsumes the other: world-postgres arbitrates event slots inside one SQL statement, while world-local arbitrates them with an exclusive `link(2)` against a directory that two processes (the app and the harness) both write to. A slot race a transaction closes is not automatically closed by a filesystem. Scale is controlled entirely by `EVENT_LOG_RACE_REPRO_*` environment variables. -Their defaults live only in `event-log-race-repro.test.ts` — neither the CI +Their defaults live only in `event-log-race-repro.test.ts`; neither the CI workflow nor the local script defines a second copy. The default scale (14 runs) is a per-PR regression check, not a rate measurement; a clean run means "the storms did not trip it", not "the rate is below X". To soak for a *rate*, use the @@ -183,46 +183,47 @@ preview does: on `main`, three 14-run passes failed 8 of their 18 `step-storm` attempts with `CORRUPTED_EVENT_LOG` while `hook-storm` and `hook-sleep` stayed clean, so the script exits non-zero. That is the harness working, not a broken setup, and it is why the local runner is the fast signal while a fix is in -flight — at 14 runs a green CI job means "the storms did not trip it", nowhere +flight. At 14 runs, a green CI job means "the storms did not trip it", nowhere near "the rate is below X". That is a *laptop* result, and the distinction matters: `CORRUPTED_EVENT_LOG` means the run finished the race, while `stuck` usually means it never got to run one. Two dispatches of the local lanes against unmodified `main` on GitHub's 4-core runners scored, at the default scale, world-postgres 5-6 of 6 `step-storm` -runs `stuck` at `runTimeoutMs` in both, and world-local 6 and 12 of 14 `stuck` — -the *same* lane, the same commit, "6/14" and "12/14" one dispatch apart. Read a +runs `stuck` at `runTimeoutMs` in both, and world-local 6 and 12 of 14 `stuck`. +The *same* lane, the same commit, "6/14" and "12/14" one dispatch apart. Read a single local-lane number as a verdict on a PR and it will mislead you; read `pressure.resumesSent` and `progress.events` in the results JSON instead, which say whether the run was racing or starving. Two properties of the harness made that starvation self-sustaining, and both are -now bounded — if you change either, know what you are giving up: +now bounded. If you change either, know what you are giving up: * **The poke pump is capped** (`EVENT_LOG_RACE_REPRO_POKE_MAX`, default 64). `step-storm`'s pressure is a wall-clock cadence, so a slow run collected *more* out-of-band writes per unit of progress than a fast one, and each one appends a `hook_received` that every later replay re-reads. Unbounded on a 4-core runner - that reached ~270 pokes per run and no run ever finished; a healthy 6-round run - sends 35-41, so the cap clips runaways only. -* **Runs abandoned at `runTimeoutMs` are cancelled.** They used to keep replaying + that reached approximately 270 pokes per run and no run ever finished; a healthy + 6-round run sends 35-41, so the cap clips runaways only. +* **Runs abandoned at `runTimeoutMs` are canceled.** They used to keep replaying in the same app process for the rest of the job. That is how world-local's - `hook-storm` came to report six `stuck` runs with `resumesSent: 0` — every one + `hook-storm` came to report six `stuck` runs with `resumesSent: 0`. Every one of them starved behind the previous scenario's six abandoned `step-storm` runs and never created a hook for the driver to resume, so the scenario measured nothing about hooks at all. -One thing about a local run is unlike CI and is worth knowing before you read a -result: in CI each replay gets its own Fluid invocation, while here every replay +One difference affects how you read a local result: in CI each replay gets its +own Fluid invocation, while here every replay of every run shares one Next.js process. world-postgres gives that process (and -the harness process) 50 embedded Graphile Worker slots each, and ~100 replays in -one heap saturates GC — measured on a 12-core laptop, all 14 attempts came back +the harness process) 50 embedded Graphile Worker slots each, and approximately +100 replays in one heap saturates GC. Measured on a 12-core laptop, all 14 +attempts came back `stuck` with the server at 6.4 GB RSS and Postgres idle. The script therefore sets `WORKFLOW_POSTGRES_WORKER_CONCURRENCY=10` (override by exporting it) and raises the app's old-space limit (`--heap-mb`). If a local run reports `stuck` rather than `CORRUPTED_EVENT_LOG`, suspect the machine before the SDK. world-local saturates the same single process from its own in-process queue, -which defaults to 1000 deliveries in flight, so the script holds it at the same +which defaults to 1,000 deliveries in flight, so the script holds it at the same number via `WORKFLOW_LOCAL_QUEUE_CONCURRENCY`. world-local's storms come out clean far more often than world-postgres's, so the @@ -230,15 +231,15 @@ default scale says even less there: the corruption it does produce needs a `hook_received` to be staged and then rejected, which the harness reaches only in a run's terminal moments. Reach for a unit test in `packages/world-local/src/storage/` when a suspected filesystem race can be -staged directly — it costs milliseconds and does not depend on the interleaving +staged directly. It costs milliseconds and does not depend on the interleaving showing up. In CI the same harness runs from `.github/workflows/event-log-race-repro.yml`, -triggered by adding the `event-log-race-repro` label to a PR (or by -`workflow_dispatch`, whose inputs are the soak dial — raise `timeout-minutes` in -that dispatch's branch if you raise `budget_ms`). Alongside the Vercel lane, the +triggered by adding the `event-log-race-repro` label to a PR or by +`workflow_dispatch`, whose inputs are the soak dial. Raise `timeout-minutes` in +that dispatch's branch if you raise `budget_ms`. Alongside the Vercel lane, the workflow runs the local script against world-local and world-postgres as -parallel lanes. Those two lanes are report-only — the local storms have red +parallel lanes. Those two lanes are report-only because the local storms have red baselines at the default scale (see above), so they publish numbers rather than a verdict and fail only when the harness produced no result file at all; the Vercel lane remains the gate. @@ -262,7 +263,7 @@ WORKFLOW_LOCAL_DATA_DIR=workbench/nextjs-turbopack/.next/workflow-data \ pnpm wf inspect ``` -### Example App Development +### Example app development ```bash # Build workflow bundles for example app @@ -273,7 +274,7 @@ cd workbench/example && pnpm workflow [command] cd workbench/example && pnpm wf [command] # shorthand ``` -### Next.js App Development +### Next.js app development ```bash # Start Next.js dev server with workflow support @@ -286,7 +287,7 @@ cd workbench/nextjs-turbopack && pnpm build cd workbench/nextjs-turbopack && pnpm start ``` -## Key Workflow Concepts +## Key workflow concepts **These are only relevant when writing code using the Workflow SDK** @@ -296,7 +297,7 @@ cd workbench/nextjs-turbopack && pnpm start - Built-in retry semantics for step functions with `FatalError`/`RetryableError` controls - Standard JavaScript async patterns work: `Promise.all()`, `Promise.race()`, etc. -## File Structure Conventions +## File structure conventions **These are only relevant when writing code using the Workflow SDK** @@ -305,22 +306,22 @@ cd workbench/nextjs-turbopack && pnpm start - Workflow files must contain `"use workflow"` or `"use step"` directives to be processed - Add `.swc` directory to `.gitignore` for SWC plugin cache artifacts -## Package Manager +## Package manager This project uses pnpm with workspace configuration. The required version is specified in `package.json#packageManager`. -## Code Style +## Code style - Uses Biome for formatting and linting - 2-space indentation, single quotes, trailing commas (ES5) - Import type enforcement enabled - Explicit `any` is discouraged (Biome's `noExplicitAny` rule is currently disabled); exhaustive dependencies warnings enabled -## Local Checks vs. CI +## Local checks vs. CI -Linting, formatting, and typechecking (`pnpm lint`, `pnpm format`, `pnpm typecheck`) are all facets of the same static-quality gate, and CI runs them on every PR. Treat them as **advisory** while working locally: run them and fix obvious issues when it's convenient, but a failure in any of them should **not** block you from committing, pushing, or opening a PR. CI is the source of truth and will report anything that matters — don't get stuck iterating locally just to make these pass before handing off. +Linting, formatting, and typechecking (`pnpm lint`, `pnpm format`, `pnpm typecheck`) are all facets of the same static-quality gate, and CI runs them on every PR. Treat them as **advisory** while working locally: run them and fix obvious issues when it's convenient, but a failure in any of them should **not** block you from committing, pushing, or opening a PR. CI is the source of truth and will report anything that matters. Don't get stuck iterating locally to make these pass before handing off. -## Documentation Standards +## Documentation standards - README.md files in each package must accurately reflect the current functionality and purpose of that package - READMEs should not contain outdated or incorrect information about package capabilities @@ -328,34 +329,34 @@ Linting, formatting, and typechecking (`pnpm lint`, `pnpm format`, `pnpm typeche - Document every user-configurable environment variable in the docs. - When modifying skill files in `skills/`, always bump the `version` field in the frontmatter metadata -### Docs Preview Links in PR Descriptions +### Docs preview links in PR descriptions When a PR adds or updates docs pages (anything under `docs/content/`), add a "Docs Preview" section to the PR description with direct links to each changed page on the `workflow-docs` preview deployment: -- Get the preview base URL from the `vercel[bot]` comment on the PR — use the Preview link from the `workflow-docs` project row (e.g. `https://workflow-docs-git-.vercel.sh`). Don't construct the URL by hand; Vercel's branch-slug normalization is not a simple substitution. +- Get the preview base URL from the `vercel[bot]` comment on the PR. Use the Preview link from the `workflow-docs` project row (e.g. `https://workflow-docs-git-.vercel.sh`). Don't construct the URL by hand because Vercel's branch-slug normalization is not a direct substitution. - Map content paths to routes: `docs/content/docs/v5/.mdx` is served at `/docs/` (v5 is the default/latest version) and `docs/content/docs/v4/.mdx` at `/v4/docs/` (v4 is the maintenance version). - When a change is scoped to a specific section of a page, link to its heading anchor (e.g. `/docs/foundations/hooks#checking-for-token-conflicts`) and verify the anchor matches a real heading in the MDX. -- A simple table with one row per page (and one column per docs version, when both v4 and v5 were updated) works well. -- The preview deployment sits behind deployment protection, so the links require Vercel team access — that's expected; include them anyway for reviewers. +- A table with one row per page (and one column per docs version, when both v4 and v5 were updated) works well. +- The preview deployment sits behind deployment protection, so the links require Vercel team access. This is expected; include them anyway for reviewers. -## SWC Plugin +## SWC plugin When modifying the SWC compiler plugin (`packages/swc-plugin-workflow`), you must also update the specification document at `packages/swc-plugin-workflow/spec.md` to reflect any changes to the transformation behavior. -## Versioning & Release Strategy +## Versioning & release strategy This repository uses a dual-branch release model with [changesets](https://github.com/changesets/changesets) for version management. -### Branch Model +### Branch model -- **`main`** — Bleeding-edge / beta channel. Changesets are in pre-release mode (`beta` tag). Published packages get the `beta` npm dist-tag (e.g. `5.0.0-beta.3`). -- **`stable`** — GA / production channel. Changesets are in regular mode. Published packages get the `latest` npm dist-tag (e.g. `4.2.1`). +- **`main`**: Bleeding-edge / beta channel. Changesets are in pre-release mode (`beta` tag). Published packages get the `beta` npm dist-tag (e.g. `5.0.0-beta.3`). +- **`stable`**: GA / production channel. Changesets are in regular mode. Published packages get the `latest` npm dist-tag (e.g. `4.2.1`). Both branches trigger the release workflow (`.github/workflows/release.yml`) on push. The changesets action creates a "Version Packages" PR on each branch when there are pending changesets. **Important:** Some directories are not fully maintained on the `stable` branch: -- **`docs/`**: Only `docs/content/` is actively maintained on `stable` — the rest of the docs app is a minimal placeholder (documentation is deployed only from `main`). `docs/content/` is kept on `stable` because the markdown files are bundled into npm packages via `prepack` scripts. +- **`docs/`**: Only `docs/content/` is actively maintained on `stable`; the rest of the docs app is a minimal placeholder (documentation is deployed only from `main`). `docs/content/` is kept on `stable` because the markdown files are bundled into npm packages via `prepack` scripts. - **`skills/`**: Not maintained on `stable` at all. Skill files are unrelated to npm packaging, so there is no reason to keep them in sync on the release branch. When backporting changes to `stable`, any conflicts involving docs app files (outside of `docs/content/`) or `skills/` files should be resolved by keeping the `stable` branch version (discarding the incoming change from `main`). Conflicts in `docs/content/` should be resolved normally. The backport GitHub Action handles this automatically. @@ -364,13 +365,13 @@ When backporting changes to `stable`, any conflicts involving docs app files (ou Every Vercel project rooted in this repo sets `git.deploymentEnabled` to `false` for `changeset-release/main` in its `vercel.json`. **When you add a new Vercel project, add that key to its `vercel.json` too.** -The changesets action force-pushes `changeset-release/main`, and it can point at exactly main's HEAD SHA. Vercel keeps one commit status per project per SHA, so a preview deployment of that branch overwrites the production deployment's status for the same commit — and `vercel/wait-for-deployment-action`, which reads the deployment ID out of that status, then hands a production e2e run a preview deployment ID, forking the run across environments. +The changesets action force-pushes `changeset-release/main`, and it can point at exactly main's HEAD SHA. Vercel keeps one commit status per project per SHA, so a preview deployment of that branch overwrites the production deployment's status for the same commit. `vercel/wait-for-deployment-action`, which reads the deployment ID out of that status, then hands a production e2e run a preview deployment ID, forking the run across environments. -Because those PRs have no deployment of their own, CI treats them specially: the Vercel e2e lanes in `tests.yml` run `vercel/wait-for-deployment-action` a second way — `environment: production` with `sha` pinned to the PR's base SHA — so they test main's production deployment and run as `production`. (The commit-status ID that action reads is unambiguous for main SHAs precisely because this repo no longer deploys `changeset-release/main`, the only branch that ever deployed a commit main also deployed.) The deployment-dependent jobs in `docs-checks.yml`, `tarballs-checks.yml`, and `benchmarks.yml` are skipped. Anything new that waits on a deployment needs the same treatment. +Because those PRs have no deployment of their own, CI treats them specially: the Vercel e2e lanes in `tests.yml` run `vercel/wait-for-deployment-action` a second way, with `environment: production` and `sha` pinned to the PR's base SHA. They therefore test main's production deployment and run as `production`. (The commit-status ID that action reads is unambiguous for main SHAs precisely because this repo no longer deploys `changeset-release/main`, the only branch that ever deployed a commit main also deployed.) The deployment-dependent jobs in `docs-checks.yml`, `tarballs-checks.yml`, and `benchmarks.yml` are skipped. Anything new that waits on a deployment needs the same treatment. ### Changesets -- `workflow` and `@workflow/core` use changesets' "fixed" versioning strategy — they always have the same version number +- `workflow` and `@workflow/core` use changesets' "fixed" versioning strategy, so they always have the same version number - Every PR requires a changeset to be included before it will be merged - To check if one is needed, run `pnpm changeset status --since=main >/dev/null 2>&1 && echo "no changeset needed" || echo "changeset needed"` - Create a changeset using `pnpm changeset add` @@ -379,13 +380,13 @@ Because those PRs have no deployment of their own, CI treats them specially: the - On `main` (pre-release mode), the bump type doesn't affect beta numbering (it always increments `beta.N`) but it **does matter** when changes are backported to `stable` - Remember to always build any packages that get changed before running downstream tests like e2e tests in the workbench - Remember that changes made to one workbench should propagate to all other workbenches. The workflows should typically only be written once inside the example workbench and symlinked into all the other workbenches -- When writing changesets (via `pnpm changeset add` from the repo root, as noted above), keep the description terse — one sentence, or two at most. Try to make changesets that are specific to each modified package so they are targeted. +- When writing changesets (via `pnpm changeset add` from the repo root, as noted above), keep the description terse: one sentence, or two at most. Try to make changesets that are specific to each modified package so they are targeted. ### Backporting to `stable` -Backports are handled by a GitHub Action (`.github/workflows/backport.yml`) that runs on every push to `main`. For each commit, AI analyzes the change and decides whether to recommend a backport. The action **always opens a PR** against `stable` for human review — it never pushes directly. The changeset file is included in the cherry-pick, so the correct semver bump type is preserved on `stable`. +Backports are handled by a GitHub Action (`.github/workflows/backport.yml`) that runs on every push to `main`. For each commit, AI analyzes the change and decides whether to recommend a backport. The action **always opens a PR** against `stable` for human review; it never pushes directly. The changeset file is included in the cherry-pick, so the correct semver bump type is preserved on `stable`. -**Decision criteria.** `stable` is a maintenance branch and takes **stability fixes only** — feature work stays on `main`, however small or cleanly it would cherry-pick. AI is instructed to recommend a backport only for: +**Decision criteria.** `stable` is a maintenance branch and takes **stability fixes only**. Feature work stays on `main`, however small or cleanly it would cherry-pick. AI is instructed to recommend a backport only for: - Bug fixes to functionality that already exists on `stable` - Correctness, data-loss, crash, hang, deadlock, and resource-leak fixes @@ -399,13 +400,13 @@ AI is told to recommend AGAINST backporting anything else: new features and feat When in doubt, AI is told to decline: a missed fix can be forced through later via `workflow_dispatch`, while unwanted change on `stable` costs its users the stability they stayed behind for. -**Manual override.** The workflow can be run manually from the GitHub Actions UI via `workflow_dispatch`, which accepts an optional `ref` input (a commit SHA on `main`; defaults to `main` HEAD) and an optional `model` input (the AI model used for AI-assisted decisions and conflict resolution, in `/` form — defaults to the workflow's current default). Manual dispatch always forces a backport (skipping AI analysis). Use this when AI declined a backport that you want to ship to `stable`. +**Manual override.** The workflow can be run manually from the GitHub Actions UI via `workflow_dispatch`, which accepts an optional `ref` input (a commit SHA on `main`; defaults to `main` HEAD) and an optional `model` input (the AI model used for AI-assisted decisions and conflict resolution, in `/` form; defaults to the workflow's current default). Manual dispatch always forces a backport (skipping AI analysis). Use this when AI declined a backport that you want to ship to `stable`. **No-backport notification.** When AI decides against a backport, it leaves a comment on the source PR (if one is associated with the commit) explaining its reasoning, with instructions for forcing a backport via `workflow_dispatch`. **Conflict handling.** If the cherry-pick fails due to conflicts, the action first auto-resolves conflicts in directories that are not maintained on `stable` (docs app files under `docs/` except `docs/content/`, and any files under `skills/`) by keeping the `stable` branch version. It also auto-resolves `pnpm-lock.yaml` conflicts by re-running `pnpm install`. Any remaining conflicts are resolved using [opencode](https://opencode.ai) (AI-powered conflict resolution); the resulting backport PR notes that conflicts were AI-resolved and must be reviewed carefully. If AI cannot resolve the conflicts, the action comments on the original PR with instructions for manual resolution. -### Pre-release Lifecycle +### Pre-release lifecycle The `main` branch uses changesets' [pre-release mode](https://github.com/changesets/changesets/blob/main/docs/prereleases.md) to publish beta versions. @@ -423,28 +424,28 @@ The `main` branch uses changesets' [pre-release mode](https://github.com/changes 2. Exit pre-release mode: `pnpm changeset pre exit` 3. The next "Version Packages" PR will publish the final stable version to npm -## Common Patterns +## Common patterns -### Build-time Version Injection +### Build-time version injection Use `genversion` to access package version at runtime. See `@workflow/core` and `@workflow/world-vercel` for examples: - Add `genversion` as devDependency - Update build script: `genversion --es6 src/version.ts && tsc` - Add `src/version.ts` to `.gitignore` and `turbo.json` outputs -### Turbo Caching for Generated Files +### Turbo caching for generated files When a build step generates files, add them to the package's `turbo.json` outputs array to ensure proper caching. -## Architecture Notes +## Architecture notes -### executionContext Field +### executionContext field The `executionContext` field on workflow runs is a flexible JSONB/CBOR object that can store arbitrary data without schema changes. It flows through all worlds (local, postgres, vercel). -### Observability Data Hydration +### Observability data hydration `packages/core/src/observability.ts` contains `hydrateResourceIO` which strips certain fields (like `executionContext`) before UI display. If you need to display data from stripped fields, extract it before the stripping occurs. ### Trace context propagation (world-vercel HTTP requests) -Every outgoing HTTP request from `@workflow/world-vercel` to workflow-server (or the queue) MUST explicitly inject W3C trace context so the server can parent its spans to the caller and traces stay correlated end to end. Call `injectTraceContextIntoHeaders(headers)` (from `packages/world-vercel/src/telemetry.ts`) on the outgoing headers, inside the client span when one exists — `makeRequest` in `utils.ts` is the reference implementation. It is a no-op when no OpenTelemetry SDK is registered. +Every outgoing HTTP request from `@workflow/world-vercel` to workflow-server (or the queue) MUST explicitly inject W3C trace context so the server can parent its spans to the caller and traces stay correlated end to end. Call `injectTraceContextIntoHeaders(headers)` (from `packages/world-vercel/src/telemetry.ts`) on the outgoing headers, inside the client span when one exists. `makeRequest` in `utils.ts` is the reference implementation. It is a no-op when no OpenTelemetry SDK is registered. -Do **not** rely on ambient OpenTelemetry auto-instrumentation to do this: world-vercel's request paths use custom undici dispatchers / `global fetch`, which auto-instrumentation does not reliably hook. When you add a new request path or API version (e.g. a future v5 events API), wire the injection in the same place you build the request headers. The v4 events path (`fetchV4` in `events-v4.ts`) regressed cross-service correlation precisely by routing around `makeRequest` and skipping this step — workflow-server spans stopped joining the flow-route invocation trace until the injection was added back. Cover new paths with a test in `trace-propagation.test.ts`. +Do **not** rely on ambient OpenTelemetry auto-instrumentation to do this: world-vercel's request paths use custom undici dispatchers / `global fetch`, which auto-instrumentation does not reliably hook. When you add a new request path or API version (e.g. a future v5 events API), wire the injection in the same place you build the request headers. The v4 events path (`fetchV4` in `events-v4.ts`) regressed cross-service correlation precisely by routing around `makeRequest` and skipping this step. Workflow-server spans stopped joining the flow-route invocation trace until the injection was added back. Cover new paths with a test in `trace-propagation.test.ts`. -The same rule covers a request path that is not an HTTP request. A non-`fetch` transport must still open the client span callers read a trace through: use `withHttpClientSpan` (`http-core.ts`), the envelope `instrumentedFetch` is built on, so the span carries the same name, kind and attributes rather than a hand-rolled parallel shape. The WS events transport is the worked example — `postEventFrameOverWs` synthesizes an `http POST` span per frame and tags it `workflow.events.transport: 'ws'`, and the handshake gets its own `workflow.events.ws.connect` span (`ws-transport-spans.test.ts`). Adding a transport that writes events without one silently deletes the per-event view of a run. +The same rule covers a request path that is not an HTTP request. A non-`fetch` transport must still open the client span callers read a trace through: use `withHttpClientSpan` (`http-core.ts`), the envelope `instrumentedFetch` is built on, so the span carries the same name, kind, and attributes rather than a hand-rolled parallel shape. The WS events transport is the worked example. `postEventFrameOverWs` synthesizes an `http POST` span per frame and tags it `workflow.events.transport: 'ws'`, and the handshake gets its own `workflow.events.ws.connect` span (`ws-transport-spans.test.ts`). Adding a transport that writes events without one silently deletes the per-event view of a run. diff --git a/docs/README.md b/docs/README.md index b36e15309e..2c504de5a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,3 @@ -# Workflow SDK Docs +# Workflow SDK docs Check out the docs [here](https://workflow-sdk.dev/) diff --git a/docs/content/docs/v4/ai/chat-session-modeling.mdx b/docs/content/docs/v4/ai/chat-session-modeling.mdx index 4a41e3b389..c4ef15dc05 100644 --- a/docs/content/docs/v4/ai/chat-session-modeling.mdx +++ b/docs/content/docs/v4/ai/chat-session-modeling.mdx @@ -22,7 +22,7 @@ Chat sessions in AI agents can be modeled at different layers of your architectu While there are many ways to model chat sessions, the two most common categories are single-turn and multi-turn. -## Single-Turn Workflows +## Single-turn workflows Each user message triggers a new workflow run. The client or API route owns the conversation history and sends the full message array with each request. @@ -81,7 +81,7 @@ export async function POST(req: Request) { -Chat messages need to be stored somewhere—typically a database. In this example, we assume a route like `/chats/:id` passes the session ID, allowing us to fetch existing messages and persist new ones. +Chat messages need to be stored somewhere, typically a database. In this example, we assume a route like `/chats/:id` passes the session ID, allowing us to fetch existing messages and persist new ones. ```typescript title="app/chats/[id]/page.tsx" lineNumbers "use client"; @@ -138,14 +138,13 @@ This is the pattern used in the [Building Durable AI Agents](/docs/ai) guide. In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's `useChat`, and past turns persisted to a user-managed database. -Persisting the turn is usually done through either: +Persist the turn through one of these methods: -- A step on the workflow that runs after `agent.stream()` and takes the message history from the agent return value (either `messages: ModelMessage[]` or `uiMessages: UIMessage[]`) -- A hook on `useChat`in the client that calls an API to persist state (or localStorage, etc.), either on every new message, or `onFinish` -- The resumable stream attached to the workflow (see [Resumable Streams](/docs/ai/resumable-streams)) - - Note that user messages are not persisted to the stream by default, and need to be explicitly persisted separately +- Run a workflow step after `agent.stream()` that takes the message history from the agent return value (either `messages: ModelMessage[]` or `uiMessages: UIMessage[]`). +- Use a `useChat` client hook that calls an API to persist state, such as on every new message or in `onFinish`. +- Use the resumable stream attached to the workflow (see [Resumable streams](/docs/ai/resumable-streams)). User messages are not persisted to the stream by default, so persist them separately. -## Multi-Turn Workflows +## Multi-turn workflows A single workflow handles the entire conversation session across multiple turns, and owns the current conversation state. The clients/API routes inject new messages via hooks. The workflow run ID serves as the session identifier. @@ -191,7 +190,7 @@ export async function chat(initialMessages: UIMessage[]) { tools: flightBookingTools, }); - // Use run ID as the hook token for easy resumption + // Use run ID as the hook token for resumption const hook = chatMessageHook.create({ token: runId }); let turnNumber = 0; @@ -254,7 +253,7 @@ export async function writeStreamClose(writable: WritableStream) -Three endpoints: start a session, send follow-up messages, and reconnect to the stream. +Use three endpoints to start a session, send follow-up messages, and reconnect to the stream. ```typescript title="app/api/chat/route.ts" lineNumbers import { createUIMessageStreamResponse, type UIMessage } from "ai"; @@ -493,13 +492,13 @@ In this pattern, the workflow owns the entire conversation session. All messages The client hook processes these markers by: -1. Iterating through message parts in order -2. When a `user-message` marker is found, flushing any accumulated assistant content and inserting the user message -3. Deduplicating against optimistic sends from the initial message +1. Iterate through message parts in order. +2. When a `user-message` marker is found, flush any accumulated assistant content and insert the user message. +3. Deduplicate against optimistic sends from the initial message. This ensures the conversation displays as User → AI → User → AI regardless of whether viewing live or replaying from the stream. -## Choosing a Pattern +## Choosing a pattern | Consideration | Single-Turn | Multi-Turn | |--------------|-------------|------------| @@ -509,13 +508,13 @@ This ensures the conversation displays as User → AI → User → AI regardless | Workflow time horizon | Minutes | Hours to indefinitely | | Observability scope | Per-turn traces | Full session traces | -**Multi-turn is recommended for most production use-cases.** If you're starting fresh, go with multi-turn. It's more flexible and grows with your requirements. You don't need to maintain the chat history yourself and can offload all that to the workflow's built in persistence. It also enables native message injection and full session observability, which becomes increasingly valuable as your agent matures. +**Multi-turn is recommended for most production use cases.** For new applications, use multi-turn workflows. The workflow's built-in persistence maintains the chat history and supports native message injection and full-session observability. -**Single-turn works well when adapting existing architectures.** If you already have a system for managing message state, and want to adopt durable agents incrementally, single-turn workflows slot in with minimal changes. Each turn maps cleanly to an independent workflow run. +**Single-turn works well when adapting existing architectures.** If you already have a system for managing message state and want to adopt durable agents incrementally, single-turn workflows require fewer changes. Each turn maps to an independent workflow run. -## Multiplayer Chat Sessions +## Multiplayer chat sessions -The multi-turn pattern also easily enables multi-player chat sessions. New messages can come from system events, external services, and other users. Since a `hook` injects messages into workflow at any point, and the entire history is a single stream that clients can reconnect to, it doesn't matter where the injected messages come from. Here are different use-cases for multi-player chat sessions: +The multi-turn pattern also enables multiplayer chat sessions. Messages can come from system events, external services, and other users. A `hook` can inject messages into a workflow at any point, while clients reconnect to one stream containing the entire history. @@ -542,7 +541,7 @@ export async function POST(req: Request) { -External webhooks from third-party services (Stripe, Twilio, etc.) can notify the conversation of events. +External webhooks from third-party services, such as Stripe and Twilio, can notify the conversation of events. ```typescript title="app/api/webhooks/payment/route.ts" lineNumbers import { chatMessageHook } from "@/workflows/chat/hooks/chat-message"; @@ -591,9 +590,9 @@ export async function POST( -## Related Documentation +## Related documentation -- [Building Durable AI Agents](/docs/ai) - Foundation guide for durable agents -- [Message Queueing](/docs/ai/message-queueing) - Queueing messages during tool execution -- [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Hook configuration options -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI SDK API for durable, resumable agents +- [Building Durable AI Agents](/docs/ai): Foundation guide for durable agents +- [Message Queueing](/docs/ai/message-queueing): Queueing messages during tool execution +- [`defineHook()` API reference](/docs/api-reference/workflow/define-hook): Hook configuration options +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK API for durable, resumable agents diff --git a/docs/content/docs/v4/ai/defining-tools.mdx b/docs/content/docs/v4/ai/defining-tools.mdx index 083d01ad6a..037b267835 100644 --- a/docs/content/docs/v4/ai/defining-tools.mdx +++ b/docs/content/docs/v4/ai/defining-tools.mdx @@ -14,11 +14,11 @@ related: This page covers the details for some common patterns when defining tools for AI agents using Workflow SDK. -Using WorkflowAgent, we model most tools as steps. These can be anything from a simple function call to a entire multi-day long workflow. +Using WorkflowAgent, we model most tools as steps. These can range from a single function call to an entire multi-day workflow. ## Accessing message context in tools -Just like in regular AI SDK tool definitions, tool in WorkflowAgent are called with a first argument of the tool's input parameters, and a second argument of the tool call context. +As with regular AI SDK tool definitions, tools in WorkflowAgent receive the tool's input parameters as the first argument and the tool call context as the second. When you tool needs access to the full message history, you can access it via the `messages` property of the tool call context: @@ -34,9 +34,9 @@ async function getWeather( } ``` -## Writing to Streams +## Writing to streams -As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step just to call `getWritable()` for writing custom data parts to the stream. +As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step only to call `getWritable()` for writing custom data parts to the stream. This can be made generic, by creating a helper step function to write arbitrary data to the stream: @@ -53,7 +53,7 @@ async function writeToStream(data: any) { } ``` -## Step-Level vs Workflow-Level Tools +## Step-level vs workflow-level tools Tools can be implemented either at the step level or the workflow level, with different capabilities and constraints. diff --git a/docs/content/docs/v4/ai/human-in-the-loop.mdx b/docs/content/docs/v4/ai/human-in-the-loop.mdx index ec79651cf8..253c005924 100644 --- a/docs/content/docs/v4/ai/human-in-the-loop.mdx +++ b/docs/content/docs/v4/ai/human-in-the-loop.mdx @@ -19,7 +19,7 @@ Workflow SDK's [webhook](/docs/api-reference/workflow/create-webhook) and [hook] If you need to react to external events programmatically, see the [hooks](/docs/foundations/hooks) documentation for more information. This part of the guide will focus on the human-in-the-loop pattern, which is a subset of the more general hook pattern. -## How It Works +## How it works @@ -45,9 +45,9 @@ The workflow receives the approval data and resumes execution. -While this demo will use a client side button for human approval, you could just as easily create a webhook and send the approval link over email or slack to resume the agent. +While this demo uses a client-side button for human approval, you could instead create a webhook and send the approval link over email or Slack to resume the agent. -## Creating a Booking Approval Tool +## Creating a booking approval tool Add a tool that allows the agent to deliberately pause execution until a human approves or rejects a flight booking: @@ -55,7 +55,7 @@ Add a tool that allows the agent to deliberately pause execution until a human a -### Define the Hook +### Define the hook Create a typed hook with a Zod schema for validation: @@ -78,7 +78,7 @@ export const bookingApprovalHook = defineHook({ -### Implement the Tool +### Implement the tool Create a tool that creates a hook instance using the tool call ID as the token. The UI will use this ID to submit the approval. @@ -126,14 +126,14 @@ export const flightBookingTools = { ``` -Note that the `defineHook().create()` function must be called from within a workflow context, not from within a step. This is why `executeBookingApproval` does not have `"use step"` - it runs in the workflow context where hooks are available. +Call `defineHook().create()` from within a workflow context, not from within a step. `executeBookingApproval` does not have `"use step"` because it runs in the workflow context where hooks are available. -### Create the API Route +### Create the API route Create a new API endpoint that the UI will call to submit the approval decision: @@ -158,7 +158,7 @@ export async function POST(request: Request) { -### Create the Approval Component +### Create the approval component Build a new component that reacts to the tool call data, and allows the user to approve or reject the booking: @@ -253,7 +253,7 @@ export function BookingApproval({ toolCallId, input, output }: BookingApprovalPr -### Show the Tool Status in the UI +### Show the tool status in the UI Use the component we just created to render the tool call and approval controls in your chat interface: @@ -332,7 +332,7 @@ export default function ChatPage() { -## Using Webhooks Directly +## Using webhooks directly For simpler cases where you don't need type-safe validation or programmatic resumption, you can use [`createWebhook()`](/docs/api-reference/workflow/create-webhook) directly. This generates a unique URL that can be called to resume the workflow: @@ -367,7 +367,7 @@ The webhook URL can be called directly with a POST request containing the approv - Payment provider callbacks - Email-based approval links -## Related Documentation +## Related documentation - [Hooks & Webhooks](/docs/foundations/hooks) - Complete guide to hooks and webhooks - [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook) - Webhook configuration options diff --git a/docs/content/docs/v4/ai/index.mdx b/docs/content/docs/v4/ai/index.mdx index 033d928a60..0ad7caa506 100644 --- a/docs/content/docs/v4/ai/index.mdx +++ b/docs/content/docs/v4/ai/index.mdx @@ -22,7 +22,7 @@ Workflow SDK makes your agents production-ready, by turning them into durable, r This guide walks you through converting a basic AI chat app into a durable AI agent using Workflow SDK. -## Why Durable Agents? +## Why durable agents? Aside from the usual challenges of getting your long-running tasks to be production-ready, building mature AI agents typically requires solving several **additional challenges**: @@ -31,22 +31,22 @@ Aside from the usual challenges of getting your long-running tasks to be product - **Resumability**: Resuming streams requires not just storing your messages, but also storing streams, and piping them across services. - **Human-in-the-loop**: Your client, API, and async job orchestration need to work together to create, track, route to, and display human approval requests, or similar webhook operations. -Workflow SDK provides all of these capabilities out of the box. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure. +Workflow SDK provides all of these capabilities without additional infrastructure. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure. -## Getting Started +## Getting started To make an Agent durable, we first need an Agent, which we'll be setting up here. If you already have an app you'd like to follow along with, you can skip this section. -For our example, we'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. We'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. +For our example, we'll need an app with a basic chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. We'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. ### Clone example app -We'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. For the follow-along steps, we'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. +We'll need an app with a basic chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. For the follow-along steps, we'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. -If you have your own project, you can skip this step, and simply apply the changes of the following steps to your own project. +If you have your own project, skip this step and apply the changes in the following steps to your project. ```bash git clone https://github.com/vercel/workflow-examples -b plain-ai-sdk @@ -59,7 +59,7 @@ cd workflow-examples/flight-booking-app ### Set up API keys -In order to connect to an LLM, we'll need to set up an API key. The easiest way to do this is to use Vercel Gateway (works with all providers at zero markup), or you can configure a custom provider. +To connect to an LLM, set up an API key. You can use Vercel Gateway, which works with all providers at zero markup, or configure a custom provider. @@ -113,15 +113,15 @@ export async function POST(req: Request) { ### Get familiar with the code -Let's take a moment to see what we're working with. Run the app with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. You should see a simple chat interface to play with. Go ahead and give it a try. +Run the app with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. You should see a basic chat interface to test. -The core code that makes all of this happen is quite simple. Here's a breakdown of the main parts. Note that there's no changes needed here, we're simply taking a look at the code to understand what's happening. +The following sections break down the core code. You don't need to make changes yet. -Our API route makes a simple call to [AI SDK's `ToolLoopAgent` class](https://ai-sdk.dev/docs/agents/overview), which encapsulates the LLM call, tool execution loop, and stopping conditions on top of [AI SDK's `streamText` function](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#streamtext). This is also where we pass tools to the agent. +Our API route calls [AI SDK's `ToolLoopAgent` class](https://ai-sdk.dev/docs/agents/overview), which encapsulates the LLM call, tool execution loop, and stopping conditions on top of [AI SDK's `streamText` function](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#streamtext). This is also where we pass tools to the agent. ```typescript title="app/api/chat/route.ts" lineNumbers import { ToolLoopAgent } from "ai"; @@ -170,7 +170,7 @@ async function searchFlights({ from, to, date }: { from: string; to: string; dat -Our `ChatPage` component has a lot of logic for nicely displaying the chat messages, but at it's core, it's simply managing input/output for the [`useChat` hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) from AI SDK. +Our `ChatPage` component contains logic for displaying chat messages, but its core responsibility is managing input and output for the [`useChat` hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) from AI SDK. ```typescript title="app/chat.tsx" lineNumbers "use client"; @@ -228,7 +228,7 @@ Now that we have a basic agent using AI SDK, we can modify it to make it durable -### Install Dependencies +### Install dependencies Add the Workflow SDK packages to your project: @@ -253,7 +253,7 @@ export default withWorkflow(nextConfig); -### Create a Workflow Function +### Create a workflow function Move the agent logic into a separate function, which will serve as our workflow definition. @@ -272,7 +272,7 @@ export async function chatWorkflow(messages: UIMessage[]) { const agent = new WorkflowAgent({ // [!code highlight] - // If using AI Gateway, just specify the model name as a string: + // If using AI Gateway, specify the model name as a string: model: "bedrock/claude-4-5-haiku-20251001-v1", // [!code highlight] // ELSE if using a custom provider, pass the provider call as an argument: @@ -300,7 +300,7 @@ Key changes: -### Update the API Route +### Update the API route Remove the agent call that we just extracted, and replace it with a call to `start()` to run the workflow: @@ -330,7 +330,7 @@ Key changes: -### Convert Tools to Steps +### Convert tools to steps Mark all tool definitions with `"use step"` to make them durable. This enables automatic retries and observability for each tool call: @@ -389,7 +389,7 @@ With `"use step"`: -That's all you need to do to convert your basic AI SDK agent into a durable agent. If you run your development server, and send a chat message, you should see your agent respond just as before, but now with added durability and observability. +Your basic AI SDK agent is now durable. Run your development server and send a chat message. The agent should respond as before, with added durability and observability. ## Observability @@ -401,7 +401,7 @@ npx workflow web This opens a local dashboard showing all workflow runs and their status, as well as a trace viewer to inspect the workflow in detail, including retry attempts, and the data being passed between steps. -## Next Steps +## Next steps Now that you have a basic durable agent, it's a only a short step to add these additional features: @@ -420,11 +420,11 @@ Now that you have a basic durable agent, it's a only a short step to add these a -## Complete Example +## Complete example A complete example that includes all of the above, plus all of the "next steps" features is available on the main branch of the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example. -## Related Documentation +## Related documentation - [Tools](/docs/ai/defining-tools) - Patterns for defining tools for your agent - [Human-in-the-Loop](/docs/ai/human-in-the-loop) - Pause agent workflows for human approval, then resume on input diff --git a/docs/content/docs/v4/ai/message-queueing.mdx b/docs/content/docs/v4/ai/message-queueing.mdx index 48ace15017..00aa147874 100644 --- a/docs/content/docs/v4/ai/message-queueing.mdx +++ b/docs/content/docs/v4/ai/message-queueing.mdx @@ -15,7 +15,7 @@ When using [multi-turn workflows](/docs/ai/chat-session-modeling#multi-turn-work `WorkflowAgent`'s `prepareStep` callback enables this by running before each step in the agent loop, giving you a chance to inject queued messages into the conversation. `prepareStep` also allows you to modify the model choice and existing messages mid-turn, see AI SDK's [prepareStep callback](https://ai-sdk.dev/docs/agents/loop-control#prepare-step) for more details. -## When to Use This +## When to use this Message queueing is useful when: @@ -24,10 +24,10 @@ Message queueing is useful when: - You want messages to influence the agent's next step rather than waiting for the current turn to complete -If you just need basic multi-turn conversations where messages arrive between turns, see [Chat Session Modeling](/docs/ai/chat-session-modeling). This guide covers the more advanced case of injecting messages *during* turns. +If you need basic multi-turn conversations where messages arrive between turns, see [Chat Session Modeling](/docs/ai/chat-session-modeling). This guide covers the more advanced case of injecting messages *during* turns. -## The `prepareStep` Callback +## The `prepareStep` callback The `prepareStep` callback runs before each step in the agent loop. It receives the current state and can modify the messages sent to the model: @@ -47,7 +47,7 @@ interface PrepareStepResult { } ``` -## Injecting Queued Messages +## Injecting queued messages Once you have a [multi-turn workflow](/docs/ai/chat-session-modeling#multi-turn-workflows), you can combine a message queue with `prepareStep` to inject messages that arrive during processing: @@ -106,7 +106,7 @@ Messages sent via `chatMessageHook.resume()` accumulate in the queue and get inj The `prepareStep` callback receives messages in `ModelMessage[]` format (with content arrays), which is the internal format used by the AI SDK. -## Combining with Multi-Turn Sessions +## Combining with multi-turn sessions You can also combine message queueing with the standard multi-turn pattern: @@ -169,7 +169,7 @@ export async function chat(initialMessages: ModelMessage[]) { } ``` -## Related Documentation +## Related documentation - [Chat Session Modeling](/docs/ai/chat-session-modeling) - Single-turn vs multi-turn patterns - [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents diff --git a/docs/content/docs/v4/ai/resumable-streams.mdx b/docs/content/docs/v4/ai/resumable-streams.mdx index 55f8f49e93..738d49ae19 100644 --- a/docs/content/docs/v4/ai/resumable-streams.mdx +++ b/docs/content/docs/v4/ai/resumable-streams.mdx @@ -13,14 +13,14 @@ related: --- -`WorkflowChatTransport` now ships in AI SDK as a 1:1 port — import it from `@ai-sdk/workflow` (the `@workflow/ai` export is deprecated). See [Resumable Streaming with `WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) for the full reference. +`WorkflowChatTransport` now ships in AI SDK as a 1:1 port, so import it from `@ai-sdk/workflow` (the `@workflow/ai` export is deprecated). See [Resumable Streaming with `WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) for the full reference. -When building chat interfaces, it's common to run into network interruptions, page refreshes, or serverless function timeouts, which can break the connection to an in-progress agent. +Network interruptions, page refreshes, or Vercel Functions timeouts can break a chat interface's connection to an in-progress agent. -Where a standard chat implementation would require the user to resend their message and wait for the entire response again, workflow runs are durable, and so are the streams attached to them. This means a stream can be resumed at any point, optionally only syncing the data that was missed since the last connection. +Workflow runs and their attached streams are durable, so users can resume a stream without resending a message or waiting for the entire response again. The client can optionally sync only the data missed since the last connection. -Resumable streams come out of the box with Workflow SDK, however, the client needs to recognize that a stream exists, and needs to know which stream to reconnect to, and needs to know where to start from. For this, Workflow SDK provides the [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) helper, a drop-in transport for the AI SDK that handles client-side resumption logic for you. +Workflow SDK supports resumable streams, but the client must identify the stream and the position from which to reconnect. The [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) helper is a drop-in AI SDK transport that handles this client-side resumption logic. When deploying a streaming route to Vercel, enable request cancellation so a browser disconnect terminates that route's abandoned stream reader instead of letting the function run until `FUNCTION_INVOCATION_TIMEOUT`. See [Avoiding Function Timeouts After Client Disconnects](/docs/foundations/streaming#avoiding-function-timeouts-after-client-disconnects). @@ -28,15 +28,15 @@ When deploying a streaming route to Vercel, enable request cancellation so a bro ## Implementing stream resumption -Let's add stream resumption to our Flight Booking Agent that we build in the [Building Durable AI Agents](/docs/ai) guide. +Add stream resumption to the Flight Booking Agent from the [Building Durable AI Agents](/docs/ai) guide. -### Return the Run ID from Your API +### Return the run ID from your API -Modify your chat endpoint to include the workflow run ID in a response header. The Run ID uniquely identifies the run's stream, so it allows the client to know which stream to reconnect to. +Modify your chat endpoint to include the workflow run ID in a response header. The run ID uniquely identifies the stream so the client knows which stream to reconnect to. {/*@skip-typecheck: incomplete code sample*/} @@ -62,9 +62,9 @@ export async function POST(req: Request) { -### Add a Stream Reconnection Endpoint +### Add a stream reconnection endpoint -Currently we only have one API endpoint that always creates a new run, so we need to create a new API route that returns the stream for an existing run: +Create an API route that returns the stream for an existing run. The current endpoint always creates a new run. ```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers import { createUIMessageStreamResponse } from "ai"; @@ -100,16 +100,16 @@ export async function GET( } ``` -The `startIndex` parameter ensures the client can choose where to resume the stream from. For instance, if the function times out during streaming, the chat transport will use `startIndex` to resume the stream exactly from the last token it received. Negative values are also supported (e.g. `-5` starts 5 chunks before the end), which is useful for custom stream consumers (such as a dashboard showing recent output) that want to show the most recent output without replaying the full stream. +The `startIndex` parameter lets the client choose where to resume the stream. For example, if the function times out during streaming, the chat transport uses `startIndex` to resume from the last token it received. Negative values are also supported. A value of `-5` starts 5 chunks before the end, which is useful for custom stream consumers that show recent output without replaying the full stream. -When using a negative `startIndex`, your stream endpoint must return a `x-workflow-stream-tail-index` header in order for relative resumption to work. Missing the header will fall back to replaying the entire stream. +When using a negative `startIndex`, your stream endpoint must return an `x-workflow-stream-tail-index` header for relative resumption. If the header is missing, the transport replays the entire stream. -### Use `WorkflowChatTransport` in the Client +### Use `WorkflowChatTransport` in the client -Replace the default transport in AI-SDK's `useChat` with [`WorkflowChatTransport`]( +Replace the default transport in AI SDK's `useChat` with [`WorkflowChatTransport`]( /docs/api-reference/workflow-ai/workflow-chat-transport ), and update the callbacks to store and use the latest run ID. For now, we'll store the run ID in localStorage. For your own app, this would be stored wherever you store session information. @@ -166,23 +166,23 @@ export default function ChatPage() { -Now try the flight booking example again. Open it up in a separate tab, or spam the refresh button, and see how the client connects to the same chat stream every time. +Open the flight booking example in another tab or refresh the page repeatedly. The client reconnects to the same chat stream each time. -## How It Works +## How it works -1. When the user sends a message, `WorkflowChatTransport` makes a POST to `/api/chat` -2. The API starts a workflow and returns the run ID in the `x-workflow-run-id` header -3. `onChatSendMessage` stores this run ID in localStorage -4. If the stream is interrupted before receiving a "finish" chunk, the transport automatically reconnects -5. `prepareReconnectToStreamRequest` builds the reconnection URL using the stored run ID, pointing to the new endpoint `/api/chat/{runId}/stream` -6. The reconnection endpoint returns the stream from where the client left off -7. When the stream completes, `onChatEnd` clears the stored run ID +1. When the user sends a message, `WorkflowChatTransport` makes a `POST` request to `/api/chat`. +2. The API starts a workflow and returns the run ID in the `x-workflow-run-id` header. +3. `onChatSendMessage` stores this run ID in `localStorage`. +4. If the stream is interrupted before receiving a `finish` chunk, the transport automatically reconnects. +5. `prepareReconnectToStreamRequest` builds the reconnection URL using the stored run ID and points to `/api/chat/{runId}/stream`. +6. The reconnection endpoint returns the stream from where the client left off. +7. When the stream completes, `onChatEnd` clears the stored run ID. -This approach also handles page refreshes, as the client will automatically reconnect to the stream from the last known position when the UI loads with a stored run ID, following the behavior of [AI SDK's stream resumption](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams#chatbot-resume-streams). +This approach also handles page refreshes, as the client will automatically reconnect to the stream from the last known position when the user interface (UI) loads with a stored run ID, following the behavior of [AI SDK's stream resumption](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams#chatbot-resume-streams). ### Resuming from the end of the stream -By default, reconnecting replays the entire stream from the beginning (`startIndex: 0`). If you only need to show recent output — for example, when resuming a long conversation after a page refresh — you can set `initialStartIndex` to a negative value to read from the end of the stream instead: +By default, reconnecting replays the entire stream from the beginning (`startIndex: 0`). If you only need to show recent output (for example, when resuming a long conversation after a page refresh), you can set `initialStartIndex` to a negative value to read from the end of the stream instead: {/*@skip-typecheck: incomplete code sample*/} @@ -206,8 +206,8 @@ When using a negative `initialStartIndex`, the reconnection endpoint **must** re A workflow stream is a flat sequence of chunks, but the AI SDK's UI protocol groups chunks into logical parts (`text-*`, `reasoning-*`, `tool-input-*`) that must be opened with a `*-start` before any `*-delta` or `*-end`. A non-zero `startIndex` can land in the middle of an open part. See [`WorkflowChatTransport` → Mid-part resumes](/docs/api-reference/workflow-ai/workflow-chat-transport#mid-part-resumes) for how this is handled and an example of rewinding to a step boundary on the server. -## Related Documentation +## Related documentation -- [`WorkflowChatTransport` API Reference](/docs/api-reference/workflow-ai/workflow-chat-transport) - Full configuration options -- [Streaming](/docs/foundations/streaming) - Understanding workflow streams -- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Retrieving existing runs +- [`WorkflowChatTransport` API reference](/docs/api-reference/workflow-ai/workflow-chat-transport): Full configuration options +- [Streaming](/docs/foundations/streaming): Understanding workflow streams +- [`getRun()` API reference](/docs/api-reference/workflow-api/get-run): Retrieving existing runs diff --git a/docs/content/docs/v4/ai/sleep-and-delays.mdx b/docs/content/docs/v4/ai/sleep-and-delays.mdx index 3d39d03685..5a8c5ba44d 100644 --- a/docs/content/docs/v4/ai/sleep-and-delays.mdx +++ b/docs/content/docs/v4/ai/sleep-and-delays.mdx @@ -12,7 +12,7 @@ related: - /docs/api-reference/workflow/sleep --- -AI agents sometimes need to pause execution in order to schedule recurring or future actions, wait before retrying an operation (e.g. for rate limiting), or wait for external state to be available. +AI agents sometimes need to pause execution to schedule recurring or future actions, wait before retrying an operation (e.g. for rate limiting), or wait for external state to be available. Workflow SDK's `sleep` function enables Agents to pause execution without consuming resources, and resume at a specified time, after a specified duration, or in response to an external event. Workflow operation that suspend will survive restarts, new deploys, and infrastructure changes, independent of whether the suspense takes seconds or months. @@ -20,15 +20,15 @@ Workflow SDK's `sleep` function enables Agents to pause execution without consum See the [`sleep()` API Reference](/docs/api-reference/workflow/sleep) for the full list of supported duration formats and detailed API documentation, and see the [hooks](/docs/foundations/hooks) documentation for more information on how to resume in response to external events. -## Adding a Sleep Tool +## Adding a sleep tool -Sleep is a built-in function in Workflow SDK, so exposing it as a tool is as simple as wrapping it in a tool definition. Learn more about how to define tools in [Patterns for Defining Tools](/docs/ai/defining-tools). +Sleep is a built-in function in Workflow SDK. To expose it as a tool, wrap it in a tool definition. Learn more about how to define tools in [Patterns for Defining Tools](/docs/ai/defining-tools). -### Define the Tool +### Define the tool Add a new "sleep" tool to the `tools` defined in `workflows/chat/steps/tools.ts`: @@ -60,7 +60,7 @@ export const flightBookingTools = { ``` - Note that the `sleep()` function must be called from within a workflow context, not from within a step. This is why `executeSleep` does not have `"use step"` - it runs in the workflow context where `sleep()` is available. + Call `sleep()` from within a workflow context, not from within a step. `executeSleep` does not have `"use step"` because it runs in the workflow context where `sleep()` is available. This already makes the full sleep functionality available to the Agent! @@ -71,7 +71,7 @@ export const flightBookingTools = { ### Show the tool status in the UI -To round it off, extend the UI to display the tool call status. This can be done either by displaying the tool call information directly, or by emitting custom data parts to the stream (see [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools) for more details). In this case, since there aren't any fine-grained progress updates to show, we'll just display the tool call information directly: +To round it off, extend the UI to display the tool call status. This can be done either by displaying the tool call information directly, or by emitting custom data parts to the stream (see [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools) for more details). Since there aren't any fine-grained progress updates to show, we'll display the tool call information directly: {/*@skip-typecheck: incomplete code sample*/} @@ -153,11 +153,11 @@ function renderToolOutput(part: any) { Now, try out the Flight Booking Agent again, and ask it to sleep for 10 seconds before checking any flight. You'll see the agent pause, and the UI reflect the tool call status. -## Use Cases +## Use cases Aside from providing `sleep()` as a tool, there are other use cases for Agents that commonly call for suspension and resumption. -### Rate Limiting +### Rate limiting When hitting API rate limits, use `RetryableError` with a delay: @@ -180,7 +180,7 @@ async function callRateLimitedAPI(endpoint: string) { } ``` -## Related Documentation +## Related documentation - [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full API documentation with all duration formats - [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow context diff --git a/docs/content/docs/v4/ai/streaming-updates-from-tools.mdx b/docs/content/docs/v4/ai/streaming-updates-from-tools.mdx index f20904021d..d1a4e5a2a3 100644 --- a/docs/content/docs/v4/ai/streaming-updates-from-tools.mdx +++ b/docs/content/docs/v4/ai/streaming-updates-from-tools.mdx @@ -22,7 +22,7 @@ As an example, we'll extend out Flight Booking Agent to use emit more granular p -### Define Your Data Part Type +### Define your data part type First, define a TypeScript type for your custom data part. This ensures type safety across your tool and client code: @@ -44,7 +44,7 @@ The `type` field must be a string starting with `data-` followed by your custom -### Emit Updates from Your Tool +### Emit updates from your tool Use [`getWritable()`](/docs/api-reference/workflow/get-writable) inside a step function to get a handle to the stream. This is the same stream that the LLM and other tools calls are writing to, so we can inject out own data packets directly. @@ -95,7 +95,7 @@ Key points: -### Handle Data Parts in the Client +### Handle data parts in the client Update your chat component to detect and render the custom data parts. Data parts are stored in the message's `parts` array alongside text and tool invocation parts: @@ -143,7 +143,7 @@ The pattern is: Now, when you run the agent to search for flights, you'll see the flight results pop up one after another. This will be most useful if you have tool calls that take minutes to complete, and you need to show granular progress updates to the user. -## Related Documentation +## Related documentation - [Building Durable AI Agents](/docs/ai) - Complete guide to durable agents - [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Stream API details diff --git a/docs/content/docs/v4/api-reference/vitest/index.mdx b/docs/content/docs/v4/api-reference/vitest/index.mdx index 1c3f8d41de..4cf3b15810 100644 --- a/docs/content/docs/v4/api-reference/vitest/index.mdx +++ b/docs/content/docs/v4/api-reference/vitest/index.mdx @@ -3,7 +3,7 @@ title: "@workflow/vitest" description: Vitest plugin and test helpers for integration testing workflows in-process. --- -The `@workflow/vitest` package provides a Vitest plugin and test helpers for running full workflow integration tests in-process — no server required. +The `@workflow/vitest` package provides a Vitest plugin and test helpers for running full workflow integration tests in-process, no server required. ## Plugin @@ -21,7 +21,7 @@ export default defineConfig({ }); ``` -Pass a [`WorkflowTestOptions`](#workflowtestoptions) object when your project uses a non-standard layout — for example, a monorepo where `workflows/` does not live at the Vitest config's directory, or when the default `.workflow-data` / `.workflow-vitest` output locations need to move. The plugin forwards these paths to `buildWorkflowTests()` and `setupWorkflowTests()` through Vitest's per-project provided context, so each Vitest workspace project stays isolated. +Pass a [`WorkflowTestOptions`](#workflowtestoptions) object when your project uses a non-standard layout, for example, a monorepo where `workflows/` does not live at the Vitest config's directory, or when the default `.workflow-data` / `.workflow-vitest` output locations need to move. The plugin forwards these paths to `buildWorkflowTests()` and `setupWorkflowTests()` through Vitest's per-project provided context, so each Vitest workspace project stays isolated. ```typescript @@ -46,7 +46,7 @@ export default defineConfig({ **Returns:** `Plugin[]` -## Setup Functions +## Setup functions ### `buildWorkflowTests()` @@ -108,11 +108,11 @@ Tears down the workflow test world. Clears the global world and closes the Local | `dataDir` | `string` | `/.workflow-data` | Directory for workflow runtime data written by the test world. Relative paths resolve against `cwd`. | | `outDir` | `string` | `/.workflow-vitest` | Directory for generated workflow and step bundles. Relative paths resolve against `cwd`. | -## Test Helpers +## Test helpers ### `waitForSleep()` -Polls the event log until the workflow has a pending `sleep()` call — one with a `wait_created` event but no corresponding `wait_completed` event. Returns the correlation ID of the pending sleep, which can be passed to [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to target a specific sleep. +Polls the event log until the workflow has a pending `sleep()` call, one with a `wait_created` event but no corresponding `wait_completed` event. Returns the correlation ID of the pending sleep, which can be passed to [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to target a specific sleep. ```typescript @@ -131,9 +131,9 @@ await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlig | `run` | `Run` | The workflow run to monitor | | `options?` | `WaitOptions` | Polling and timeout configuration | -**Returns:** `Promise` — The correlation ID of the first pending sleep. Pass this to `wakeUp({ correlationIds: [id] })` to target a specific sleep. +**Returns:** `Promise`, the correlation ID of the first pending sleep. Pass this to `wakeUp({ correlationIds: [id] })` to target a specific sleep. -#### Behavior with Multiple Sleeps +#### Behavior with multiple sleeps - **Sequential sleeps**: `waitForSleep()` returns each sleep as the workflow reaches it. After waking one, call `waitForSleep()` again for the next. - **Parallel sleeps**: `waitForSleep()` returns whichever pending sleep is found first. After waking it, call `waitForSleep()` again to get the next one. @@ -159,7 +159,7 @@ await resumeHook(hook.token, { approved: true }); // [!code highlight] | `run` | `Run` | The workflow run to monitor | | `options?` | `WaitOptions & { token?: string }` | Polling, timeout, and optional token filter | -**Returns:** `Promise` — The first pending hook matching the filter. The hook object includes `token`, `hookId`, and `runId`. +**Returns:** `Promise`, the first pending hook matching the filter. The hook object includes `token`, `hookId`, and `runId`. ### `WaitOptions` diff --git a/docs/content/docs/v4/api-reference/workflow-ai/durable-agent.mdx b/docs/content/docs/v4/api-reference/workflow-ai/durable-agent.mdx index f38f3a0d7c..611e4cfcd5 100644 --- a/docs/content/docs/v4/api-reference/workflow-ai/durable-agent.mdx +++ b/docs/content/docs/v4/api-reference/workflow-ai/durable-agent.mdx @@ -10,14 +10,14 @@ related: --- -`DurableAgent` is deprecated. Use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for new durable agents — see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). +`DurableAgent` is deprecated. Use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for new durable agents. See the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). This reference is kept for existing applications that still import `DurableAgent` from `@workflow/ai/agent`. Do not use `DurableAgent` for new code. For current examples and implementation guidance, see AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) docs. For legacy code, the API surface below documents the existing `DurableAgent` exports. -## API Signature +## API signature ### Class @@ -125,7 +125,7 @@ import type { StreamTextOnAbortCallback } from "@workflow/ai/agent"; export default StreamTextOnAbortCallback;`} /> -### Advanced Types +### Advanced types #### ToolCallRepairFunction @@ -157,30 +157,30 @@ import type { OutputSpecification } from "@workflow/ai/agent"; export default OutputSpecification;`} /> -## Key Features +## Key features -- **Durable Execution**: Agents can be interrupted and resumed without losing state -- **Flexible Tool Implementation**: Tools can be implemented as workflow steps for automatic retries, or as regular workflow-level logic -- **Stream Processing**: Handles streaming responses and tool calls in a structured way -- **Workflow Native**: Fully integrated with Workflow SDK for production-grade reliability -- **AI SDK Parity**: Supports the same options as AI SDK's `streamText` including generation settings, callbacks, and structured output +- **Durable execution**: Agents can be interrupted and resumed without losing state. +- **Flexible tool implementation**: Tools can be implemented as workflow steps for automatic retries or as regular workflow-level logic. +- **Stream processing**: Handles streaming responses and tool calls in a structured way. +- **Workflow native**: Fully integrated with Workflow SDK for production reliability. +- **AI SDK parity**: Supports the same options as AI SDK's `streamText`, including generation settings, callbacks, and structured output. -## Good to Know +## Good to know -- Tools can be implemented as workflow steps (using `"use step"` for automatic retries), or as regular workflow-level logic -- Tools can use core library features like `sleep()` and Hooks within their `execute` functions -- The agent processes tool calls iteratively until completion or `maxSteps` is reached -- **Default `maxSteps` is unlimited** - set a value to limit the number of LLM calls -- The `stream()` method returns `{ messages, steps, toolCalls, toolResults, experimental_output, uiMessages }` containing the full conversation history, step details, tool call details, optional structured output, and optionally accumulated UI messages -- Use `collectUIMessages: true` to accumulate `UIMessage[]` during streaming, useful for persisting conversation state without re-reading the stream -- The `prepareStep` callback runs before each step and can modify model, messages, generation settings, tool choice, and context -- Generation settings (temperature, maxOutputTokens, etc.) can be set on the constructor and overridden per-stream call -- Use `activeTools` to limit which tools are available for a specific stream call -- The `onFinish` callback is called when all steps complete; `onAbort` is called if aborted +- Tools can be implemented as workflow steps (using `"use step"` for automatic retries) or as regular workflow-level logic. +- Tools can use core library features like `sleep()` and hooks within their `execute` functions. +- The agent processes tool calls iteratively until completion or until `maxSteps` is reached. +- **Default `maxSteps` is unlimited**: Set a value to limit the number of large language model (LLM) calls. +- The `stream()` method returns `{ messages, steps, toolCalls, toolResults, experimental_output, uiMessages }` containing the full conversation history, step details, tool call details, optional structured output, and optionally accumulated user interface (UI) messages. +- Use `collectUIMessages: true` to accumulate `UIMessage[]` during streaming, which is useful for persisting conversation state without re-reading the stream. +- The `prepareStep` callback runs before each step and can modify the model, messages, generation settings, tool choice, and context. +- Generation settings (`temperature`, `maxOutputTokens`, and others) can be set on the constructor and overridden per-stream call. +- Use `activeTools` to limit which tools are available for a specific stream call. +- The `onFinish` callback is called when all steps complete; `onAbort` is called if aborted. ## Examples -### Basic Agent with Tools +### Basic agent with tools ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -222,7 +222,7 @@ async function weatherAgentWorkflow(userQuery: string) { } ``` -### Multiple Tools +### Multiple tools ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -271,7 +271,7 @@ async function multiToolAgentWorkflow(userQuery: string) { } ``` -### Multi-turn Conversation +### Multi-turn conversation ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -325,7 +325,7 @@ async function multiTurnAgentWorkflow() { } ``` -### Tools with Workflow Library Features +### Tools with Workflow library features ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -347,7 +347,7 @@ async function requestApproval({ message }: { message: string }) { // Note: No "use step" for this tool call either, // since hooks are awaited at the workflow level - // Utilize a Hook for Human-in-the-loop approval + // Use a Hook for Human-in-the-loop approval const hook = approvalHook.create({ metadata: { message } }); @@ -392,7 +392,7 @@ async function agentWithLibraryFeaturesWorkflow(userRequest: string) { } ``` -### Dynamic Context with prepareStep +### Dynamic context with prepareStep Use `prepareStep` to modify settings before each step in the agent loop: @@ -436,7 +436,7 @@ async function agentWithPrepareStep(userMessage: string) { } ``` -### Message Injection with prepareStep +### Message injection with prepareStep Inject messages from external sources (like hooks) before each LLM call: @@ -486,7 +486,7 @@ async function agentWithMessageQueue(initialMessage: string) { } ``` -### Generation Settings +### Generation settings Configure model generation parameters at the constructor or stream level: @@ -524,7 +524,7 @@ async function agentWithGenerationSettings() { } ``` -### Limiting Steps with maxSteps +### Limiting steps with maxSteps By default, the agent loops until completion. Use `maxSteps` to limit the number of LLM calls: @@ -575,7 +575,7 @@ async function multiStepAgent() { } ``` -### Callbacks for Monitoring +### Callbacks for monitoring Use callbacks to monitor streaming progress, handle errors, and react to completion: @@ -616,7 +616,7 @@ async function agentWithCallbacks() { } ``` -### Structured Output +### Structured output Parse structured data from the LLM response using `Output.object`: @@ -651,7 +651,7 @@ async function agentWithStructuredOutput() { } ``` -### Tool Choice Control +### Tool choice control Control when and which tools the model can use: @@ -714,7 +714,7 @@ async function agentWithToolChoice() { } ``` -### Passing Context to Tools +### Passing context to tools Use `experimental_context` to pass shared context to tool executions: @@ -758,7 +758,7 @@ async function agentWithContext(userId: string) { } ``` -### Collecting UI Messages +### Collecting UI messages Use `collectUIMessages` to accumulate `UIMessage[]` during streaming. This is useful when you need to persist the conversation without re-reading the run's output stream: @@ -800,7 +800,7 @@ async function saveConversation(messages: UIMessage[]) { The `uiMessages` property is only available when `collectUIMessages` is set to `true`. When disabled, `uiMessages` is `undefined`. -### Machine-Readable Tool Results +### Machine-readable tool results `stream()` returns tool call information you can inspect programmatically. Compare `toolCalls` with `toolResults` to find unresolved tool calls that need client-side handling: @@ -857,7 +857,7 @@ async function agentWithToolInspection(userMessage: string) { `toolCalls` and `toolResults` reflect the *last step* of the agent loop. Tools without an `execute` function will appear in `toolCalls` but not in `toolResults`, which is how you detect calls that need client-side handling. -### Aborting Long-Running Streams +### Aborting long-running streams Use `timeout` to abort a stream automatically after a fixed duration: @@ -885,10 +885,10 @@ async function agentWithTimeout(userMessage: string) { } ``` -## See Also +## See also -- [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents -- [Queueing User Messages](/docs/ai/message-queueing) - Using prepareStep for message injection -- [WorkflowChatTransport](/docs/api-reference/workflow-ai/workflow-chat-transport) - Transport layer for AI SDK streams -- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals -- [AI SDK Loop Control](https://ai-sdk.dev/docs/agents/loop-control) - AI SDK's agent loop control patterns +- [Building Durable AI Agents](/docs/ai): Complete guide to creating durable agents +- [Queueing User Messages](/docs/ai/message-queueing): Using `prepareStep` for message injection +- [WorkflowChatTransport](/docs/api-reference/workflow-ai/workflow-chat-transport): Transport layer for AI SDK streams +- [Workflows and Steps](/docs/foundations/workflows-and-steps): Understanding workflow fundamentals +- [AI SDK Loop Control](https://ai-sdk.dev/docs/agents/loop-control): AI SDK's agent loop control patterns diff --git a/docs/content/docs/v4/api-reference/workflow-ai/index.mdx b/docs/content/docs/v4/api-reference/workflow-ai/index.mdx index cc3a10372d..b54c958fb2 100644 --- a/docs/content/docs/v4/api-reference/workflow-ai/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-ai/index.mdx @@ -7,15 +7,15 @@ related: - /docs/ai --- -Helpers for integrating AI SDK for building AI-powered workflows. +The `@workflow/ai` package provides helpers for integrating AI SDK into AI-powered workflows. ## Classes - Deprecated — use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). Reference kept for existing `@workflow/ai/agent` imports. + Deprecated: use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). Reference kept for existing `@workflow/ai/agent` imports. - Deprecated — use AI SDK's [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) from `@ai-sdk/workflow`. Reference kept for existing `@workflow/ai` imports. + Deprecated: use AI SDK's [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) from `@ai-sdk/workflow`. Reference kept for existing `@workflow/ai` imports. diff --git a/docs/content/docs/v4/api-reference/workflow-ai/workflow-chat-transport.mdx b/docs/content/docs/v4/api-reference/workflow-ai/workflow-chat-transport.mdx index ae56e03a4c..d5de056bc7 100644 --- a/docs/content/docs/v4/api-reference/workflow-ai/workflow-chat-transport.mdx +++ b/docs/content/docs/v4/api-reference/workflow-ai/workflow-chat-transport.mdx @@ -10,10 +10,10 @@ related: --- -`WorkflowChatTransport` from `@workflow/ai` is deprecated. AI SDK ships a 1:1 port — use [`WorkflowChatTransport` from `@ai-sdk/workflow`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) instead. This reference is kept for existing applications that still import it from `@workflow/ai`. +`WorkflowChatTransport` from `@workflow/ai` is deprecated. AI SDK ships a 1:1 port, so use [`WorkflowChatTransport` from `@ai-sdk/workflow`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) instead. This reference is kept for existing applications that still import it from `@workflow/ai`. -A chat transport implementation for the AI SDK that provides reliable message streaming with automatic reconnection to interrupted streams. This transport is a drop-in replacement for the default AI SDK transport, enabling seamless recovery from network issues, page refreshes, or Vercel Function timeouts. +`WorkflowChatTransport` is an AI SDK chat transport that automatically reconnects to interrupted streams. It replaces the default AI SDK transport and recovers from network issues, page refreshes, or Vercel Functions timeouts. `WorkflowChatTransport` implements the [`ChatTransport`](https://ai-sdk.dev/docs/ai-sdk-ui/transport) interface from the AI SDK and is designed to work with workflow-based chat applications. It requires endpoints that return the `x-workflow-run-id` header to enable stream resumption. @@ -38,7 +38,7 @@ export default function Chat() { } ``` -## API Signature +## API signature ### Class @@ -56,27 +56,27 @@ import type { WorkflowChatTransportOptions } from "@workflow/ai"; export default WorkflowChatTransportOptions;`} /> -## Key Features +## Key features -- **Automatic Reconnection**: Automatically recovers from interrupted streams with configurable retry limits -- **Workflow Integration**: Seamlessly works with workflow-based endpoints that provide the `x-workflow-run-id` header -- **Customizable Requests**: Allows intercepting and modifying requests via `prepareSendMessagesRequest` and `prepareReconnectToStreamRequest` -- **Stream Callbacks**: Provides hooks for tracking chat lifecycle via `onChatSendMessage` and `onChatEnd` -- **Custom Fetch**: Supports custom fetch implementations for advanced use cases +- **Automatic reconnection**: Recovers from interrupted streams with configurable retry limits. +- **Workflow integration**: Works with workflow-based endpoints that provide the `x-workflow-run-id` header. +- **Customizable requests**: Allows intercepting and modifying requests through `prepareSendMessagesRequest` and `prepareReconnectToStreamRequest`. +- **Stream callbacks**: Provides hooks for tracking the chat lifecycle through `onChatSendMessage` and `onChatEnd`. +- **Custom fetch**: Supports custom fetch implementations for advanced use cases. -## Good to Know +## Good to know -- The transport expects chat endpoints to return the `x-workflow-run-id` header in the response to enable stream resumption -- By default, the transport posts to `/api/chat` and reconnects via `/api/chat/{runId}/stream` -- The `onChatSendMessage` callback receives the full response object, allowing you to extract and store the workflow run ID for session resumption -- Stream interruptions are automatically detected when a "finish" chunk is not received in the initial response -- The `maxConsecutiveErrors` option controls how many reconnection attempts are made before giving up (default: 3) -- `initialStartIndex` (constructor option) sets the default chunk position for the **first** reconnection attempt (e.g. after a page refresh). Subsequent retries within the same reconnection loop always resume from the last received chunk. Negative values (e.g. `-20`) read from the end of the stream, which is useful for showing only recent output without replaying the full conversation. `startIndex` (per-call option on `reconnectToStream`) overrides `initialStartIndex` for a single reconnection -- When using a negative `initialStartIndex`, the reconnection endpoint must return the `x-workflow-stream-tail-index` response header (via `readable.getTailIndex()`). The transport reads this header to compute absolute chunk positions for retries. Without it, startIndex is assumed to be 0, replaying the entire stream +- The transport expects chat endpoints to return the `x-workflow-run-id` header in the response to enable stream resumption. +- By default, the transport posts to `/api/chat` and reconnects through `/api/chat/{runId}/stream`. +- The `onChatSendMessage` callback receives the full response object, allowing you to extract and store the workflow run ID for session resumption. +- Stream interruptions are automatically detected when a `finish` chunk is not received in the initial response. +- The `maxConsecutiveErrors` option controls how many reconnection attempts are made before giving up (default: 3). +- `initialStartIndex` (constructor option) sets the default chunk position for the **first** reconnection attempt (for example, after a page refresh). Subsequent retries within the same reconnection loop always resume from the last received chunk. Negative values (for example, `-20`) read from the end of the stream, which is useful for showing only recent output without replaying the full conversation. `startIndex` (per-call option on `reconnectToStream`) overrides `initialStartIndex` for a single reconnection. +- When using a negative `initialStartIndex`, the reconnection endpoint must return the `x-workflow-stream-tail-index` response header (through `readable.getTailIndex()`). The transport reads this header to compute absolute chunk positions for retries. Without it, `startIndex` is assumed to be `0`, replaying the entire stream. ## Examples -### Basic Chat Setup +### Basic chat setup ```typescript "use client"; @@ -119,7 +119,7 @@ export default function BasicChat() { } ``` -### With Session Persistence and Resumption +### With session persistence and resumption ```typescript "use client"; @@ -187,7 +187,7 @@ export default function ChatWithResumption() { } ``` -### With Custom Request Configuration +### With custom request configuration ```typescript "use client"; @@ -256,11 +256,11 @@ export default function ChatWithCustomConfig() { ## Mid-part resumes -A workflow stream is a flat sequence of chunks, but the AI SDK's UI protocol groups chunks into logical parts: a `text-start` opens a text part that subsequent `text-delta`s extend and a `text-end` closes, and the same shape applies to `reasoning-*` and `tool-input-*`. The AI SDK client enforces that grammar — a `reasoning-delta` whose `reasoning-start` was never seen throws and breaks the chat. +A workflow stream is a flat sequence of chunks, but the AI SDK's user interface (UI) protocol groups chunks into logical parts: a `text-start` opens a text part that subsequent `text-delta`s extend and a `text-end` closes, and the same shape applies to `reasoning-*` and `tool-input-*`. The AI SDK client enforces that grammar: a `reasoning-delta` whose `reasoning-start` was never seen throws and breaks the chat. -A non-zero `startIndex` (in particular a negative `initialStartIndex`) resolves to a chunk offset with no awareness of those part boundaries, so it can land in the middle of an open part. When that happens, `WorkflowChatTransport` will **drop chunks that reference a part it didn't see a start for** and log a one-time warning. The chat keeps working, but any partial part overlapping the resume cursor is discarded. Tool calls are an exception: `tool-input-available` / `tool-input-error` chunks are self-contained (they carry the full input), so a tool call is recovered as soon as one of those chunks appears in the resumed window — only its streamed input deltas are lost. +A non-zero `startIndex` (in particular a negative `initialStartIndex`) resolves to a chunk offset with no awareness of those part boundaries, so it can land in the middle of an open part. When that happens, `WorkflowChatTransport` will **drop chunks that reference a part it didn't see a start for** and log a one-time warning. The chat keeps working, but any partial part overlapping the resume cursor is discarded. Tool calls are an exception: `tool-input-available` / `tool-input-error` chunks are self-contained (they carry the full input), so a tool call is recovered as soon as one of those chunks appears in the resumed window. Only its streamed input deltas are lost. -To preserve those partial parts, rewind to a step boundary on the server before returning the readable. `start-step` / `finish-step` chunks are the natural seams — no UI part is ever open across them. Sketch: +To preserve those partial parts, rewind to a step boundary on the server before returning the readable. `start-step` / `finish-step` chunks are the natural seams: no UI part is ever open across them. Sketch: {/*@skip-typecheck: incomplete code sample*/} @@ -293,9 +293,9 @@ return createUIMessageStreamResponse({ }); ``` -## See Also +## See also -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - Building durable, resumable AI agents (replaces `DurableAgent`) -- [AI SDK `useChat` Documentation](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) - Using `useChat` with custom transports -- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals -- ["flight-booking-app" Example](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) - An example application which uses `WorkflowChatTransport` +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Build durable, resumable AI agents (replaces `DurableAgent`) +- [AI SDK `useChat` documentation](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat): Use `useChat` with custom transports +- [Workflows and Steps](/docs/foundations/workflows-and-steps): Understand workflow fundamentals +- [`flight-booking-app` example](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app): View an example application that uses `WorkflowChatTransport` diff --git a/docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx b/docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx index 1448c48f3e..22cdec92ce 100644 --- a/docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx +++ b/docs/content/docs/v4/api-reference/workflow-api/get-hook-by-token.mdx @@ -16,7 +16,7 @@ Retrieves a hook by its unique token, returning the associated workflow run info -Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). +Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). ```typescript lineNumbers @@ -29,7 +29,7 @@ export async function POST(request: Request) { } ``` -## API Signature +## API signature ### Parameters @@ -53,7 +53,7 @@ showSections={["returns"]} ## Examples -### Basic Hook Lookup +### Basic hook lookup Retrieve hook information before resuming: @@ -83,7 +83,7 @@ export async function POST(request: Request) { } ``` -### Validating Hook Before Resume +### Validating hook before resume Use `getHookByToken` to validate hook ownership or metadata before resuming: @@ -113,7 +113,7 @@ export async function POST(request: Request) { } ``` -### Checking Hook Environment +### Checking hook environment Verify the hook belongs to the expected environment: @@ -142,7 +142,7 @@ export async function POST(request: Request) { } ``` -### Logging Hook Information +### Logging hook information Log hook details for debugging or auditing: @@ -179,9 +179,9 @@ export async function POST(request: Request) { } ``` -## Related Functions +## Related functions -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload. -- [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper. -- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a hook with a payload. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper. +- [Idempotency](/docs/foundations/idempotency): Deduplicate step side effects and workflow starts. diff --git a/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx b/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx index 8c7db48a49..27764ab352 100644 --- a/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx +++ b/docs/content/docs/v4/api-reference/workflow-api/get-run.mdx @@ -9,7 +9,7 @@ related: - /docs/foundations/idempotency --- -Retrieves the workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion, making it ideal for status checking and monitoring. +Retrieves workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion. Use this function when you need to check workflow status, get timing information, or access workflow metadata without blocking on workflow completion. @@ -23,7 +23,7 @@ import { getRun } from "workflow/api"; const run = getRun("my-run-id"); ``` -## API Signature +## API signature ### Parameters @@ -45,11 +45,11 @@ export default Run;`} showSections={["returns"]} /> -`run.returnValue` polls until the run completes, handling [`WorkflowRunNotCompletedError`](/docs/api-reference/workflow-errors/workflow-run-not-completed-error) internally, and throws [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) if the run was cancelled. +`run.returnValue` polls until the run completes, handling [`WorkflowRunNotCompletedError`](/docs/api-reference/workflow-errors/workflow-run-not-completed-error) internally, and throws [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) if the run was canceled. #### WorkflowReadableStream -`run.getReadable()` returns a `WorkflowReadableStream` — a standard `ReadableStream` extended with a `getTailIndex()` helper: +`run.getReadable()` returns a `WorkflowReadableStream`, a standard `ReadableStream` extended with a `getTailIndex()` helper: Start/enqueue a new workflow run. @@ -30,5 +28,5 @@ The API package is for access and introspection of workflow data to inspect runs - Looking for `getWorld()` and the World SDK? They are exported from `workflow/runtime` — see the [`workflow/runtime` reference](/docs/api-reference/workflow-runtime). + Looking for `getWorld()` and the World SDK? They are exported from `workflow/runtime`. See the [`workflow/runtime` reference](/docs/api-reference/workflow-runtime). diff --git a/docs/content/docs/v4/api-reference/workflow-api/resume-hook.mdx b/docs/content/docs/v4/api-reference/workflow-api/resume-hook.mdx index 6a1372d739..a8e5c5b186 100644 --- a/docs/content/docs/v4/api-reference/workflow-api/resume-hook.mdx +++ b/docs/content/docs/v4/api-reference/workflow-api/resume-hook.mdx @@ -35,7 +35,7 @@ export async function POST(request: Request) { } ``` -## API Signature +## API signature ### Parameters @@ -59,7 +59,7 @@ showSections={["returns"]} ## Examples -### Basic API Route +### Basic API route Using `resumeHook` in a basic API route to resume a hook: @@ -82,7 +82,7 @@ export async function POST(request: Request) { } ``` -### With Type Safety +### With type safety Defining a payload type and using `resumeHook` to resume a hook with type safety: @@ -110,7 +110,7 @@ export async function POST(request: Request) { } ``` -### Server Action (Next.js) +### Server action (Next.js) Using `resumeHook` in Next.js server actions to resume a hook: @@ -129,7 +129,7 @@ export async function approveRequest(token: string, approved: boolean) { } ``` -### Webhook Handler +### Webhook handler Using `resumeHook` in a generic webhook handler to resume a hook: @@ -156,11 +156,11 @@ export async function POST(request: Request) { } ``` -### Resume or Start +### Resume or start -A common endpoint shape is "resume or start": one route that resumes the active workflow run for a business key if one exists, or starts a new run otherwise. This comes up when the workflow uses a deterministic hook token as its idempotency key — for example, one active run per order or conversation. +A common endpoint shape is "resume or start": one route that resumes the active workflow run for a business key if one exists, or starts a new run otherwise. This comes up when the workflow uses a deterministic hook token as its idempotency key, for example, one active run per order or conversation. -`resumeHook()` is the resume half of that flow. Try it first; if it throws `HookNotFoundError`, no active run owns the token yet, so start the workflow. One subtlety: `start()` returns before the new run executes and registers its hook, so you cannot resume immediately after starting. Retry the resume until the hook is registered — if you drop the payload and only start the workflow, the data from this request is lost. +`resumeHook()` is the resume half of that flow. Try it first; if it throws `HookNotFoundError`, no active run owns the token yet, so start the workflow. One subtlety: `start()` returns before the new run executes and registers its hook, so you cannot resume immediately after starting. Retry the resume until the hook is registered: if you drop the payload and only start the workflow, the data from this request is lost. ```typescript lineNumbers import { resumeHook, start } from "workflow/api"; @@ -211,9 +211,9 @@ export async function POST(request: Request) { See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how the workflow claims the token with `hook.getConflict()` and how concurrent starts converge on one active owner. -## Related Functions +## Related functions -- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) - Get hook details before resuming. -- [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper. -- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts. +- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token): Get hook details before resuming. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper. +- [Idempotency](/docs/foundations/idempotency): Deduplicate step side effects and workflow starts. diff --git a/docs/content/docs/v4/api-reference/workflow-api/resume-webhook.mdx b/docs/content/docs/v4/api-reference/workflow-api/resume-webhook.mdx index b4ee9f2008..c0c3f45e6c 100644 --- a/docs/content/docs/v4/api-reference/workflow-api/resume-webhook.mdx +++ b/docs/content/docs/v4/api-reference/workflow-api/resume-webhook.mdx @@ -37,7 +37,7 @@ export async function POST(request: Request) { } ``` -## API Signature +## API signature ### Parameters @@ -56,7 +56,7 @@ Returns a `Promise` that resolves to: Throws an error if the webhook token is not found or invalid. -## Usage Note +## Usage note In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a random webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL. @@ -88,7 +88,7 @@ export async function POST(request: Request) { } ``` -## Related Functions +## Related functions - [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Create a webhook in a workflow - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with arbitrary payload diff --git a/docs/content/docs/v4/api-reference/workflow-api/start.mdx b/docs/content/docs/v4/api-reference/workflow-api/start.mdx index c347ce1452..5d6a701ac8 100644 --- a/docs/content/docs/v4/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v4/api-reference/workflow-api/start.mdx @@ -9,7 +9,7 @@ related: - /docs/foundations/idempotency --- -Start/enqueue a new workflow run. +`start()` enqueues a new workflow run and returns a `Run` object. ```typescript lineNumbers import { start } from "workflow/api"; @@ -18,7 +18,7 @@ import { myWorkflow } from "./workflows/my-workflow"; const run = await start(myWorkflow); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -50,14 +50,14 @@ showSections={["returns"]} Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-api/get-run#workflowreadablestreamoptions). -## Good to Know +## Good to know -* The `start()` function is used in runtime/non-workflow contexts to programmatically trigger workflow executions. -* This is different from calling workflow functions directly, which is the typical pattern in Next.js applications. -* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete. -* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency). -* All arguments must be [serializable](/docs/foundations/serialization). -* When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments. +- The `start()` function is used in runtime contexts outside workflows to programmatically trigger workflow executions. +- This is different from calling workflow functions directly, which is the typical pattern in Next.js applications. +- The function returns immediately after enqueuing the workflow. It doesn't wait for the workflow to complete. +- Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency). +- All arguments must be [serializable](/docs/foundations/serialization). +- When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments. If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function). @@ -65,7 +65,7 @@ If `start()` throws `'start' received an invalid workflow function. Ensure the W ## Examples -### With Arguments +### With arguments ```typescript import { start } from "workflow/api"; @@ -99,7 +99,7 @@ const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight] ``` -The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment — the same production target for production deployments, or the same git branch for preview deployments. +The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment: the same production target for production deployments, or the same git branch for preview deployments. In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development. @@ -108,5 +108,5 @@ In Worlds without atomic, immutable deployments (such as local development or se When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility: - **Workflow identity**: The workflow ID is derived from the function name and file path. If the latest deployment has renamed the workflow function or moved it to a different directory, the workflow ID will no longer match and the run will fail to start. -- **Input and output compatibility**: The arguments passed to `start()` are serialized by the calling deployment but deserialized by the target deployment. Similarly, the workflow's return value is serialized by the target deployment but deserialized by the caller. If the workflow's expected arguments or return type have changed (e.g. added required fields, removed fields, or changed types), the run may fail or behave unexpectedly. Ensure that input and output schemas remain backward-compatible across deployments. +- **Input and output compatibility**: The arguments passed to `start()` are serialized by the calling deployment but deserialized by the target deployment. Similarly, the workflow's return value is serialized by the target deployment but deserialized by the caller. If the workflow's expected arguments or return type have changed (for example, added required fields, removed fields, or changed types), the run may fail or behave unexpectedly. Ensure that input and output schemas remain backward-compatible across deployments. diff --git a/docs/content/docs/v4/api-reference/workflow-astro/workflow.mdx b/docs/content/docs/v4/api-reference/workflow-astro/workflow.mdx index 40c9f32259..0fc23af882 100644 --- a/docs/content/docs/v4/api-reference/workflow-astro/workflow.mdx +++ b/docs/content/docs/v4/api-reference/workflow-astro/workflow.mdx @@ -24,9 +24,9 @@ export default defineConfig({ }); ``` -The integration registers the workflow Vite transform plugins during `astro:config:setup` and builds the workflow bundles — locally during config setup, or via the Vercel builder after `astro:build:done` when deploying to Vercel. +The integration registers the workflow Vite transform plugins during `astro:config:setup` and builds the workflow bundles: locally during config setup, or via the Vercel builder after `astro:build:done` when deploying to Vercel. -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow-errors/entity-conflict-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/entity-conflict-error.mdx index d249089853..7d464aa0ad 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/entity-conflict-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/entity-conflict-error.mdx @@ -26,12 +26,12 @@ try { await world.events.create(runId, event); } catch (error) { if (EntityConflictError.is(error)) { // [!code highlight] - // Event already exists — safe to ignore during replay + // Event already exists, safe to ignore during replay } } ``` -## API Signature +## API signature ### Properties @@ -44,11 +44,11 @@ interface EntityConflictError { export default EntityConflictError;`} /> -### Static Methods +### Static methods #### `EntityConflictError.is(value)` -Type-safe check for `EntityConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `EntityConflictError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { EntityConflictError } from "workflow/errors" diff --git a/docs/content/docs/v4/api-reference/workflow-errors/hook-conflict-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/hook-conflict-error.mdx index 90467b1864..4bd66f511d 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/hook-conflict-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/hook-conflict-error.mdx @@ -9,7 +9,7 @@ related: - /docs/errors/hook-conflict --- -`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows — see the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. +`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. ```typescript lineNumbers import { HookConflictError } from "workflow/errors" @@ -27,7 +27,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -44,11 +44,11 @@ interface HookConflictError { export default HookConflictError;`} /> -### Static Methods +### Static methods #### `HookConflictError.is(value)` -Type-safe check for `HookConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `HookConflictError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { HookConflictError } from "workflow/errors" diff --git a/docs/content/docs/v4/api-reference/workflow-errors/hook-not-found-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/hook-not-found-error.mdx index 20dd65095d..f41b3c9513 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/hook-not-found-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/hook-not-found-error.mdx @@ -10,9 +10,9 @@ related: `HookNotFoundError` is thrown when calling `resumeHook()` or `resumeWebhook()` with a token that does not match any active hook. This typically happens when: -- The hook has expired (past its TTL) -- The hook was already consumed and disposed -- The workflow has not started yet, so the hook does not exist +- The hook's time to live (TTL) has expired. +- The hook was already consumed and disposed. +- The workflow has not started yet, so the hook does not exist. ```typescript lineNumbers import { HookNotFoundError } from "workflow/errors" @@ -29,7 +29,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -44,11 +44,11 @@ interface HookNotFoundError { export default HookNotFoundError;`} /> -### Static Methods +### Static methods #### `HookNotFoundError.is(value)` -Type-safe check for `HookNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `HookNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { HookNotFoundError } from "workflow/errors" @@ -66,7 +66,7 @@ if (HookNotFoundError.is(error)) { A common pattern for idempotent workflows is to try resuming a hook, and if it doesn't exist yet, start a new workflow run with the input data. -This "resume or start" pattern is not atomic — there is a small window where a race condition is possible. A better native approach is being worked on, but this pattern works well for many use cases. +This "resume or start" pattern is not atomic: there is a small window where a race condition is possible. A better native approach is being worked on, but this pattern works well for many use cases. ```typescript lineNumbers @@ -80,7 +80,7 @@ async function handleIncomingEvent(token: string, data: unknown) { await resumeHook(token, data); } catch (error) { if (HookNotFoundError.is(error)) { // [!code highlight] - // Hook doesn't exist yet — start a new workflow run + // Hook doesn't exist yet, so start a new workflow run await startWorkflow("processEvent", data); // [!code highlight] } else { throw error; diff --git a/docs/content/docs/v4/api-reference/workflow-errors/index.mdx b/docs/content/docs/v4/api-reference/workflow-errors/index.mdx index 7669d37b2c..6e9963d965 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/index.mdx @@ -11,7 +11,7 @@ API reference for the error classes exported from the `workflow/errors` package. All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow-error), so you can catch any SDK error with a single `instanceof` check, or narrow to a specific class for fine-grained handling. Failures from workflow storage backends extend [`WorkflowWorldError`](/docs/api-reference/workflow-errors/workflow-world-error). -## Base Classes +## Base classes @@ -22,7 +22,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Registration Errors +## Registration errors @@ -33,7 +33,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Run Errors +## Run errors @@ -43,7 +43,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow Thrown when awaiting the return value of a failed workflow run. - Thrown when awaiting the return value of a cancelled workflow run. + Thrown when awaiting the return value of a canceled workflow run. Thrown when requesting the result of a workflow run that has not completed yet. @@ -59,7 +59,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Hook Errors +## Hook errors @@ -70,7 +70,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Backend Errors +## Backend errors diff --git a/docs/content/docs/v4/api-reference/workflow-errors/run-expired-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/run-expired-error.mdx index aa9e6eb4a7..0dc63a8273 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/run-expired-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/run-expired-error.mdx @@ -29,7 +29,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,7 +42,7 @@ interface RunExpiredError { export default RunExpiredError;`} /> -### Static Methods +### Static methods #### `RunExpiredError.is(value)` diff --git a/docs/content/docs/v4/api-reference/workflow-errors/run-not-supported-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/run-not-supported-error.mdx index d7789f857b..6730bb5a2b 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/run-not-supported-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/run-not-supported-error.mdx @@ -7,7 +7,7 @@ related: - /docs/foundations/versioning --- -`RunNotSupportedError` is thrown when reading a workflow run whose data was written with a newer workflow spec version than the running SDK supports. This typically means the run was created by a newer version of the `workflow` package — upgrade the package to process it. +`RunNotSupportedError` is thrown when reading a workflow run whose data was written with a newer workflow spec version than the running SDK supports. This typically means the run was created by a newer version of the `workflow` package. Upgrade the package to process it. ```typescript lineNumbers import { RunNotSupportedError } from "workflow/errors" @@ -25,7 +25,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,11 +42,11 @@ interface RunNotSupportedError { export default RunNotSupportedError;`} /> -### Static Methods +### Static methods #### `RunNotSupportedError.is(value)` -Type-safe check for `RunNotSupportedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `RunNotSupportedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { RunNotSupportedError } from "workflow/errors" diff --git a/docs/content/docs/v4/api-reference/workflow-errors/step-not-registered-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/step-not-registered-error.mdx index a4c4ec355b..9a34ade775 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/step-not-registered-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/step-not-registered-error.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-errors/workflow-not-registered-error --- -`StepNotRegisteredError` is thrown when the runtime tries to execute a step function that is not registered in the current deployment. This is an infrastructure error — not a user code error. It typically indicates a build or bundling issue that caused the step to not be included in the deployment. +`StepNotRegisteredError` is thrown when the runtime tries to execute a step function that is not registered in the current deployment. This is an infrastructure error, not a user code error. It typically indicates a build or bundling issue that caused the step to not be included in the deployment. When this error occurs, the step fails (like a `FatalError`) and control is passed back to the workflow function, which can handle the failure gracefully. @@ -21,7 +21,7 @@ if (StepNotRegisteredError.is(error)) { // [!code highlight] } ``` -## API Signature +## API signature ### Properties @@ -36,14 +36,14 @@ interface StepNotRegisteredError { export default StepNotRegisteredError;`} /> -### Static Methods +### Static methods #### `StepNotRegisteredError.is(value)` -Type-safe check for `StepNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `StepNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. -The `.is()` method works in server-side Node.js code (API routes, middleware, hooks). Inside `"use workflow"` functions, step errors arrive deserialized from the event log and won't be actual `StepNotRegisteredError` instances — use `error.message` matching instead. See the [troubleshooting page](/docs/errors/step-not-registered) for workflow-side error handling examples. +The `.is()` method works in server-side Node.js code (API routes, middleware, hooks). Inside `"use workflow"` functions, step errors arrive deserialized from the event log and won't be actual `StepNotRegisteredError` instances. Use `error.message` matching instead. See the [troubleshooting page](/docs/errors/step-not-registered) for workflow-side error handling examples. ```typescript diff --git a/docs/content/docs/v4/api-reference/workflow-errors/throttle-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/throttle-error.mdx index 4d7bc7596c..e354586219 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/throttle-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/throttle-error.mdx @@ -31,7 +31,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -46,7 +46,7 @@ interface ThrottleError { export default ThrottleError;`} /> -### Static Methods +### Static methods #### `ThrottleError.is(value)` diff --git a/docs/content/docs/v4/api-reference/workflow-errors/too-early-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/too-early-error.mdx index e333cbd0b7..1ad6b5e82d 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/too-early-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/too-early-error.mdx @@ -31,7 +31,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -46,7 +46,7 @@ interface TooEarlyError { export default TooEarlyError;`} /> -### Static Methods +### Static methods #### `TooEarlyError.is(value)` diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-error.mdx index 21c5dec3dc..b373da4c67 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-error.mdx @@ -17,7 +17,7 @@ const error = new WorkflowError("something went wrong", { }); ``` -## API Signature +## API signature ### Properties @@ -32,14 +32,14 @@ interface WorkflowError { export default WorkflowError;`} /> -### Static Methods +### Static methods #### `WorkflowError.is(value)` -Type-safe check for `WorkflowError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. - `WorkflowError.is()` matches only direct `WorkflowError` instances — not subclasses, which override the error name it checks. To handle a specific error type, use that subclass's own `.is()` method (e.g. `WorkflowRunFailedError.is(error)`). + `WorkflowError.is()` matches only direct `WorkflowError` instances, not subclasses, which override the error name it checks. To handle a specific error type, use that subclass's own `.is()` method (e.g. `WorkflowRunFailedError.is(error)`). ```typescript diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-not-registered-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-not-registered-error.mdx index b2eee91ead..34c8ea35e5 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-not-registered-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-not-registered-error.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-errors/step-not-registered-error --- -`WorkflowNotRegisteredError` is thrown when the runtime tries to execute a workflow function that is not registered in the current deployment. This is an infrastructure error — not a user code error. It typically means a run was started against a deployment that does not have this workflow (e.g., the workflow was renamed or moved), or there was a build/bundling issue. +`WorkflowNotRegisteredError` is thrown when the runtime tries to execute a workflow function that is not registered in the current deployment. This is an infrastructure error, not a user code error. It typically means a run was started against a deployment that does not have this workflow (e.g., the workflow was renamed or moved), or there was a build/bundling issue. When this error occurs, the run fails with a `RUNTIME_ERROR` error code. @@ -21,7 +21,7 @@ if (WorkflowNotRegisteredError.is(error)) { // [!code highlight] } ``` -## API Signature +## API signature ### Properties @@ -36,14 +36,14 @@ interface WorkflowNotRegisteredError { export default WorkflowNotRegisteredError;`} /> -### Static Methods +### Static methods #### `WorkflowNotRegisteredError.is(value)` -Type-safe check for `WorkflowNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. -The `.is()` method works in server-side Node.js code (API routes, middleware). When checking the error from `run.returnValue`, use `WorkflowRunFailedError.is()` and inspect `error.cause` — the underlying error is deserialized from the event log. +The `.is()` method works in server-side Node.js code (API routes, middleware). When checking the error from `run.returnValue`, use `WorkflowRunFailedError.is()` and inspect `error.cause`: the underlying error is deserialized from the event log. ```typescript @@ -54,4 +54,3 @@ if (WorkflowNotRegisteredError.is(error)) { // error is typed as WorkflowNotRegisteredError } ``` - diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-cancelled-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-cancelled-error.mdx index 3c3a6bc5ed..4e3d2823c6 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-cancelled-error.mdx @@ -1,14 +1,14 @@ --- title: WorkflowRunCancelledError -description: Thrown when awaiting the return value of a cancelled workflow run. +description: Thrown when awaiting the return value of a canceled workflow run. type: reference -summary: Catch WorkflowRunCancelledError when awaiting run.returnValue on a run that was cancelled. +summary: Catch WorkflowRunCancelledError when awaiting run.returnValue on a run that was canceled. related: - /docs/api-reference/workflow-errors/workflow-run-failed-error - /docs/api-reference/workflow-errors/workflow-run-not-found-error --- -`WorkflowRunCancelledError` is thrown when awaiting `run.returnValue` on a workflow run that was explicitly cancelled via `run.cancel()`. Cancelled runs do not produce a return value. +`WorkflowRunCancelledError` is thrown when awaiting `run.returnValue` on a workflow run that was explicitly canceled via `run.cancel()`. Canceled runs do not produce a return value. You can check for cancellation before awaiting by inspecting `run.status`. @@ -25,14 +25,14 @@ try { } ``` -## API Signature +## API signature ### Properties -### Static Methods +### Static methods #### `WorkflowRunCancelledError.is(value)` diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-failed-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-failed-error.mdx index 6ce8d42cf0..bf08c9a0d6 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-failed-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-failed-error.mdx @@ -11,7 +11,7 @@ related: `WorkflowRunFailedError` is thrown when awaiting `run.returnValue` on a workflow run whose status is `'failed'`. This indicates that the workflow encountered a fatal error during execution and cannot produce a return value. -The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown` — narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property. +The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown`, so narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property. ```typescript lineNumbers import { WorkflowRunFailedError } from "workflow/errors" @@ -31,7 +31,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -45,7 +45,7 @@ interface WorkflowRunFailedError { * the workflow serialization pipeline. Preserves the original type identity * (Error subclasses, FatalError, custom classes with WORKFLOW_SERIALIZE, * etc.) and custom properties. Typed as \`unknown\` because any value can - * be thrown — narrow with \`instanceof Error\` before accessing fields. + * be thrown, so narrow with \`instanceof Error\` before accessing fields. */ cause: unknown; /** The high-level error category (e.g. \`USER_ERROR\`, \`RUNTIME_ERROR\`). */ @@ -56,11 +56,11 @@ interface WorkflowRunFailedError { export default WorkflowRunFailedError;`} /> -### Static Methods +### Static methods #### `WorkflowRunFailedError.is(value)` -Type-safe check for `WorkflowRunFailedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowRunFailedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { WorkflowRunFailedError } from "workflow/errors" diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-completed-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-completed-error.mdx index a50c8c814f..af4b904e79 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-completed-error.mdx @@ -9,7 +9,7 @@ related: `WorkflowRunNotCompletedError` is thrown when requesting the result of a workflow run that has not completed yet. The run's current status (for example `pending` or `running`) is available on the error. -[`run.returnValue()`](/docs/api-reference/workflow-api/get-run) handles this error internally — it polls until the run completes — so you will mainly encounter it when building custom polling logic on lower-level APIs. +[`run.returnValue()`](/docs/api-reference/workflow-api/get-run) handles this error internally (it polls until the run completes), so you will mainly encounter it when building custom polling logic on lower-level APIs. ```typescript lineNumbers import { WorkflowRunNotCompletedError } from "workflow/errors" @@ -25,7 +25,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,11 +42,11 @@ interface WorkflowRunNotCompletedError { export default WorkflowRunNotCompletedError;`} /> -### Static Methods +### Static methods #### `WorkflowRunNotCompletedError.is(value)` -Type-safe check for `WorkflowRunNotCompletedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowRunNotCompletedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { WorkflowRunNotCompletedError } from "workflow/errors" diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-found-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-found-error.mdx index b27d7321c7..e997b6a2c9 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-found-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-run-not-found-error.mdx @@ -10,7 +10,7 @@ related: `WorkflowRunNotFoundError` is thrown when performing operations on a workflow run that does not exist. This includes calling methods like `run.status`, `run.cancel()`, or awaiting `run.returnValue` on a run whose ID does not match any known workflow run. -Note that `getRun(id)` itself is synchronous and will not throw — the error is raised when subsequent operations on the run object discover the run is missing. +`getRun(id)` itself is synchronous and will not throw. Subsequent operations on the run object raise the error when they discover the run is missing. ```typescript lineNumbers import { WorkflowRunNotFoundError } from "workflow/errors" @@ -25,7 +25,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -40,11 +40,11 @@ interface WorkflowRunNotFoundError { export default WorkflowRunNotFoundError;`} /> -### Static Methods +### Static methods #### `WorkflowRunNotFoundError.is(value)` -Type-safe check for `WorkflowRunNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowRunNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { WorkflowRunNotFoundError } from "workflow/errors" diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-runtime-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-runtime-error.mdx index 77463afb2d..9cadbcf90d 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-runtime-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-runtime-error.mdx @@ -27,7 +27,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,7 +42,7 @@ interface WorkflowRuntimeError { export default WorkflowRuntimeError;`} /> -### Static Methods +### Static methods #### `WorkflowRuntimeError.is(value)` diff --git a/docs/content/docs/v4/api-reference/workflow-errors/workflow-world-error.mdx b/docs/content/docs/v4/api-reference/workflow-errors/workflow-world-error.mdx index ac12e75ef3..ea6c2622b2 100644 --- a/docs/content/docs/v4/api-reference/workflow-errors/workflow-world-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow-errors/workflow-world-error.mdx @@ -12,7 +12,7 @@ related: `WorkflowWorldError` is the base error class for failures originating from a workflow world (storage backend). World implementations (local, Postgres, Vercel) throw subclasses of this error when storage operations fail. -You can use `instanceof WorkflowWorldError` to catch any world-related error regardless of the specific type. Note that the static `.is()` method only matches errors constructed directly as `WorkflowWorldError` — use the subclass-specific `.is()` methods (e.g. `EntityConflictError.is()`) to match specific error types. +You can use `instanceof WorkflowWorldError` to catch any World-related error regardless of the specific type. The static `.is()` method only matches errors constructed directly as `WorkflowWorldError`. Use the subclass-specific `.is()` methods (for example, `EntityConflictError.is()`) to match specific error types. Most world errors are handled automatically by the Workflow runtime. You will typically only encounter these errors when interacting with world storage APIs directly or when there are infrastructure-level issues. @@ -33,7 +33,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -54,11 +54,11 @@ interface WorkflowWorldError { export default WorkflowWorldError;`} /> -### Static Methods +### Static methods #### `WorkflowWorldError.is(value)` -Type-safe check that matches only errors constructed directly as `WorkflowWorldError`. Does not match subclasses like `EntityConflictError` — use `instanceof` to catch all world errors, or the subclass-specific `.is()` methods. +Type-safe check that matches only errors constructed directly as `WorkflowWorldError`. Does not match subclasses like `EntityConflictError`. Use `instanceof` to catch all world errors, or the subclass-specific `.is()` methods. ```typescript import { WorkflowWorldError } from "workflow/errors" @@ -73,7 +73,7 @@ if (WorkflowWorldError.is(error)) { The following error types extend `WorkflowWorldError`: -- [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error) — operation conflicts with entity state -- [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error) — run has expired -- [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error) — request made before system is ready -- [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error) — request was rate-limited +- [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error): operation conflicts with entity state +- [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error): run has expired +- [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error): request made before system is ready +- [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error): request was rate-limited diff --git a/docs/content/docs/v4/api-reference/workflow-globals.mdx b/docs/content/docs/v4/api-reference/workflow-globals.mdx index 6ec32b6b0b..f07074befa 100644 --- a/docs/content/docs/v4/api-reference/workflow-globals.mdx +++ b/docs/content/docs/v4/api-reference/workflow-globals.mdx @@ -22,29 +22,29 @@ These APIs are available but are **seeded or fixed** to ensure deterministic beh | API | Behavior | |-----|----------| -| [`Math.random()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) | Seeded random number generator — same seed produces the same sequence every replay | +| [`Math.random()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) | Seeded random number generator: same seed produces the same sequence every replay | | [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) / `Date.now()` / `new Date()` | Returns a fixed timestamp that advances with the workflow's logical clock | -| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded — produces deterministic output for a given workflow run | -| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded — produces deterministic UUIDs for a given workflow run | +| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded: produces deterministic output for a given workflow run | +| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded: produces deterministic UUIDs for a given workflow run | | [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Passes through to the real implementation (SHA-256, etc. are deterministic by nature) | You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays. -## Web Platform APIs +## Web platform APIs These standard Web APIs are available in workflow functions: - [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) - [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) / [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) - [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) / [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) -- [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) — custom implementations with [special behavior in the workflow context](/docs/foundations/serialization#request--response). Body methods like `.json()` and `.text()` are automatically treated as step invocations. +- [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response): custom implementations with [special behavior in the workflow context](/docs/foundations/serialization#request--response). Body methods like `.json()` and `.text()` are automatically treated as step invocations. - [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console) - [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone) - [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) / [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) -## Environment Variables +## Environment variables `process.env` is available as a **read-only, frozen** snapshot of the environment variables at the time the workflow was started. You cannot modify it. @@ -53,11 +53,11 @@ export async function myWorkflow() { "use workflow"; const apiKey = process.env.API_KEY; // works - process.env.FOO = "bar"; // throws — process.env is frozen + process.env.FOO = "bar"; // throws: process.env is frozen } ``` -## Binary Data +## Binary data Standard JavaScript typed arrays (`Uint8Array`, `Int32Array`, `Float64Array`, etc.) are available in workflow functions. @@ -92,7 +92,7 @@ target.setFromHex("48656c6c6f"); // { read: 10, written: 5 } These methods are polyfilled in the workflow environment. When the JavaScript runtime ships native support, the polyfill is automatically bypassed. -## Not Available +## Not available The following are **not available** in workflow functions. Move this logic to [step functions](/docs/foundations/workflows-and-steps#step-functions) instead. diff --git a/docs/content/docs/v4/api-reference/workflow-nest/configure-workflow-controller.mdx b/docs/content/docs/v4/api-reference/workflow-nest/configure-workflow-controller.mdx index 670dbbab92..d7b7ab8d61 100644 --- a/docs/content/docs/v4/api-reference/workflow-nest/configure-workflow-controller.mdx +++ b/docs/content/docs/v4/api-reference/workflow-nest/configure-workflow-controller.mdx @@ -9,7 +9,7 @@ prerequisites: Configures the output directory that [`WorkflowController`](/docs/api-reference/workflow-nest/workflow-controller) loads the generated workflow bundles (`steps.mjs`, `workflows.mjs`, `webhook.mjs`, `manifest.json`) from. -[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) calls this for you with its resolved `outDir` — call it yourself only when registering `WorkflowController` manually. The controller's route handlers throw if no directory has been configured. +[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) calls this for you with its resolved `outDir`. Call it yourself only when registering `WorkflowController` manually. The controller's route handlers throw if no directory has been configured. ## Usage @@ -20,7 +20,7 @@ import { configureWorkflowController } from "workflow/nest"; // [!code highlight configureWorkflowController(join(process.cwd(), ".nestjs/workflow")); // [!code highlight] ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow-nest/nest-local-builder.mdx b/docs/content/docs/v4/api-reference/workflow-nest/nest-local-builder.mdx index e37707119b..e99c9a7252 100644 --- a/docs/content/docs/v4/api-reference/workflow-nest/nest-local-builder.mdx +++ b/docs/content/docs/v4/api-reference/workflow-nest/nest-local-builder.mdx @@ -7,7 +7,7 @@ prerequisites: - /docs/getting-started/nestjs --- -Builder that scans a NestJS project for workflow files and compiles them into the step, workflow, and webhook bundles plus a manifest. [`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) creates and runs one automatically on startup — instantiate it yourself only when you need to build bundles outside the module lifecycle (e.g. a custom build script for production with `skipBuild`). +Builder that scans a NestJS project for workflow files and compiles them into the step, workflow, and webhook bundles plus a manifest. [`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) creates and runs one automatically on startup. Instantiate it yourself only when you need to build bundles outside the module lifecycle, for example, in a custom production build script with `skipBuild`. ## Usage @@ -23,7 +23,7 @@ await builder.build(); // [!code highlight] console.log(`Workflow bundles written to ${builder.outDir}`); ``` -## API Signature +## API signature ### Constructor @@ -43,7 +43,7 @@ console.log(`Workflow bundles written to ${builder.outDir}`); | `dirs` | `string[]` | `['src']` | Directories to scan for workflow files. | | `outDir` | `string` | `'.nestjs/workflow'` (relative to `workingDir`) | Output directory for generated workflow bundles. | | `watch` | `boolean` | `false` | Enable watch mode for development. | -| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. When `'commonjs'`, the builder rewrites externalized imports in the steps bundle to use `require()` via `createRequire`, avoiding ESM/CJS named-export interop issues with SWC's output. | +| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. When `'commonjs'`, the builder rewrites externalized imports in the steps bundle to use `require()` through `createRequire`, avoiding ECMAScript module (ESM) and CommonJS (CJS) named-export interop issues with SWC's output. | | `distDir` | `string` | `'dist'` | Directory where NestJS compiles `.ts` source files to `.js` (relative to `workingDir`). Used when `moduleType` is `'commonjs'` to resolve compiled file paths. Should match the `outDir` in your `tsconfig.json`. | ### Methods @@ -56,7 +56,7 @@ Builds the workflow bundles. Writes `steps.mjs`, `workflows.mjs`, `webhook.mjs`, #### `outDir` -Read-only getter that returns the output directory for generated workflow bundles — the `outDir` option as passed, or the default `.nestjs/workflow` resolved against `workingDir`. +Read-only getter that returns the output directory for generated workflow bundles: the `outDir` option as passed, or the default `.nestjs/workflow` resolved against `workingDir`. ### Returns diff --git a/docs/content/docs/v4/api-reference/workflow-nest/workflow-controller.mdx b/docs/content/docs/v4/api-reference/workflow-nest/workflow-controller.mdx index c6382b7dad..530cf901a5 100644 --- a/docs/content/docs/v4/api-reference/workflow-nest/workflow-controller.mdx +++ b/docs/content/docs/v4/api-reference/workflow-nest/workflow-controller.mdx @@ -9,7 +9,7 @@ prerequisites: NestJS controller that handles the well-known workflow endpoints under `.well-known/workflow/v1`. It dynamically imports the generated workflow bundles and converts between Express/Fastify requests and the Web API `Request`/`Response` objects the workflow runtime expects. Both the Express and Fastify HTTP adapters are supported. -[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) registers this controller automatically — you only register it yourself if you are not using `WorkflowModule`. +[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) registers this controller automatically. You only register it yourself if you are not using `WorkflowModule`. ## Usage diff --git a/docs/content/docs/v4/api-reference/workflow-nest/workflow-module.mdx b/docs/content/docs/v4/api-reference/workflow-nest/workflow-module.mdx index 5491e6b4d5..09d6d631a7 100644 --- a/docs/content/docs/v4/api-reference/workflow-nest/workflow-module.mdx +++ b/docs/content/docs/v4/api-reference/workflow-nest/workflow-module.mdx @@ -40,9 +40,9 @@ import { WorkflowModule } from "workflow/nest"; export class AppModule {} ``` -## API Signature +## API signature -### Static Methods +### Static methods #### `forRoot(options?)` @@ -56,7 +56,7 @@ Configures the module and returns a NestJS `DynamicModule` registered as `global #### WorkflowModuleOptions -Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-builder#nestbuilderoptions) — all builder options are accepted, plus `skipBuild`: +Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-builder#nestbuilderoptions): all builder options are accepted, plus `skipBuild`: | Option | Type | Default | Description | | --- | --- | --- | --- | @@ -65,7 +65,7 @@ Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-buil | `dirs` | `string[]` | `['src']` | Directories to scan for workflow files. | | `outDir` | `string` | `'.nestjs/workflow'` (relative to `workingDir`) | Output directory for generated workflow bundles. | | `watch` | `boolean` | `false` | Enable watch mode for development. | -| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. Set to `'commonjs'` if your NestJS project compiles to CJS via SWC. | +| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. Set to `'commonjs'` if your NestJS project compiles to CommonJS (CJS) through SWC. | | `distDir` | `string` | `'dist'` | Directory where NestJS compiles `.ts` source files to `.js` (relative to `workingDir`). Used when `moduleType` is `'commonjs'`. Should match the `outDir` in your `tsconfig.json`. | ### Returns diff --git a/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx index c314a01077..0d147978fe 100644 --- a/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx +++ b/docs/content/docs/v4/api-reference/workflow-next/with-workflow.mdx @@ -1,13 +1,13 @@ --- title: withWorkflow -description: Configure webpack/turbopack to transform workflow directives in Next.js. +description: Configure webpack and Turbopack to transform workflow directives in Next.js. type: reference summary: Wrap your Next.js config with withWorkflow to enable workflow directive transformation. prerequisites: - /docs/getting-started/next --- -Configures webpack/turbopack loaders to transform workflow code (`"use step"`/`"use workflow"` directives) +Configures webpack and Turbopack loaders to transform workflow code (`"use step"` and `"use workflow"` directives). ## Usage @@ -22,7 +22,7 @@ const nextConfig: NextConfig = { }; // not required but allows configuring workflow options -const workflowConfig = {} +const workflowConfig = {}; export default withWorkflow(nextConfig, workflowConfig); // [!code highlight] ``` @@ -36,7 +36,7 @@ Remove that package from `serverExternalPackages` in your `next.config` to silence the warning. -### Monorepos and Workspace Imports +### Monorepos and workspace imports By default, Next.js detects the correct workspace root automatically. If your Next.js app lives in a subdirectory such as `apps/web` and workspace resolution is not working correctly, you can set `outputFileTracingRoot` as a workaround: @@ -83,7 +83,7 @@ export default withWorkflow(nextConfig, { ### Source maps -The step bundle and intermediate workflow bundle default to `'inline'` source maps **in development** — so stack traces from step errors and workflow VM errors point at your source files — and to **`false` in production**, so function bundles stay small. The `sourcemap` option lets you change that: +The step bundle and intermediate workflow bundle default to `'inline'` source maps **in development** (so stack traces from step errors and workflow virtual machine (VM) errors point at your source files) and to **`false` in production**, so function bundles stay small. The `sourcemap` option lets you change that: | Value | Behavior | | --- | --- | @@ -93,10 +93,10 @@ The step bundle and intermediate workflow bundle default to `'inline'` source ma | `'both'` | Emit both inline and external source maps. | | `false` | Omit source maps entirely. | -In production, source maps are already off by default. Setting `sourcemap: false` explicitly also turns them off in development, and it drops the inline source map from every bundle while skipping the source-map-support runtime shim on the Vercel step function (the same behavior production gets by default) — the main lever for staying under the Vercel 250MB function size limit. The tradeoff is that workflow VM stack traces will reference generated code (e.g. `evalmachine.`) rather than your source files. +In production, source maps are already off by default. Setting `sourcemap: false` explicitly also turns them off in development, and it drops the inline source map from every bundle while skipping the source-map-support runtime shim on the Vercel step function (the same behavior production gets by default), the main lever for staying under the Vercel 250 MB function size limit. The tradeoff is that workflow VM stack traces will reference generated code (for example, `evalmachine.`) rather than your source files. -Setting `sourcemap` explicitly affects **all** generated bundles (steps, workflows, webhook). The legacy `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING=1` environment variable is narrower — it only toggles source maps on the final workflow wrapper and webhook bundle (which default to off). It continues to work, but new code should use the `sourcemap` option or the `WORKFLOW_SOURCEMAP` environment variable instead. +Setting `sourcemap` explicitly affects **all** generated bundles (steps, workflows, webhook). The legacy `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING=1` environment variable is narrower: it only toggles source maps on the final workflow wrapper and webhook bundle (which default to off). It continues to work, but new code should use the `sourcemap` option or the `WORKFLOW_SOURCEMAP` environment variable instead. The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable, which accepts the same values plus `'0'` / `'1'` as aliases for `false` / `true`. Precedence is: explicit config > `WORKFLOW_SOURCEMAP` > the environment-aware default (`'inline'` in development, `false` in production). Development is detected from `next dev` / `NODE_ENV=development`, so the config option and the env var both let you force either behavior in either environment. @@ -105,7 +105,7 @@ The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable, wh The `workflows.local` options only affect local development. When deployed to Vercel, the runtime ignores `local` settings and uses the Vercel world automatically. -## Exporting a Function +## Exporting a function If you are exporting a function in your `next.config` you will need to ensure you call the function returned from `withWorkflow`. @@ -134,4 +134,4 @@ export default async function config( } return nextConfig; } -``` +``` diff --git a/docs/content/docs/v4/api-reference/workflow-nitro/index.mdx b/docs/content/docs/v4/api-reference/workflow-nitro/index.mdx index afb4c05774..f931df6896 100644 --- a/docs/content/docs/v4/api-reference/workflow-nitro/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-nitro/index.mdx @@ -7,7 +7,7 @@ related: - /docs/getting-started/nitro --- -Nitro integration for Workflow SDK. The `workflow/nitro` entry point's default export is a [Nitro module](https://v3.nitro.build/guide/modules) — it has no callable API. You enable it by adding it to the `modules` array of your Nitro config and configure it via the `workflow` key. +Nitro integration for Workflow SDK. The `workflow/nitro` entry point's default export is a [Nitro module](https://v3.nitro.build/guide/modules): it has no callable API. You enable it by adding it to the `modules` array of your Nitro config and configure it via the `workflow` key. ## Usage @@ -26,10 +26,10 @@ When enabled, the module: - Builds the workflow, step, and webhook bundles, and rebuilds them on file changes in development. - Registers the workflow runtime routes under `/.well-known/workflow/v1/`. - Serves a redirect to the local observability dashboard at `/_workflow` in development. -- Configures Vercel function rules (queue triggers and `maxDuration`) for the workflow routes when deploying to Vercel. +- Configures function rules for Vercel Functions (queue triggers and `maxDuration`) on the workflow routes when deploying to Vercel. - Uses Nitro's `workspaceDir` as the workflow project root so monorepo apps can import sibling workspace packages without extra workflow config. -## Module Options +## Module options Options are read from the `workflow` key of your Nitro config. The option type is exported as `ModuleOptions`: @@ -50,8 +50,8 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | | `dirs` | `string[]` | — | Directories to scan for workflows and steps. By default, the `workflows/` directory is scanned from the project root and all layer source directories. | -| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense. | -| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). Only applies when deploying to Vercel. | +| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for integrated development environment (IDE) IntelliSense. | +| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (for example, `'nodejs22.x'` or `'nodejs24.x'`). Only applies when deploying to Vercel. | ## Vite-based Nitro diff --git a/docs/content/docs/v4/api-reference/workflow-nuxt/index.mdx b/docs/content/docs/v4/api-reference/workflow-nuxt/index.mdx index 191931c40d..d223774197 100644 --- a/docs/content/docs/v4/api-reference/workflow-nuxt/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-nuxt/index.mdx @@ -7,7 +7,7 @@ related: - /docs/getting-started/nuxt --- -Nuxt integration for Workflow SDK. The `workflow/nuxt` entry point's default export is a Nuxt module — it has no callable API. You enable it by adding it to the `modules` array of your Nuxt config and configure it via the `workflow` key. +Nuxt integration for Workflow SDK. The `workflow/nuxt` entry point's default export is a Nuxt module: it has no callable API. You enable it by adding it to the `modules` array of your Nuxt config and configure it via the `workflow` key. ## Usage @@ -23,11 +23,11 @@ export default defineNuxtConfig({ When enabled, the module: - Registers the [`workflow/nitro`](/docs/api-reference/workflow-nitro) module on Nuxt's Nitro server, which transforms `"use workflow"` and `"use step"` directives, builds the workflow bundles, and registers the workflow runtime routes under `/.well-known/workflow/v1/`. -- Configures Vite to bundle (rather than externalize) the Workflow SDK packages in SSR mode so workflow code is transformed correctly. -- Enables the `workflow` TypeScript plugin by default for IDE IntelliSense. +- Configures Vite to bundle (rather than externalize) the Workflow SDK packages in server-side rendering (SSR) mode so workflow code is transformed correctly. +- Enables the `workflow` TypeScript plugin by default for integrated development environment (IDE) IntelliSense. - Uses Nuxt/Nitro's detected `workspaceDir` so monorepo apps can import sibling workspace packages without extra workflow config. -## Module Options +## Module options Options are read from the `workflow` key of your Nuxt config. The option type is exported as `ModuleOptions`: diff --git a/docs/content/docs/v4/api-reference/workflow-observability/hydrate-data.mdx b/docs/content/docs/v4/api-reference/workflow-observability/hydrate-data.mdx index a53d2a9f4b..cf7baa48d1 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/hydrate-data.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/hydrate-data.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/observability-revivers --- -Hydrates (deserializes) a single value that was stored by the workflow runtime. This is the lower-level building block behind [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) — use it when you have a raw serialized value rather than a whole resource, such as a single field from an event payload. +Hydrates (deserializes) a single value that was stored by the workflow runtime. This is the lower-level building block behind [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io). Use it when you have a raw serialized value rather than a whole resource, such as a single field from an event payload. ```typescript lineNumbers import { hydrateData, observabilityRevivers } from "workflow/observability"; // [!code highlight] @@ -17,7 +17,7 @@ declare const serialized: unknown; // @setup const value = hydrateData(serialized, observabilityRevivers); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -30,6 +30,6 @@ const value = hydrateData(serialized, observabilityRevivers); // [!code highligh The hydrated plain JavaScript value. The input is handled by shape: -- Format-prefixed binary data (`Uint8Array`) is decoded and parsed from the [devalue](https://github.com/Rich-Harris/devalue) format -- Encrypted data is returned as-is (a raw `Uint8Array`) — see [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data) -- Already-plain values (numbers, strings, `null`) are returned unchanged +- Format-prefixed binary data (`Uint8Array`) is decoded and parsed from the [devalue](https://github.com/Rich-Harris/devalue) format. +- Encrypted data is returned as-is (a raw `Uint8Array`). See [Encrypted data](/docs/api-reference/workflow-observability#encrypted-data). +- Already-plain values (numbers, strings, and `null`) are returned unchanged. diff --git a/docs/content/docs/v4/api-reference/workflow-observability/hydrate-resource-io.mdx b/docs/content/docs/v4/api-reference/workflow-observability/hydrate-resource-io.mdx index 0cdd9577ad..fd8c1ea85e 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/hydrate-resource-io.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/hydrate-resource-io.mdx @@ -10,7 +10,7 @@ related: - /docs/api-reference/workflow-runtime/world/storage --- -Hydrates (deserializes) the data fields of a resource returned by the [World SDK](/docs/api-reference/workflow-runtime/world) — a workflow run, step, hook, or event. Workflow data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format, so this is required before displaying step input/output in a UI. +Hydrates (deserializes) the data fields of a resource returned by the [World SDK](/docs/api-reference/workflow-runtime/world): a workflow run, step, hook, or event. Workflow data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format, so this is required before displaying step input/output in a user interface. The function dispatches on the resource shape: steps get `input`/`output` hydrated, hooks get `metadata`, events get `eventData`, and runs get `input`/`output`. @@ -26,7 +26,7 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl console.log(hydrated.input, hydrated.output); ``` -## API Signature +## API signature ### Parameters @@ -40,12 +40,12 @@ console.log(hydrated.input, hydrated.output); The same resource with its data fields hydrated into plain JavaScript values. - Encrypted data fields pass through as raw `Uint8Array` values rather than being decrypted — see [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data). + Encrypted data fields pass through as raw `Uint8Array` values rather than being decrypted. See [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data). ## Examples -### Display a Run's Steps with Hydrated I/O +### Display a run's steps with hydrated I/O ```typescript lineNumbers import { getWorld } from "workflow/runtime"; diff --git a/docs/content/docs/v4/api-reference/workflow-observability/index.mdx b/docs/content/docs/v4/api-reference/workflow-observability/index.mdx index ef6740fb8e..2c2100d191 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/index.mdx @@ -1,6 +1,6 @@ --- title: "workflow/observability" -description: Utilities to hydrate serialized step I/O and parse machine-readable workflow names for display. +description: Utilities to hydrate serialized step input/output (I/O) and parse machine-readable workflow names for display. type: overview summary: Explore utilities for hydrating serialized workflow data and parsing display names in observability tools. keywords: @@ -16,9 +16,7 @@ keywords: - display name parsing --- -API reference for observability utilities from the `workflow/observability` package. - -The observability package provides utilities for working with workflow data in observability and debugging tools — hydrating serialized step I/O for display, and parsing machine-readable names into display-friendly formats. +The `workflow/observability` package provides utilities for observability and debugging tools. Use it to hydrate serialized step input/output (I/O) for display and parse machine-readable names into display-friendly formats. ```typescript lineNumbers import { // [!code highlight] @@ -31,7 +29,7 @@ import { // [!code highlight] } from "workflow/observability"; // [!code highlight] ``` -## Data Hydration +## Data hydration @@ -45,7 +43,7 @@ import { // [!code highlight] -## Name Parsing +## Name parsing @@ -59,6 +57,6 @@ import { // [!code highlight] -## Encrypted Data +## Encrypted data -When a [World](/docs/api-reference/workflow-runtime/world) stores encrypted data, the hydration utilities intentionally leave encrypted values untouched: [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data) and [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) return encrypted fields as raw `Uint8Array` values so observability tools can detect them and decide how to render them (for example, the Workflow CLI shows an "Encrypted" placeholder). Decryption is handled by the runtime and the World implementation — see [Encryption](/docs/how-it-works/encryption) for how keys are managed. +When a [World](/docs/api-reference/workflow-runtime/world) stores encrypted data, the hydration utilities intentionally leave encrypted values untouched: [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data) and [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) return encrypted fields as raw `Uint8Array` values so observability tools can detect them and decide how to render them (for example, the Workflow CLI shows an "Encrypted" placeholder). Decryption is handled by the runtime and the World implementation. See [Encryption](/docs/how-it-works/encryption) for how keys are managed. diff --git a/docs/content/docs/v4/api-reference/workflow-observability/observability-revivers.mdx b/docs/content/docs/v4/api-reference/workflow-observability/observability-revivers.mdx index 262e108336..daa65d04f8 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/observability-revivers.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/observability-revivers.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/hydrate-data --- -A set of reviver functions that handle the workflow serialization format's workflow-specific types — streams, step/workflow function references, class instances, `AbortController`/`AbortSignal`, and `DOMException` — reviving them as display-friendly marker objects or strings. Built-in JavaScript types (`Date`, `Map`, `Set`, `RegExp`, etc.) are handled by the devalue format itself and need no revivers. +A set of reviver functions that handle the workflow serialization format's workflow-specific types (streams, step/workflow function references, class instances, `AbortController`/`AbortSignal`, and `DOMException`), reviving them as display-friendly marker objects or strings. Built-in JavaScript types (`Date`, `Map`, `Set`, `RegExp`, etc.) are handled by the devalue format itself and need no revivers. Pass it as the `revivers` argument to [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) or [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data). Use [`parseClassName()`](/docs/api-reference/workflow-observability/parse-class-name) to turn the machine-readable class IDs on revived class-instance markers into display-friendly names. @@ -20,7 +20,7 @@ declare const step: Step; // @setup const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight] ``` -## API Signature +## API signature ```typescript import type { Revivers } from "workflow/observability"; diff --git a/docs/content/docs/v4/api-reference/workflow-observability/parse-class-name.mdx b/docs/content/docs/v4/api-reference/workflow-observability/parse-class-name.mdx index 8371184e46..23609e481c 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/parse-class-name.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/parse-class-name.mdx @@ -9,7 +9,7 @@ related: - /docs/api-reference/workflow-serde --- -Serialized class instances reference their class with machine-readable IDs like `class//./src/models//User`. This function parses them into components suitable for display in a UI. +Serialized class instances reference their class with machine-readable IDs like `class//./src/models//User`. This function parses them into components suitable for display in a user interface. ```typescript lineNumbers import { parseClassName } from "workflow/observability"; // [!code highlight] @@ -20,7 +20,7 @@ const parsed = parseClassName("class//./src/models//User"); // [!code highlight] // parsed?.functionName → "User" ``` -## API Signature +## API signature ### Parameters @@ -35,7 +35,7 @@ const parsed = parseClassName("class//./src/models//User"); // [!code highlight] | Property | Description | |----------|-------------| | `shortName` | The display name of the class (e.g. `"User"`). | -| `moduleSpecifier` | The module the class is defined in — a relative path (`./src/models`) or a package specifier (`point@0.0.1`). | +| `moduleSpecifier` | The module the class is defined in: a relative path (`./src/models`) or a package specifier (`point@0.0.1`). | | `functionName` | The class name as recorded by the compiler. | Returns `null` when the input is not a valid class ID. diff --git a/docs/content/docs/v4/api-reference/workflow-observability/parse-step-name.mdx b/docs/content/docs/v4/api-reference/workflow-observability/parse-step-name.mdx index bd00f9789e..efffbd21cc 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/parse-step-name.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/parse-step-name.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/parse-class-name --- -Step names are stored as machine-readable identifiers like `step//./src/workflows/order//processPayment`. This function parses them into components suitable for display in a UI. +Step names are stored as machine-readable identifiers like `step//./src/workflows/order//processPayment`. This function parses them into components suitable for display in a user interface. ```typescript lineNumbers import { parseStepName } from "workflow/observability"; // [!code highlight] @@ -19,7 +19,7 @@ const parsed = parseStepName("step//./src/workflows/order//processPayment"); // // parsed?.functionName → "processPayment" ``` -## API Signature +## API signature ### Parameters @@ -33,8 +33,8 @@ const parsed = parseStepName("step//./src/workflows/order//processPayment"); // | Property | Description | |----------|-------------| -| `shortName` | The display name — the last segment of the function name. For nested steps like `processOrder/chargeCard`, this is `"chargeCard"`. | -| `moduleSpecifier` | The module the step is defined in — a relative path (`./src/workflows/order`) or a package specifier (`@myorg/tasks@2.0.0`). | +| `shortName` | The display name: the last segment of the function name. For nested steps like `processOrder/chargeCard`, this is `"chargeCard"`. | +| `moduleSpecifier` | The module the step is defined in: a relative path (`./src/workflows/order`) or a package specifier (`@myorg/tasks@2.0.0`). | | `functionName` | The full function name including nesting (e.g. `processOrder/chargeCard`). | Returns `null` when the input is not a valid step name. diff --git a/docs/content/docs/v4/api-reference/workflow-observability/parse-workflow-name.mdx b/docs/content/docs/v4/api-reference/workflow-observability/parse-workflow-name.mdx index fb28d48faa..648743c385 100644 --- a/docs/content/docs/v4/api-reference/workflow-observability/parse-workflow-name.mdx +++ b/docs/content/docs/v4/api-reference/workflow-observability/parse-workflow-name.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/parse-class-name --- -Workflow names are stored as machine-readable identifiers like `workflow//./src/workflows/order//processOrder`. This function parses them into components suitable for display in a UI — for example when listing runs from the [World SDK](/docs/api-reference/workflow-runtime/world/storage), where `run.workflowName` holds the machine-readable form. +Workflow names are stored as machine-readable identifiers like `workflow//./src/workflows/order//processOrder`. This function parses them into components suitable for display in a user interface, for example when listing runs from the [World SDK](/docs/api-reference/workflow-runtime/world/storage), where `run.workflowName` holds the machine-readable form. ```typescript lineNumbers import { parseWorkflowName } from "workflow/observability"; // [!code highlight] @@ -19,7 +19,7 @@ const parsed = parseWorkflowName("workflow//./src/workflows/order//processOrder" // parsed?.functionName → "processOrder" ``` -## API Signature +## API signature ### Parameters @@ -34,12 +34,12 @@ const parsed = parseWorkflowName("workflow//./src/workflows/order//processOrder" | Property | Description | |----------|-------------| | `shortName` | The display name. For default exports, falls back to the module's short name (e.g. `"order"` for `./src/workflows/order`). | -| `moduleSpecifier` | The module the workflow is defined in — a relative path (`./src/workflows/order`) or a package specifier (`@myorg/flows@1.0.0`). | +| `moduleSpecifier` | The module the workflow is defined in: a relative path (`./src/workflows/order`) or a package specifier (`@myorg/flows@1.0.0`). | | `functionName` | The full exported function name. | Returns `null` when the input is not a valid workflow name. -## Example: List Runs with Display Names +## Example: list runs with display names ```typescript lineNumbers import { getWorld } from "workflow/runtime"; diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/create-world.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/create-world.mdx index 7180a63ae1..821932a8eb 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/create-world.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/create-world.mdx @@ -11,7 +11,7 @@ related: Creates a new [World](/docs/api-reference/workflow-runtime/world) instance based on environment configuration. The `WORKFLOW_TARGET_WORLD` environment variable determines which World implementation is instantiated (for example the local development World or the Vercel production World). -Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself. +Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()`. `createWorld()` is for infrastructure code that manages World lifecycles itself. ```typescript lineNumbers import { createWorld } from "workflow/runtime"; @@ -19,7 +19,7 @@ import { createWorld } from "workflow/runtime"; const world = await createWorld(); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -37,7 +37,7 @@ Returns a newly constructed `World` instance. Tooling that needs to construct a World with explicit (non-environment) configuration should instantiate the specific World implementation directly and register it with [`setWorld()`](/docs/api-reference/workflow-runtime/set-world). -## Related Functions +## Related functions -- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance (preferred in application code). -- [`setWorld()`](/docs/api-reference/workflow-runtime/set-world) - Override the cached World instance. +- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): Resolve the cached World instance (preferred in application code). +- [`setWorld()`](/docs/api-reference/workflow-runtime/set-world): Override the cached World instance. diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/get-world-handlers.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/get-world-handlers.mdx index d63f079fdd..76d3ac6e84 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/get-world-handlers.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/get-world-handlers.mdx @@ -9,7 +9,7 @@ prerequisites: Returns a restricted view of the [World](/docs/api-reference/workflow-runtime/world) exposing only the members that are safe to use at build time: `createQueueHandler` and `specVersion`. Framework adapters use it while generating workflow route handlers, before the deployment's runtime environment variables exist. -Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance — caching at build time would lock in incomplete environment configuration. +Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance: caching at build time would lock in incomplete environment configuration. ```typescript lineNumbers import { getWorldHandlers } from "workflow/runtime"; @@ -18,7 +18,7 @@ const handlers = await getWorldHandlers(); // [!code highlight] console.log(handlers.specVersion); ``` -## API Signature +## API signature ### Parameters @@ -38,7 +38,7 @@ type WorldHandlers = Pick; This is SDK infrastructure used by framework adapters and the workflow entrypoint. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead. -## Related Functions +## Related functions -- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the full World instance at runtime. -- [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint) - The route handler factory built on these handlers. +- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): Resolve the full World instance at runtime. +- [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint): The route handler factory built on these handlers. diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/get-world.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/get-world.mdx index dd63c20f67..a5c78304f3 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/get-world.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/get-world.mdx @@ -21,7 +21,7 @@ const world = await getWorld(); // [!code highlight] In workflow 4.x, `getWorld()` is synchronous and returns `World` directly. It becomes async in 5.x, so writing `await getWorld()` works on both versions. -## API Signature +## API signature ### Parameters @@ -54,7 +54,7 @@ The World object provides access to several entity interfaces. See the [World SD -## Data Hydration +## Data hydration Step and run data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `workflow/observability` to hydrate it for display: @@ -67,7 +67,7 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl See [`workflow/observability`](/docs/api-reference/workflow-observability) for the full hydration and parsing API. -### List Workflow Runs (Display Names) +### List workflow runs (display names) List workflow runs and derive human-readable names from the `workflowName` field: @@ -118,7 +118,7 @@ export async function GET(req: Request) { and `moduleSpecifier` for display in your UI. -## Related Functions +## Related functions - [`getRun()`](/docs/api-reference/workflow-api/get-run) - Higher-level API for working with individual runs by ID. - [`start()`](/docs/api-reference/workflow-api/start) - Start a new workflow run. diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/health-check.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/health-check.mdx index fae43ffbdb..53589e0d5e 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/health-check.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/health-check.mdx @@ -20,7 +20,7 @@ if (!result.healthy) { } ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/index.mdx index 2c079d16d1..70a50d6110 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/index.mdx @@ -5,9 +5,7 @@ type: overview summary: Explore runtime functions for resolving the World instance and configuring workflow infrastructure. --- -API reference for runtime functions from the `workflow/runtime` package. - -The runtime package provides low-level access to the workflow runtime — resolving the [World](/docs/api-reference/workflow-runtime/world) instance that backs storage, queuing, and streaming, and wiring up workflow infrastructure in custom server environments. +The `workflow/runtime` package provides low-level access to the workflow runtime. Use it to resolve the [World](/docs/api-reference/workflow-runtime/world) instance that backs storage, queuing, and streaming or to wire up workflow infrastructure in custom server environments. ## Functions @@ -20,7 +18,7 @@ The runtime package provides low-level access to the workflow runtime — resolv -## Infrastructure Functions +## Infrastructure functions These functions are primarily used by framework adapters and custom world setups, and are rarely needed in application code: diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/set-world.mdx index 6dcc03d8bd..60aab379fb 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/set-world.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/set-world.mdx @@ -20,7 +20,7 @@ setWorld(customWorld); // [!code highlight] const world = await getWorld(); // resolves customWorld ``` -## API Signature +## API signature ### Parameters @@ -32,7 +32,7 @@ const world = await getWorld(); // resolves customWorld This function does not return a value. -## Example: Reset After Environment Changes +## Example: Reset after environment changes ```typescript lineNumbers import { setWorld, getWorld } from "workflow/runtime"; @@ -43,7 +43,7 @@ setWorld(undefined); // clear the cached instance // [!code highlight] const world = await getWorld(); // reinitialized with new configuration ``` -## Related Functions +## Related functions - [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance. - [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh World from environment configuration. diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/step-entrypoint.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/step-entrypoint.mdx index c330417f5d..ce3fc839b2 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/step-entrypoint.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/step-entrypoint.mdx @@ -11,20 +11,20 @@ related: The HTTP route handler that executes step functions. It receives step execution requests from the queue, routes them to the appropriate step function, and reports results back to the workflow run. -Unlike [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint), this is the handler itself rather than a factory — step bundles register their step functions globally, and the handler routes by step name. +Unlike [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint), this is the handler itself rather than a factory: step bundles register their step functions globally, and the handler routes by step name. -Framework adapters mount this for you at `/.well-known/workflow/v1/step` — you only need it when wiring workflow support into a custom server environment. +Framework adapters mount this for you at `/.well-known/workflow/v1/step`. You only need it when wiring workflow support into a custom server environment. {/* @skip-typecheck: stepEntrypoint exists in workflow@4 only; docs samples are type-checked against the v5 packages on main */} ```typescript lineNumbers import { stepEntrypoint } from "workflow/runtime"; -// Mount on your server, e.g. a fetch-style route: +// Mount on your server, for example, as a fetch-style route: export const POST = stepEntrypoint; // [!code highlight] ``` -## API Signature +## API signature {/* @skip-typecheck: type-only signature snippet, not compilable code */} diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/workflow-entrypoint.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/workflow-entrypoint.mdx index 08f28ec1b9..f9622eb027 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/workflow-entrypoint.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/workflow-entrypoint.mdx @@ -11,7 +11,7 @@ related: Creates the HTTP route handler that executes workflow runs. The handler receives [queue messages](/docs/api-reference/workflow-runtime/world/queue), replays the workflow from its event log, executes steps inline where possible, and suspends when the workflow waits on sleeps or hooks. -Framework adapters (Next.js, Nitro, SvelteKit, etc.) call this for you and mount the result at `/.well-known/workflow/v1/flow` — you only need it when wiring workflow support into a custom server environment. +Framework adapters, including Next.js, Nitro, and SvelteKit, call this for you and mount the result at `/.well-known/workflow/v1/flow`. You only need it when wiring workflow support into a custom server environment. ```typescript lineNumbers import { workflowEntrypoint } from "workflow/runtime"; @@ -19,25 +19,25 @@ declare const workflowBundleCode: string; // @setup const handler = workflowEntrypoint(workflowBundleCode); // [!code highlight] -// Mount on your server, e.g. a fetch-style route: +// Mount on your server, for example, as a fetch-style route: export const POST = (req: Request) => handler(req); ``` -## API Signature +## API signature ### Parameters | Parameter | Type | Description | |-----------|------|-------------| -| `workflowCode` | `string` | The compiled workflow bundle code containing all workflow functions | +| `workflowCode` | `string` | The compiled workflow bundle code containing all workflow functions. | | `options` | `{ namespace?: string }` | Optional. `namespace` scopes the queue topics this handler consumes. | ### Returns Returns a fetch-style request handler: `(req: Request) => Promise`. -## Related Functions +## Related functions -- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - The build-time World access this handler is built on. -- [`stepEntrypoint`](/docs/api-reference/workflow-runtime/step-entrypoint) - The equivalent route handler for executing step functions. -- [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end. +- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers): The build-time World access this handler is built on. +- [`stepEntrypoint`](/docs/api-reference/workflow-runtime/step-entrypoint): The equivalent route handler for executing step functions. +- [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check): Verify the entrypoint processes queue messages end-to-end. diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/world/index.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/world/index.mdx index 5685640d58..63050928a9 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/world/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/world/index.mdx @@ -14,7 +14,7 @@ keywords: - workflow management --- -The World SDK provides direct access to workflow infrastructure — runs, steps, events, hooks, streams, and queues. Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic. +The World SDK provides direct access to workflow infrastructure (runs, steps, events, hooks, streams, and queues). Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic. ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -40,9 +40,9 @@ const world = await getWorld(); // [!code highlight] The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide. -## Data Hydration +## Data hydration -Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`: +Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your user interface (UI), use the hydration utilities from `workflow/observability`: ```typescript lineNumbers import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight] diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/world/queue.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/world/queue.mdx index 2c61ef284f..3c7286123d 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/world/queue.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/world/queue.mdx @@ -2,7 +2,7 @@ title: Queue description: Low-level queue interface for dispatching workflow and step invocations. type: reference -summary: "Methods: getDeploymentId(), queue(), createQueueHandler(). Internal queue dispatch — normally handled by the SDK." +summary: "Methods: getDeploymentId(), queue(), createQueueHandler(). Internal queue dispatch, normally handled by the SDK." prerequisites: - /docs/api-reference/workflow-runtime/get-world related: @@ -20,7 +20,7 @@ keywords: Queue methods live directly on the `world` object (not nested). They dispatch internal workflow and step invocations to the queue backend. - These methods are used internally by the Workflow SDK to dispatch execution. You do not need to call them in normal operations — use [`start()`](/docs/api-reference/workflow-api/start) to trigger workflows instead. Direct queue access is only needed if you programmatically create a run via `world.events.create()` with a `run_created` event and need to kick off its initial execution, or for debugging resumption of a flow or step route. + These methods are used internally by the Workflow SDK to dispatch execution. You do not need to call them in normal operations. Use [`start()`](/docs/api-reference/workflow-api/start) to trigger workflows instead. Direct queue access is only needed if you programmatically create a run via `world.events.create()` with a `run_created` event and need to kick off its initial execution, or for debugging resumption of a flow or step route. ## Import @@ -29,24 +29,24 @@ Queue methods live directly on the `world` object (not nested). They dispatch in import { getWorld } from "workflow/runtime"; const world = await getWorld(); // [!code highlight] -// Queue methods are called directly on world — e.g. world.queue() +// Queue methods are called directly on world, for example, world.queue() ``` ## Methods ### getDeploymentId() -Get the current deployment ID. Used internally for routing queue messages to the correct deployment. +Gets the current deployment ID. The SDK uses the ID to route queue messages to the correct deployment. ```typescript lineNumbers const deploymentId = await world.getDeploymentId(); // [!code highlight] ``` -**Returns:** `string` — The current deployment ID +**Returns:** `string`, the current deployment ID ### queue() -Dispatch a message to a named queue. The message payload is an internal SDK type (`WorkflowInvokePayload`, `StepInvokePayload`, or `HealthCheckPayload`). +Dispatches a message to a named queue. The message payload is an internal SDK type (`WorkflowInvokePayload`, `StepInvokePayload`, or `HealthCheckPayload`). ```typescript lineNumbers const { messageId } = await world.queue(queueName, payload, opts); // [!code highlight] @@ -58,13 +58,13 @@ const { messageId } = await world.queue(queueName, payload, opts); // [!code hig |-----------|------|-------------| | `queueName` | `ValidQueueName` | The queue name (branded string) | | `message` | `QueuePayload` | Internal SDK payload | -| `opts` | `QueueOptions` | Optional — `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers` | +| `opts` | `QueueOptions` | Optional: `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers` | **Returns:** `{ messageId: MessageId | null }` ### createQueueHandler() -Create an HTTP handler that processes messages from a queue. Used to set up the queue consumer endpoint. +Creates an HTTP handler that processes messages from a queue. Use it to set up the queue consumer endpoint. ```typescript lineNumbers const handler = world.createQueueHandler(prefix, callback); // [!code highlight] @@ -81,6 +81,6 @@ const handler = world.createQueueHandler(prefix, callback); // [!code highlight] ## Related -- [start()](/docs/api-reference/workflow-api/start) — The standard way to start workflow runs -- [Starting Workflows](/docs/foundations/starting-workflows) — Core concepts for workflow invocation -- [Storage](/docs/api-reference/workflow-runtime/world/storage) — Create events that trigger queue dispatch +- [`start()`](/docs/api-reference/workflow-api/start): The standard way to start workflow runs +- [Starting workflows](/docs/foundations/starting-workflows): Core concepts for workflow invocation +- [Storage](/docs/api-reference/workflow-runtime/world/storage): Create events that trigger queue dispatch diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/world/storage.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/world/storage.mdx index 53a0bebb25..b342460fb2 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/world/storage.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/world/storage.mdx @@ -31,8 +31,8 @@ keywords: The World storage interface exposes four sub-interfaces for querying workflow data: -- **`world.events`** — The append-only event log. This is the source of truth for all workflow state. See [Event Sourcing](/docs/how-it-works/event-sourcing) for background. -- **`world.runs`**, **`world.steps`**, **`world.hooks`** — Materialized views derived from the event log, provided as convenience accessors for the most common query patterns. +- **`world.events`**: The append-only event log. This is the source of truth for all workflow state. See [Event Sourcing](/docs/how-it-works/event-sourcing) for background. +- **`world.runs`**, **`world.steps`**, **`world.hooks`**: Materialized views derived from the event log, provided as convenience accessors for the most common query patterns. ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -62,7 +62,7 @@ await world.events.create(runId, { // [!code highlight] | `data` | `CreateEventRequest` | Event data including `eventType` | | `params` | `object` | Optional parameters | -**Returns:** `EventResult` — The created event and the affected entity (run/step/hook) +**Returns:** `EventResult`, the created event and the affected entity (run/step/hook) ### events.get() @@ -111,7 +111,7 @@ const result = await world.events.listByCorrelationId({ // [!code highlight] **Returns:** `{ data: Event[], cursor?: string }` -### Event Types +### Event types | Category | Types | |----------|-------| @@ -154,11 +154,11 @@ const result = await world.runs.list({ // [!code highlight] **Returns:** `{ data: WorkflowRun[], cursor?: string }` -### Cancelling Runs +### Cancelling runs -To cancel a run, create a `run_cancelled` event via `world.events.create()` (see [world.events](#worldevents) above), or use the CLI or Web UI helpers. +To cancel a run, create a `run_cancelled` event through `world.events.create()` (see [world.events](#worldevents) above), or use the Workflow CLI or web interface helpers. -### WorkflowRun Type +### WorkflowRun type | Field | Type | Description | |-------|------|-------------| @@ -212,7 +212,7 @@ const result = await world.steps.list({ // [!code highlight] **Returns:** `{ data: Step[], cursor?: string }` -### Step Type +### Step type | Field | Type | Description | |-------|------|-------------| @@ -229,11 +229,11 @@ const result = await world.steps.list({ // [!code highlight] | `retryAfter` | `string \| null` | ISO timestamp for next retry attempt | - Step I/O is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `hydrateResourceIO()` from `workflow/observability` to deserialize it for display. See [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io). + Step input/output (I/O) is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `hydrateResourceIO()` from `workflow/observability` to deserialize it for display. See [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io). - `stepName` is a machine-readable identifier like `step//./src/workflows/order//processPayment`. Use [`parseStepName()`](/docs/api-reference/workflow-observability/parse-step-name) from `workflow/observability` to extract the `shortName` for UI display. + `stepName` is a machine-readable identifier like `step//./src/workflows/order//processPayment`. Use [`parseStepName()`](/docs/api-reference/workflow-observability/parse-step-name) from `workflow/observability` to extract the `shortName` for display in a user interface. --- @@ -261,7 +261,7 @@ Look up a hook by its token. Useful in webhook resume flows where you receive a For runtime application code, prefer [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). Use `world.hooks.getByToken()` when you are working directly with the World storage interface for custom tooling, admin views, or low-level integrations. -Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first — on a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). +Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first. On a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). ```typescript lineNumbers @@ -288,7 +288,7 @@ const result = await world.hooks.list({ // [!code highlight] **Returns:** `{ data: Hook[], cursor?: string }` -### Hook Type +### Hook type | Field | Type | Description | |-------|------|-------------| @@ -305,7 +305,7 @@ const result = await world.hooks.list({ // [!code highlight] ## Examples -### List Runs with Pagination +### List runs with pagination ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -320,23 +320,23 @@ const runs = await world.runs.list({ // [!code highlight] cursor = runs.cursor; // pass to next call for pagination ``` -### Get a Run — Full Data vs. Metadata Only +### Get a run: full data vs. metadata only ```typescript lineNumbers import { getWorld } from "workflow/runtime"; const world = await getWorld(); -// Full data (default) — includes serialized input/output +// Full data (default): includes serialized input/output const run = await world.runs.get(runId); // [!code highlight] -// Metadata only — lighter, no I/O loaded +// Metadata only: lighter, no I/O loaded const lightweight = await world.runs.get(runId, { // [!code highlight] resolveData: "none", // [!code highlight] }); // [!code highlight] ``` -### List Steps for a Progress Dashboard +### List steps for a progress dashboard ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -358,7 +358,7 @@ const progress = steps.data.map((step) => { }); ``` -### Hydrate Step I/O +### Hydrate step I/O ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -370,7 +370,7 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl console.log(hydrated.input, hydrated.output); ``` -### Cancel a Run +### Cancel a run ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -381,7 +381,7 @@ await world.events.create(runId, { // [!code highlight] }); // [!code highlight] ``` -### Look Up Hook by Token +### Look up hook by token ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -391,7 +391,7 @@ const hook = await world.hooks.getByToken(token); // [!code highlight] console.log(hook.runId, hook.metadata); // [!code highlight] ``` -### List Events for Audit Trail +### List events for audit trail ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -406,9 +406,9 @@ for (const event of events.data) { ## Related -- [Event Sourcing](/docs/how-it-works/event-sourcing) — How the event log powers workflow replay and state -- [getRun()](/docs/api-reference/workflow-api/get-run) — Higher-level API for working with individual runs -- [`workflow/observability`](/docs/api-reference/workflow-observability) — Hydrate step I/O and parse display names -- [resumeHook()](/docs/api-reference/workflow-api/resume-hook) — Resume a workflow by sending a payload to a hook -- [Hooks](/docs/foundations/hooks) — Core concepts for hooks and pause points -- [Workflows and Steps](/docs/foundations/workflows-and-steps) — Core concepts for steps +- [Event sourcing](/docs/how-it-works/event-sourcing): How the event log powers workflow replay and state +- [`getRun()`](/docs/api-reference/workflow-api/get-run): Higher-level API for working with individual runs +- [`workflow/observability`](/docs/api-reference/workflow-observability): Hydrate step I/O and parse display names +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a workflow by sending a payload to a hook +- [Hooks](/docs/foundations/hooks): Core concepts for hooks and pause points +- [Workflows and steps](/docs/foundations/workflows-and-steps): Core concepts for steps diff --git a/docs/content/docs/v4/api-reference/workflow-runtime/world/streams.mdx b/docs/content/docs/v4/api-reference/workflow-runtime/world/streams.mdx index b458b6c940..9ef4b396d3 100644 --- a/docs/content/docs/v4/api-reference/workflow-runtime/world/streams.mdx +++ b/docs/content/docs/v4/api-reference/workflow-runtime/world/streams.mdx @@ -33,7 +33,7 @@ Stream methods live on `world.streams` (the `streams` sub-object of the `World` import { getWorld } from "workflow/runtime"; const world = await getWorld(); // [!code highlight] -// Stream methods are called on world.streams — e.g. world.streams.write() +// Stream methods are called on world.streams, e.g. world.streams.write() ``` ## Methods @@ -56,7 +56,7 @@ await world.streams.write(runId, "default", chunk); // [!code highlight] ### writeMulti() -Write multiple chunks in a single operation. Optional optimization — not all World implementations support it. Falls back to sequential `write()` calls if unavailable. +Write multiple chunks in a single operation. Optional optimization: not all World implementations support it. Falls back to sequential `write()` calls if unavailable. ```typescript lineNumbers await world.streams.writeMulti?.(runId, "default", [chunk1, chunk2]); // [!code highlight] @@ -173,7 +173,7 @@ const info = await world.streams.getInfo(runId, "default"); // [!code highlight] ## Examples -### Read a Stream as a Response +### Read a stream as a response ```typescript lineNumbers // app/api/workflow-streams/read/route.ts @@ -192,7 +192,7 @@ export async function GET(req: Request) { } ``` -### Paginate Through Stream Chunks +### Paginate through stream chunks ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -211,6 +211,6 @@ do { ## Related -- [Streaming](/docs/foundations/streaming) — Core concepts for streaming data from workflows -- [getWritable()](/docs/api-reference/workflow/get-writable) — The standard way to write to streams from within steps -- [Storage](/docs/api-reference/workflow-runtime/world/storage) — Query runs, steps, hooks, and events +- [Streaming](/docs/foundations/streaming): Core concepts for streaming data from workflows +- [`getWritable()`](/docs/api-reference/workflow/get-writable): The standard way to write to streams from within steps +- [Storage](/docs/api-reference/workflow-runtime/world/storage): Query runs, steps, hooks, and events diff --git a/docs/content/docs/v4/api-reference/workflow-serde/index.mdx b/docs/content/docs/v4/api-reference/workflow-serde/index.mdx index edb147d0cf..8980cd48ed 100644 --- a/docs/content/docs/v4/api-reference/workflow-serde/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow-serde/index.mdx @@ -27,7 +27,7 @@ The `@workflow/serde` package provides two symbols that allow you to define cust -## Quick Example +## Quick example ```typescript lineNumbers diff --git a/docs/content/docs/v4/api-reference/workflow-serde/workflow-deserialize.mdx b/docs/content/docs/v4/api-reference/workflow-serde/workflow-deserialize.mdx index aec181f061..432dd025f0 100644 --- a/docs/content/docs/v4/api-reference/workflow-serde/workflow-deserialize.mdx +++ b/docs/content/docs/v4/api-reference/workflow-serde/workflow-deserialize.mdx @@ -23,7 +23,7 @@ class Point { } ``` -## API Signature +## API signature {/* @skip-typecheck: type-only signature snippet, not compilable code */} @@ -65,5 +65,5 @@ This method runs inside the workflow context and is subject to the same constrai - No non-deterministic operations (like `Math.random()` or `Date.now()`) - No external network calls -Keep this method simple and focused on reconstructing the instance from the provided data. +Keep this method focused on reconstructing the instance from the provided data. diff --git a/docs/content/docs/v4/api-reference/workflow-serde/workflow-serialize.mdx b/docs/content/docs/v4/api-reference/workflow-serde/workflow-serialize.mdx index 85c5eb8acf..ac4342d236 100644 --- a/docs/content/docs/v4/api-reference/workflow-serde/workflow-serialize.mdx +++ b/docs/content/docs/v4/api-reference/workflow-serde/workflow-serialize.mdx @@ -23,7 +23,7 @@ class Point { } ``` -## API Signature +## API signature {/* @skip-typecheck: type-only signature snippet, not compilable code */} @@ -70,5 +70,5 @@ This method runs inside the workflow context and is subject to the same constrai - No non-deterministic operations (like `Math.random()` or `Date.now()`) - No external network calls -Keep this method simple and focused on extracting data from the instance. +Keep this method focused on extracting data from the instance. diff --git a/docs/content/docs/v4/api-reference/workflow-sveltekit/workflow-plugin.mdx b/docs/content/docs/v4/api-reference/workflow-sveltekit/workflow-plugin.mdx index 7f1e2a00bf..279fd476b7 100644 --- a/docs/content/docs/v4/api-reference/workflow-sveltekit/workflow-plugin.mdx +++ b/docs/content/docs/v4/api-reference/workflow-sveltekit/workflow-plugin.mdx @@ -23,7 +23,7 @@ export default defineConfig({ }); ``` -## API Signature +## API signature ### Parameters @@ -31,4 +31,4 @@ This function does not accept any parameters in workflow 4.x. (5.x adds an optio ### Returns -Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config — Vite flattens nested plugin arrays automatically. +Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config. Vite flattens nested plugin arrays automatically. diff --git a/docs/content/docs/v4/api-reference/workflow-vite/workflow.mdx b/docs/content/docs/v4/api-reference/workflow-vite/workflow.mdx index 81ba1043d7..5e9dcdfa59 100644 --- a/docs/content/docs/v4/api-reference/workflow-vite/workflow.mdx +++ b/docs/content/docs/v4/api-reference/workflow-vite/workflow.mdx @@ -26,7 +26,7 @@ export default defineConfig({ }); ``` -## API Signature +## API signature ### Parameters @@ -39,9 +39,9 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | | `dirs` | `string[]` | — | Directories to scan for workflows and steps. By default, the `workflows/` directory is scanned from the project root and all layer source directories. | -| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense. | -| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). Only applies when deploying to Vercel. | +| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for integrated development environment (IDE) IntelliSense. | +| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (for example, `'nodejs22.x'` or `'nodejs24.x'`). Only applies when deploying to Vercel. | ### Returns -Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config — Vite flattens nested plugin arrays automatically. +Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config. Vite flattens nested plugin arrays automatically. diff --git a/docs/content/docs/v4/api-reference/workflow/create-hook.mdx b/docs/content/docs/v4/api-reference/workflow/create-hook.mdx index c9cb8e3fe6..2bf6850b06 100644 --- a/docs/content/docs/v4/api-reference/workflow/create-hook.mdx +++ b/docs/content/docs/v4/api-reference/workflow/create-hook.mdx @@ -26,7 +26,7 @@ export async function hookWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -66,11 +66,11 @@ export default Hook;`} The returned `Hook` object also implements `AsyncIterable`, which allows you to iterate over incoming payloads using `for await...of` syntax. -Use `hook.getConflict()` (available starting in `workflow@4.5.0`) to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with `{ runId }` identifying the conflicting run if another active hook already owns the same token. +Use `hook.getConflict()` (available starting in `workflow@4.5.0`) to check whether the hook token is already claimed by another active hook, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook: registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with `{ runId }` identifying the conflicting run if another active hook already owns the same token. ## Examples -### Basic Usage +### Basic usage When creating a hook, you can specify a payload type for automatic type safety: @@ -91,7 +91,7 @@ export async function approvalWorkflow() { } ``` -### Customizing Tokens +### Customizing tokens Tokens are used to identify a specific hook. You can customize the token to be more specific to a use case. @@ -115,7 +115,7 @@ export async function slackBotWorkflow(channelId: string) { } ``` -### Detecting Token Conflicts +### Detecting token conflicts Use `hook.getConflict()` (available starting in `workflow@4.5.0`) when the workflow needs to claim a hook token before doing other work, but does not need a payload yet: @@ -141,15 +141,15 @@ async function processOrder(orderId: string) { } ``` -Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration — to receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`. +Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration. To receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`. -On a conflict, the resolved value is `{ runId }` identifying the run that currently owns the token. To act on the owner — inspect its status, wait for its result, or cancel it — pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context. +On a conflict, the resolved value is `{ runId }` identifying the run that currently owns the token. To act on the owner (inspect its status, wait for its result, or cancel it), pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context. Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). -### Waiting for Multiple Payloads +### Waiting for multiple payloads You can also wait for multiple payloads by using the `for await...of` syntax. @@ -172,7 +172,7 @@ export async function collectHookWorkflow() { } ``` -### Disposing Hooks Early +### Disposing hooks early You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running. @@ -201,7 +201,7 @@ export async function handoffWorkflow(channelId: string) { After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use. -### Automatic Disposal with `using` +### Automatic disposal with `using` Hooks implement the [TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal, allowing automatic disposal with the `using` keyword: @@ -227,7 +227,7 @@ export async function scopedHookWorkflow(channelId: string) { This is equivalent to manually calling `dispose()` but ensures the hook is always cleaned up, even if an error occurs. -## Related Functions +## Related functions - [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload diff --git a/docs/content/docs/v4/api-reference/workflow/create-webhook.mdx b/docs/content/docs/v4/api-reference/workflow/create-webhook.mdx index 7b76967148..bd787c594e 100644 --- a/docs/content/docs/v4/api-reference/workflow/create-webhook.mdx +++ b/docs/content/docs/v4/api-reference/workflow/create-webhook.mdx @@ -11,10 +11,10 @@ related: Creates a webhook that can be used to suspend and resume a workflow run upon receiving an HTTP request. -Webhooks provide a way for external systems to send HTTP requests directly to your workflow. Unlike hooks which accept arbitrary payloads, webhooks work with standard HTTP `Request` objects and can return HTTP `Response` objects. +Webhooks provide a way for external systems to send HTTP requests directly to your workflow. Unlike hooks, which accept arbitrary payloads, webhooks work with standard HTTP `Request` objects and can return HTTP `Response` objects. -`createWebhook()` creates a public endpoint at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. This is convenient for prototypes and simple resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. +`createWebhook()` creates a public endpoint at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. This is convenient for prototypes and basic resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. ```ts lineNumbers @@ -31,7 +31,7 @@ export async function webhookWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -63,22 +63,22 @@ When using `createWebhook({ respondWith: 'manual' })`, the resolved request type Use the simplest option that satisfies the prompt: -- `createWebhook()` — generated callback URL, and the default `202 Accepted` response is fine -- `createWebhook({ respondWith: 'manual' })` — generated callback URL, but you must send a custom body, status, or headers -- `createHook()` + `resumeHook()` — the app resumes from server-side code with a deterministic business token instead of a generated callback URL +- `createWebhook()`: generated callback URL, and the default `202 Accepted` response is fine +- `createWebhook({ respondWith: 'manual' })`: generated callback URL, but you must send a custom body, status, or headers +- `createHook()` + `resumeHook()`: the app resumes from server-side code with a deterministic business token instead of a generated callback URL
Common wrong turns -- Do not use `respondWith: 'manual'` just because the flow has a callback URL. +- A callback URL alone does not require `respondWith: 'manual'`. - Do not use `RequestWithResponse` unless you chose manual mode. - Do not invent a custom callback route when `webhook.url` is the intended callback surface.
## Examples -### Basic Usage +### Basic usage Create a webhook that receives HTTP requests and logs the request details: @@ -101,11 +101,11 @@ export async function basicWebhookWorkflow() { } ``` -### Responding to Webhook Requests (Manual Mode) +### Responding to webhook requests (manual mode) Use this section only when the caller requires a non-default HTTP response. If `202 Accepted` is acceptable, use `createWebhook()` without `respondWith: "manual"`. -Pass `{ respondWith: "manual" }` to get a `RequestWithResponse` object with a `respondWith()` method. If a code path finishes without sending a response, the webhook fails with the [webhook-response-not-sent](/docs/errors/webhook-response-not-sent) error. Note that `respondWith()` must be called from within a step function: +Pass `{ respondWith: "manual" }` to get a `RequestWithResponse` object with a `respondWith()` method. If a code path finishes without sending a response, the webhook fails with the [webhook-response-not-sent](/docs/errors/webhook-response-not-sent) error. Call `respondWith()` from within a step function: ```typescript lineNumbers import { createWebhook, type RequestWithResponse } from "workflow" @@ -143,7 +143,7 @@ async function processData(data: any): Promise { } ``` -### Waiting for Multiple Requests +### Waiting for multiple requests You can also wait for multiple requests by using the `for await...of` syntax. @@ -182,9 +182,9 @@ export async function eventCollectorWorkflow() { } ``` -## Related Functions +## Related functions -- [`createHook()`](/docs/api-reference/workflow/create-hook) — Use when the app resumes from server-side code with a deterministic business token. -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — Pairs with `createHook()` for deterministic server-side resume. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — Type-safe hook helper. -- [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) — Low-level runtime API. Most integrations should call `webhook.url` directly instead of adding a custom callback route. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Use when the app resumes from server-side code with a deterministic business token. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Pairs with `createHook()` for deterministic server-side resume. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper. +- [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook): Low-level runtime API. Most integrations should call `webhook.url` directly instead of adding a custom callback route. diff --git a/docs/content/docs/v4/api-reference/workflow/define-hook.mdx b/docs/content/docs/v4/api-reference/workflow/define-hook.mdx index ad0d697f30..2f05af0698 100644 --- a/docs/content/docs/v4/api-reference/workflow/define-hook.mdx +++ b/docs/content/docs/v4/api-reference/workflow/define-hook.mdx @@ -33,7 +33,7 @@ export async function nameWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -66,7 +66,7 @@ export default DefineHook;`} ## Examples -### Basic Type-Safe Hook Definition +### Basic type-safe hook definition By defining the hook once with a specific payload type, you can reuse it in multiple workflows and API routes with automatic type safety. @@ -91,7 +91,7 @@ export async function workflowWithApproval() { } ``` -### Resuming with Type Safety +### Resuming with type safety Hooks can be resumed using the same defined hook and a token. By using the same hook, you can ensure that the payload matches the defined type when resuming a hook. @@ -114,7 +114,7 @@ export async function POST(request: Request) { } ``` -### Validate and Transform with Schema +### Validate and transform with schema You can provide runtime validation and transformation of hook payloads using the `schema` option. This option accepts any validator that conforms to the [Standard Schema v1](https://standardschema.dev) specification. @@ -172,7 +172,7 @@ export async function POST(request: Request) { } ``` -#### Using Other Standard Schema Libraries +#### Using other Standard Schema libraries The same pattern works with any Standard Schema v1 compliant library. Here's an example with [Valibot](https://valibot.dev): @@ -188,7 +188,7 @@ export const approvalHook = defineHook({ }); ``` -### Customizing Tokens +### Customizing tokens Tokens are used to identify a specific hook and for resuming a hook. You can customize the token to be more specific to a use case. @@ -209,7 +209,7 @@ export async function slackBotWorkflow(channelId: string) { } ``` -## Related Functions +## Related functions * [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow. * [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload. diff --git a/docs/content/docs/v4/api-reference/workflow/fatal-error.mdx b/docs/content/docs/v4/api-reference/workflow/fatal-error.mdx index 8c31e1e611..d5806987ad 100644 --- a/docs/content/docs/v4/api-reference/workflow/fatal-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow/fatal-error.mdx @@ -27,7 +27,7 @@ async function fallibleStep() { } ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow/fetch.mdx b/docs/content/docs/v4/api-reference/workflow/fetch.mdx index ad900d134c..69bf355207 100644 --- a/docs/content/docs/v4/api-reference/workflow/fetch.mdx +++ b/docs/content/docs/v4/api-reference/workflow/fetch.mdx @@ -34,7 +34,7 @@ async function apiWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -60,9 +60,9 @@ showSections={['returns']} ## Examples -### Basic Usage +### Basic usage -Here's a simple example of how you can use `fetch` inside your workflow. +This example shows how to use `fetch` inside your workflow. ```typescript lineNumbers import { fetch } from "workflow" @@ -87,11 +87,11 @@ async function apiWorkflow() { } ``` -We call `fetch()` with a URL and optional request options, just like the standard fetch API. The workflow runtime automatically handles the response serialization. +Call `fetch()` with a URL and optional request options, as with the standard fetch API. The workflow runtime automatically handles the response serialization. -This API is provided as a convenience to easily use `fetch` in workflow, but often, you might want to extend and implement your own fetch for more powerful error handing and retry logic. +This API provides `fetch` in a workflow, but you might want to implement your own fetch for more control over error handling and retry logic. -### Customizing Fetch Behavior +### Customizing fetch behavior Here's an example of a custom fetch wrapper that provides more sophisticated error handling with custom retry logic: diff --git a/docs/content/docs/v4/api-reference/workflow/get-step-metadata.mdx b/docs/content/docs/v4/api-reference/workflow/get-step-metadata.mdx index fc00488a38..249edb00e2 100644 --- a/docs/content/docs/v4/api-reference/workflow/get-step-metadata.mdx +++ b/docs/content/docs/v4/api-reference/workflow/get-step-metadata.mdx @@ -36,7 +36,7 @@ async function logStepId() { } ``` -### Example: Use `stepId` as an idempotency key +### Example: use `stepId` as an idempotency key ```typescript lineNumbers import { getStepMetadata } from "workflow"; @@ -63,7 +63,7 @@ async function chargeUser(userId: string, amount: number) { Idempotency guide. -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow/get-workflow-metadata.mdx b/docs/content/docs/v4/api-reference/workflow/get-workflow-metadata.mdx index c5c24c6b8d..0a17e9b842 100644 --- a/docs/content/docs/v4/api-reference/workflow/get-workflow-metadata.mdx +++ b/docs/content/docs/v4/api-reference/workflow/get-workflow-metadata.mdx @@ -30,7 +30,7 @@ async function testWorkflow() { } ``` -### Detecting Workflow Runtime +### Detecting workflow runtime You can use `getWorkflowMetadata` to detect whether your code is running inside a workflow context. This is useful when building shared utilities that need to behave differently inside and outside of workflows. @@ -64,7 +64,7 @@ function log(message: string) { } ``` -### Detecting Encryption +### Detecting encryption The `features` object indicates which capabilities are active for the current run. Library authors can use `features.encryption` to control whether sensitive data is included in step return values, which are serialized to the event log: @@ -90,7 +90,7 @@ async function fetchUserProfile(userId: string) { } ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow/get-writable.mdx b/docs/content/docs/v4/api-reference/workflow/get-writable.mdx index a82257bcd4..341cc3ab6b 100644 --- a/docs/content/docs/v4/api-reference/workflow/get-writable.mdx +++ b/docs/content/docs/v4/api-reference/workflow/get-writable.mdx @@ -47,7 +47,7 @@ async function writeToStream(writable: WritableStream) { } ``` -## API Signature +## API signature ### Parameters @@ -69,7 +69,7 @@ export default getWritable;`} Returns a `WritableStream` where `W` is the type of data you plan to write to the stream. -## Good to Know +## Good to know - **Workflow functions can only obtain the stream** - Call `getWritable()` in a workflow to get the stream reference, but you cannot call methods like `getWriter()`, `write()`, or `close()` directly in the workflow context. - **Step functions can interact with streams** - Steps can receive the stream as an argument or call `getWritable()` directly, and they can freely interact with it (write, close, etc.). @@ -81,9 +81,9 @@ Returns a `WritableStream` where `W` is the type of data you plan to write to ## Examples -### Basic Text Streaming +### Basic text streaming -Here's a simple example streaming text data: +This example streams text data: ```typescript lineNumbers import { sleep, getWritable } from "workflow"; @@ -118,7 +118,7 @@ async function stepCloseOutputStream(writable: WritableStream) { } ``` -### Calling `getWritable()` Inside Steps +### Calling `getWritable()` inside steps You can also call `getWritable()` directly inside step functions without passing it as a parameter: @@ -157,7 +157,7 @@ async function stepCloseOutputStreamInside() { } ``` -### Using Namespaced Streams in Steps +### Using namespaced streams in steps You can also use namespaced streams when calling `getWritable()` from steps: @@ -201,7 +201,7 @@ async function closeStreams() { } ``` -### Advanced Chat Streaming +### Advanced chat streaming Here's a more complex example showing how you might stream AI chat responses: diff --git a/docs/content/docs/v4/api-reference/workflow/index.mdx b/docs/content/docs/v4/api-reference/workflow/index.mdx index 8544b47d70..af947b3ea0 100644 --- a/docs/content/docs/v4/api-reference/workflow/index.mdx +++ b/docs/content/docs/v4/api-reference/workflow/index.mdx @@ -49,7 +49,7 @@ Workflow SDK contains the following functions you can use inside your workflow f -## Error Classes +## Error classes Workflow SDK includes error classes that can be thrown in a workflow or step to change the error exit strategy of a workflow. diff --git a/docs/content/docs/v4/api-reference/workflow/retryable-error.mdx b/docs/content/docs/v4/api-reference/workflow/retryable-error.mdx index 7783e4c981..a4f5243885 100644 --- a/docs/content/docs/v4/api-reference/workflow/retryable-error.mdx +++ b/docs/content/docs/v4/api-reference/workflow/retryable-error.mdx @@ -31,7 +31,7 @@ async function retryStep() { The difference between `Error` and `RetryableError` may not be entirely obvious, since when both are thrown, they both retry. The difference is that `RetryableError` has an additional configurable `retryAfter` parameter. -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v4/api-reference/workflow/sleep.mdx b/docs/content/docs/v4/api-reference/workflow/sleep.mdx index 86d7b14f9e..11b78e723b 100644 --- a/docs/content/docs/v4/api-reference/workflow/sleep.mdx +++ b/docs/content/docs/v4/api-reference/workflow/sleep.mdx @@ -26,7 +26,7 @@ async function testWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -39,7 +39,7 @@ showSections={['parameters']} ## Examples -### Sleeping With a Duration +### Sleeping with a duration You can specify a duration for `sleep` to suspend the workflow for a fixed amount of time. @@ -52,7 +52,7 @@ async function testWorkflow() { } ``` -### Sleeping Until an End Date +### Sleeping until an end date You can specify a future `Date` object for `sleep` to suspend the workflow until a specific date. diff --git a/docs/content/docs/v4/changelog/index.mdx b/docs/content/docs/v4/changelog/index.mdx index c8a7078298..49982ae78c 100644 --- a/docs/content/docs/v4/changelog/index.mdx +++ b/docs/content/docs/v4/changelog/index.mdx @@ -12,5 +12,5 @@ Stay up to date with the latest changes to Workflow SDK. ## 2026 -- [Resilient run start](/docs/changelog/resilient-start) — April 2026 -- Serializable AbortController and AbortSignal — March 12, 2026 +- [Resilient run start](/docs/changelog/resilient-start) (April 2026) +- Serializable AbortController and AbortSignal (March 12, 2026) diff --git a/docs/content/docs/v4/changelog/resilient-start.mdx b/docs/content/docs/v4/changelog/resilient-start.mdx index 8560762807..a2fd3f7cd0 100644 --- a/docs/content/docs/v4/changelog/resilient-start.mdx +++ b/docs/content/docs/v4/changelog/resilient-start.mdx @@ -1,6 +1,6 @@ --- title: Resilient run start -description: Overhaul run start logic to tolerate world storage unavailability, as long as the queue is healthy, and significantly speeds up run start. +description: Run start logic tolerates World storage unavailability when the queue is healthy and reduces run start latency. --- # Resilient `start()` @@ -9,18 +9,18 @@ description: Overhaul run start logic to tolerate world storage unavailability, When `world` storage is unavailable but the queue is up, `start()` previously failed entirely because `world.events.create(run_created)` is called before `world.queue()`. This change decouples run creation from queue dispatch so that runs can still be accepted when storage is degraded. -Additionally, the runtime previously called `world.runs.get(runId)` before `run_started`, adding an extra round-trip. By always calling `run_started` directly, we save that round-trip and can return pre-loaded events in the response to skip the initial `events.list` call, reducing TTFB. +The runtime also previously called `world.runs.get(runId)` before `run_started`, adding an extra round trip. Calling `run_started` directly removes that round trip and can return preloaded events in the response to skip the initial `events.list` call, reducing time to first byte (TTFB). ## Design ### `start()` changes - `world.events.create` (run_created) and `world.queue` are now called **in parallel** via `Promise.allSettled`. -- If `events.create` errors with **429 or 5xx**, we log a warning saying that run creation failed but the run was accepted — creation will be re-tried async by the runtime when it processes the queue message. The returned `Run` instance is marked with `resilientStart = true`. +- If `events.create` returns a **429 or 5xx** error, the runtime logs a warning that run creation failed but the run was accepted. The runtime retries creation asynchronously when it processes the queue message. The returned `Run` instance is marked with `resilientStart = true`. - If `events.create` errors with **409** (EntityConflictError), the run already exists (e.g., the queue handler's resilient start path created it first due to a cold-start race). This is treated as success. -- If `world.queue` fails, we still throw — the run truly failed and was not enqueued. +- If `world.queue` fails, we still throw: the run truly failed and was not enqueued. - The queue invocation now receives all the run inputs (`input`, `deploymentId`, `workflowName`, `specVersion`, `executionContext`) via `runInput` so the runtime can create the run later if needed. -- When the runtime re-enqueues itself, it does **not** pass these inputs — only the first queue cycle carries them. +- When the runtime re-enqueues itself, it does **not** pass these inputs: only the first queue cycle carries them. ### `workflowEntrypoint` changes @@ -29,7 +29,7 @@ Additionally, the runtime previously called `world.runs.get(runId)` before `run_ ### `Run.returnValue` polling - When `resilientStart` is true on the Run instance (run_created failed), the `pollReturnValue` loop retries on `WorkflowRunNotFoundError` up to 3 times (1s + 3s + 6s = 10s total) to give the queue time to deliver and the runtime to create the run via `run_started`. -- When `resilientStart` is false (normal path), 404 fails immediately — no delay for the common case of a wrong run ID. +- When `resilientStart` is false (normal path), 404 fails immediately: no delay for the common case of a wrong run ID. ### World contract changes @@ -40,15 +40,15 @@ Additionally, the runtime previously called `world.runs.get(runId)` before `run_ `Uint8Array` values (the serialized workflow input in `runInput`) don't survive plain JSON serialization. Each world uses a transport that preserves binary data: -- **world-vercel**: CBOR transport — CBOR-encodes the entire queue payload into a `Buffer` and uses `BufferTransport` from `@vercel/queue`. Uint8Array survives natively. -- **world-local**: `TypedJsonTransport` — encodes Uint8Array as `{ __type: 'Uint8Array', data: '' }`. -- **world-postgres**: Inline typed JSON transport — same tagged-envelope approach as world-local. +- **world-vercel**: Uses Concise Binary Object Representation (CBOR) transport, which CBOR-encodes the entire queue payload into a `Buffer` and uses `BufferTransport` from `@vercel/queue`. `Uint8Array` survives natively. +- **world-local**: Uses `TypedJsonTransport`, which encodes `Uint8Array` as `{ __type: 'Uint8Array', data: '' }`. +- **world-postgres**: Uses inline typed JSON transport, the same tagged-envelope approach as world-local. ## Decisions -1. **Parallel not sequential**: We chose `Promise.allSettled` over sequential calls to minimize latency in the happy path. +1. **Parallel, not sequential**: We chose `Promise.allSettled` over sequential calls to minimize latency in the successful path. -2. **Already-running returns run without event**: When `run_started` encounters an already-running run, all worlds return `{ run }` with `event: undefined` (no `events` array) instead of throwing. The runtime detects this by checking for `result.event === undefined`. This avoids an extra `world.runs.get` round-trip. +2. **Already-running returns run without event**: When `run_started` encounters an already-running run, all worlds return `{ run }` with `event: undefined` (no `events` array) instead of throwing. The runtime detects this by checking for `result.event === undefined`. This avoids an extra `world.runs.get` round trip. 3. **Events in 200 response**: We only return events on the 200 path (first caller). On the already-running path, we fall back to the normal `events.list` call. This is correct because only on 200 can we be certain we know the full event history. @@ -64,12 +64,12 @@ On Vercel, the parallel dispatch can cause the queue message to be processed bef 2. The original `run_created` arrives and gets 409 (EntityConflictError). 3. `start()` treats the 409 as success (the run exists). -The `resilientStart` flag is NOT set on the Run instance in this case (409 is not a retryable error), so `returnValue` fails fast on 404. +The `resilientStart` flag is not set on the `Run` instance in this case (409 is not a retryable error), so `returnValue` fails immediately on 404. ### Atomicity of run entity creation -The normal `run_created` path and the resilient start path can race on creating the run entity. In `world-local`, both paths use `writeExclusive` (O_CREAT|O_EXCL) — atomic at the OS level, so exactly one writer wins and the other gets EEXIST. The normal path throws `EntityConflictError` on conflict (handled by `start()` as 409); the resilient start path re-reads the run from disk on conflict. +The normal `run_created` path and the resilient start path can race on creating the run entity. In `world-local`, both paths use `writeExclusive` (O_CREAT|O_EXCL), atomic at the OS level, so exactly one writer wins and the other gets EEXIST. The normal path throws `EntityConflictError` on conflict (handled by `start()` as 409); the resilient start path re-reads the run from disk on conflict. In `world-postgres`, the resilient start path uses `onConflictDoNothing` plus a re-read on conflict for the same effect, with the same outcome on either side of the race. -The narrow crash window in `world-postgres` between the run insert and the event insert is acceptable — if the run insert succeeds but the event insert crashes, the run exists and `run_started` will still proceed normally (the event log will be missing a `run_created` entry, but the run itself is functional). +The narrow crash window in `world-postgres` between the run insert and the event insert is acceptable: if the run insert succeeds but the event insert crashes, the run exists and `run_started` will still proceed normally (the event log will be missing a `run_created` entry, but the run itself is functional). diff --git a/docs/content/docs/v4/comparisons/index.mdx b/docs/content/docs/v4/comparisons/index.mdx index 72b30efb48..ccbe99177e 100644 --- a/docs/content/docs/v4/comparisons/index.mdx +++ b/docs/content/docs/v4/comparisons/index.mdx @@ -1,6 +1,6 @@ --- title: Comparisons -description: How the Workflow SDK compares to other durable execution, workflow, and AI-agent frameworks — Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev. +description: 'How the Workflow SDK compares to other durable execution, workflow, and AI-agent frameworks: Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev.' type: overview summary: Side-by-side comparisons of the Workflow SDK against Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev. related: @@ -13,26 +13,26 @@ The Workflow SDK overlaps with several categories: durable execution engines, ba ## What makes the Workflow SDK different -- **It's an open-source SDK, not a hosted product.** Your workflows are plain TypeScript in your existing app. Run them on the managed [Vercel World](/worlds/vercel), or self-host on the [Postgres World](/worlds/postgres) — the [World abstraction](/worlds/building-a-world) lets you own and swap the storage, queue, and streaming layers independently. +- **It's an open-source SDK, not a hosted product.** Your workflows are plain TypeScript in your existing app. Run them on the managed [Vercel World](/worlds/vercel), or self-host on the [Postgres World](/worlds/postgres). The [World abstraction](/worlds/building-a-world) lets you own and swap the storage, queue, and streaming layers independently. - **Versioning is safe by default.** Runs are pinned to the immutable deployment that started them, so shipping new code never disturbs in-flight runs. Upgrading a run is explicit and opt-in. See [Versioning](/docs/comparisons/workflow-sdk-vs-temporal#versioning). -- **Realtime durable streaming is built in.** Stream partial output (LLM tokens, progress) to clients with [streams](/docs/foundations/streaming) that survive reconnects, cold starts, and replays — essential for chat and agent UIs. +- **Real-time durable streaming is built in.** Stream partial output, such as large language model (LLM) tokens and progress, to clients with [streams](/docs/foundations/streaming) that survive reconnects, cold starts, and replays. This durability supports chat and agent UIs. - **First-class AI agents.** `WorkflowAgent` ships inside the [AI SDK](/docs/ai), turning an agent loop into a durable workflow with automatic step retries and human-in-the-loop pauses. ## Snapshot -These comparisons are compiled from each product's public documentation and are **not** based on head-to-head benchmarks — durable engines differ enough that a single number rarely compares cleanly. Treat them as directional and verify current pricing and limits against each vendor's docs. +These comparisons are compiled from each product's public documentation and are **not** based on head-to-head benchmarks: durable engines differ enough that a single number rarely compares cleanly. Treat them as directional and verify current pricing and limits against each vendor's docs. | Tool | Category | Durability model | Open source / self-host | Language(s) | | --- | --- | --- | --- | --- | -| **Workflow SDK** | Durable functions SDK | Event-log + deterministic replay | ✅ Apache-2.0 — self-host (Postgres) or Vercel | TypeScript (Python beta) | -| [Temporal](/docs/comparisons/workflow-sdk-vs-temporal) | Durable execution platform | Event-sourced replay | ✅ MIT server — self-host or Temporal Cloud | Go, Java, TS, Python, .NET, PHP, Ruby | +| **Workflow SDK** | Durable functions SDK | Event-log + deterministic replay | ✅ Apache-2.0: self-host (Postgres) or Vercel | TypeScript (Python beta) | +| [Temporal](/docs/comparisons/workflow-sdk-vs-temporal) | Durable execution platform | Event-sourced replay | ✅ MIT server: self-host or Temporal Cloud | Go, Java, TypeScript, Python, .NET, PHP, Ruby | | [Cloudflare Workflows](/docs/comparisons/workflow-sdk-vs-cloudflare-workflows) | Durable execution engine | Step-result memoization + replay | ❌ Cloudflare-only | TypeScript (Python beta) | | [AWS Step Functions](/docs/comparisons/workflow-sdk-vs-aws-step-functions) | Managed state-machine orchestrator | Declarative ASL state machine | ❌ AWS-only | ASL JSON (tasks: any language) | | [AWS Bedrock AgentCore](/docs/comparisons/workflow-sdk-vs-aws-agentcore) | AI-agent hosting platform | Not durable execution (ephemeral sessions) | ❌ AWS-only | Python, Node.js | -| [Inngest](/docs/comparisons/workflow-sdk-vs-inngest) | Durable functions / event platform | Step-result memoization | ◑ SSPL — self-host (community/best-effort) or SaaS | TypeScript (Python/Go pre-1.0) | -| [trigger.dev](/docs/comparisons/workflow-sdk-vs-trigger-dev) | Durable task platform | Process checkpoint/restore (CRIU) | ✅ Apache-2.0 — self-host or Cloud | TypeScript only | +| [Inngest](/docs/comparisons/workflow-sdk-vs-inngest) | Durable functions / event platform | Step-result memoization | ◑ SSPL: self-host (community/best-effort) or SaaS | TypeScript (Python/Go pre-1.0) | +| [trigger.dev](/docs/comparisons/workflow-sdk-vs-trigger-dev) | Durable task platform | Process checkpoint/restore (CRIU) | ✅ Apache-2.0: self-host or Cloud | TypeScript only | ## Deep dives @@ -41,16 +41,16 @@ These comparisons are compiled from each product's public documentation and are The mature, language-agnostic durable-execution platform. You run the workers; Workflow SDK runs in your app. - A durable engine on Workers + Durable Objects. Both replay; they handle versioning and encryption very differently. + A durable engine on Workers and Durable Objects. Both replay; they handle versioning and encryption differently. - Declarative ASL JSON state machines vs. plain TypeScript control flow. + Declarative ASL JSON state machines compared with plain TypeScript control flow. - An AI-agent hosting platform — not a durable-execution engine. Different axis. + An AI-agent hosting platform, not a durable-execution engine. Different axis. - Event-driven durable functions that run on your own infra over HTTP. + Event-driven durable functions that run on your own infrastructure over HTTP. A TypeScript task platform that achieves durability by snapshotting the process (CRIU). @@ -58,7 +58,7 @@ These comparisons are compiled from each product's public documentation and are -Moving an existing system over? Each deep dive includes a concept-mapping section, and the Workflow SDK migration skill can translate code for you: +Each deep dive includes a concept-mapping section for moving an existing system. The Workflow SDK migration skill can translate code for you: ```bash npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk diff --git a/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-agentcore.mdx b/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-agentcore.mdx index 619a866ccf..daf880e7c2 100644 --- a/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-agentcore.mdx +++ b/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-agentcore.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs AWS Bedrock AgentCore -description: How the Workflow SDK compares to AWS Bedrock AgentCore — a durable-execution framework versus an AI-agent hosting platform. They solve different problems. +description: How the Workflow SDK compares to AWS Bedrock AgentCore, a durable-execution framework versus an AI-agent hosting platform. They solve different problems. type: conceptual summary: AgentCore hosts and operates AI agents in isolated microVMs but is not a durable-execution engine. The Workflow SDK provides durable orchestration and resumable streaming for agents. prerequisites: @@ -11,10 +11,10 @@ related: - /docs/foundations/streaming --- -[Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html) is AWS's platform for **hosting and operating AI agents** — secure microVM runtime, plus building blocks for Memory, tool Gateways, and Identity. It is *not* a durable-execution engine, which makes this less a head-to-head and more a "different axis" comparison. +[Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html) is AWS's platform for **hosting and operating AI agents**, with a secure microVM runtime and building blocks for Memory, tool Gateways, and Identity. AgentCore is not a durable-execution engine, so it addresses a different part of the AI agent stack. -**These solve different problems.** AgentCore answers "where do I securely run and operate an agent on AWS?" The Workflow SDK answers "how do I make a multi-step, tool-calling agent loop durable, resumable, and streamable?" AWS itself pairs AgentCore with a durable layer (Step Functions, or Temporal) for resumability — the Workflow SDK provides that durable layer natively, in your own app. +**These solve different problems.** AgentCore answers "where do I securely run and operate an agent on AWS?" The Workflow SDK answers "how do I make a multi-step, tool-calling agent loop durable, resumable, and streamable?" AWS itself pairs AgentCore with a durable layer (Step Functions, or Temporal) for resumability. The Workflow SDK provides that durable layer natively, in your own app. ## At a glance @@ -22,22 +22,22 @@ related: | | Workflow SDK | AWS Bedrock AgentCore | | --- | --- | --- | | **Category** | Open-source durable-functions SDK | AI-agent hosting & operations platform | -| **Durable execution?** | ✅ Event-log replay; the agent loop resumes from its last checkpoint after a crash | ❌ Not built-in — sessions are ephemeral microVMs; durability is opt-in via Memory or a framework checkpointer | +| **Durable execution?** | ✅ Event-log replay; the agent loop resumes from its last checkpoint after a crash | ❌ Not built-in: sessions are ephemeral microVMs; durability is opt-in via Memory or a framework checkpointer | | **What it gives you** | Durable orchestration, steps, hooks, streaming, observability | Runtime (microVM hosting), Harness (managed agent loop), Memory, Gateway (tools/MCP), Identity, Browser, Code Interpreter, Observability, Policy, Evaluations | -| **Languages** | TypeScript / JS (Python beta) | Python-first for authoring; TypeScript or Python project scaffolding via the `@aws/agentcore` CLI; framework-agnostic (LangGraph, CrewAI, Strands, etc.) | +| **Languages** | TypeScript / JavaScript (Python beta) | Python-first for authoring; TypeScript or Python project scaffolding via the `@aws/agentcore` command-line interface (CLI); framework-agnostic (LangGraph, CrewAI, Strands, and others) | | **Isolation** | VM-sandboxed workflow code + full-Node steps | Dedicated Firecracker microVM per session (strong hardware isolation) | | **Max duration** | No limit ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 8-hour hard cap per session | -| **AI streaming** | Native **durable, resumable** streaming (survives reconnect, cold start, replay) | Live SSE / WebSocket only — no buffering or replay of missed tokens | -| **Versioning** | Runs pinned to immutable deployment | Immutable runtime versions + endpoints; in-flight sessions stay on their launch version (deployment/rollback only — no replay) | +| **AI streaming** | Native **durable, resumable** streaming (survives reconnect, cold start, replay) | Live server-sent events (SSE) / WebSocket only: no buffering or replay of missed tokens | +| **Versioning** | Runs pinned to immutable deployment | Immutable runtime versions + endpoints; in-flight sessions stay on their launch version (deployment/rollback only, no replay) | | **Portability** | Apache-2.0; runs anywhere Node runs; World abstraction | AWS-only; agent *code* is portable, the operating platform is not | -| **Compliance** | Inherits your platform | HIPAA-eligible; SOC/PCI/ISO not third-party certified; not FedRAMP-authorized | +| **Compliance** | Inherits your platform | HIPAA-eligible; SOC, PCI, and ISO not third-party certified; not FedRAMP-authorized | | **Pricing** | SDK free; pay your platform | Per-module consumption (vCPU-hr / GB-hr, etc.) + model inference billed via Bedrock; no minimums | -**What the limits mean in practice:** AgentCore's 8-hour session cap means an agent that waits on a human, a long-running job, or a slow external system can't span that wait in one session. Workflow SDK runs have [no duration cap](https://vercel.com/docs/workflows/pricing) — they suspend durably at `sleep()` and hooks for hours or weeks. +**What the limits mean in practice**: AgentCore's 8-hour session cap means an agent that waits on a human, a long-running job, or a slow external system can't span that wait in one session. Workflow SDK runs have [no duration cap](https://vercel.com/docs/workflows/pricing): they suspend durably at `sleep()` and hooks for hours or weeks. ## The core distinction: durability -AgentCore Runtime gives each session an isolated microVM with up to 8 hours of runtime, but the compute is **ephemeral** — on a crash or stop, the next invocation gets a fresh microVM with no automatic replay of the agent loop. AWS's own guidance is to use AgentCore Memory or a framework checkpointer for state durability, and to layer a workflow engine (Step Functions or Temporal) on top when you need durable orchestration. +AgentCore Runtime gives each session an isolated microVM with up to 8 hours of runtime, but the compute is **ephemeral**: on a crash or stop, the next invocation gets a fresh microVM with no automatic replay of the agent loop. AWS's own guidance is to use AgentCore Memory or a framework checkpointer for state durability, and to layer a workflow engine (Step Functions or Temporal) on top when you need durable orchestration. The Workflow SDK *is* that durable layer. With `WorkflowAgent` (in the [AI SDK](/docs/ai)), the agent loop becomes a durable workflow: each model call and tool execution is a checkpointed step, the run resumes mid-loop after a failure, and partial output streams to the client through [resumable streams](/docs/ai/resumable-streams) that survive disconnects and cold starts. @@ -45,11 +45,11 @@ The Workflow SDK *is* that durable layer. With `WorkflowAgent` (in the [AI SDK]( AgentCore is purpose-built for operating agents on AWS, and brings things the Workflow SDK doesn't try to be: -- **MicroVM isolation per session** — the strongest hardware isolation among the tools in this section. -- **Managed agent infrastructure** — Memory, a tool Gateway (turn APIs/Lambda/MCP servers into tools), and Identity (credential vaulting, OAuth) as first-class managed services. -- **Enterprise/AWS compliance breadth** and VPC/PrivateLink networking. +- **MicroVM isolation per session**: the strongest hardware isolation among the tools in this section. +- **Managed agent infrastructure**: Memory, a tool Gateway (turn APIs/Lambda/MCP servers into tools), and Identity (credential vaulting, OAuth) as first-class managed services. +- **Enterprise/AWS compliance breadth** and Virtual Private Cloud (VPC)/PrivateLink networking. -If your priority is running agents inside AWS with managed memory, tools, and identity, AgentCore is a strong fit. If your priority is **durable, resumable, streamable** agent execution that lives in your own TypeScript app and isn't tied to AWS, the Workflow SDK fits better — and the two can be combined (host on AgentCore, orchestrate durably with the Workflow SDK). +AgentCore fits applications that run agents inside AWS with managed memory, tools, and identity. The Workflow SDK fits **durable, resumable, streamable** agent execution in your own TypeScript app without an AWS dependency. You can also combine them by hosting on AgentCore and orchestrating durably with the Workflow SDK. --- -*Compiled from public documentation. AgentCore cold-start figures are community-sourced (no published SLA). Verify against [the AgentCore docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html). Not based on head-to-head benchmarks.* +*Compiled from public documentation. AgentCore cold-start figures are community-sourced (no published service-level agreement). Verify against [the AgentCore docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html). Not based on head-to-head benchmarks.* diff --git a/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-step-functions.mdx b/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-step-functions.mdx index eccadae01c..014af59825 100644 --- a/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-step-functions.mdx +++ b/docs/content/docs/v4/comparisons/workflow-sdk-vs-aws-step-functions.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs AWS Step Functions -description: How the Workflow SDK compares to AWS Step Functions — plain TypeScript control flow versus declarative Amazon States Language JSON, plus a concept-mapping migration guide. +description: 'How the Workflow SDK compares to AWS Step Functions: plain TypeScript control flow versus declarative Amazon States Language JSON, plus a concept-mapping migration guide.' type: conceptual summary: AWS Step Functions is a managed state-machine orchestrator authored in declarative ASL JSON. The Workflow SDK expresses the same orchestration as plain TypeScript. prerequisites: @@ -11,36 +11,36 @@ related: - /worlds/vercel --- -[AWS Step Functions](https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html) is a mature, managed orchestrator that runs **state machines defined in Amazon States Language (ASL)** — a declarative JSON DSL. The headline contrast with the Workflow SDK is the authoring model: you assemble a state machine (JSON or a visual editor) instead of writing plain control-flow code. +[AWS Step Functions](https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html) is a managed orchestrator that runs **state machines defined in Amazon States Language (ASL)**, a declarative JSON domain-specific language (DSL). Its authoring model differs from the Workflow SDK: you assemble a state machine with JSON or a visual editor instead of writing plain control-flow code. -**Choose the Workflow SDK** when you want orchestration as ordinary TypeScript (`await`, `if`, `Promise.all`, `try/catch`) that lives in your app, portable off a single cloud, with built-in streaming. **Choose Step Functions** when you're deep in the AWS ecosystem and want a managed engine with native optimized integrations to 200+ AWS services and the broadest compliance footprint. +**Choose the Workflow SDK** when you want orchestration as TypeScript (`await`, `if`, `Promise.all`, `try/catch`) that lives in your app, remains portable across clouds, and includes streaming. **Choose Step Functions** when you use the AWS ecosystem and want a managed engine with optimized integrations for more than 200 AWS services and extensive compliance coverage. ## At a glance | | Workflow SDK | AWS Step Functions | | --- | --- | --- | -| **Authoring** | Plain TypeScript: `"use workflow"` orchestrators calling `"use step"` functions | **Declarative ASL JSON** (or Workflow Studio visual editor / CDK) — not plain code | +| **Authoring** | Plain TypeScript: `"use workflow"` orchestrators calling `"use step"` functions | **Declarative ASL JSON** (or Workflow Studio visual editor / CDK), not plain code | | **Durability model** | Event log + deterministic replay | Managed state machine. **Standard** = exactly-once, up to 1 year; **Express** = at-least-once, up to 5 minutes | | **Control flow** | `await`, `if`/`switch`, `Promise.all`, `try/catch` | `Task` / `Choice` / `Wait` / `Parallel` / `Map` states wired with `Next` | | **Where it runs** | Your platform (Vercel managed or self-host) | AWS-managed; tasks run in Lambda or 200+ integrated AWS services | -| **Languages** | TypeScript / JS (Python beta) | ASL JSON for the machine; tasks can be any language (via Lambda) | +| **Languages** | TypeScript / JavaScript (Python beta) | ASL JSON for the machine; tasks can be any language (via Lambda) | | **Human-in-the-loop** | `createHook()` / `createWebhook()` | `.waitForTaskToken` callback (Standard only) | | **Streaming** | Native durable, resumable streaming to clients | No native client streaming | | **Versioning** | Runs pinned to immutable deployment | Published versions are immutable; aliases route (≤2 versions) for canary/rollback; **in-flight executions keep their start-time definition** | -| **Portability** | Apache-2.0; runs anywhere Node runs | Proprietary, AWS-only; ASL is AWS-specific — high lock-in | +| **Portability** | Apache-2.0; runs anywhere Node runs | Proprietary, AWS-only; ASL is AWS-specific (high lock-in) | | **Pricing** | SDK free; pay your platform | **Standard:** $0.025 / 1K state transitions. **Express:** $1 / M requests + GB-second duration | -| **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 256 KB payload between states; Standard 25K history events / 1 year; Express 5 minutes | +| **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 256 KB payload between states; Standard 25,000 history events / 1 year; Express 5 minutes | | **AI** | `WorkflowAgent` in the AI SDK; durable streaming | Bedrock integration + a preview AgentCore "InvokeHarness" task; no native client streaming | -**What the limits mean in practice:** the 256 KB cap on payloads between states is the binding constraint for AI workloads — virtually any model context or tool transcript has to round-trip through S3 with claim-check plumbing — and Standard executions cap history at 25K events. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. +**What the limits mean in practice**: The 256 KB cap on payloads between states constrains AI workloads because model context or tool transcripts may require S3 claim-check plumbing. Standard executions also cap history at 25,000 events. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. ## Code vs. JSON -The defining difference: in Step Functions even "call one Lambda" requires a state-machine shell, and orchestration logic is expressed as ASL states (`Choice`, `Wait`, `Parallel`, `Map`). In the Workflow SDK it's ordinary TypeScript — transitions are `await`, branches are `if`, parallelism is `Promise.all`, and error handling is `try/catch`. That keeps orchestration in the same language, repo, and tests as the rest of your app, and removes the orchestrator/compute split (per-task Lambdas, IAM roles, callback queues). +In Step Functions, even "call one Lambda" requires a state-machine shell, and orchestration logic is expressed as ASL states (`Choice`, `Wait`, `Parallel`, `Map`). In the Workflow SDK, transitions are `await`, branches are `if`, parallelism is `Promise.all`, and error handling is `try/catch`. This keeps orchestration in the same language, repository, and tests as the rest of your app, and removes the orchestrator/compute split of per-task Lambdas, identity and access management (IAM) roles, and callback queues. -The trade-off: Step Functions' optimized service integrations call AWS services (DynamoDB, SQS, EventBridge, Bedrock, `ecs:runTask.sync`, …) declaratively. In the Workflow SDK those become ordinary SDK calls inside `"use step"` functions — you own the credentials, retries, and any polling. +The trade-off: Step Functions' optimized service integrations call AWS services (DynamoDB, SQS, EventBridge, Bedrock, `ecs:runTask.sync`, …) declaratively. In the Workflow SDK those become ordinary SDK calls inside `"use step"` functions: you own the credentials, retries, and any polling. ## Migrating from Step Functions @@ -53,7 +53,7 @@ This guide assumes **Standard** workflows. Express workflows have different sema | Choice state | `if` / `else` / `switch` | Native control flow. | | Wait state | `sleep()` | `sleep('1m')` or `sleep(date)`. | | Parallel state | `Promise.all()` | Standard concurrency. | -| Map state | `for` loop / bounded `Promise.all` (e.g. `p-limit`) / step-wrapped `start()` per item for large fan-out | Match the original concurrency mode. | +| Map state | `for` loop / bounded `Promise.all` (for example, `p-limit`) / step-wrapped `start()` per item for large fan-out | Match the original concurrency mode. | | Retry / Catch | `maxRetries`, `RetryableError`, `FatalError`; `try/catch` for compensation | Retry logic moves to step boundaries. | | `.waitForTaskToken` | `createHook()` / `createWebhook()` | Hooks for typed signals; webhooks for HTTP. | | Child state machine (`StartExecution`) | `"use step"` wrapper around `start()` / `getRun()` | Return the `Run` object for deep-linking. | @@ -74,7 +74,7 @@ async function loadOrder(id: string) { } ``` -A `.waitForTaskToken` callback becomes a hook — no SQS queue, task token, or callback Lambda: +A `.waitForTaskToken` callback becomes a hook, with no SQS queue, task token, or callback Lambda: ```typescript title="workflows/refund.ts" import { createHook } from 'workflow'; diff --git a/docs/content/docs/v4/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx b/docs/content/docs/v4/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx index 9db90611b3..200c0c85d3 100644 --- a/docs/content/docs/v4/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +++ b/docs/content/docs/v4/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs Cloudflare Workflows -description: How the Workflow SDK compares to Cloudflare Workflows — both are durable replay engines, but they handle versioning, encryption, portability, and global distribution very differently. +description: How the Workflow SDK compares to Cloudflare Workflows. Both are durable replay engines, but they handle versioning, encryption, portability, and global distribution differently. type: conceptual summary: Cloudflare Workflows is a durable engine on Workers and Durable Objects. It and the Workflow SDK both replay, but differ on versioning safety, encryption, and lock-in. prerequisites: @@ -11,10 +11,10 @@ related: - /worlds/building-a-world --- -[Cloudflare Workflows](https://developers.cloudflare.com/workflows/) is a durable-execution engine built on Cloudflare Workers and SQLite-backed Durable Objects. It's the closest architectural peer to the Workflow SDK — both persist progress and replay to survive failures — which makes the differences in **versioning safety, encryption, and portability** the deciding factors. +[Cloudflare Workflows](https://developers.cloudflare.com/workflows/) is a durable-execution engine built on Cloudflare Workers and SQLite-backed Durable Objects. It's the closest architectural peer to the Workflow SDK (both persist progress and replay to survive failures), which makes the differences in **versioning safety, encryption, and portability** the deciding factors. -**Choose the Workflow SDK** when you want an open-source, portable engine that runs in your existing app (and off a single vendor), deployment-pinned versioning, and application-level E2E encryption. **Choose Cloudflare Workflows** when you're already all-in on Cloudflare. +**Choose the Workflow SDK** when you want an open-source, portable engine that runs in your existing app without depending on one vendor, deployment-pinned versioning, and application-level end-to-end (E2E) encryption. **Choose Cloudflare Workflows** when your application already runs on Cloudflare. ## At a glance @@ -23,34 +23,34 @@ related: | --- | --- | --- | | **Category** | Open-source durable-functions SDK; portable backends | Durable execution engine, hosted on Cloudflare | | **Durability model** | Event log + deterministic replay | Step-result **memoization** in SQLite-backed Durable Objects + deterministic re-calculation ("game-loop") | -| **Authoring** | `"use workflow"` / `"use step"` in plain async TS, in your app | Class extends `WorkflowEntrypoint`; explicit `step.do(name, cb)` wrapping; Cloudflare Workers only | -| **Where it runs** | Your platform (Vercel managed, or self-host) | Cloudflare only — both orchestration and execution run on-network (engine ↔ step over internal RPC) | -| **Versioning** | Runs pinned to their immutable deployment — safe by default | **No version pinning** — running instances resume on the *latest* deployed code; changing step names/order can desync the cached replay. No patching API | +| **Authoring** | `"use workflow"` / `"use step"` in plain async TypeScript, in your app | Class extends `WorkflowEntrypoint`; explicit `step.do(name, cb)` wrapping; Cloudflare Workers only | +| **Where it runs** | Your platform (Vercel managed, or self-host) | Cloudflare only: both orchestration and execution run on-network (engine ↔ step over internal remote procedure calls) | +| **Versioning** | Runs pinned to their immutable deployment, safe by default | **No version pinning**: running instances resume on the *latest* deployed code; changing step names/order can desync the cached replay. No patching API | | **Encryption** | Per-run AES-256-GCM **end-to-end** encryption | **At-rest only** (AES-256, Cloudflare-managed keys) + TLS; no E2E, no customer-managed keys | | **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | Durable agents via Workflows + the Agents SDK; resumable streaming buffered in SQLite (mid-call eviction needs opt-in `chatRecovery`) | -| **Performance** | No-penalty resume; scale-to-zero; up to 100K concurrency (Vercel) | **~0 ms isolate cold starts**; 50K concurrent instances; global Anycast (330+ cities) — strongest cold-start and edge story | +| **Performance** | No-penalty resume; scale-to-zero; up to 100,000 concurrency (Vercel) | **Approximately 0 ms isolate cold starts**; 50,000 concurrent instances; global Anycast (more than 330 cities) | | **Portability** | Apache-2.0; World abstraction; self-hostable | Engine proprietary; tied to Durable Objects; **highest lock-in** of these tools | -| **Pricing** | SDK free; pay your platform | Workers Standard: requests + CPU-time + storage + per-step ($0.80 / 100K steps); idle/sleep not billed | -| **Limits** | 50 MB payloads; 2 GB/run; 10K steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | **1 MiB** step result & event payload; 1 GB state/instance; 10K steps (up to 25K) | +| **Pricing** | SDK free; pay your platform | Workers Standard: requests + CPU-time + storage + per-step ($0.80 / 100,000 steps); idle/sleep not billed | +| **Limits** | 50 MB payloads; 2 GB/run; 10,000 steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | **1 MiB** step result and event payload; 1 GB state/instance; 10,000 steps (up to 25,000) | -**What the limits mean in practice:** Cloudflare's 1 MiB cap on step results and event payloads is the tightest in this section — a single large model response or document can exceed it, pushing anything sizable into R2/KV indirection — and instance state is capped at 1 GB. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. +**What the limits mean in practice**: Cloudflare's 1 MiB cap on step results and event payloads is the tightest in this section (a single large model response or document can exceed it, pushing anything sizable into R2/KV indirection), and instance state is capped at 1 GB. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. ## Versioning: pinned vs. live code -Both engines replay, so changing code mid-run is the key hazard — and they take opposite approaches: +Both engines replay, so changing code mid-run is the key hazard, and they take opposite approaches: -- **Cloudflare** does not pin a running instance to a code version. When an instance resumes (after a sleep, a wait, or a deploy), it runs against whatever code is currently deployed. Because step names act as the replay cache key, reordering, renaming, or inserting steps before already-completed ones can desync the replay of an in-flight instance. There is no patching API — just the documented "[Rules of Workflows](https://developers.cloudflare.com/workflows/build/rules-of-workflows/)" you must follow by hand. +- **Cloudflare** does not pin a running instance to a code version. When an instance resumes (after a sleep, a wait, or a deploy), it runs against whatever code is currently deployed. Because step names act as the replay cache key, reordering, renaming, or inserting steps before already-completed ones can desync the replay of an in-flight instance. There is no patching API, only the documented "[Rules of Workflows](https://developers.cloudflare.com/workflows/build/rules-of-workflows/)" you must follow by hand. - **Workflow SDK** pins each run to the immutable deployment that started it, so a deploy never disturbs in-flight runs. Evolving code is safe by default and upgrades are explicit. ## Encryption and portability -Cloudflare encrypts Durable Object data at rest with Cloudflare-managed keys, but there is no application-level / end-to-end encryption and no customer-managed-key option — payloads are visible to the platform. The Workflow SDK encrypts each run's inputs, outputs, step I/O, and streams with a per-run AES-256-GCM key. +Cloudflare encrypts Durable Object data at rest with Cloudflare-managed keys, but there is no application-level / end-to-end encryption and no customer-managed-key option, so payloads are visible to the platform. The Workflow SDK encrypts each run's inputs, outputs, step I/O, and streams with a per-run AES-256-GCM key. On portability, Cloudflare Workflows is the most locked-in of the tools in this section: the API and Durable-Object-bound state are Cloudflare-specific, so moving means a rewrite. The Workflow SDK is Apache-2.0 and its [World abstraction](/worlds/building-a-world) lets you run the same code on Vercel, on Postgres, or on a backend you build. ## Where Cloudflare leads -Credit where due: Cloudflare's V8-isolate model gives **near-zero cold starts**, and code runs across its global Anycast network with no region selection. For latency-sensitive, globally-distributed workloads on Cloudflare's platform, that's a genuine strength. The Workflow SDK's performance depends on the World it runs on; on Vercel it benefits from Fluid Compute and a 100K concurrency ceiling, with multi-region rolling out. +Cloudflare's V8-isolate model provides **near-zero cold starts**, and code runs across its global Anycast network with no region selection. This model supports latency-sensitive, globally distributed workloads on Cloudflare's platform. The Workflow SDK's performance depends on the World it runs on; on Vercel, it uses Fluid Compute and supports up to 100,000 concurrent runs, with multi-region support rolling out. ## Moving from Cloudflare Workflows diff --git a/docs/content/docs/v4/comparisons/workflow-sdk-vs-inngest.mdx b/docs/content/docs/v4/comparisons/workflow-sdk-vs-inngest.mdx index 3d1404cd9e..806eadb7e5 100644 --- a/docs/content/docs/v4/comparisons/workflow-sdk-vs-inngest.mdx +++ b/docs/content/docs/v4/comparisons/workflow-sdk-vs-inngest.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs Inngest -description: How the Workflow SDK compares to Inngest — event-driven durable functions that run on your own infrastructure over HTTP, with a concept-mapping migration guide. +description: How the Workflow SDK compares to Inngest, event-driven durable functions that run on your own infrastructure over HTTP, with a concept-mapping migration guide. type: conceptual summary: Inngest is an event-driven durable-functions platform that invokes your code over HTTP and memoizes step results. The Workflow SDK co-locates orchestration and execution and replays from an event log. prerequisites: @@ -11,10 +11,10 @@ related: - /docs/ai --- -[Inngest](https://www.inngest.com) is a durable-functions platform with an **event-driven** core: functions trigger on events or cron, and Inngest invokes your code — running on your own infrastructure — one step at a time over HTTP, memoizing each step's result. It overlaps heavily with the Workflow SDK, with a different execution topology and a strong AI/agent story on both sides. +[Inngest](https://www.inngest.com) is a durable-functions platform with an **event-driven** core: functions trigger on events or cron, and Inngest invokes your code (running on your own infrastructure) one step at a time over HTTP, memoizing each step's result. It overlaps heavily with the Workflow SDK, with a different execution topology and a strong AI/agent story on both sides. -**Choose the Workflow SDK** when you want orchestration and execution co-located (no per-step HTTP round-trips), deployment-pinned versioning, and a fully-supported open-source self-host path. **Choose Inngest** when an event-bus model fits your architecture and you want its mature flow-control suite (concurrency, throttling, debounce, batching, priority) out of the box. +**Choose the Workflow SDK** when you want orchestration and execution co-located (no per-step HTTP round trips), deployment-pinned versioning, and a supported open-source self-hosting path. **Choose Inngest** when an event-bus model fits your architecture and you want built-in flow controls for concurrency, throttling, debounce, batching, and priority. ## At a glance @@ -22,34 +22,34 @@ related: | | Workflow SDK | Inngest | | --- | --- | --- | | **Category** | Open-source durable-functions SDK | Durable functions on an event-driven platform | -| **Durability model** | Event log + deterministic replay | **Step-result memoization** (each `step.run` runs once; completed steps are skipped) — not whole-function replay | +| **Durability model** | Event log + deterministic replay | **Step-result memoization** (each `step.run` runs once; completed steps are skipped), not whole-function replay | | **Trigger model** | Direct `start(workflow, [args])` (import the function) | Event bus (`inngest.send`) + cron; loosely coupled publishers/consumers | -| **Where execution runs** | Orchestration + execution co-located on your platform | **Your code runs on your infra**; Inngest invokes it per step over HTTP (Serve) or a persistent worker connection (Connect, in public beta) | -| **Languages** | TypeScript / JS (Python beta) | TypeScript; Python & Go (production, pre-1.0) | -| **Versioning** | Runs pinned to immutable deployment | Tracked by step-ID hashes — hot-edit functions, but editing a step's logic under the **same ID** reuses the old memoized result for in-flight runs; rename the ID or route a new function by timestamp for rewrites | -| **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | Native AI SDK via `step.ai.wrap`; **AgentKit** multi-agent framework and durable **Realtime** (`step.realtime.publish`) + `useAgent` hook — both Developer Preview | -| **Security** | Zero-config per-run E2E encryption by default; platform security is per-World (the Vercel World inherits Vercel's security posture) | Runs on your infra; signed requests; first-party E2E encryption middleware (TS + Python); SOC 2 Type II, HIPAA add-on | -| **Portability** | Apache-2.0; World abstraction; **self-host supported** | Engine open source (SSPL); self-host via single Go binary + Postgres, but **self-hosting is community/best-effort** — no support SLA, and the `inngest start` binary is Beta (SaaS is the default) | +| **Where execution runs** | Orchestration + execution co-located on your platform | **Your code runs on your infrastructure**; Inngest invokes it per step over HTTP (Serve) or a persistent worker connection (Connect, in public beta) | +| **Languages** | TypeScript / JavaScript (Python beta) | TypeScript; Python and Go (production, pre-1.0) | +| **Versioning** | Runs pinned to immutable deployment | Tracked by step-ID hashes: hot-edit functions, but editing a step's logic under the **same ID** reuses the old memoized result for in-flight runs; rename the ID or route a new function by timestamp for rewrites | +| **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | Native AI SDK via `step.ai.wrap`; **AgentKit** multi-agent framework and durable **Realtime** (`step.realtime.publish`) + `useAgent` hook, both Developer Preview | +| **Security** | Zero-config per-run end-to-end (E2E) encryption by default; platform security is per World (the Vercel World inherits Vercel's security posture) | Runs on your infrastructure; signed requests; first-party E2E encryption middleware (TypeScript and Python); SOC 2 Type II, HIPAA add-on | +| **Portability** | Apache-2.0; World abstraction; **self-host supported** | Engine open source (Server Side Public License); self-host via single Go binary + Postgres, but **self-hosting is community/best-effort**: no support service-level agreement, and the `inngest start` binary is Beta (the hosted service is the default) | | **Pricing** | SDK free; pay your platform | Per-execution: billed for the run **plus each step plus retries**; Pro from $99/mo, then ~$50 per 1M executions | -| **Limits** | 50 MB payload; 2 GB/run; 10K steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 1,000 steps/function; 4 MiB step payload; 32 MiB run state; runs up to 366 days | +| **Limits** | 50 MB payload; 2 GB/run; 10,000 steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 1,000 steps/function; 4 MiB step payload; 32 MiB run state; runs up to 366 days | -**What the limits mean in practice:** Inngest's caps are restrictive for real long-running AI workloads. An agent loop spends steps on every model and tool call, so 1,000 steps per function goes quickly; a single large LLM response can approach the 4 MiB step-payload cap; and an accumulated conversation or context easily outgrows 32 MiB of run state. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. +**What the limits mean in practice**: Inngest's caps are restrictive for real long-running AI workloads. An agent loop spends steps on every model and tool call, so it can consume 1,000 steps per function; a single large language model response can approach the 4 MiB step-payload cap; and an accumulated conversation or context can outgrow 32 MiB of run state. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. ## Topology: co-located vs. invoked-over-HTTP -Inngest's engine lives outside your code and calls your functions step-by-step over HTTP (or a Connect worker). That keeps your code on your own infra (a portability and data-locality plus), but adds a network round-trip per step — relevant for workflows with many small sequential steps. The Workflow SDK co-locates orchestration and execution on one platform, so steps don't pay a per-step HTTP hop. +Inngest's engine lives outside your code and calls your functions step-by-step over HTTP (or a Connect worker). That keeps your code on your own infrastructure for portability and data locality, but adds a network round trip per step. This overhead matters for workflows with many small sequential steps. The Workflow SDK co-locates orchestration and execution on one platform, so steps don't require a per-step HTTP request. -Triggering differs too: Inngest is event-driven (publishers `send` events; functions subscribe), which is great for loosely-coupled fan-out. The Workflow SDK's `start()` imports the workflow function directly — tighter coupling, stronger type safety. For event-bus-style fan-out, wrap `start()` in a shared publisher. +Triggering also differs. Inngest is event-driven (publishers `send` events; functions subscribe), which supports loosely coupled fan-out. The Workflow SDK's `start()` imports the workflow function directly, providing tighter coupling and stronger type safety. For event-bus-style fan-out, wrap `start()` in a shared publisher. ## Versioning -Inngest doesn't use version numbers; it keys state by **step-ID hash**, so you can edit functions while runs are in flight. The catch: if you change the logic *inside* a step but keep the same ID, in-flight runs that already completed that step silently reuse the **old** memoized result — only new runs see the change. To force re-execution you rename the step ID, and for incompatible rewrites the recommended pattern is a new function with timestamp-based event routing. +Inngest doesn't use version numbers; it keys state by **step-ID hash**, so you can edit functions while runs are in flight. The catch: if you change the logic *inside* a step but keep the same ID, in-flight runs that already completed that step silently reuse the **old** memoized result: only new runs see the change. To force re-execution you rename the step ID, and for incompatible rewrites the recommended pattern is a new function with timestamp-based event routing. -The Workflow SDK pins each run to its immutable deployment, so in-flight runs always finish on the exact code they started with, and upgrades are explicit — no per-step-ID reasoning required. +The Workflow SDK pins each run to its immutable deployment, so in-flight runs always finish on the exact code they started with, and upgrades are explicit: no per-step-ID reasoning required. ## AI agents -Both are strong here. Inngest offers `step.ai.wrap()` (wrap Vercel AI SDK calls as durable steps), **AgentKit** (a multi-agent framework with MCP tools), and durable **Realtime** streaming with a `useAgent` React hook — though AgentKit and Realtime are both Developer Preview. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK and native [resumable streaming](/docs/ai/resumable-streams). If you're already event-driven and want a batteries-included agent framework, AgentKit is compelling; if you want the agent loop to *be* a durable workflow in your app with streaming built into the runtime, the Workflow SDK fits. +Inngest offers `step.ai.wrap()` (wrap Vercel AI SDK calls as durable steps), **AgentKit** (a multi-agent framework with Model Context Protocol (MCP) tools), and durable **Realtime** streaming with a `useAgent` React hook, though AgentKit and Realtime are both Developer Preview. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK and native [resumable streaming](/docs/ai/resumable-streams). AgentKit fits event-driven applications that need an integrated agent framework. The Workflow SDK fits applications that need the agent loop to be a durable workflow with streaming built into the runtime. ## Migrating from Inngest diff --git a/docs/content/docs/v4/comparisons/workflow-sdk-vs-temporal.mdx b/docs/content/docs/v4/comparisons/workflow-sdk-vs-temporal.mdx index 65a1e3b24c..0d35e3ba0d 100644 --- a/docs/content/docs/v4/comparisons/workflow-sdk-vs-temporal.mdx +++ b/docs/content/docs/v4/comparisons/workflow-sdk-vs-temporal.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs Temporal -description: How the Workflow SDK compares to Temporal — execution model, where workers run, versioning, AI agents, pricing, and a concept-mapping migration guide. +description: 'How the Workflow SDK compares to Temporal: execution model, where workers run, versioning, AI agents, pricing, and a concept-mapping migration guide.' type: conceptual summary: Temporal is a mature, language-agnostic durable-execution platform where you run the workers. The Workflow SDK runs in your existing app and pins runs to immutable deployments. prerequisites: @@ -12,10 +12,10 @@ related: - /worlds/vercel --- -[Temporal](https://temporal.io) is the most mature durable-execution platform — battle-tested at large scale, with seven language SDKs. It and the Workflow SDK share the same core idea (durable orchestration via event-sourced replay), so the real differences are operational: **where your code runs, how you version it, and how it streams to clients.** +[Temporal](https://temporal.io) is a durable-execution platform with seven language SDKs and large-scale production use. Temporal and the Workflow SDK share the same core model of durable orchestration through event-sourced replay. The operational differences are **where your code runs, how you version it, and how it streams to clients.** -**Choose the Workflow SDK** when you want durable execution inside your existing TypeScript app with nothing extra to operate, deployment-pinned versioning, and native streaming for AI apps. **Choose Temporal** when you need polyglot SDKs (Go/Java/etc.), want a self-hostable control plane you fully own, or are standardizing a large org on one orchestration backend across many languages. +**Choose the Workflow SDK** when you want durable execution inside your existing TypeScript app with no additional infrastructure to operate, deployment-pinned versioning, and native streaming for AI apps. **Choose Temporal** when you need SDKs for languages such as Go and Java, want a self-hostable control plane you own, or are standardizing a large organization on one orchestration backend across many languages. ## At a glance @@ -23,22 +23,22 @@ related: | | Workflow SDK | Temporal | | --- | --- | --- | | **Category** | Open-source durable-functions SDK; managed on Vercel or self-hosted | Durable-execution platform; Temporal Cloud or self-hosted cluster | -| **Durability model** | Event log + deterministic replay (`"use workflow"` orchestrators, `"use step"` functions) | Event-sourced replay (Workflows + Activities). Same model — `"use workflow"` ≈ Workflow, `"use step"` ≈ Activity | -| **Languages** | TypeScript / JS (Python beta) | Go, Java, TypeScript, Python, .NET, PHP, Ruby (7 SDKs) | -| **Where execution runs** | Orchestration + execution + observability co-located on your platform; private networking and E2E encryption out of the box on Vercel | **You run and scale your own Workers.** Temporal Cloud hosts orchestration only; workers connect outbound over the public internet (PrivateLink optional) | -| **Versioning** | Runs pinned to their immutable deployment — safe by default; opt-in `deploymentId: 'latest'` to upgrade | Editing workflow code can break in-flight runs (non-determinism errors); evolve safely via patch APIs or Worker Versioning (keep old worker fleets draining) | -| **AI SDK & agents** | `WorkflowAgent` ships in the AI SDK; durable agent loop; **native resumable streaming** (`getWritable`/`getReadable`, `WorkflowChatTransport`) | First-party `@temporalio/ai-sdk` and "Workflow Streams" — both **Public Preview**; streaming rides on Signals/Updates (batched, history-bound) | -| **Security** | Zero-config per-run AES-256-GCM E2E encryption by default; platform security is per-World (the Vercel World inherits Vercel's security posture) | Workers run your code on your infra (never enters Temporal's plane); client-side E2E via a Codec Server you operate. Cloud: SOC 2 II, HIPAA, GDPR | -| **Performance** | No-penalty resume; serverless scale-to-zero (true suspension); up to 100K concurrency on Vercel | Self-managed workers are long-running; Cloud namespace default 500 actions/sec (auto-scales) | +| **Durability model** | Event log + deterministic replay (`"use workflow"` orchestrators, `"use step"` functions) | Event-sourced replay (Workflows + Activities). Same model: `"use workflow"` ≈ Workflow, `"use step"` ≈ Activity | +| **Languages** | TypeScript / JavaScript (Python beta) | Go, Java, TypeScript, Python, .NET, PHP, Ruby (7 SDKs) | +| **Where execution runs** | Orchestration + execution + observability co-located on your platform; private networking and end-to-end (E2E) encryption included on Vercel | **You run and scale your own Workers.** Temporal Cloud hosts orchestration only; workers connect outbound over the public internet (PrivateLink optional) | +| **Versioning** | Runs pinned to their immutable deployment, safe by default; opt-in `deploymentId: 'latest'` to upgrade | Editing workflow code can break in-flight runs (non-determinism errors); evolve safely via patch APIs or Worker Versioning (keep old worker fleets draining) | +| **AI SDK & agents** | `WorkflowAgent` ships in the AI SDK; durable agent loop; **native resumable streaming** (`getWritable`/`getReadable`, `WorkflowChatTransport`) | First-party `@temporalio/ai-sdk` and "Workflow Streams", both **Public Preview**; streaming rides on Signals/Updates (batched, history-bound) | +| **Security** | Zero-config per-run AES-256-GCM E2E encryption by default; platform security is per World (the Vercel World inherits Vercel's security posture) | Workers run your code on your infrastructure (never enters Temporal's plane); client-side E2E via a Codec Server you operate. Cloud: SOC 2 II, HIPAA, GDPR | +| **Performance** | No-penalty resume; serverless scale-to-zero (true suspension); up to 100,000 concurrency on Vercel | Self-managed workers are long-running; Cloud namespace default 500 actions/sec (auto-scales) | | **Portability** | Apache-2.0 SDK; World abstraction swaps storage/queue/streams independently | MIT server; pluggable persistence (Cassandra/Postgres/MySQL), but an opinionated monolithic backend you run or pay for | -| **Pricing** | SDK free; pay your platform (Vercel: events + data) or just your infra if self-hosted | Self-host = free software; Temporal Cloud bills per Action (from $50/M) + storage | -| **Limits** | No run/sleep cap; 10K steps, 50 MB payload, 2 GB/run ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | No run cap (Continue-As-New for long histories); event history capped at 51,200 events / 50 MB; 2 MB payloads | +| **Pricing** | SDK free; pay your platform (Vercel: events + data) or only your infrastructure if self-hosted | Self-host = free software; Temporal Cloud bills per Action (from $50 per million) + storage | +| **Limits** | No run/sleep cap; 10,000 steps, 50 MB payload, 2 GB/run ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | No run cap (Continue-As-New for long histories); event history capped at 51,200 events / 50 MB; 2 MB payloads | -**What the limits mean in practice:** Temporal caps payloads at 2 MB and event history at 51,200 events, which binds quickly for AI workloads — a large model context, tool transcript, or embedding batch routinely exceeds 2 MB, forcing external blob storage and claim-check plumbing, and long agent loops must be split with Continue-As-New before the history fills. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) (50 MB payloads, 2 GB of state per run, no run or sleep caps) leave room to keep full contexts in the run itself. +**What the limits mean in practice**: Temporal caps payloads at 2 MB and event history at 51,200 events. A model context, tool transcript, or embedding batch that exceeds 2 MB requires external blob storage and claim-check plumbing. Long agent loops must use Continue-As-New before the history fills. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) (50 MB payloads, 2 GB of state per run, and no run or sleep caps) provide more space for contexts in the run itself. ## The biggest difference: what you operate -Temporal Cloud manages the durable engine, but **you still build, deploy, and scale a fleet of Workers** that poll task queues and run your Workflow and Activity code. Those workers connect *out* to Temporal Cloud, typically over the public internet (AWS PrivateLink / GCP Private Service Connect are available as same-region options). Readable observability requires you to stand up a **Codec Server** so the Web UI can decrypt payloads your data converter encrypted. +Temporal Cloud manages the durable engine, but **you still build, deploy, and scale a fleet of Workers** that poll task queues and run your Workflow and Activity code. Those workers connect *out* to Temporal Cloud, typically over the public internet (AWS PrivateLink and Google Cloud Private Service Connect are available as same-region options). To make encrypted payloads readable in the Web UI, you must run a **Codec Server**. With the Workflow SDK on Vercel, orchestration, execution, and observability are co-located on one platform with internal networking, and per-run E2E encryption is on by default with no codec server to run. There are no workers, task queues, or a control plane to operate. (Self-hosting via the [Postgres World](/worlds/postgres) is the closest analog to running your own Temporal cluster.) @@ -46,14 +46,14 @@ With the Workflow SDK on Vercel, orchestration, execution, and observability are This is where the two differ most in day-to-day risk. Because both replay code against history, *changing* workflow code mid-flight is the hazard. -- **Temporal:** simply editing a workflow can produce a non-determinism error that breaks or stalls open executions. You evolve safely with **patch APIs** (`patched()` / `GetVersion()`, which accumulate branch cruft) or **Worker Versioning** (Build IDs / Worker Deployments pin workflows to a build and keep the old worker fleet running until it drains). It works, but the burden is on you for every long-running workflow. -- **Workflow SDK:** runs are **pinned to the immutable deployment that started them**. Shipping new code never touches in-flight runs — they keep replaying against the exact code they began on. Upgrading a run is explicit and opt-in (start it with `deploymentId: 'latest'`, or self-restart at a checkpoint). Safe by default, no patch branches, no draining worker fleets. +- **Temporal**: Editing a workflow can produce a non-determinism error that breaks or stalls open executions. You can evolve workflows with **patch APIs** (`patched()` / `GetVersion()`, which accumulate patch branches) or **Worker Versioning** (Build IDs / Worker Deployments pin workflows to a build and keep the old worker fleet running until it drains). You must manage this process for every long-running workflow. +- **Workflow SDK**: Runs are **pinned to the immutable deployment that started them**. Shipping new code never touches in-flight runs because they keep replaying against the exact code they began on. Upgrading a run is explicit and opt-in (start it with `deploymentId: 'latest'`, or self-restart at a checkpoint). This model requires no patch branches or draining worker fleets. ## AI agents and streaming Both target AI agents, but the integration depth differs. The Workflow SDK's `WorkflowAgent` is a first-class construct **inside the AI SDK** (`@ai-sdk/workflow`): the agent loop becomes a durable workflow, each tool `execute` marked `"use step"` is an auto-retried durable step, and partial output streams through [durable, resumable streams](/docs/ai/resumable-streams) that survive reconnects and cold starts. -Temporal ships a first-party `@temporalio/ai-sdk` plugin and a "Workflow Streams" library, but both are **Public Preview**, and streaming is built on Signals/Updates — every chunk is written to history, so it's batched rather than per-token. +Temporal ships a first-party `@temporalio/ai-sdk` plugin and a "Workflow Streams" library, but both are **Public Preview**, and streaming is built on Signals/Updates: every chunk is written to history, so it's batched rather than per-token. ## Migrating from Temporal @@ -70,7 +70,7 @@ The model maps closely. Keep your orchestration logic; drop the workers, task qu | Activity retry policy | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary. | | Event History | Workflow event log / run timeline | Same durable replay; built-in observability UI. | -A minimal translation — the orchestrator loses `proxyActivities` and becomes plain TypeScript: +A minimal translation, where the orchestrator loses `proxyActivities` and becomes plain TypeScript: ```typescript title="workflows/order.ts" export async function processOrder(orderId: string) { @@ -85,7 +85,7 @@ async function chargePayment(orderId: string) { } ``` -Signals become hooks — one `createHook()` + `await` replaces a signal definition, handler, and `condition()` guard: +Signals become hooks, where one `createHook()` + `await` replaces a signal definition, handler, and `condition()` guard: ```typescript title="workflows/refund.ts" import { createHook } from 'workflow'; diff --git a/docs/content/docs/v4/comparisons/workflow-sdk-vs-trigger-dev.mdx b/docs/content/docs/v4/comparisons/workflow-sdk-vs-trigger-dev.mdx index 2087a9854c..3d638acac2 100644 --- a/docs/content/docs/v4/comparisons/workflow-sdk-vs-trigger-dev.mdx +++ b/docs/content/docs/v4/comparisons/workflow-sdk-vs-trigger-dev.mdx @@ -1,8 +1,8 @@ --- title: Workflow SDK vs trigger.dev -description: How the Workflow SDK compares to trigger.dev — deterministic event-log replay versus CRIU process checkpoint/restore, plus a concept-mapping migration guide. +description: How the Workflow SDK compares to trigger.dev, including deterministic event-log replay versus CRIU process checkpoint/restore and a concept-mapping migration guide. type: conceptual -summary: trigger.dev achieves durability by snapshotting the process (CRIU), so code has no determinism constraints. The Workflow SDK uses event-log replay and runs in your existing app. +summary: trigger.dev achieves durability by snapshotting the process with Checkpoint/Restore in Userspace (CRIU), so code has no determinism constraints. The Workflow SDK uses event-log replay and runs in your existing app. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -11,10 +11,10 @@ related: - /docs/ai --- -[trigger.dev](https://trigger.dev) is an open-source, TypeScript-first durable task platform. Its defining trait is *how* it achieves durability: instead of replaying code, it **snapshots the whole process** (CRIU checkpoint/restore) at each wait point and restores it later. That single design choice drives most of the differences with the Workflow SDK. +[trigger.dev](https://trigger.dev) is an open-source, TypeScript-first durable task platform. Instead of replaying code, it **snapshots the whole process** with Checkpoint/Restore in Userspace (CRIU) at each wait point and restores it later. This design drives most of the differences with the Workflow SDK. -**Choose the Workflow SDK** when you want durable orchestration that runs in your existing app, a portable open-source backend you can fully self-host, TypeScript *and* Python, and broad framework support. **Choose trigger.dev** when you want a managed task platform with no determinism constraints (code runs as-is), and you're TypeScript-only. +**Choose the Workflow SDK** when you want durable orchestration that runs in your existing app, a portable open-source backend you can self-host, TypeScript and Python, and broad framework support. **Choose trigger.dev** when you want a managed task platform with no determinism constraints and use only TypeScript. ## At a glance @@ -22,30 +22,30 @@ related: | | Workflow SDK | trigger.dev | | --- | --- | --- | | **Category** | Open-source durable-functions SDK that runs in your app | Durable task platform with its own runtime (Cloud or self-hosted) | -| **Durability model** | Event log + **deterministic replay** (workflow body must be deterministic) | **Process checkpoint/restore (CRIU)** — snapshots memory/CPU/FDs; **no determinism constraints**, code runs as-is | +| **Durability model** | Event log + **deterministic replay** (workflow body must be deterministic) | **Process checkpoint/restore (CRIU)**: snapshots memory, CPU, and file descriptors; **no determinism constraints**, code runs as-is | | **Authoring** | `"use workflow"` / `"use step"` in your existing app | `task()` / `schemaTask()` deployed to trigger.dev as a separate target (Docker image) | -| **Languages** | TypeScript / JS (Python beta) | **TypeScript / JS only** | +| **Languages** | TypeScript / JavaScript (Python beta) | **TypeScript / JavaScript only** | | **Where it runs** | Co-located with your app (Vercel managed or self-host) | trigger.dev's run engine (isolated containers) | -| **Versioning** | Runs pinned to immutable deployment — safe by default | **Atomic versioning** — runs lock to their deploy version; new deploys never touch in-flight runs (same safety property) | -| **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | First-class: AI SDK tools, native `useChat` transport, resumable Realtime, durable multi-turn Sessions, HITL via `wait.forToken` | +| **Versioning** | Runs pinned to immutable deployment, safe by default | **Atomic versioning**: runs lock to their deploy version; new deploys never touch in-flight runs (same safety property) | +| **AI and streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | AI SDK tools, native `useChat` transport, resumable Realtime, durable multi-turn Sessions, human-in-the-loop (HITL) via `wait.forToken` | | **Concurrency control** | Enforce in steps / at the publisher | First-class queues + concurrency keys | -| **Portability** | Apache-2.0; World abstraction; runs anywhere Node runs | Apache-2.0; self-host on Docker/K8s — but CRIU needs a compatible host (heavier than plain Docker); TS-only | +| **Portability** | Apache-2.0; World abstraction; runs anywhere Node runs | Apache-2.0; self-host on Docker/Kubernetes, but CRIU needs a compatible host (heavier than plain Docker); TypeScript-only | | **Pricing** | SDK free; pay your platform | Cloud: compute-seconds + per-run ($0.0000338/s Small + $0.000025/run); no charge while checkpointed | -| **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 3 MB payload / 10 MB output; 14-day max run TTL; CPU-time-based max duration | +| **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 3 MB payload / 10 MB output; 14-day maximum run lifetime; CPU-time-based maximum duration | -**What the limits mean in practice:** trigger.dev caps task payloads at 3 MB and outputs at 10 MB — large model contexts and transcripts need external storage — and the 14-day run TTL means human-in-the-loop flows that wait longer than two weeks can't complete in one run. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB payloads, 2 GB of state per run, and no run-duration cap. +**What the limits mean in practice**: trigger.dev caps task payloads at 3 MB and outputs at 10 MB (large model contexts and transcripts need external storage), and the 14-day maximum run time means human-in-the-loop flows that wait longer than two weeks can't complete in one run. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB payloads, 2 GB of state per run, and no run-duration cap. ## The core difference: checkpoint/restore vs. replay -trigger.dev freezes the entire OS process with CRIU when a task hits a wait point, then restores it later — so there's **no replay and no determinism rule**: you can call `Date.now()` or `Math.random()` anywhere, and prior steps don't re-execute. The cost is an execution model that requires CRIU-capable infrastructure (which makes self-hosting heavier than a plain container) and runs on trigger.dev's runtime as a separate deploy target. +trigger.dev freezes the entire operating system process with CRIU when a task hits a wait point, then restores it later, so there's **no replay and no determinism rule**: you can call `Date.now()` or `Math.random()` anywhere, and prior steps don't re-execute. The cost is an execution model that requires CRIU-capable infrastructure (which makes self-hosting heavier than a plain container) and runs on trigger.dev's runtime as a separate deploy target. The Workflow SDK reconstructs state by **replaying the workflow function** against its event log. That requires the workflow body to be deterministic (side effects go in `"use step"` functions), but it runs inside your existing app and deployment with no special host requirements, and the [World abstraction](/worlds/building-a-world) lets you swap the storage/queue/stream layers. -Notably, **both pin runs to a version** so deploys never corrupt in-flight work — trigger.dev via atomic version-locking, the Workflow SDK via immutable-deployment pinning. +Notably, **both pin runs to a version** so deploys never corrupt in-flight work: trigger.dev via atomic version-locking, the Workflow SDK via immutable-deployment pinning. ## AI agents -Both invest heavily in AI. trigger.dev offers AI SDK tool wrapping, a native `useChat` transport over its Realtime layer, resumable streaming, and durable multi-turn Sessions. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK plus native [resumable streaming](/docs/ai/resumable-streams). Both support human-in-the-loop (trigger.dev's `wait.forToken`, the Workflow SDK's hooks). The deciding factors are usually language (trigger.dev is TS-only; the Workflow SDK adds Python) and whether you want the agent to run in your app vs. on a dedicated platform. +trigger.dev offers AI SDK tool wrapping, a native `useChat` transport over its Realtime layer, resumable streaming, and durable multi-turn Sessions. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK plus native [resumable streaming](/docs/ai/resumable-streams). Both support human-in-the-loop workflows (trigger.dev's `wait.forToken` and the Workflow SDK's hooks). Key differences are language support (trigger.dev is TypeScript-only; the Workflow SDK adds Python) and whether the agent runs in your app or on a dedicated platform. ## Migrating from trigger.dev @@ -61,7 +61,7 @@ Both invest heavily in AI. trigger.dev offers AI SDK tool wrapping, a native `us | `metadata.stream()` / Realtime | `getWritable()` / named streams | Clients read from the stream. | | `AbortTaskRunError` | `FatalError` | Stops retries immediately. | -The `task()` factory collapses into a plain function — and because the workflow body is replayed, move side effects into steps: +The `task()` factory collapses into a plain function, and because the workflow body is replayed, move side effects into steps: ```typescript title="workflows/order.ts" export async function processOrder(orderId: string) { @@ -78,7 +78,7 @@ async function loadOrder(orderId: string) { ``` -trigger.dev's `run` body has full Node.js access. The Workflow SDK's `"use workflow"` body runs in a sandboxed VM — side effects (`fetch`, `Date.now()`, `Math.random()`, DB access) must live inside `"use step"` functions. Orchestration stays in the workflow body. +trigger.dev's `run` body has full Node.js access. The Workflow SDK's `"use workflow"` body runs in a sandboxed virtual machine (VM). Side effects (`fetch`, `Date.now()`, `Math.random()`, and database access) must live inside `"use step"` functions. Orchestration stays in the workflow body. diff --git a/docs/content/docs/v4/cookbook/advanced/child-workflows.mdx b/docs/content/docs/v4/cookbook/advanced/child-workflows.mdx index b11b10f5de..ba0926a6cc 100644 --- a/docs/content/docs/v4/cookbook/advanced/child-workflows.mdx +++ b/docs/content/docs/v4/cookbook/advanced/child-workflows.mdx @@ -2,7 +2,7 @@ title: Child Workflows description: Spawn child workflows from a parent and wait for completion via hook resume. type: guide -summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait() — the child resumes the parent's hook when done instead of polling getRun().status. +summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait(). The child resumes the parent's hook when done instead of polling getRun().status. related: - /docs/api-reference/workflow-api/start --- @@ -11,16 +11,16 @@ related: text="Refactor this workflow to use child workflows. Keep the parent as an exported `"use workflow"` function. Move independent units of durable work into separate exported child workflow functions. From the parent, call `start(childWorkflow, [args])` from `workflow/api` or the documented `startAndWait`/hook pattern where completion must resume the parent. Pass only serializable state to children. For fan-out, start children in parallel with `Promise.all` or bounded batches, collect run IDs, handle partial failures with `Promise.allSettled`, and use `getRun(runId)` when status, cancellation, streams, or return values are needed. Verify child start, completion, failure, and parent resume behavior." /> -Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings. +Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope. If one child fails, it doesn't take down the parent or siblings. ## When to use child workflows Child workflows are the right choice when: -- **Work units are independent.** Each child can run without knowing about the others (e.g., processing individual documents, generating separate reports). -- **You need isolated failure boundaries.** A failing child should not abort unrelated work. The parent decides how to handle failures. -- **You want massive fan-out.** Spawning 50 or 500 children is practical because each runs on its own infrastructure. -- **You need per-item observability.** Each child workflow has its own run ID, status, and event log for monitoring. +- **Work units are independent**: Each child can run without knowing about the others (for example, when processing individual documents or generating separate reports). +- **You need isolated failure boundaries**: A failing child should not abort unrelated work. The parent decides how to handle failures. +- **You want large fan-out**: Spawning 50 or 500 children is practical because each runs on its own infrastructure. +- **You need per-item observability**: Each child workflow has its own run ID, status, and event log for monitoring. For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead. @@ -28,7 +28,7 @@ For simpler cases where steps share a single event log, use [direct await compos The recommended pattern has four parts: -1. A **completion hook** the parent creates and awaits — zero compute while waiting +1. A **completion hook** the parent creates and awaits, with zero compute while waiting 2. A **wrapped child export** that runs the real child in try/catch/finally and resumes the parent's hook from a step in `finally` 3. A **spawn step** that calls `start()` with the wrapped child and the hook token 4. A **`startAndWait()` helper** that ties the hook, spawn, and typed result together @@ -87,7 +87,7 @@ async function withChildCompletionHook( } } -// Child workflow -- processes a single document +// Child workflow: processes a single document export async function processDocument(documentId: string) { "use workflow"; @@ -98,7 +98,7 @@ export async function processDocument(documentId: string) { return { documentId, summary }; } -// Spawnable wrapper -- explicit export so `start()` can register it +// Spawnable wrapper: explicit export so `start()` can register it export async function processDocumentWithCompletion( documentId: string, completionTokenArg: string @@ -141,7 +141,7 @@ async function startAndWait( return completion.value as TResult; } -// Parent workflow -- orchestrates document processing +// Parent workflow: orchestrates document processing export async function processDocumentBatch(documentIds: string[]) { "use workflow"; @@ -161,12 +161,12 @@ export async function processDocumentBatch(documentIds: string[]) { Polling with `getRun().status` in a `sleep()` loop works, but hook resume is preferable because: -- **Zero compute while waiting** — the parent suspends on the hook instead of waking every poll interval -- **Immediate wake-up** — the parent resumes as soon as the child finishes, not on the next poll tick -- **Typed payloads** — the child sends `{ status, value | error }` directly; no separate `returnValue` fetch step -- **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/v5/docs/changelog/eager-processing)) +- **Zero compute while waiting**: The parent suspends on the hook instead of waking every poll interval. +- **Immediate wake-up**: The parent resumes as soon as the child finishes, not on the next poll tick. +- **Typed payloads**: The child sends `{ status, value | error }` directly, with no separate `returnValue` fetch step. +- **No worker-pool pressure**: `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/v5/docs/changelog/eager-processing)). -When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works — pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`. +When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works: pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`. ## Fan-out pattern: chunked spawning @@ -253,7 +253,7 @@ declare function withChildCompletionHook( ### Tolerating partial failures -Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }` — no status polling required. +Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }`, so no status polling is required. ```typescript declare function startAndWait( @@ -324,19 +324,19 @@ async function startAndWaitWithRetries( ## Tips -- **`start()` must be called from a step** in v4, not directly from a workflow function. Bake the wrapped workflow reference into the step — don't pass workflow functions as step arguments. -- **`defineHook().resume()` must be called from a step.** The wrapped child's `finally` block calls a step that resumes the parent hook. -- **Export wrapped children at module scope.** The SDK registers `"use workflow"` functions statically — a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`. -- **Use stable hook keys** — document ID, job ID, or index — so parallel children inside one parent run don't collide on tokens. -- **Use chunked spawning for large batches.** Spawning 500 children in a single step can time out. Break it into chunks of 10-50. -- **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook. -- **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations. +- **Call `start()` from a step in v4**: Don't call it directly from a workflow function. Bake the wrapped workflow reference into the step, and don't pass workflow functions as step arguments. +- **Call `defineHook().resume()` from a step**: The wrapped child's `finally` block calls a step that resumes the parent hook. +- **Export wrapped children at module scope**: The SDK registers `"use workflow"` functions statically, so a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`. +- **Use stable hook keys**: Document IDs, job IDs, or indexes prevent token collisions between parallel children in one parent run. +- **Use chunked spawning for large batches**: Spawning 500 children in a single step can time out. Break the work into chunks of 10-50. +- **Account for each child's retry semantics**: Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook. +- **Use `deploymentId: "latest"` when children should run on the most recent deployment**: See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations. ## Key APIs -- [`start()`](/docs/api-reference/workflow-api/start) -- spawn a new workflow run and get its run ID -- [`defineHook()`](/docs/api-reference/workflow/define-hook) -- typed hook for parent/child completion handshakes -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resume a waiting parent from a step (called by the child wrapper) -- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) -- read the parent run ID for deterministic hook tokens -- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access +- [`start()`](/docs/api-reference/workflow-api/start): Spawns a new workflow run and returns its run ID. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a typed hook for parent-child completion handshakes. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resumes a waiting parent from a step called by the child wrapper. +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Returns the parent run ID for deterministic hook tokens. +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions with full Node.js access. diff --git a/docs/content/docs/v4/cookbook/advanced/distributed-abort-controller.mdx b/docs/content/docs/v4/cookbook/advanced/distributed-abort-controller.mdx index 8eb36cd236..183fa74338 100644 --- a/docs/content/docs/v4/cookbook/advanced/distributed-abort-controller.mdx +++ b/docs/content/docs/v4/cookbook/advanced/distributed-abort-controller.mdx @@ -9,24 +9,24 @@ summary: Build a distributed abort controller that uses workflow streams and hoo text="Build a distributed AbortController backed by a durable workflow. Define `abortHook` with `defineHook<{ reason?: string }>()` from `workflow` and derive a deterministic token from the user-provided ID (for example `abort:${id}`). In the coordination workflow, create the hook with that token, then race the hook against `sleep(ttl)` from `workflow`; when either fires, write an abort message to the run's stream via `getWritable()` in a "use step" helper. In the client class, make `create()` idempotent by checking `getHookByToken(token)` from `workflow/api` before `start()`; implement `.signal` by reading the run's stream via `getRun(runId).readable` and triggering a local `AbortController`; implement `.abort(reason)` by resuming the hook so every process sharing the ID gets the signal. Add an API route for remote aborts and wire the UI cancel button to it. Verify same-process abort, cross-process abort, TTL expiry, and reconnecting to an existing controller." /> -Use this pattern when you need an `AbortController`-like interface that works across distributed systems. The controller uses a durable workflow to coordinate cancellation — calling `.abort()` on one machine triggers the `.signal` on any other machine. +Use this pattern when you need an `AbortController`-like interface that works across distributed systems. The controller uses a durable workflow to coordinate cancellation: calling `.abort()` on one machine triggers the `.signal` on any other machine. ## When to use this -- **Cross-process cancellation** — Cancel a long-running operation from a different server, worker, or edge function -- **Durable cancellation** — The abort signal persists even if the process that created it crashes -- **UI stop buttons** — Let users cancel operations running on the server from the browser -- **Timeout coordination** — The built-in TTL auto-expires stale controllers +- **Cross-process cancellation**: Cancel a long-running operation from a different server, worker, or edge function. +- **Durable cancellation**: The abort signal persists even if the process that created it crashes. +- **User interface (UI) stop buttons**: Let users cancel operations running on the server from the browser. +- **Timeout coordination**: The built-in time to live (TTL) automatically expires stale controllers. ## Pattern The `DistributedAbortController` class encapsulates a workflow that: -1. Accepts a user-provided unique ID (like a chat ID or task ID) -2. Creates or reconnects to an existing workflow using that ID -3. Waits for a hook signal OR TTL expiration -4. Writes a cancellation message to the run's stream when triggered +1. Accepts a user-provided unique ID, such as a chat ID or task ID. +2. Creates or reconnects to an existing workflow using that ID. +3. Waits for a hook signal or TTL expiration. +4. Writes a cancellation message to the run's stream when triggered. -### Core Implementation +### Core implementation ```typescript lineNumbers import { defineHook, getWritable, sleep } from "workflow"; @@ -77,7 +77,7 @@ export async function abortControllerWorkflow( const startTime = Date.now(); const hook = abortHook.create({ token: getAbortToken(id) }); - // Race: manual abort OR TTL expiration // [!code highlight] + // Race: manual abort or TTL expiration // [!code highlight] const result = await Promise.race([ hook.then((payload) => ({ reason: payload.reason, @@ -106,7 +106,7 @@ export async function abortControllerWorkflow( /** * A distributed abort controller that works across process boundaries. - * Uses a semantically meaningful ID (like a chat ID or task ID) to coordinate. + * Uses a meaningful ID, such as a chat ID or task ID, to coordinate. */ export class DistributedAbortController { private id: string; @@ -122,8 +122,8 @@ export class DistributedAbortController { * If a controller with this ID already exists, reconnects to it. * Otherwise, starts a new workflow. * - * @param id - A unique, semantically meaningful ID (e.g., "chat:123") - * @param options.ttlMs - Time-to-live in ms (default: 24 hours) + * @param id - A unique, meaningful ID (for example, "chat:123") + * @param options.ttlMs - Time to live in ms (default: 24 hours) * @param options.graceMs - Grace period after abort (default: 1 hour) */ static async create( // [!code highlight] @@ -201,7 +201,7 @@ export class DistributedAbortController { } ``` -### Usage: Single Process +### Usage: single process ```typescript lineNumbers import { DistributedAbortController } from "./distributed-abort-controller"; @@ -216,10 +216,10 @@ const response = await fetch("https://api.example.com/long-operation", { }); // Later: abort the operation -await controller.abort("User cancelled"); +await controller.abort("User canceled"); ``` -### Usage: Cross-Process Coordination +### Usage: cross-process coordination ```typescript lineNumbers import { DistributedAbortController } from "./distributed-abort-controller"; @@ -230,12 +230,12 @@ const controller = await DistributedAbortController.create("task:build-123"); // Process B: Reconnect and abort (no run ID sharing needed!) const sameController = await DistributedAbortController.create("task:build-123"); // [!code highlight] -await sameController.abort("Cancelled by admin"); +await sameController.abort("Canceled by admin"); // Process C: Reconnect and listen const anotherRef = await DistributedAbortController.create("task:build-123"); anotherRef.signal.addEventListener("abort", (e) => { - console.log("Task was cancelled:", (e.target as AbortSignal).reason); + console.log("Task was canceled:", (e.target as AbortSignal).reason); }); ``` @@ -244,7 +244,7 @@ anotherRef.signal.addEventListener("abort", (e) => { ```typescript lineNumbers import { DistributedAbortController } from "./distributed-abort-controller"; -// Short-lived controller for a quick operation (5 minutes) +// Short-lived controller for a brief operation (5 minutes) const shortLived = await DistributedAbortController.create("quick-task", { ttlMs: 5 * 60 * 1000, }); @@ -263,7 +263,7 @@ shortLived.signal.addEventListener("abort", (e) => { }); ``` -### API Route for Remote Abort +### API route for remote abort ```typescript lineNumbers import { DistributedAbortController } from "@/lib/distributed-abort-controller"; @@ -276,13 +276,13 @@ export async function POST( const { reason } = await request.json(); const controller = await DistributedAbortController.create(id); - await controller.abort(reason || "Cancelled via API"); + await controller.abort(reason || "Canceled via API"); return Response.json({ success: true }); } ``` -### Client Cancel Button +### Client cancel button ```tsx lineNumbers "use client"; @@ -306,17 +306,17 @@ export function CancelButton({ taskId }: { taskId: string }) { ## Tips -- **Use semantic IDs** — Use meaningful IDs like `chat:123` or `task:abc` instead of random UUIDs -- **Create is idempotent** — Calling `create()` with the same ID reconnects to the existing controller -- **TTL auto-cleanup** — Workflows self-terminate after TTL expires; no manual cleanup needed -- **Signal is a getter** — Each access to `.signal` creates a new listener; cache it if needed -- **One-shot** — Once aborted or expired, the workflow completes; create a new controller for new operations +- **Use meaningful IDs**: Use IDs like `chat:123` or `task:abc` instead of random UUIDs. +- **Create controllers idempotently**: Calling `create()` with the same ID reconnects to the existing controller. +- **Use TTL cleanup**: Workflows terminate after the TTL expires, so no manual cleanup is needed. +- **Cache the signal if needed**: Each access to `.signal` creates a new listener. +- **Treat controllers as one-shot**: Once aborted or expired, the workflow completes. Create a new controller for new operations. ## Key APIs -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the abort trigger -- [`getWritable()`](/docs/api-reference/workflow/get-writable) — write abort messages to the stream -- [`sleep()`](/docs/api-reference/workflow/sleep) — TTL timer for auto-expiration -- [`start()`](/docs/api-reference/workflow-api/start) — start the abort controller workflow -- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) — find existing run by hook token -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — reconnect to the workflow's readable stream +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a type-safe hook for the abort trigger. +- [`getWritable()`](/docs/api-reference/workflow/get-writable): Writes abort messages to the stream. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides the TTL timer for automatic expiration. +- [`start()`](/docs/api-reference/workflow-api/start): Starts the abort controller workflow. +- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token): Finds an existing run by hook token. +- [`getRun()`](/docs/api-reference/workflow-api/get-run): Reconnects to the workflow's readable stream. diff --git a/docs/content/docs/v4/cookbook/advanced/publishing-libraries.mdx b/docs/content/docs/v4/cookbook/advanced/publishing-libraries.mdx index 71fc8294af..3bc30ae967 100644 --- a/docs/content/docs/v4/cookbook/advanced/publishing-libraries.mdx +++ b/docs/content/docs/v4/cookbook/advanced/publishing-libraries.mdx @@ -2,7 +2,7 @@ title: Publishing Libraries description: Structure and publish npm packages that export workflow functions for consumers to use with Workflow SDK. type: guide -summary: Learn how to build, export, and test npm packages that ship workflow and step functions — including package.json exports, re-exporting so the consumer's compiler discovers your workflows, keeping step I/O clean, and integration testing. +summary: Learn how to build, export, and test npm packages that ship workflow and step functions, including package.json exports, re-exporting so the consumer's compiler discovers your workflows, keeping step I/O clean, and integration testing. --- -## Package Structure +## Package structure A workflow library follows a standard TypeScript package layout with a dedicated `workflows/` directory. Each workflow file exports one or more workflow functions that consumers can import and pass to `start()`. @@ -44,14 +44,14 @@ A workflow library follows a standard TypeScript package layout with a dedicated Key files: -- **`src/index.ts`** — Package entry point. Exports the public API. -- **`src/types.ts`** — Shared TypeScript types. -- **`src/workflows/index.ts`** — Re-exports every workflow so consumers can pull them in under one specifier (see [Entry Points and Exports](#entry-points-and-exports)). -- **`src/workflows/*.ts`** — One file per workflow function (e.g. `transcode.ts`, `generate-thumbnails.ts`). -- **`src/lib/`** — Internal helpers. Plain async code, *not* marked with `"use workflow"` or `"use step"`. -- **`test-server/workflows.ts`** — Re-export file used by integration tests (see [Testing Workflow Libraries](#testing-workflow-libraries)). +- **`src/index.ts`**: Package entry point that exports the public API. +- **`src/types.ts`**: Shared TypeScript types. +- **`src/workflows/index.ts`**: Re-exports every workflow so consumers can pull them in under one specifier (see [Entry points and exports](#entry-points-and-exports)). +- **`src/workflows/*.ts`**: One file per workflow function (for example, `transcode.ts` or `generate-thumbnails.ts`). +- **`src/lib/`**: Internal helpers with plain async code that is not marked with `"use workflow"` or `"use step"`. +- **`test-server/workflows.ts`**: Re-export file used by integration tests (see [Test workflow libraries](#test-workflow-libraries)). -### Entry Points and Exports +### Entry points and exports Use the `exports` field in `package.json` to expose separate entry points for the main API and the raw workflow functions: @@ -75,7 +75,7 @@ Use the `exports` field in `package.json` to expose separate entry points for th The main entry point (`@acme/media`) exports types, utilities, and convenience wrappers. The `./workflows` entry point (`@acme/media/workflows`) exports the raw workflow functions that consumers need for the build system. -### Source Files +### Source files The package entry re-exports workflows alongside any utilities: @@ -93,7 +93,7 @@ export * from "./transcode"; export * from "./generate-thumbnails"; ``` -### Build Configuration +### Build configuration Use a bundler like `tsup` with separate entry points for each export. Mark `workflow` as external so it's resolved from the consumer's project: @@ -114,13 +114,13 @@ export default defineConfig({ }); ``` -## Re-Exporting for Compiler Discovery +## Re-exporting for compiler discovery The workflow compiler only transforms files it discovers, and discovery starts from the consumer's `workflows/` directory and follows imports out from there. A library's workflow functions are not on that graph by default, so nothing compiles them and the runtime has no definition to run. The fix is a **re-export file**. The consumer creates a file in their `workflows/` directory that re-exports the library's workflows, which pulls the library's source onto the discovery graph and gives its entry point an address the runtime can resolve. -### Consumer Setup +### Consumer setup ```typescript lineNumbers // workflows/media.ts (in the consumer's project) @@ -130,7 +130,7 @@ export * from "@acme/media/workflows"; // [!code highlight] This one-line file is all that's needed. The compiler follows the re-export into the package, transforms the workflow and step functions it finds, and registers them under IDs the runtime can resolve. -### Why This Is Necessary +### Why this is necessary Without re-exporting, the workflow runtime cannot match a running workflow to its function definition. When a run is replayed after a cold start, the runtime looks up functions by their compiler-assigned IDs. If those functions were never compiled, the IDs don't exist and replay fails. @@ -144,13 +144,13 @@ That is safe on worlds with deployment pinning, such as Vercel, because runs are The re-export file does not change any of this. An ID is derived from where the file lives, not from how it was imported, so a package file keeps its `name@version` ID whether or not a consumer re-exports it. -## Keeping Step I/O Clean +## Keeping step I/O clean When you publish a workflow library, every step function's inputs and outputs are recorded in the event log. This has two implications: -### 1. Everything Must Be Serializable +### 1. Everything must be serializable -Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON — including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/foundations/serialization#custom-class-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return: +Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON, including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/foundations/serialization#custom-class-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return: - Functions or closures - `WeakRef`, `WeakMap`, or `WeakSet` @@ -169,7 +169,7 @@ async function callExternalApi(endpoint: string, params: Record) // Bad: pass a pre-constructed client object async function callExternalApi(client: ApiClient, params: Record) { "use step"; - // ApiClient is not serializable — this will fail on replay + // ApiClient is not serializable, so this will fail on replay return await client.request(params); } ``` @@ -199,13 +199,13 @@ async function fetchData(apiKey: string, query: string) { The choice is a matter of library API design preference. Resolving from environment variables keeps the step signature simpler, while passing credentials explicitly makes dependencies visible and can be easier to test. -## Testing Workflow Libraries +## Testing workflow libraries -Library authors need integration tests that exercise workflows through the full Workflow SDK runtime — not just unit tests of individual functions. +Library authors need integration tests that exercise workflows through the full Workflow SDK runtime, rather than only unit tests of individual functions. -### Test Server Pattern +### Test server pattern -Create a minimal test server that re-exports your library's workflows, just like a consumer would: +Create a minimal test server that re-exports your library's workflows, like a consumer would: ```typescript lineNumbers // test-server/workflows.ts @@ -214,7 +214,7 @@ export * from "@acme/media/workflows"; // [!code highlight] This test server acts as a stand-in consumer app. Point your test runner at it to exercise the full workflow lifecycle: start, replay, and completion. -### Vitest Configuration +### Vitest configuration Use a dedicated Vitest config for integration tests that run against the Workflow SDK runtime: @@ -241,22 +241,22 @@ pnpm vitest run tests/unit pnpm vitest run --config vitest.workflowsdk.config.ts ``` -### What to Test +### What to test -- **Happy path**: workflow starts, all steps execute, and the final result is correct -- **Serialization round-trip**: inputs and outputs survive the event log -- **Replay**: kill and restart a workflow mid-execution to verify deterministic replay -- **Error handling**: verify that step failures produce the expected errors +- **Happy path**: The workflow starts, all steps execute, and the final result is correct. +- **Serialization round-trip**: Inputs and outputs survive the event log. +- **Replay**: Stop and restart a workflow during execution to verify deterministic replay. +- **Error handling**: Step failures produce the expected errors. -## Working With and Without Workflow Installed +## Working with and without Workflow installed -Some libraries want to be useful to consumers who *aren't* using Workflow SDK at all — the library picks up durable behavior when a workflow runtime is present and falls back to plain async execution otherwise. +Some libraries need to support consumers who *aren't* using Workflow SDK. The library gains durable behavior when a workflow runtime is present and falls back to plain async execution otherwise. Two rules for isomorphic packages: -1. **Any runtime reference to the `workflow` package must be loaded via dynamic `import("workflow")` inside a try/catch.** A static top-level import makes the module fail to load for consumers who haven't installed workflow. -2. **The `"use workflow"` and `"use step"` directives are safe to keep in your library source.** When a consumer compiles your code with the Workflow SDK toolchain (via the [re-export pattern](#re-exporting-for-compiler-discovery) above), the SWC plugin transforms them into durable-execution glue. When they're not compiled — plain Node, plain tests, a consumer without the runtime — they are just string expression statements and run as no-ops. +1. **Load any runtime reference to the `workflow` package through dynamic `import("workflow")` inside a try/catch.** A static top-level import makes the module fail to load for consumers who haven't installed Workflow. +2. **Keep the `"use workflow"` and `"use step"` directives in your library source.** When a consumer compiles your code with the Workflow SDK toolchain (via the [re-export pattern](#re-export-for-compiler-discovery) above), the SWC plugin transforms the directives into durable-execution glue. When the directives aren't compiled (plain Node.js, plain tests, or a consumer without the runtime), they are string expression statements and run as no-ops. ### Optional peer dependency @@ -294,7 +294,7 @@ async function getWorkflowStepId(): Promise { // [!code highlight ### A concrete use case: replay-safe idempotency keys -A payments utility that uses the current workflow step ID as a Stripe idempotency key when available, and a fresh UUID otherwise: +A payments utility can use the current workflow step ID as a Stripe idempotency key when available and a fresh universally unique identifier (UUID) otherwise: ```typescript lineNumbers declare function getWorkflowStepId(): Promise; // @setup (defined in the previous block) @@ -315,14 +315,14 @@ export async function processPayment(amount: number, currency: string) { } ``` -When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated. For more patterns, see [Idempotency](/docs/foundations/idempotency). +When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries, so Stripe deduplicates retries. When called from a plain Node.js process, it behaves like any other function and generates a fresh UUID. For more patterns, see [Idempotency](/docs/foundations/idempotency). ### In production Packages in the wild built on Workflow SDK: -- **[`@mux/ai`](https://github.com/muxinc/ai)** — Reusable video AI workflows (summaries, chapters, content moderation, translation, embeddings) exported with `"use workflow"` / `"use step"` directives. In a standard Node environment the directives are no-ops and the SDK runs as a plain async library; in a Workflow SDK environment the consumer's compiler transforms them into durable, resumable steps with automatic retries and observability. Written up in detail in [*How Mux shipped durable video workflows with their @mux/ai SDK*](https://vercel.com/blog/how-mux-shipped-durable-video-workflows-with-their-mux-ai-sdk) on the Vercel blog. -- **World ID** — Human-in-the-loop "proof of human" primitive for agent workflows. Developers drop a World ID step into any workflow to require a zero-knowledge cryptographic proof that a real, unique human authorized a specific action (deploy approvals, large payments, sensitive data access, etc.). Because it runs as a workflow step, every verification is durable, replay-safe, and viewable inside the run's execution timeline — giving you a provable audit record of which human approved what. Available on npm and announced in [*World ID for agents: Browserbase, Exa, Okta, and Vercel*](https://world.org/blog/announcements/browserbase-exa-okta-world-id-for-agentic-web) on the World blog. +- **[`@mux/ai`](https://github.com/muxinc/ai)**: Reusable video AI workflows (summaries, chapters, content moderation, translation, embeddings) exported with `"use workflow"` / `"use step"` directives. In a standard Node environment the directives are no-ops and the SDK runs as a plain async library; in a Workflow SDK environment the consumer's compiler transforms them into durable, resumable steps with automatic retries and observability. Written up in detail in [*How Mux shipped durable video workflows with their @mux/ai SDK*](https://vercel.com/blog/how-mux-shipped-durable-video-workflows-with-their-mux-ai-sdk) on the Vercel blog. +- **World ID**: Human-in-the-loop "proof of human" primitive for agent workflows. Developers drop a World ID step into any workflow to require a zero-knowledge cryptographic proof that a real, unique human authorized a specific action (deploy approvals, large payments, sensitive data access, etc.). Because it runs as a workflow step, every verification is durable, replay-safe, and viewable inside the run's execution timeline, giving you a provable audit record of which human approved what. Available on npm and announced in [*World ID for agents: Browserbase, Exa, Okta, and Vercel*](https://world.org/blog/announcements/browserbase-exa-okta-world-id-for-agentic-web) on the World blog. ## Checklist @@ -339,7 +339,7 @@ Before publishing a workflow library: ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks functions for durable execution -- [`start`](/docs/api-reference/workflow-api/start) — starts a workflow run -- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — runtime detection and run ID access +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Marks functions for durable execution. +- [`start`](/docs/api-reference/workflow-api/start): Starts a workflow run. +- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata): Provides runtime detection and run ID access. diff --git a/docs/content/docs/v4/cookbook/advanced/serializable-steps.mdx b/docs/content/docs/v4/cookbook/advanced/serializable-steps.mdx index ddd04ab6e5..f7559e3d4c 100644 --- a/docs/content/docs/v4/cookbook/advanced/serializable-steps.mdx +++ b/docs/content/docs/v4/cookbook/advanced/serializable-steps.mdx @@ -10,7 +10,7 @@ related: --- @@ -21,12 +21,12 @@ This is an advanced guide. It dives into workflow internals and is not required Workflow functions run inside a sandboxed VM where every value that crosses a function boundary must be serializable. There are two ways to get a non-serializable object across that boundary, depending on whether you own the class: -- **You own the class** — implement the [`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` protocol](/docs/foundations/serialization#custom-class-serialization). The instance becomes a first-class serializable value: you can pass it as a workflow input, return it from a step, and call `"use step"` instance methods on it directly. This is the right tool when the class is yours to modify. -- **You don't own the class** — you can't add methods to `openai("gpt-4o")` from `@ai-sdk/openai` or `new S3Client({...})` from `@aws-sdk/client-s3`. Instead, wrap construction in a `"use step"` factory function and pass the factory across the boundary. That's what this page covers. +- **You own the class**: implement the [`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` protocol](/docs/foundations/serialization#custom-class-serialization). The instance becomes a first-class serializable value: you can pass it as a workflow input, return it from a step, and call `"use step"` instance methods on it directly. This is the right tool when the class is yours to modify. +- **You don't own the class**: you can't add methods to `openai("gpt-4o")` from `@ai-sdk/openai` or `new S3Client({...})` from `@aws-sdk/client-s3`. Instead, wrap construction in a `"use step"` factory function and pass the factory across the boundary. That's what this page covers. -## The Problem +## The problem -AI SDK model providers — `openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc. — return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error, and you can't bolt `WORKFLOW_SERIALIZE` onto a third-party class. +AI SDK model providers (`openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc.) return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error, and you can't bolt `WORKFLOW_SERIALIZE` onto a third-party class. ```typescript lineNumbers import { openai } from "@ai-sdk/openai"; @@ -39,7 +39,7 @@ export async function brokenAgent(prompt: string) { const writable = getWritable(); const agent = new DurableAgent({ - // This fails — the model object is not serializable + // This fails: the model object is not serializable model: openai("gpt-4o"), }); @@ -47,9 +47,9 @@ export async function brokenAgent(prompt: string) { } ``` -## The Solution: Step-as-Factory +## The solution: step-as-factory -Instead of passing the model object, pass a **callback function** that returns the model. Marking that callback with `"use step"` tells the compiler to serialize the *function reference* (which is just a string identifier) rather than its return value. The provider is only instantiated at execution time, inside the step's full Node.js runtime. +Instead of passing the model object, pass a **callback function** that returns the model. Marking that callback with `"use step"` tells the compiler to serialize the *function reference* (which is a string identifier) rather than its return value. The provider is only instantiated at execution time, inside the step's full Node.js runtime. ```typescript lineNumbers import { openai as openaiProvider } from "@ai-sdk/openai"; @@ -63,12 +63,12 @@ export function openai(...args: Parameters) { } ``` -The `DurableAgent` receives a function (`() => Promise`) instead of a model object. When the agent needs to call the LLM, it invokes the factory inside a step where the real provider can be constructed with full Node.js access. +The `DurableAgent` receives a function (`() => Promise`) instead of a model object. When the agent needs to call the large language model (LLM), it invokes the factory inside a step where the real provider can be constructed with full Node.js access. -## How `@workflow/ai` Uses This +## How `@workflow/ai` uses this -`@workflow/ai`'s pre-wrapped providers and `DurableAgent` are deprecated. AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) resolves models from AI Gateway model strings (e.g. `"openai/gpt-4o"`), which usually removes the need for a model factory — see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The serialization pattern on this page still applies to any non-serializable dependency you own (for example, cloud SDK clients). +`@workflow/ai`'s pre-wrapped providers and `DurableAgent` are deprecated. AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) resolves models from AI Gateway model strings (for example, `"openai/gpt-4o"`), which usually removes the need for a model factory; see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The serialization pattern on this page still applies to any non-serializable dependency you own (for example, cloud SDK clients). The `@workflow/ai` package ships pre-wrapped providers for all major AI SDK backends. Each one follows the same pattern: @@ -105,14 +105,14 @@ export async function chatAgent(prompt: string) { } ``` -## Writing Your Own Serializable Wrapper +## Writing your own serializable wrapper Apply the same pattern to any non-serializable dependency. The key rule: **the outer function captures serializable arguments, and the inner `"use step"` function constructs the real object at runtime**. ```typescript lineNumbers import type { S3Client as S3ClientType } from "@aws-sdk/client-s3"; -// The arguments (region, bucket) are plain strings — serializable +// The arguments (region, bucket) are plain strings, which are serializable export function createS3Client(region: string) { return async (): Promise => { "use step"; @@ -141,15 +141,15 @@ async function uploadFile( } ``` -## Why This Works +## Why this works -1. **Compiler transformation**: `"use step"` tells the SWC plugin to extract the function into a separate bundle. The workflow VM only sees a serializable reference (function ID + captured arguments). -2. **Closure tracking**: The compiler tracks which variables the step function closes over. Only serializable values (strings, numbers, plain objects) can be captured. -3. **Deferred construction**: The actual provider/client is only constructed when the step executes in the Node.js runtime — never in the sandboxed workflow VM. +1. **Compiler transformation**: `"use step"` tells the SWC plugin to extract the function into a separate bundle. The workflow VM only sees a serializable reference (function ID and captured arguments). +2. **Closure tracking**: The compiler tracks which variables the step function closes over. The function can capture only serializable values, such as strings, numbers, and plain objects. +3. **Deferred construction**: The step constructs the provider or client only when it executes in the Node.js runtime, never in the sandboxed workflow VM. ## Key APIs -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks a function for extraction and serialization -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent (resolves models via AI Gateway strings; replaces `DurableAgent`) -- [Custom class serialization](/docs/foundations/serialization#custom-class-serialization) — the companion pattern for classes you own (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`) +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Marks a function for extraction and serialization. +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Provides AI SDK's durable agent, resolves models through AI Gateway strings, and replaces `DurableAgent`. +- [Custom class serialization](/docs/foundations/serialization#custom-class-serialization): Provides the companion pattern for classes you own (`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`). diff --git a/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx b/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx index a6da264049..dc62802d52 100644 --- a/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx +++ b/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx @@ -2,7 +2,7 @@ title: Upgrading Workflows description: Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward. type: guide -summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })` — either automatically on every iteration, or on demand via a dedicated upgrade hook.' +summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })`, either automatically on every iteration, or on demand via a dedicated upgrade hook.' related: - /docs/foundations/versioning - /cookbook/common-patterns/workflow-composition @@ -14,27 +14,27 @@ related: text="Add a safe self-upgrade point to this long-running workflow. Identify the loop boundary where no step is mid-side-effect. Define a serializable state object that contains all progress needed to continue. At the boundary, call `start(self, [state], { deploymentId: "latest" })` or the documented replacement workflow with the carried state, then return from the old run. If upgrades should be manual, add a `defineHook()` upgrade signal and resume it from an API route with `resumeHook()` from `workflow/api`. Make the handoff idempotent so retries do not start duplicate successor runs, and verify old-to-new handoff plus duplicate prevention." /> -Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** — a moment where it's safe to checkpoint state and start fresh — and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run. +Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** (a moment where it's safe to checkpoint state and start fresh) and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run. -For the underlying model — why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary — see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments. +For the underlying model (why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary), see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments. A clean upgrade point is any spot in the workflow where: -- All in-progress side effects have completed (or aren't needed by the next iteration) -- The relevant state can be serialized into the workflow's input arguments -- It's natural for the workflow to "checkpoint" — typically right after handling an external event, completing a batch, or finishing a logical phase +- All in-progress side effects have completed or aren't needed by the next iteration. +- The relevant state can be serialized into the workflow's input arguments. +- The workflow can create a checkpoint after handling an external event, completing a batch, or finishing a logical phase. There are two ways to apply this: -1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)). Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. Simple — no extra triggers — but every event pays the respawn cost. -2. **Upgrade on demand via a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)). A single long-lived run handles many events in a loop and only respawns when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane (e.g. after a deploy). More control and fewer respawns, at the cost of an explicit trigger. +1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)): Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. This method needs no extra triggers, but every event incurs the respawn cost. +2. **Upgrade on demand through a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)): A single long-lived run handles many events in a loop and respawns only when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane, for example, after a deployment. This method provides more control and fewer respawns at the cost of an explicit trigger. ### When to use each -- **Method 1** when iterations are short and frequent, the work is cheap to checkpoint, and you want shipped fixes to apply on the very next event. Long-lived "session" workflows (subscriptions, queues, FSMs) that already process events one at a time fit this naturally. -- **Method 2** when iterations are infrequent or expensive (you don't want to respawn on every event), or when you need to roll out a fix to a fleet of in-flight runs after a deploy by fanning out to a control-plane endpoint. Also fits when "upgrade" should be an explicit operation rather than a side effect of handling each event. +- **Use Method 1**: Choose this method when iterations are short and frequent, the work is inexpensive to checkpoint, and you want shipped fixes to apply on the next event. Long-lived session workflows, such as subscriptions, queues, and finite-state machines (FSMs), that already process events one at a time fit this method. +- **Use Method 2**: Choose this method when iterations are infrequent or expensive, or when you need to roll out a fix to a fleet of in-flight runs after a deployment by fanning out to a control-plane endpoint. This method also fits when an upgrade should be an explicit operation rather than a side effect of handling each event. ## Method 1: Upgrade on every iteration @@ -57,7 +57,7 @@ async function spawnSelfOnLatest(state: QueueState): Promise { "use step"; // [!code highlight] // `deploymentId: "latest"` resolves to whichever deployment is current - // when this spawn lands — NOT the deployment running this code. + // when this spawn lands, NOT the deployment running this code. const next = await start(longRunningQueue, [state], { // [!code highlight] deploymentId: "latest", // [!code highlight] }); // [!code highlight] @@ -71,7 +71,7 @@ export async function longRunningQueue( const { workflowRunId } = getWorkflowMetadata(); - // Block until something fires the hook — could be hours, days, or longer. + // Block until something fires the hook. Could be hours, days, or longer. // Per-run hook tokens (workflowRunId) keep concurrent chains isolated. const { itemId } = await nextItemHook.create({ token: workflowRunId }); // [!code highlight] @@ -101,11 +101,11 @@ export async function POST(req: Request) { } ``` -The caller tracks the active `runId` (e.g. in a database, KV, or returned from the previous iteration) and updates it whenever the chain advances. +The caller tracks the active `runId`, such as in a database or returned from the previous iteration, and updates it whenever the chain advances. ## Method 2: Upgrade on demand via a dedicated hook -Use a single long-running workflow that handles events in a loop. Define a second hook — `upgradeHook` — alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits. +Use a single long-running workflow that handles events in a loop. Define a second hook, `upgradeHook`, alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits. ```typescript lineNumbers import { defineHook, getWorkflowMetadata } from "workflow"; @@ -166,7 +166,7 @@ export async function longRunningQueue( ### Triggering the upgrade -Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deploy pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix. +Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deployment pipeline, an admin interface, or a fan-out script that iterates over every active run after shipping a fix. ```typescript import { upgradeHook } from "@/workflows/long-running-queue"; @@ -182,35 +182,35 @@ export async function POST(req: Request) { } ``` -To upgrade a fleet of runs after a deploy, list active runs (e.g. from a tracking store) and call this endpoint for each. +To upgrade a fleet of runs after a deployment, list active runs from a tracking store and call this endpoint for each run. ## How it works -1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up — so any shipped fix applies starting from that respawn. Both methods rely on this. -2. **`start()` from a step.** [`start()`](/docs/api-reference/workflow-api/start) is not allowed directly inside `"use workflow"` functions — wrap it in a `"use step"` helper to keep the spawn deterministic across replays. -3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself. -4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering. -5. **Method 1 vs Method 2 is just where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting — there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run. +1. **Use `deploymentId: "latest"` to upgrade**: Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up, so any shipped fix applies starting from that respawn. Both methods rely on this. +2. **Call `start()` from a step**: [`start()`](/docs/api-reference/workflow-api/start) is not allowed directly inside `"use workflow"` functions in v4. Wrap it in a `"use step"` helper to keep the spawn deterministic across replays. +3. **Carry state through the function argument**: The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself. +4. **Use per-run hook tokens**: Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering. +5. **Choose where the spawn happens**: In Method 1, every run spawns its successor unconditionally before exiting, so there is no long-lived process to migrate. In Method 2, the spawn happens only when the upgrade hook fires. Otherwise, the loop keeps handling events on the same run. ## Adapting to your use case -- **Combine with a sleep.** Race the hook against `sleep()` so iterations also tick on a timer: `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives. -- **Stateless successors.** If the next iteration doesn't need the previous state (e.g. a pure event router), call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing. -- **Persist state externally.** If state needs to be readable from outside the workflow (dashboards, debugging, recovery), write it to a database in a step before spawning the next run. -- **Track the active runId externally.** Whatever resumes the hook needs to know the current run. Have the spawn step write the new `runId` to a KV/database keyed by a stable session identifier so resumers always look up the latest one. +- **Combine with a sleep**: Race the hook against `sleep()` so iterations also tick on a timer. `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives. +- **Use stateless successors**: If the next iteration doesn't need the previous state, such as for a pure event router, call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing. +- **Persist state externally**: If state needs to be readable from outside the workflow for dashboards, debugging, or recovery, write it to a database in a step before spawning the next run. +- **Track the active `runId` externally**: The system that resumes the hook needs to know the current run. Have the spawn step write the new `runId` to a database keyed by a stable session identifier so resumers always look up the latest run. ## Caveats -- **Backward compatibility matters.** Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest). -- **Workflow identity is the function name + file path.** Renaming the function or moving the file across a deployment changes the workflow ID — the next iteration will fail to resolve. Treat the workflow's name and location as stable interfaces. -- **There is a tiny gap between iterations.** The current run ends as soon as `start()` returns; the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready. -- **Method 2: track active runs externally.** Because Method 2's runs are long-lived, the set of in-flight runs only changes when one starts, completes, or upgrades. Persist run IDs (and clean them up on completion or upgrade) so a rollout script can fan out reliably. After resuming `upgradeHook`, also update the tracked run ID once the new run reports back, the same way you would in Method 1. -- **`start()` must be called from a step**, never directly from the workflow body. +- **Maintain backward compatibility**: Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest). +- **Keep the workflow identity stable**: The function name and file path form the workflow identity. Renaming the function or moving the file across a deployment changes the workflow ID, so the next iteration will fail to resolve. +- **Account for the gap between iterations**: The current run ends as soon as `start()` returns, and the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready. +- **Track active Method 2 runs externally**: Because Method 2's runs are long-lived, the set of in-flight runs changes only when one starts, completes, or upgrades. Persist run IDs and clean them up on completion or upgrade so a rollout script can fan out reliably. After resuming `upgradeHook`, update the tracked run ID once the new run reports back, as you would in Method 1. +- **Call `start()` from a step**: Never call it directly from the workflow body in v4. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — required wrapper for `start()` calls -- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) — spawn the successor on the newest deployment -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspend the workflow until an external event resumes it -- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — exposes `workflowRunId` for per-run hook tokens +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Provides the required wrapper for `start()` calls in v4. +- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest): Spawns the successor on the newest deployment. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Suspends the workflow until an external event resumes it. +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Exposes `workflowRunId` for per-run hook tokens. diff --git a/docs/content/docs/v4/cookbook/agent-patterns/agent-cancellation.mdx b/docs/content/docs/v4/cookbook/agent-patterns/agent-cancellation.mdx index 32ca889c00..58a80d096c 100644 --- a/docs/content/docs/v4/cookbook/agent-patterns/agent-cancellation.mdx +++ b/docs/content/docs/v4/cookbook/agent-patterns/agent-cancellation.mdx @@ -1,32 +1,32 @@ --- title: Agent Cancellation -description: Cancel a running agent from the outside — either immediately via run.cancel() or gracefully via a stop signal hook. +description: Cancel a running agent from the outside, either immediately via run.cancel() or gracefully via a stop signal hook. type: guide -summary: Two patterns for cancelling a running agent — Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook + Promise.race for a clean exit with cleanup and final stream notification. +summary: 'Two patterns for canceling a running agent: Hard Cancellation via getRun(runId).cancel() for forced termination, or Stop Signal via a hook + Promise.race for a clean exit with cleanup and final stream notification.' --- This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The cancellation patterns here (`run.cancel()`, stop-signal hook + `Promise.race`) apply to either API. -Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback. Two patterns are available depending on whether you need the agent to exit cleanly or just need the run to stop: **Hard Cancellation** via `getRun(runId).cancel()` for immediate forced termination, or **Stop Signal** via a hook + `Promise.race` for a graceful exit that runs cleanup and notifies streaming clients before returning. +Cancel a running agent from the outside, for example, through a **Stop** button in a chat user interface (UI), an admin cancellation endpoint, or a timeout fallback. Two patterns are available depending on whether you need the agent to exit cleanly or only need the run to stop: **Hard Cancellation** through `getRun(runId).cancel()` for immediate forced termination, or **Stop Signal** through a hook and `Promise.race` for a graceful exit that runs cleanup and notifies streaming clients before returning. ## When to use this -* **Chat stop buttons** — let users cancel a long-running agent from the browser -* **Admin cancellation** — stop an agent from a different process or API -* **Timeout fallback** — combine with `sleep()` to auto-stop after a deadline +* **Chat stop buttons**: Let users cancel a long-running agent from the browser. +* **Admin cancellation**: Stop an agent from a different process or API. +* **Timeout fallback**: Combine with `sleep()` to stop automatically after a deadline. ## Choosing an approach Pick the option that matches what your endpoint needs to deliver to the caller: -* **Hard Cancellation** — terminates the run immediately with no opportunity for cleanup or client notification. A single line of code, but the workflow throws `WorkflowRunCancelledError` and any streaming clients see an abrupt connection close. -* **Stop Signal** — the workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a real result. +* **Hard Cancellation**: Terminates the run immediately with no opportunity for cleanup or client notification. The workflow throws `WorkflowRunCancelledError`, and any streaming clients see an abrupt connection close. +* **Stop Signal**: The workflow exits as soon as the hook fires, runs any pending cleanup, emits a final `data-stopped` part to the stream so the client can render cleanly, and returns a result. The trade-offs at a glance: @@ -40,7 +40,7 @@ The trade-offs at a glance: | Code complexity | One line | Hook + race + signal step | | Best for | Stuck or unresponsive runs, forced termination | User-facing stop, admin cancel, timeouts | -## Hard Cancellation +## Hard cancellation Call `.cancel()` on a run to terminate it immediately: @@ -57,19 +57,19 @@ export async function POST( } ``` -This is an abrupt termination — the run is stopped mid-step with no opportunity to exit cleanly: +This is an abrupt termination: the run is stopped mid-step with no opportunity to exit cleanly: -* **No cleanup runs** — `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped -* **No final notification to the client** — the writable closes abruptly, so a streaming UI just sees the connection drop with no `data-stopped` part to render a clean ending -* **`run.returnValue` throws** — anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload -* **Underlying step keeps running** — same caveat as the Stop Signal pattern below: the model stream or HTTP call inside the current step continues to completion in the background +* **No cleanup runs**: `finally` blocks, defer-style step cleanup, and any logic after the current step are all skipped. +* **No final notification reaches the client**: The writable closes abruptly, so a streaming UI sees the connection drop with no `data-stopped` part to render a clean ending. +* **`run.returnValue` throws**: Anyone awaiting the result receives [`WorkflowRunCancelledError`](/docs/api-reference/workflow-errors/workflow-run-cancelled-error) instead of a meaningful payload. +* **The underlying step keeps running**: The same caveat as the Stop Signal pattern below applies. The model stream or HTTP call inside the current step continues to completion in the background. -Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else — chat stop buttons, admin "stop" actions, timeout fallbacks — you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result. +Hard Cancellation is the appropriate choice when the run is stuck or unresponsive, has exceeded its expected runtime, or you don't need a clean exit. For everything else (chat stop buttons, admin "stop" actions, timeout fallbacks), you typically want the Stop Signal pattern: the agent finishes its current step, emits a final stream part so the client renders a clean ending, and returns a real result. -## Stop Signal +## Stop signal -**Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes — tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller). +**Limitation:** This pattern does not cancel the underlying model stream. The agent step writing to the writable continues running in the background until it completes: tokens generated after the stop signal are still produced (and billed by your model provider). What this pattern *does* is exit the workflow function as soon as the hook fires and emit a `data-stopped` part so the client can stop rendering. For hard cross-process cancellation that signals the inner step to bail out, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller). ### Example @@ -144,7 +144,7 @@ export async function stoppableAgent(messages: ModelMessage[]) { } ``` -### API Route to Trigger Stop +### API route to trigger stop ```typescript lineNumbers import { stopHook } from "@/workflows/stoppable-agent"; @@ -164,7 +164,7 @@ export async function POST( } ``` -### Client Stop Button +### Client stop button ```tsx lineNumbers "use client"; @@ -188,26 +188,26 @@ export function StopButton({ runId }: { runId: string }) { ## How it works -1. A hook is created with token `stop:${workflowRunId}` when the workflow starts -2. `Promise.race` runs the agent stream and the stop hook concurrently -3. When the stop API resumes the hook, the race resolves immediately — the workflow exits -4. Before returning, `emitStopSignal` writes a `data-stopped` part to the stream so the client knows the agent was stopped (not just disconnected) -5. The client detects `data-stopped` and updates the UI accordingly +1. A hook is created with token `stop:${workflowRunId}` when the workflow starts. +2. `Promise.race` runs the agent stream and the stop hook concurrently. +3. When the stop API resumes the hook, the race resolves immediately and the workflow exits. +4. Before returning, `emitStopSignal` writes a `data-stopped` part to the stream so the client knows the agent was stopped rather than disconnected. +5. The client detects `data-stopped` and updates the UI accordingly. -This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller) — race a long-running operation against a hook signal. +This is the same pattern used by the [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller): race a long-running operation against a hook signal. ## Adapting this -* **Add a timeout** — race a third `sleep()` promise to auto-stop after a deadline -* **Audit logging** — include a `reason` field in the stop schema to record who stopped and why -* **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID -* **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop -* **Hard Cancellation as a fallback** — wire your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume errors with `not found` / `expired` (for example, the hook was already consumed). This guarantees the run is terminated even when the Stop Signal path is unavailable. +* **Add a timeout**: Race a third `sleep()` promise to stop automatically after a deadline. +* **Audit logging**: Include a `reason` field in the stop schema to record who stopped the agent and why. +* **Cross-process**: The hook token is deterministic, so any process can call `stopHook.resume()` with the run ID. +* **Step limits**: Combine with `maxSteps` on the agent to cap execution without a manual stop. +* **Hard Cancellation as a fallback**: Configure your stop endpoint to fall back to `getRun(runId).cancel()` if the hook resume returns a `not found` or `expired` error, for example, if the hook was already consumed. This guarantees the run is terminated even when the Stop Signal path is unavailable. ## Key APIs -* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal -* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens -* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream a stop notification to the client -* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent that gets raced against the stop hook (replaces `DurableAgent`) -* [`getRun()`](/docs/api-reference/workflow-api/get-run) — entry point for Hard Cancellation: `getRun(runId).cancel()` +* [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a type-safe hook for the stop signal. +* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Provides the run ID for deterministic hook tokens. +* [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams a stop notification to the client. +* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK's durable agent that gets raced against the stop hook and replaces `DurableAgent`. +* [`getRun()`](/docs/api-reference/workflow-api/get-run): Provides the entry point for Hard Cancellation through `getRun(runId).cancel()`. diff --git a/docs/content/docs/v4/cookbook/agent-patterns/human-in-the-loop.mdx b/docs/content/docs/v4/cookbook/agent-patterns/human-in-the-loop.mdx index 582ccc3ca3..1f0933b4c4 100644 --- a/docs/content/docs/v4/cookbook/agent-patterns/human-in-the-loop.mdx +++ b/docs/content/docs/v4/cookbook/agent-patterns/human-in-the-loop.mdx @@ -13,14 +13,14 @@ summary: Use defineHook with the tool call ID to suspend an agent for human appr This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The human-in-the-loop pattern here (hooks, `Promise.race`, approval gating) applies to either API. -Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds. +Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds through a user interface (UI) or API. ## When to use this - Booking confirmations where users must approve before charges are made - Content publishing gates where an editor must sign off -- Any agent action where the cost of getting it wrong justifies a human check -- Actions with side effects that can't be easily undone +- Agent actions where the cost of an error justifies a human check +- Actions with side effects that are difficult to reverse ## Pattern @@ -67,7 +67,7 @@ async function confirmBooking({ flightId, passenger }: { } // Stream a custom data part so the client can render the approval UI. -// This MUST run before the hook suspends the workflow — otherwise +// This MUST run before the hook suspends the workflow, otherwise // the tool-invocation won't appear in the stream until the tool returns, // and the client would have no way to show approval buttons. async function emitApprovalRequest(details: { @@ -107,7 +107,7 @@ async function emitApprovalResolved(details: { } } -// No "use step" — hooks are workflow-level primitives +// No "use step": hooks are workflow-level primitives async function requestBookingApproval( { flightId, passenger, price }: { flightId: string; @@ -238,26 +238,26 @@ const approvalResult = messages ## How it works -1. **`defineHook()` with schema** — creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it. -2. **`toolCallId` as token** — the approval tool uses the tool call ID as the hook token, naturally linking the hook to the specific tool invocation. -3. **`emitApprovalRequest` step** — writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this, the client would never see the approval controls because tool invocations don't stream until the tool returns. -4. **No `"use step"` on the approval tool** — the tool runs at the workflow level because `defineHook().create()` is a workflow primitive. It calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, `confirmBooking`) for I/O. -5. **`Promise.race` with sleep** — the approval races against a durable timeout. If nobody responds, the workflow continues with an expiration message. -6. **`emitApprovalResolved` step** — writes the outcome to the stream so the client can update the card immediately, without waiting for the tool-invocation result. +1. **`defineHook()` with schema**: Creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it. +2. **`toolCallId` as token**: Uses the tool call ID as the hook token, linking the hook to the specific tool invocation. +3. **`emitApprovalRequest` step**: Writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this step, the client wouldn't see the approval controls because tool invocations don't stream until the tool returns. +4. **No `"use step"` on the approval tool**: Runs the tool at the workflow level because `defineHook().create()` is a workflow primitive. The tool calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, and `confirmBooking`) for I/O. +5. **`Promise.race` with sleep**: Races the approval against a durable timeout. If nobody responds, the workflow continues with an expiration message. +6. **`emitApprovalResolved` step**: Writes the outcome to the stream so the client can update the card immediately without waiting for the tool-invocation result. ## Adapting to your use case -- **Change the approval schema** — add fields like `reason`, `amount`, `reviewerEmail` to match your domain. -- **Multiple approval gates** — the pattern works for any number of tools. Each tool creates its own hook with its own `toolCallId`. -- **Escalation** — if the first approver doesn't respond, use `sleep()` + another hook to escalate to a backup reviewer. -- **Adjust timeout** — use `"24h"` for production, shorter durations for demos. -- **Workflow-level vs step tools** — tools that use `sleep()`, `defineHook()`, or other workflow primitives must NOT use `"use step"`. Tools with only I/O (API calls, DB queries) should use `"use step"` for retries. +- **Change the approval schema**: Add fields such as `reason`, `amount`, and `reviewerEmail` to match your domain. +- **Multiple approval gates**: Apply the pattern to any number of tools. Each tool creates its own hook with its own `toolCallId`. +- **Escalation**: If the first approver doesn't respond, use `sleep()` and another hook to escalate to a backup reviewer. +- **Adjust the timeout**: Use `"24h"` for production and shorter durations for demos. +- **Workflow-level versus step tools**: Tools that use `sleep()`, `defineHook()`, or other workflow primitives must not use `"use step"`. Tools with only I/O, such as API calls and database queries, should use `"use step"` for retries. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — declares step functions with retries -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry -- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent (replaces `DurableAgent`) +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with retries. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a type-safe hook with schema validation. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides a durable timeout for approval expiration. +- [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams custom data parts from steps. +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Provides AI SDK's durable agent and replaces `DurableAgent`. diff --git a/docs/content/docs/v4/cookbook/common-patterns/batching.mdx b/docs/content/docs/v4/cookbook/common-patterns/batching.mdx index 962c62dcaa..d255a183df 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/batching.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/batching.mdx @@ -13,7 +13,7 @@ Use batching when you need to process a large list of items in parallel while co ## When to use this -- Bulk data imports (contacts, orders, products from a CSV) +- Bulk data imports (contacts, orders, or products from a comma-separated values (CSV) file) - Processing hundreds or thousands of items against external APIs - Calling rate-limited APIs where you need to control concurrency - Any fan-out where you want failure isolation between groups @@ -21,7 +21,7 @@ Use batching when you need to process a large list of items in parallel while co ## How it works 1. Records are split into fixed-size batches. -2. Each batch runs in parallel via `Promise.allSettled` — failures in one record don't affect others. +2. Each batch runs in parallel through `Promise.allSettled`, so failures in one record don't affect others. 3. A `sleep()` between batches paces requests to avoid overloading downstream services. 4. After all batches, a summary is returned with succeeded/failed counts. @@ -45,7 +45,7 @@ export async function batchImport(records: Record[], batchSize: number) { for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); - // Run batch in parallel — failures are isolated per record + // Run batch in parallel: failures are isolated per record const outcomes = await Promise.allSettled( // [!code highlight] batch.map((record) => processRecord(record)) ); @@ -89,21 +89,21 @@ async function processRecord(record: Record): Promise { ## Adapting to your use case -- Replace the `Record` type with your actual data shape (orders, images, products, etc.). -- Replace `processRecord()` with your real import logic — DB upserts, API calls, file processing. +- Replace the `Record` type with your actual data shape, such as orders, images, or products. +- Replace `processRecord()` with your import logic, such as database upserts, API calls, or file processing. - Tune `batchSize` and the `sleep()` duration to match your downstream rate limits. -- Add or remove tracking as needed — the pattern works with any item type. +- Add or remove tracking as needed; the pattern works with any item type. ## Tips -- **Use `Promise.allSettled` over `Promise.all`** when you want to continue even if some items fail. `Promise.all` rejects on the first failure; `allSettled` waits for everything and tells you what failed. -- **Tune batch size to your downstream API limits.** If the API allows 10 concurrent requests, use `batchSize: 10`. -- **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable — it survives cold starts. -- **Each `processRecord` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch. +- **Use `Promise.allSettled` instead of `Promise.all`**: Use this pattern when you want to continue even if some items fail. `Promise.all` rejects on the first failure, while `allSettled` waits for everything and identifies failures. +- **Tune batch size to your downstream API limits**: If the API allows 10 concurrent requests, use `batchSize: 10`. +- **Add pacing with `sleep()`**: Add a delay between batches to respect rate limits. The sleep is durable and survives cold starts. +- **Treat each `processRecord` call as an independent step**: If one call fails, it retries up to three times without affecting other items in the batch. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access -- [`sleep()`](/docs/api-reference/workflow/sleep) -- pacing delay between batches -- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- runs items in parallel, isolating failures +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions that run with full Node.js access. +- [`sleep()`](/docs/api-reference/workflow/sleep): Adds a pacing delay between batches. +- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled): Runs items in parallel and isolates failures. diff --git a/docs/content/docs/v4/cookbook/common-patterns/idempotency.mdx b/docs/content/docs/v4/cookbook/common-patterns/idempotency.mdx index 733d1af356..d1283ea4bc 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/idempotency.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/idempotency.mdx @@ -6,7 +6,7 @@ summary: Use step IDs for retry-safe external calls, and use deterministic hook --- Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts. @@ -82,14 +82,14 @@ export async function POST(request: Request) { } ``` -The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration. +The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work: awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access -- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys -- [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token -- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the active hook for a token -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data -- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with full Node.js access. +- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata): Provides the deterministic `stepId` for idempotency keys. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Creates a hook with an optional deterministic token. +- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token): Finds the active hook for a token. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resumes the active hook when the duplicate request carries data. +- [`start()`](/docs/api-reference/workflow-api/start): Starts a new workflow run. diff --git a/docs/content/docs/v4/cookbook/common-patterns/saga.mdx b/docs/content/docs/v4/cookbook/common-patterns/saga.mdx index 834816c961..9abe7390a6 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/saga.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/saga.mdx @@ -20,8 +20,8 @@ Use the saga pattern when a business transaction spans multiple services and you ## How it works 1. Each forward step does work and registers a compensation function. -2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency. -3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help. +2. If any step throws `FatalError`, the catch block runs compensations in reverse, or last in, first out (LIFO), order to restore consistency. +3. Regular errors are retried automatically, up to three times by default. Use `FatalError` only for permanent failures where retrying won't help. ## Pattern @@ -53,7 +53,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) const entitlementId = await provisionSeats(accountId, seats); compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight] - // No compensation — notifications are fire-and-forget + // No compensation: notifications are fire-and-forget await sendConfirmation(accountId, invoiceId, entitlementId); return { status: "completed" }; @@ -70,7 +70,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) ### Step functions -Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback. +Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent: safe to call multiple times if the workflow restarts mid-rollback. ```typescript import { FatalError } from "workflow"; @@ -122,7 +122,7 @@ async function sendConfirmation( }); } -// Compensation steps — must be idempotent +// Compensation steps: must be idempotent async function releaseSeats(accountId: string, reservationId: string): Promise { "use step"; @@ -151,7 +151,7 @@ async function deprovisionSeats(accountId: string, entitlementId: string): Promi ### Streaming step progress (optional) -Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time. +Use `getWritable()` to stream progress events to a user interface (UI) so users can see each step execute in real time. ```typescript import { FatalError } from "workflow"; @@ -204,7 +204,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) }); await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId }); - // No compensation — notifications are fire-and-forget + // No compensation: notifications are fire-and-forget await emit({ type: "step_start", step: "Send Confirmation" }); await sendConfirmation(accountId, invoiceId, entitlementId); await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" }); @@ -231,21 +231,21 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) ## Adapting to your use case - Replace the step functions with real API calls. Each `"use step"` function has full Node.js access. -- Add or remove steps as needed — the pattern scales to any number of steps. -- Make compensations idempotent — they may be retried if the workflow restarts mid-rollback. -- The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress. +- Add or remove steps as needed; the pattern scales to any number of steps. +- Make compensations idempotent, since they may be retried if the workflow restarts mid-rollback. +- The `emit()` calls and `SagaEvent` type are optional; remove them if you don't need real-time UI progress. ## Tips -- **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input). -- **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again. -- **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off. -- **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state. -- **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation. +- **Use `FatalError` for permanent failures**: Regular errors trigger automatic retries, up to three times by default. Throw `FatalError` when retrying won't help, such as for insufficient funds or invalid input. +- **Make compensations idempotent**: If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again. +- **Use `"use step"` functions for compensation steps**: This makes them durable. If the workflow restarts during rollback, it resumes where it left off. +- **Capture values in closures carefully**: Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state. +- **Skip compensations for notifications**: Fire-and-forget steps such as sending emails or Slack messages typically don't register a compensation. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access -- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation -- [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with full Node.js access. +- [`FatalError`](/docs/api-reference/workflow/fatal-error): Represents a non-retryable error that triggers compensation. +- [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams data from workflows for real-time UI updates. diff --git a/docs/content/docs/v4/cookbook/common-patterns/scheduling.mdx b/docs/content/docs/v4/cookbook/common-patterns/scheduling.mdx index 41e16ce44d..c749068342 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/scheduling.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/scheduling.mdx @@ -6,10 +6,10 @@ summary: Schedule future actions with durable sleep that survives cold starts, a --- -Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences. +Workflow's `sleep()` is durable: it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences. Scheduled workflows are still pinned to the deployment that started them. If you are building recurring or indefinitely running schedules that should adopt newer code over time, see [Versioning](/docs/foundations/versioning) for the explicit `deploymentId: "latest"` continuation pattern. @@ -17,13 +17,13 @@ Scheduled workflows are still pinned to the deployment that started them. If you ## When to use this -- Sending emails on a schedule (drip campaigns, onboarding sequences, reminders) -- Waiting for a deadline but allowing early cancellation -- Any pattern where "do X, wait N hours, then do Y" needs to be both reliable and interruptible +- Sending emails on a schedule, such as drip campaigns, onboarding sequences, or reminders +- Waiting for a deadline while allowing early cancellation +- Reliable, interruptible patterns that perform one action, wait for a specified time, and then perform another action ## Drip campaign with cancellation -A drip campaign sends emails at intervals, sleeping between each. Each sleep races against a cancellation hook — if an external event fires the hook (e.g. user converts, unsubscribes), the campaign stops immediately. +A drip campaign sends emails at intervals, sleeping between each message. Each sleep races against a cancellation hook. If an external event fires the hook, such as when a user converts or unsubscribes, the campaign stops immediately. ```typescript import { defineHook, sleep } from "workflow"; @@ -105,10 +105,10 @@ export async function POST(req: Request) { ## How it works -1. **Durable sleep** — `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires. -2. **Hook creation** — `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token. -3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first. -4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token. +1. **Durable sleep**: `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes when the timer fires. +2. **Hook creation**: `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token. +3. **Race**: `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first. +4. **Fresh hooks per window**: After a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token. Deterministic hook tokens can also serve as the idempotency point for scheduled runs. If duplicate schedule starts would send duplicate campaigns or reminders, create a hook with a token derived from the campaign key near the beginning of the workflow and route retries through that hook. If two scheduled starts race, the duplicate run can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the duplicate can defer to it. See [Idempotency](/docs/foundations/idempotency). @@ -116,22 +116,22 @@ Deterministic hook tokens can also serve as the idempotency point for scheduled ## Adapting to your use case -- **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times. -- **Add more steps** — the pattern scales to any number of email-then-sleep pairs. -- **Snooze instead of cancel** — resolve the hook with a `snooze` payload and sleep again: `sleep(new Date(Date.now() + payload.snoozeMs))`. -- **Timeout any operation** — the same `Promise.race(sleep, work)` pattern works for adding deadlines to slow steps. -- **Real providers** — swap the `sendEmail` step body for Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access. +- **Change durations**: Replace `"2d"` with any duration string (`"1h"`, `"7d"`, or `"30m"`) or a `Date` object for absolute times. +- **Add more steps**: The pattern scales to any number of email-then-sleep pairs. +- **Snooze instead of cancel**: Resolve the hook with a `snooze` payload and sleep again with `sleep(new Date(Date.now() + payload.snoozeMs))`. +- **Set a timeout for any operation**: Use the same `Promise.race(sleep, work)` pattern to add deadlines to slow steps. +- **Use production providers**: Replace the `sendEmail` step body with Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access. ## Tips -- **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time. -- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping — no compute, no memory. -- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step. +- **Pass supported values to `sleep()`**: Use duration strings (`"1d"`, `"2h"`, or `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time. +- **Sleeping consumes no compute or memory**: A workflow waiting on `sleep("7d")` consumes no compute or memory while sleeping. +- **Use `sleep()` only in workflow context**: Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions that run with full Node.js access -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost) -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — creates a typed hook that external systems can fire -- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — races sleep against hooks for interruptible waits +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions that run with full Node.js access. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides a durable wait that survives restarts with zero compute cost. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Creates a typed hook that external systems can fire. +- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race): Races sleep against hooks for interruptible waits. diff --git a/docs/content/docs/v4/cookbook/common-patterns/sequential-and-parallel.mdx b/docs/content/docs/v4/cookbook/common-patterns/sequential-and-parallel.mdx index 639857c991..6f90a8475d 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/sequential-and-parallel.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/sequential-and-parallel.mdx @@ -1,8 +1,8 @@ --- title: Sequential & Parallel Execution -description: Compose steps with familiar async/await patterns — sequential await, Promise.all, and Promise.race. +description: Compose steps with familiar async/await patterns, sequential await, Promise.all, and Promise.race. type: guide -summary: Workflows are just async functions, so all the standard composition primitives (await, Promise.all, Promise.race) apply unchanged — including racing webhooks against durable sleeps. +summary: Workflows are plain async functions, so all the standard composition primitives (await, Promise.all, Promise.race) apply unchanged, including racing webhooks against durable sleeps. related: - /docs/foundations/workflows-and-steps - /cookbook/common-patterns/timeouts @@ -10,17 +10,17 @@ related: --- -Workflows are written in plain async/await — there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also just promises. +Workflows are written in plain async/await: there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also promises. ## When to use this -- **Pipelines** — each step depends on the previous step's output (validate → process → store) -- **Independent fan-out** — fetch multiple resources or perform multiple actions that don't depend on each other -- **Race conditions** — return as soon as one of N operations completes (timeout, first-responder, deadline) -- **Mixing primitives** — running steps, sleeps, and webhooks side-by-side in the same control-flow expression +- **Pipelines**: Each step depends on the previous step's output (validate → process → store). +- **Independent fan-out**: Fetch multiple resources or perform multiple actions that don't depend on each other. +- **Race conditions**: Return as soon as one operation completes, such as for a timeout, first responder, or deadline. +- **Mixing primitives**: Run steps, sleeps, and webhooks side by side in the same control-flow expression. ## Pattern @@ -68,7 +68,7 @@ export async function fetchUserData(userId: string) { ### Race with `Promise.race` -`Promise.race` resolves as soon as the first promise settles. Since [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) return promises, they compose naturally — for example, waiting for a webhook callback with a deadline: +`Promise.race` resolves as soon as the first promise settles. Since [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) return promises, they compose naturally. For example, waiting for a webhook callback with a deadline: ```typescript lineNumbers import { sleep, createWebhook } from "workflow"; @@ -90,11 +90,11 @@ export async function runExternalTask(userId: string) { } ``` -For racing operations against deadlines specifically (timeouts), see the dedicated [Timeouts](/cookbook/common-patterns/timeouts) recipe — it covers result discrimination, `FatalError` semantics, and the "loser keeps running" caveat. +For racing operations against deadlines specifically (timeouts), see the dedicated [Timeouts](/cookbook/common-patterns/timeouts) recipe, which covers result discrimination, `FatalError` semantics, and the "loser keeps running" caveat. ### Combining sequential, parallel, and durable primitives -Most real workflows combine all three. Here's a simplified version of the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator) — sequential card generation, parallel RSVP fan-out, non-blocking webhook collection, and a durable sleep until the birthday: +Most workflows combine all three patterns. The following simplified version of the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator) uses sequential card generation, parallel invitation-response fan-out, non-blocking webhook collection, and a durable sleep until the birthday: ```typescript lineNumbers import { createWebhook, sleep, type Webhook } from "workflow"; @@ -136,24 +136,24 @@ export async function birthdayWorkflow( ## How it works -1. **`await` is durable.** When the workflow awaits a step, the runtime persists the step's input, suspends the workflow, runs the step, and replays the workflow with the step's result on resume. The same applies to `sleep()` and `createWebhook()`. -2. **`Promise.all` runs steps concurrently.** Each promise in the array is suspended on its own and the workflow resumes only when all have settled. Failures propagate — if any promise rejects, the whole `Promise.all` rejects. -3. **`Promise.race` resolves on the first settle.** The losing promises keep running in the background but their results are discarded by the workflow. -4. **All primitives are promises.** `sleep("1 day")` and `createWebhook()` return promises, so they compose with `Promise.all` / `Promise.race` exactly like steps do — this is what makes patterns like "race a webhook against a 24-hour deadline" a one-liner. +1. **`await` is durable**: When the workflow awaits a step, the runtime persists the step's input, suspends the workflow, runs the step, and replays the workflow with the step's result on resume. The same applies to `sleep()` and `createWebhook()`. +2. **`Promise.all` runs steps concurrently**: Each promise in the array is suspended on its own, and the workflow resumes only when all have settled. If any promise rejects, the whole `Promise.all` rejects. +3. **`Promise.race` resolves when the first promise settles**: The losing promises keep running in the background, but the workflow discards their results. +4. **All primitives are promises**: `sleep("1 day")` and `createWebhook()` return promises, so they compose with `Promise.all` and `Promise.race` like steps do. This behavior enables patterns such as racing a webhook against a 24-hour deadline. ## Adapting to your use case -- **Replace `Promise.all` with `Promise.allSettled`** when partial failures should not abort the rest. You'll get an array of `{ status, value | reason }` instead of throwing on the first rejection. -- **Bound the parallelism** — `Promise.all` over 1000 items will fan out 1000 concurrent steps. If your downstream APIs can't handle that, batch the array into chunks (see [Batching](/cookbook/common-patterns/batching)). -- **Add a deadline to any race** — pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts). -- **Mix steps and hooks in a race** — wait for an external signal *or* a deadline *or* a step result, all in the same `Promise.race`. The first one to resolve wins. +- **Replace `Promise.all` with `Promise.allSettled`**: Use this option when partial failures shouldn't abort the remaining operations. You'll get an array of `{ status, value | reason }` instead of an error on the first rejection. +- **Bound the parallelism**: `Promise.all` over 1,000 items will fan out 1,000 concurrent steps. If your downstream APIs can't handle that, batch the array into chunks (see [Batching](/cookbook/common-patterns/batching)). +- **Add a deadline to any race**: Pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts). +- **Mix steps and hooks in a race**: Wait for an external signal, a deadline, or a step result in the same `Promise.race`. The first promise to resolve wins. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable sleep that survives restarts -- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — webhook URL the workflow can race against -- [`Promise.all()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) — wait for all promises -- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — wait for the first to settle -- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) — wait for all, including failures +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions with full Node.js access. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides durable sleep that survives restarts. +- [`createWebhook()`](/docs/api-reference/workflow/create-webhook): Provides a webhook URL that the workflow can race against. +- [`Promise.all()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all): Waits for all promises. +- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race): Waits for the first promise to settle. +- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled): Waits for all promises, including failures. diff --git a/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx b/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx index ee125d21b5..456d369a28 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/timeouts.mdx @@ -2,7 +2,7 @@ title: Timeouts description: Add deadlines to slow operations by racing them against a durable sleep. type: guide -summary: Use `Promise.race` with `sleep()` to bound the time any step, hook, or webhook is allowed to take — and recover gracefully when the deadline fires first. +summary: Use `Promise.race` with `sleep()` to bound the time any step, hook, or webhook is allowed to take, and recover gracefully when the deadline fires first. related: - /docs/api-reference/workflow/sleep - /docs/foundations/hooks @@ -11,17 +11,17 @@ related: --- -A common requirement is bounding how long a workflow waits for something to finish — a slow step, an external webhook, a human approval. Race the operation against a durable `sleep()` with `Promise.race()` — whichever finishes first wins, and the loser keeps running but its result is ignored. +Workflows often need to limit how long they wait for a slow step, an external webhook, or human approval. Race the operation against a durable `sleep()` with `Promise.race()`. The first operation to finish wins, while the other keeps running and its result is ignored. ## When to use this -- **Slow steps** — bound the time spent waiting on third-party APIs, model calls, or expensive computation -- **External callbacks** — give webhooks a deadline so the workflow doesn't hang forever waiting for an event that may never arrive -- **Human approvals** — auto-decline or escalate when a hook isn't resumed within a window -- **Polling loops** — give an outer poll-until-ready loop an overall budget +- **Slow steps**: Bound the time spent waiting on third-party APIs, model calls, or expensive computation. +- **External callbacks**: Give webhooks a deadline so the workflow doesn't hang forever waiting for an event that may never arrive. +- **Human approvals**: Auto-decline or escalate when a hook isn't resumed within a window. +- **Polling loops**: Give an outer poll-until-ready loop an overall budget. ## Pattern @@ -50,7 +50,7 @@ export async function processWithTimeout(data: string) { ### Timeout on a webhook -The same pattern works for any promise — including hooks and webhooks. Here a webhook waits for an external service to call back, with a hard deadline of 7 days: +The same pattern works for any promise, including hooks and webhooks. Here a webhook waits for an external service to call back, with a hard deadline of 7 days: ```typescript lineNumbers import { sleep, createWebhook } from "workflow"; @@ -81,27 +81,27 @@ export async function waitForApproval(requestId: string) { ## How it works -1. **Durable sleep** — `sleep("30s")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires. -2. **Race** — `Promise.race([work, sleep(...)])` returns the value of whichever promise resolves first. The loser keeps running in the background but its result is ignored by the workflow. -3. **Discriminated result** — tagging the sleep branch with a sentinel value (`"timeout" as const`, `{ timedOut: true }`) lets TypeScript narrow the result and pick the right branch. -4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows. +1. **Durable sleep**: `sleep("30s")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires. +2. **Race**: `Promise.race([work, sleep(...)])` returns the value of whichever promise resolves first. The loser keeps running in the background but its result is ignored by the workflow. +3. **Discriminated result**: tagging the sleep branch with a sentinel value (`"timeout" as const`, `{ timedOut: true }`) lets TypeScript narrow the result and pick the right branch. +4. **Throw to fail the workflow**: inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows. -**The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Use idempotency keys for non-idempotent side effects. For hard cancellation across processes, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller), and see [Idempotency](/docs/foundations/idempotency) for retry-safe side effects. +**The losing operation keeps running.** `Promise.race` doesn't cancel: when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Use idempotency keys for non-idempotent side effects. For hard cancellation across processes, see [Distributed Abort Controller](/cookbook/advanced/distributed-abort-controller), and see [Idempotency](/docs/foundations/idempotency) for retry-safe side effects. ## Adapting to your use case -- **Different durations** — `sleep()` accepts duration strings (`"30s"`, `"5m"`, `"7 days"`), milliseconds, or `Date` objects for absolute deadlines. -- **Soft timeout (retry)** — instead of throwing, loop and retry with a fresh `Promise.race` and a backoff. -- **Soft timeout (fallback)** — return a default value when the timer wins instead of throwing: `if (result === "timeout") return cachedFallback`. -- **Combine with cancellation** — race three promises: the operation, a deadline `sleep()`, and a cancellation hook. See the [Scheduling cookbook](/cookbook/common-patterns/scheduling) for the cancellation half of this pattern. -- **Per-step deadlines** — wrap each step in its own `Promise.race` for independent budgets, or use a single outer race for an overall workflow deadline. +- **Different durations**: `sleep()` accepts duration strings (`"30s"`, `"5m"`, `"7 days"`), milliseconds, or `Date` objects for absolute deadlines. +- **Soft timeout (retry)**: Instead of throwing, loop and retry with a fresh `Promise.race` and a backoff. +- **Soft timeout (fallback)**: Return a default value when the timer wins instead of throwing: `if (result === "timeout") return cachedFallback`. +- **Combine with cancellation**: Race three promises: the operation, a deadline `sleep()`, and a cancellation hook. See the [Scheduling cookbook](/cookbook/common-patterns/scheduling) for the cancellation half of this pattern. +- **Per-step deadlines**: Wrap each step in its own `Promise.race` for independent budgets, or use a single outer race for an overall workflow deadline. ## Key APIs -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost) -- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — create a webhook URL the workflow can race against -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — typed hook for in-process cancellation -- [Idempotency](/docs/foundations/idempotency) — protect side effects that may keep running after a timeout -- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — race operations against deadlines +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides a durable wait that survives restarts at zero compute cost. +- [`createWebhook()`](/docs/api-reference/workflow/create-webhook): Creates a webhook URL the workflow can race against. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Creates a typed hook for in-process cancellation. +- [Idempotency](/docs/foundations/idempotency): Protects side effects that may keep running after a timeout. +- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race): Races operations against deadlines. diff --git a/docs/content/docs/v4/cookbook/common-patterns/webhooks.mdx b/docs/content/docs/v4/cookbook/common-patterns/webhooks.mdx index 7a5f338d8d..a983b51276 100644 --- a/docs/content/docs/v4/cookbook/common-patterns/webhooks.mdx +++ b/docs/content/docs/v4/cookbook/common-patterns/webhooks.mdx @@ -2,7 +2,7 @@ title: Webhooks & External Callbacks description: Receive HTTP callbacks from external services, process them durably, and respond inline. type: guide -summary: Create webhook endpoints that your workflow can await, process incoming requests in steps, and respond to the caller — all within durable workflow context. +summary: Create webhook endpoints that your workflow can await, process incoming requests in steps, and respond to the caller, all within durable workflow context. --- -Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows). +Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or starts the child as an independent run (background spawn). For large fan-out operations with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows). ## When to use this -- **Direct await** — the parent needs the child's result before continuing, and you want a single unified event log -- **Background spawn** — the parent doesn't need to wait, and you want the child to be observable as a separate run with its own `runId` +- **Direct await**: The parent needs the child's result before continuing, and you want a single unified event log. +- **Background spawn**: The parent doesn't need to wait, and you want the child to be observable as a separate run with its own `runId`. ## Pattern ### Direct await (flattening) -Call a child workflow with `await` and the child's steps execute inline within the parent — they appear in the parent's event log as if you'd called them directly. +Call a child workflow with `await` and the child's steps execute inline within the parent. They appear in the parent's event log as if you'd called them directly. ```typescript lineNumbers declare function sendEmail(userId: string): Promise; // @setup @@ -94,14 +94,14 @@ Each background spawn creates a separate run. If duplicate requests must route t -If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures. +If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments. Renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures. ## How it works -1. **Direct await flattens.** When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline — the child's steps emit into the parent's event log and share the parent's run ID. -2. **`start()` mints a new run.** The child gets its own `runId`, its own event log, and its own retry boundary. The parent only sees the `runId` returned by `start()`. -3. **`start()` must be called from a step.** Calling `start()` directly from a workflow function is not allowed — wrap it in a `"use step"` function. This keeps the spawn deterministic across replays. +1. **Direct await flattens the child workflow**: When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline. The child's steps emit into the parent's event log and share the parent's run ID. +2. **`start()` creates a new run**: The child gets its own `runId`, event log, and retry boundary. The parent only sees the `runId` returned by `start()`. +3. **`start()` must be called from a step**: Calling `start()` directly from a workflow function is not allowed. Wrap it in a `"use step"` function. This keeps the spawn deterministic across replays. ## Choosing between the two modes @@ -115,14 +115,14 @@ If you want the child workflow to run on the latest deployment rather than the c ## Adapting to your use case -- **Spawn many children at once** — call `start()` in a loop inside a step. For more advanced fan-out (chunking, hook-based waiting, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe. -- **Wait for a background child to finish** — combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern. -- **Pass results back from background children** — the wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`. +- **Spawn many children at once**: Call `start()` in a loop inside a step. For more advanced fan-out (chunking, hook-based waiting, and partial-failure handling), use the [Child Workflows](/cookbook/advanced/child-workflows) recipe. +- **Wait for a background child to finish**: Combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern. +- **Pass results back from background children**: The wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access -- [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value -- [Idempotency](/docs/foundations/idempotency) — deduplicate step side effects and workflow starts +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions with full Node.js access. +- [`start()`](/docs/api-reference/workflow-api/start): Spawns a child workflow as a separate run. +- [`getRun()`](/docs/api-reference/workflow-api/get-run): Retrieves a workflow run's status and return value. +- [Idempotency](/docs/foundations/idempotency): Deduplicates step side effects and workflow starts. diff --git a/docs/content/docs/v4/cookbook/index.mdx b/docs/content/docs/v4/cookbook/index.mdx index 68ab1b51aa..ef17320071 100644 --- a/docs/content/docs/v4/cookbook/index.mdx +++ b/docs/content/docs/v4/cookbook/index.mdx @@ -4,36 +4,36 @@ description: Best-practice workflow patterns with copy-paste code examples. type: overview --- -A curated collection of workflow patterns with clean, copy-paste code examples for real use cases. +Use these workflow patterns and copy-paste code examples to implement common use cases. -## Agent Patterns +## Agent patterns -- [**WorkflowAgent**](/cookbook/agent-patterns/durable-agent) — Build durable, resumable AI agents with AI SDK's WorkflowAgent -- [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop) — Pause an agent for human approval, then resume based on the decision -- [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation) — Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race` +- [**WorkflowAgent**](/cookbook/agent-patterns/durable-agent): Build durable, resumable AI agents with AI SDK's WorkflowAgent. +- [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop): Pause an agent for human approval, then resume based on the decision. +- [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation): Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race`. -## Common Patterns +## Common patterns -- [**Sequential & Parallel Execution**](/cookbook/common-patterns/sequential-and-parallel) — Compose steps with `await`, `Promise.all`, and `Promise.race` against durable sleeps and webhooks -- [**Workflow Composition**](/cookbook/common-patterns/workflow-composition) — Call workflows from other workflows by direct await or background spawn via `start()` -- [**Saga**](/cookbook/common-patterns/saga) — Coordinate multi-step transactions with automatic rollback when a step fails -- [**Batching**](/cookbook/common-patterns/batching) — Process large collections in parallel batches with failure isolation -- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff -- [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead -- [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep -- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects and duplicate starts are safe to retry -- [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably +- [**Sequential & Parallel Execution**](/cookbook/common-patterns/sequential-and-parallel): Compose steps with `await`, `Promise.all`, and `Promise.race` against durable sleeps and webhooks. +- [**Workflow Composition**](/cookbook/common-patterns/workflow-composition): Call workflows from other workflows by direct await or background spawn via `start()`. +- [**Saga**](/cookbook/common-patterns/saga): Coordinate multi-step transactions with automatic rollback when a step fails. +- [**Batching**](/cookbook/common-patterns/batching): Process large collections in parallel batches with failure isolation. +- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting): Handle 429 responses and transient failures with RetryableError and backoff. +- [**Scheduling**](/cookbook/common-patterns/scheduling): Use durable sleep to schedule actions minutes, hours, or weeks ahead. +- [**Timeouts**](/cookbook/common-patterns/timeouts): Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep. +- [**Idempotency**](/cookbook/common-patterns/idempotency): Ensure side effects and duplicate starts are safe to retry. +- [**Webhooks**](/cookbook/common-patterns/webhooks): Receive HTTP callbacks from external services and process them durably. ## Integrations -- [**AI SDK**](/cookbook/integrations/ai-sdk) — Use streamText() directly inside a workflow for lower-level control over model calls and tool execution -- [**Chat SDK**](/cookbook/integrations/chat-sdk) — Build durable chat sessions with workflow persistence and AI SDK chat primitives -- [**Sandbox**](/cookbook/integrations/sandbox) — Orchestrate Vercel Sandbox lifecycle inside durable workflows +- [**AI SDK**](/cookbook/integrations/ai-sdk): Use `streamText()` directly inside a workflow for lower-level control over model calls and tool execution. +- [**Chat SDK**](/cookbook/integrations/chat-sdk): Build durable chat sessions with workflow persistence and AI SDK chat primitives. +- [**Sandbox**](/cookbook/integrations/sandbox): Orchestrate Vercel Sandbox lifecycle inside durable workflows. ## Advanced -- [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent -- [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks -- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward -- [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary -- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions +- [**Child Workflows**](/cookbook/advanced/child-workflows): Spawn and orchestrate child workflows from a parent. +- [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller): Build a cross-process abort controller using workflow streams and hooks. +- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows): Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward. +- [**Serializable Steps**](/cookbook/advanced/serializable-steps): Wrap non-serializable third-party objects so they cross the workflow boundary. +- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries): Ship npm packages that export reusable workflow functions. diff --git a/docs/content/docs/v4/cookbook/integrations/ai-sdk.mdx b/docs/content/docs/v4/cookbook/integrations/ai-sdk.mdx index f3aa397692..4896151b56 100644 --- a/docs/content/docs/v4/cookbook/integrations/ai-sdk.mdx +++ b/docs/content/docs/v4/cookbook/integrations/ai-sdk.mdx @@ -2,7 +2,7 @@ title: AI SDK description: Use AI SDK's streamText directly inside durable workflows when you need the raw AI SDK API or a per-turn durability boundary. type: guide -summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by WorkflowAgent. Individual tool calls and LLM calls inside a turn are not separately durable. +summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by WorkflowAgent. Individual tool calls and large language model (LLM) calls inside a turn are not separately durable. related: - /docs/ai - /docs/ai/chat-session-modeling @@ -15,27 +15,27 @@ related: text="Implement the durable AI SDK multi-turn pattern. Use `streamText`, `stepCountIs`, and `createUIMessageStreamResponse` from `ai`; `defineHook`, `getWritable`, and `getWorkflowMetadata` from `workflow`; and `start`/`getRun` from `workflow/api`. Put the model call in a `"use step"` function such as `runTurn(messages)` and pipe `result.toUIMessageStream()` to `getWritable()` with `{ preventClose: true }`. In the workflow, create one hook with `turnHook.create({ token: workflowRunId })`, loop over turns, and await the hook between user messages. Add an API route that starts a run on first message, stores/returns the run ID in `x-workflow-run-id`, resumes the hook for follow-up messages, reads from `run.getReadable({ startIndex })`, and handles stale run IDs by starting fresh. Wire the client transport to send `runId` with each request and verify first turn, follow-up turn, `/done`, and reconnect behavior." /> -[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making the multi-turn loop durable: the conversation state, hooks, and per-turn responses survive restarts and timeouts. Note that in this pattern the durability boundary is the entire turn — individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable) below). +[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents. It provides unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK makes the multi-turn loop durable, so the conversation state, hooks, and per-turn responses survive restarts and timeouts. In this pattern, the durability boundary is the entire turn, and individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable)). -For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points. +For the full AI SDK reference, including providers, `streamText`, `generateObject`, `useChat`, and tool calling, see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points. -For most agent use cases, prefer AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope — each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step — accepting that tool calls inside that turn are not individually durable. +For most agent use cases, prefer AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope: each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step, accepting that tool calls inside that turn are not individually durable. ## When to use streamText directly Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `WorkflowAgent` when you need: -* **The raw AI SDK API** — `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `WorkflowAgent.stream()` -* **Per-turn durability** — wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together -* **Custom multi-turn orchestration** — manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `WorkflowAgent` +* **The raw AI SDK API**: `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `WorkflowAgent.stream()` +* **Per-turn durability**: wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together +* **Custom multi-turn orchestration**: manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `WorkflowAgent` `WorkflowAgent` already supports `stopWhen`, `prepareStep`, lifecycle callbacks, structured output (`output`), per-step model switching, and [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options). See the [`WorkflowAgent` docs](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). ## Multi-turn pattern -One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run. +One workflow run represents one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run. Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see [Versioning](/docs/foundations/versioning) for the child-run continuation pattern. @@ -57,8 +57,8 @@ export const turnHook = defineHook({ // [!code highlight] schema: z.object({ message: z.string() }), }); -// `streamText` runs tool executes inside `runTurn` (a step), so tool calls -// are not individually durable — the entire turn retries together. See +// `streamText` runs tool execution inside `runTurn` (a step), so tool calls +// are not individually durable: the entire turn retries together. See // "Tools are not individually durable" below. Make side-effectful tools idempotent. async function lookupOrder({ orderId }: { orderId: string }) { const res = await fetch(`https://api.store.com/orders/${orderId}`); @@ -86,7 +86,7 @@ const TOOLS = { }, }; -// Per-turn step — streams one agent response to the durable writable // [!code highlight] +// Per-turn step: streams one agent response to the durable writable // [!code highlight] async function runTurn(messages: ModelMessage[]) { "use step"; @@ -99,7 +99,8 @@ async function runTurn(messages: ModelMessage[]) { }); const writable = getWritable(); - // preventClose keeps the durable writable open so the next turn can // write to it. Each turn still emits its own start + finish chunks. + // preventClose keeps the durable writable open so the next turn can write + // to it. Each turn still emits its own start and finish chunks. await result.toUIMessageStream().pipeTo(writable, { preventClose: true }); // [!code highlight] const response = await result.response; @@ -110,7 +111,7 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); - // Create the hook once, outside the loop — same token = HookConflictError // [!code highlight] + // Create the hook once, outside the loop: same token = HookConflictError // [!code highlight] const hook = turnHook.create({ token: workflowRunId }); // [!code highlight] let allMessages = initialMessages; @@ -144,7 +145,8 @@ import { convertToModelMessages, createUIMessageStreamResponse } from "ai"; import { start, getRun } from "workflow/api"; import { supportWorkflow, turnHook } from "@/workflows/support"; -// Pump the durable stream until this turn's `finish` chunk, then close // the HTTP response. The source reader is released (not cancelled) so the +// Pump the durable stream until this turn's `finish` chunk, then close the +// HTTP response. The source reader is released (not canceled) so the // workflow's durable stream keeps flowing for the next turn. function sliceUntilFinish( // [!code highlight] source: ReadableStream @@ -229,7 +231,7 @@ export async function POST(req: Request) { } catch (e: unknown) { const msg = e instanceof Error ? e.message.toLowerCase() : ""; if (!msg.includes("not found") && !msg.includes("expired")) throw e; - // Stale runId — fall through to start fresh + // Stale runId: fall through to start fresh } } @@ -303,21 +305,21 @@ export function SupportChat() { ## How it works -1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns. -2. **`runTurn` is the durability boundary.** Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries — individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable). -3. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`. -4. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it. -5. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing. -6. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns. -7. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming". +1. **One workflow represents one conversation**: The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns. +2. **`runTurn` is the durability boundary**: Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries. Individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable). +3. **The hook is created once**: Call `turnHook.create({ token: workflowRunId })` outside the loop. Calling it twice with the same token throws `HookConflictError`. +4. **`preventClose: true` keeps the writable open**: Set this option on `pipeTo` so the next turn can write to the durable writable. +5. **`sliceUntilFinish` closes each HTTP response**: The API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released, not canceled, so the workflow stream keeps flowing. +6. **`startIndex: tailIndex + 1` returns only new chunks**: Each follow-up response avoids replaying previous turns. +7. **`/done` exits the workflow**: The route resumes the hook so the workflow exits cleanly, then returns synthetic `start` and `finish` chunks so `useChat` transitions out of "streaming". ## Pitfalls -Non-obvious correctness details worth knowing before adapting this pattern. +Review these correctness details before adapting this pattern. ### Tools are not individually durable -`streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions) — the function just runs inline. +`streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions): the function runs inline. The consequences: @@ -327,8 +329,8 @@ The consequences: **Mitigations:** -- Make side-effectful tool implementations idempotent — dedupe server-side on a stable key (e.g. `orderId`, an `Idempotency-Key` header, etc.). -- Or use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which runs tools at workflow scope — each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks. +- Make side-effectful tool implementations idempotent: deduplicate server-side on a stable key, such as `orderId` or an `Idempotency-Key` header. +- Or use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which runs tools at workflow scope: each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks. ### Snapshot `tailIndex` *before* resuming the hook @@ -352,11 +354,11 @@ A `TransformStream` with `controller.terminate()` on the `finish` chunk seems li ### Release the source reader, don't cancel it -In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Cancelling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock just detaches our reader; the durable stream keeps flowing. +In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Canceling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock only detaches our reader; the durable stream keeps flowing. ### Handle stale `runId` gracefully -Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow. +Clients can send a `runId` from a workflow that no longer exists, such as after using local storage, navigating back, or restarting the server. Wrap the follow-up path in a `try/catch` for `not found` or `expired`, then use the first-turn code path to start a new workflow. ### Make the first turn idempotent when needed @@ -368,10 +370,10 @@ This example stores the `runId` after the first response. For strict one-session |---|---|---| | **Tool loop** | AI SDK handles via `stopWhen` | Handles internally (AI SDK–compatible options) | | **LLM call durability** | Re-executes with the parent turn | Each LLM call is a durable step | -| **Tool call durability** | Not individually durable — re-executes with the parent turn | Per tool — mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks | +| **Tool call durability** | Not individually durable: re-executes with the parent turn | Per tool: mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks | | **Stop conditions** | `stopWhen`, `prepareStep` | `stopWhen`, `prepareStep` | | **Structured output** | `Output.object()`, `Output.array()` | `output` (`Output.object()`, `Output.text()`) | -| **Step callbacks** | `onStepFinish`, `onChunk`, etc. | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available) | +| **Step callbacks** | `onStepFinish`, `onChunk`, and others | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available) | | **Setup** | Manual stream piping and turn slicing | Automatic | Use `WorkflowAgent` for most agent use cases. Use `streamText` when you need the raw AI SDK surface or a per-turn durability boundary. @@ -380,17 +382,17 @@ Use `WorkflowAgent` for most agent use cases. Use `streamText` when you need the **AI SDK** ([docs](https://ai-sdk.dev/docs)) -* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable -* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable)) -* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn -* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary -* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client +* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text): core streaming function; `toUIMessageStream()` pipes into the durable writable +* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling): tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable)) +* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions): bound the agent loop inside each turn +* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response): UI ↔ model message conversion at the API boundary +* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat): React hook that consumes the UI message stream on the client **Workflow SDK** -* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit -* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages -* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output -* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams -* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns -* [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first turns and side effects +* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): applied to `runTurn` to make each turn a durable, retryable unit +* [`defineHook()`](/docs/api-reference/workflow/define-hook): suspension point for follow-up messages +* [`getWritable()`](/docs/api-reference/workflow/get-writable): resumable stream output +* [`getRun()`](/docs/api-reference/workflow-api/get-run): `run.getReadable({ startIndex })` for slicing per-turn streams +* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport): passes `runId` between turns +* [Idempotency](/docs/foundations/idempotency): protect duplicate-sensitive first turns and side effects diff --git a/docs/content/docs/v4/cookbook/integrations/chat-sdk.mdx b/docs/content/docs/v4/cookbook/integrations/chat-sdk.mdx index 98f35d35f4..72f9b8ccb9 100644 --- a/docs/content/docs/v4/cookbook/integrations/chat-sdk.mdx +++ b/docs/content/docs/v4/cookbook/integrations/chat-sdk.mdx @@ -1,8 +1,8 @@ --- title: Chat SDK -description: Make Chat SDK bot sessions durable — one workflow run per conversation thread, with hooks bridging inbound platform events into long-running agent logic. +description: Make Chat SDK bot sessions durable, with one workflow run per conversation thread and hooks bridging inbound platform events into long-running agent logic. type: guide -summary: Chat SDK normalizes Slack, Teams, Discord, Telegram and friends into one thread/message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts. +summary: Chat SDK normalizes Slack, Teams, Discord, Telegram, and similar platforms into one thread and message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts. related: - /docs/cookbook/integrations/ai-sdk - /docs/cookbook/integrations/sandbox @@ -15,13 +15,13 @@ related: text="Make this Chat SDK bot durable with Workflow SDK. Install/use `workflow`. Create one exported workflow function with "use workflow" per chat thread. Store the Chat SDK thread ID, Workflow run ID, and any serialized conversation state in the project data store. Use `defineHook()` from `workflow` for incoming turns and call `resumeHook()` from `workflow/api` from the Chat SDK webhook or message handler. Put provider calls, database writes, and outbound platform messages in "use step" helper functions. Start a new run with `start(workflowFn, [initialThreadState])` when no run exists, otherwise resume the existing hook. Use `getRun(runId)` for status, cancellation, or stream reads. Verify first message, follow-up message, restart/reconnect, duplicate webhook, and failed-send retry behavior." /> -[Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write the bot once, deploy to every platform. It handles webhook verification, event normalization, subscriptions, and cross-platform features like cards and modals. +[Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. A single bot can support each platform. Chat SDK handles webhook verification, event normalization, subscriptions, and cross-platform features such as cards and modals. Workflow SDK complements it by making bot **sessions** durable. Each conversation thread maps to a long-running workflow run that: - Owns multi-turn state in the durable event log instead of Redis-by-hand bookkeeping - Can `sleep()` for hours or days waiting for a user reply, an approval, or a scheduled follow-up -- Survives deploys, cold starts, and crashes — the session picks up from the last step on replay +- Survives deploys, cold starts, and crashes: the session picks up from the last step on replay - Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running @@ -30,9 +30,9 @@ One thread mapped to one workflow run also means the thread stays on the deploym The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev. -## How It Fits Together +## How it fits together -Chat SDK owns the edge — webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session — state, loops, sleeps, retries. They meet at exactly two points: +Chat SDK owns the edge: webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session: state, loops, sleeps, retries. They meet at exactly two points: ```mermaid flowchart TD @@ -44,8 +44,8 @@ flowchart TD E --> F[""use step" helpers
thread.post(), thread.subscribe(), thread.setState(), …"] ``` -- **Inbound** — Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter). -- **Outbound** — the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file — adapter packages use Node-only modules that aren't available in the workflow sandbox. +- **Inbound**: Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter). +- **Outbound**: the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file, since adapter packages use Node-only modules that aren't available in the workflow sandbox. ## Why Workflow + Chat SDK @@ -60,11 +60,11 @@ Workflow replaces all of that with a single durable function. The bot can: - Schedule a follow-up message 24 hours later via `sleep("24h")` - Pause on sandbox snapshot, resume when the user sends the next command (see the [Sandbox integration](/docs/cookbook/integrations/sandbox)) -Because the session *is* a workflow run, its history is recoverable from the event log — no separate message store to keep in sync. +Because the session *is* a workflow run, its history is recoverable from the event log, so there's no separate message store to keep in sync. -## The Pattern: One Thread = One Workflow Run +## The pattern: one thread = one workflow run -Three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox. +This pattern uses three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox. @@ -125,7 +125,7 @@ async function postAssistantMessage( async function runTurn(text: string) { "use step"; - // Your AI SDK call, database lookup, tool loop, etc. + // Your AI SDK call, database lookup, tool loop, and other operations. return `You said: ${text}`; } @@ -157,7 +157,7 @@ export async function durableChatSession(payload: string) { if (!(await handleMessage(thread, message))) return; // Each hook resumption is one turn. The workflow stays suspended between - // messages — zero compute cost while idle. + // messages: zero compute cost while idle. while (true) { const { message: nextRaw } = await hook; // [!code highlight] const next = Message.fromJSON(nextRaw); @@ -204,7 +204,7 @@ async function startSession(thread: Thread, message: Message) { async function routeTurn(thread: Thread, message: Message) { const state = await thread.state; - // No run yet, or the previous run finished — start fresh. + // No run yet, or the previous run finished: start fresh. if (!state?.runId || !(await getRun(state.runId).exists)) { await startSession(thread, message); return; @@ -217,7 +217,7 @@ async function routeTurn(thread: Thread, message: Message) { } catch (err) { const msg = err instanceof Error ? err.message.toLowerCase() : ""; if (msg.includes("not found") || msg.includes("expired")) { - // Stale runId — start a new session rather than dropping the message. + // Stale runId: start a new session rather than dropping the message. await startSession(thread, message); return; } @@ -260,30 +260,30 @@ export async function POST( -## How It Works +## How it works -1. **Thread state stores the `runId`.** Chat SDK's state adapter (Redis, Postgres, memory) holds `{ runId }` per thread. That's the only piece of glue between the two SDKs. -2. **First mention → `start()`.** Handler serializes `thread` + `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, stashes the returned `runId` in thread state. -3. **Subsequent messages → `resumeHook()`.** Handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow picks up on the next `await hook` iteration. -4. **Workflow posts back via steps.** All Chat SDK side effects (`thread.post`, `thread.subscribe`, `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox. -5. **Session ends — two ways.** The workflow returns normally (user said `done`, approval granted, etc.), or the workflow throws. Either way the run completes; the next inbound message with the stale `runId` falls through to `startSession()`. +1. **Thread state stores the `runId`**: Chat SDK's state adapter (Redis, Postgres, or memory) holds `{ runId }` per thread. This state connects the two SDKs. +2. **The first mention calls `start()`**: The handler serializes `thread` and `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, and stores the returned `runId` in thread state. +3. **Subsequent messages call `resumeHook()`**: The handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow continues on the next `await hook` iteration. +4. **The workflow posts through steps**: All Chat SDK side effects (`thread.post`, `thread.subscribe`, and `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox. +5. **The session ends in two ways**: The workflow returns normally when the user sends `done` or an approval is granted, or the workflow throws. Either way, the run completes. The next inbound message with the stale `runId` falls through to `startSession()`. The workflow is fully durable between turns: `await hook` suspends with zero compute cost, and platform webhooks can fire from anywhere without concern for which server instance handled the previous turn. -## Extending the Pattern +## Extending the pattern -Because the session is just a workflow, everything else from the cookbook composes naturally: +Because the session is a workflow, everything else from the cookbook composes naturally: -- **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/docs/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()` — Chat SDK handles platform-specific streaming (Slack edit-in-place, Telegram message-per-chunk, etc.). +- **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/docs/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()`. Chat SDK handles platform-specific streaming, including Slack edit-in-place and Telegram message-per-chunk. - **Give the bot a sandbox.** Combine with the [Sandbox integration](/docs/cookbook/integrations/sandbox): each thread gets its own persistent sandbox session, snapshots on idle, resumes on the next message. That's effectively a coding-agent bot. - **Human-in-the-loop approvals.** `Promise.race([hook, approvalHook])` inside the workflow, post buttons in the thread via [cards](https://chat-sdk.dev/docs/cards), resume `approvalHook` from `bot.onAction(...)`. -- **Scheduled follow-ups.** `sleep("24h")` before a proactive check-in. Surviving restarts is free. +- **Scheduled follow-ups.** Call `sleep("24h")` before a proactive check-in. The workflow preserves the timer across restarts. ## Pitfalls ### Don't import the bot at the top of workflow files -Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow — it's standalone and has no adapter dependencies. +Adapter packages such as `@chat-adapter/slack` and `@chat-adapter/telegram` depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow: it's standalone and has no adapter dependencies. ### Register the bot as a singleton @@ -307,14 +307,14 @@ One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused eve ### Platform timeouts are separate from workflow timeouts -Slack wants a 200 within 3 seconds. The webhook handler returns immediately after `resumeHook` (which is fast) — the workflow then runs in the background and posts back via `thread.post`. Don't try to `await` the whole turn inside the webhook handler; that's what breaks in the naive integration. +Slack requires an HTTP 200 response within 3s. The webhook handler returns after `resumeHook`, then the workflow runs in the background and posts through `thread.post`. Don't `await` the whole turn inside the webhook handler because that synchronous integration exceeds the platform timeout. ## Key APIs -- [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message) — Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer. -- [`start()`](/docs/api-reference/workflow-api/start) — start a new session workflow. Store the returned `runId` in thread state. -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — forward a new platform message to the running workflow. -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow. -- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions. -- [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first messages and side effects. +- [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message): Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer. +- [`start()`](/docs/api-reference/workflow-api/start): start a new session workflow. Store the returned `runId` in thread state. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): forward a new platform message to the running workflow. +- [`getRun()`](/docs/api-reference/workflow-api/get-run): `run.exists` before resuming, to detect stale `runId`s. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): per-turn suspension point inside the workflow. +- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat): makes the bot resolvable from inside step functions. +- [Idempotency](/docs/foundations/idempotency): protect duplicate-sensitive first messages and side effects. diff --git a/docs/content/docs/v4/cookbook/integrations/sandbox.mdx b/docs/content/docs/v4/cookbook/integrations/sandbox.mdx index ad3768b67f..65b2a6befc 100644 --- a/docs/content/docs/v4/cookbook/integrations/sandbox.mdx +++ b/docs/content/docs/v4/cookbook/integrations/sandbox.mdx @@ -1,8 +1,8 @@ --- title: Sandbox -description: Model one Vercel Sandbox per workflow run — durable, idle-efficient, and not bound by the 5-hour sandbox hard cap. +description: Model one Vercel Sandbox per workflow run, durable, idle-efficient, and not bound by the 5-hour sandbox hard cap. type: guide -summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by runId — so one logical session can run effectively forever. +summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by `runId`, so one logical session can run effectively forever. related: - /docs/ai/defining-tools - /docs/foundations/errors-and-retries @@ -14,34 +14,34 @@ related: text="Implement a durable Vercel Sandbox-backed coding-agent workflow. Install the Sandbox package used by this project and `workflow`. Create an exported workflow function with "use workflow" that owns the agent session. Put sandbox creation, command execution, snapshot, refresh, and cleanup into helper functions with "use step". Persist the sandbox ID, snapshot ID, and workflow run ID in the project data store so clients can reconnect. Use `getWritable()` from `workflow` to stream agent progress and command output. Use `sleep()` to hibernate, refresh, or enforce idle timeouts. Add API routes to start a session, reconnect by run ID, and stop/cleanup. Verify first run, reconnect after reload, snapshot restore, timeout, and cleanup behavior." /> -[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK — the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function. +[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK: the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function. ## Why Workflow + Sandbox -A sandbox alone gets you an isolated VM. A workflow around it gets you a **durable controller** for that VM's entire lifetime: +A sandbox alone provides an isolated virtual machine (VM). A workflow provides a **durable controller** for that VM's entire lifetime: - **One workflow run = one sandbox session.** The `runId` is the only state you need to persist on the client. Close the tab, come back a week later, POST the same `runId` and you're back in the same session. -- **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer — when idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. Next command → spin a new sandbox from the snapshot with filesystem, installed packages, and git history intact. -- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure. +- **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer. When idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. When the next command arrives, the workflow starts a new sandbox from the snapshot with the filesystem, installed packages, and git history intact. +- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively creates a snapshot and replacement before the cap, so the logical session outlives any one VM. This provides an effectively unbounded session on time-bounded infrastructure. - **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy. An effectively unbounded sandbox session is still one workflow run, so it stays on the deployment that started it. If the controller or agent code should upgrade over time, use an explicit version boundary and pass the serialized state or stream handles forward. See [Versioning](/docs/foundations/versioning). -## Use Case: Coding Agents +## Use case: coding agents -This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return. +This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox (full filesystem, network, and runtime access), and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return. Most coding-agent workloads look like this: -- User sends a task → agent plans, reads files, runs shell commands, commits. -- User walks away mid-run → agent keeps going, eventually goes idle waiting for input. -- User comes back days later → same branch, same filesystem, same conversation history. +- The user sends a task, and the agent plans, reads files, runs shell commands, and commits. +- If the user leaves mid-run, the agent continues and eventually waits for input. +- When the user returns, the same branch, filesystem, and conversation history remain available. -Without durable workflows you'd need a separate state store for the agent loop, a separate job queue for retries, a separate scheduler for idle cleanup, and bespoke reconnection logic. With the pattern below, all of it is one file. +Without durable workflows, you'd need a separate state store for the agent loop, a job queue for retries, a scheduler for idle cleanup, and custom reconnection logic. The pattern below keeps the workflow controller in one file. -## Quickstart: One-shot Pipeline +## Quickstart: one-shot pipeline Before the full session pattern, the simplest shape. Each sandbox method is an implicit step, so the event log records every command and the workflow replays from the last completed call on restart. @@ -74,17 +74,17 @@ export async function sandboxPipeline(input: { commands: string[] }) { } ``` -## Session Pattern: Persistent Sandbox Beyond the Hard Cap +## Session pattern: persistent sandbox beyond the hard cap One workflow run owns a sandbox for its whole lifetime. The workflow's loop does two jobs simultaneously: -1. **Command pipeline** — await a hook, run the next user command, stream output, loop. -2. **Sandbox lifecycle** — race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap). +1. **Command pipeline**: await a hook, run the next user command, stream output, loop. +2. **Sandbox lifecycle**: race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap). When the timer wins: -- **Idle** → `sandbox.snapshot()` and wait indefinitely for the next command. No compute while asleep. -- **Near sandbox hard cap** → `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session appears continuous; the underlying VM just rotated. +- **Idle**: Call `sandbox.snapshot()` and wait indefinitely for the next command. The workflow uses no compute while suspended. +- **Near the sandbox hard cap**: Call `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session remains continuous while the underlying VM rotates. The only way out is an explicit `/destroy` command. @@ -170,7 +170,7 @@ export async function sandboxSessionWorkflow() { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); - // Create the hook once, outside the loop — reusing the same token from inside // [!code highlight] + // Create the hook once, outside the loop: reusing the same token from inside // [!code highlight] // the loop would throw HookConflictError. // [!code highlight] const hook = commandHook.create({ token: workflowRunId }); @@ -205,8 +205,8 @@ export async function sandboxSessionWorkflow() { try { while (!destroyed) { if (hibernated && snapshot) { - // While hibernated, the VM is already stopped. Just wait for the next - // command — no idle timer, no compute cost. + // While hibernated, the VM is already stopped. Wait for the next + // command: no idle timer, no compute cost. const payload = await hook; if (payload.command === "/destroy") { destroyed = true; break; } @@ -231,7 +231,7 @@ export async function sandboxSessionWorkflow() { continue; } - // Active — wake at whichever comes first: idle-deadline or refresh-deadline. + // Active. Wake at whichever comes first: idle-deadline or refresh-deadline. const idleDeadline = lastActivityAt + HIBERNATE_AFTER_MS; const refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS; const wakeAt = Math.min(idleDeadline, refreshDeadline); @@ -246,7 +246,7 @@ export async function sandboxSessionWorkflow() { const nearExpiry = Date.now() >= refreshDeadline; if (nearExpiry) { - // Proactive refresh — snapshot and immediately recreate so the + // Proactive refresh: snapshot and immediately recreate so the // session outlives the sandbox hard cap. await emit({ type: "status", state: "refreshing", at: Date.now() }); const snap = await sandbox.snapshot(); // [!code highlight] @@ -263,7 +263,7 @@ export async function sandboxSessionWorkflow() { }); lastActivityAt = Date.now(); } else { - // Idle — snapshot and hibernate indefinitely. + // Idle: snapshot and hibernate indefinitely. await emit({ type: "status", state: "hibernating", at: Date.now() }); snapshot = await sandbox.snapshot(); // [!code highlight] hibernated = true; @@ -302,7 +302,7 @@ export async function sandboxSessionWorkflow() { -Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream. +Two endpoints manage the session. `/start` accepts an optional `{ runId }`: if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream. This example starts a fresh sandbox session when no `runId` is provided. If your product needs one sandbox session per user, project, or task, use a deterministic hook token derived from that session key and route retries through the active hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). @@ -334,7 +334,7 @@ export async function POST(req: Request) { }, }); } - // Stale runId — fall through to start fresh. + // Stale runId: fall through to start fresh. } const run = await start(sandboxSessionWorkflow, []); @@ -383,7 +383,7 @@ export async function POST(req: Request) { -On mount, if a `runId` is stashed in `localStorage`, reconnect to the existing run. Otherwise start fresh. Commands are POSTed to `/command` — output lands on the `/start` stream. +On mount, reconnect to the existing run if `localStorage` contains a `runId`. Otherwise, start a new run. Send commands to `/command` with POST requests. Output arrives on the `/start` stream. ```tsx title="components/sandbox-runner.tsx" lineNumbers "use client"; @@ -476,22 +476,22 @@ export function SandboxRunner() {
-## How It Works +## How it works -1. **One workflow = one session.** The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember. -2. **Hook created once.** `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`. -3. **Two timer branches.** The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone — no timer, no compute. -4. **Proactive refresh.** `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS`. Hitting this triggers a snapshot + immediate new sandbox from that snapshot, rolling over the hard cap without user intervention. -5. **`sandbox.snapshot()` stops the VM.** It's documented as part of the snapshot process — don't call `stop()` separately. -6. **Resume = new sandbox.** `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a fresh VM from the snapshot. The new sandbox has a different `sandboxId`; filesystem, installed packages, and git history are preserved. -7. **Reconnect by runId.** `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, who rebuilds UI state from the replay. -8. **Exit only on `/destroy`.** The workflow loop has no hard deadline of its own. Individual sandboxes time out; the session doesn't. +1. **One workflow represents one session**: The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember. +2. **Create the hook once**: Call `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`. +3. **Two timer branches control wake-up**: The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone, with no timer or compute. +4. **Proactive refresh replaces the sandbox**: When `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS` arrives, the workflow takes a snapshot and immediately creates a new sandbox from it. This rolls over the hard cap without user intervention. +5. **`sandbox.snapshot()` stops the VM**: The snapshot process stops the VM, so don't call `stop()` separately. +6. **Resume creates a new sandbox**: `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a new VM from the snapshot. The new sandbox has a different `sandboxId`; the filesystem, installed packages, and git history are preserved. +7. **Reconnect by `runId`**: `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, which rebuilds UI state from the replay. +8. **Exit only on `/destroy`**: The workflow loop has no hard deadline of its own. Individual sandboxes time out, but the session doesn't. ## Pitfalls ### `sandbox.stop()` is terminal -A stopped sandbox cannot be restarted — you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later. +A stopped sandbox cannot be restarted: you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later. ### `snapshot()` already stops the VM @@ -503,7 +503,7 @@ Both `resuming` (idle → command) and `refreshing` (near-hard-cap rotation) cre ### Keep the refresh margin generous -`snapshot()` + `Sandbox.create({ source })` takes real time (typically tens of seconds). If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; 5 minutes is comfortable. +`snapshot()` followed by `Sandbox.create({ source })` takes time, typically tens of seconds. If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; the example uses 5 minutes. ### Don't call `writable.close()` inside a workflow function @@ -523,11 +523,11 @@ Each iteration's `hook.then(...)` attaches a listener to the same hook instance. ## Key APIs -- [`Sandbox.create`](https://vercel.com/docs/sandbox) — provision a VM (runtime, source, timeout) -- [`sandbox.runCommand`](https://vercel.com/docs/sandbox) — execute a command; implicit step -- [`sandbox.snapshot`](https://vercel.com/docs/sandbox) — save state and stop the VM; returns `Snapshot` -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for user commands -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection -- [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream -- [Idempotency](/docs/foundations/idempotency) — choose when `/start` should reuse an existing run +- [`Sandbox.create`](https://vercel.com/docs/sandbox): provision a VM (runtime, source, timeout) +- [`sandbox.runCommand`](https://vercel.com/docs/sandbox): execute a command; implicit step +- [`sandbox.snapshot`](https://vercel.com/docs/sandbox): save state and stop the VM; returns `Snapshot` +- [`defineHook()`](/docs/api-reference/workflow/define-hook): suspension point for user commands +- [`sleep()`](/docs/api-reference/workflow/sleep): durable timer that powers both idle hibernation and proactive refresh +- [`getRun()`](/docs/api-reference/workflow-api/get-run): look up a run and replay its event log for reconnection +- [`getWritable()`](/docs/api-reference/workflow/get-writable): resumable newline-delimited JSON (NDJSON) event stream +- [Idempotency](/docs/foundations/idempotency): choose when `/start` should reuse an existing run diff --git a/docs/content/docs/v4/deploying.mdx b/docs/content/docs/v4/deploying.mdx index 17074c9ae9..df203d089c 100644 --- a/docs/content/docs/v4/deploying.mdx +++ b/docs/content/docs/v4/deploying.mdx @@ -10,18 +10,18 @@ related: - /worlds/building-a-world --- -Workflows are designed to be highly portable. The same workflow code can run locally during development, on Vercel with zero configuration, or on any infrastructure using **Worlds** — pluggable adapters that handle storage, queuing, and communication. +The same workflow code can run locally during development, on Vercel with zero configuration, or on any infrastructure using **Worlds**. These pluggable adapters handle storage, queuing, and communication. -## Local Development +## Local development -During local development, workflows automatically use the **Local World** — no configuration required. The Local World stores workflow data in a `.workflow-data/` directory and processes steps synchronously, making it perfect for development and testing. +During local development, workflows automatically use the **Local World**, with no configuration required. The Local World stores workflow data in a `.workflow-data/` directory and processes steps synchronously, making it suitable for development and testing. ```bash -# Just run your dev server - workflows work out of the box +# Run your development server; workflows need no additional configuration npm run dev ``` -You can inspect local workflow data using the CLI: +You can inspect local workflow data using the Workflow CLI: ```bash npx workflow inspect runs @@ -33,16 +33,16 @@ npx workflow inspect runs ## Deploying to Vercel -The easiest way to deploy workflows to production is on Vercel. When you deploy to Vercel, workflows automatically use the **Vercel World** — again, with zero configuration. +The recommended way to deploy workflows to production is on Vercel. When you deploy to Vercel, workflows automatically use the **Vercel World**, again with zero configuration. The Vercel World provides: -- **Durable storage** - Workflow state persists across function invocations -- **Managed queuing** - Steps are processed reliably with automatic retries -- **Automatic scaling** - Workflows scale with your application -- **Built-in observability** - View workflow runs in the Vercel dashboard +- **Durable storage**: Workflow state persists across function invocations. +- **Managed queuing**: Steps are processed reliably with automatic retries. +- **Automatic scaling**: Workflows scale with your application. +- **Built-in observability**: View workflow runs in the Vercel dashboard. -Simply deploy your application: +Deploy your application: ```bash vercel deploy @@ -61,7 +61,7 @@ vercel deploy [Multi-region](/v5/worlds/vercel#multi-region). -## Self-Hosting & Other Providers +## Self-hosting & other providers For self-hosting or deploying to other cloud providers, you can use community-maintained Worlds or build your own. @@ -74,7 +74,7 @@ For self-hosting or deploying to other cloud providers, you can use community-ma -### Using a Third-Party World +### Using a third-party World To use a different World implementation, set the `WORKFLOW_TARGET_WORLD` environment variable: @@ -84,11 +84,11 @@ export WORKFLOW_TARGET_WORLD=@workflow/world-postgres export DATABASE_URL=postgres://... ``` -Each World may have its own configuration requirements — refer to the specific World's documentation for details. +Each World may have its own configuration requirements. Refer to the specific World's documentation for details. ## Observability -The [Observability tools](/docs/observability) work with any World backend. By default they connect to your local environment, but can be configured to inspect remote deployments: +The [Observability tools](/docs/observability) work with any World backend. By default, the tools connect to your local environment, but you can configure them to inspect remote deployments: ```bash # Inspect local workflows diff --git a/docs/content/docs/v4/errors/corrupted-event-log.mdx b/docs/content/docs/v4/errors/corrupted-event-log.mdx index efd1028cde..7fde78c66b 100644 --- a/docs/content/docs/v4/errors/corrupted-event-log.mdx +++ b/docs/content/docs/v4/errors/corrupted-event-log.mdx @@ -13,27 +13,27 @@ This error occurs when the Workflow runtime repeatedly cannot replay events in t This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover. -## Error Message +## Error message -``` +```text Workflow replay diverged times after recovery replays; latest divergent event was . Last divergence:
``` -## Why This Happens +## Why this happens -Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). When an event has no matching consumer, the runtime cannot advance past it, which would block all subsequent events and hang the workflow indefinitely. +Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence: every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). When an event has no matching consumer, the runtime cannot advance past it, which would block all subsequent events and hang the workflow indefinitely. Instead of silently hanging, the runtime retries a divergent replay before failing the workflow and surfacing this terminal error. Common scenarios that produce this error: -1. **Duplicate completion events** — Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, but the second has no consumer. -2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code. -3. **Events after terminal state** — An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`). +- **Duplicate completion events**: Two `wait_completed` events for a single `wait_created`, or two `step_completed` events for the same step. The first is consumed normally, but the second has no consumer. +- **Orphaned events**: A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code. +- **Events after terminal state**: An event that arrives after its corresponding step or wait has already reached a terminal state (e.g., `step_retrying` after `step_completed`). -## What To Do +## What to do -This error indicates a bug in the Workflow SDK or Workflow server — not in your workflow code. Your workflow code does not need to change. Follow these steps to resolve the issue: +This error indicates a bug in the Workflow SDK or Workflow server, not in your workflow code. Your workflow code does not need to change. Follow these steps to resolve the issue: ### 1. Upgrade to the latest `workflow` package @@ -49,14 +49,14 @@ If this error is displayed, automatic replay recovery has already been exhausted ### 3. Report the issue -If the error persists after upgrading, please [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate and fix the underlying bug. Include the following details to help us diagnose the problem: +If the error persists after upgrading, [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate and fix the underlying bug. Include the following details to help us diagnose the problem: -- The version of the `workflow` package you are using -- The run ID(s) of the affected workflow run(s) -- The error message (including `eventType`, `correlationId`, and `eventId`) -- Any details about the event log or the workflow that triggered the error +- The version of the `workflow` package you are using. +- The run IDs of the affected workflow runs. +- The error message (including `eventType`, `correlationId`, and `eventId`). +- Any details about the event log or the workflow that triggered the error. -## This Error Cannot Be Caught +## This error cannot be caught Unlike other workflow errors, a corrupted event log error is **not catchable** inside your workflow function. Because the event log itself is invalid, the runtime cannot safely continue executing any user code. The entire run fails immediately and is marked as `failed`. diff --git a/docs/content/docs/v4/errors/deployment-mismatch.mdx b/docs/content/docs/v4/errors/deployment-mismatch.mdx index bbeced87ae..fcaaf320b2 100644 --- a/docs/content/docs/v4/errors/deployment-mismatch.mdx +++ b/docs/content/docs/v4/errors/deployment-mismatch.mdx @@ -15,30 +15,30 @@ Every run is pinned to a single deployment when it starts. When a queued workflo This is an SDK/runtime signal, not an error thrown by your workflow code, and it is not catchable inside a workflow function. -## Error Message +## Error message -``` +```text Workflow run "wrun_..." is pinned to deployment "dpl_A", but was received by deployment "dpl_B". The runtime re-routed the message to "dpl_A" 3 times and it kept arriving elsewhere, so the run was stopped to protect against code-skew errors. Verify that the run's deployment is still available and that queue callbacks are routed to it. ``` -When the queue definitively reports that the run's deployment cannot be reached — it was deleted, or aged out of its retention window — no re-route is possible and the message omits the re-routing clause. Transient or unknown publishing failures leave the current delivery unacknowledged so the queue can redeliver it; they do not fail the run or consume this recovery budget. +When the queue definitively reports that the run's deployment cannot be reached (it was deleted, or aged out of its retention window), no re-route is possible and the message omits the re-routing clause. Transient or unknown publishing failures leave the current delivery unacknowledged so the queue can redeliver it; they do not fail the run or consume this recovery budget. -## Why A Run Is Pinned +## Why a run is pinned A run's deployment is chosen once, at [`start()`](/docs/api-reference/workflow-api/start): -- By default it is the deployment that called `start()` — see [Versioning](/docs/foundations/versioning) for why runs are pinned this way. +- By default it is the deployment that called `start()`. See [Versioning](/docs/foundations/versioning) for why runs are pinned this way. - With `start(workflow, args, { deploymentId })` it is the id you pass, so a run can deliberately target a deployment other than the one that created it. - With `deploymentId: "latest"` it is the most recent deployment for the current environment, resolved at start time. Whichever it is, that `deploymentId` is recorded on the run, and every subsequent workflow replay and step execution must happen on that deployment. Continuing on a different one is unsafe: 1. **Code skew.** The workflow and step bundles on the receiving deployment may not match the code that produced the run's recorded history, so replay could diverge or produce incorrect results. -2. **Encryption.** Step inputs and other event-log payloads are encrypted with a per-run key derived from the pinned deployment's key material. A different deployment derives the wrong key and cannot decrypt them — previously the source of a confusing [runtime-decryption-failed](/docs/errors/runtime-decryption-failed) that exhausted retries with no clear cause. +2. **Encryption.** Step inputs and other event-log payloads are encrypted with a per-run key derived from the pinned deployment's key material. A different deployment derives the wrong key and cannot decrypt them, previously the source of a confusing [runtime-decryption-failed](/docs/errors/runtime-decryption-failed) that exhausted retries with no clear cause. -So the runtime checks the pinned deployment before it executes anything, and `DEPLOYMENT_MISMATCH` names the result — instead of the mismatch surfacing later as an unrelated decryption failure. +So the runtime checks the pinned deployment before it executes anything, and `DEPLOYMENT_MISMATCH` names the result, instead of the mismatch surfacing later as an unrelated decryption failure. -## Automatic Recovery +## Automatic recovery A deployment that receives a run it does not own first tries to fix the delivery rather than fail the run: @@ -46,19 +46,19 @@ A deployment that receives a run it does not own first tries to fix the delivery 2. Delivery is delayed with a short exponential backoff (1s, 2s, 4s). 3. If the run keeps arriving at the wrong deployment, the run is failed with `DEPLOYMENT_MISMATCH` after `WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES` attempts (default `3`). Set it to `0` to fail on the first misrouted delivery instead. -Nothing is executed on the wrong deployment during recovery: no workflow code, no step body, no `step_started`, and no hook resume. Whatever the delivery was carrying travels with it, so a pending step keeps its identity and a hook resume keeps its payload — they run on the deployment that can actually decrypt them. +Nothing is executed on the wrong deployment during recovery: no workflow code, no step body, no `step_started`, and no hook resume. Whatever the delivery was carrying travels with it, so a pending step keeps its identity and a hook resume keeps its payload: they run on the deployment that can actually decrypt them. Recovery attempts do not create events on the run, so a run that self-heals looks completely normal. They are reported on the invocation's trace span (`workflow.deployment.pinned_id`, `workflow.deployment_mismatch.retry_count`, `workflow.deployment_mismatch.recovered`) and as a runtime warning in your function logs. -## What To Do +## What to do - **Re-run from the current deployment.** Trigger the workflow again from your latest deployment (or use the **Re-run** button in the Workflow Dashboard). The new run is pinned to the current deployment. -- **Keep a run's deployment available** for the lifetime of that run. A run whose deployment has been deleted or has aged out cannot be resumed and must be re-run — recovery cannot help, so these fail on the first misrouted delivery. This applies to runs started with an explicit `deploymentId` too: pinning a run to an older deployment keeps it dependent on that deployment for its whole lifetime. -- **Report it** if the pinned deployment was still available. Include both deployment ids and the run id from the error message, plus the trace span attributes above — a run that failed this way despite a reachable target is a routing fault worth investigating rather than something to work around. +- **Keep a run's deployment available** for the lifetime of that run. A run whose deployment has been deleted or has aged out cannot be resumed and must be re-run: recovery cannot help, so these fail on the first misrouted delivery. This applies to runs started with an explicit `deploymentId` too: pinning a run to an older deployment keeps it dependent on that deployment for its whole lifetime. +- **Report it** if the pinned deployment was still available. Include both deployment IDs and the run ID from the error message, plus the trace span attributes above. A run that failed this way despite a reachable target is a routing fault worth investigating rather than something to work around. -## This Error Cannot Be Caught +## This error cannot be caught -Like other runtime signals, `DEPLOYMENT_MISMATCH` is **not catchable** inside your workflow function — the run is failed before any workflow or step code executes on the receiving deployment. Check the run status from outside instead: +Like other runtime signals, `DEPLOYMENT_MISMATCH` is **not catchable** inside your workflow function: the run is failed before any workflow or step code executes on the receiving deployment. Check the run status from outside instead: ```typescript lineNumbers import { getRun } from "workflow/api"; diff --git a/docs/content/docs/v4/errors/fetch-in-workflow.mdx b/docs/content/docs/v4/errors/fetch-in-workflow.mdx index 140d92f4ed..41f98455bc 100644 --- a/docs/content/docs/v4/errors/fetch-in-workflow.mdx +++ b/docs/content/docs/v4/errors/fetch-in-workflow.mdx @@ -10,24 +10,24 @@ related: --- This error occurs when you try to use `fetch()` directly in a workflow function, or when a library (like the AI SDK) tries to call `fetch()` under the hood. -## Error Message +## Error message -``` +```text Global "fetch" is unavailable in workflow functions. Use the "fetch" step function from "workflow" to make HTTP requests. ``` -## Why This Happens +## Why this happens Workflow functions run in a sandboxed environment without direct access to `fetch()`. Many libraries make HTTP requests under the hood. For example, the AI SDK's `generateText()` function calls `fetch()` to make HTTP requests to AI providers. When these libraries run inside a workflow function, they fail because the global `fetch` is not available. -## Quick Fix +## Quick fix Import the `fetch` step function from the `workflow` package and assign it to `globalThis.fetch` inside your workflow function. This version of `fetch` is a step function that wraps the standard `fetch` API, automatically handling serialization and providing retry capabilities. This will also make `fetch()` available to all functions and libraries in the current workflow function. @@ -72,9 +72,9 @@ export async function chatWorkflow(prompt: string) { } ``` -## Common Scenarios +## Common scenarios -### AI SDK Integration +### AI SDK integration This is the most common scenario - using AI SDK functions that make HTTP requests: @@ -98,7 +98,7 @@ export async function aiWorkflow(userMessage: string) { } ``` -### Direct API Calls +### Direct API calls You can also use the fetch step function directly for your own HTTP requests: diff --git a/docs/content/docs/v4/errors/hook-conflict.mdx b/docs/content/docs/v4/errors/hook-conflict.mdx index 3d43222512..7b68045463 100644 --- a/docs/content/docs/v4/errors/hook-conflict.mdx +++ b/docs/content/docs/v4/errors/hook-conflict.mdx @@ -16,13 +16,13 @@ related: This error occurs when you try to create a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows in your project. -## Error Message +## Error message -``` +```text Hook token "" is already in use by another workflow ``` -## Why This Happens +## Why this happens Hooks use tokens to identify incoming webhook payloads. When you create a hook with `createHook({ token: "my-token" })`, the Workflow runtime reserves that token for your workflow run. If another workflow run is already using that token, a conflict occurs. @@ -31,9 +31,9 @@ This typically happens when: 1. **Two workflows start simultaneously** with the same hardcoded token 2. **A previous workflow run is still waiting** for a hook when a new run tries to use the same token -## Common Causes +## Common causes -### Hardcoded Token Values +### Hardcoded token values {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -61,7 +61,7 @@ export async function processPayment(orderId: string) { } ``` -### Omitting the Token (Auto-generated) +### Omitting the token (auto-generated) The safest approach is to let the Workflow runtime generate a unique token automatically: @@ -77,7 +77,7 @@ export async function processPayment() { } ``` -## Handling Hook Conflicts +## Handling hook conflicts When a hook conflict occurs, awaiting the hook will throw a [`HookConflictError`](/docs/api-reference/workflow-errors/hook-conflict-error). The error exposes the token that conflicted and, for current worlds, the run ID that currently owns it. `conflictingRunId` remains optional for compatibility with older persisted events and world implementations, so guard it before delegating: @@ -114,7 +114,7 @@ export async function processPayment(orderId: string) { This pattern is useful when you want to detect duplicate processing inside the workflow. Runtime APIs such as `resumeHook()` and `getRun()` must be called outside workflow functions, for example from an API route or in a step. -### Delegate to the Active Run +### Delegate to the active Run In idempotency flows, a conflict means another active run already owns the hook token. You can return the duplicate-processing payload from the workflow, resume the active hook to deliver the payload to the existing run, then use `getRun(result.runId)` to wait for, stream, or cancel the active run: @@ -156,17 +156,17 @@ export async function POST(request: Request) { If the caller needs live output instead of the final result, return `activeRun.getReadable()` from the same branch. If the duplicate request should replace the active work, call `await activeRun.cancel()` after inspecting the run. -## When Hook Tokens Are Released +## When hook tokens are released Hook tokens are automatically released when: - The workflow run **completes** (successfully or with an error) -- The workflow run is **cancelled** +- The workflow run is **canceled** - The hook is explicitly **disposed** After a workflow completes, its hook tokens become available for reuse by other workflows. -## Best Practices +## Best practices 1. **Use auto-generated tokens** when possible - they are guaranteed to be unique 2. **Include unique identifiers** if you need custom tokens (order ID, user ID, etc.) diff --git a/docs/content/docs/v4/errors/index.mdx b/docs/content/docs/v4/errors/index.mdx index ebc703c681..bcd285f0c6 100644 --- a/docs/content/docs/v4/errors/index.mdx +++ b/docs/content/docs/v4/errors/index.mdx @@ -11,7 +11,7 @@ Fix common mistakes when creating and executing workflows in the **Workflow SDK* -## Learn More +## Learn more * [API Reference](/docs/api-reference) - Complete API documentation * [Foundations](/docs/foundations) - Architecture and core concepts diff --git a/docs/content/docs/v4/errors/node-js-module-in-workflow.mdx b/docs/content/docs/v4/errors/node-js-module-in-workflow.mdx index 670adab787..82cf4aa8e4 100644 --- a/docs/content/docs/v4/errors/node-js-module-in-workflow.mdx +++ b/docs/content/docs/v4/errors/node-js-module-in-workflow.mdx @@ -15,19 +15,19 @@ related: This error occurs when you try to import or use Node.js core modules (like `fs`, `http`, `crypto`, `path`, etc.) directly inside a workflow function. -## Error Message +## Error message -``` +```text Cannot use Node.js module "fs" in workflow functions. Move this module to a step function. ``` -## Why This Happens +## Why this happens Workflow functions run in a sandboxed environment without full Node.js runtime access. This restriction is important for maintaining **determinism** - the ability to replay workflows exactly and resume from where they left off after suspensions or failures. Node.js modules have side effects and non-deterministic behavior that could break workflow replay guarantees. -## Quick Fix +## Quick fix Move any code using Node.js modules to a step function. Step functions have full Node.js runtime access. @@ -68,7 +68,7 @@ async function read(filePath: string) { } ``` -## Common Node.js Modules +## Common Node.js modules These common Node.js core modules cannot be used in workflow functions: diff --git a/docs/content/docs/v4/errors/replay-divergence.mdx b/docs/content/docs/v4/errors/replay-divergence.mdx index 0fadb48e48..724148dcbc 100644 --- a/docs/content/docs/v4/errors/replay-divergence.mdx +++ b/docs/content/docs/v4/errors/replay-divergence.mdx @@ -14,7 +14,7 @@ A replay divergence occurs when one invocation of a workflow cannot consume the This is an SDK/runtime signal, not an error thrown by your workflow code. It is not catchable inside a workflow function. -## Automatic Recovery +## Automatic recovery A single divergent replay does not prove that persisted history is corrupted. For example, asynchronous delivery ordering may cause one invocation to follow the wrong side of a race while another replay can follow the recorded history correctly. @@ -22,6 +22,6 @@ The runtime automatically queues another replay when an invocation reports `REPL If recovery replays continue to diverge after the retry budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis. -## What To Do +## What to do Most replay divergence signals recover without action. If a run ultimately fails with `CORRUPTED_EVENT_LOG`, update to the latest `workflow` package and report the run ID and error details if the failure persists. diff --git a/docs/content/docs/v4/errors/runtime-decryption-failed.mdx b/docs/content/docs/v4/errors/runtime-decryption-failed.mdx index c8a7536251..91d1a6a2f9 100644 --- a/docs/content/docs/v4/errors/runtime-decryption-failed.mdx +++ b/docs/content/docs/v4/errors/runtime-decryption-failed.mdx @@ -11,33 +11,33 @@ related: This error occurs when the Workflow SDK's built-in AES-GCM encryption layer fails while encrypting or decrypting a workflow payload. The SDK encrypts step inputs, step outputs, hook payloads, and other event-log data with a per-run AES-256 key whenever encryption is configured for the deployment. -This is an **internal SDK failure** — your workflow code never invokes the encryption primitives directly. When this surfaces, it means the ciphertext, nonce, or auth tag the SDK tried to verify is not the bytes that were originally produced. The run is failed with the `RUNTIME_ERROR` classification. +This is an **internal SDK failure**: your workflow code never invokes the encryption primitives directly. When this surfaces, the ciphertext, nonce, or authentication tag the SDK tried to verify does not match the bytes that were originally produced. The run fails with the `RUNTIME_ERROR` classification. -## Error Message +## Error message -``` +```text AES-256-GCM decryption failed: The operation failed for an operation-specific reason ``` -The underlying cause is a native Web Crypto [`OperationError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#operationerror) — most commonly raised by `AESCipherJob.onDone` in Node's `node:internal/crypto/util` module when the GCM authentication tag does not verify. +The underlying cause is a native Web Crypto [`OperationError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#operationerror), most commonly raised by `AESCipherJob.onDone` in Node's `node:internal/crypto/util` module when the GCM authentication tag does not verify. The thrown `RuntimeDecryptionError` carries a small `context` object with diagnostic fields to help triangulate the source: -- `operation` — `'encrypt'` or `'decrypt'` -- `byteLength` — total byte length of the payload at the failure site -- `formatPrefix` — the first 4 bytes of the input (`'encr'` for a well-formed encrypted envelope, otherwise a hex dump) +- `operation`: `'encrypt'` or `'decrypt'` +- `byteLength`: total byte length of the payload at the failure site +- `formatPrefix`: the first 4 bytes of the input (`'encr'` for a well-formed encrypted envelope, otherwise a hex dump) -## Why This Happens +## Why this happens Common causes, in rough order of likelihood: 1. **Ciphertext mutation or truncation in transit.** The encrypted payload reached the SDK with bytes that differ from what storage holds. Possible sources include a truncated HTTP response from a workflow-server ref endpoint, an edge-cache miss returning a partial 200, or a proxy drop during streaming. A truncated body whose first 4 bytes happen to still spell `encr` produces the exact "auth tag mismatch" symptom. -2. **Key resolution mismatch.** The key used to decrypt is not the key that was used to encrypt — e.g. the run's `deploymentId` was not threaded through key resolution and the SDK fell back to the wrong deployment's key material. +2. **Key resolution mismatch.** The key used to decrypt is not the key that was used to encrypt, e.g. the run's `deploymentId` was not threaded through key resolution and the SDK fell back to the wrong deployment's key material. 3. **Malformed encrypted envelope.** The envelope is too short to contain the GCM nonce (12 bytes) and auth tag (16 bytes), so decryption is rejected before it begins. -## What To Do +## What to do -This error indicates an SDK or infrastructure problem — not a bug in your workflow code. Your workflow code does not need to change. +This error indicates an SDK or infrastructure problem, not a bug in your workflow code. Your workflow code does not need to change. ### 1. Upgrade to the latest `workflow` package @@ -60,7 +60,7 @@ If the error persists after upgrading, please [open an issue on GitHub](https:// - The full error message, including the `context` fields (`operation`, `byteLength`, `formatPrefix`) - Whether the affected workflows make heavy use of large step inputs/outputs (which may indicate the failure is on the lazy-loaded ref read path) -## This Error Cannot Be Caught +## This error cannot be caught Like other `WorkflowRuntimeError` subclasses, a runtime decryption failure is **not catchable** inside your workflow function. The runtime cannot safely continue executing user code when an event-log payload can't be verified, so the entire run fails immediately and is marked as `failed`. diff --git a/docs/content/docs/v4/errors/serialization-failed.mdx b/docs/content/docs/v4/errors/serialization-failed.mdx index 5a65bebc25..bee05af15f 100644 --- a/docs/content/docs/v4/errors/serialization-failed.mdx +++ b/docs/content/docs/v4/errors/serialization-failed.mdx @@ -15,9 +15,9 @@ related: This error occurs when you try to pass non-serializable data between execution boundaries in your workflow. All data passed between workflow functions, step functions, and the workflow runtime must be serializable to persist in the event log. -## Error Message +## Error message -``` +```text Failed to serialize workflow arguments. Ensure you're passing serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set). ``` @@ -29,7 +29,7 @@ This error can appear when: - Serializing step arguments - Serializing step return values -## Why This Happens +## Why this happens Workflows persist their state using an event log. Every value that crosses execution boundaries must be: @@ -38,9 +38,9 @@ Workflows persist their state using an event log. Every value that crosses execu Functions, class instances, symbols, and other non-serializable types cannot be properly reconstructed after serialization, which would break workflow replay. -## Common Causes +## Common causes -### Passing Functions +### Passing functions {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -72,7 +72,7 @@ async function processStep(config: { shouldLog: boolean }) { } ``` -### Class Instances +### Class instances ```typescript lineNumbers class User { @@ -111,11 +111,11 @@ async function greetStep(userData: { name: string }) { } ``` -## Supported Serializable Types +## Supported serializable types Workflow SDK supports these types across execution boundaries: -### Standard JSON Types +### Standard JSON types - `string`, `number`, `boolean`, `null` - Arrays of serializable values @@ -123,7 +123,7 @@ Workflow SDK supports these types across execution boundaries: To learn more about supported types, see the [Serialization](/docs/foundations/serialization) section. -## Debugging Serialization Issues +## Debugging serialization issues To identify what's causing serialization to fail: diff --git a/docs/content/docs/v4/errors/start-invalid-workflow-function.mdx b/docs/content/docs/v4/errors/start-invalid-workflow-function.mdx index a1ed19823a..c1543deda6 100644 --- a/docs/content/docs/v4/errors/start-invalid-workflow-function.mdx +++ b/docs/content/docs/v4/errors/start-invalid-workflow-function.mdx @@ -17,17 +17,17 @@ related: This error occurs when `start()` receives a function that does not have Workflow SDK's generated workflow metadata. In practice, that usually means the function is missing `"use workflow"` or the file was never transformed by your framework integration. -## Error Message +## Error message -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` -## Why This Happens +## Why this happens -`start()` expects an imported workflow function, not just any async function. During compilation, Workflow SDK transforms files that contain `"use workflow"` and attaches generated metadata such as the workflow ID. If that transform never runs, or if you pass a wrapper function instead of the transformed export, `start()` cannot identify what to enqueue and throws this error. +`start()` expects an imported workflow function rather than any async function. During compilation, Workflow SDK transforms files that contain `"use workflow"` and attaches generated metadata such as the workflow ID. If that transform never runs, or if you pass a wrapper function instead of the transformed export, `start()` cannot identify what to enqueue and throws this error. -## Common Causes +## Common causes ### Missing `"use workflow"` diff --git a/docs/content/docs/v4/errors/step-executed-multiple-times.mdx b/docs/content/docs/v4/errors/step-executed-multiple-times.mdx index a7159cbaf0..76eff10f26 100644 --- a/docs/content/docs/v4/errors/step-executed-multiple-times.mdx +++ b/docs/content/docs/v4/errors/step-executed-multiple-times.mdx @@ -12,12 +12,12 @@ related: There may be cases where you see multiple `step_started` events for the same step in a workflow run. This happens if the function invocation executing the step crashes unexpectedly, and the step can not report the error. The step will be re-tried according to your retry policy in this case, but no error will be visible in the [Observability UI](/docs/observability). -## Common Causes +## Common causes - **Function timeouts**: if your step code runs longer than the configured maximum function duration, it will be killed. Compare the gap between the `step_started` events to your configured function duration to be sure. - **Out of memory (OOM)**: if your step code loads enough data into memory, especially if the step is invoked concurrently, the function invocation might run out of memory. You can see your function's peak memory use by going to the [Observability Query page](https://vercel.com/docs/observability) and showing the **Function Invocation Peak Memory** metric, then filtering down the **Route** to `/.well-known/workflow` endpoints. - **Network issues**: persistent firewall, network stability, and related issues might prevent your function from reporting results or errors. This should be temporary. -## Getting Help +## Getting help If you consistently see multiple `step_started` events and have ruled out function timeouts, OOMs, and firewall issues, please [contact support](https://vercel.com/help). diff --git a/docs/content/docs/v4/errors/step-not-registered.mdx b/docs/content/docs/v4/errors/step-not-registered.mdx index 595b2ad2dc..bb87ccd0dc 100644 --- a/docs/content/docs/v4/errors/step-not-registered.mdx +++ b/docs/content/docs/v4/errors/step-not-registered.mdx @@ -12,21 +12,21 @@ related: This error occurs when the Workflow runtime tries to execute a step function that is not registered in the current deployment. When this happens, the step fails (like a `FatalError`) and control is passed back to the workflow function, which can optionally handle the failure. The runtime surfaces this as a [`StepNotRegisteredError`](/docs/api-reference/workflow-errors/step-not-registered-error). -## Error Message +## Error message -``` +```text Step "" is not registered in the current deployment. This usually indicates a build or bundling issue that caused the step to not be included in the deployment. ``` -## Why This Happens +## Why this happens Workflow runs are pegged to a specific deployment, so this error is not caused by newer deployments overriding the running code. Instead, it means the step function was not included in the deployment's workflow bundle at build time. This is an **infrastructure error**, not a user code error. -## Common Causes +## Common causes ### Build tooling issue @@ -40,7 +40,7 @@ Something went wrong during the build process that caused the step function to n The step function was deleted or its `"use step"` directive was removed, but the workflow still references it. Ensure all steps referenced by your workflow are present in the codebase. -## How to Resolve +## How to resolve 1. **Check your build logs:** Look for errors or warnings related to workflow bundling. Ensure the step file contains a valid `"use step"` directive and is properly exported. diff --git a/docs/content/docs/v4/errors/timeout-in-workflow.mdx b/docs/content/docs/v4/errors/timeout-in-workflow.mdx index 78ead0b220..5505f5abad 100644 --- a/docs/content/docs/v4/errors/timeout-in-workflow.mdx +++ b/docs/content/docs/v4/errors/timeout-in-workflow.mdx @@ -15,19 +15,19 @@ related: This error occurs when you try to use `setTimeout()`, `setInterval()`, or related timing functions directly inside a workflow function. -## Error Message +## Error message -``` +```text Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions. Use the "sleep" function from "workflow" for time-based delays. ``` -## Why This Happens +## Why this happens Workflow functions run in a sandboxed environment where timing functions like `setTimeout()` and `setInterval()` are not available. These functions rely on asynchronous scheduling that would break the **deterministic replay** guarantees that workflows depend on. When a workflow suspends and later resumes, it replays from the event log. If timing functions were allowed, the replay would produce different results than the original execution. -## Quick Fix +## Quick fix Use the `sleep` function from the `workflow` package for time-based delays. Unlike `setTimeout()`, `sleep` is tracked in the event log and replays correctly. @@ -59,7 +59,7 @@ export async function delayedWorkflow() { } ``` -## Unavailable Functions +## Unavailable functions These timing functions cannot be used in workflow functions: @@ -70,9 +70,9 @@ These timing functions cannot be used in workflow functions: - `clearInterval()` - `clearImmediate()` -## Common Scenarios +## Common scenarios -### Polling with Delays +### Polling with delays If you need to poll an external service with delays between requests: @@ -102,7 +102,7 @@ async function checkStatus() { } ``` -### Scheduled Delays +### Scheduled delays For workflows that need to wait for a specific duration: diff --git a/docs/content/docs/v4/errors/webhook-invalid-respond-with-value.mdx b/docs/content/docs/v4/errors/webhook-invalid-respond-with-value.mdx index dc341d324d..61647749c9 100644 --- a/docs/content/docs/v4/errors/webhook-invalid-respond-with-value.mdx +++ b/docs/content/docs/v4/errors/webhook-invalid-respond-with-value.mdx @@ -11,13 +11,13 @@ related: This error occurs when you provide an invalid value for the `respondWith` option when creating a webhook. The `respondWith` option must be either `"manual"` or a `Response` object. -## Error Message +## Error message -``` +```text Invalid `respondWith` value: [value] ``` -## Why This Happens +## Why this happens When creating a webhook with `createWebhook()`, you can specify how the webhook should respond to incoming HTTP requests using the `respondWith` option. This option only accepts specific values: @@ -25,9 +25,9 @@ When creating a webhook with `createWebhook()`, you can specify how the webhook 2. A `Response` object - A pre-defined response to send immediately 3. `undefined` (default) - Returns a `202 Accepted` response -## Common Causes +## Common causes -### Using an Invalid String Value +### Using an invalid string value ```typescript lineNumbers // Error - invalid string value @@ -60,7 +60,7 @@ export async function webhookWorkflow() { } ``` -### Using a Non-Response Object +### Using a non-Response object ```typescript lineNumbers // Error - plain object instead of Response @@ -88,9 +88,9 @@ export async function webhookWorkflow() { } ``` -## Valid Usage Examples +## Valid usage examples -### Default Behavior (202 Response) +### Default behavior (202 response) ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -101,7 +101,7 @@ const request = await webhook; // No need to send a response ``` -### Manual Response +### Manual response ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -125,7 +125,7 @@ await request.respondWith( ); ``` -### Pre-defined Response +### Pre-defined response ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -139,7 +139,7 @@ const request = await webhook; // Response already sent ``` -## Learn More +## Learn more - [createWebhook() API Reference](/docs/api-reference/workflow/create-webhook) - [resumeWebhook() API Reference](/docs/api-reference/workflow-api/resume-webhook) diff --git a/docs/content/docs/v4/errors/webhook-response-not-sent.mdx b/docs/content/docs/v4/errors/webhook-response-not-sent.mdx index 8d449ba851..5e900671e7 100644 --- a/docs/content/docs/v4/errors/webhook-response-not-sent.mdx +++ b/docs/content/docs/v4/errors/webhook-response-not-sent.mdx @@ -15,21 +15,21 @@ related: This error occurs when a webhook is configured with `respondWith: "manual"` but the workflow does not send a response using `request.respondWith()` before the webhook execution completes. -## Error Message +## Error message -``` +```text Workflow run did not send a response ``` -## Why This Happens +## Why this happens When you create a webhook with `respondWith: "manual"`, you are responsible for calling `request.respondWith()` to send the HTTP response back to the caller. If the workflow execution completes without sending a response, this error will be thrown. The webhook infrastructure waits for a response to be sent, and if none is provided, it cannot complete the HTTP request properly. -## Common Causes +## Common causes -### Forgetting to Call `request.respondWith()` +### Forgetting to call `request.respondWith()` ```typescript lineNumbers // Error - no response sent @@ -74,7 +74,7 @@ export async function webhookWorkflow() { } ``` -### Conditional Response Logic +### Conditional response logic ```typescript lineNumbers // Error - response only sent in some branches @@ -119,7 +119,7 @@ export async function webhookWorkflow() { } ``` -### Exception Before Response +### Exception before response ```typescript lineNumbers // Error - exception thrown before response @@ -168,7 +168,7 @@ export async function webhookWorkflow() { } ``` -## Alternative: Use Default Response Mode +## Alternative: Use default response mode If you don't need custom response control, consider using the default response mode which automatically returns a `202 Accepted` response: @@ -189,7 +189,7 @@ export async function webhookWorkflow() { } ``` -## Learn More +## Learn more - [createWebhook() API Reference](/docs/api-reference/workflow/create-webhook) - [resumeWebhook() API Reference](/docs/api-reference/workflow-api/resume-webhook) diff --git a/docs/content/docs/v4/errors/workflow-not-registered.mdx b/docs/content/docs/v4/errors/workflow-not-registered.mdx index 5d441dfcf0..d5f12df4f5 100644 --- a/docs/content/docs/v4/errors/workflow-not-registered.mdx +++ b/docs/content/docs/v4/errors/workflow-not-registered.mdx @@ -12,19 +12,19 @@ related: This error occurs when the Workflow runtime tries to execute a workflow function that is not registered in the current deployment. When this happens, the run fails with a `RUNTIME_ERROR` error code. The underlying error class is [`WorkflowNotRegisteredError`](/docs/api-reference/workflow-errors/workflow-not-registered-error). -## Error Message +## Error message -``` +```text Workflow "" is not registered in the current deployment. This usually means a run was started against a deployment that does not have this workflow, or there was a build/bundling issue. ``` -## Why This Happens +## Why this happens This error means the deployment that received the workflow execution request does not have the specified workflow function in its bundle. This is an **infrastructure error**, not a user code error. -## Common Causes +## Common causes ### Run started against a deployment without the workflow @@ -57,7 +57,7 @@ Something went wrong during the build process that caused the workflow function - The workflow function is not exported from the workflow file - An esbuild or SWC plugin error silently excluded the workflow -## How to Resolve +## How to resolve 1. **If the workflow was renamed or moved:** Deploy with the workflow restored to its original name and location, then retry the run. Alternatively, start a new run using the updated workflow name against the current deployment. diff --git a/docs/content/docs/v4/foundations/errors-and-retries.mdx b/docs/content/docs/v4/foundations/errors-and-retries.mdx index 3af30d9388..017e613386 100644 --- a/docs/content/docs/v4/foundations/errors-and-retries.mdx +++ b/docs/content/docs/v4/foundations/errors-and-retries.mdx @@ -1,6 +1,6 @@ --- title: Errors & Retrying -description: Customize retry behavior with FatalError and RetryableError for robust error handling. +description: Customize retry behavior with FatalError and RetryableError for controlled error handling. type: conceptual summary: Control how steps handle failures and customize retry behavior. prerequisites: @@ -12,7 +12,7 @@ related: By default, errors thrown inside steps are retried. Additionally, Workflow SDK provides two new types of errors you can use to customize retries. -## Default Retrying +## Default retrying By default, steps retry up to 3 times on arbitrary errors. You can customize the number of retries by adding a `maxRetries` property to the step function. @@ -42,9 +42,9 @@ Steps get enqueued immediately after a failure. Read on to see how this can be c more information. -## Intentional Errors +## Intentional errors -When your step needs to intentionally throw an error and skip retrying, simply throw a [`FatalError`](/docs/api-reference/workflow/fatal-error). +When your step needs to intentionally throw an error and skip retrying, throw a [`FatalError`](/docs/api-reference/workflow/fatal-error). ```typescript lineNumbers import { FatalError } from "workflow"; @@ -67,7 +67,7 @@ async function callApi(endpoint: string) { } ``` -## Customize Retry Behavior +## Customize retry behavior When you need to customize the delay on a retry, use [`RetryableError`](/docs/api-reference/workflow/retryable-error) and set the `retryAfter` property. @@ -97,7 +97,7 @@ async function callApi(endpoint: string) { } ``` -## Advanced Example +## Advanced example This final example combines everything we've learned, along with [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata). @@ -139,7 +139,7 @@ callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts) step can run up to 4 times total (1 initial attempt + 3 retries). -## Error Codes +## Error codes When a workflow run fails, the error includes an `errorCode` that classifies the failure, alongside the original thrown value (preserved as `cause`): @@ -172,7 +172,7 @@ try { The error code is also available on the run entity via the CLI (`npx workflow inspect runs `) in the `error.code` field, and as an OTEL span attribute (`workflow.error.code`) for observability. -## Rolling Back Failed Steps +## Rolling back failed steps When a workflow fails partway through, it can leave the system in an inconsistent state. A common pattern to address this is "rollbacks": for each successful step, record a corresponding rollback action that can undo it. diff --git a/docs/content/docs/v4/foundations/hooks.mdx b/docs/content/docs/v4/foundations/hooks.mdx index c3e204d4ec..7819488f9a 100644 --- a/docs/content/docs/v4/foundations/hooks.mdx +++ b/docs/content/docs/v4/foundations/hooks.mdx @@ -11,9 +11,9 @@ related: - /docs/ai/human-in-the-loop --- -Hooks provide a powerful mechanism for pausing workflow execution and resuming it later with external data. They enable workflows to wait for external events, user interactions (also known as "human in the loop"), or HTTP requests. This guide will teach you the core concepts, starting with the low-level Hook primitive and building up to the higher-level Webhook abstraction. +Hooks pause workflow execution and resume it later with external data. Workflows can wait for external events, user interactions (also known as "human in the loop"), or HTTP requests. -## Understanding Hooks +## Understanding hooks At their core, **Hooks** are a low-level primitive that allows you to pause a workflow and resume it later with arbitrary [serializable data](/docs/foundations/serialization). Think of them as suspension points in your workflow where you're waiting for external input. @@ -23,9 +23,9 @@ When you create a hook, it generates a unique token that external systems can us - Receiving data from an external system or service - Implementing event-driven workflows that react to multiple events over time -### Creating Your First Hook +### Creating your first hook -Let's start with a simple example. Here's a workflow that creates a hook and waits for external data: +This workflow creates a hook and waits for external data: ```typescript lineNumbers import { createHook } from "workflow"; @@ -59,7 +59,7 @@ We recommend using the `using` keyword which implements the [TC39 Explicit Resou See the full API reference for [`createHook()`](/docs/api-reference/workflow/create-hook) for all available options. -### Resuming a Hook +### Resuming a hook To send data to a waiting workflow, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) from an API route, server action, or any other external context: @@ -81,11 +81,11 @@ export async function POST(request: Request) { ``` The key points: -- Hooks allow you to pass **any [serializable data](/docs/foundations/serialization)** as the payload -- You need the hook's `token` to resume it -- The workflow will resume execution right where it left off +- Hooks allow you to pass **any [serializable data](/docs/foundations/serialization)** as the payload. +- You need the hook's `token` to resume it. +- The workflow resumes execution where it left off. -### Checking for Token Conflicts +### Checking for token conflicts Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` (available starting in `workflow@4.5.0`) for that: @@ -112,9 +112,9 @@ export async function orderWorkflow(orderId: string) { } ``` -Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with `{ runId }` identifying the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. To act on the owner — inspect its status, wait for its result, or cancel it — pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies. +Calling `createHook()` on its own does not register the hook; registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with `{ runId }` identifying the run that owns the token if another active hook already claimed it (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. To act on the owner (inspect its status, wait for its result, or cancel it), pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies. -### Custom Tokens for Deterministic Hooks +### Custom tokens for deterministic hooks By default, hooks generate a random token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events. @@ -168,9 +168,9 @@ export async function POST(request: Request) { } ``` -### Receiving Multiple Events +### Receiving multiple events -Hooks are _reusable_ - they implement `AsyncIterable`, which means you can use `for await...of` to receive multiple events over time: +Hooks are _reusable_. They implement `AsyncIterable`, which means you can use `for await...of` to receive multiple events over time: ```typescript lineNumbers import { createHook } from "workflow"; @@ -198,7 +198,7 @@ export async function dataCollectionWorkflow() { Each time you call `resumeHook()` with the same token, the loop receives another value. -### Disposing Hooks Early +### Disposing hooks early When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens: @@ -240,9 +240,9 @@ hook.dispose(); // Manually release the token After disposal, the hook will no longer receive events and the async iterator will stop yielding values. -## Understanding Webhooks +## Understanding webhooks -While hooks are powerful, they require you to manually handle HTTP requests and route them to workflows. **Webhooks** solve this by providing a higher-level abstraction built on top of hooks that: +Hooks require you to manually handle HTTP requests and route them to workflows. **Webhooks** provide a higher-level abstraction built on top of hooks that: 1. Automatically serializes the entire HTTP [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object 2. Provides an automatically addressable `url` property pointing to the generated webhook endpoint @@ -251,16 +251,16 @@ While hooks are powerful, they require you to manually handle HTTP requests and When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v1/webhook/:token` without any additional setup. -`createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. This is convenient for prototypes and a simple developer experience because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. +`createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. This is convenient for prototypes because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. See the full API reference for [`createWebhook()`](/docs/api-reference/workflow/create-webhook) for all available options. -### Creating Your First Webhook +### Creating your first webhook -Here's a simple webhook that receives HTTP requests. Like hooks, webhooks support the `using` keyword for automatic cleanup: +Here's a webhook that receives HTTP requests. Like hooks, webhooks support the `using` keyword for automatic cleanup: ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -284,13 +284,13 @@ export async function webhookWorkflow() { } ``` -The webhook will automatically respond with a `202 Accepted` status by default. External systems can simply make an HTTP request to the `webhook.url` to resume your workflow. +The webhook will automatically respond with a `202 Accepted` status by default. External systems can make an HTTP request to the `webhook.url` to resume your workflow. -### Sending Custom Responses +### Sending custom responses Webhooks provide two ways to send custom HTTP responses: **static responses** and **dynamic responses**. -#### Static Responses +#### Static responses Use the `respondWith` option to provide a static response that will be sent automatically for every request: @@ -319,7 +319,7 @@ async function processData(data: any) { } ``` -#### Dynamic Responses (Manual Mode) +#### Dynamic responses (manual mode) For dynamic responses based on the request content, set `respondWith: "manual"` and call the `respondWith()` method on the request: @@ -365,7 +365,7 @@ export async function webhookWithDynamicResponse() { When using `respondWith: "manual"`, the `respondWith()` method **must** be called from within a step function due to serialization requirements. This requirement may be removed in the future. -### Handling Multiple Webhook Requests +### Handling multiple webhook requests Like hooks, webhooks support iteration: @@ -405,7 +405,7 @@ export async function eventCollectorWorkflow() { } ``` -## Hooks vs. Webhooks: When to Use Each +## Hooks vs. webhooks: when to use each | Feature | Hooks | Webhooks | |---------|-------|----------| @@ -415,19 +415,19 @@ export async function eventCollectorWorkflow() { | **Use Case** | Custom integrations, type-safe payloads | HTTP webhooks, standard REST APIs | | **Resuming** | [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) | Automatic via HTTP, or [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) | -**Use Hooks when:** -- You need full control over the payload structure -- You're integrating with custom event sources -- You want strong TypeScript typing with [`defineHook()`](/docs/api-reference/workflow/define-hook) +**Use hooks when:** +- You need full control over the payload structure. +- You're integrating with custom event sources. +- You want strong TypeScript typing with [`defineHook()`](/docs/api-reference/workflow/define-hook). -**Use Webhooks when:** -- You're receiving HTTP requests from external services -- You need to send HTTP responses back to the caller -- You want automatic URL routing without writing API handlers +**Use webhooks when:** +- You're receiving HTTP requests from external services. +- You need to send HTTP responses back to the caller. +- You want automatic URL routing without writing API handlers. -## Advanced Patterns +## Advanced patterns -### Type-Safe Hooks with `defineHook()` +### Type-safe hooks with `defineHook()` The [`defineHook()`](/docs/api-reference/workflow/define-hook) helper provides type safety and runtime validation between creating and resuming hooks using [Standard Schema v1](https://standardschema.dev). Use any compliant validator like Zod or Valibot: @@ -476,25 +476,25 @@ export async function POST(request: Request) { This pattern is especially valuable in larger applications where the workflow and API code are in separate files, providing both compile-time type safety and runtime validation. -## Best Practices +## Best practices -### Token Design +### Token design Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always use randomly generated tokens to prevent unauthorized access to public webhook endpoints. When using custom tokens with `createHook()`: -- **Make them deterministic**: Base them on data the external system can reconstruct (like channel IDs, user IDs, etc.) -- **Use namespacing**: Prefix tokens to avoid conflicts (e.g., `slack:${channelId}`, `github:${repoId}`) -- **Include routing information**: Ensure the token contains enough information to identify the correct workflow instance +- **Make them deterministic**: Base them on data the external system can reconstruct, such as channel IDs or user IDs. +- **Use namespacing**: Prefix tokens to avoid conflicts, such as `slack:${channelId}` or `github:${repoId}`. +- **Include routing information**: Ensure the token contains enough information to identify the correct workflow instance. -### Response Handling in Webhooks +### Response handling in webhooks -- Use **static responses** (`respondWith: Response`) for simple acknowledgments -- Use **manual mode** (`respondWith: "manual"`) when responses depend on request processing -- Remember that `respondWith()` must be called from within a step function +- Use **static responses** (`respondWith: Response`) for acknowledgments. +- Use **manual mode** (`respondWith: "manual"`) when responses depend on request processing. +- Call `respondWith()` from within a step function. -### Iterating Over Events +### Iterating over events Both hooks and webhooks support iteration, making them perfect for long-running event loops: @@ -513,9 +513,9 @@ for await (const event of hook) { This pattern allows a single workflow instance to handle multiple events over time, maintaining state between events. -## Related Documentation +## Related documentation -- [Serialization](/docs/foundations/serialization) - Understanding what data can be passed through hooks +- [Serialization](/docs/foundations/serialization): Understand what data can be passed through hooks. - [`createHook()` API Reference](/docs/api-reference/workflow/create-hook) - [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook) - [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) diff --git a/docs/content/docs/v4/foundations/idempotency.mdx b/docs/content/docs/v4/foundations/idempotency.mdx index 2be33a56bf..da72d8f268 100644 --- a/docs/content/docs/v4/foundations/idempotency.mdx +++ b/docs/content/docs/v4/foundations/idempotency.mdx @@ -15,9 +15,9 @@ Idempotency is a property of an operation that ensures repeated attempts have th In Workflow, idempotency shows up in two related places: step idempotency makes external calls safe when a step retries, and run idempotency coordinates duplicate requests that try to start the same workflow. -## Step Idempotency +## Step idempotency -In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds. +In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once by seeing if it succeeds. Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again. To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests. @@ -53,7 +53,7 @@ Why this works: - **Stable across retries**: `stepId` does not change between attempts. - **Globally unique per step**: Fulfills the uniqueness requirement for an idempotency key. -A step can also run more than once when its function invocation crashes before reporting a result — see [Step executed multiple times](/docs/errors/step-executed-multiple-times) for how to diagnose duplicate step executions. +A step can also run more than once when its function invocation crashes before reporting a result. See [Step executed multiple times](/docs/errors/step-executed-multiple-times) for how to diagnose duplicate step executions. ## Run idempotency @@ -63,7 +63,7 @@ Because [hooks](/docs/foundations/hooks) already ensure globally unique active t Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with `{ runId }` identifying the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload. -The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration. +The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook; awaiting `getConflict()` suspends the workflow to commit the registration. ```typescript lineNumbers import { createHook } from "workflow"; @@ -98,7 +98,7 @@ export async function processOrder(orderId: string): Promise { } ``` -The runtime creates the hook atomically. At most one active hook can own `order:${orderId}`, so duplicate workflow runs converge on one active owner. A duplicate run observes `getConflict()` resolving with `{ runId }` and returns before it reaches `chargeOrder()`. To act on the owner, pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step — the duplicate run can do more than report the owner; see [conflict-handling strategies](#conflict-handling-strategies) below. +The runtime creates the hook atomically. At most one active hook can own `order:${orderId}`, so duplicate workflow runs converge on one active owner. A duplicate run observes `getConflict()` resolving with `{ runId }` and returns before it reaches `chargeOrder()`. To act on the owner, pass `conflict.runId` to [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step. The duplicate run can do more than report the owner; see [conflict-handling strategies](#conflict-handling-strategies) below. Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook: @@ -148,14 +148,14 @@ export async function POST(request: Request) { ``` -This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`. +This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work, and the route detects it by comparing the resumed hook's `runId` against the newly started run, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`. This is active-run coordination. When the workflow completes and disposes the hook, the token can be used again. If a duplicate request after completion must return the original result instead of starting fresh work, persist that completed result under the same domain key. ### Conflict-handling strategies -Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy — typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting run's ID, and the policy is ordinary code — including policies that inspect state before deciding, which static configuration can't express. Retrieve a `Run` handle for the owner with [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step: +Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy, typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting run's ID, and the policy is ordinary code, including policies that inspect state before deciding, which static configuration can't express. Retrieve a `Run` handle for the owner with [`getRun()`](/docs/api-reference/workflow-api/get-run) inside a step: ```typescript lineNumbers import { getRun } from "workflow/api"; @@ -178,7 +178,7 @@ async function cancelOwner(runId: string) { The example above implements **reject the duplicate**: return the owner's `runId` and let the caller decide. Other common strategies: -**Adopt the owner's result.** Wait for the active run to finish and return its result, so callers cannot tell which run did the work: +**Adopt the owner's result:** Wait for the active run to finish and return its result, so callers cannot tell which run did the work: ```typescript lineNumbers import { createHook } from "workflow"; @@ -204,7 +204,7 @@ export async function processOrder(orderId: string) { } ``` -**Inspect the owner before deciding.** Branch on the owner's live state: +**Inspect the owner before deciding:** Branch on the owner's live state: ```typescript lineNumbers import { createHook } from "workflow"; @@ -233,7 +233,7 @@ export async function processOrder(orderId: string) { } ``` -**Signal the owner instead of doing the work.** The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step: +**Signal the owner instead of doing the work:** The duplicate run knows the token, so it can deliver this run's input to the owner's hook from a step: ```typescript lineNumbers import { createHook } from "workflow"; @@ -262,7 +262,7 @@ export async function processOrder(orderId: string, confirmed: boolean) { } ``` -**Supersede the owner.** Newest-wins: cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet: +**Supersede the owner:** Newest wins. Cancel the active run, then claim the released token. Cancellation disposes the owner's hooks; the retry loop covers the window where that disposal has not propagated yet: ```typescript lineNumbers import { createHook } from "workflow"; @@ -286,7 +286,7 @@ export async function processOrderNewestWins(orderId: string) { const conflict = await request.getConflict(); if (!conflict) { - // Token claimed — this run is now the owner. + // Token claimed: this run is now the owner. const { confirmed } = await request; if (confirmed) { await chargeOrder(orderId); @@ -307,10 +307,10 @@ Because this pattern uses hooks for idempotency, duplicate requests can also inj ## Related docs -- Learn about retries in [Errors & Retrying](/docs/foundations/errors-and-retries) -- API reference: [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata) -- API reference: [`createHook()`](/docs/api-reference/workflow/create-hook) -- API reference: [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- API reference: [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- API reference: [`start()`](/docs/api-reference/workflow-api/start) -- Learn about deterministic hook tokens in [Hooks](/docs/foundations/hooks) +- Learn about retries in [Errors & Retrying](/docs/foundations/errors-and-retries). +- See the [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata) API reference. +- See the [`createHook()`](/docs/api-reference/workflow/create-hook) API reference. +- See the [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) API reference. +- See the [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) API reference. +- See the [`start()`](/docs/api-reference/workflow-api/start) API reference. +- Learn about deterministic hook tokens in [Hooks](/docs/foundations/hooks). diff --git a/docs/content/docs/v4/foundations/serialization.mdx b/docs/content/docs/v4/foundations/serialization.mdx index 3e7fc4d5c1..b87137d106 100644 --- a/docs/content/docs/v4/foundations/serialization.mdx +++ b/docs/content/docs/v4/foundations/serialization.mdx @@ -9,17 +9,17 @@ related: - /docs/errors/serialization-failed --- -All function arguments and return values passed between workflow and step functions must be serializable. Workflow SDK uses a custom serialization system built on top of [devalue](https://github.com/sveltejs/devalue). This system supports standard JSON types, as well as a few additional popular Web API types. Non-serializable values surface as a [`WorkflowRuntimeError`](/docs/api-reference/workflow-errors/workflow-runtime-error) — see the [serialization-failed](/docs/errors/serialization-failed) error guide for common causes and fixes. +All function arguments and return values passed between workflow and step functions must be serializable. Workflow SDK uses a custom serialization system built on top of [devalue](https://github.com/sveltejs/devalue). This system supports standard JSON types, as well as a few additional popular Web API types. Non-serializable values surface as a [`WorkflowRuntimeError`](/docs/api-reference/workflow-errors/workflow-runtime-error). See the [serialization-failed](/docs/errors/serialization-failed) error guide for common causes and fixes. The serialization system ensures that all data persists correctly across workflow suspensions and resumptions, enabling durable execution. -## Supported Serializable Types +## Supported serializable types The following types can be serialized and passed through workflow functions: -**Standard JSON Types:** +**Standard JSON types:** - `string` - `number` @@ -28,7 +28,7 @@ The following types can be serialized and passed through workflow functions: - Arrays of serializable values - Objects with string keys and serializable values -**Extended Types:** +**Extended types:** - `undefined` - `bigint` @@ -56,7 +56,7 @@ These types have special handling and are explained in detail in the sections be - `ReadableStream` - `WritableStream` -## Pass-by-Value Semantics +## Pass-by-value semantics **Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow. @@ -79,7 +79,7 @@ async function updateUserStep(user: { id: string; name: string; email: string }) } ``` -**Correct - return the modified data:** +**Correct, return the modified data:** ```typescript title="workflows/correct-mutation.ts" lineNumbers export async function updateUserWorkflow(userId: string) { @@ -98,7 +98,7 @@ async function updateUserStep(user: { id: string; name: string; email: string }) } ``` -**Custom Classes:** +**Custom classes:** - Class instances that implement [`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`](#custom-class-serialization) @@ -108,7 +108,7 @@ async function updateUserStep(user: { id: string; name: string; email: string }) For complete information about using streams in workflows, including patterns for AI streaming, file processing, and progress updates, see the [Streaming Guide](/docs/foundations/streaming). -## Request & Response +## Request & response The Web API [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) APIs are supported by the serialization system, and can be passed around between workflow and step functions similarly to other data types. @@ -138,7 +138,7 @@ export async function handleWebhookWorkflow() { } ``` -### Using `fetch` in Workflows +### Using `fetch` in workflows Because `Request` and `Response` are serializable, Workflow SDK provides a `fetch` function that can be used directly in workflow functions: @@ -156,7 +156,7 @@ export async function apiWorkflow() { } ``` -The implementation is straightforward - `fetch` from workflow is a step function that wraps the standard `fetch`: +The `fetch` implementation from `workflow` is a step function that wraps the standard `fetch`: ```typescript title="Implementation" lineNumbers export async function fetch(...args: Parameters) { @@ -167,11 +167,11 @@ export async function fetch(...args: Parameters) { This allows you to make HTTP requests directly in workflow functions while maintaining deterministic replay behavior through automatic caching. -## Custom Class Serialization +## Custom class serialization By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the `@workflow/serde` package. -### Basic Example +### Basic example {/* @expect-error:2351 */} @@ -221,13 +221,13 @@ async function doublePoint(point: Point) { } ``` -### How It Works +### How it works -1. **[`WORKFLOW_SERIALIZE`](/docs/api-reference/workflow-serde/workflow-serialize)**: A static method that receives a class instance and returns serializable data (primitives, plain objects, arrays, etc.) +1. **[`WORKFLOW_SERIALIZE`](/docs/api-reference/workflow-serde/workflow-serialize)**: A static method that receives a class instance and returns serializable data (primitives, plain objects, arrays, etc.). -2. **[`WORKFLOW_DESERIALIZE`](/docs/api-reference/workflow-serde/workflow-deserialize)**: A static method that receives the serialized data and returns a new class instance +2. **[`WORKFLOW_DESERIALIZE`](/docs/api-reference/workflow-serde/workflow-deserialize)**: A static method that receives the serialized data and returns a new class instance. -3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time — no manual registration step is required +3. **Automatic registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time. No manual registration step is required. ### Requirements @@ -235,21 +235,21 @@ async function doublePoint(point: Point) { `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented as **static** methods on the class. Defining them as instance methods is not supported. -- The data returned by `WORKFLOW_SERIALIZE` must itself be serializable (see [Supported Serializable Types](#supported-serializable-types)) -- Both symbols must be implemented together - a class with only one will not be serializable +- The data returned by `WORKFLOW_SERIALIZE` must itself be serializable (see [Supported serializable types](#supported-serializable-types)). +- Both symbols must be implemented together; a class with only one will not be serializable. The `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` methods run inside the workflow context and are subject to the same constraints as `"use workflow"` functions. This means: -- No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.) -- No non-deterministic operations (like `Math.random()` or `Date.now()`) -- No external network calls +- No Node.js-specific APIs (such as `fs`, `path`, or `crypto`). +- No non-deterministic operations (such as `Math.random()` or `Date.now()`). +- No external network calls. -Keep these methods simple and focused on data transformation only. +Keep these methods focused on data transformation only. -### Instance Methods as Steps +### Instance methods as steps -In practice, many classes have methods that need Node.js APIs, perform network calls, or interact with databases — operations that are not allowed in the `"use workflow"` execution context. You can make these methods workflow-compatible by adding `"use step"` to them. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step — with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary. +In practice, many classes have methods that need Node.js APIs, perform network calls, or interact with databases, operations that are not allowed in the `"use workflow"` execution context. You can make these methods workflow-compatible by adding `"use step"` to them. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step, with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary. This requires the class to implement `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`, so that the instance can be passed to the step execution context. @@ -266,7 +266,7 @@ class Order { public createdAt: Date ) {} - // Custom serialization — data must be serializable types + // Custom serialization: data must be serializable types static [WORKFLOW_SERIALIZE](instance: Order) { // [!code highlight] return { // [!code highlight] id: instance.id, // [!code highlight] @@ -294,7 +294,7 @@ class Order { } // Instance methods with "use step" run as step functions - // with full Node.js access — `this` is automatically serialized + // with full Node.js access; `this` is automatically serialized async save(): Promise { "use step"; // [!code highlight] await db.orders.insert({ // [!code highlight] @@ -320,7 +320,7 @@ class Order { } ``` -The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step — with automatic caching, retry semantics, and full Node.js runtime access. Methods _without_ `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions: +The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step, with automatic caching, retry semantics, and full Node.js runtime access. Methods _without_ `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions: {/* @expect-error:2693 */} @@ -334,7 +334,7 @@ export async function processOrderWorkflow( const order = new Order(orderId, items, new Date()); // [!code highlight] - // Runs in the workflow context — no "use step" needed + // Runs in the workflow context; no "use step" needed const itemCount = order.total(); // [!code highlight] // Each "use step" instance method call runs as a separate step @@ -345,7 +345,7 @@ export async function processOrderWorkflow( } ``` -Note that [pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow: +[Pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow: {/* @expect-error:2351 */} @@ -373,4 +373,3 @@ export async function processOrderWorkflow() { order = await order.addItem("Widget", 3); // [!code highlight] } ``` - diff --git a/docs/content/docs/v4/foundations/starting-workflows.mdx b/docs/content/docs/v4/foundations/starting-workflows.mdx index b3335fa789..19530b0a0f 100644 --- a/docs/content/docs/v4/foundations/starting-workflows.mdx +++ b/docs/content/docs/v4/foundations/starting-workflows.mdx @@ -11,7 +11,7 @@ related: Once you've defined your workflow functions, you need to trigger them to begin execution. This is done using the `start()` function from `workflow/api`, which enqueues a new workflow run and returns a `Run` object that you can use to track its progress. -## The `start()` Function +## The `start()` function The [`start()`](/docs/api-reference/workflow-api/start) function is used to programmatically trigger workflow executions from runtime contexts like API routes, Server Actions, or any server-side code. @@ -41,7 +41,7 @@ export async function POST(request: Request) { **Learn more**: [`start()` API Reference](/docs/api-reference/workflow-api/start) -## The `Run` Object +## The `Run` object When you call `start()`, it returns a [`Run`](/docs/api-reference/workflow-api/start#returns) object that provides access to the workflow's status and results. @@ -74,9 +74,9 @@ Most `Run` properties are async getters that return promises. You need to `await **Learn more**: [`Run` API Reference](/docs/api-reference/workflow-api/start#returns) -## Common Patterns +## Common patterns -### Fire and Forget +### Fire and forget The most common pattern is to start a workflow and immediately return, letting it execute in the background: @@ -96,7 +96,7 @@ export async function POST(request: Request) { } ``` -### Wait for Completion +### Wait for completion If you need to wait for the workflow to complete before responding: @@ -118,7 +118,7 @@ export async function POST(request: Request) { Be cautious when waiting for `returnValue` - if your workflow takes a long time, your API route may timeout. -### Stream Updates to Client +### Stream updates to client Stream real-time updates from your workflow as it executes, without waiting for completion: @@ -183,7 +183,7 @@ Streams are particularly useful for AI workflows where you want to show progress **Learn more**: [Streaming in Workflows](/docs/foundations/serialization#streaming) -### Check Status Later +### Check status later You can retrieve a workflow run later using its `runId` with [`getRun()`](/docs/api-reference/workflow-api/get-run): @@ -209,7 +209,7 @@ export async function GET(request: Request) { } ``` -## Next Steps +## Next steps Now that you understand how to start workflows and track their execution: diff --git a/docs/content/docs/v4/foundations/streaming.mdx b/docs/content/docs/v4/foundations/streaming.mdx index 060f49bbd9..cb1c9b2970 100644 --- a/docs/content/docs/v4/foundations/streaming.mdx +++ b/docs/content/docs/v4/foundations/streaming.mdx @@ -12,7 +12,7 @@ related: Workflows can stream data in real-time to clients without waiting for the entire workflow to complete. This enables progress updates, AI-generated content, log messages, and other incremental data to be delivered as workflows execute. -## Getting Started with `getWritable()` +## Getting started with `getWritable()` Every workflow run has a default writable stream that steps can write to using [`getWritable()`](/docs/api-reference/workflow/get-writable). Data written to this stream becomes immediately available to clients consuming the workflow's output. @@ -38,7 +38,7 @@ export async function simpleStreamingWorkflow() { } ``` -### Consuming the Stream +### Consuming the stream Use the `Run` object's `readable` property to consume the stream from your API route: @@ -58,7 +58,7 @@ export async function POST() { When a client makes a request to this endpoint, they'll receive each message as it's written, without waiting for the workflow to complete. -### Avoiding Function Timeouts After Client Disconnects +### Avoiding function timeouts after client disconnects On Vercel, `run.readable` and `run.getReadable()` reconnect to Workflow's stream storage while the workflow is still running. By default, a client disconnect does not terminate the Vercel Function serving the stream. If a user closes the page or stops the request, the function can therefore keep reconnecting until it reaches its maximum duration and fails with `FUNCTION_INVOCATION_TIMEOUT`. @@ -82,7 +82,7 @@ Cancellation applies to every function matching the configured path or glob, eve This setting prevents abandoned stream readers from consuming the rest of a function invocation. It does not extend the function's maximum duration: an actively connected streaming response can still reach the configured limit, at which point the client should reconnect to the durable stream. -### Resuming Streams from a Specific Point +### Resuming streams from a specific point Use `run.getReadable({ startIndex })` to resume a stream from a specific position. This is useful for reconnecting after timeouts or network interruptions: @@ -127,9 +127,9 @@ If the absolute value exceeds the total number of chunks, reading starts from th Because streams are live and continue receiving chunks, negative `startIndex` values resolve to different absolute positions on each call. Accurate pagination over a live stream requires cursor-based access, which is not yet supported. Keep this in mind when building clients that paginate over stream data. -## Streams as Data Types +## Streams as data types -[`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) and [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) are standard Web Streams API types that Workflow SDK makes serializable. These are not custom types - they follow the web standard - but Workflow SDK adds the ability to pass them between functions while maintaining their streaming capabilities. +[`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) and [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) are standard Web Streams API types that Workflow SDK makes serializable. They follow the web standard, and Workflow SDK lets you pass them between functions while maintaining their streaming capabilities. Unlike regular values that are fully serialized to the [event log](/docs/how-it-works/event-sourcing), streams maintain their streaming capabilities when passed between functions. @@ -147,7 +147,7 @@ Streams in Workflow SDK are backed by persistent, resumable storage provided by - **Local development**: Stream chunks are stored in the filesystem -### Passing Streams as Arguments +### Passing streams as arguments Since streams are serializable data types, you don't need to use the special [`getWritable()`](/docs/api-reference/workflow/get-writable). You can even wire your own streams through workflows, passing them as arguments from outside into steps. @@ -191,15 +191,15 @@ async function processInputStream(input: ReadableStream) { } ``` -## Important Limitation +## Important limitation -**Streams Cannot Be Used Directly in Workflow Context** +**Streams cannot be used directly in workflow context** You cannot read from or write to streams directly within a workflow function. All stream operations must happen in step functions. -Workflow functions must be deterministic to support replay. Since streams bypass the [event log](/docs/how-it-works/event-sourcing) for performance, reading stream data in a workflow would break determinism - each replay could see different data. By requiring all stream operations to happen in steps, the framework ensures consistent behavior. +Workflow functions must be deterministic to support replay. Since streams bypass the [event log](/docs/how-it-works/event-sourcing) for performance, reading stream data in a workflow would break determinism because each replay could see different data. Requiring all stream operations to happen in steps ensures consistent behavior. For more on determinism and replay, see [Workflows and Steps](/docs/foundations/workflows-and-steps). @@ -238,7 +238,7 @@ async function writeToStream(data: string) { } ``` -## Namespaced Streams +## Namespaced streams Use `getWritable({ namespace: 'name' })` to create multiple independent streams for different types of data. This is useful when you want to separate logs, metrics, data outputs, or other distinct channels. @@ -288,7 +288,7 @@ export async function multiStreamWorkflow() { } ``` -### Consuming Namespaced Streams +### Consuming namespaced streams Use `run.getReadable({ namespace: 'name' })` to access specific streams: @@ -313,9 +313,9 @@ export async function POST(request: Request) { } ``` -## Common Patterns +## Common patterns -### Progress Updates for Long-Running Tasks +### Progress updates for long-running tasks Send incremental progress updates to keep users informed during lengthy workflows: @@ -369,7 +369,7 @@ export async function batchProcessingWorkflow(items: string[]) { } ``` -### Streaming AI Responses with `WorkflowAgent` +### Streaming AI responses with `WorkflowAgent` Stream AI-generated content using AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) from `@ai-sdk/workflow`. The agent writes `ModelCallStreamPart` chunks to the workflow stream, and route handlers convert them to UI message chunks with `createModelCallToUIChunkTransform()` before returning the response: @@ -430,7 +430,7 @@ export async function POST(request: Request) { For the full agent API and migration notes, see the [`WorkflowAgent` documentation](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). -### Streaming Between Steps +### Streaming between steps One step produces a stream and another step consumes it: @@ -471,7 +471,7 @@ async function consumeData(readable: ReadableStream) { } ``` -### Processing Large Files Without Memory Overhead +### Processing large files without memory overhead Process large files by streaming chunks through transformation steps: @@ -512,7 +512,7 @@ async function uploadResult(stream: ReadableStream) { } ``` -## Best Practices +## Best practices **Release locks properly:** @@ -530,7 +530,7 @@ Stream locks acquired in a step only apply within that step, not across other st -If a lock is not released, the step function's HTTP request cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out—wasting compute resources unnecessarily. +If a lock is not released, the step function's HTTP request cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out, wasting compute resources unnecessarily. **Close streams when done:** @@ -556,7 +556,7 @@ const writer = writable.getWriter(); await writer.write({ /* typed data */ }); ``` -## Stream Failures +## Stream failures When a step returns a stream, the step is considered successful once it returns, even if the stream later encounters an error. The workflow won't automatically retry the step. The consumer of the stream must handle errors gracefully. For more on retry behavior, see [Errors and Retries](/docs/foundations/errors-and-retries). @@ -602,7 +602,7 @@ export async function streamErrorWorkflow() { Stream errors don't trigger automatic retries for the producer step. Design your stream consumers to handle errors appropriately. Since the stream is already in an errored state, retrying the consumer won't help - use `FatalError` to fail the workflow immediately. -## Related Documentation +## Related documentation - [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Get the workflow's writable stream - [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Pause workflow execution for a duration diff --git a/docs/content/docs/v4/foundations/versioning.mdx b/docs/content/docs/v4/foundations/versioning.mdx index 67fdda636a..246d36160f 100644 --- a/docs/content/docs/v4/foundations/versioning.mdx +++ b/docs/content/docs/v4/foundations/versioning.mdx @@ -70,11 +70,11 @@ If you deploy a change to `chargeCustomer()` while a run is in the two-day sleep Sometimes you deploy because the old code had a bug. The safest fix is usually explicit: 1. Deploy the fixed code. -2. Find the affected runs in [observability](/docs/observability) or with the CLI. +2. Find the affected runs in [observability](/docs/observability) or with the Workflow CLI. 3. Cancel the old runs if they are still running. 4. Rerun them on the latest deployment with the same inputs. -This keeps the version boundary visible. The old run ends as cancelled or failed, and the replacement run starts fresh on the fixed deployment. This is a good fit for one-off, ad-hoc upgrades where you explicitly opt in to moving affected runs onto a new version. +This keeps the version boundary visible. The old run ends as canceled or failed, and the replacement run starts fresh on the fixed deployment. This is a good fit for one-off, ad-hoc upgrades where you explicitly opt in to moving affected runs onto a new version. ```bash # Inspect affected runs and copy the exact workflowName value. @@ -118,13 +118,13 @@ export async function POST(request: Request) { `deploymentId: "latest"` is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment. Because the caller and target deployment can be different, keep the [workflow function name and file path](/docs/errors/workflow-not-registered), arguments, and return value backward-compatible across the deployments you plan to bridge. -SDK versions can also differ across deployments. Reading a run whose data was written by a newer version of the `workflow` package throws [`RunNotSupportedError`](/docs/api-reference/workflow-errors/run-not-supported-error) — upgrade the package to process those runs. +SDK versions can also differ across deployments. Reading a run whose data was written by a newer version of the `workflow` package throws [`RunNotSupportedError`](/docs/api-reference/workflow-errors/run-not-supported-error). Upgrade the package to process those runs. ## Self upgrading workflows -Some workflows are expected to run for a very long time. Scheduled loops, recurring jobs, agents, and chat sessions often should not stay on one deployment forever. +Some workflows are expected to run for long periods. Scheduled loops, recurring jobs, agents, and chat sessions often should not stay on one deployment forever. -Model those as a sequence of runs. Each run does a bounded piece of work, then starts the next run on the latest deployment and exits. This is similar to `continueAsNew` in other durable execution systems, but in Workflow SDK it is just [explicit recursion through `start()`](/cookbook/common-patterns/workflow-composition). +Model those as a sequence of runs. Each run does a bounded piece of work, then starts the next run on the latest deployment and exits. This is similar to `continueAsNew` in other durable execution systems, but Workflow SDK uses [explicit recursion through `start()`](/cookbook/common-patterns/workflow-composition). ```typescript title="workflows/daily-digest.ts" lineNumbers import { sleep } from "workflow"; diff --git a/docs/content/docs/v4/foundations/workflows-and-steps.mdx b/docs/content/docs/v4/foundations/workflows-and-steps.mdx index b334a2bf6e..aef02f45b5 100644 --- a/docs/content/docs/v4/foundations/workflows-and-steps.mdx +++ b/docs/content/docs/v4/foundations/workflows-and-steps.mdx @@ -14,18 +14,18 @@ import { File, Folder, Files } from "fumadocs-ui/components/files"; Workflows (a.k.a. *durable functions*) are a programming model for building long-running, stateful application logic that can maintain its execution state across restarts, failures, or user events. Unlike traditional serverless functions that lose all state when they terminate, workflows persist their progress and can resume exactly where they left off. -Moreover, workflows let you easily model complex multi-step processes in simple, elegant code. To do this, we introduce two fundamental entities: +Workflows let you model complex multi-step processes in code. To do this, we introduce two fundamental entities: 1. **Workflow Functions**: Functions that orchestrate/organize steps 2. **Step Functions**: Functions that carry out the actual work -## Workflow Functions +## Workflow functions *Directive: `"use workflow"`* Workflow functions define the entrypoint of a workflow and organize how step functions are called. This type of function does not have access to the Node.js runtime, and usable `npm` packages are limited. -Although this may seem limiting initially, this feature is important in order to suspend and accurately resume execution of workflows. +Although this may seem limiting initially, this feature is required to suspend and accurately resume workflow execution. It helps to think of the workflow function less like a full JavaScript runtime and more like "stitching together" various steps using conditionals, loops, try/catch handlers, `Promise.all`, and other language primitives. @@ -51,7 +51,7 @@ Determinism in the workflow is required to resume the workflow from a suspension The sandboxed environment that workflows run in already ensures determinism. For instance, `Math.random` and `Date` constructors are fixed in workflow runs, so you are safe to use them, and the framework ensures that the values don't change across replays. -## Step Functions +## Step functions *Directive: `"use step"`* @@ -115,10 +115,10 @@ export async function POST() { ``` -Keep in mind that calling a step function outside of a workflow function will not have retry semantics, nor will it be observable. Additionally, certain workflow-specific functions like [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) will throw an error when used inside a step that's called outside a workflow. +Calling a step function outside a workflow function provides neither retry semantics nor observability. Additionally, certain workflow-specific functions like [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) will throw an error when used inside a step that's called outside a workflow. -### Suspension and Resumption +### Suspension and resumption Workflow functions have the ability to automatically suspend while they wait on asynchronous work. While suspended, the workflow's state is stored via the [event log](/docs/how-it-works/event-sourcing) and no compute resources are used until the workflow resumes execution. @@ -152,9 +152,9 @@ export async function documentReviewProcess(userId: string) { } ``` -## Writing Workflows +## Writing workflows -### Basic Structure +### Basic structure The simplest workflow consists of a workflow function and one or more step functions. diff --git a/docs/content/docs/v4/getting-started/astro.mdx b/docs/content/docs/v4/getting-started/astro.mdx index 4f946ab223..45356bc2cf 100644 --- a/docs/content/docs/v4/getting-started/astro.mdx +++ b/docs/content/docs/v4/getting-started/astro.mdx @@ -13,16 +13,16 @@ related: text="In this Astro app, run `npm i workflow`. In `astro.config.mjs`, import `workflow` from `workflow/astro` and add `integrations: [workflow()]`. Add the TypeScript plugin `{ "name": "workflow" }` to `tsconfig.json` if TypeScript is used. Create `src/workflows/user-signup.ts` exporting `handleUserSignup(email)` with `"use workflow"`, `sleep` from `workflow`, and `"use step"` helpers. Add `src/pages/api/signup.ts` exporting `POST: APIRoute` that reads `{ email }`, calls `start(handleUserSignup, [email])` from `workflow/api`, returns `Response.json`, and sets `prerender = false`. Run `npm run dev`, call `curl -X POST --json '{"email":"hello@example.com"}' http://localhost:4321/api/signup`, and inspect with `npx workflow inspect runs`." /> -This guide will walk through setting up your first workflow in an Astro app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +Set up your first durable workflow in an Astro app and learn the core Workflow SDK concepts. --- -## Create Your Astro Project +## Create your Astro project -Start by creating a new Astro project. This command will create a new directory named `my-workflow-app` and setup a minimal Astro project inside it. +Create an Astro project in a new directory named `my-workflow-app`: ```bash npm create astro@latest my-workflow-app -- --template minimal --install --yes @@ -59,16 +59,16 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | - ### Setup IntelliSense for TypeScript (Optional) + ### Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -91,7 +91,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -112,17 +112,17 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="src/workflows/user-signup.ts" lineNumbers -import { FatalError } from "workflow" +import { FatalError } from "workflow"; // Our workflow function defined earlier @@ -135,18 +135,18 @@ async function createUser(email: string) { return { id: crypto.randomUUID(), email }; } -async function sendWelcomeEmail(user: { id: string; email: string; }) { +async function sendWelcomeEmail(user: { id: string; email: string }) { "use step"; // [!code highlight] console.log(`Sending welcome email to user: ${user.id}`); if (Math.random() < 0.3) { - // By default, steps will be retried for unhandled errors - throw new Error("Retryable!"); + // By default, steps will be retried for unhandled errors + throw new Error("Retryable!"); } } -async function sendOnboardingEmail(user: { id: string; email: string}) { +async function sendOnboardingEmail(user: { id: string; email: string }) { "use step"; // [!code highlight] if (!user.email.includes("@")) { @@ -160,7 +160,7 @@ async function sendOnboardingEmail(user: { id: string; email: string}) { Taking a look at this code: -* Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`. +* Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, as it does for `sleep`. * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count). * Steps can throw a `FatalError` if an error is intentional and should not be retried. @@ -172,7 +172,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `src/pages/api/signup.ts` with the following code: @@ -204,7 +204,7 @@ Workflows can be triggered from API routes or any server-side code. -## Run in Development +## Run in development To start your development server, run the following command in your terminal in the Vite root directory: @@ -220,7 +220,7 @@ curl -X POST --json '{"email":"hello@example.com"}' http://localhost:4321/api/si Check the Astro development server logs to see your workflow execute as well as the steps that are being processed. -Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail. +You can also use the [Workflow CLI or web UI](/docs/observability) to inspect your workflow runs and steps in detail. ```bash npx workflow inspect runs @@ -231,9 +231,9 @@ npx workflow inspect runs --- -## Deploying to Production +## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. @@ -251,7 +251,7 @@ Additionally, check the [Deploying](/docs/deploying) section to learn how your w If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -262,7 +262,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/express.mdx b/docs/content/docs/v4/getting-started/express.mdx index bd3c43deb4..012dfb7f5a 100644 --- a/docs/content/docs/v4/getting-started/express.mdx +++ b/docs/content/docs/v4/getting-started/express.mdx @@ -20,7 +20,7 @@ This guide will walk through setting up your first workflow in an Express app. A -## Create Your Express Project +## Create your Express project Start by creating a new Express project. @@ -117,7 +117,7 @@ To use the Nitro builder, update your `package.json` to include the following sc -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -137,14 +137,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -198,7 +198,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create both the Express app and a new API route handler at `src/index.ts` with the following code: @@ -272,7 +272,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -283,7 +283,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/fastify.mdx b/docs/content/docs/v4/getting-started/fastify.mdx index d13a3e923f..abce6eecbd 100644 --- a/docs/content/docs/v4/getting-started/fastify.mdx +++ b/docs/content/docs/v4/getting-started/fastify.mdx @@ -20,7 +20,7 @@ This guide will walk through setting up your first workflow in a Fastify app. Al -## Create Your Fastify Project +## Create your Fastify project Start by creating a new Fastify project. @@ -116,7 +116,7 @@ To use the Nitro builder, update your `package.json` to include the following sc -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -136,13 +136,13 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions: +Define the missing functions: ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -186,7 +186,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create both the Fastify app and a new API route handler at `src/index.ts` with the following code: @@ -259,7 +259,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -270,7 +270,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/hono.mdx b/docs/content/docs/v4/getting-started/hono.mdx index a60f18af48..a26ea8f1e0 100644 --- a/docs/content/docs/v4/getting-started/hono.mdx +++ b/docs/content/docs/v4/getting-started/hono.mdx @@ -16,7 +16,7 @@ related: -## Create Your Hono Project +## Create your Hono project Start by creating a new Hono project. This command will create a new directory named `my-workflow-app` and set up a Hono project inside it. @@ -100,7 +100,7 @@ To use the Nitro builder, update your `package.json` to include the following sc -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -122,14 +122,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -183,7 +183,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create a new API route handler at `src/index.ts` with the following code: @@ -254,7 +254,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -265,7 +265,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/nestjs.mdx b/docs/content/docs/v4/getting-started/nestjs.mdx index 15cb13dbb5..292d959f50 100644 --- a/docs/content/docs/v4/getting-started/nestjs.mdx +++ b/docs/content/docs/v4/getting-started/nestjs.mdx @@ -24,7 +24,7 @@ NestJS integration is experimental and not yet supported for deployment to Verce -## Create Your NestJS Project +## Create your NestJS project Start by creating a new NestJS project using the NestJS CLI. @@ -45,7 +45,7 @@ cd my-workflow-app npm i workflow @workflow/nest ``` -### Choose Your Module Format +### Choose your module format NestJS projects using [`@workflow/nest`](/docs/api-reference/workflow-nest) can compile as either ESM or CommonJS. Choose the setup that matches your SWC output instead of assuming ESM is required. @@ -111,7 +111,7 @@ Ensure your `nest-cli.json` has SWC as the builder: } ``` -### Initialize SWC Configuration +### Initialize SWC configuration Run the init command to generate the SWC configuration: @@ -207,7 +207,7 @@ The `WorkflowModule` handles workflow bundle building and provides HTTP routing -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow in the `src/workflows` directory: @@ -240,14 +240,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="src/workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -301,7 +301,7 @@ Taking a look at this code: -## Create Your Controller +## Create your controller To invoke your new workflow, update your controller with a new endpoint: @@ -365,7 +365,7 @@ npx workflow inspect runs --- -## Configuration Options +## Configuration options The `WorkflowModule.forRoot()` method accepts optional configuration: @@ -395,7 +395,7 @@ WorkflowModule.forRoot({ // development, false in production). // Accepts the same values as esbuild's sourcemap option: true, false, // 'inline', 'linked', 'external', 'both'. Set to false for smaller - // function bundles (useful for staying under the Vercel 250MB function + // function bundles (useful for staying under the Vercel 250 MB function // size limit) at the cost of stack traces pointing at generated code. // Can also be set via the WORKFLOW_SOURCEMAP environment variable. sourcemap: 'inline', @@ -408,7 +408,7 @@ WorkflowModule.forRoot({ If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -418,7 +418,7 @@ Check both of these first: 2. Your NestJS app imports and registers the `WorkflowModule`. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/next.mdx b/docs/content/docs/v4/getting-started/next.mdx index 0cf3ccdeac..6c2e108759 100644 --- a/docs/content/docs/v4/getting-started/next.mdx +++ b/docs/content/docs/v4/getting-started/next.mdx @@ -17,7 +17,7 @@ related: -## Create Your Next.js Project +## Create your Next.js project Start by creating a new Next.js project. This command will create a new directory named `my-workflow-app` and set up a Next.js project inside it. @@ -89,7 +89,7 @@ If your Next.js app has a [proxy handler](https://nextjs.org/docs/app/api-refere (formerly known as "middleware"), you'll need to update the matcher pattern to exclude Workflow's internal paths to prevent the proxy handler from running on them. -If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`. +If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This issue can be hard to spot in Next.js 16, where `proxy.ts` replaced `middleware.ts`. Add `.well-known/workflow/*` to your matcher exclusion list: @@ -121,7 +121,7 @@ This ensures that internal Workflow paths are not intercepted by your middleware -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -144,14 +144,14 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -204,7 +204,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll need to add your workflow to a `POST` API Route Handler, `app/api/signup/route.ts`, with the following code: @@ -276,7 +276,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error when upgrading to Next.js 16.1 or later: -``` +```text Build error occurred Error: Cannot find module 'next/dist/lib/server-external-packages.json' ``` @@ -315,7 +315,7 @@ Without this configuration, you may experience intermittent issues where workflo If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -326,7 +326,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/nitro.mdx b/docs/content/docs/v4/getting-started/nitro.mdx index fb4fd1189d..1efb56075c 100644 --- a/docs/content/docs/v4/getting-started/nitro.mdx +++ b/docs/content/docs/v4/getting-started/nitro.mdx @@ -1,6 +1,6 @@ --- title: Nitro -description: This guide will walk through setting up your first workflow in a Nitro v3 project. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +description: Set up your first durable workflow in a Nitro v3 project. type: guide summary: Set up Workflow SDK in a Nitro app. prerequisites: @@ -16,9 +16,9 @@ related: -## Create Your Nitro Project +## Create your Nitro project -Start by creating a new [Nitro v3](https://v3.nitro.build/) project. This command will create a new directory named `nitro-app` and setup a Nitro project inside it. +Create a [Nitro v3](https://v3.nitro.build/) project in a new directory named `nitro-app`: ```bash npx create-nitro-app @@ -38,7 +38,7 @@ npm i workflow ### Configure Nitro -Add `workflow/nitro` module to your `nitro.config.ts` This enables usage of the `"use workflow"` and `"use step"` directives. +Add the `workflow/nitro` module to your `nitro.config.ts`. This enables the `"use workflow"` and `"use step"` directives. ```typescript title="nitro.config.ts" lineNumbers import { defineConfig } from "nitro"; @@ -70,15 +70,15 @@ export default defineConfig({ | --- | --- | --- | --- | | `dirs` | `string[]` | — | Directories to scan for workflows and steps. By default, `workflows/` is scanned from the project root and all layer source directories. | | `runtime` | `string` | `'nodejs22.x'` | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | - Setup IntelliSense for TypeScript (Optional) + Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -102,7 +102,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -118,20 +118,20 @@ export async function handleUserSignup(email: string) { await sleep("5s"); // Pause for 5s - doesn't consume any resources await sendOnboardingEmail(user); - console.log("Workflow is complete! Run 'npx workflow web' to inspect your run") + console.log("Workflow is complete! Run 'npx workflow web' to inspect your run"); return { userId: user.id, status: "onboarded" }; } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -172,7 +172,7 @@ async function sendOnboardingEmail(user: { id: string; email: string }) { Taking a look at this code: -- Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`. +- Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, as it does for `sleep`. - If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count). - Steps can throw a `FatalError` if an error is intentional and should not be retried. @@ -185,7 +185,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create a new API route handler at `server/api/signup.post.ts` with the following code: @@ -204,7 +204,7 @@ export default defineEventHandler(async ({ req }) => { }); ``` -This Route Handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow. +This route handler creates a `POST` request endpoint at `/api/signup` that triggers your workflow. Workflows can be triggered from API routes or any server-side @@ -231,7 +231,7 @@ curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/si Check the Nitro development server logs to see your workflow execute as well as the steps that are being processed. -Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail. +You can also use the [Workflow CLI or web UI](/docs/observability) to inspect your workflow runs and steps in detail. ```bash # Open the observability Web UI @@ -248,7 +248,7 @@ npx workflow inspect runs ## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. @@ -260,7 +260,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -271,7 +271,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/nuxt.mdx b/docs/content/docs/v4/getting-started/nuxt.mdx index 1ac38e86a9..d9162c4003 100644 --- a/docs/content/docs/v4/getting-started/nuxt.mdx +++ b/docs/content/docs/v4/getting-started/nuxt.mdx @@ -16,7 +16,7 @@ related: -## Create Your Nuxt Project +## Create your Nuxt project Start by creating a new Nuxt project. This command will create a new directory named `nuxt-app` and setup a Nuxt project inside it. @@ -79,7 +79,7 @@ export default defineNuxtConfig({ -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -101,14 +101,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="server/workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -162,7 +162,7 @@ Taking a look at this code: -## Create Your API Route +## Create your API route To invoke your new workflow, we'll create a new API route handler at `server/api/signup.post.ts` with the following code: @@ -239,7 +239,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -250,7 +250,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/python.mdx b/docs/content/docs/v4/getting-started/python.mdx index e6ffb0e8e0..b603f5b375 100644 --- a/docs/content/docs/v4/getting-started/python.mdx +++ b/docs/content/docs/v4/getting-started/python.mdx @@ -20,7 +20,7 @@ The Python SDK is currently in **beta**. APIs and behavior may change. For the l You can build durable workflows in Python using the [`vercel` Python SDK](https://pypi.org/project/vercel/). Your workflow code can pause, resume, and maintain state, just like the JavaScript and TypeScript Workflow SDK. -## Getting Started +## Getting started Add the `vercel` package and workflow entrypoint to `pyproject.toml`: @@ -160,11 +160,11 @@ async def resume(approval: Approval): When a hook receives data, the workflow resumes automatically. You don't need polling, message queues, or manual state management. -## Learn More +## Learn more For comprehensive documentation, examples, and the latest updates, visit the [official Vercel Workflow Python documentation](https://vercel.com/docs/workflows/python). -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/sveltekit.mdx b/docs/content/docs/v4/getting-started/sveltekit.mdx index 16ae15555f..47ee4f2f92 100644 --- a/docs/content/docs/v4/getting-started/sveltekit.mdx +++ b/docs/content/docs/v4/getting-started/sveltekit.mdx @@ -1,6 +1,6 @@ --- title: SvelteKit -description: This guide will walk through setting up your first workflow in a SvelteKit app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +description: Set up your first durable workflow in a SvelteKit app. type: guide summary: Set up Workflow SDK in a SvelteKit app. prerequisites: @@ -16,9 +16,9 @@ related: -## Create Your SvelteKit Project +## Create your SvelteKit project -Start by creating a new SvelteKit project. This command will create a new directory named `my-workflow-app` with a minimal setup and setup a SvelteKit project inside it. +Create a minimal SvelteKit project in a new directory named `my-workflow-app`: ```bash npx sv create my-workflow-app --template=minimal --types=ts --no-add-ons @@ -54,16 +54,16 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | - ### Setup IntelliSense for TypeScript (Optional) + ### Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -86,7 +86,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -102,24 +102,24 @@ export async function handleUserSignup(email: string) { await sleep("5s"); // Pause for 5s - doesn't consume any resources await sendOnboardingEmail(user); - console.log("Workflow is complete! Run 'npx workflow web' to inspect your run") + console.log("Workflow is complete! Run 'npx workflow web' to inspect your run"); return { userId: user.id, status: "onboarded" }; } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers -import { FatalError } from "workflow" +import { FatalError } from "workflow"; // Our workflow function defined earlier @@ -132,18 +132,18 @@ async function createUser(email: string) { return { id: crypto.randomUUID(), email }; } -async function sendWelcomeEmail(user: { id: string; email: string; }) { +async function sendWelcomeEmail(user: { id: string; email: string }) { "use step"; // [!code highlight] console.log(`Sending welcome email to user: ${user.id}`); if (Math.random() < 0.3) { - // By default, steps will be retried for unhandled errors - throw new Error("Retryable!"); + // By default, steps will be retried for unhandled errors + throw new Error("Retryable!"); } } -async function sendOnboardingEmail(user: { id: string; email: string}) { +async function sendOnboardingEmail(user: { id: string; email: string }) { "use step"; // [!code highlight] if (!user.email.includes("@")) { @@ -157,7 +157,7 @@ async function sendOnboardingEmail(user: { id: string; email: string}) { Taking a look at this code: -* Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`. +* Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, as it does for `sleep`. * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count). * Steps can throw a `FatalError` if an error is intentional and should not be retried. @@ -169,7 +169,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `src/routes/api/signup/+server.ts` with the following code: @@ -219,7 +219,7 @@ curl -X POST --json '{"email":"hello@example.com"}' http://localhost:5173/api/si Check the SvelteKit development server logs to see your workflow execute as well as the steps that are being processed. -Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail. +You can also use the [Workflow CLI or web UI](/docs/observability) to inspect your workflow runs and steps in detail. ```bash # Open the observability Web UI @@ -232,7 +232,7 @@ npx workflow inspect runs ## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. @@ -244,7 +244,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -255,7 +255,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/tanstack-start.mdx b/docs/content/docs/v4/getting-started/tanstack-start.mdx index 074d98f84c..ca4d164b9e 100644 --- a/docs/content/docs/v4/getting-started/tanstack-start.mdx +++ b/docs/content/docs/v4/getting-started/tanstack-start.mdx @@ -13,14 +13,14 @@ related: text="In this TanStack Start app, run `npm i workflow`. In `vite.config.ts`, import `workflow` from `workflow/vite` and add `workflow()` first in the existing `plugins` array before `tanstackStart()`, `nitro()`, or other plugins. Add `{ "name": "workflow" }` to `compilerOptions.plugins` in `tsconfig.json` if TypeScript is used. Create `src/workflows/user-signup.ts` with `handleUserSignup(email)`, `"use workflow"`, `sleep`, and `"use step"` helpers. Add `src/routes/api/signup.ts` using `createFileRoute("/api/signup")`, a POST server handler, `start` from `workflow/api`, and `json` from `@tanstack/react-start`. Run `npm run dev`, call `curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup`, and inspect with `npx workflow web` or `npx workflow inspect runs`." /> -This guide will walk through setting up your first workflow in a TanStack Start app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +Set up your first durable workflow in a TanStack Start app and learn the core Workflow SDK concepts. --- -## Create Your TanStack Start Project +## Create your TanStack Start project Start by creating a new TanStack Start project: @@ -42,7 +42,7 @@ npm i workflow ### Configure TanStack Start -TanStack Start runs on Vite, so the Workflow SDK is wired in via the same `workflow/vite` plugin. Add `workflow()` to the existing `plugins` array in your Vite config — list it first so the `"use workflow"` and `"use step"` transforms run before any other plugin processes the file. +TanStack Start runs on Vite, so the Workflow SDK is wired in via the same `workflow/vite` plugin. Add `workflow()` to the existing `plugins` array in your Vite config. List it first so the `"use workflow"` and `"use step"` transforms run before any other plugin processes the file. ```typescript title="vite.config.ts" lineNumbers import { defineConfig } from "vite"; @@ -60,11 +60,11 @@ export default defineConfig({ - ### Setup IntelliSense for TypeScript (Optional) + ### Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -87,7 +87,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -107,17 +107,17 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="src/workflows/user-signup.ts" lineNumbers -import { FatalError } from "workflow" +import { FatalError } from "workflow"; // Our workflow function defined earlier @@ -130,18 +130,18 @@ async function createUser(email: string) { return { id: crypto.randomUUID(), email }; } -async function sendWelcomeEmail(user: { id: string; email: string; }) { +async function sendWelcomeEmail(user: { id: string; email: string }) { "use step"; // [!code highlight] console.log(`Sending welcome email to user: ${user.id}`); if (Math.random() < 0.3) { - // By default, steps will be retried for unhandled errors - throw new Error("Retryable!"); + // By default, steps will be retried for unhandled errors + throw new Error("Retryable!"); } } -async function sendOnboardingEmail(user: { id: string; email: string}) { +async function sendOnboardingEmail(user: { id: string; email: string }) { "use step"; // [!code highlight] if (!user.email.includes("@")) { @@ -155,7 +155,7 @@ async function sendOnboardingEmail(user: { id: string; email: string}) { Taking a look at this code: -* Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, just like `sleep`. +* Business logic lives inside **steps**. When a step is invoked inside a **workflow**, it gets enqueued to run on a separate request while the workflow is suspended, as it does for `sleep`. * If a step throws an error, like in `sendWelcomeEmail`, the step will automatically be retried until it succeeds (or hits the step's max retry count). * Steps can throw a `FatalError` if an error is intentional and should not be retried. @@ -167,7 +167,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, add a server handler at `src/routes/api/signup.ts`: @@ -217,7 +217,7 @@ curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/si Check the dev server logs to see your workflow execute as well as the steps that are being processed. -Additionally, you can use the [Workflow SDK CLI or Web UI](/docs/observability) to inspect your workflow runs and steps in detail. +You can also use the [Workflow CLI or web UI](/docs/observability) to inspect your workflow runs and steps in detail. ```bash # Open the observability Web UI on http://localhost:3456 @@ -232,13 +232,13 @@ npx workflow inspect runs ## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/getting-started/vite.mdx b/docs/content/docs/v4/getting-started/vite.mdx index 1cdbc183c5..ec67a04ccc 100644 --- a/docs/content/docs/v4/getting-started/vite.mdx +++ b/docs/content/docs/v4/getting-started/vite.mdx @@ -20,7 +20,7 @@ This guide will walk through setting up your first workflow in a Vite app. Along -## Create Your Vite Project +## Create your Vite project Start by creating a new Vite project. This command will create a new directory named `my-workflow-app` with a minimal setup and setup a Vite project inside it. @@ -91,7 +91,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -112,14 +112,14 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -172,7 +172,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `api/signup.post.ts` with the following code: @@ -244,7 +244,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow Development Kit is configured correctly and the function includes a 'use workflow' directive. ``` @@ -255,7 +255,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v4/how-it-works/code-transform.mdx b/docs/content/docs/v4/how-it-works/code-transform.mdx index f2337e951f..0106f0bb6e 100644 --- a/docs/content/docs/v4/how-it-works/code-transform.mdx +++ b/docs/content/docs/v4/how-it-works/code-transform.mdx @@ -10,12 +10,12 @@ related: --- -This is an advanced guide that dives into internals of the Workflow SDK directive and is not required reading to use workflows. To simply use the Workflow SDK, check out the [getting started](/docs/getting-started) guides for your framework. +This is an advanced guide that dives into internals of the Workflow SDK directive and is not required reading to use workflows. To use the Workflow SDK, check out the [getting started](/docs/getting-started) guides for your framework. Workflows use special directives to mark code for transformation by the Workflow SDK compiler. This page explains how `"use workflow"` and `"use step"` directives work, what transformations are applied, and why they're necessary for durable execution. -## Directives Overview +## Directives overview Workflows use two directives to mark functions for special handling: @@ -39,12 +39,12 @@ async function createUser(email: string) { **Key directives:** -- `"use workflow"`: Marks a function as a durable workflow entry point -- `"use step"`: Marks a function as an atomic, retryable step +- `"use workflow"`: Marks a function as a durable workflow entry point. +- `"use step"`: Marks a function as an atomic, retryable step. These directives trigger the `@workflow/swc-plugin` compiler to transform your code in different ways depending on the execution context. -## The Three Transformation Modes +## The three transformation modes The compiler operates in three distinct modes, transforming the same source code differently for each execution context: @@ -58,7 +58,7 @@ flowchart LR D --> G["Your App Code
(Enables `start`)"] ``` -### Comparison Table +### Comparison table | Mode | Used In | Purpose | Output API Route | Required? | |----------|------------|--------------------------------|------------------------------------|-----------| @@ -66,9 +66,9 @@ flowchart LR | Workflow | Build time | Bundles workflow orchestrators | `.well-known/workflow/v1/flow` | Yes | | Client | Build/Runtime | Provides workflow IDs and types to `start` | Your application code | Optional* | -\* Client mode is **recommended** for better developer experience—it provides automatic ID generation and type safety. Without it, you must manually construct workflow IDs or use the build manifest. +\* Client mode is **recommended** for better developer experience: it provides automatic ID generation and type safety. Without it, you must manually construct workflow IDs or use the build manifest. -## Detailed Transformation Examples +## Detailed transformation examples @@ -101,12 +101,12 @@ export async function createUser(email: string) { **What happens:** -- The `"use step"` directive is removed -- The function body is kept completely intact (no transformation) -- The function is registered with the runtime via an inline IIFE (no imports needed) -- Step functions run with full Node.js/Deno/Bun access +- The `"use step"` directive is removed. +- The function body is kept completely intact (no transformation). +- The function is registered with the runtime via an inline immediately invoked function expression (IIFE), with no imports needed. +- Step functions run with full Node.js, Deno, or Bun access. -**Why no transformation?** Step functions execute in your main runtime with full access to Node.js APIs, file system, databases, etc. They don't need any special handling—they just run normally. +**Why no transformation?** Step functions execute in your main runtime with full access to Node.js APIs, file system, databases, etc. They don't need any special handling; they run normally. **ID Format:** Step IDs follow the pattern `step//{filepath}//{functionName}`, where the filepath is relative to your project root. @@ -148,10 +148,10 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; / **What happens:** -- Step function bodies are **replaced** with calls to `globalThis[Symbol.for("WORKFLOW_USE_STEP")]` -- Workflow function bodies remain **intact**—they execute deterministically during replay -- The workflow function gets a `workflowId` property for runtime identification -- The `"use workflow"` directive is removed +- Step function bodies are **replaced** with calls to `globalThis[Symbol.for("WORKFLOW_USE_STEP")]`. +- Workflow function bodies remain **intact**; they execute deterministically during replay. +- The workflow function gets a `workflowId` property for runtime identification. +- The `"use workflow"` directive is removed. **Why this transformation?** When a workflow executes, it needs to replay past steps from the [event log](/docs/how-it-works/event-sourcing) rather than re-executing them. The `WORKFLOW_USE_STEP` symbol is a special runtime hook that: @@ -189,11 +189,11 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; / **What happens:** -- Workflow function bodies are **replaced** with an error throw -- The `workflowId` property is added (same as workflow mode) -- Step functions are not transformed in client mode +- Workflow function bodies are **replaced** with an error throw. +- The `workflowId` property is added (same as workflow mode). +- Step functions are not transformed in client mode. -**Why this transformation?** Workflow functions cannot be called directly—they must be started using [`start()`](/docs/api-reference/workflow-api/start). The error prevents accidental direct execution while the `workflowId` property allows the `start()` function to identify which workflow to launch. +**Why this transformation?** Workflow functions cannot be called directly from application code; they must be started using [`start()`](/docs/api-reference/workflow-api/start). The error prevents accidental direct execution while the `workflowId` property allows the `start()` function to identify which workflow to launch. The IDs are generated exactly like in workflow mode to ensure they can be directly referenced at runtime. @@ -209,7 +209,7 @@ The IDs are generated exactly like in workflow mode to ensure they can be direct -## Generated Files +## Generated files When you build your application, the Workflow SDK generates three handler files in `.well-known/workflow/v1/`: @@ -221,9 +221,9 @@ Contains all workflow functions transformed in **workflow mode**. This file is i All workflow code is bundled together and embedded as a string inside `flow.js`. When a workflow needs to execute, this bundled code is run inside a **Node.js VM** (virtual machine) to ensure: -- **Determinism**: The same inputs always produce the same outputs -- **Side-effect prevention**: Direct access to Node.js APIs, file system, network, etc. is blocked -- **Sandboxed execution**: Workflow orchestration logic is isolated from the main runtime +- **Determinism**: The same inputs always produce the same outputs. +- **Side-effect prevention**: Direct access to Node.js APIs, the file system, and the network is blocked. +- **Sandboxed execution**: Workflow orchestration logic is isolated from the main runtime. **Build-time validation:** @@ -265,34 +265,34 @@ Contains webhook handling logic for delivering external data to running workflow - Validates tokens and routes data to the correct workflow run - Resumes workflow execution after webhook delivery -**Note:** The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to leverage App Router's dynamic routing, while other frameworks generate a single `webhook.js` or `webhook.mjs` handler. +**Note:** The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to use App Router's dynamic routing, while other frameworks generate a single `webhook.js` or `webhook.mjs` handler. -## Why Three Modes? +## Why three modes? The multi-mode transformation enables the Workflow SDK's durable execution model: -1. **Step Mode** (required) - Bundles executable step functions that can access the full runtime -2. **Workflow Mode** (required) - Creates orchestration logic that can replay from event logs -3. **Client Mode** (optional) - Prevents direct execution and enables type-safe workflow references +1. **Step mode** (required): Bundles executable step functions that can access the full runtime. +2. **Workflow mode** (required): Creates orchestration logic that can replay from event logs. +3. **Client mode** (optional): Prevents direct execution and enables type-safe workflow references. This separation allows: -- **Deterministic replay**: Workflows can be safely replayed from event logs without re-executing side effects -- **Sandboxed orchestration**: Workflow logic runs in a controlled VM without direct runtime access -- **Stateless execution**: Your compute can scale to zero and resume from any point in the workflow -- **Type safety**: TypeScript works seamlessly with workflow references (when using client mode) +- **Deterministic replay**: Workflows can be safely replayed from event logs without re-executing side effects. +- **Sandboxed orchestration**: Workflow logic runs in a controlled VM without direct runtime access. +- **Stateless execution**: Your compute can scale to zero and resume from any point in the workflow. +- **Type safety**: TypeScript works with workflow references (when using client mode). -## Determinism and Replay +## Determinism and replay A key aspect of the transformation is maintaining **deterministic replay** for workflow functions. **Workflow functions must be deterministic:** -- Same inputs always produce the same outputs +- Same inputs always produce the same outputs. - No direct side effects (no API calls, no database writes, no file I/O) - Can use seeded random/time APIs provided by the VM (`Math.random()`, `Date.now()`, etc.) -Because workflow functions are deterministic and have no side effects, they can be safely re-run multiple times to calculate what the next step should be. This is why workflow function bodies remain intact in workflow mode—they're pure orchestration logic. +Because workflow functions are deterministic and have no side effects, they can be safely re-run multiple times to calculate what the next step should be. This is why workflow function bodies remain intact in workflow mode: they're pure orchestration logic. **Step functions can be non-deterministic:** @@ -302,7 +302,7 @@ Because workflow functions are deterministic and have no side effects, they can Learn more about [Workflows and Steps](/docs/foundations/workflows-and-steps). -## ID Generation +## ID generation The compiler generates stable IDs for workflows and steps based on file paths and function names: @@ -316,27 +316,27 @@ The compiler generates stable IDs for workflows and steps based on file paths an **Key properties:** -- **Stable**: IDs don't change unless you rename files or functions -- **Unique**: Each workflow/step has a unique identifier -- **Portable**: Works across different runtimes and deployments +- **Stable**: IDs don't change unless you rename files or functions. +- **Unique**: Each workflow or step has a unique identifier. +- **Portable**: IDs work across different runtimes and deployments. Although IDs can change when files are moved or functions are renamed, Workflow SDK functions assume [atomic versioning](/docs/foundations/versioning) in the World. This means changing IDs won't break old workflows from running, but will prevent runs from being upgraded and will cause your workflow/step names to change in observability across deployments. -## Framework Integration +## Framework integration -These transformations are framework-agnostic—they output standard JavaScript that works anywhere. +These transformations are framework-agnostic; they output standard JavaScript that works anywhere. **For users**: Your framework handles all transformations automatically. See the [Getting Started](/docs/getting-started) guide for your framework. **For framework authors**: Learn how to integrate these transformations into your framework in [Building Framework Integrations](/docs/how-it-works/framework-integrations). -## Debugging Transformed Code +## Debugging transformed code If you need to debug transformation issues, you can inspect the generated files: -1. **Look in `.well-known/workflow/v1/`**: Check the generated `flow.js`, `step.js`,`webhook.js`, and other emitted debug files. -2. **Check build logs**: Most frameworks log transformation activity during builds -3. **Verify directives**: Ensure `"use workflow"` and `"use step"` are the first statements in functions -4. **Check file locations**: Transformations only apply to files in configured source directories +1. **Look in `.well-known/workflow/v1/`**: Check the generated `flow.js`, `step.js`, `webhook.js`, and other emitted debug files. +2. **Check build logs**: Most frameworks log transformation activity during builds. +3. **Verify directives**: Ensure `"use workflow"` and `"use step"` are the first statements in functions. +4. **Check file locations**: Transformations only apply to files in configured source directories. diff --git a/docs/content/docs/v4/how-it-works/encryption.mdx b/docs/content/docs/v4/how-it-works/encryption.mdx index a10a81a1d4..b21262aa06 100644 --- a/docs/content/docs/v4/how-it-works/encryption.mdx +++ b/docs/content/docs/v4/how-it-works/encryption.mdx @@ -11,56 +11,56 @@ related: --- -This guide explains how Workflow SDK encrypts user data in the event log. Understanding these details is not required to use workflows — encryption is automatic and requires no code changes. For getting started, see the [getting started](/docs/getting-started) guides for your framework. +This guide explains how Workflow SDK encrypts user data in the event log. Understanding these details is not required to use workflows: encryption is automatic and requires no code changes. For getting started, see the [getting started](/docs/getting-started) guides for your framework. -Workflow SDK supports automatic end-to-end encryption of all user data before it is written to the event log. When a `World` implementation provides encryption support, it is safe to pass sensitive data — such as API keys, tokens, or user credentials — as workflow inputs, step arguments, and return values. The storage backend only ever sees ciphertext. +Workflow SDK supports automatic end-to-end encryption of all user data before it is written to the event log. When a `World` implementation provides encryption support, it is safe to pass sensitive data (such as API keys, tokens, or user credentials) as workflow inputs, step arguments, and return values. The storage backend only ever sees ciphertext. -Encryption support varies by `World` implementation. See the [Worlds](/worlds) page to check which worlds support this feature. `World` implementations opt into encryption by providing a `getEncryptionKeyForRun()` method — the core runtime will use it automatically when present. +Encryption support varies by `World` implementation. See the [Worlds](/worlds) page to check which worlds support this feature. `World` implementations opt into encryption by providing a `getEncryptionKeyForRun()` method; the core runtime will use it automatically when present. -## What Is Encrypted +## What is encrypted All user data flowing through the event log is encrypted: -- **Workflow inputs** — arguments passed when starting a workflow -- **Workflow return values** — the final output of a workflow -- **Step inputs** — arguments passed to step functions -- **Step return values** — the result returned by step functions -- **Hook metadata** — data attached when creating a hook -- **Hook payloads** — data received by hooks and webhooks -- **Stream data** — each frame in a `ReadableStream` or `WritableStream` +- **Workflow inputs**: arguments passed when starting a workflow +- **Workflow return values**: the final output of a workflow +- **Step inputs**: arguments passed to step functions +- **Step return values**: the result returned by step functions +- **Hook metadata**: data attached when creating a hook +- **Hook payloads**: data received by hooks and webhooks +- **Stream data**: each frame in a `ReadableStream` or `WritableStream` Metadata such as workflow names, step names, entity IDs, timestamps, and lifecycle states are **not** encrypted. This allows the observability tools to display run structure and timelines without requiring decryption. -## How It Works +## How it works -### Key Management +### Key management Each workflow run is encrypted with its own unique key, provided by the `World` implementation via `getEncryptionKeyForRun()`. How the key is generated and stored is up to the `World`. For example, the [Vercel World](/worlds/vercel) provides unique keys per run and execution environment, ensuring that a given run can only decrypt data from that run itself. -### Encryption Algorithm +### Encryption algorithm Data is encrypted using **AES-256-GCM** via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API): -- A random 12-byte nonce is generated for each encryption operation -- The GCM authentication tag provides integrity verification — any tampering with the ciphertext is detected -- The same plaintext produces different ciphertext each time due to the random nonce +- A random 12-byte nonce is generated for each encryption operation. +- The GCM authentication tag provides integrity verification; any tampering with the ciphertext is detected. +- The same plaintext produces different ciphertext each time due to the random nonce. If the ciphertext, nonce, or auth tag fails verification, the run fails with a [runtime-decryption-failed](/docs/errors/runtime-decryption-failed) error. -## Decrypting Data +## Decrypting data When viewing workflow runs through the observability tools, encrypted fields display as locked placeholders until you explicitly choose to decrypt them. ### Permissions -Decryption access is controlled by the `World` implementation. On Vercel, decryption follows the same permissions model as project environment variables — if you don't have permission to view environment variable values for a project, you won't be able to decrypt workflow data either. Each decryption request is recorded in your [Vercel audit log](https://vercel.com/docs/audit-log), giving your team full visibility into when and by whom workflow data was accessed. +Decryption access is controlled by the `World` implementation. On Vercel, decryption follows the same permissions model as project environment variables: if you don't have permission to view environment variable values for a project, you won't be able to decrypt workflow data either. Each decryption request is recorded in your [Vercel audit log](https://vercel.com/docs/audit-log), giving your team full visibility into when and by whom workflow data was accessed. -### Web Dashboard +### Web dashboard -Click the **Decrypt** button in the run detail panel to decrypt all data fields. Decryption happens entirely in the browser via the Web Crypto API — the observability server retrieves the encryption key but never sees your plaintext data. +Click the **Decrypt** button in the run detail panel to decrypt all data fields. Decryption happens entirely in the browser via the Web Crypto API; the observability server retrieves the encryption key but never sees your plaintext data. ### CLI @@ -82,7 +82,7 @@ npx workflow inspect stream --run --decrypt Without `--decrypt`, encrypted fields display as `🔒 Encrypted` placeholders. -## Custom World Implementations +## Custom World implementations The core runtime encrypts data automatically when the `World` implementation provides a `getEncryptionKeyForRun()` method. The core runtime can call this method in two forms: @@ -99,9 +99,9 @@ Use `getEncryptionKeyForRun(run)` when the run entity already exists. Use `getEn To add encryption support to a custom `World`: -1. Implement `getEncryptionKeyForRun()` on your `World` class, handling both call shapes -2. Return the raw 32-byte key as a `Uint8Array` — the core runtime uses it for AES-256-GCM operations -3. Ensure the same key is returned for the same run ID across invocations (for decryption during replay) +1. Implement `getEncryptionKeyForRun()` on your `World` class, handling both call shapes. +2. Return the raw 32-byte key as a `Uint8Array`; the core runtime uses it for AES-256-GCM operations. +3. Ensure the same key is returned for the same run ID across invocations (for decryption during replay). ```typescript import type { WorkflowRun, World } from "@workflow/world"; diff --git a/docs/content/docs/v4/how-it-works/event-sourcing.mdx b/docs/content/docs/v4/how-it-works/event-sourcing.mdx index d3b3e2d865..7ad4398a2b 100644 --- a/docs/content/docs/v4/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v4/how-it-works/event-sourcing.mdx @@ -17,7 +17,7 @@ The Workflow SDK uses event sourcing to track all state changes in workflow exec This page explains the event sourcing model and entity lifecycles. -## Event Sourcing Overview +## Event sourcing overview Event sourcing is a persistence pattern where state changes are stored as a sequence of events rather than by updating records in place. The current state of any entity is reconstructed by replaying its events from the beginning. @@ -37,15 +37,15 @@ In the Workflow SDK, the following entity types are managed through events: - **Hooks**: Suspension points that can receive external data (materialized in storage) - **Waits**: Sleep or delay operations (materialized in storage) -## Entity Lifecycles +## Entity lifecycles -Each entity type follows a specific lifecycle defined by the events that can affect it. Events transition entities between states, and certain states are terminal—once reached, no further transitions are possible. +Each entity type follows a specific lifecycle defined by the events that can affect it. Events transition entities between states, and certain states are terminal: once reached, no further transitions are possible. In the diagrams below, purple nodes indicate terminal states that cannot be transitioned out of. -### Run Lifecycle +### Run lifecycle A run represents a single execution of a workflow function. Runs begin in `pending` state when created, transition to `running` when execution starts, and end in one of three terminal states. @@ -69,9 +69,9 @@ flowchart TD - `running`: Actively executing workflow code - `completed`: Finished successfully with an output value - `failed`: Terminated due to an unrecoverable error -- `cancelled`: Explicitly cancelled by the user or system +- `cancelled`: Explicitly canceled by the user or system -### Step Lifecycle +### Step lifecycle A step represents a single invocation of a step function. Steps can retry on failure, either transitioning back to `pending` via `step_retrying` or being re-executed directly with another `step_started` event. @@ -104,7 +104,7 @@ When present, the `step_retrying` event moves a step back to `pending` state and - **Cleaner observability**: The event log explicitly shows retry transitions rather than consecutive `step_started` events - **Error history**: The error that triggered the retry is preserved for debugging -### Hook Lifecycle +### Hook lifecycle A hook represents a suspension point that can receive external data, created by [`createHook()`](/docs/api-reference/workflow/create-hook). Hooks enable workflows to pause and wait for external events, user interactions, or HTTP requests. Webhooks (created with [`createWebhook()`](/docs/api-reference/workflow/create-webhook)) are a higher-level abstraction built on hooks that adds automatic HTTP request/response handling. @@ -127,7 +127,7 @@ flowchart TD - `disposed`: No longer accepting payloads (hook is deleted from storage) - `conflicted`: Hook creation failed because the token is already in use by another workflow -Unlike other entities, hooks don't have a `status` field—the states above are conceptual. An "active" hook is one that exists in storage, while "disposed" means the hook has been deleted. When a `hook_disposed` event is created, the hook record is removed rather than updated. +Unlike other entities, hooks don't have a `status` field; the states above are conceptual. An "active" hook is one that exists in storage, while "disposed" means the hook has been deleted. When a `hook_disposed` event is created, the hook record is removed rather than updated. While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token that is already in use by another active hook, a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that currently owns it, though older persisted events or world implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details. @@ -135,7 +135,7 @@ When a hook is disposed (either explicitly or when its workflow completes), the See [Hooks & Webhooks](/docs/foundations/hooks) for more on how hooks and webhooks work. -### Wait Lifecycle +### Wait lifecycle A wait represents a sleep operation created by [`sleep()`](/docs/api-reference/workflow/sleep). Waits track when a delay period has elapsed. @@ -153,10 +153,10 @@ flowchart TD - `completed`: Delay period has elapsed, workflow can resume -Like Runs, Steps, and Hooks, waits are materialized as entities in storage. When a `wait_created` event is processed, a wait entity is created with status `waiting`. When a `wait_completed` event is processed, the wait entity is atomically transitioned to `completed` — this guarantees that a wait can only be completed exactly once, even if multiple concurrent invocations attempt to complete it simultaneously. +Like Runs, Steps, and Hooks, waits are materialized as entities in storage. When a `wait_created` event is processed, a wait entity is created with status `waiting`. When a `wait_completed` event is processed, the wait entity is atomically transitioned to `completed`. This guarantees that a wait can only be completed exactly once, even if multiple concurrent invocations attempt to complete it simultaneously. -## Event Types Reference +## Event types reference Events are categorized by the entity type they affect. Each event contains metadata including a timestamp and a `correlationId` that links the event to a specific entity: @@ -165,7 +165,7 @@ Events are categorized by the entity type they affect. Each event contains metad - Wait events use the `waitId` as the correlation ID - Run events do not require a correlation ID since the `runId` itself identifies the entity -### Run Events +### Run events | Event | Description | |-------|-------------| @@ -175,7 +175,7 @@ Events are categorized by the entity type they affect. Each event contains metad | `run_failed` | Transitions the run to `failed` state with error details and optional error code. | | `run_cancelled` | Transitions the run to `cancelled` state. Can be triggered from `pending` or `running` states. | -### Step Events +### Step events | Event | Description | |-------|-------------| @@ -185,7 +185,7 @@ Events are categorized by the entity type they affect. Each event contains metad | `step_failed` | Transitions the step to `failed` state with error details. The step will not be retried. | | `step_retrying` | (Optional) Transitions the step back to `pending` state for retry. Contains the error that caused the retry and optional delay before the next attempt. When not emitted, retries appear as consecutive `step_started` events. | -### Hook Events +### Hook events | Event | Description | |-------|-------------| @@ -194,14 +194,14 @@ Events are categorized by the entity type they affect. Each event contains metad | `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads. | | `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. | -### Wait Events +### Wait events | Event | Description | |-------|-------------| | `wait_created` | Creates a new wait in `waiting` state. Contains the timestamp when the wait should complete. | | `wait_completed` | Transitions the wait to `completed` state when the delay period has elapsed. | -## Terminal States +## Terminal states Terminal states represent the end of an entity's lifecycle. Once an entity reaches a terminal state, no further events can transition it to another state. @@ -209,7 +209,7 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach - `completed`: Workflow finished successfully - `failed`: Workflow encountered an unrecoverable error -- `cancelled`: Workflow was explicitly cancelled +- `cancelled`: Workflow was explicitly canceled **Step terminal states:** @@ -227,7 +227,7 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach Attempting to create an event that would transition an entity out of a terminal state will result in an error. This prevents inconsistent state and ensures the integrity of the event log. -## Event Correlation +## Event correlation Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance: @@ -243,7 +243,7 @@ This correlation enables: - Building timelines of entity lifecycle transitions - Debugging by tracing the complete history of any entity -### Request ID Correlation +### Request ID correlation Some `World` implementations also attach a `requestId` to events for platform-log correlation. This is different from `correlationId`: @@ -275,6 +275,6 @@ All entities in the Workflow SDK use a consistent ID format: a 4-character prefi **Why this format?** -- **Prefixes enable introspection**: Given any ID, you can immediately identify what type of entity it refers to. This makes debugging, logging, and cross-referencing entities across the system straightforward. +- **Prefixes enable introspection**: Given any ID, you can immediately identify what type of entity it refers to. This helps you debug, log, and cross-reference entities across the system. -- **ULIDs enable chronological ordering**: Unlike UUIDs, ULIDs encode a timestamp in their first 48 bits, making them lexicographically sortable by creation time. This property is essential for the event log—events are always stored and retrieved in the correct chronological order simply by sorting their IDs. +- **ULIDs enable chronological ordering**: Unlike UUIDs, ULIDs encode a timestamp in their first 48 bits, making them lexicographically sortable by creation time. This property is essential for the event log: events are always stored and retrieved in the correct chronological order by sorting their IDs alone. diff --git a/docs/content/docs/v4/how-it-works/framework-integrations.mdx b/docs/content/docs/v4/how-it-works/framework-integrations.mdx index 94e5fc4cad..50f7682153 100644 --- a/docs/content/docs/v4/how-it-works/framework-integrations.mdx +++ b/docs/content/docs/v4/how-it-works/framework-integrations.mdx @@ -10,21 +10,21 @@ related: --- - **For users:** If you just want to use Workflow SDK with an existing framework, check out the [Getting Started](/docs/getting-started) guide instead. This page is for framework authors who want to integrate Workflow SDK with their framework or runtime. + **For users:** If you want to use Workflow SDK with an existing framework, check out the [Getting Started](/docs/getting-started) guide instead. This page is for framework authors who want to integrate Workflow SDK with their framework or runtime. -This guide walks you through building a framework integration for Workflow SDK using Bun as a concrete example. The same principles apply to any JavaScript runtime (Node.js, Deno, Cloudflare Workers, etc.). +Build a framework integration for Workflow SDK using Bun as a concrete example. The same principles apply to JavaScript runtimes such as Node.js, Deno, and Cloudflare Workers. **Prerequisites:** Before building a framework integration, we recommend reading [How the Directives Work](/docs/how-it-works/code-transform) to understand the transformation system that powers Workflow SDK. -## What You'll Build +## What you'll build A framework integration has two main components: -1. **Build-time**: Generate workflow handler files (`flow.js`, `step.js`, `webhook.js`) -2. **Runtime**: Expose these handlers as HTTP endpoints in your application server +1. **Build time**: Generate workflow handler files (`flow.js`, `step.js`, and `webhook.js`). +2. **Runtime**: Expose these handlers as HTTP endpoints in your application server. ```mermaid flowchart TD @@ -46,19 +46,19 @@ flowchart TD style J fill:#a78bfa,stroke:#8b5cf6,color:#000 ``` -The purple boxes are what you implement—everything else is provided by Workflow SDK. +The purple boxes are what you implement; everything else is provided by Workflow SDK. -## Example: Bun Integration +## Example: Bun integration -Let's build a complete integration for Bun. Bun is unique because it serves as both a runtime (needs code transformations) and a framework (provides `Bun.serve()` for HTTP routing). +Build a complete integration for Bun. Bun is unique because it serves as both a runtime (needs code transformations) and a framework (provides `Bun.serve()` for HTTP routing). A working example can be [found here](https://github.com/vercel/workflow-examples/tree/main/custom-adapter). For a production-ready reference, see the [Next.js integration](https://github.com/vercel/workflow/tree/main/packages/next). -### Step 1: Generate Handler Files +### Step 1: Generate handler files -Use the `workflow` CLI to generate the handler bundles. The CLI scans your `workflows/` directory and creates `flow.js`, `step.js`, and `webhook.js`. +Use the Workflow CLI to generate the handler bundles. The CLI scans your `workflows/` directory and creates `flow.js`, `step.js`, and `webhook.js`. ```json title="package.json" { @@ -74,13 +74,13 @@ Use the `workflow` CLI to generate the handler bundles. The CLI scans your `work **What gets generated:** -- `/.well-known/workflow/v1/flow.js` - Handles workflow execution (workflow mode transform) -- `/.well-known/workflow/v1/step.js` - Handles step execution (step mode transform) -- `/.well-known/workflow/v1/webhook.js` - Handles webhook delivery +- `/.well-known/workflow/v1/flow.js`: Handles workflow execution (workflow mode transform). +- `/.well-known/workflow/v1/step.js`: Handles step execution (step mode transform). +- `/.well-known/workflow/v1/webhook.js`: Handles webhook delivery. Each file exports a `POST` function that accepts Web standard `Request` objects. -### Step 2: Add Client Mode Transform (Optional) +### Step 2: Add client mode transform (optional) Client mode transforms your application code to provide better DX. Add a Bun plugin to apply this transformation at runtime: @@ -131,7 +131,7 @@ preload = ["./workflow-plugin.ts"] **Why optional?** Without client mode, you can still use workflows by manually constructing IDs or referencing the build manifest. -### Step 3: Expose HTTP Endpoints +### Step 3: Expose HTTP endpoints Wire up the generated handlers to HTTP endpoints using `Bun.serve()`: @@ -175,15 +175,15 @@ console.log(`Server listening on http://localhost:${server.port}`); **That's it!** Your Bun integration is complete. -## Understanding the Endpoints +## Understanding the endpoints -Your integration must expose three HTTP endpoints. The generated handlers manage all protocol details—you just route requests. +Your integration must expose three HTTP endpoints. The generated handlers manage all protocol details; you only route requests. -### Workflow Endpoint +### Workflow endpoint **Route:** `POST /.well-known/workflow/v1/flow` -Executes workflow orchestration logic. The workflow function is "rendered" multiple times during execution—each time it progresses until it encounters the next step. +Executes workflow orchestration logic. The workflow function is "rendered" multiple times during execution; each time it progresses until it encounters the next step. **Called when:** @@ -192,27 +192,27 @@ Executes workflow orchestration logic. The workflow function is "rendered" multi - Resuming after a webhook or hook triggers - Recovering from failures -### Step Endpoint +### Step endpoint **Route:** `POST /.well-known/workflow/v1/step` Executes individual atomic operations within workflows. Each step runs exactly once per execution (unless retried due to failure). Steps have full runtime access (Node.js APIs, file system, databases, etc.). -### Webhook Endpoint +### Webhook endpoint **Route:** `POST /.well-known/workflow/v1/webhook/:token` Delivers webhook data to running workflows via [`createWebhook()`](/docs/api-reference/workflow/create-webhook). The `:token` parameter identifies which workflow run should receive the data. - The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to leverage App Router's dynamic routing, while other frameworks generate a single `webhook.js` handler. + The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to use App Router's dynamic routing, while other frameworks generate a single `webhook.js` handler. -## Adapting to Other Frameworks +## Adapting to other frameworks The Bun example demonstrates the core pattern. To adapt for your framework: -### Build-Time +### Build-time **Option 1: Use the CLI** (simplest) @@ -261,7 +261,7 @@ class MyFrameworkBuilder extends BaseBuilder { If your framework supports virtual server routes and dev mode watching, make sure to adapt accordingly. Please open a PR to the Workflow SDK if the base builder class is missing necessary functionality. -### Monorepos and Workspace Imports +### Monorepos and workspace imports If your framework integration lives in a subdirectory and your workflows import code from sibling workspace packages, pass `projectRoot` to `BaseBuilder`. Use the smallest directory that contains every workspace package imported by your workflows. @@ -299,7 +299,7 @@ framework.hooks.hook("build:before", async () => { }); ``` -### Runtime (Client Mode) +### Runtime (client mode) Add a loader/plugin for your bundler: @@ -342,7 +342,7 @@ module.exports = { }; ``` -### HTTP Server +### HTTP server Route the three endpoints to the generated handlers. The exact implementation depends on your framework's routing API. @@ -369,7 +369,7 @@ const server = Bun.serve({ }); ``` -Production framework integrations should handle this routing in the plugin instead of leaving it to the user, and this depends on each framework's unique implementaiton. +Production framework integrations should handle this routing in the plugin instead of leaving it to the user. The approach depends on each framework's implementation. Check the Workflow SDK source code for examples of production framework implementations. In the future, the Workflow SDK will emit more routes under the `.well-known/workflow` namespace. @@ -414,22 +414,22 @@ const flowTriggers = [getWorkflowQueueTrigger()]; const stepTriggers = [STEP_QUEUE_TRIGGER]; ``` -If your integration constructs the flow trigger object itself instead of calling `getWorkflowQueueTrigger()`, it must add `maxConcurrency: 1` to that trigger when sequential replays are enabled at build time (`WORKFLOW_SEQUENTIAL_REPLAYS=1` — the exported `isSequentialReplaysEnabled()` helper implements this check). The runtime half of the feature (per-run queue topics) activates from the environment variable alone — without the trigger half, those per-run topics are not serialized and the setting only adds queue-topic cardinality. +If your integration constructs the flow trigger object itself instead of calling `getWorkflowQueueTrigger()`, it must add `maxConcurrency: 1` to that trigger when sequential replays are enabled at build time (`WORKFLOW_SEQUENTIAL_REPLAYS=1`; the exported `isSequentialReplaysEnabled()` helper implements this check). The runtime half of the feature (per-run queue topics) activates from the environment variable alone. Without the trigger half, those per-run topics are not serialized and the setting only adds queue-topic cardinality. ### Custom implementations For self-hosted or non-Vercel deployments, you are responsible for securing the handler endpoints: -- **Framework middleware** — Add authentication (API keys, JWT, OIDC) in front of the `/.well-known/workflow/v1/*` routes -- **Network-level security** — Deploy handlers behind a VPC, private network, or firewall rules so only your queue infrastructure can reach them -- **Rate limiting** — Add request validation and rate limiting to prevent abuse +- **Framework middleware**: Add authentication (API keys, JWT, and OIDC) in front of the `/.well-known/workflow/v1/*` routes. +- **Network-level security**: Deploy handlers behind a VPC, private network, or firewall rules so only your queue infrastructure can reach them. +- **Rate limiting**: Add request validation and rate limiting to prevent abuse. Learn more about [building custom Worlds](/worlds/building-a-world). -## Testing Your Integration +## Testing your integration -### 1. Test Build Output +### 1. Test build output Create a test workflow: @@ -479,11 +479,11 @@ async function sendOnboardingEmail(user: { id: string; email: string }, callback Run your build and verify: -- `.well-known/workflow/v1/flow.js` exists -- `.well-known/workflow/v1/step.js` exists -- `.well-known/workflow/v1/webhook.js` exists +- `.well-known/workflow/v1/flow.js` exists. +- `.well-known/workflow/v1/step.js` exists. +- `.well-known/workflow/v1/webhook.js` exists. -### 2. Test HTTP Endpoints +### 2. Test HTTP endpoints Start your server and verify routes respond: @@ -495,7 +495,7 @@ curl -X POST http://localhost:3000/.well-known/workflow/v1/webhook/test (Should respond but not trigger meaningful code without authentication/proper workflow run) -### 3. Run a Workflow End-to-End +### 3. Run a workflow end-to-end ```typescript import { start } from "workflow/api"; diff --git a/docs/content/docs/v4/how-it-works/understanding-directives.mdx b/docs/content/docs/v4/how-it-works/understanding-directives.mdx index c37e21d97c..aa8d4474d4 100644 --- a/docs/content/docs/v4/how-it-works/understanding-directives.mdx +++ b/docs/content/docs/v4/how-it-works/understanding-directives.mdx @@ -21,7 +21,7 @@ This page explores how directives enable this execution model and the design pri To understand how directives work, let's first understand what workflows and steps are in the Workflow SDK. -## Workflows and Steps Primer +## Workflows and steps primer The Workflow SDK has two types of functions: @@ -66,9 +66,9 @@ This replay mechanism requires deterministic code. If `Math.random()` weren't se For a deeper dive into workflows and steps, see [Workflows and Steps](/docs/foundations/workflows-and-steps).
-## The Core Challenge +## The core challenge -This execution model enables powerful durability features - workflows can suspend for days, survive restarts, and resume from any point. However, it also requires a semantic boundary in the code that tells **the compiler, runtime, and developer** that execution semantics have changed. +This execution model provides durability: workflows can suspend for days, survive restarts, and resume from any point. However, it also requires a semantic boundary in the code that tells **the compiler, runtime, and developer** that execution semantics have changed. The challenge: how do we mark this boundary in a way that: @@ -77,7 +77,7 @@ The challenge: how do we mark this boundary in a way that: 3. Allows static analysis of workflow structure 4. Feels natural to JavaScript developers -Let's look at where directives have been used before, and the alternatives we considered: +Directives have prior uses, and we considered several alternatives: ## Prior art on directives @@ -104,11 +104,11 @@ The `"use workflow"` directive is also used by the Language Server Plugin shippe But we didn't get here immediately. This took some discovery to arrive at: -## Alternatives We Explored +## Alternatives we explored -Before settling on directives, we prototyped several other approaches. Each had significant limitations that made them unsuitable for production use. +Before settling on directives, we prototyped several other approaches. Each had limitations that made them unsuitable for production use. -### Runtime-Only "Suspense" API +### Runtime-only "Suspense" API Our first proof of concept used a wrapper-based API without a build step: @@ -140,7 +140,7 @@ export const myWorkflow = workflow(async () => { }); ``` -This was verbose and easy to forget. Moreover, if a developer forgot to wrap something innocent like using `Date.now()`, it led to unstable runtime behavior. +This was verbose and developers could forget it. If a developer forgot to wrap something like `Date.now()`, it led to unstable runtime behavior. For example: @@ -200,7 +200,7 @@ export const myWorkflow = workflow(async () => { }); ``` -### Generator-Based API +### Generator-based API We explored using generators for explicit suspension points, inspired by libraries like Effect.ts: @@ -220,7 +220,7 @@ We're big fans of [Effect.ts](https://effect.website/) and the power of generato **1. Syntax felt more like a DSL than JavaScript** -Generators require a custom mental model that differs significantly from familiar async/await patterns. The `yield*` syntax and generator delegation were unfamiliar to many developers: +Generators require a custom mental model that differs from familiar async/await patterns. The `yield*` syntax and generator delegation were unfamiliar to many developers: {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -267,7 +267,7 @@ export const myWorkflow = workflow(function*() { The generator syntax addressed suspension but didn't solve the fundamental sandboxing problem. -### File System-Based Conventions +### File system-based conventions We explored using file system conventions to identify workflows and steps, similar to how modern frameworks handle routing (Next.js, Hono, Nitro, SvelteKit): @@ -282,7 +282,7 @@ We explored using file system conventions to identify workflows and steps, simil -With this approach, any function in the `workflows/` directory would be transformed as a workflow, and any function in `steps/` would be a step. No directives needed, just file locations. +With this approach, any function in the `workflows/` directory would be transformed as a workflow, and any function in `steps/` would be a step. File locations would replace directives. **Why this could work:** @@ -308,7 +308,7 @@ The directive approach solved all these issues: it works in any project structur ### Decorators -We considered decorators, but they presented significant challenges both technical and ergonomic. +We considered decorators, but they presented technical and ergonomic challenges. **Decorators are non-yet-standard and class-focused** @@ -349,7 +349,7 @@ While decorators can be handled at compile-time with build tool support, they pr See the [Macro Wrapper](#macro-wrapper-approach) section below for a deeper dive into why this approach breaks down with concrete examples. -### Macro Wrapper Approach +### Macro wrapper approach We also explored compile-time macro approaches - using a compiler to transform wrapper functions or decorators into directive-based code: @@ -385,7 +385,7 @@ export const processOrder = async (orderId: string) => { }; ``` -The benefit is that macros could enforce types and provide "Go To Definition" or other LSP features out of the box. +The benefit is that macros could enforce types and provide "Go To Definition" or other LSP features without additional configuration. However, **the core problem remains: Workflows aren't runtime values** @@ -431,7 +431,7 @@ To detect that `processOrder` is actually a workflow, the compiler would need wh This level of cross-function analysis is impractical for build tools - it would require analyzing every function call chain in your entire codebase and all dependencies. The compiler can only reliably detect direct `useWorkflow` calls, not calls hidden behind abstractions. -## How Directives Solve These Problems +## How directives solve these problems Directives address all the issues we encountered with previous approaches: @@ -527,7 +527,7 @@ export async function processOrder(orderId: string) { The `"use step"` directive maintains consistency. While steps run in the full Node.js runtime and *could* work without a directive, they need some way to signal to the workflow runtime that they're steps. -We could have used a function wrapper just for steps: +We could have used a function wrapper for steps: {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -584,7 +584,7 @@ By requiring explicit `"use step"` directives, developers have fine-grained cont To understand how directives are transformed at compile time, see [How the Code Transform Works](/docs/how-it-works/code-transform). -## What Directives Enable +## What directives enable Because `"use workflow"` defines a compile-time semantic boundary, we can provide: @@ -603,7 +603,7 @@ Because `"use workflow"` defines a compile-time semantic boundary, we can provid -## Directives as a JavaScript Pattern +## Directives as a JavaScript pattern Directives in JavaScript have always been contracts between the developer and the execution environment. `"use strict"` made this pattern familiar - it's a string literal that changes how code is interpreted. @@ -611,7 +611,7 @@ While JavaScript doesn't yet have first-class support for custom directives (lik As TC39 members, we at Vercel are actively working with the standards body and broader ecosystem to explore formal specifications for pragma-like syntax or macro annotations that can express execution semantics. -## Closing Thoughts +## Closing thoughts Directives aren't about syntax preference, they're about expressing semantic boundaries. `"use workflow"` tells the compiler, developer, and runtime that this code is deterministic, resumable, and sandboxed. diff --git a/docs/content/docs/v4/internal/index.mdx b/docs/content/docs/v4/internal/index.mdx index 9192202e37..93f945e6b7 100644 --- a/docs/content/docs/v4/internal/index.mdx +++ b/docs/content/docs/v4/internal/index.mdx @@ -8,10 +8,10 @@ type: overview This page is only visible on preview deployments and local development. It does not appear in production. -## Preview Package +## Preview package -## Draft Changelogs +## Draft changelogs Changelog entries staged here for review before publishing to the Vercel website. There are currently no drafts staged for v4. diff --git a/docs/content/docs/v4/observability/index.mdx b/docs/content/docs/v4/observability/index.mdx index 7e7d29b686..1946ac1619 100644 --- a/docs/content/docs/v4/observability/index.mdx +++ b/docs/content/docs/v4/observability/index.mdx @@ -1,8 +1,8 @@ --- title: Observability -description: Inspect, monitor, and debug workflows through the CLI and Web UI with powerful observability tools. +description: Inspect, monitor, and debug workflows through the CLI and web UI. type: overview -summary: Inspect and debug workflow runs using the CLI and Web UI. +summary: Inspect and debug workflow runs using the CLI and web UI. prerequisites: - /docs/foundations related: @@ -10,9 +10,9 @@ related: - /docs/how-it-works/encryption --- -Workflow SDK provides powerful tools to inspect, monitor, and debug your workflows through the CLI and Web UI. These tools allow you to inspect workflow runs, steps, webhooks, [events](/docs/how-it-works/event-sourcing), and stream output. +Workflow SDK provides a Workflow CLI and web UI to inspect, monitor, and debug workflows. You can inspect workflow runs, steps, webhooks, [events](/docs/how-it-works/event-sourcing), and stream output. -## Quick Start +## Quick start ```bash npx workflow @@ -33,10 +33,10 @@ npx workflow inspect runs ## Web UI Workflow SDK ships with a local web UI for inspecting your workflows. The CLI -will locally serve the Web UI when using the `--web` flag. +serves the web UI locally when you use the `--web` flag. ```bash -# Launch Web UI for visual exploration +# Launch the web UI for visual exploration npx workflow inspect runs --web ``` @@ -44,7 +44,7 @@ npx workflow inspect runs --web To share a link to a specific run without opening a browser, use the `--url` flag. It prints the dashboard deep link to stdout and exits (no browser, no -local server) — useful for scripts, PR comments, or automation. Add `--json` to +local server), which is useful for scripts, PR comments, or automation. Add `--json` to get `{ "url": "..." }`. ```bash @@ -66,9 +66,9 @@ If you're deploying workflows to a production environment, but want to inspect t Backends might require additional configuration. If you're missing environment variables, the World package should provide instructions on how to configure it. -### Vercel Backend +### Vercel backend -To inspect workflows running on Vercel, ensure you're logged in to the Vercel CLI and have linked your project. See [Vercel CLI authentication and project linking docs](https://vercel.com/docs/cli/project-linking) for more information. Then, simply specify the backend as `vercel`. +To inspect workflows running on Vercel, ensure you're logged in to the Vercel CLI and have linked your project. See [Vercel CLI authentication and project linking docs](https://vercel.com/docs/cli/project-linking) for more information. Then, specify the backend as `vercel`. ```bash # Inspect workflows running on Vercel diff --git a/docs/content/docs/v4/testing/index.mdx b/docs/content/docs/v4/testing/index.mdx index ad1f87483f..e6c4b4d30c 100644 --- a/docs/content/docs/v4/testing/index.mdx +++ b/docs/content/docs/v4/testing/index.mdx @@ -3,18 +3,18 @@ title: Testing description: Unit test individual steps and integration test entire workflows using Vitest. --- -Testing is a critical part of building reliable workflows. Because steps are just functions annotated with directives, they can be unit tested like any other JavaScript function. Workflow SDK also provides a Vitest plugin that runs full workflows in-process — no running server required. +Test steps like any other JavaScript function, or use the Workflow SDK Vitest plugin to run complete workflows in-process without a server. This guide covers two approaches: -1. **Unit testing** - Test individual steps as plain functions, without the workflow runtime. -2. **Integration testing** - Test entire workflows in-process using the `workflow()` Vitest plugin. Required when you want to test workflow specific code paths, like those using [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), retries, etc. +1. **Unit testing**: Test individual steps as plain functions without the workflow runtime. +2. **Integration testing**: Test entire workflows in-process using the `workflow()` Vitest plugin. Use integration tests for workflow-specific code paths that use [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), or retries. -## Unit Testing Steps +## Unit testing steps -Without the workflow compiler, the `"use step"` directive is a no-op. Your step functions run as regular JavaScript functions, making them straightforward to unit test with no special configuration. +Without the workflow compiler, the `"use step"` directive is a no-op. Your step functions run as regular JavaScript functions, so you can unit test them without special configuration. -### Example Steps +### Example steps Given a workflow file with step functions like this: @@ -49,7 +49,7 @@ export async function sendOnboardingEmail(user: { id: string; email: string }) { } ``` -### Writing Unit Tests for Steps +### Writing unit tests for steps You can import and test step functions directly with Vitest. No special configuration or workflow plugin is needed: @@ -77,18 +77,18 @@ describe("sendWelcomeEmail step", () => { This approach is ideal for verifying the business logic inside individual steps in isolation. -Unit testing works well for individual steps. A simple workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features. +Unit testing works well for individual steps. A workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features. -## Integration Testing with the Vitest Plugin +## Integration testing with the Vitest plugin -For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically — it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required. +For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically: it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required. -`vi.mock()` and related calls do _not_ work inside workflow functions, only step functions. Your workflow functions cannot import third party code that needs to be mocked. Mocking works for npm packages imported in step functions. If something needs to be mocked, it likely belongs inside a step function either way. +`vi.mock()` and related calls do _not_ work inside workflow functions, only step functions. Your workflow functions cannot import third-party code that needs to be mocked. Mocking works for npm packages imported in step functions. If something needs to be mocked, it likely belongs inside a step function either way. -### Vitest Configuration +### Vitest configuration Create a separate Vitest config for integration tests that includes the `workflow()` plugin: @@ -107,15 +107,15 @@ export default defineConfig({ That's it. The plugin automatically: -1. Transforms `"use workflow"` and `"use step"` directives via SWC -2. Builds workflow and step bundles before tests run -3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker — all workflow data is cleared automatically between test files for full isolation +1. Transforms `"use workflow"` and `"use step"` directives via SWC. +2. Builds workflow and step bundles before tests run. +3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker; all workflow data is cleared automatically between test files for full isolation. Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above. -### Writing Integration Tests +### Writing integration tests Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returns) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails): @@ -144,9 +144,9 @@ describe("calculateWorkflow", () => { }); ``` -### Testing Hooks and Waits +### Testing hooks and waits -The real power of integration testing comes when testing workflow-only features. Hooks and waits can be resumed programmatically using the [`workflow/api`](/docs/api-reference/workflow-api) functions, making it straightforward to simulate external events in your tests. +Integration testing is most useful for workflow-only features. You can resume hooks and waits programmatically using the [`workflow/api`](/docs/api-reference/workflow-api) functions to simulate external events in your tests. Given a workflow that waits for approval via a hook, then sleeps before publishing: @@ -228,7 +228,7 @@ describe("approvalWorkflow", () => { reviewer: "bob", }); - // No wakeUp() needed here — the rejected path has no sleep + // No wakeUp() needed here; the rejected path has no sleep const result = await run.returnValue; expect(result).toEqual({ status: "rejected", @@ -243,12 +243,12 @@ describe("approvalWorkflow", () => { -`waitForSleep()` returns the first **pending** sleep — one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it. +`waitForSleep()` returns the first **pending** sleep, one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it. -### Testing Webhooks +### Testing webhooks -Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload — no HTTP server needed: +Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload, with no HTTP server needed: ```typescript title="workflows/ingest.ts" lineNumbers import { createWebhook } from "workflow"; @@ -303,7 +303,7 @@ describe("ingestWorkflow", () => { }); ``` -### Manual Setup +### Manual setup If you need more control over the test lifecycle, the plugin also exports the individual setup functions: @@ -354,11 +354,11 @@ afterAll(async () => { For advanced setups that require a running server (e.g. testing against your actual framework's HTTP layer), see [Server-based integration testing](/docs/testing/server-based). -## Debugging Test Runs +## Debugging test runs -When integration tests fail, the [Workflow SDK CLI and Web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you would use in development. +When integration tests fail, the [Workflow CLI and web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you use in development. -Launch the Web UI to visually explore your test workflow runs: +Launch the web UI to explore your test workflow runs: ```bash npx workflow web @@ -374,46 +374,46 @@ npx workflow inspect runs npx workflow inspect run ``` -The Web UI shows each step, its inputs and outputs, retry attempts, hook state, and timing. This is especially useful for diagnosing issues with hooks that were not resumed, steps that failed unexpectedly, or workflows that timed out. +The web UI shows each step, its inputs and outputs, retry attempts, hook state, and timing. Use it to diagnose hooks that were not resumed, steps that failed unexpectedly, or workflows that timed out. ![Workflow SDK Web UI](/o11y-ui.png) -See the [Observability](/docs/observability) docs for the full set of CLI commands and Web UI features. +See the [Observability](/docs/observability) docs for the full set of CLI commands and web UI features. -## Best Practices +## Best practices -### Separate Unit and Integration Tests +### Separate unit and integration tests Keep two test configurations: -- **Unit tests** - Standard Vitest config, no workflow plugin. Fast, no infrastructure required. -- **Integration tests** - Vitest config with `workflow()` plugin. Tests the full workflow lifecycle including hooks, sleeps, and retries. +- **Unit tests**: Standard Vitest config with no workflow plugin. These tests require no infrastructure. +- **Integration tests**: Vitest config with the `workflow()` plugin. These tests cover the full workflow lifecycle, including hooks, sleeps, and retries. -### Use Custom Hook Tokens for Deterministic Testing +### Use custom hook tokens for deterministic testing -When testing workflows with hooks, use [custom tokens](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) based on predictable values (like document IDs or test identifiers). This makes it easy to resume the correct hook in your test code. +When testing workflows with hooks, use [custom tokens](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) based on predictable values (like document IDs or test identifiers). This lets you resume the correct hook in your test code. -### Set Appropriate Timeouts +### Set appropriate timeouts Workflows may take longer to execute than typical unit tests, especially when they involve multiple steps or retries. Set a generous `testTimeout` in your integration test config. -### Test Error and Retry Scenarios +### Test error and retry scenarios Integration tests are the right place to verify that your workflows handle errors correctly, including retryable errors, fatal errors, and timeout scenarios. -## Further Reading - -- [Hooks & Webhooks](/docs/foundations/hooks) - Pausing and resuming workflows with external data -- [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows programmatically -- [`resumeHook()` API Reference](/docs/api-reference/workflow-api/resume-hook) - Resume hooks with data -- [`resumeWebhook()` API Reference](/docs/api-reference/workflow-api/resume-webhook) - Resume webhooks with Request objects -- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Check workflow run status and wake up sleeping runs -- [`@workflow/vitest` API Reference](/docs/api-reference/vitest) - Test helpers: `waitForSleep()`, `waitForHook()`, and plugin setup -- [Vite Integration](/docs/getting-started/vite) - Set up the Vite plugin -- [Observability](/docs/observability) - Inspect and debug workflow runs with the CLI and Web UI -- [Server-based testing](/docs/testing/server-based) - Integration testing with a running server +## Further reading + +- [Hooks & Webhooks](/docs/foundations/hooks): Pause and resume workflows with external data. +- [`start()` API reference](/docs/api-reference/workflow-api/start): Start workflows programmatically. +- [`resumeHook()` API reference](/docs/api-reference/workflow-api/resume-hook): Resume hooks with data. +- [`resumeWebhook()` API reference](/docs/api-reference/workflow-api/resume-webhook): Resume webhooks with `Request` objects. +- [`getRun()` API reference](/docs/api-reference/workflow-api/get-run): Check workflow run status and wake up sleeping runs. +- [`@workflow/vitest` API reference](/docs/api-reference/vitest): Use the `waitForSleep()` and `waitForHook()` test helpers and configure the plugin. +- [Vite integration](/docs/getting-started/vite): Set up the Vite plugin. +- [Observability](/docs/observability): Inspect and debug workflow runs with the CLI and web UI. +- [Server-based testing](/docs/testing/server-based): Test integrations with a running server. --- diff --git a/docs/content/docs/v4/testing/server-based.mdx b/docs/content/docs/v4/testing/server-based.mdx index ef803376bc..a2ea006e4a 100644 --- a/docs/content/docs/v4/testing/server-based.mdx +++ b/docs/content/docs/v4/testing/server-based.mdx @@ -9,9 +9,9 @@ The [Vitest plugin](/docs/testing#integration-testing-with-the-vitest-plugin) ru - Reproducing behavior that only occurs in a specific framework's runtime (e.g. Next.js, Nitro) - Testing webhook endpoints that receive real HTTP requests -This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point — customize the server setup to match your own deployment environment. +This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point; customize the server setup to match your own deployment environment. -## Vitest Configuration +## Vitest configuration Create a Vitest config with the `workflow()` Vite plugin for code transforms and a `globalSetup` script that manages the server lifecycle: @@ -36,7 +36,7 @@ export default defineConfig({ Note the import path: `workflow/vite` (not `@workflow/vitest`). The Vite plugin handles code transforms but does not set up in-process execution. The server handles workflow execution instead. -## Global Setup Script +## Global setup script The `globalSetup` script starts a dev server before tests run and tears it down afterwards. This example uses [Nitro](https://v3.nitro.build), but you can use any server framework that supports the workflow runtime. @@ -154,9 +154,9 @@ The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends st You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server. -## Writing Tests +## Writing tests -Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs — [`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) — to control workflow execution: +Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs ([`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run)) to control workflow execution: ```typescript title="workflows/calculate.server.test.ts" lineNumbers import { describe, it, expect } from "vitest"; @@ -199,10 +199,10 @@ describe("approvalWorkflow", () => { ``` -In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly — you may need to add short delays or polling to ensure the workflow has reached the desired state before resuming. +In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly. You may need to add short delays or polling to ensure the workflow has reached the desired state before resuming. -## Running Tests +## Running tests Add a script to your `package.json`: @@ -215,12 +215,12 @@ Add a script to your `package.json`: } ``` -## When to Use This Approach +## When to use this approach | Scenario | Recommended approach | | --- | --- | | Testing workflow logic, steps, hooks, retries | [In-process plugin](/docs/testing) | | Testing HTTP middleware or authentication | Server-based | | Testing webhook endpoints with real HTTP | Server-based | -| CI/CD pipeline testing | [In-process plugin](/docs/testing) | +| Continuous integration and continuous delivery (CI/CD) pipeline testing | [In-process plugin](/docs/testing) | | Reproducing framework-specific behavior | Server-based | diff --git a/docs/content/docs/v5/ai/chat-session-modeling.mdx b/docs/content/docs/v5/ai/chat-session-modeling.mdx index 4a41e3b389..c4ef15dc05 100644 --- a/docs/content/docs/v5/ai/chat-session-modeling.mdx +++ b/docs/content/docs/v5/ai/chat-session-modeling.mdx @@ -22,7 +22,7 @@ Chat sessions in AI agents can be modeled at different layers of your architectu While there are many ways to model chat sessions, the two most common categories are single-turn and multi-turn. -## Single-Turn Workflows +## Single-turn workflows Each user message triggers a new workflow run. The client or API route owns the conversation history and sends the full message array with each request. @@ -81,7 +81,7 @@ export async function POST(req: Request) { -Chat messages need to be stored somewhere—typically a database. In this example, we assume a route like `/chats/:id` passes the session ID, allowing us to fetch existing messages and persist new ones. +Chat messages need to be stored somewhere, typically a database. In this example, we assume a route like `/chats/:id` passes the session ID, allowing us to fetch existing messages and persist new ones. ```typescript title="app/chats/[id]/page.tsx" lineNumbers "use client"; @@ -138,14 +138,13 @@ This is the pattern used in the [Building Durable AI Agents](/docs/ai) guide. In this pattern, the client owns conversation state, with the latest turn managed by the AI SDK's `useChat`, and past turns persisted to a user-managed database. -Persisting the turn is usually done through either: +Persist the turn through one of these methods: -- A step on the workflow that runs after `agent.stream()` and takes the message history from the agent return value (either `messages: ModelMessage[]` or `uiMessages: UIMessage[]`) -- A hook on `useChat`in the client that calls an API to persist state (or localStorage, etc.), either on every new message, or `onFinish` -- The resumable stream attached to the workflow (see [Resumable Streams](/docs/ai/resumable-streams)) - - Note that user messages are not persisted to the stream by default, and need to be explicitly persisted separately +- Run a workflow step after `agent.stream()` that takes the message history from the agent return value (either `messages: ModelMessage[]` or `uiMessages: UIMessage[]`). +- Use a `useChat` client hook that calls an API to persist state, such as on every new message or in `onFinish`. +- Use the resumable stream attached to the workflow (see [Resumable streams](/docs/ai/resumable-streams)). User messages are not persisted to the stream by default, so persist them separately. -## Multi-Turn Workflows +## Multi-turn workflows A single workflow handles the entire conversation session across multiple turns, and owns the current conversation state. The clients/API routes inject new messages via hooks. The workflow run ID serves as the session identifier. @@ -191,7 +190,7 @@ export async function chat(initialMessages: UIMessage[]) { tools: flightBookingTools, }); - // Use run ID as the hook token for easy resumption + // Use run ID as the hook token for resumption const hook = chatMessageHook.create({ token: runId }); let turnNumber = 0; @@ -254,7 +253,7 @@ export async function writeStreamClose(writable: WritableStream) -Three endpoints: start a session, send follow-up messages, and reconnect to the stream. +Use three endpoints to start a session, send follow-up messages, and reconnect to the stream. ```typescript title="app/api/chat/route.ts" lineNumbers import { createUIMessageStreamResponse, type UIMessage } from "ai"; @@ -493,13 +492,13 @@ In this pattern, the workflow owns the entire conversation session. All messages The client hook processes these markers by: -1. Iterating through message parts in order -2. When a `user-message` marker is found, flushing any accumulated assistant content and inserting the user message -3. Deduplicating against optimistic sends from the initial message +1. Iterate through message parts in order. +2. When a `user-message` marker is found, flush any accumulated assistant content and insert the user message. +3. Deduplicate against optimistic sends from the initial message. This ensures the conversation displays as User → AI → User → AI regardless of whether viewing live or replaying from the stream. -## Choosing a Pattern +## Choosing a pattern | Consideration | Single-Turn | Multi-Turn | |--------------|-------------|------------| @@ -509,13 +508,13 @@ This ensures the conversation displays as User → AI → User → AI regardless | Workflow time horizon | Minutes | Hours to indefinitely | | Observability scope | Per-turn traces | Full session traces | -**Multi-turn is recommended for most production use-cases.** If you're starting fresh, go with multi-turn. It's more flexible and grows with your requirements. You don't need to maintain the chat history yourself and can offload all that to the workflow's built in persistence. It also enables native message injection and full session observability, which becomes increasingly valuable as your agent matures. +**Multi-turn is recommended for most production use cases.** For new applications, use multi-turn workflows. The workflow's built-in persistence maintains the chat history and supports native message injection and full-session observability. -**Single-turn works well when adapting existing architectures.** If you already have a system for managing message state, and want to adopt durable agents incrementally, single-turn workflows slot in with minimal changes. Each turn maps cleanly to an independent workflow run. +**Single-turn works well when adapting existing architectures.** If you already have a system for managing message state and want to adopt durable agents incrementally, single-turn workflows require fewer changes. Each turn maps to an independent workflow run. -## Multiplayer Chat Sessions +## Multiplayer chat sessions -The multi-turn pattern also easily enables multi-player chat sessions. New messages can come from system events, external services, and other users. Since a `hook` injects messages into workflow at any point, and the entire history is a single stream that clients can reconnect to, it doesn't matter where the injected messages come from. Here are different use-cases for multi-player chat sessions: +The multi-turn pattern also enables multiplayer chat sessions. Messages can come from system events, external services, and other users. A `hook` can inject messages into a workflow at any point, while clients reconnect to one stream containing the entire history. @@ -542,7 +541,7 @@ export async function POST(req: Request) { -External webhooks from third-party services (Stripe, Twilio, etc.) can notify the conversation of events. +External webhooks from third-party services, such as Stripe and Twilio, can notify the conversation of events. ```typescript title="app/api/webhooks/payment/route.ts" lineNumbers import { chatMessageHook } from "@/workflows/chat/hooks/chat-message"; @@ -591,9 +590,9 @@ export async function POST( -## Related Documentation +## Related documentation -- [Building Durable AI Agents](/docs/ai) - Foundation guide for durable agents -- [Message Queueing](/docs/ai/message-queueing) - Queueing messages during tool execution -- [`defineHook()` API Reference](/docs/api-reference/workflow/define-hook) - Hook configuration options -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI SDK API for durable, resumable agents +- [Building Durable AI Agents](/docs/ai): Foundation guide for durable agents +- [Message Queueing](/docs/ai/message-queueing): Queueing messages during tool execution +- [`defineHook()` API reference](/docs/api-reference/workflow/define-hook): Hook configuration options +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK API for durable, resumable agents diff --git a/docs/content/docs/v5/ai/defining-tools.mdx b/docs/content/docs/v5/ai/defining-tools.mdx index 23027d1996..59f0ea0cf1 100644 --- a/docs/content/docs/v5/ai/defining-tools.mdx +++ b/docs/content/docs/v5/ai/defining-tools.mdx @@ -14,11 +14,11 @@ related: This page covers the details for some common patterns when defining tools for AI agents using Workflow SDK. -Using WorkflowAgent, we model most tools as steps. These can be anything from a simple function call to a entire multi-day long workflow. +Using WorkflowAgent, we model most tools as steps. These can range from a single function call to an entire multi-day workflow. ## Accessing message context in tools -Just like in regular AI SDK tool definitions, tool in WorkflowAgent are called with a first argument of the tool's input parameters, and a second argument of the tool call context. +As with regular AI SDK tool definitions, tools in WorkflowAgent receive the tool's input parameters as the first argument and the tool call context as the second. When you tool needs access to the full message history, you can access it via the `messages` property of the tool call context: @@ -33,9 +33,9 @@ async function getWeather( } ``` -## Writing to Streams +## Writing to streams -As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step just to call `getWritable()` for writing custom data parts to the stream. +As discussed in [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools), it's common to use a step only to call `getWritable()` for writing custom data parts to the stream. This can be made generic, by creating a helper step function to write arbitrary data to the stream: @@ -52,7 +52,7 @@ async function writeToStream(data: any) { } ``` -## Step-Level vs Workflow-Level Tools +## Step-level vs workflow-level tools Tools can be implemented either at the step level or the workflow level, with different capabilities and constraints. diff --git a/docs/content/docs/v5/ai/human-in-the-loop.mdx b/docs/content/docs/v5/ai/human-in-the-loop.mdx index ec79651cf8..253c005924 100644 --- a/docs/content/docs/v5/ai/human-in-the-loop.mdx +++ b/docs/content/docs/v5/ai/human-in-the-loop.mdx @@ -19,7 +19,7 @@ Workflow SDK's [webhook](/docs/api-reference/workflow/create-webhook) and [hook] If you need to react to external events programmatically, see the [hooks](/docs/foundations/hooks) documentation for more information. This part of the guide will focus on the human-in-the-loop pattern, which is a subset of the more general hook pattern. -## How It Works +## How it works @@ -45,9 +45,9 @@ The workflow receives the approval data and resumes execution. -While this demo will use a client side button for human approval, you could just as easily create a webhook and send the approval link over email or slack to resume the agent. +While this demo uses a client-side button for human approval, you could instead create a webhook and send the approval link over email or Slack to resume the agent. -## Creating a Booking Approval Tool +## Creating a booking approval tool Add a tool that allows the agent to deliberately pause execution until a human approves or rejects a flight booking: @@ -55,7 +55,7 @@ Add a tool that allows the agent to deliberately pause execution until a human a -### Define the Hook +### Define the hook Create a typed hook with a Zod schema for validation: @@ -78,7 +78,7 @@ export const bookingApprovalHook = defineHook({ -### Implement the Tool +### Implement the tool Create a tool that creates a hook instance using the tool call ID as the token. The UI will use this ID to submit the approval. @@ -126,14 +126,14 @@ export const flightBookingTools = { ``` -Note that the `defineHook().create()` function must be called from within a workflow context, not from within a step. This is why `executeBookingApproval` does not have `"use step"` - it runs in the workflow context where hooks are available. +Call `defineHook().create()` from within a workflow context, not from within a step. `executeBookingApproval` does not have `"use step"` because it runs in the workflow context where hooks are available. -### Create the API Route +### Create the API route Create a new API endpoint that the UI will call to submit the approval decision: @@ -158,7 +158,7 @@ export async function POST(request: Request) { -### Create the Approval Component +### Create the approval component Build a new component that reacts to the tool call data, and allows the user to approve or reject the booking: @@ -253,7 +253,7 @@ export function BookingApproval({ toolCallId, input, output }: BookingApprovalPr -### Show the Tool Status in the UI +### Show the tool status in the UI Use the component we just created to render the tool call and approval controls in your chat interface: @@ -332,7 +332,7 @@ export default function ChatPage() {
-## Using Webhooks Directly +## Using webhooks directly For simpler cases where you don't need type-safe validation or programmatic resumption, you can use [`createWebhook()`](/docs/api-reference/workflow/create-webhook) directly. This generates a unique URL that can be called to resume the workflow: @@ -367,7 +367,7 @@ The webhook URL can be called directly with a POST request containing the approv - Payment provider callbacks - Email-based approval links -## Related Documentation +## Related documentation - [Hooks & Webhooks](/docs/foundations/hooks) - Complete guide to hooks and webhooks - [`createWebhook()` API Reference](/docs/api-reference/workflow/create-webhook) - Webhook configuration options diff --git a/docs/content/docs/v5/ai/index.mdx b/docs/content/docs/v5/ai/index.mdx index da35f4ac41..744e5beeca 100644 --- a/docs/content/docs/v5/ai/index.mdx +++ b/docs/content/docs/v5/ai/index.mdx @@ -22,7 +22,7 @@ Workflow SDK makes your agents production-ready, by turning them into durable, r This guide walks you through converting a basic AI chat app into a durable AI agent using Workflow SDK. -## Why Durable Agents? +## Why durable agents? Aside from the usual challenges of getting your long-running tasks to be production-ready, building mature AI agents typically requires solving several **additional challenges**: @@ -31,22 +31,22 @@ Aside from the usual challenges of getting your long-running tasks to be product - **Resumability**: Resuming streams requires not just storing your messages, but also storing streams, and piping them across services. - **Human-in-the-loop**: Your client, API, and async job orchestration need to work together to create, track, route to, and display human approval requests, or similar webhook operations. -Workflow SDK provides all of these capabilities out of the box. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure. +Workflow SDK provides all of these capabilities without additional infrastructure. Your agent becomes a workflow, your tools become steps, and the framework handles interplay with your existing infrastructure. -## Getting Started +## Getting started To make an Agent durable, we first need an Agent, which we'll be setting up here. If you already have an app you'd like to follow along with, you can skip this section. -For our example, we'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. We'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. +For our example, we'll need an app with a basic chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. We'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. ### Clone example app -We'll need an app with a simple chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. For the follow-along steps, we'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. +We'll need an app with a basic chat interface and an API route calling an LLM, so that we can add Workflow SDK to it. For the follow-along steps, we'll use the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example as a starting point, which comes with a chat interface built using Next.js, AI SDK, and Shadcn UI. -If you have your own project, you can skip this step, and simply apply the changes of the following steps to your own project. +If you have your own project, skip this step and apply the changes in the following steps to your project. ```bash git clone https://github.com/vercel/workflow-examples -b plain-ai-sdk @@ -59,7 +59,7 @@ cd workflow-examples/flight-booking-app ### Set up API keys -In order to connect to an LLM, we'll need to set up an API key. The easiest way to do this is to use Vercel Gateway (works with all providers at zero markup), or you can configure a custom provider. +To connect to an LLM, set up an API key. You can use Vercel Gateway, which works with all providers at zero markup, or configure a custom provider. @@ -113,15 +113,15 @@ export async function POST(req: Request) { ### Get familiar with the code -Let's take a moment to see what we're working with. Run the app with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. You should see a simple chat interface to play with. Go ahead and give it a try. +Run the app with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. You should see a basic chat interface to test. -The core code that makes all of this happen is quite simple. Here's a breakdown of the main parts. Note that there's no changes needed here, we're simply taking a look at the code to understand what's happening. +The following sections break down the core code. You don't need to make changes yet. -Our API route makes a simple call to [AI SDK's `ToolLoopAgent` class](https://ai-sdk.dev/docs/agents/overview), which encapsulates the LLM call, tool execution loop, and stopping conditions on top of [AI SDK's `streamText` function](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#streamtext). This is also where we pass tools to the agent. +Our API route calls [AI SDK's `ToolLoopAgent` class](https://ai-sdk.dev/docs/agents/overview), which encapsulates the LLM call, tool execution loop, and stopping conditions on top of [AI SDK's `streamText` function](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text#streamtext). This is also where we pass tools to the agent. ```typescript title="app/api/chat/route.ts" lineNumbers import { ToolLoopAgent } from "ai"; @@ -170,7 +170,7 @@ async function searchFlights({ from, to, date }: { from: string; to: string; dat -Our `ChatPage` component has a lot of logic for nicely displaying the chat messages, but at it's core, it's simply managing input/output for the [`useChat` hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) from AI SDK. +Our `ChatPage` component contains logic for displaying chat messages, but its core responsibility is managing input and output for the [`useChat` hook](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat#usechat) from AI SDK. ```typescript title="app/chat.tsx" lineNumbers "use client"; @@ -228,7 +228,7 @@ Now that we have a basic agent using AI SDK, we can modify it to make it durable -### Install Dependencies +### Install dependencies Add the Workflow SDK packages to your project: @@ -253,7 +253,7 @@ export default withWorkflow(nextConfig); -### Create a Workflow Function +### Create a workflow function Move the agent logic into a separate function, which will serve as our workflow definition. @@ -272,7 +272,7 @@ export async function chatWorkflow(messages: UIMessage[]) { const agent = new WorkflowAgent({ // [!code highlight] - // If using AI Gateway, just specify the model name as a string: + // If using AI Gateway, specify the model name as a string: model: "bedrock/claude-4-5-haiku-20251001-v1", // [!code highlight] // ELSE if using a custom provider, pass the provider call as an argument: @@ -300,7 +300,7 @@ Key changes: -### Update the API Route +### Update the API route Remove the agent call that we just extracted, and replace it with a call to `start()` to run the workflow: @@ -330,7 +330,7 @@ Key changes: -### Convert Tools to Steps +### Convert tools to steps Mark all tool definitions with `"use step"` to make them durable. This enables automatic retries and observability for each tool call: @@ -389,7 +389,7 @@ With `"use step"`: -That's all you need to do to convert your basic AI SDK agent into a durable agent. If you run your development server, and send a chat message, you should see your agent respond just as before, but now with added durability and observability. +Your basic AI SDK agent is now durable. Run your development server and send a chat message. The agent should respond as before, with added durability and observability. ## Observability @@ -401,7 +401,7 @@ npx workflow web This opens a local dashboard showing all workflow runs and their status, as well as a trace viewer to inspect the workflow in detail, including retry attempts, and the data being passed between steps. -## Next Steps +## Next steps Now that you have a basic durable agent, it's a only a short step to add these additional features: @@ -420,11 +420,11 @@ Now that you have a basic durable agent, it's a only a short step to add these a -## Complete Example +## Complete example A complete example that includes all of the above, plus all of the "next steps" features is available on the main branch of the [Flight Booking Agent](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) example. -## Related Documentation +## Related documentation - [Tools](/docs/ai/defining-tools) - Patterns for defining tools for your agent - [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI SDK API for durable, resumable agents diff --git a/docs/content/docs/v5/ai/message-queueing.mdx b/docs/content/docs/v5/ai/message-queueing.mdx index 48ace15017..00aa147874 100644 --- a/docs/content/docs/v5/ai/message-queueing.mdx +++ b/docs/content/docs/v5/ai/message-queueing.mdx @@ -15,7 +15,7 @@ When using [multi-turn workflows](/docs/ai/chat-session-modeling#multi-turn-work `WorkflowAgent`'s `prepareStep` callback enables this by running before each step in the agent loop, giving you a chance to inject queued messages into the conversation. `prepareStep` also allows you to modify the model choice and existing messages mid-turn, see AI SDK's [prepareStep callback](https://ai-sdk.dev/docs/agents/loop-control#prepare-step) for more details. -## When to Use This +## When to use this Message queueing is useful when: @@ -24,10 +24,10 @@ Message queueing is useful when: - You want messages to influence the agent's next step rather than waiting for the current turn to complete -If you just need basic multi-turn conversations where messages arrive between turns, see [Chat Session Modeling](/docs/ai/chat-session-modeling). This guide covers the more advanced case of injecting messages *during* turns. +If you need basic multi-turn conversations where messages arrive between turns, see [Chat Session Modeling](/docs/ai/chat-session-modeling). This guide covers the more advanced case of injecting messages *during* turns. -## The `prepareStep` Callback +## The `prepareStep` callback The `prepareStep` callback runs before each step in the agent loop. It receives the current state and can modify the messages sent to the model: @@ -47,7 +47,7 @@ interface PrepareStepResult { } ``` -## Injecting Queued Messages +## Injecting queued messages Once you have a [multi-turn workflow](/docs/ai/chat-session-modeling#multi-turn-workflows), you can combine a message queue with `prepareStep` to inject messages that arrive during processing: @@ -106,7 +106,7 @@ Messages sent via `chatMessageHook.resume()` accumulate in the queue and get inj The `prepareStep` callback receives messages in `ModelMessage[]` format (with content arrays), which is the internal format used by the AI SDK. -## Combining with Multi-Turn Sessions +## Combining with multi-turn sessions You can also combine message queueing with the standard multi-turn pattern: @@ -169,7 +169,7 @@ export async function chat(initialMessages: ModelMessage[]) { } ``` -## Related Documentation +## Related documentation - [Chat Session Modeling](/docs/ai/chat-session-modeling) - Single-turn vs multi-turn patterns - [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents diff --git a/docs/content/docs/v5/ai/resumable-streams.mdx b/docs/content/docs/v5/ai/resumable-streams.mdx index 55f8f49e93..738d49ae19 100644 --- a/docs/content/docs/v5/ai/resumable-streams.mdx +++ b/docs/content/docs/v5/ai/resumable-streams.mdx @@ -13,14 +13,14 @@ related: --- -`WorkflowChatTransport` now ships in AI SDK as a 1:1 port — import it from `@ai-sdk/workflow` (the `@workflow/ai` export is deprecated). See [Resumable Streaming with `WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) for the full reference. +`WorkflowChatTransport` now ships in AI SDK as a 1:1 port, so import it from `@ai-sdk/workflow` (the `@workflow/ai` export is deprecated). See [Resumable Streaming with `WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) for the full reference. -When building chat interfaces, it's common to run into network interruptions, page refreshes, or serverless function timeouts, which can break the connection to an in-progress agent. +Network interruptions, page refreshes, or Vercel Functions timeouts can break a chat interface's connection to an in-progress agent. -Where a standard chat implementation would require the user to resend their message and wait for the entire response again, workflow runs are durable, and so are the streams attached to them. This means a stream can be resumed at any point, optionally only syncing the data that was missed since the last connection. +Workflow runs and their attached streams are durable, so users can resume a stream without resending a message or waiting for the entire response again. The client can optionally sync only the data missed since the last connection. -Resumable streams come out of the box with Workflow SDK, however, the client needs to recognize that a stream exists, and needs to know which stream to reconnect to, and needs to know where to start from. For this, Workflow SDK provides the [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) helper, a drop-in transport for the AI SDK that handles client-side resumption logic for you. +Workflow SDK supports resumable streams, but the client must identify the stream and the position from which to reconnect. The [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) helper is a drop-in AI SDK transport that handles this client-side resumption logic. When deploying a streaming route to Vercel, enable request cancellation so a browser disconnect terminates that route's abandoned stream reader instead of letting the function run until `FUNCTION_INVOCATION_TIMEOUT`. See [Avoiding Function Timeouts After Client Disconnects](/docs/foundations/streaming#avoiding-function-timeouts-after-client-disconnects). @@ -28,15 +28,15 @@ When deploying a streaming route to Vercel, enable request cancellation so a bro ## Implementing stream resumption -Let's add stream resumption to our Flight Booking Agent that we build in the [Building Durable AI Agents](/docs/ai) guide. +Add stream resumption to the Flight Booking Agent from the [Building Durable AI Agents](/docs/ai) guide. -### Return the Run ID from Your API +### Return the run ID from your API -Modify your chat endpoint to include the workflow run ID in a response header. The Run ID uniquely identifies the run's stream, so it allows the client to know which stream to reconnect to. +Modify your chat endpoint to include the workflow run ID in a response header. The run ID uniquely identifies the stream so the client knows which stream to reconnect to. {/*@skip-typecheck: incomplete code sample*/} @@ -62,9 +62,9 @@ export async function POST(req: Request) { -### Add a Stream Reconnection Endpoint +### Add a stream reconnection endpoint -Currently we only have one API endpoint that always creates a new run, so we need to create a new API route that returns the stream for an existing run: +Create an API route that returns the stream for an existing run. The current endpoint always creates a new run. ```typescript title="app/api/chat/[id]/stream/route.ts" lineNumbers import { createUIMessageStreamResponse } from "ai"; @@ -100,16 +100,16 @@ export async function GET( } ``` -The `startIndex` parameter ensures the client can choose where to resume the stream from. For instance, if the function times out during streaming, the chat transport will use `startIndex` to resume the stream exactly from the last token it received. Negative values are also supported (e.g. `-5` starts 5 chunks before the end), which is useful for custom stream consumers (such as a dashboard showing recent output) that want to show the most recent output without replaying the full stream. +The `startIndex` parameter lets the client choose where to resume the stream. For example, if the function times out during streaming, the chat transport uses `startIndex` to resume from the last token it received. Negative values are also supported. A value of `-5` starts 5 chunks before the end, which is useful for custom stream consumers that show recent output without replaying the full stream. -When using a negative `startIndex`, your stream endpoint must return a `x-workflow-stream-tail-index` header in order for relative resumption to work. Missing the header will fall back to replaying the entire stream. +When using a negative `startIndex`, your stream endpoint must return an `x-workflow-stream-tail-index` header for relative resumption. If the header is missing, the transport replays the entire stream. -### Use `WorkflowChatTransport` in the Client +### Use `WorkflowChatTransport` in the client -Replace the default transport in AI-SDK's `useChat` with [`WorkflowChatTransport`]( +Replace the default transport in AI SDK's `useChat` with [`WorkflowChatTransport`]( /docs/api-reference/workflow-ai/workflow-chat-transport ), and update the callbacks to store and use the latest run ID. For now, we'll store the run ID in localStorage. For your own app, this would be stored wherever you store session information. @@ -166,23 +166,23 @@ export default function ChatPage() { -Now try the flight booking example again. Open it up in a separate tab, or spam the refresh button, and see how the client connects to the same chat stream every time. +Open the flight booking example in another tab or refresh the page repeatedly. The client reconnects to the same chat stream each time. -## How It Works +## How it works -1. When the user sends a message, `WorkflowChatTransport` makes a POST to `/api/chat` -2. The API starts a workflow and returns the run ID in the `x-workflow-run-id` header -3. `onChatSendMessage` stores this run ID in localStorage -4. If the stream is interrupted before receiving a "finish" chunk, the transport automatically reconnects -5. `prepareReconnectToStreamRequest` builds the reconnection URL using the stored run ID, pointing to the new endpoint `/api/chat/{runId}/stream` -6. The reconnection endpoint returns the stream from where the client left off -7. When the stream completes, `onChatEnd` clears the stored run ID +1. When the user sends a message, `WorkflowChatTransport` makes a `POST` request to `/api/chat`. +2. The API starts a workflow and returns the run ID in the `x-workflow-run-id` header. +3. `onChatSendMessage` stores this run ID in `localStorage`. +4. If the stream is interrupted before receiving a `finish` chunk, the transport automatically reconnects. +5. `prepareReconnectToStreamRequest` builds the reconnection URL using the stored run ID and points to `/api/chat/{runId}/stream`. +6. The reconnection endpoint returns the stream from where the client left off. +7. When the stream completes, `onChatEnd` clears the stored run ID. -This approach also handles page refreshes, as the client will automatically reconnect to the stream from the last known position when the UI loads with a stored run ID, following the behavior of [AI SDK's stream resumption](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams#chatbot-resume-streams). +This approach also handles page refreshes, as the client will automatically reconnect to the stream from the last known position when the user interface (UI) loads with a stored run ID, following the behavior of [AI SDK's stream resumption](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams#chatbot-resume-streams). ### Resuming from the end of the stream -By default, reconnecting replays the entire stream from the beginning (`startIndex: 0`). If you only need to show recent output — for example, when resuming a long conversation after a page refresh — you can set `initialStartIndex` to a negative value to read from the end of the stream instead: +By default, reconnecting replays the entire stream from the beginning (`startIndex: 0`). If you only need to show recent output (for example, when resuming a long conversation after a page refresh), you can set `initialStartIndex` to a negative value to read from the end of the stream instead: {/*@skip-typecheck: incomplete code sample*/} @@ -206,8 +206,8 @@ When using a negative `initialStartIndex`, the reconnection endpoint **must** re A workflow stream is a flat sequence of chunks, but the AI SDK's UI protocol groups chunks into logical parts (`text-*`, `reasoning-*`, `tool-input-*`) that must be opened with a `*-start` before any `*-delta` or `*-end`. A non-zero `startIndex` can land in the middle of an open part. See [`WorkflowChatTransport` → Mid-part resumes](/docs/api-reference/workflow-ai/workflow-chat-transport#mid-part-resumes) for how this is handled and an example of rewinding to a step boundary on the server. -## Related Documentation +## Related documentation -- [`WorkflowChatTransport` API Reference](/docs/api-reference/workflow-ai/workflow-chat-transport) - Full configuration options -- [Streaming](/docs/foundations/streaming) - Understanding workflow streams -- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Retrieving existing runs +- [`WorkflowChatTransport` API reference](/docs/api-reference/workflow-ai/workflow-chat-transport): Full configuration options +- [Streaming](/docs/foundations/streaming): Understanding workflow streams +- [`getRun()` API reference](/docs/api-reference/workflow-api/get-run): Retrieving existing runs diff --git a/docs/content/docs/v5/ai/sleep-and-delays.mdx b/docs/content/docs/v5/ai/sleep-and-delays.mdx index 3d39d03685..5a8c5ba44d 100644 --- a/docs/content/docs/v5/ai/sleep-and-delays.mdx +++ b/docs/content/docs/v5/ai/sleep-and-delays.mdx @@ -12,7 +12,7 @@ related: - /docs/api-reference/workflow/sleep --- -AI agents sometimes need to pause execution in order to schedule recurring or future actions, wait before retrying an operation (e.g. for rate limiting), or wait for external state to be available. +AI agents sometimes need to pause execution to schedule recurring or future actions, wait before retrying an operation (e.g. for rate limiting), or wait for external state to be available. Workflow SDK's `sleep` function enables Agents to pause execution without consuming resources, and resume at a specified time, after a specified duration, or in response to an external event. Workflow operation that suspend will survive restarts, new deploys, and infrastructure changes, independent of whether the suspense takes seconds or months. @@ -20,15 +20,15 @@ Workflow SDK's `sleep` function enables Agents to pause execution without consum See the [`sleep()` API Reference](/docs/api-reference/workflow/sleep) for the full list of supported duration formats and detailed API documentation, and see the [hooks](/docs/foundations/hooks) documentation for more information on how to resume in response to external events. -## Adding a Sleep Tool +## Adding a sleep tool -Sleep is a built-in function in Workflow SDK, so exposing it as a tool is as simple as wrapping it in a tool definition. Learn more about how to define tools in [Patterns for Defining Tools](/docs/ai/defining-tools). +Sleep is a built-in function in Workflow SDK. To expose it as a tool, wrap it in a tool definition. Learn more about how to define tools in [Patterns for Defining Tools](/docs/ai/defining-tools). -### Define the Tool +### Define the tool Add a new "sleep" tool to the `tools` defined in `workflows/chat/steps/tools.ts`: @@ -60,7 +60,7 @@ export const flightBookingTools = { ``` - Note that the `sleep()` function must be called from within a workflow context, not from within a step. This is why `executeSleep` does not have `"use step"` - it runs in the workflow context where `sleep()` is available. + Call `sleep()` from within a workflow context, not from within a step. `executeSleep` does not have `"use step"` because it runs in the workflow context where `sleep()` is available. This already makes the full sleep functionality available to the Agent! @@ -71,7 +71,7 @@ export const flightBookingTools = { ### Show the tool status in the UI -To round it off, extend the UI to display the tool call status. This can be done either by displaying the tool call information directly, or by emitting custom data parts to the stream (see [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools) for more details). In this case, since there aren't any fine-grained progress updates to show, we'll just display the tool call information directly: +To round it off, extend the UI to display the tool call status. This can be done either by displaying the tool call information directly, or by emitting custom data parts to the stream (see [Streaming Updates from Tools](/docs/ai/streaming-updates-from-tools) for more details). Since there aren't any fine-grained progress updates to show, we'll display the tool call information directly: {/*@skip-typecheck: incomplete code sample*/} @@ -153,11 +153,11 @@ function renderToolOutput(part: any) { Now, try out the Flight Booking Agent again, and ask it to sleep for 10 seconds before checking any flight. You'll see the agent pause, and the UI reflect the tool call status. -## Use Cases +## Use cases Aside from providing `sleep()` as a tool, there are other use cases for Agents that commonly call for suspension and resumption. -### Rate Limiting +### Rate limiting When hitting API rate limits, use `RetryableError` with a delay: @@ -180,7 +180,7 @@ async function callRateLimitedAPI(endpoint: string) { } ``` -## Related Documentation +## Related documentation - [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Full API documentation with all duration formats - [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow context diff --git a/docs/content/docs/v5/ai/streaming-updates-from-tools.mdx b/docs/content/docs/v5/ai/streaming-updates-from-tools.mdx index f20904021d..d1a4e5a2a3 100644 --- a/docs/content/docs/v5/ai/streaming-updates-from-tools.mdx +++ b/docs/content/docs/v5/ai/streaming-updates-from-tools.mdx @@ -22,7 +22,7 @@ As an example, we'll extend out Flight Booking Agent to use emit more granular p -### Define Your Data Part Type +### Define your data part type First, define a TypeScript type for your custom data part. This ensures type safety across your tool and client code: @@ -44,7 +44,7 @@ The `type` field must be a string starting with `data-` followed by your custom -### Emit Updates from Your Tool +### Emit updates from your tool Use [`getWritable()`](/docs/api-reference/workflow/get-writable) inside a step function to get a handle to the stream. This is the same stream that the LLM and other tools calls are writing to, so we can inject out own data packets directly. @@ -95,7 +95,7 @@ Key points: -### Handle Data Parts in the Client +### Handle data parts in the client Update your chat component to detect and render the custom data parts. Data parts are stored in the message's `parts` array alongside text and tool invocation parts: @@ -143,7 +143,7 @@ The pattern is: Now, when you run the agent to search for flights, you'll see the flight results pop up one after another. This will be most useful if you have tool calls that take minutes to complete, and you need to show granular progress updates to the user. -## Related Documentation +## Related documentation - [Building Durable AI Agents](/docs/ai) - Complete guide to durable agents - [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Stream API details diff --git a/docs/content/docs/v5/api-reference/vitest/index.mdx b/docs/content/docs/v5/api-reference/vitest/index.mdx index 1c3f8d41de..4cf3b15810 100644 --- a/docs/content/docs/v5/api-reference/vitest/index.mdx +++ b/docs/content/docs/v5/api-reference/vitest/index.mdx @@ -3,7 +3,7 @@ title: "@workflow/vitest" description: Vitest plugin and test helpers for integration testing workflows in-process. --- -The `@workflow/vitest` package provides a Vitest plugin and test helpers for running full workflow integration tests in-process — no server required. +The `@workflow/vitest` package provides a Vitest plugin and test helpers for running full workflow integration tests in-process, no server required. ## Plugin @@ -21,7 +21,7 @@ export default defineConfig({ }); ``` -Pass a [`WorkflowTestOptions`](#workflowtestoptions) object when your project uses a non-standard layout — for example, a monorepo where `workflows/` does not live at the Vitest config's directory, or when the default `.workflow-data` / `.workflow-vitest` output locations need to move. The plugin forwards these paths to `buildWorkflowTests()` and `setupWorkflowTests()` through Vitest's per-project provided context, so each Vitest workspace project stays isolated. +Pass a [`WorkflowTestOptions`](#workflowtestoptions) object when your project uses a non-standard layout, for example, a monorepo where `workflows/` does not live at the Vitest config's directory, or when the default `.workflow-data` / `.workflow-vitest` output locations need to move. The plugin forwards these paths to `buildWorkflowTests()` and `setupWorkflowTests()` through Vitest's per-project provided context, so each Vitest workspace project stays isolated. ```typescript @@ -46,7 +46,7 @@ export default defineConfig({ **Returns:** `Plugin[]` -## Setup Functions +## Setup functions ### `buildWorkflowTests()` @@ -108,11 +108,11 @@ Tears down the workflow test world. Clears the global world and closes the Local | `dataDir` | `string` | `/.workflow-data` | Directory for workflow runtime data written by the test world. Relative paths resolve against `cwd`. | | `outDir` | `string` | `/.workflow-vitest` | Directory for generated workflow and step bundles. Relative paths resolve against `cwd`. | -## Test Helpers +## Test helpers ### `waitForSleep()` -Polls the event log until the workflow has a pending `sleep()` call — one with a `wait_created` event but no corresponding `wait_completed` event. Returns the correlation ID of the pending sleep, which can be passed to [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to target a specific sleep. +Polls the event log until the workflow has a pending `sleep()` call, one with a `wait_created` event but no corresponding `wait_completed` event. Returns the correlation ID of the pending sleep, which can be passed to [`wakeUp()`](/docs/api-reference/workflow-api/get-run) to target a specific sleep. ```typescript @@ -131,9 +131,9 @@ await getRun(run.runId).wakeUp({ correlationIds: [sleepId] }); // [!code highlig | `run` | `Run` | The workflow run to monitor | | `options?` | `WaitOptions` | Polling and timeout configuration | -**Returns:** `Promise` — The correlation ID of the first pending sleep. Pass this to `wakeUp({ correlationIds: [id] })` to target a specific sleep. +**Returns:** `Promise`, the correlation ID of the first pending sleep. Pass this to `wakeUp({ correlationIds: [id] })` to target a specific sleep. -#### Behavior with Multiple Sleeps +#### Behavior with multiple sleeps - **Sequential sleeps**: `waitForSleep()` returns each sleep as the workflow reaches it. After waking one, call `waitForSleep()` again for the next. - **Parallel sleeps**: `waitForSleep()` returns whichever pending sleep is found first. After waking it, call `waitForSleep()` again to get the next one. @@ -159,7 +159,7 @@ await resumeHook(hook.token, { approved: true }); // [!code highlight] | `run` | `Run` | The workflow run to monitor | | `options?` | `WaitOptions & { token?: string }` | Polling, timeout, and optional token filter | -**Returns:** `Promise` — The first pending hook matching the filter. The hook object includes `token`, `hookId`, and `runId`. +**Returns:** `Promise`, the first pending hook matching the filter. The hook object includes `token`, `hookId`, and `runId`. ### `WaitOptions` diff --git a/docs/content/docs/v5/api-reference/workflow-ai/durable-agent.mdx b/docs/content/docs/v5/api-reference/workflow-ai/durable-agent.mdx index f38f3a0d7c..611e4cfcd5 100644 --- a/docs/content/docs/v5/api-reference/workflow-ai/durable-agent.mdx +++ b/docs/content/docs/v5/api-reference/workflow-ai/durable-agent.mdx @@ -10,14 +10,14 @@ related: --- -`DurableAgent` is deprecated. Use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for new durable agents — see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). +`DurableAgent` is deprecated. Use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) for new durable agents. See the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). This reference is kept for existing applications that still import `DurableAgent` from `@workflow/ai/agent`. Do not use `DurableAgent` for new code. For current examples and implementation guidance, see AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) docs. For legacy code, the API surface below documents the existing `DurableAgent` exports. -## API Signature +## API signature ### Class @@ -125,7 +125,7 @@ import type { StreamTextOnAbortCallback } from "@workflow/ai/agent"; export default StreamTextOnAbortCallback;`} /> -### Advanced Types +### Advanced types #### ToolCallRepairFunction @@ -157,30 +157,30 @@ import type { OutputSpecification } from "@workflow/ai/agent"; export default OutputSpecification;`} /> -## Key Features +## Key features -- **Durable Execution**: Agents can be interrupted and resumed without losing state -- **Flexible Tool Implementation**: Tools can be implemented as workflow steps for automatic retries, or as regular workflow-level logic -- **Stream Processing**: Handles streaming responses and tool calls in a structured way -- **Workflow Native**: Fully integrated with Workflow SDK for production-grade reliability -- **AI SDK Parity**: Supports the same options as AI SDK's `streamText` including generation settings, callbacks, and structured output +- **Durable execution**: Agents can be interrupted and resumed without losing state. +- **Flexible tool implementation**: Tools can be implemented as workflow steps for automatic retries or as regular workflow-level logic. +- **Stream processing**: Handles streaming responses and tool calls in a structured way. +- **Workflow native**: Fully integrated with Workflow SDK for production reliability. +- **AI SDK parity**: Supports the same options as AI SDK's `streamText`, including generation settings, callbacks, and structured output. -## Good to Know +## Good to know -- Tools can be implemented as workflow steps (using `"use step"` for automatic retries), or as regular workflow-level logic -- Tools can use core library features like `sleep()` and Hooks within their `execute` functions -- The agent processes tool calls iteratively until completion or `maxSteps` is reached -- **Default `maxSteps` is unlimited** - set a value to limit the number of LLM calls -- The `stream()` method returns `{ messages, steps, toolCalls, toolResults, experimental_output, uiMessages }` containing the full conversation history, step details, tool call details, optional structured output, and optionally accumulated UI messages -- Use `collectUIMessages: true` to accumulate `UIMessage[]` during streaming, useful for persisting conversation state without re-reading the stream -- The `prepareStep` callback runs before each step and can modify model, messages, generation settings, tool choice, and context -- Generation settings (temperature, maxOutputTokens, etc.) can be set on the constructor and overridden per-stream call -- Use `activeTools` to limit which tools are available for a specific stream call -- The `onFinish` callback is called when all steps complete; `onAbort` is called if aborted +- Tools can be implemented as workflow steps (using `"use step"` for automatic retries) or as regular workflow-level logic. +- Tools can use core library features like `sleep()` and hooks within their `execute` functions. +- The agent processes tool calls iteratively until completion or until `maxSteps` is reached. +- **Default `maxSteps` is unlimited**: Set a value to limit the number of large language model (LLM) calls. +- The `stream()` method returns `{ messages, steps, toolCalls, toolResults, experimental_output, uiMessages }` containing the full conversation history, step details, tool call details, optional structured output, and optionally accumulated user interface (UI) messages. +- Use `collectUIMessages: true` to accumulate `UIMessage[]` during streaming, which is useful for persisting conversation state without re-reading the stream. +- The `prepareStep` callback runs before each step and can modify the model, messages, generation settings, tool choice, and context. +- Generation settings (`temperature`, `maxOutputTokens`, and others) can be set on the constructor and overridden per-stream call. +- Use `activeTools` to limit which tools are available for a specific stream call. +- The `onFinish` callback is called when all steps complete; `onAbort` is called if aborted. ## Examples -### Basic Agent with Tools +### Basic agent with tools ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -222,7 +222,7 @@ async function weatherAgentWorkflow(userQuery: string) { } ``` -### Multiple Tools +### Multiple tools ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -271,7 +271,7 @@ async function multiToolAgentWorkflow(userQuery: string) { } ``` -### Multi-turn Conversation +### Multi-turn conversation ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -325,7 +325,7 @@ async function multiTurnAgentWorkflow() { } ``` -### Tools with Workflow Library Features +### Tools with Workflow library features ```typescript import { DurableAgent } from "@workflow/ai/agent"; @@ -347,7 +347,7 @@ async function requestApproval({ message }: { message: string }) { // Note: No "use step" for this tool call either, // since hooks are awaited at the workflow level - // Utilize a Hook for Human-in-the-loop approval + // Use a Hook for Human-in-the-loop approval const hook = approvalHook.create({ metadata: { message } }); @@ -392,7 +392,7 @@ async function agentWithLibraryFeaturesWorkflow(userRequest: string) { } ``` -### Dynamic Context with prepareStep +### Dynamic context with prepareStep Use `prepareStep` to modify settings before each step in the agent loop: @@ -436,7 +436,7 @@ async function agentWithPrepareStep(userMessage: string) { } ``` -### Message Injection with prepareStep +### Message injection with prepareStep Inject messages from external sources (like hooks) before each LLM call: @@ -486,7 +486,7 @@ async function agentWithMessageQueue(initialMessage: string) { } ``` -### Generation Settings +### Generation settings Configure model generation parameters at the constructor or stream level: @@ -524,7 +524,7 @@ async function agentWithGenerationSettings() { } ``` -### Limiting Steps with maxSteps +### Limiting steps with maxSteps By default, the agent loops until completion. Use `maxSteps` to limit the number of LLM calls: @@ -575,7 +575,7 @@ async function multiStepAgent() { } ``` -### Callbacks for Monitoring +### Callbacks for monitoring Use callbacks to monitor streaming progress, handle errors, and react to completion: @@ -616,7 +616,7 @@ async function agentWithCallbacks() { } ``` -### Structured Output +### Structured output Parse structured data from the LLM response using `Output.object`: @@ -651,7 +651,7 @@ async function agentWithStructuredOutput() { } ``` -### Tool Choice Control +### Tool choice control Control when and which tools the model can use: @@ -714,7 +714,7 @@ async function agentWithToolChoice() { } ``` -### Passing Context to Tools +### Passing context to tools Use `experimental_context` to pass shared context to tool executions: @@ -758,7 +758,7 @@ async function agentWithContext(userId: string) { } ``` -### Collecting UI Messages +### Collecting UI messages Use `collectUIMessages` to accumulate `UIMessage[]` during streaming. This is useful when you need to persist the conversation without re-reading the run's output stream: @@ -800,7 +800,7 @@ async function saveConversation(messages: UIMessage[]) { The `uiMessages` property is only available when `collectUIMessages` is set to `true`. When disabled, `uiMessages` is `undefined`. -### Machine-Readable Tool Results +### Machine-readable tool results `stream()` returns tool call information you can inspect programmatically. Compare `toolCalls` with `toolResults` to find unresolved tool calls that need client-side handling: @@ -857,7 +857,7 @@ async function agentWithToolInspection(userMessage: string) { `toolCalls` and `toolResults` reflect the *last step* of the agent loop. Tools without an `execute` function will appear in `toolCalls` but not in `toolResults`, which is how you detect calls that need client-side handling. -### Aborting Long-Running Streams +### Aborting long-running streams Use `timeout` to abort a stream automatically after a fixed duration: @@ -885,10 +885,10 @@ async function agentWithTimeout(userMessage: string) { } ``` -## See Also +## See also -- [Building Durable AI Agents](/docs/ai) - Complete guide to creating durable agents -- [Queueing User Messages](/docs/ai/message-queueing) - Using prepareStep for message injection -- [WorkflowChatTransport](/docs/api-reference/workflow-ai/workflow-chat-transport) - Transport layer for AI SDK streams -- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals -- [AI SDK Loop Control](https://ai-sdk.dev/docs/agents/loop-control) - AI SDK's agent loop control patterns +- [Building Durable AI Agents](/docs/ai): Complete guide to creating durable agents +- [Queueing User Messages](/docs/ai/message-queueing): Using `prepareStep` for message injection +- [WorkflowChatTransport](/docs/api-reference/workflow-ai/workflow-chat-transport): Transport layer for AI SDK streams +- [Workflows and Steps](/docs/foundations/workflows-and-steps): Understanding workflow fundamentals +- [AI SDK Loop Control](https://ai-sdk.dev/docs/agents/loop-control): AI SDK's agent loop control patterns diff --git a/docs/content/docs/v5/api-reference/workflow-ai/index.mdx b/docs/content/docs/v5/api-reference/workflow-ai/index.mdx index cc3a10372d..b54c958fb2 100644 --- a/docs/content/docs/v5/api-reference/workflow-ai/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-ai/index.mdx @@ -7,15 +7,15 @@ related: - /docs/ai --- -Helpers for integrating AI SDK for building AI-powered workflows. +The `@workflow/ai` package provides helpers for integrating AI SDK into AI-powered workflows. ## Classes - Deprecated — use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). Reference kept for existing `@workflow/ai/agent` imports. + Deprecated: use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). Reference kept for existing `@workflow/ai/agent` imports. - Deprecated — use AI SDK's [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) from `@ai-sdk/workflow`. Reference kept for existing `@workflow/ai` imports. + Deprecated: use AI SDK's [`WorkflowChatTransport`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) from `@ai-sdk/workflow`. Reference kept for existing `@workflow/ai` imports. diff --git a/docs/content/docs/v5/api-reference/workflow-ai/workflow-chat-transport.mdx b/docs/content/docs/v5/api-reference/workflow-ai/workflow-chat-transport.mdx index ae56e03a4c..d5de056bc7 100644 --- a/docs/content/docs/v5/api-reference/workflow-ai/workflow-chat-transport.mdx +++ b/docs/content/docs/v5/api-reference/workflow-ai/workflow-chat-transport.mdx @@ -10,10 +10,10 @@ related: --- -`WorkflowChatTransport` from `@workflow/ai` is deprecated. AI SDK ships a 1:1 port — use [`WorkflowChatTransport` from `@ai-sdk/workflow`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) instead. This reference is kept for existing applications that still import it from `@workflow/ai`. +`WorkflowChatTransport` from `@workflow/ai` is deprecated. AI SDK ships a 1:1 port, so use [`WorkflowChatTransport` from `@ai-sdk/workflow`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#resumable-streaming-with-workflowchattransport) instead. This reference is kept for existing applications that still import it from `@workflow/ai`. -A chat transport implementation for the AI SDK that provides reliable message streaming with automatic reconnection to interrupted streams. This transport is a drop-in replacement for the default AI SDK transport, enabling seamless recovery from network issues, page refreshes, or Vercel Function timeouts. +`WorkflowChatTransport` is an AI SDK chat transport that automatically reconnects to interrupted streams. It replaces the default AI SDK transport and recovers from network issues, page refreshes, or Vercel Functions timeouts. `WorkflowChatTransport` implements the [`ChatTransport`](https://ai-sdk.dev/docs/ai-sdk-ui/transport) interface from the AI SDK and is designed to work with workflow-based chat applications. It requires endpoints that return the `x-workflow-run-id` header to enable stream resumption. @@ -38,7 +38,7 @@ export default function Chat() { } ``` -## API Signature +## API signature ### Class @@ -56,27 +56,27 @@ import type { WorkflowChatTransportOptions } from "@workflow/ai"; export default WorkflowChatTransportOptions;`} /> -## Key Features +## Key features -- **Automatic Reconnection**: Automatically recovers from interrupted streams with configurable retry limits -- **Workflow Integration**: Seamlessly works with workflow-based endpoints that provide the `x-workflow-run-id` header -- **Customizable Requests**: Allows intercepting and modifying requests via `prepareSendMessagesRequest` and `prepareReconnectToStreamRequest` -- **Stream Callbacks**: Provides hooks for tracking chat lifecycle via `onChatSendMessage` and `onChatEnd` -- **Custom Fetch**: Supports custom fetch implementations for advanced use cases +- **Automatic reconnection**: Recovers from interrupted streams with configurable retry limits. +- **Workflow integration**: Works with workflow-based endpoints that provide the `x-workflow-run-id` header. +- **Customizable requests**: Allows intercepting and modifying requests through `prepareSendMessagesRequest` and `prepareReconnectToStreamRequest`. +- **Stream callbacks**: Provides hooks for tracking the chat lifecycle through `onChatSendMessage` and `onChatEnd`. +- **Custom fetch**: Supports custom fetch implementations for advanced use cases. -## Good to Know +## Good to know -- The transport expects chat endpoints to return the `x-workflow-run-id` header in the response to enable stream resumption -- By default, the transport posts to `/api/chat` and reconnects via `/api/chat/{runId}/stream` -- The `onChatSendMessage` callback receives the full response object, allowing you to extract and store the workflow run ID for session resumption -- Stream interruptions are automatically detected when a "finish" chunk is not received in the initial response -- The `maxConsecutiveErrors` option controls how many reconnection attempts are made before giving up (default: 3) -- `initialStartIndex` (constructor option) sets the default chunk position for the **first** reconnection attempt (e.g. after a page refresh). Subsequent retries within the same reconnection loop always resume from the last received chunk. Negative values (e.g. `-20`) read from the end of the stream, which is useful for showing only recent output without replaying the full conversation. `startIndex` (per-call option on `reconnectToStream`) overrides `initialStartIndex` for a single reconnection -- When using a negative `initialStartIndex`, the reconnection endpoint must return the `x-workflow-stream-tail-index` response header (via `readable.getTailIndex()`). The transport reads this header to compute absolute chunk positions for retries. Without it, startIndex is assumed to be 0, replaying the entire stream +- The transport expects chat endpoints to return the `x-workflow-run-id` header in the response to enable stream resumption. +- By default, the transport posts to `/api/chat` and reconnects through `/api/chat/{runId}/stream`. +- The `onChatSendMessage` callback receives the full response object, allowing you to extract and store the workflow run ID for session resumption. +- Stream interruptions are automatically detected when a `finish` chunk is not received in the initial response. +- The `maxConsecutiveErrors` option controls how many reconnection attempts are made before giving up (default: 3). +- `initialStartIndex` (constructor option) sets the default chunk position for the **first** reconnection attempt (for example, after a page refresh). Subsequent retries within the same reconnection loop always resume from the last received chunk. Negative values (for example, `-20`) read from the end of the stream, which is useful for showing only recent output without replaying the full conversation. `startIndex` (per-call option on `reconnectToStream`) overrides `initialStartIndex` for a single reconnection. +- When using a negative `initialStartIndex`, the reconnection endpoint must return the `x-workflow-stream-tail-index` response header (through `readable.getTailIndex()`). The transport reads this header to compute absolute chunk positions for retries. Without it, `startIndex` is assumed to be `0`, replaying the entire stream. ## Examples -### Basic Chat Setup +### Basic chat setup ```typescript "use client"; @@ -119,7 +119,7 @@ export default function BasicChat() { } ``` -### With Session Persistence and Resumption +### With session persistence and resumption ```typescript "use client"; @@ -187,7 +187,7 @@ export default function ChatWithResumption() { } ``` -### With Custom Request Configuration +### With custom request configuration ```typescript "use client"; @@ -256,11 +256,11 @@ export default function ChatWithCustomConfig() { ## Mid-part resumes -A workflow stream is a flat sequence of chunks, but the AI SDK's UI protocol groups chunks into logical parts: a `text-start` opens a text part that subsequent `text-delta`s extend and a `text-end` closes, and the same shape applies to `reasoning-*` and `tool-input-*`. The AI SDK client enforces that grammar — a `reasoning-delta` whose `reasoning-start` was never seen throws and breaks the chat. +A workflow stream is a flat sequence of chunks, but the AI SDK's user interface (UI) protocol groups chunks into logical parts: a `text-start` opens a text part that subsequent `text-delta`s extend and a `text-end` closes, and the same shape applies to `reasoning-*` and `tool-input-*`. The AI SDK client enforces that grammar: a `reasoning-delta` whose `reasoning-start` was never seen throws and breaks the chat. -A non-zero `startIndex` (in particular a negative `initialStartIndex`) resolves to a chunk offset with no awareness of those part boundaries, so it can land in the middle of an open part. When that happens, `WorkflowChatTransport` will **drop chunks that reference a part it didn't see a start for** and log a one-time warning. The chat keeps working, but any partial part overlapping the resume cursor is discarded. Tool calls are an exception: `tool-input-available` / `tool-input-error` chunks are self-contained (they carry the full input), so a tool call is recovered as soon as one of those chunks appears in the resumed window — only its streamed input deltas are lost. +A non-zero `startIndex` (in particular a negative `initialStartIndex`) resolves to a chunk offset with no awareness of those part boundaries, so it can land in the middle of an open part. When that happens, `WorkflowChatTransport` will **drop chunks that reference a part it didn't see a start for** and log a one-time warning. The chat keeps working, but any partial part overlapping the resume cursor is discarded. Tool calls are an exception: `tool-input-available` / `tool-input-error` chunks are self-contained (they carry the full input), so a tool call is recovered as soon as one of those chunks appears in the resumed window. Only its streamed input deltas are lost. -To preserve those partial parts, rewind to a step boundary on the server before returning the readable. `start-step` / `finish-step` chunks are the natural seams — no UI part is ever open across them. Sketch: +To preserve those partial parts, rewind to a step boundary on the server before returning the readable. `start-step` / `finish-step` chunks are the natural seams: no UI part is ever open across them. Sketch: {/*@skip-typecheck: incomplete code sample*/} @@ -293,9 +293,9 @@ return createUIMessageStreamResponse({ }); ``` -## See Also +## See also -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - Building durable, resumable AI agents (replaces `DurableAgent`) -- [AI SDK `useChat` Documentation](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) - Using `useChat` with custom transports -- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Understanding workflow fundamentals -- ["flight-booking-app" Example](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app) - An example application which uses `WorkflowChatTransport` +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Build durable, resumable AI agents (replaces `DurableAgent`) +- [AI SDK `useChat` documentation](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat): Use `useChat` with custom transports +- [Workflows and Steps](/docs/foundations/workflows-and-steps): Understand workflow fundamentals +- [`flight-booking-app` example](https://github.com/vercel/workflow-examples/tree/main/flight-booking-app): View an example application that uses `WorkflowChatTransport` diff --git a/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx b/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx index 82638ae989..edb05c9f4a 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/get-hook-by-token.mdx @@ -18,7 +18,7 @@ When `experimental_minRetention` is set, this function continues to return the H -Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). +Looking up a deterministic hook token is useful in hook-based idempotency flows, but it is only an advisory check. If no hook exists yet, another request can still start the same workflow before your `start()` call registers its hook. Use the lookup to avoid obvious duplicate starts, and handle the race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict it resolves with the run that owns the token, so the duplicate can route the caller to the active owner. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). ```typescript lineNumbers @@ -31,7 +31,7 @@ export async function POST(request: Request) { } ``` -## API Signature +## API signature ### Parameters @@ -55,7 +55,7 @@ showSections={["returns"]} ## Examples -### Basic Hook Lookup +### Basic hook lookup Retrieve hook information before resuming: @@ -85,7 +85,7 @@ export async function POST(request: Request) { } ``` -### Validating Hook Before Resume +### Validating hook before resume Use `getHookByToken` to validate hook ownership or metadata before resuming: @@ -115,7 +115,7 @@ export async function POST(request: Request) { } ``` -### Checking Hook Environment +### Checking hook environment Verify the hook belongs to the expected environment: @@ -144,7 +144,7 @@ export async function POST(request: Request) { } ``` -### Logging Hook Information +### Logging hook information Log hook details for debugging or auditing: @@ -181,9 +181,9 @@ export async function POST(request: Request) { } ``` -## Related Functions +## Related functions -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload. -- [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper. -- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a hook with a payload. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper. +- [Idempotency](/docs/foundations/idempotency): Deduplicate step side effects and workflow starts. diff --git a/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx b/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx index b457421e85..88bba5eb20 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/get-run.mdx @@ -9,7 +9,7 @@ related: - /docs/foundations/idempotency --- -Retrieves the workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion, making it ideal for status checking and monitoring. +Retrieves workflow run metadata and status information for a given run ID. This function provides immediate access to workflow run details without waiting for completion. Use this function when you need to check workflow status, get timing information, or access workflow metadata without blocking on workflow completion. @@ -23,7 +23,7 @@ import { getRun } from "workflow/api"; const run = getRun("my-run-id"); ``` -## API Signature +## API signature ### Parameters @@ -47,7 +47,7 @@ showSections={["returns"]} #### WorkflowReadableStream -`run.getReadable()` returns a `WorkflowReadableStream` — a standard `ReadableStream` extended with a `getTailIndex()` helper: +`run.getReadable()` returns a `WorkflowReadableStream`, a standard `ReadableStream` extended with a `getTailIndex()` helper: Start/enqueue a new workflow run. @@ -30,5 +28,5 @@ The API package is for access and introspection of workflow data to inspect runs - Looking for `getWorld()` and the World SDK? They are exported from `workflow/runtime` — see the [`workflow/runtime` reference](/docs/api-reference/workflow-runtime). + Looking for `getWorld()` and the World SDK? They are exported from `workflow/runtime`. See the [`workflow/runtime` reference](/docs/api-reference/workflow-runtime). diff --git a/docs/content/docs/v5/api-reference/workflow-api/resume-hook.mdx b/docs/content/docs/v5/api-reference/workflow-api/resume-hook.mdx index d27c8706cf..06257a6584 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/resume-hook.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/resume-hook.mdx @@ -37,7 +37,7 @@ export async function POST(request: Request) { } ``` -## API Signature +## API signature ### Parameters @@ -50,7 +50,7 @@ showSections={["parameters"]} ### Returns -Returns a `Promise` — a `Hook` extended with an optional `resilientResume` flag. Resolving means the resume was accepted and the workflow will continue, whether the `hook_received` event was written directly or, on the parallel fast path, delivered through the workflow queue for the runtime to materialize (see the [lazy hook resume changelog](/docs/changelog/resilient-resume)). `resilientResume` is `true` only when the direct event write failed transiently and the resume was recovered through the queue; on the happy path it is absent. The resolved hook: +Returns a `Promise`, a `Hook` extended with an optional `resilientResume` flag. Resolving means the resume was accepted and the workflow will continue, whether the `hook_received` event was written directly or, on the parallel fast path, delivered through the workflow queue for the runtime to materialize (see the [lazy hook resume changelog](/docs/changelog/resilient-resume)). `resilientResume` is `true` only when the direct event write failed transiently and the resume was recovered through the queue; on the happy path it is absent. The resolved hook: ` that resolves to one of: Throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error) if the token does not match an active webhook. -## Usage Note +## Usage note In most cases, you should not need to call `resumeWebhook()` directly. When you use `createWebhook()`, the framework automatically generates a webhook token and provides a public URL at `/.well-known/workflow/v1/webhook/:token`. External systems can send HTTP requests directly to that URL. @@ -90,7 +90,7 @@ export async function POST(request: Request) { } ``` -## Related Functions +## Related functions - [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Create a webhook in a workflow - [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with arbitrary payload diff --git a/docs/content/docs/v5/api-reference/workflow-api/start.mdx b/docs/content/docs/v5/api-reference/workflow-api/start.mdx index 611db4723c..0fe6e443ec 100644 --- a/docs/content/docs/v5/api-reference/workflow-api/start.mdx +++ b/docs/content/docs/v5/api-reference/workflow-api/start.mdx @@ -18,7 +18,7 @@ import { myWorkflow } from "./workflows/my-workflow"; const run = await start(myWorkflow); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -50,25 +50,25 @@ showSections={["returns"]} Learn more about [`WorkflowReadableStreamOptions`](/docs/api-reference/workflow-api/get-run#workflowreadablestreamoptions). -## Good to Know +## Good to know -* The `start()` function is used in runtime contexts to programmatically trigger workflow executions. -* In v5, `start()` can also be called directly from a workflow function to spawn a child run or continue work in a new run. See [Workflow Composition](/cookbook/common-patterns/workflow-composition) and [Versioning](/docs/foundations/versioning). +* Use the `start()` function in runtime contexts to programmatically trigger workflow executions. +* In v5, you can also call `start()` directly from a workflow function to spawn a child run or continue work in a new run. See [Workflow Composition](/cookbook/common-patterns/workflow-composition) and [Versioning](/docs/foundations/versioning). * This is different from calling workflow functions directly, which is the typical pattern in Next.js applications. -* The function returns immediately after enqueuing the workflow - it doesn't wait for the workflow to complete. -* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered; handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work — on a conflict it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency). +* The function returns immediately after enqueuing the workflow. It doesn't wait for the workflow to complete. +* Each call to `start()` creates a new workflow run. If retried requests must route to one active workflow, have the workflow create a deterministic hook token and use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) to reuse an already-registered active hook. The lookup is not atomic with `start()`, so concurrent callers can still create extra runs before the hook is registered. Handle that race inside the workflow by checking `await hook.getConflict()` before duplicate-sensitive work. On a conflict, it resolves with the run that owns the token, so the duplicate can return the active owner to the caller. If duplicates must be rejected before a workflow body runs, keep a durable request record until native atomic start-and-hook registration exists. See [Idempotency](/docs/foundations/idempotency#run-idempotency). * All arguments must be [serializable](/docs/foundations/serialization). -* When `deploymentId` is provided, the argument types and return type become `unknown` since there is no guarantee the workflow function's types will be consistent across different deployments. +* When you provide `deploymentId`, the argument types and return type become `unknown` because the workflow function's types may differ across deployments. * `attributes` seeds plaintext run metadata as part of creation and requires a World implementing spec version 4 or later. Keys that start with `$` are reserved for framework and library code; framework-level callers can pass `allowReservedAttributes: true` to seed reserved keys, with the same semantics as the [`setAttributes`](/docs/api-reference/workflow/set-attributes) option of the same name. -* `region` pins the new run to a specific region on Worlds with a regional dimension. On the [Vercel World](/worlds/vercel#explicit-region-selection) the run's storage, queue dispatch, and streams are then served from that region; when omitted, the run is pinned to the region it was created in. Worlds without regions ignore the option. +* `region` pins the new run to a specific region on Worlds with a regional dimension. The [Vercel World](/worlds/vercel#explicit-region-selection) then serves the run's storage, queue dispatch, and streams from that region. When you omit `region`, the run is pinned to the region where it was created. Worlds without regions ignore the option. -If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive.`, the passed function was not transformed as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function). +If `start()` throws `'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive.`, the compiler did not transform the passed function as a workflow. The two most common causes are a missing `"use workflow"` directive or missing framework integration. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function). ## Examples -### With Arguments +### With arguments ```typescript import { start } from "workflow/api"; @@ -103,7 +103,7 @@ const run = await start(myWorkflow, ["arg1", "arg2"], { // [!code highlight] ``` -The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment — the same production target for production deployments, or the same git branch for preview deployments. +The `deploymentId` option is currently a Vercel-specific feature. Other Worlds may implement this option differently to match their own deployment runtimes, and the World spec may rename it from `deploymentId` to `version` in a future SDK version. On Vercel, `"latest"` resolves to the most recent deployment matching your current environment: the same production target for production deployments, or the same git branch for preview deployments. In Worlds without atomic, immutable deployments (such as local development or self-hosted Postgres), there is no notion of multiple deployments to resolve between, so `deploymentId: "latest"` has no effect: the SDK logs a warning and the run targets the current deployment. This means a workflow that opts into `"latest"` on Vercel still runs unchanged in local development. @@ -111,13 +111,13 @@ In Worlds without atomic, immutable deployments (such as local development or se When using `deploymentId: "latest"`, the workflow run will execute on a potentially different deployment than the one calling `start()`. Be mindful of forward and backward compatibility: -- **Workflow identity**: The workflow ID is derived from the function name and file path. If the latest deployment has renamed the workflow function or moved it to a different directory, the workflow ID will no longer match and the run will fail to start. -- **Input and output compatibility**: The arguments passed to `start()` are serialized by the calling deployment but deserialized by the target deployment. Similarly, the workflow's return value is serialized by the target deployment but deserialized by the caller. If the workflow's expected arguments or return type have changed (e.g. added required fields, removed fields, or changed types), the run may fail or behave unexpectedly. Ensure that input and output schemas remain backward-compatible across deployments. +- **Workflow identity**: The function name and file path determine the workflow ID. If the latest deployment has renamed the workflow function or moved it to a different directory, the workflow ID will no longer match and the run will fail to start. +- **Input and output compatibility**: The calling deployment serializes the arguments passed to `start()`, and the target deployment deserializes them. Similarly, the target deployment serializes the workflow's return value, and the caller deserializes it. If the workflow's expected arguments or return type have changed (for example, added required fields, removed fields, or changed types), the run may fail or behave unexpectedly. Ensure that input and output schemas remain backward-compatible across deployments. -### Inside a Workflow Function +### Inside a workflow function -`start()` can be called directly from a workflow function to spawn a child run. It is step-backed, so the spawn records a deterministic step boundary in the parent's event log. +Call `start()` directly from a workflow function to spawn a child run. It is step-backed, so the spawn records a deterministic step boundary in the parent's event log. ```typescript import { start } from "workflow/api"; @@ -137,5 +137,5 @@ The returned `Run` object is fully functional inside a workflow. Each property a -`returnValue` polls the child run every second and holds the polling step's worker slot open for as long as the child takes to finish. For long-running children, spawn without awaiting `returnValue` and have the child resume a [hook](/docs/foundations/hooks) when it completes — see the [`startAndWait()` pattern](/cookbook/advanced/child-workflows). +`returnValue` polls the child run every second and holds the polling step's worker slot open for as long as the child takes to finish. For long-running children, spawn without awaiting `returnValue` and have the child resume a [hook](/docs/foundations/hooks) when it completes. See the [`startAndWait()` pattern](/cookbook/advanced/child-workflows). diff --git a/docs/content/docs/v5/api-reference/workflow-astro/workflow.mdx b/docs/content/docs/v5/api-reference/workflow-astro/workflow.mdx index 0c90f9ce97..89ef55573a 100644 --- a/docs/content/docs/v5/api-reference/workflow-astro/workflow.mdx +++ b/docs/content/docs/v5/api-reference/workflow-astro/workflow.mdx @@ -24,9 +24,9 @@ export default defineConfig({ }); ``` -The integration registers the workflow Vite transform plugins during `astro:config:setup` and builds the workflow bundles — locally during config setup, or via the Vercel builder after `astro:build:done` when deploying to Vercel. +The integration registers the workflow Vite transform plugins during `astro:config:setup` and builds the workflow bundles: locally during config setup, or via the Vercel builder after `astro:build:done` when deploying to Vercel. -## API Signature +## API signature ### Parameters @@ -38,7 +38,7 @@ The integration registers the workflow Vite transform plugins during `astro:conf | Option | Type | Default | Description | | --- | --- | --- | --- | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | ### Returns diff --git a/docs/content/docs/v5/api-reference/workflow-errors/entity-conflict-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/entity-conflict-error.mdx index d249089853..7d464aa0ad 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/entity-conflict-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/entity-conflict-error.mdx @@ -26,12 +26,12 @@ try { await world.events.create(runId, event); } catch (error) { if (EntityConflictError.is(error)) { // [!code highlight] - // Event already exists — safe to ignore during replay + // Event already exists, safe to ignore during replay } } ``` -## API Signature +## API signature ### Properties @@ -44,11 +44,11 @@ interface EntityConflictError { export default EntityConflictError;`} /> -### Static Methods +### Static methods #### `EntityConflictError.is(value)` -Type-safe check for `EntityConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `EntityConflictError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { EntityConflictError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx index 90467b1864..4bd66f511d 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/hook-conflict-error.mdx @@ -9,7 +9,7 @@ related: - /docs/errors/hook-conflict --- -`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows — see the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. +`HookConflictError` is thrown when creating a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows. See the [hook-conflict](/docs/errors/hook-conflict) error guide for resolution strategies. ```typescript lineNumbers import { HookConflictError } from "workflow/errors" @@ -27,7 +27,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -44,11 +44,11 @@ interface HookConflictError { export default HookConflictError;`} /> -### Static Methods +### Static methods #### `HookConflictError.is(value)` -Type-safe check for `HookConflictError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `HookConflictError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { HookConflictError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/hook-not-found-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/hook-not-found-error.mdx index 20dd65095d..f41b3c9513 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/hook-not-found-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/hook-not-found-error.mdx @@ -10,9 +10,9 @@ related: `HookNotFoundError` is thrown when calling `resumeHook()` or `resumeWebhook()` with a token that does not match any active hook. This typically happens when: -- The hook has expired (past its TTL) -- The hook was already consumed and disposed -- The workflow has not started yet, so the hook does not exist +- The hook's time to live (TTL) has expired. +- The hook was already consumed and disposed. +- The workflow has not started yet, so the hook does not exist. ```typescript lineNumbers import { HookNotFoundError } from "workflow/errors" @@ -29,7 +29,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -44,11 +44,11 @@ interface HookNotFoundError { export default HookNotFoundError;`} /> -### Static Methods +### Static methods #### `HookNotFoundError.is(value)` -Type-safe check for `HookNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `HookNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { HookNotFoundError } from "workflow/errors" @@ -66,7 +66,7 @@ if (HookNotFoundError.is(error)) { A common pattern for idempotent workflows is to try resuming a hook, and if it doesn't exist yet, start a new workflow run with the input data. -This "resume or start" pattern is not atomic — there is a small window where a race condition is possible. A better native approach is being worked on, but this pattern works well for many use cases. +This "resume or start" pattern is not atomic: there is a small window where a race condition is possible. A better native approach is being worked on, but this pattern works well for many use cases. ```typescript lineNumbers @@ -80,7 +80,7 @@ async function handleIncomingEvent(token: string, data: unknown) { await resumeHook(token, data); } catch (error) { if (HookNotFoundError.is(error)) { // [!code highlight] - // Hook doesn't exist yet — start a new workflow run + // Hook doesn't exist yet, so start a new workflow run await startWorkflow("processEvent", data); // [!code highlight] } else { throw error; diff --git a/docs/content/docs/v5/api-reference/workflow-errors/index.mdx b/docs/content/docs/v5/api-reference/workflow-errors/index.mdx index bd4c061ada..5bb8428ffb 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/index.mdx @@ -11,7 +11,7 @@ API reference for the error classes exported from the `workflow/errors` package. All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow-error), so you can catch any SDK error with a single `instanceof` check, or narrow to a specific class for fine-grained handling. -## Base Classes +## Base classes @@ -22,7 +22,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Registration Errors +## Registration errors @@ -33,7 +33,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Run Errors +## Run errors @@ -43,7 +43,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow Thrown when awaiting the return value of a failed workflow run. - Thrown when awaiting the return value of a cancelled workflow run. + Thrown when awaiting the return value of a canceled workflow run. Thrown when requesting the result of a workflow run that has not completed yet. @@ -59,7 +59,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Hook Errors +## Hook errors @@ -70,7 +70,7 @@ All errors extend [`WorkflowError`](/docs/api-reference/workflow-errors/workflow -## Backend Errors +## Backend errors diff --git a/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx index 2b32d365aa..4ba800f5f8 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/precondition-failed-error.mdx @@ -1,24 +1,24 @@ --- title: PreconditionFailedError -description: Thrown when an event creation is rejected because the client's event-log snapshot is stale. +description: World implementations throw this error when they reject an event creation because the client's event-log snapshot is stale. type: reference -summary: Catch PreconditionFailedError when a world rejects an event creation made from a stale event-log snapshot. +summary: Catch PreconditionFailedError when a World rejects an event creation made from a stale event-log snapshot. related: - /docs/api-reference/workflow-errors/workflow-world-error - /docs/api-reference/workflow-errors/entity-conflict-error --- -`PreconditionFailedError` is thrown by world implementations when an event creation is rejected because the client's event-log snapshot is stale: the log already held more events than the position the creation named. It corresponds to HTTP 412 Precondition Failed semantics. +World implementations throw `PreconditionFailedError` when they reject an event creation because the client's event-log snapshot is stale: the log already held more events than the position the creation named. It corresponds to HTTP 412 Precondition Failed semantics. -No world in this repository throws it. A stale replay does not need to be refused: its log is a prefix rather than a prefix with a hole in it, replay is deterministic on a prefix, and the write it makes next comes back carrying the events it was pushed past (see [Stale reads](/docs/configuration/runtime-tuning#stale-reads-and-why-nothing-has-to-be-rejected)). The error and the runtime's handling of it remain for a world that would rather refuse than report — one that allocates positions somewhere other than the commit, and so cannot report a gap reliably. Event creations that carry no position are never rejected with it. +No World in this repository throws it. A stale replay does not need to be refused: its log is a prefix rather than a prefix with a hole in it, replay is deterministic on a prefix, and the write it makes next comes back carrying the events it was pushed past (see [Stale reads](/docs/configuration/runtime-tuning#stale-reads-and-why-nothing-has-to-be-rejected)). The error and the runtime's handling of it remain for a World that would rather refuse than report. Such a World allocates positions somewhere other than the commit, so it cannot report a gap reliably. Event creations that carry no position are never rejected with it. -A World rejects only on evidence and accepts the creation whenever it cannot decide, so this error always means the snapshot really was stale — but not receiving it does not prove the snapshot was current. +A World rejects only on evidence and accepts the creation whenever it cannot decide. This error always means the snapshot was stale, but not receiving it does not prove the snapshot was current. -The Workflow runtime handles this error automatically: it restarts the replay in the same invocation from a corrected event log, and re-invokes the run for a fresh replay only once its in-process restart budget is spent. It never retries the rejected creation as-is, because a replay working from a corrected log derives different events. You will only encounter it when interacting with world storage APIs directly. +The Workflow runtime handles this error by restarting the replay in the same invocation from a corrected event log. It re-invokes the run for a fresh replay only after spending its in-process restart budget. It never retries the rejected creation as-is because a replay working from a corrected log derives different events. You will only encounter it when interacting with World storage APIs directly. -A world may attach the events the client was missing to the rejection as `details`, which lets the runtime restart without re-reading the event log. Doing so is optional, and the runtime falls back to a full reload when the details are absent or unusable. +A World may attach the events the client was missing to the rejection as `details`, which lets the runtime restart without re-reading the event log. This is optional, and the runtime falls back to a full reload when the details are absent or unusable. ```typescript lineNumbers import { PreconditionFailedError } from "workflow/errors" @@ -35,7 +35,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -52,7 +52,7 @@ interface PreconditionFailedError { export default PreconditionFailedError;`} /> -### Static Methods +### Static methods #### `PreconditionFailedError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow-errors/run-expired-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/run-expired-error.mdx index aa9e6eb4a7..0dc63a8273 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/run-expired-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/run-expired-error.mdx @@ -29,7 +29,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,7 +42,7 @@ interface RunExpiredError { export default RunExpiredError;`} /> -### Static Methods +### Static methods #### `RunExpiredError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow-errors/run-not-supported-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/run-not-supported-error.mdx index d7789f857b..6730bb5a2b 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/run-not-supported-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/run-not-supported-error.mdx @@ -7,7 +7,7 @@ related: - /docs/foundations/versioning --- -`RunNotSupportedError` is thrown when reading a workflow run whose data was written with a newer workflow spec version than the running SDK supports. This typically means the run was created by a newer version of the `workflow` package — upgrade the package to process it. +`RunNotSupportedError` is thrown when reading a workflow run whose data was written with a newer workflow spec version than the running SDK supports. This typically means the run was created by a newer version of the `workflow` package. Upgrade the package to process it. ```typescript lineNumbers import { RunNotSupportedError } from "workflow/errors" @@ -25,7 +25,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,11 +42,11 @@ interface RunNotSupportedError { export default RunNotSupportedError;`} /> -### Static Methods +### Static methods #### `RunNotSupportedError.is(value)` -Type-safe check for `RunNotSupportedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `RunNotSupportedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { RunNotSupportedError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/step-not-registered-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/step-not-registered-error.mdx index a4c4ec355b..9a34ade775 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/step-not-registered-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/step-not-registered-error.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-errors/workflow-not-registered-error --- -`StepNotRegisteredError` is thrown when the runtime tries to execute a step function that is not registered in the current deployment. This is an infrastructure error — not a user code error. It typically indicates a build or bundling issue that caused the step to not be included in the deployment. +`StepNotRegisteredError` is thrown when the runtime tries to execute a step function that is not registered in the current deployment. This is an infrastructure error, not a user code error. It typically indicates a build or bundling issue that caused the step to not be included in the deployment. When this error occurs, the step fails (like a `FatalError`) and control is passed back to the workflow function, which can handle the failure gracefully. @@ -21,7 +21,7 @@ if (StepNotRegisteredError.is(error)) { // [!code highlight] } ``` -## API Signature +## API signature ### Properties @@ -36,14 +36,14 @@ interface StepNotRegisteredError { export default StepNotRegisteredError;`} /> -### Static Methods +### Static methods #### `StepNotRegisteredError.is(value)` -Type-safe check for `StepNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `StepNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. -The `.is()` method works in server-side Node.js code (API routes, middleware, hooks). Inside `"use workflow"` functions, step errors arrive deserialized from the event log and won't be actual `StepNotRegisteredError` instances — use `error.message` matching instead. See the [troubleshooting page](/docs/errors/step-not-registered) for workflow-side error handling examples. +The `.is()` method works in server-side Node.js code (API routes, middleware, hooks). Inside `"use workflow"` functions, step errors arrive deserialized from the event log and won't be actual `StepNotRegisteredError` instances. Use `error.message` matching instead. See the [troubleshooting page](/docs/errors/step-not-registered) for workflow-side error handling examples. ```typescript diff --git a/docs/content/docs/v5/api-reference/workflow-errors/throttle-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/throttle-error.mdx index 4d7bc7596c..e354586219 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/throttle-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/throttle-error.mdx @@ -31,7 +31,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -46,7 +46,7 @@ interface ThrottleError { export default ThrottleError;`} /> -### Static Methods +### Static methods #### `ThrottleError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow-errors/too-early-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/too-early-error.mdx index e333cbd0b7..1ad6b5e82d 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/too-early-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/too-early-error.mdx @@ -31,7 +31,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -46,7 +46,7 @@ interface TooEarlyError { export default TooEarlyError;`} /> -### Static Methods +### Static methods #### `TooEarlyError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-error.mdx index 21c5dec3dc..b373da4c67 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-error.mdx @@ -17,7 +17,7 @@ const error = new WorkflowError("something went wrong", { }); ``` -## API Signature +## API signature ### Properties @@ -32,14 +32,14 @@ interface WorkflowError { export default WorkflowError;`} /> -### Static Methods +### Static methods #### `WorkflowError.is(value)` -Type-safe check for `WorkflowError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. - `WorkflowError.is()` matches only direct `WorkflowError` instances — not subclasses, which override the error name it checks. To handle a specific error type, use that subclass's own `.is()` method (e.g. `WorkflowRunFailedError.is(error)`). + `WorkflowError.is()` matches only direct `WorkflowError` instances, not subclasses, which override the error name it checks. To handle a specific error type, use that subclass's own `.is()` method (e.g. `WorkflowRunFailedError.is(error)`). ```typescript diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-not-registered-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-not-registered-error.mdx index b2eee91ead..34c8ea35e5 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-not-registered-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-not-registered-error.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-errors/step-not-registered-error --- -`WorkflowNotRegisteredError` is thrown when the runtime tries to execute a workflow function that is not registered in the current deployment. This is an infrastructure error — not a user code error. It typically means a run was started against a deployment that does not have this workflow (e.g., the workflow was renamed or moved), or there was a build/bundling issue. +`WorkflowNotRegisteredError` is thrown when the runtime tries to execute a workflow function that is not registered in the current deployment. This is an infrastructure error, not a user code error. It typically means a run was started against a deployment that does not have this workflow (e.g., the workflow was renamed or moved), or there was a build/bundling issue. When this error occurs, the run fails with a `RUNTIME_ERROR` error code. @@ -21,7 +21,7 @@ if (WorkflowNotRegisteredError.is(error)) { // [!code highlight] } ``` -## API Signature +## API signature ### Properties @@ -36,14 +36,14 @@ interface WorkflowNotRegisteredError { export default WorkflowNotRegisteredError;`} /> -### Static Methods +### Static methods #### `WorkflowNotRegisteredError.is(value)` -Type-safe check for `WorkflowNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowNotRegisteredError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. -The `.is()` method works in server-side Node.js code (API routes, middleware). When checking the error from `run.returnValue`, use `WorkflowRunFailedError.is()` and inspect `error.cause` — the underlying error is deserialized from the event log. +The `.is()` method works in server-side Node.js code (API routes, middleware). When checking the error from `run.returnValue`, use `WorkflowRunFailedError.is()` and inspect `error.cause`: the underlying error is deserialized from the event log. ```typescript @@ -54,4 +54,3 @@ if (WorkflowNotRegisteredError.is(error)) { // error is typed as WorkflowNotRegisteredError } ``` - diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-cancelled-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-cancelled-error.mdx index 3c3a6bc5ed..4e3d2823c6 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-cancelled-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-cancelled-error.mdx @@ -1,14 +1,14 @@ --- title: WorkflowRunCancelledError -description: Thrown when awaiting the return value of a cancelled workflow run. +description: Thrown when awaiting the return value of a canceled workflow run. type: reference -summary: Catch WorkflowRunCancelledError when awaiting run.returnValue on a run that was cancelled. +summary: Catch WorkflowRunCancelledError when awaiting run.returnValue on a run that was canceled. related: - /docs/api-reference/workflow-errors/workflow-run-failed-error - /docs/api-reference/workflow-errors/workflow-run-not-found-error --- -`WorkflowRunCancelledError` is thrown when awaiting `run.returnValue` on a workflow run that was explicitly cancelled via `run.cancel()`. Cancelled runs do not produce a return value. +`WorkflowRunCancelledError` is thrown when awaiting `run.returnValue` on a workflow run that was explicitly canceled via `run.cancel()`. Canceled runs do not produce a return value. You can check for cancellation before awaiting by inspecting `run.status`. @@ -25,14 +25,14 @@ try { } ``` -## API Signature +## API signature ### Properties -### Static Methods +### Static methods #### `WorkflowRunCancelledError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-failed-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-failed-error.mdx index 6ce8d42cf0..bf08c9a0d6 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-failed-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-failed-error.mdx @@ -11,7 +11,7 @@ related: `WorkflowRunFailedError` is thrown when awaiting `run.returnValue` on a workflow run whose status is `'failed'`. This indicates that the workflow encountered a fatal error during execution and cannot produce a return value. -The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown` — narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property. +The `cause` property holds the original thrown value, hydrated through the workflow serialization pipeline so its type identity (e.g. `FatalError`, `RetryableError`, custom `Error` subclasses), `cause` chain, and custom properties are preserved. Because any JavaScript value can be thrown, `cause` is typed as `unknown`, so narrow it with `instanceof Error` (or a more specific check) before accessing fields like `message`. The high-level error classification is exposed as the top-level `errorCode` property. ```typescript lineNumbers import { WorkflowRunFailedError } from "workflow/errors" @@ -31,7 +31,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -45,7 +45,7 @@ interface WorkflowRunFailedError { * the workflow serialization pipeline. Preserves the original type identity * (Error subclasses, FatalError, custom classes with WORKFLOW_SERIALIZE, * etc.) and custom properties. Typed as \`unknown\` because any value can - * be thrown — narrow with \`instanceof Error\` before accessing fields. + * be thrown, so narrow with \`instanceof Error\` before accessing fields. */ cause: unknown; /** The high-level error category (e.g. \`USER_ERROR\`, \`RUNTIME_ERROR\`). */ @@ -56,11 +56,11 @@ interface WorkflowRunFailedError { export default WorkflowRunFailedError;`} /> -### Static Methods +### Static methods #### `WorkflowRunFailedError.is(value)` -Type-safe check for `WorkflowRunFailedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowRunFailedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { WorkflowRunFailedError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-completed-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-completed-error.mdx index a50c8c814f..af4b904e79 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-completed-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-completed-error.mdx @@ -9,7 +9,7 @@ related: `WorkflowRunNotCompletedError` is thrown when requesting the result of a workflow run that has not completed yet. The run's current status (for example `pending` or `running`) is available on the error. -[`run.returnValue()`](/docs/api-reference/workflow-api/get-run) handles this error internally — it polls until the run completes — so you will mainly encounter it when building custom polling logic on lower-level APIs. +[`run.returnValue()`](/docs/api-reference/workflow-api/get-run) handles this error internally (it polls until the run completes), so you will mainly encounter it when building custom polling logic on lower-level APIs. ```typescript lineNumbers import { WorkflowRunNotCompletedError } from "workflow/errors" @@ -25,7 +25,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,11 +42,11 @@ interface WorkflowRunNotCompletedError { export default WorkflowRunNotCompletedError;`} /> -### Static Methods +### Static methods #### `WorkflowRunNotCompletedError.is(value)` -Type-safe check for `WorkflowRunNotCompletedError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowRunNotCompletedError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { WorkflowRunNotCompletedError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-found-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-found-error.mdx index b27d7321c7..e997b6a2c9 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-found-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-run-not-found-error.mdx @@ -10,7 +10,7 @@ related: `WorkflowRunNotFoundError` is thrown when performing operations on a workflow run that does not exist. This includes calling methods like `run.status`, `run.cancel()`, or awaiting `run.returnValue` on a run whose ID does not match any known workflow run. -Note that `getRun(id)` itself is synchronous and will not throw — the error is raised when subsequent operations on the run object discover the run is missing. +`getRun(id)` itself is synchronous and will not throw. Subsequent operations on the run object raise the error when they discover the run is missing. ```typescript lineNumbers import { WorkflowRunNotFoundError } from "workflow/errors" @@ -25,7 +25,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -40,11 +40,11 @@ interface WorkflowRunNotFoundError { export default WorkflowRunNotFoundError;`} /> -### Static Methods +### Static methods #### `WorkflowRunNotFoundError.is(value)` -Type-safe check for `WorkflowRunNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and VM contexts. +Type-safe check for `WorkflowRunNotFoundError` instances. Preferred over `instanceof` because it works across module boundaries and virtual machine contexts. ```typescript import { WorkflowRunNotFoundError } from "workflow/errors" diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-runtime-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-runtime-error.mdx index 77463afb2d..9cadbcf90d 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-runtime-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-runtime-error.mdx @@ -27,7 +27,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -42,7 +42,7 @@ interface WorkflowRuntimeError { export default WorkflowRuntimeError;`} /> -### Static Methods +### Static methods #### `WorkflowRuntimeError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx b/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx index ac12e75ef3..ea6c2622b2 100644 --- a/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow-errors/workflow-world-error.mdx @@ -12,7 +12,7 @@ related: `WorkflowWorldError` is the base error class for failures originating from a workflow world (storage backend). World implementations (local, Postgres, Vercel) throw subclasses of this error when storage operations fail. -You can use `instanceof WorkflowWorldError` to catch any world-related error regardless of the specific type. Note that the static `.is()` method only matches errors constructed directly as `WorkflowWorldError` — use the subclass-specific `.is()` methods (e.g. `EntityConflictError.is()`) to match specific error types. +You can use `instanceof WorkflowWorldError` to catch any World-related error regardless of the specific type. The static `.is()` method only matches errors constructed directly as `WorkflowWorldError`. Use the subclass-specific `.is()` methods (for example, `EntityConflictError.is()`) to match specific error types. Most world errors are handled automatically by the Workflow runtime. You will typically only encounter these errors when interacting with world storage APIs directly or when there are infrastructure-level issues. @@ -33,7 +33,7 @@ try { } ``` -## API Signature +## API signature ### Properties @@ -54,11 +54,11 @@ interface WorkflowWorldError { export default WorkflowWorldError;`} /> -### Static Methods +### Static methods #### `WorkflowWorldError.is(value)` -Type-safe check that matches only errors constructed directly as `WorkflowWorldError`. Does not match subclasses like `EntityConflictError` — use `instanceof` to catch all world errors, or the subclass-specific `.is()` methods. +Type-safe check that matches only errors constructed directly as `WorkflowWorldError`. Does not match subclasses like `EntityConflictError`. Use `instanceof` to catch all world errors, or the subclass-specific `.is()` methods. ```typescript import { WorkflowWorldError } from "workflow/errors" @@ -73,7 +73,7 @@ if (WorkflowWorldError.is(error)) { The following error types extend `WorkflowWorldError`: -- [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error) — operation conflicts with entity state -- [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error) — run has expired -- [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error) — request made before system is ready -- [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error) — request was rate-limited +- [`EntityConflictError`](/docs/api-reference/workflow-errors/entity-conflict-error): operation conflicts with entity state +- [`RunExpiredError`](/docs/api-reference/workflow-errors/run-expired-error): run has expired +- [`TooEarlyError`](/docs/api-reference/workflow-errors/too-early-error): request made before system is ready +- [`ThrottleError`](/docs/api-reference/workflow-errors/throttle-error): request was rate-limited diff --git a/docs/content/docs/v5/api-reference/workflow-globals.mdx b/docs/content/docs/v5/api-reference/workflow-globals.mdx index bcc725b5c0..488e20cc13 100644 --- a/docs/content/docs/v5/api-reference/workflow-globals.mdx +++ b/docs/content/docs/v5/api-reference/workflow-globals.mdx @@ -22,30 +22,30 @@ These APIs are available but are **seeded or fixed** to ensure deterministic beh | API | Behavior | |-----|----------| -| [`Math.random()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) | Seeded random number generator — same seed produces the same sequence every replay | +| [`Math.random()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random) | Seeded random number generator: same seed produces the same sequence every replay | | [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) / `Date.now()` / `new Date()` | Returns a fixed timestamp that advances with the workflow's logical clock | -| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded — produces deterministic output for a given workflow run | -| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded — produces deterministic UUIDs for a given workflow run | +| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded: produces deterministic output for a given workflow run | +| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded: produces deterministic UUIDs for a given workflow run | | [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Computed synchronously via `node:crypto` (values are byte-identical to WebCrypto), so the promise settles at a deterministic point during replay | You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays. -## Web Platform APIs +## Web platform APIs These standard Web APIs are available in workflow functions: - [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) - [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) / [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder) - [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) / [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) -- [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) — custom implementations with [special behavior in the workflow context](/docs/foundations/serialization#request--response). Body methods like `.json()` and `.text()` are automatically treated as step invocations. +- [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) / [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response): custom implementations with [special behavior in the workflow context](/docs/foundations/serialization#request--response). Body methods like `.json()` and `.text()` are automatically treated as step invocations. - [`console`](https://developer.mozilla.org/en-US/docs/Web/API/console) - [`structuredClone`](https://developer.mozilla.org/en-US/docs/Web/API/Window/structuredClone) - [`atob`](https://developer.mozilla.org/en-US/docs/Web/API/Window/atob) / [`btoa`](https://developer.mozilla.org/en-US/docs/Web/API/Window/btoa) -- [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) / [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) — a durable, serializable implementation whose abort state survives replay and can be passed into steps to cancel in-flight work. See [Cancellation](/docs/foundations/cancellation). +- [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) / [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal): A durable, serializable implementation whose abort state survives replay and can be passed into steps to cancel in-flight work. See [Cancellation](/docs/foundations/cancellation). -## Environment Variables +## Environment variables `process.env` is available as a **read-only, frozen** snapshot of the environment variables at the time the workflow was started. You cannot modify it. @@ -54,11 +54,11 @@ export async function myWorkflow() { "use workflow"; const apiKey = process.env.API_KEY; // works - process.env.FOO = "bar"; // throws — process.env is frozen + process.env.FOO = "bar"; // throws: process.env is frozen } ``` -## Binary Data +## Binary data Standard JavaScript typed arrays (`Uint8Array`, `Int32Array`, `Float64Array`, etc.) are available in workflow functions. @@ -93,7 +93,7 @@ target.setFromHex("48656c6c6f"); // { read: 10, written: 5 } These methods are polyfilled in the workflow environment. When the JavaScript runtime ships native support, the polyfill is automatically bypassed. -## Not Available +## Not available The following are **not available** in workflow functions. Move this logic to [step functions](/docs/foundations/workflows-and-steps#step-functions) instead. @@ -101,6 +101,6 @@ The following are **not available** in workflow functions. Move this logic to [s - **Global `fetch`**: Use [`import { fetch } from "workflow"`](/docs/api-reference/workflow/fetch) instead. See [fetch-in-workflow](/docs/errors/fetch-in-workflow). - **Timers**: `setTimeout`, `setInterval`, `setImmediate`, and their `clear*` counterparts. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. See [timeout-in-workflow](/docs/errors/timeout-in-workflow). - **`Buffer`**: Node.js-specific API. Use `Uint8Array` with `toBase64()` / `fromBase64()` / `toHex()` / `fromHex()` for binary data encoding, or `atob()` / `btoa()` for string-based base64. -- **`WeakRef` and `FinalizationRegistry`**: garbage-collection timing is not deterministic, so observing it would make workflow code impossible to replay. (`WeakMap` and `WeakSet` remain available — they do not expose GC state.) +- **`WeakRef` and `FinalizationRegistry`**: Garbage collection timing is not deterministic, so observing it would make workflow code impossible to replay. (`WeakMap` and `WeakSet` remain available since they do not expose garbage collection state.) - **`Atomics.waitAsync`**: a wall-clock timer, which cannot be replayed. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. -- **Async `WebAssembly` compilation** (`compile`, `instantiate`, `compileStreaming`, `instantiateStreaming`): resolves on compile-thread timing. The synchronous `new WebAssembly.Module()` and `new WebAssembly.Instance()` constructors remain available. +- **Async `WebAssembly` compilation**: The `compile`, `instantiate`, `compileStreaming`, and `instantiateStreaming` methods resolve on compile-thread timing. The synchronous `new WebAssembly.Module()` and `new WebAssembly.Instance()` constructors remain available. diff --git a/docs/content/docs/v5/api-reference/workflow-nest/configure-workflow-controller.mdx b/docs/content/docs/v5/api-reference/workflow-nest/configure-workflow-controller.mdx index 670dbbab92..d7b7ab8d61 100644 --- a/docs/content/docs/v5/api-reference/workflow-nest/configure-workflow-controller.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nest/configure-workflow-controller.mdx @@ -9,7 +9,7 @@ prerequisites: Configures the output directory that [`WorkflowController`](/docs/api-reference/workflow-nest/workflow-controller) loads the generated workflow bundles (`steps.mjs`, `workflows.mjs`, `webhook.mjs`, `manifest.json`) from. -[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) calls this for you with its resolved `outDir` — call it yourself only when registering `WorkflowController` manually. The controller's route handlers throw if no directory has been configured. +[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) calls this for you with its resolved `outDir`. Call it yourself only when registering `WorkflowController` manually. The controller's route handlers throw if no directory has been configured. ## Usage @@ -20,7 +20,7 @@ import { configureWorkflowController } from "workflow/nest"; // [!code highlight configureWorkflowController(join(process.cwd(), ".nestjs/workflow")); // [!code highlight] ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v5/api-reference/workflow-nest/nest-local-builder.mdx b/docs/content/docs/v5/api-reference/workflow-nest/nest-local-builder.mdx index 6b9af587fd..1c4ac703c2 100644 --- a/docs/content/docs/v5/api-reference/workflow-nest/nest-local-builder.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nest/nest-local-builder.mdx @@ -7,7 +7,7 @@ prerequisites: - /docs/getting-started/nestjs --- -Builder that scans a NestJS project for workflow files and compiles them into the step, workflow, and webhook bundles plus a manifest. [`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) creates and runs one automatically on startup — instantiate it yourself only when you need to build bundles outside the module lifecycle (e.g. a custom build script for production with `skipBuild`). +Builder that scans a NestJS project for workflow files and compiles them into the step, workflow, and webhook bundles plus a manifest. [`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) creates and runs one automatically on startup. Instantiate it yourself only when you need to build bundles outside the module lifecycle (e.g. a custom build script for production with `skipBuild`). ## Usage @@ -23,7 +23,7 @@ await builder.build(); // [!code highlight] console.log(`Workflow bundles written to ${builder.outDir}`); ``` -## API Signature +## API signature ### Constructor @@ -43,7 +43,7 @@ console.log(`Workflow bundles written to ${builder.outDir}`); | `dirs` | `string[]` | `['src']` | Directories to scan for workflow files. | | `outDir` | `string` | `'.nestjs/workflow'` (relative to `workingDir`) | Output directory for generated workflow bundles. | | `watch` | `boolean` | `false` | Enable watch mode for development. | -| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. When `'commonjs'`, the builder rewrites externalized imports in the steps bundle to use `require()` via `createRequire`, avoiding ESM/CJS named-export interop issues with SWC's output. | +| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. When `'commonjs'`, the builder rewrites externalized imports in the steps bundle to use `require()` through `createRequire`, avoiding ECMAScript module (ESM) and CommonJS (CJS) named-export interop issues with SWC's output. | | `distDir` | `string` | `'dist'` | Directory where NestJS compiles `.ts` source files to `.js` (relative to `workingDir`). Used when `moduleType` is `'commonjs'` to resolve compiled file paths. Should match the `outDir` in your `tsconfig.json`. | | `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production. Can also be set via the `WORKFLOW_SOURCEMAP` environment variable. | @@ -57,7 +57,7 @@ Builds the workflow bundles. Writes `steps.mjs`, `workflows.mjs`, `webhook.mjs`, #### `outDir` -Read-only getter that returns the output directory for generated workflow bundles — the `outDir` option as passed, or the default `.nestjs/workflow` resolved against `workingDir`. +Read-only getter that returns the output directory for generated workflow bundles: the `outDir` option as passed, or the default `.nestjs/workflow` resolved against `workingDir`. ### Returns diff --git a/docs/content/docs/v5/api-reference/workflow-nest/workflow-controller.mdx b/docs/content/docs/v5/api-reference/workflow-nest/workflow-controller.mdx index c6382b7dad..530cf901a5 100644 --- a/docs/content/docs/v5/api-reference/workflow-nest/workflow-controller.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nest/workflow-controller.mdx @@ -9,7 +9,7 @@ prerequisites: NestJS controller that handles the well-known workflow endpoints under `.well-known/workflow/v1`. It dynamically imports the generated workflow bundles and converts between Express/Fastify requests and the Web API `Request`/`Response` objects the workflow runtime expects. Both the Express and Fastify HTTP adapters are supported. -[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) registers this controller automatically — you only register it yourself if you are not using `WorkflowModule`. +[`WorkflowModule.forRoot()`](/docs/api-reference/workflow-nest/workflow-module) registers this controller automatically. You only register it yourself if you are not using `WorkflowModule`. ## Usage diff --git a/docs/content/docs/v5/api-reference/workflow-nest/workflow-module.mdx b/docs/content/docs/v5/api-reference/workflow-nest/workflow-module.mdx index be051a197c..7c90310027 100644 --- a/docs/content/docs/v5/api-reference/workflow-nest/workflow-module.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nest/workflow-module.mdx @@ -40,9 +40,9 @@ import { WorkflowModule } from "workflow/nest"; export class AppModule {} ``` -## API Signature +## API signature -### Static Methods +### Static methods #### `forRoot(options?)` @@ -56,7 +56,7 @@ Configures the module and returns a NestJS `DynamicModule` registered as `global #### WorkflowModuleOptions -Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-builder#nestbuilderoptions) — all builder options are accepted, plus `skipBuild`: +Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-builder#nestbuilderoptions): all builder options are accepted, plus `skipBuild`: | Option | Type | Default | Description | | --- | --- | --- | --- | @@ -65,7 +65,7 @@ Extends [`NestBuilderOptions`](/docs/api-reference/workflow-nest/nest-local-buil | `dirs` | `string[]` | `['src']` | Directories to scan for workflow files. | | `outDir` | `string` | `'.nestjs/workflow'` (relative to `workingDir`) | Output directory for generated workflow bundles. | | `watch` | `boolean` | `false` | Enable watch mode for development. | -| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. Set to `'commonjs'` if your NestJS project compiles to CJS via SWC. | +| `moduleType` | `'es6' \| 'commonjs'` | `'es6'` | SWC module compilation type. Set to `'commonjs'` if your NestJS project compiles to CommonJS (CJS) through SWC. | | `distDir` | `string` | `'dist'` | Directory where NestJS compiles `.ts` source files to `.js` (relative to `workingDir`). Used when `moduleType` is `'commonjs'`. Should match the `outDir` in your `tsconfig.json`. | | `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Defaults to `'inline'` in development and `false` in production. Can also be set via the `WORKFLOW_SOURCEMAP` environment variable. | diff --git a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx index acfc585691..bfe65a85e2 100644 --- a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx +++ b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx @@ -1,13 +1,13 @@ --- title: withWorkflow -description: Configure webpack/turbopack to transform workflow directives in Next.js. +description: Configure webpack and Turbopack to transform workflow directives in Next.js. type: reference summary: Wrap your Next.js config with withWorkflow to enable workflow directive transformation. prerequisites: - /docs/getting-started/next --- -Configures webpack/turbopack loaders to transform workflow code (`"use step"`/`"use workflow"` directives) +Configures webpack and Turbopack loaders to transform workflow code (`"use step"` and `"use workflow"` directives). ## Usage @@ -16,13 +16,13 @@ To enable `"use step"` and `"use workflow"` directives while developing locally ```typescript title="next.config.ts" lineNumbers import { withWorkflow } from "workflow/next"; // [!code highlight] import type { NextConfig } from "next"; - + const nextConfig: NextConfig = { // … rest of your Next.js config }; // not required but allows configuring workflow options -const workflowConfig = {} +const workflowConfig = {}; export default withWorkflow(nextConfig, workflowConfig); // [!code highlight] ``` @@ -36,22 +36,22 @@ Remove that package from `serverExternalPackages` in your `next.config` to silence the warning. -### Workflow Discovery in Next.js +### Workflow discovery in Next.js -`withWorkflow()` discovers workflows by scanning your Next.js entrypoints — App -Router `route`, `page`, and `layout` files (under `app/` or `src/app/`) and any -file under `pages/` or `src/pages/` — for `start()` calls imported from +`withWorkflow()` discovers workflows by scanning your Next.js entrypoints (App +Router `route`, `page`, and `layout` files under `app/` or `src/app/`, and any +file under `pages/` or `src/pages/`) for `start()` calls imported from `workflow/api`. The workflow and step files themselves can live anywhere (for example `src/workflows/`); they are discovered transitively through imports, as long as a `start()` call in an entrypoint statically reaches them. Call `start()` from server-side entrypoints, including Route Handlers and Server -Actions. Don't call workflow functions directly — that bypasses the workflow +Actions. Don't call workflow functions directly, which bypasses the workflow runtime. -### Next.js Server Actions and `"use server"` +### Next.js server actions and `"use server"` Don't put a top-level `"use server"` directive in modules imported by workflow or step functions. Workflow transformation wraps imported modules in synchronous @@ -60,7 +60,7 @@ with errors like `Server Actions must be async functions`. Keep `"use server"` on the files that define your Server Actions, and move shared logic into separate modules that don't carry the directive. -### Monorepos and Workspace Imports +### Monorepos and workspace imports By default, Next.js detects the correct workspace root automatically. If your Next.js app lives in a subdirectory such as `apps/web` and workspace resolution is not working correctly, you can set `outputFileTracingRoot` as a workaround: @@ -107,7 +107,7 @@ export default withWorkflow(nextConfig, { ### Source maps -The step bundle and intermediate workflow bundle default to `'inline'` source maps **in development** — so stack traces from step errors and workflow VM errors point at your source files — and to **`false` in production**, so function bundles stay small. The `sourcemap` option lets you change that: +The step bundle and intermediate workflow bundle default to `'inline'` source maps **in development** (so stack traces from step errors and workflow virtual machine (VM) errors point at your source files) and to **`false` in production**, so function bundles stay small. The `sourcemap` option lets you change that: | Value | Behavior | | --- | --- | @@ -117,10 +117,10 @@ The step bundle and intermediate workflow bundle default to `'inline'` source ma | `'both'` | Emit both inline and external source maps. | | `false` | Omit source maps entirely. | -In production, source maps are already off by default. Setting `sourcemap: false` explicitly also turns them off in development, and it drops the inline source map from every bundle while skipping the source-map-support runtime shim on the Vercel step function (the same behavior production gets by default) — the main lever for staying under the Vercel 250MB function size limit. The tradeoff is that workflow VM stack traces will reference generated code (e.g. `evalmachine.`) rather than your source files. +In production, source maps are already off by default. Setting `sourcemap: false` explicitly also turns them off in development, and it drops the inline source map from every bundle while skipping the source-map-support runtime shim on the Vercel step function (the same behavior production gets by default), the main lever for staying under the Vercel 250 MB function size limit. The tradeoff is that workflow VM stack traces will reference generated code (for example, `evalmachine.`) rather than your source files. -Setting `sourcemap` explicitly affects **all** generated bundles (steps, workflows, webhook). The legacy `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING=1` environment variable is narrower — it only toggles source maps on the final workflow wrapper and webhook bundle (which default to off). It continues to work, but new code should use the `sourcemap` option or the `WORKFLOW_SOURCEMAP` environment variable instead. +Setting `sourcemap` explicitly affects **all** generated bundles (steps, workflows, webhook). The legacy `WORKFLOW_EMIT_SOURCEMAPS_FOR_DEBUGGING=1` environment variable is narrower: it only toggles source maps on the final workflow wrapper and webhook bundle (which default to off). It continues to work, but new code should use the `sourcemap` option or the `WORKFLOW_SOURCEMAP` environment variable instead. The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable, which accepts the same values plus `'0'` / `'1'` as aliases for `false` / `true`. Precedence is: explicit config > `WORKFLOW_SOURCEMAP` > the environment-aware default (`'inline'` in development, `false` in production). Development is detected from `next dev` / `NODE_ENV=development`, so the config option and the env var both let you force either behavior in either environment. @@ -129,7 +129,7 @@ The option can also be set via the `WORKFLOW_SOURCEMAP` environment variable, wh The `workflows.local` options only affect local development. When deployed to Vercel, the runtime ignores `local` settings and uses the Vercel world automatically. -## Exporting a Function +## Exporting a function If you are exporting a function in your `next.config` you will need to ensure you call the function returned from `withWorkflow`. @@ -158,4 +158,4 @@ export default async function config( } return nextConfig; } -``` +``` diff --git a/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx b/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx index 3064694525..20959ade75 100644 --- a/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nitro/index.mdx @@ -7,7 +7,7 @@ related: - /docs/getting-started/nitro --- -Nitro integration for Workflow SDK. The `workflow/nitro` entry point's default export is a [Nitro module](https://v3.nitro.build/guide/modules) — it has no callable API. You enable it by adding it to the `modules` array of your Nitro config and configure it via the `workflow` key. +Nitro integration for Workflow SDK. The `workflow/nitro` entry point's default export is a [Nitro module](https://v3.nitro.build/guide/modules): it has no callable API. You enable it by adding it to the `modules` array of your Nitro config and configure it via the `workflow` key. ## Usage @@ -26,10 +26,10 @@ When enabled, the module: - Builds the workflow, step, and webhook bundles, and rebuilds them on file changes in development. - Registers the workflow runtime routes under `/.well-known/workflow/v1/`. - Serves a redirect to the local observability dashboard at `/_workflow` in development. -- Configures Vercel function rules (queue triggers and `maxDuration`) for the workflow routes when deploying to Vercel. +- Configures function rules for Vercel Functions (queue triggers and `maxDuration`) on the workflow routes when deploying to Vercel. - Uses Nitro's `workspaceDir` as the workflow project root so monorepo apps can import sibling workspace packages without extra workflow config. -## Module Options +## Module options Options are read from the `workflow` key of your Nitro config. The option type is exported as `ModuleOptions`: @@ -51,9 +51,9 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | | `dirs` | `string[]` | — | Directories to scan for workflows and steps. By default, the `workflows/` directory is scanned from the project root and all layer source directories. | -| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense. | -| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). Only applies when deploying to Vercel. | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for integrated development environment (IDE) IntelliSense. | +| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (for example, `'nodejs22.x'` or `'nodejs24.x'`). Only applies when deploying to Vercel. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | ## Vite-based Nitro diff --git a/docs/content/docs/v5/api-reference/workflow-nuxt/index.mdx b/docs/content/docs/v5/api-reference/workflow-nuxt/index.mdx index 191931c40d..d223774197 100644 --- a/docs/content/docs/v5/api-reference/workflow-nuxt/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-nuxt/index.mdx @@ -7,7 +7,7 @@ related: - /docs/getting-started/nuxt --- -Nuxt integration for Workflow SDK. The `workflow/nuxt` entry point's default export is a Nuxt module — it has no callable API. You enable it by adding it to the `modules` array of your Nuxt config and configure it via the `workflow` key. +Nuxt integration for Workflow SDK. The `workflow/nuxt` entry point's default export is a Nuxt module: it has no callable API. You enable it by adding it to the `modules` array of your Nuxt config and configure it via the `workflow` key. ## Usage @@ -23,11 +23,11 @@ export default defineNuxtConfig({ When enabled, the module: - Registers the [`workflow/nitro`](/docs/api-reference/workflow-nitro) module on Nuxt's Nitro server, which transforms `"use workflow"` and `"use step"` directives, builds the workflow bundles, and registers the workflow runtime routes under `/.well-known/workflow/v1/`. -- Configures Vite to bundle (rather than externalize) the Workflow SDK packages in SSR mode so workflow code is transformed correctly. -- Enables the `workflow` TypeScript plugin by default for IDE IntelliSense. +- Configures Vite to bundle (rather than externalize) the Workflow SDK packages in server-side rendering (SSR) mode so workflow code is transformed correctly. +- Enables the `workflow` TypeScript plugin by default for integrated development environment (IDE) IntelliSense. - Uses Nuxt/Nitro's detected `workspaceDir` so monorepo apps can import sibling workspace packages without extra workflow config. -## Module Options +## Module options Options are read from the `workflow` key of your Nuxt config. The option type is exported as `ModuleOptions`: diff --git a/docs/content/docs/v5/api-reference/workflow-observability/hydrate-data.mdx b/docs/content/docs/v5/api-reference/workflow-observability/hydrate-data.mdx index a53d2a9f4b..cf7baa48d1 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/hydrate-data.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/hydrate-data.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/observability-revivers --- -Hydrates (deserializes) a single value that was stored by the workflow runtime. This is the lower-level building block behind [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) — use it when you have a raw serialized value rather than a whole resource, such as a single field from an event payload. +Hydrates (deserializes) a single value that was stored by the workflow runtime. This is the lower-level building block behind [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io). Use it when you have a raw serialized value rather than a whole resource, such as a single field from an event payload. ```typescript lineNumbers import { hydrateData, observabilityRevivers } from "workflow/observability"; // [!code highlight] @@ -17,7 +17,7 @@ declare const serialized: unknown; // @setup const value = hydrateData(serialized, observabilityRevivers); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -30,6 +30,6 @@ const value = hydrateData(serialized, observabilityRevivers); // [!code highligh The hydrated plain JavaScript value. The input is handled by shape: -- Format-prefixed binary data (`Uint8Array`) is decoded and parsed from the [devalue](https://github.com/Rich-Harris/devalue) format -- Encrypted data is returned as-is (a raw `Uint8Array`) — see [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data) -- Already-plain values (numbers, strings, `null`) are returned unchanged +- Format-prefixed binary data (`Uint8Array`) is decoded and parsed from the [devalue](https://github.com/Rich-Harris/devalue) format. +- Encrypted data is returned as-is (a raw `Uint8Array`). See [Encrypted data](/docs/api-reference/workflow-observability#encrypted-data). +- Already-plain values (numbers, strings, and `null`) are returned unchanged. diff --git a/docs/content/docs/v5/api-reference/workflow-observability/hydrate-resource-io.mdx b/docs/content/docs/v5/api-reference/workflow-observability/hydrate-resource-io.mdx index 0cdd9577ad..fd8c1ea85e 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/hydrate-resource-io.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/hydrate-resource-io.mdx @@ -10,7 +10,7 @@ related: - /docs/api-reference/workflow-runtime/world/storage --- -Hydrates (deserializes) the data fields of a resource returned by the [World SDK](/docs/api-reference/workflow-runtime/world) — a workflow run, step, hook, or event. Workflow data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format, so this is required before displaying step input/output in a UI. +Hydrates (deserializes) the data fields of a resource returned by the [World SDK](/docs/api-reference/workflow-runtime/world): a workflow run, step, hook, or event. Workflow data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format, so this is required before displaying step input/output in a user interface. The function dispatches on the resource shape: steps get `input`/`output` hydrated, hooks get `metadata`, events get `eventData`, and runs get `input`/`output`. @@ -26,7 +26,7 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl console.log(hydrated.input, hydrated.output); ``` -## API Signature +## API signature ### Parameters @@ -40,12 +40,12 @@ console.log(hydrated.input, hydrated.output); The same resource with its data fields hydrated into plain JavaScript values. - Encrypted data fields pass through as raw `Uint8Array` values rather than being decrypted — see [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data). + Encrypted data fields pass through as raw `Uint8Array` values rather than being decrypted. See [Encrypted Data](/docs/api-reference/workflow-observability#encrypted-data). ## Examples -### Display a Run's Steps with Hydrated I/O +### Display a run's steps with hydrated I/O ```typescript lineNumbers import { getWorld } from "workflow/runtime"; diff --git a/docs/content/docs/v5/api-reference/workflow-observability/index.mdx b/docs/content/docs/v5/api-reference/workflow-observability/index.mdx index ef6740fb8e..2c2100d191 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/index.mdx @@ -1,6 +1,6 @@ --- title: "workflow/observability" -description: Utilities to hydrate serialized step I/O and parse machine-readable workflow names for display. +description: Utilities to hydrate serialized step input/output (I/O) and parse machine-readable workflow names for display. type: overview summary: Explore utilities for hydrating serialized workflow data and parsing display names in observability tools. keywords: @@ -16,9 +16,7 @@ keywords: - display name parsing --- -API reference for observability utilities from the `workflow/observability` package. - -The observability package provides utilities for working with workflow data in observability and debugging tools — hydrating serialized step I/O for display, and parsing machine-readable names into display-friendly formats. +The `workflow/observability` package provides utilities for observability and debugging tools. Use it to hydrate serialized step input/output (I/O) for display and parse machine-readable names into display-friendly formats. ```typescript lineNumbers import { // [!code highlight] @@ -31,7 +29,7 @@ import { // [!code highlight] } from "workflow/observability"; // [!code highlight] ``` -## Data Hydration +## Data hydration @@ -45,7 +43,7 @@ import { // [!code highlight] -## Name Parsing +## Name parsing @@ -59,6 +57,6 @@ import { // [!code highlight] -## Encrypted Data +## Encrypted data -When a [World](/docs/api-reference/workflow-runtime/world) stores encrypted data, the hydration utilities intentionally leave encrypted values untouched: [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data) and [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) return encrypted fields as raw `Uint8Array` values so observability tools can detect them and decide how to render them (for example, the Workflow CLI shows an "Encrypted" placeholder). Decryption is handled by the runtime and the World implementation — see [Encryption](/docs/how-it-works/encryption) for how keys are managed. +When a [World](/docs/api-reference/workflow-runtime/world) stores encrypted data, the hydration utilities intentionally leave encrypted values untouched: [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data) and [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) return encrypted fields as raw `Uint8Array` values so observability tools can detect them and decide how to render them (for example, the Workflow CLI shows an "Encrypted" placeholder). Decryption is handled by the runtime and the World implementation. See [Encryption](/docs/how-it-works/encryption) for how keys are managed. diff --git a/docs/content/docs/v5/api-reference/workflow-observability/observability-revivers.mdx b/docs/content/docs/v5/api-reference/workflow-observability/observability-revivers.mdx index e58e956260..014b9e3b75 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/observability-revivers.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/observability-revivers.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/hydrate-data --- -A set of reviver functions that handle the workflow serialization format's workflow-specific types — streams, step/workflow function references, class instances, `AbortController`/`AbortSignal`, and `DOMException` — reviving them as display-friendly marker objects or strings. Built-in JavaScript types (`Date`, `Map`, `Set`, `RegExp`, etc.) are handled by the devalue format itself and need no revivers. +A set of reviver functions that handle the workflow serialization format's workflow-specific types (streams, step/workflow function references, class instances, `AbortController`/`AbortSignal`, and `DOMException`), reviving them as display-friendly marker objects or strings. Built-in JavaScript types (`Date`, `Map`, `Set`, `RegExp`, etc.) are handled by the devalue format itself and need no revivers. Pass it as the `revivers` argument to [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io) or [`hydrateData()`](/docs/api-reference/workflow-observability/hydrate-data). @@ -20,7 +20,7 @@ declare const step: Step; // @setup const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highlight] ``` -## API Signature +## API signature ```typescript import type { Revivers } from "workflow/observability"; diff --git a/docs/content/docs/v5/api-reference/workflow-observability/parse-class-name.mdx b/docs/content/docs/v5/api-reference/workflow-observability/parse-class-name.mdx index 8371184e46..23609e481c 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/parse-class-name.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/parse-class-name.mdx @@ -9,7 +9,7 @@ related: - /docs/api-reference/workflow-serde --- -Serialized class instances reference their class with machine-readable IDs like `class//./src/models//User`. This function parses them into components suitable for display in a UI. +Serialized class instances reference their class with machine-readable IDs like `class//./src/models//User`. This function parses them into components suitable for display in a user interface. ```typescript lineNumbers import { parseClassName } from "workflow/observability"; // [!code highlight] @@ -20,7 +20,7 @@ const parsed = parseClassName("class//./src/models//User"); // [!code highlight] // parsed?.functionName → "User" ``` -## API Signature +## API signature ### Parameters @@ -35,7 +35,7 @@ const parsed = parseClassName("class//./src/models//User"); // [!code highlight] | Property | Description | |----------|-------------| | `shortName` | The display name of the class (e.g. `"User"`). | -| `moduleSpecifier` | The module the class is defined in — a relative path (`./src/models`) or a package specifier (`point@0.0.1`). | +| `moduleSpecifier` | The module the class is defined in: a relative path (`./src/models`) or a package specifier (`point@0.0.1`). | | `functionName` | The class name as recorded by the compiler. | Returns `null` when the input is not a valid class ID. diff --git a/docs/content/docs/v5/api-reference/workflow-observability/parse-step-name.mdx b/docs/content/docs/v5/api-reference/workflow-observability/parse-step-name.mdx index bd00f9789e..efffbd21cc 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/parse-step-name.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/parse-step-name.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/parse-class-name --- -Step names are stored as machine-readable identifiers like `step//./src/workflows/order//processPayment`. This function parses them into components suitable for display in a UI. +Step names are stored as machine-readable identifiers like `step//./src/workflows/order//processPayment`. This function parses them into components suitable for display in a user interface. ```typescript lineNumbers import { parseStepName } from "workflow/observability"; // [!code highlight] @@ -19,7 +19,7 @@ const parsed = parseStepName("step//./src/workflows/order//processPayment"); // // parsed?.functionName → "processPayment" ``` -## API Signature +## API signature ### Parameters @@ -33,8 +33,8 @@ const parsed = parseStepName("step//./src/workflows/order//processPayment"); // | Property | Description | |----------|-------------| -| `shortName` | The display name — the last segment of the function name. For nested steps like `processOrder/chargeCard`, this is `"chargeCard"`. | -| `moduleSpecifier` | The module the step is defined in — a relative path (`./src/workflows/order`) or a package specifier (`@myorg/tasks@2.0.0`). | +| `shortName` | The display name: the last segment of the function name. For nested steps like `processOrder/chargeCard`, this is `"chargeCard"`. | +| `moduleSpecifier` | The module the step is defined in: a relative path (`./src/workflows/order`) or a package specifier (`@myorg/tasks@2.0.0`). | | `functionName` | The full function name including nesting (e.g. `processOrder/chargeCard`). | Returns `null` when the input is not a valid step name. diff --git a/docs/content/docs/v5/api-reference/workflow-observability/parse-workflow-name.mdx b/docs/content/docs/v5/api-reference/workflow-observability/parse-workflow-name.mdx index fb28d48faa..648743c385 100644 --- a/docs/content/docs/v5/api-reference/workflow-observability/parse-workflow-name.mdx +++ b/docs/content/docs/v5/api-reference/workflow-observability/parse-workflow-name.mdx @@ -8,7 +8,7 @@ related: - /docs/api-reference/workflow-observability/parse-class-name --- -Workflow names are stored as machine-readable identifiers like `workflow//./src/workflows/order//processOrder`. This function parses them into components suitable for display in a UI — for example when listing runs from the [World SDK](/docs/api-reference/workflow-runtime/world/storage), where `run.workflowName` holds the machine-readable form. +Workflow names are stored as machine-readable identifiers like `workflow//./src/workflows/order//processOrder`. This function parses them into components suitable for display in a user interface, for example when listing runs from the [World SDK](/docs/api-reference/workflow-runtime/world/storage), where `run.workflowName` holds the machine-readable form. ```typescript lineNumbers import { parseWorkflowName } from "workflow/observability"; // [!code highlight] @@ -19,7 +19,7 @@ const parsed = parseWorkflowName("workflow//./src/workflows/order//processOrder" // parsed?.functionName → "processOrder" ``` -## API Signature +## API signature ### Parameters @@ -34,12 +34,12 @@ const parsed = parseWorkflowName("workflow//./src/workflows/order//processOrder" | Property | Description | |----------|-------------| | `shortName` | The display name. For default exports, falls back to the module's short name (e.g. `"order"` for `./src/workflows/order`). | -| `moduleSpecifier` | The module the workflow is defined in — a relative path (`./src/workflows/order`) or a package specifier (`@myorg/flows@1.0.0`). | +| `moduleSpecifier` | The module the workflow is defined in: a relative path (`./src/workflows/order`) or a package specifier (`@myorg/flows@1.0.0`). | | `functionName` | The full exported function name. | Returns `null` when the input is not a valid workflow name. -## Example: List Runs with Display Names +## Example: list runs with display names ```typescript lineNumbers import { getWorld } from "workflow/runtime"; diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx index f3eb714d78..fe6b3970df 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/create-world.mdx @@ -9,9 +9,9 @@ related: - /docs/api-reference/workflow-runtime/set-world --- -Creates a new [World](/docs/api-reference/workflow-runtime/world) instance by invoking the World factory that was statically injected into the bundle at build time. Which implementation that is (for example the local development World or the Vercel production World) is decided when the app is built, via the `WORKFLOW_TARGET_WORLD` environment variable — changing the variable at runtime has no effect. +Creates a new [World](/docs/api-reference/workflow-runtime/world) instance by invoking the World factory that was statically injected into the bundle at build time. The `WORKFLOW_TARGET_WORLD` environment variable selects the implementation, such as the local development World or the Vercel production World, when the app is built. Changing the variable at runtime has no effect. -Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()` — `createWorld()` is for infrastructure code that manages World lifecycles itself. +Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), which caches a singleton instance, `createWorld()` constructs a fresh instance on every call. Application code should almost always use `getWorld()`. `createWorld()` is for infrastructure code that manages World lifecycles itself. ```typescript lineNumbers import { createWorld } from "workflow/runtime"; @@ -19,7 +19,7 @@ import { createWorld } from "workflow/runtime"; const world = await createWorld(); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -33,7 +33,7 @@ Returns a `Promise` with a newly constructed World instance. Tooling that needs to construct a World with explicit (non-environment) configuration should instantiate the specific World implementation directly and register it with [`setWorld()`](/docs/api-reference/workflow-runtime/set-world). -## Related Functions +## Related functions -- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance (preferred in application code). -- [`setWorld()`](/docs/api-reference/workflow-runtime/set-world) - Override the cached World instance. +- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): Resolve the cached World instance (preferred in application code). +- [`setWorld()`](/docs/api-reference/workflow-runtime/set-world): Override the cached World instance. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx index 4c2bfad0a4..8eeef050c2 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world-handlers.mdx @@ -9,7 +9,7 @@ prerequisites: Returns a restricted view of the [World](/docs/api-reference/workflow-runtime/world) exposing only the members that are safe to use at build time: `createQueueHandler` and `specVersion`. Framework adapters use it while generating workflow route handlers, before the deployment's runtime environment variables exist. -Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance — caching at build time would lock in incomplete environment configuration. +Unlike [`getWorld()`](/docs/api-reference/workflow-runtime/get-world), this function does not cache a fully configured World instance: caching at build time would lock in incomplete environment configuration. ```typescript lineNumbers import { getWorldHandlers } from "workflow/runtime"; @@ -18,7 +18,7 @@ const handlers = await getWorldHandlers(); // [!code highlight] console.log(handlers.specVersion); ``` -## API Signature +## API signature ### Parameters @@ -38,7 +38,7 @@ type WorldHandlers = Pick; This is SDK infrastructure used by framework adapters and the workflow entrypoint. Application code should use [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) instead. -## Related Functions +## Related functions -- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the full World instance at runtime. -- [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint) - The route handler factory built on these handlers. +- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): Resolve the full World instance at runtime. +- [`workflowEntrypoint()`](/docs/api-reference/workflow-runtime/workflow-entrypoint): The route handler factory built on these handlers. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx index f094291e18..d4045b4b2a 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/get-world.mdx @@ -17,7 +17,7 @@ import { getWorld } from "workflow/runtime"; const world = await getWorld(); // [!code highlight] ``` -## API Signature +## API signature ### Parameters @@ -50,7 +50,7 @@ The World object provides access to several entity interfaces. See the [World SD -## Data Hydration +## Data hydration Step and run data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `workflow/observability` to hydrate it for display: @@ -63,7 +63,7 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl See [`workflow/observability`](/docs/api-reference/workflow-observability) for the full hydration and parsing API. -### List Workflow Runs (Display Names) +### List workflow runs (display names) List workflow runs and derive human-readable names from the `workflowName` field: @@ -114,7 +114,7 @@ export async function GET(req: Request) { and `moduleSpecifier` for display in your UI. -## Related Functions +## Related functions - [`getRun()`](/docs/api-reference/workflow-api/get-run) - Higher-level API for working with individual runs by ID. - [`start()`](/docs/api-reference/workflow-api/start) - Start a new workflow run. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/health-check.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/health-check.mdx index 4944186c99..a52ab6f165 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/health-check.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/health-check.mdx @@ -20,7 +20,7 @@ if (!result.healthy) { } ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx index 5111d655d9..173719390b 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/index.mdx @@ -5,9 +5,7 @@ type: overview summary: Explore runtime functions for resolving the World instance and configuring workflow infrastructure. --- -API reference for runtime functions from the `workflow/runtime` package. - -The runtime package provides low-level access to the workflow runtime — resolving the [World](/docs/api-reference/workflow-runtime/world) instance that backs storage, queuing, and streaming, and wiring up workflow infrastructure in custom server environments. +The `workflow/runtime` package provides low-level access to the workflow runtime. Use it to resolve the [World](/docs/api-reference/workflow-runtime/world) instance that backs storage, queuing, and streaming or to wire up workflow infrastructure in custom server environments. ## Functions @@ -20,7 +18,7 @@ The runtime package provides low-level access to the workflow runtime — resolv -## Infrastructure Functions +## Infrastructure functions These functions are primarily used by framework adapters and custom world setups, and are rarely needed in application code: diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx index 0a78b0a6b4..ef144d5adf 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/set-world.mdx @@ -20,7 +20,7 @@ setWorld(customWorld); // [!code highlight] const world = await getWorld(); // resolves customWorld ``` -## API Signature +## API signature ### Parameters @@ -32,9 +32,9 @@ const world = await getWorld(); // resolves customWorld This function does not return a value. -## Example: Inject a Specific World +## Example: inject a specific World -The target World is selected at build time (via `WORKFLOW_TARGET_WORLD` when the app was built) and statically injected into the bundle — changing the environment variable at runtime has no effect. To use a different World at runtime, construct it explicitly with the World package's `createWorld()` factory and inject it: +The build selects the target World via `WORKFLOW_TARGET_WORLD` and statically injects it into the bundle. Changing the environment variable at runtime has no effect. To use a different World at runtime, construct it explicitly with the World package's `createWorld()` factory and inject it: ```typescript lineNumbers import { setWorld } from "workflow/runtime"; @@ -45,7 +45,7 @@ setWorld(createWorld({ dataDir: "/tmp/workflow-test" })); // [!code highlight] Calling `setWorld(undefined)` afterwards restores the build-injected World on the next `getWorld()` call. -## Related Functions +## Related functions -- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world) - Resolve the cached World instance. -- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world) - Construct a fresh instance of the build-injected World. +- [`getWorld()`](/docs/api-reference/workflow-runtime/get-world): Resolve the cached World instance. +- [`createWorld()`](/docs/api-reference/workflow-runtime/create-world): Construct a fresh instance of the build-injected World. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx index efce612990..d1d776b51e 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/workflow-entrypoint.mdx @@ -11,7 +11,7 @@ related: Creates the HTTP route handler that executes workflow runs. The handler receives queue messages, replays the workflow from its event log, executes steps inline where possible, and suspends when the workflow waits on sleeps or hooks. -Framework adapters (Next.js, Nitro, SvelteKit, etc.) call this for you and mount the result at `/.well-known/workflow/v1/flow` — you only need it when wiring workflow support into a custom server environment. +Framework adapters (Next.js, Nitro, SvelteKit, etc.) call this for you and mount the result at `/.well-known/workflow/v1/flow`. You only need it when wiring workflow support into a custom server environment. ```typescript lineNumbers import { workflowEntrypoint } from "workflow/runtime"; @@ -23,7 +23,7 @@ const handler = workflowEntrypoint(workflowBundleCode); // [!code highlight] export const POST = (req: Request) => handler(req); ``` -## API Signature +## API signature ### Parameters @@ -36,7 +36,7 @@ export const POST = (req: Request) => handler(req); Returns a fetch-style request handler: `(req: Request) => Promise`. -## Related Functions +## Related functions -- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers) - The build-time World access this handler is built on. -- [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check) - Verify the entrypoint processes queue messages end-to-end. +- [`getWorldHandlers()`](/docs/api-reference/workflow-runtime/get-world-handlers): The build-time World access this handler is built on. +- [`healthCheck()`](/docs/api-reference/workflow-runtime/health-check): Verify the entrypoint processes queue messages end-to-end. diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/analytics.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/analytics.mdx index a29d002e25..a5cd1a32a3 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/analytics.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/analytics.mdx @@ -20,7 +20,7 @@ keywords: - metadata-only --- -`world.analytics` is an optional, read-only namespace for observability surfaces — dashboards, CLIs, and admin tools that list large numbers of runs without touching payload data. +`world.analytics` is an optional, read-only namespace for observability surfaces: dashboards, command-line interface (CLI) tools, and admin tools that list large numbers of runs without touching payload data. For observability and inspection listings, prefer this namespace over [`world.runs.list()`](/docs/api-reference/workflow-runtime/world/storage#runslist). @@ -28,10 +28,10 @@ The storage API remains available for operational and payload-bearing reads. It differs from [Storage](/docs/api-reference/workflow-runtime/world/storage) in two ways: -- **Metadata only.** Results never include run input/output, step data, or hook tokens. There is no `resolveData` option. -- **Served from the observability pipeline.** On Vercel, queries are served from the Vercel observability data pipeline, so large listings do not compete with workflow execution. Data is ingested asynchronously and may trail the live state by a few seconds. +- **Metadata only**: Results never include run input/output, step data, or hook tokens. There is no `resolveData` option. +- **Served from the observability pipeline**: On Vercel, queries are served from the Vercel observability data pipeline, so large listings do not compete with workflow execution. Data is ingested asynchronously and may trail the live state by a few seconds. -The namespace is optional — worlds that don't implement it (such as the local development world) leave it `undefined`, so feature-detect before use: +The namespace is optional: worlds that don't implement it (such as the local development world) leave it `undefined`, so feature-detect before use: ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -67,13 +67,13 @@ const page = await world.analytics.runs.list({ | `params.attributes` | `Record` | Only return runs whose latest attributes match every pair (up to 8) | | `params.pagination` | `PaginationOptions` | Cursor pagination | -**Returns:** `PaginatedResponse` — each run includes `runId`, `status`, `workflowName`, `deploymentId`, `attributes`, and lifecycle timestamps. +**Returns:** `PaginatedResponse`. Each run includes `runId`, `status`, `workflowName`, `deploymentId`, `attributes`, and lifecycle timestamps. Attribute matching is latest-write-wins: a run whose attribute moved from `"v1"` to `"v2"` no longer matches `{ key: "v1" }`. Reserved `$`-prefixed keys may be used in filters even though user code cannot write them. ### runs.get() -Fetch one run by ID. Point lookups search the full plan window, not just the trailing 24 hours. +Fetch one run by ID. Point lookups search the full plan window rather than only the trailing 24 hours. ```typescript lineNumbers const run = await world.analytics.runs.get(runId); @@ -83,7 +83,7 @@ const run = await world.analytics.runs.get(runId); ## analytics.attributes -Discover which [attributes](/docs/observability/attributes) exist on your runs — for example to build filter dropdowns over arbitrary user-defined keys. +Discover which [attributes](/docs/observability/attributes) exist on your runs, for example to build filter dropdowns over arbitrary user-defined keys. ### attributes.list() @@ -104,7 +104,7 @@ for (const { key, runCount, lastSeenAt } of page.data) { | `params.startTime` / `params.endTime` | `string` | ISO 8601 window; must be provided together | | `params.pagination` | `PaginationOptions` | Cursor pagination | -**Returns:** `PaginatedResponse` — `{ key, runCount, firstSeenAt, lastSeenAt }` +**Returns:** `PaginatedResponse`: `{ key, runCount, firstSeenAt, lastSeenAt }` --- @@ -120,7 +120,7 @@ const hooks = await world.analytics.hooks.list({ runId }); const waits = await world.analytics.waits.list({ runId, status: "waiting" }); ``` -Each namespace also has a `get()` for point lookups (`steps.get(runId, stepId)`, `events.get(runId, eventId)`, `hooks.get(hookId)`, `waits.get(runId, waitId)`). Hook listings never include the hook token — resolve it separately through the runtime APIs if you need to deliver a payload. +Each namespace also has a `get()` for point lookups (`steps.get(runId, stepId)`, `events.get(runId, eventId)`, `hooks.get(hookId)`, `waits.get(runId, waitId)`). Hook listings never include the hook token. Resolve it separately through the runtime APIs if you need to deliver a payload. --- diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx index 21a7ea5731..74925b47a3 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/index.mdx @@ -14,7 +14,7 @@ keywords: - workflow management --- -The World SDK provides direct access to workflow infrastructure — runs, steps, events, hooks, streams, and queues — plus a metadata-only [Analytics](/docs/api-reference/workflow-runtime/world/analytics) namespace with attribute discovery and filtering. Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic. +The World SDK provides direct access to workflow infrastructure, including runs, steps, events, hooks, streams, and queues. It also provides a metadata-only [Analytics](/docs/api-reference/workflow-runtime/world/analytics) namespace with attribute discovery and filtering. Use it to build observability dashboards, admin panels, debugging tools, and custom workflow management logic. ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -43,11 +43,11 @@ const world = await getWorld(); // [!code highlight] The World SDK is the low-level foundation that higher-level functions like [`getRun()`](/docs/api-reference/workflow-api/get-run) and [`start()`](/docs/api-reference/workflow-api/start) are built on. Use it when you need capabilities beyond what those functions provide. -Beyond these namespaces, the `World` interface carries a handful of top-level members aimed at World authors — `specVersion`, `capabilities`, lifecycle hooks (`start()`/`close()`), `getEncryptionKeyForRun()`, and the optional `createRunId()` / `describeRun()` hooks behind regional run placement and world-specific `inspect` output. Those are documented in [Building a World](/worlds/building-a-world). +Beyond these namespaces, the `World` interface carries several top-level members for World authors: `specVersion`, `capabilities`, lifecycle hooks (`start()`/`close()`), `getEncryptionKeyForRun()`, and the optional `createRunId()` / `describeRun()` hooks behind regional run placement and World-specific `inspect` output. [Building a World](/worlds/building-a-world) documents these members. -## Data Hydration +## Data hydration -Step input/output data is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. To display this data in your UI, use the hydration utilities from `workflow/observability`: +The [devalue](https://github.com/Rich-Harris/devalue) format serializes step input/output data. To display this data in your UI, use the hydration utilities from `workflow/observability`: ```typescript lineNumbers import { hydrateResourceIO, observabilityRevivers } from "workflow/observability"; // [!code highlight] diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx index 596836dd5b..15bf72a259 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/queue.mdx @@ -2,7 +2,7 @@ title: Queue description: Low-level queue interface for dispatching workflow invocations. type: reference -summary: "Methods: getDeploymentId(), queue(), createQueueHandler(). Internal queue dispatch — normally handled by the SDK." +summary: "Methods: getDeploymentId(), queue(), createQueueHandler(). The SDK normally handles internal queue dispatch." prerequisites: - /docs/api-reference/workflow-runtime/get-world related: @@ -20,7 +20,7 @@ keywords: Queue methods live directly on the `world` object (not nested). They dispatch internal workflow invocations, including queued step work, to the queue backend. - These methods are used internally by the Workflow SDK to dispatch execution. You do not need to call them in normal operations — use [`start()`](/docs/api-reference/workflow-api/start) to trigger workflows instead. Direct queue access is only needed if you programmatically create a run via `world.events.create()` with a `run_created` event and need to kick off its initial execution, or for debugging flow resumption. + The Workflow SDK uses these methods internally to dispatch execution. In normal operations, use [`start()`](/docs/api-reference/workflow-api/start) to trigger workflows instead. You only need direct queue access to start the initial execution of a run you programmatically created via `world.events.create()` with a `run_created` event, or to debug flow resumption. ## Import @@ -29,20 +29,20 @@ Queue methods live directly on the `world` object (not nested). They dispatch in import { getWorld } from "workflow/runtime"; const world = await getWorld(); // [!code highlight] -// Queue methods are called directly on world — e.g. world.queue() +// Call queue methods directly on world, for example, world.queue() ``` ## Methods ### getDeploymentId() -Get the current deployment ID. Used internally for routing queue messages to the correct deployment. +Get the current deployment ID. The SDK uses it internally to route queue messages to the correct deployment. ```typescript lineNumbers const deploymentId = await world.getDeploymentId(); // [!code highlight] ``` -**Returns:** `string` — The current deployment ID +**Returns:** `string`. The current deployment ID. ### queue() @@ -58,13 +58,13 @@ const { messageId } = await world.queue(queueName, payload, opts); // [!code hig |-----------|------|-------------| | `queueName` | `ValidQueueName` | The queue name (branded string) | | `message` | `QueuePayload` | Internal SDK payload | -| `opts` | `QueueOptions` | Optional — `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers`, `region` (regional routing hint), `specVersion` | +| `opts` | `QueueOptions` | Optional: `deploymentId`, `idempotencyKey`, `delaySeconds`, `headers`, `region` (regional routing hint), `specVersion` | **Returns:** `{ messageId: MessageId | null }` ### createQueueHandler() -Create an HTTP handler that processes messages from a queue. Used to set up the queue consumer endpoint. +Create an HTTP handler that processes messages from a queue. Use it to set up the queue consumer endpoint. ```typescript lineNumbers const handler = world.createQueueHandler(prefix, callback); // [!code highlight] @@ -79,10 +79,10 @@ const handler = world.createQueueHandler(prefix, callback); // [!code highlight] **Returns:** `(req: Request) => Promise` -`meta.messageId` should be stable across redeliveries of the same message (one ID per enqueued message, reused on every delivery attempt). The runtime records it on inline `step_started` events as a liveness lease so that only a redelivery of the owning message re-executes a crashed inline step before the lease expires (see [Inline step message ownership](/docs/changelog/step-message-ownership)). A World whose queue mints a fresh ID per delivery degrades gracefully — crashed inline steps recover via the delayed backstop instead of immediately on redelivery — but never wedges or duplicates. +`meta.messageId` should be stable across redeliveries of the same message (one ID per enqueued message, reused on every delivery attempt). The runtime records it on inline `step_started` events as a liveness lease so that only a redelivery of the owning message re-executes a crashed inline step before the lease expires (see [Inline step message ownership](/docs/changelog/step-message-ownership)). A World whose queue mints a fresh ID per delivery degrades gracefully. Crashed inline steps recover via the delayed backstop instead of immediately on redelivery, but never wedge or duplicate. ## Related -- [start()](/docs/api-reference/workflow-api/start) — The standard way to start workflow runs -- [Starting Workflows](/docs/foundations/starting-workflows) — Core concepts for workflow invocation -- [Storage](/docs/api-reference/workflow-runtime/world/storage) — Create events that trigger queue dispatch +- [start()](/docs/api-reference/workflow-api/start): The standard way to start workflow runs +- [Starting Workflows](/docs/foundations/starting-workflows): Core concepts for workflow invocation +- [Storage](/docs/api-reference/workflow-runtime/world/storage): Create events that trigger queue dispatch diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx index 4bd1114fbd..15932175d2 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/storage.mdx @@ -31,8 +31,8 @@ keywords: The World storage interface exposes four sub-interfaces for querying workflow data: -- **`world.events`** — The append-only event log. This is the source of truth for all workflow state. See [Event Sourcing](/docs/how-it-works/event-sourcing) for background. -- **`world.runs`**, **`world.steps`**, **`world.hooks`** — Materialized views derived from the event log, provided as convenience accessors for the most common query patterns. +- **`world.events`**: The append-only event log. This is the source of truth for all workflow state. See [Event Sourcing](/docs/how-it-works/event-sourcing) for background. +- **`world.runs`**, **`world.steps`**, **`world.hooks`**: Materialized views derived from the event log, provided as convenience accessors for the most common query patterns. ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -62,7 +62,7 @@ await world.events.create(runId, { // [!code highlight] | `data` | `CreateEventRequest` | Event data including `eventType` | | `params` | `object` | Optional parameters | -**Returns:** `EventResult` — The created event and the affected entity (run/step/hook) +**Returns:** `EventResult`, the created event and the affected entity (run/step/hook) ### events.get() @@ -119,7 +119,7 @@ const result = await world.events.listByCorrelationId({ // [!code highlight] **Returns:** `{ data: Event[], cursor?: string }` -### Event Types +### Event types | Category | Types | |----------|-------| @@ -167,11 +167,11 @@ const run = await world.runs.waitForTerminalStatus?.(runId, { // [!code highligh | Parameter | Type | Description | |-----------|------|-------------| | `runId` | `string` | The workflow run ID | -| `params.timeoutMs` | `number` | Upper bound on the wait — the call returns earlier, the moment the run is terminal | +| `params.timeoutMs` | `number` | Upper bound on the wait. The call returns earlier, the moment the run is terminal | | `params.signal` | `AbortSignal` | Abandons the wait | | `params.resolveData` | `'all' \| 'none'` | Whether to include input/output data. Default: `'all'` | -**Returns:** the same `WorkflowRun` as `runs.get()` — terminal if the run +**Returns:** the same `WorkflowRun` as `runs.get()`: terminal if the run finished within the budget, otherwise the latest snapshot. An expired budget is a normal return, not an error, and a missing run throws `WorkflowRunNotFoundError` exactly as `runs.get()` does. @@ -179,8 +179,8 @@ a normal return, not an error, and a missing run throws Not every backend can hold a read open, so this method is optional and may also return a non-terminal snapshot before `timeoutMs` is up. Callers pace - their own retries — `await run.returnValue` keeps consecutive non-terminal - observations at least one `WORKFLOW_RETURN_VALUE_POLL_INTERVAL_MS` apart — and + their own retries (`await run.returnValue` keeps consecutive non-terminal + observations at least one `WORKFLOW_RETURN_VALUE_POLL_INTERVAL_MS` apart), and worlds that omit the method are polled on that interval instead. Set `WORKFLOW_RETURN_VALUE_LONG_POLL=0` to force interval polling everywhere. @@ -208,13 +208,13 @@ const result = await world.runs.list({ // [!code highlight] reads. -### Cancelling Runs +### Cancelling runs -To cancel a run, create a `run_cancelled` event via `world.events.create()` (see [world.events](#worldevents) above), or use the CLI or Web UI helpers. +To cancel a run, create a `run_cancelled` event through `world.events.create()` (see [world.events](#worldevents) above), or use the Workflow CLI or web interface helpers. To cancel a batch in one call, a world may implement the optional `runs.cancelMany({ runIds })`. It returns a summary plus a per-run outcome (`cancelled`, `already_cancelled`, `not_cancellable`, `not_found`, or `failed`). Backends that omit it fall back to per-run cancellation automatically. -### WorkflowRun Type +### WorkflowRun type | Field | Type | Description | |-------|------|-------------| @@ -268,7 +268,7 @@ const result = await world.steps.list({ // [!code highlight] **Returns:** `{ data: Step[], cursor?: string }` -### Step Type +### Step type | Field | Type | Description | |-------|------|-------------| @@ -285,11 +285,11 @@ const result = await world.steps.list({ // [!code highlight] | `retryAfter` | `string \| null` | ISO timestamp for next retry attempt | - Step I/O is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `hydrateResourceIO()` from `workflow/observability` to deserialize it for display. See [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io). + Step input/output (I/O) is serialized using the [devalue](https://github.com/Rich-Harris/devalue) format. Use `hydrateResourceIO()` from `workflow/observability` to deserialize it for display. See [`hydrateResourceIO()`](/docs/api-reference/workflow-observability/hydrate-resource-io). - `stepName` is a machine-readable identifier like `step//./src/workflows/order//processPayment`. Use `parseStepName()` from `workflow/observability` to extract the `shortName` for UI display. + `stepName` is a machine-readable identifier like `step//./src/workflows/order//processPayment`. Use `parseStepName()` from `workflow/observability` to extract the `shortName` for display in a user interface. --- @@ -317,7 +317,7 @@ Look up a hook by its token. Useful in webhook resume flows where you receive a For runtime application code, prefer [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token). Use `world.hooks.getByToken()` when you are working directly with the World storage interface for custom tooling, admin views, or low-level integrations. -Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first — on a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). +Hook-token lookup is the low-level form of the recommended idempotency flow: if a hook is already registered for your business key, reuse the hook's `runId` or resume that hook instead of starting another run. If no hook exists yet, start a workflow that creates the deterministic hook near the beginning and checks `await hook.getConflict()` to detect whether another run claimed the token first. On a conflict it resolves with the run that owns the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). ```typescript lineNumbers @@ -344,7 +344,7 @@ const result = await world.hooks.list({ // [!code highlight] **Returns:** `{ data: Hook[], cursor?: string }` -### Hook Type +### Hook type | Field | Type | Description | |-------|------|-------------| @@ -361,7 +361,7 @@ const result = await world.hooks.list({ // [!code highlight] ## Examples -### List Runs with Pagination +### List runs with pagination ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -376,23 +376,23 @@ const runs = await world.runs.list({ // [!code highlight] cursor = runs.cursor; // pass to next call for pagination ``` -### Get a Run — Full Data vs. Metadata Only +### Get a run: full data vs. metadata only ```typescript lineNumbers import { getWorld } from "workflow/runtime"; const world = await getWorld(); -// Full data (default) — includes serialized input/output +// Full data (default): includes serialized input/output const run = await world.runs.get(runId); // [!code highlight] -// Metadata only — lighter, no I/O loaded +// Metadata only: lighter, no I/O loaded const lightweight = await world.runs.get(runId, { // [!code highlight] resolveData: "none", // [!code highlight] }); // [!code highlight] ``` -### List Steps for a Progress Dashboard +### List steps for a progress dashboard ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -414,7 +414,7 @@ const progress = steps.data.map((step) => { }); ``` -### Hydrate Step I/O +### Hydrate step I/O ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -426,7 +426,7 @@ const hydrated = hydrateResourceIO(step, observabilityRevivers); // [!code highl console.log(hydrated.input, hydrated.output); ``` -### Cancel a Run +### Cancel a run ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -437,7 +437,7 @@ await world.events.create(runId, { // [!code highlight] }); // [!code highlight] ``` -### Look Up Hook by Token +### Look up hook by token ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -447,7 +447,7 @@ const hook = await world.hooks.getByToken(token); // [!code highlight] console.log(hook.runId, hook.metadata); // [!code highlight] ``` -### List Events for Audit Trail +### List events for audit trail ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -462,9 +462,9 @@ for (const event of events.data) { ## Related -- [Event Sourcing](/docs/how-it-works/event-sourcing) — How the event log powers workflow replay and state -- [getRun()](/docs/api-reference/workflow-api/get-run) — Higher-level API for working with individual runs -- [`workflow/observability`](/docs/api-reference/workflow-observability) — Hydrate step I/O and parse display names -- [resumeHook()](/docs/api-reference/workflow-api/resume-hook) — Resume a workflow by sending a payload to a hook -- [Hooks](/docs/foundations/hooks) — Core concepts for hooks and pause points -- [Workflows and Steps](/docs/foundations/workflows-and-steps) — Core concepts for steps +- [Event sourcing](/docs/how-it-works/event-sourcing): How the event log powers workflow replay and state +- [`getRun()`](/docs/api-reference/workflow-api/get-run): Higher-level API for working with individual runs +- [`workflow/observability`](/docs/api-reference/workflow-observability): Hydrate step I/O and parse display names +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a workflow by sending a payload to a hook +- [Hooks](/docs/foundations/hooks): Core concepts for hooks and pause points +- [Workflows and steps](/docs/foundations/workflows-and-steps): Core concepts for steps diff --git a/docs/content/docs/v5/api-reference/workflow-runtime/world/streams.mdx b/docs/content/docs/v5/api-reference/workflow-runtime/world/streams.mdx index b458b6c940..9ef4b396d3 100644 --- a/docs/content/docs/v5/api-reference/workflow-runtime/world/streams.mdx +++ b/docs/content/docs/v5/api-reference/workflow-runtime/world/streams.mdx @@ -33,7 +33,7 @@ Stream methods live on `world.streams` (the `streams` sub-object of the `World` import { getWorld } from "workflow/runtime"; const world = await getWorld(); // [!code highlight] -// Stream methods are called on world.streams — e.g. world.streams.write() +// Stream methods are called on world.streams, e.g. world.streams.write() ``` ## Methods @@ -56,7 +56,7 @@ await world.streams.write(runId, "default", chunk); // [!code highlight] ### writeMulti() -Write multiple chunks in a single operation. Optional optimization — not all World implementations support it. Falls back to sequential `write()` calls if unavailable. +Write multiple chunks in a single operation. Optional optimization: not all World implementations support it. Falls back to sequential `write()` calls if unavailable. ```typescript lineNumbers await world.streams.writeMulti?.(runId, "default", [chunk1, chunk2]); // [!code highlight] @@ -173,7 +173,7 @@ const info = await world.streams.getInfo(runId, "default"); // [!code highlight] ## Examples -### Read a Stream as a Response +### Read a stream as a response ```typescript lineNumbers // app/api/workflow-streams/read/route.ts @@ -192,7 +192,7 @@ export async function GET(req: Request) { } ``` -### Paginate Through Stream Chunks +### Paginate through stream chunks ```typescript lineNumbers import { getWorld } from "workflow/runtime"; @@ -211,6 +211,6 @@ do { ## Related -- [Streaming](/docs/foundations/streaming) — Core concepts for streaming data from workflows -- [getWritable()](/docs/api-reference/workflow/get-writable) — The standard way to write to streams from within steps -- [Storage](/docs/api-reference/workflow-runtime/world/storage) — Query runs, steps, hooks, and events +- [Streaming](/docs/foundations/streaming): Core concepts for streaming data from workflows +- [`getWritable()`](/docs/api-reference/workflow/get-writable): The standard way to write to streams from within steps +- [Storage](/docs/api-reference/workflow-runtime/world/storage): Query runs, steps, hooks, and events diff --git a/docs/content/docs/v5/api-reference/workflow-serde/index.mdx b/docs/content/docs/v5/api-reference/workflow-serde/index.mdx index edb147d0cf..8980cd48ed 100644 --- a/docs/content/docs/v5/api-reference/workflow-serde/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow-serde/index.mdx @@ -27,7 +27,7 @@ The `@workflow/serde` package provides two symbols that allow you to define cust -## Quick Example +## Quick example ```typescript lineNumbers diff --git a/docs/content/docs/v5/api-reference/workflow-serde/workflow-deserialize.mdx b/docs/content/docs/v5/api-reference/workflow-serde/workflow-deserialize.mdx index aec181f061..432dd025f0 100644 --- a/docs/content/docs/v5/api-reference/workflow-serde/workflow-deserialize.mdx +++ b/docs/content/docs/v5/api-reference/workflow-serde/workflow-deserialize.mdx @@ -23,7 +23,7 @@ class Point { } ``` -## API Signature +## API signature {/* @skip-typecheck: type-only signature snippet, not compilable code */} @@ -65,5 +65,5 @@ This method runs inside the workflow context and is subject to the same constrai - No non-deterministic operations (like `Math.random()` or `Date.now()`) - No external network calls -Keep this method simple and focused on reconstructing the instance from the provided data. +Keep this method focused on reconstructing the instance from the provided data. diff --git a/docs/content/docs/v5/api-reference/workflow-serde/workflow-serialize.mdx b/docs/content/docs/v5/api-reference/workflow-serde/workflow-serialize.mdx index a5380c3188..6986fac5f9 100644 --- a/docs/content/docs/v5/api-reference/workflow-serde/workflow-serialize.mdx +++ b/docs/content/docs/v5/api-reference/workflow-serde/workflow-serialize.mdx @@ -2,7 +2,7 @@ title: WORKFLOW_SERIALIZE --- -A symbol used to define custom serialization for user-defined class instances. The static method should accept an instance and return serializable data. +`WORKFLOW_SERIALIZE` defines custom serialization for user-defined class instances. The static method accepts an instance and returns serializable data. ## Usage @@ -23,7 +23,7 @@ class Point { } ``` -## API Signature +## API signature {/* @skip-typecheck: type-only signature snippet, not compilable code */} @@ -60,16 +60,16 @@ The method should return serializable data. This can be: The method must be implemented as a **static** method on the class. Instance methods are not supported. -- Both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented together -- The returned data must itself be serializable -- The SWC compiler plugin automatically detects and registers classes that implement these symbols +- Both `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` must be implemented together. +- The returned data must itself be serializable. +- The SWC compiler plugin automatically detects and registers classes that implement these symbols. This method runs inside the workflow context and is subject to the same constraints as `"use workflow"` functions: - No Node.js-specific APIs (like `fs`, `path`, `crypto`, etc.) - No non-deterministic operations (like `Math.random()` or `Date.now()`) - No external network calls -- No side effects on workflow state — the method may run outside deterministic replay, so mutations would not be reconstructed +- No side effects on workflow state: the method may run outside deterministic replay, so mutations would not be reconstructed -Keep this method simple and focused on extracting data from the instance. +Keep this method focused on extracting data from the instance. diff --git a/docs/content/docs/v5/api-reference/workflow-sveltekit/workflow-plugin.mdx b/docs/content/docs/v5/api-reference/workflow-sveltekit/workflow-plugin.mdx index 99d420f60c..cc93490983 100644 --- a/docs/content/docs/v5/api-reference/workflow-sveltekit/workflow-plugin.mdx +++ b/docs/content/docs/v5/api-reference/workflow-sveltekit/workflow-plugin.mdx @@ -23,7 +23,7 @@ export default defineConfig({ }); ``` -## API Signature +## API signature ### Parameters @@ -35,8 +35,8 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | ### Returns -Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config — Vite flattens nested plugin arrays automatically. +Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config. Vite flattens nested plugin arrays automatically. diff --git a/docs/content/docs/v5/api-reference/workflow-vite/workflow.mdx b/docs/content/docs/v5/api-reference/workflow-vite/workflow.mdx index 3a7ec3b46a..d1bab6798b 100644 --- a/docs/content/docs/v5/api-reference/workflow-vite/workflow.mdx +++ b/docs/content/docs/v5/api-reference/workflow-vite/workflow.mdx @@ -26,7 +26,7 @@ export default defineConfig({ }); ``` -## API Signature +## API signature ### Parameters @@ -39,10 +39,10 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | | `dirs` | `string[]` | — | Directories to scan for workflows and steps. By default, the `workflows/` directory is scanned from the project root and all layer source directories. | -| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for IDE IntelliSense. | -| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). Only applies when deploying to Vercel. | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `typescriptPlugin` | `boolean` | `false` | Adds the `workflow` TypeScript plugin to the generated `tsconfig.json` for integrated development environment (IDE) IntelliSense. | +| `runtime` | `string` | — | Node.js runtime version for Vercel Functions (for example, `'nodejs22.x'` or `'nodejs24.x'`). Only applies when deploying to Vercel. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | ### Returns -Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config — Vite flattens nested plugin arrays automatically. +Returns an array of Vite `Plugin` objects. Spread or pass the array directly to the `plugins` option of your Vite config. Vite flattens nested plugin arrays automatically. diff --git a/docs/content/docs/v5/api-reference/workflow/create-hook.mdx b/docs/content/docs/v5/api-reference/workflow/create-hook.mdx index 2b1c5403a5..8a914d2d09 100644 --- a/docs/content/docs/v5/api-reference/workflow/create-hook.mdx +++ b/docs/content/docs/v5/api-reference/workflow/create-hook.mdx @@ -26,7 +26,7 @@ export async function hookWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -66,11 +66,11 @@ export default Hook;`} The returned `Hook` object also implements `AsyncIterable`, which allows you to iterate over incoming payloads using `for await...of` syntax. -Use `hook.getConflict()` to check whether the hook token is already claimed by another hook, including one kept reserved after its run ends, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run). +Use `hook.getConflict()` to check whether the hook token is already claimed by another hook, including one kept reserved after its run ends, without waiting for hook payload data. Calling `createHook()` on its own does not register the hook: registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the registration, then resolves with `null` once `hook_created` is recorded, or with the conflicting [`Run`](/docs/api-reference/workflow-api/get-run). ## Examples -### Basic Usage +### Basic usage When creating a hook, you can specify a payload type for automatic type safety: @@ -91,7 +91,7 @@ export async function approvalWorkflow() { } ``` -### Customizing Tokens +### Customizing tokens Tokens are used to identify a specific hook. You can customize the token to be more specific to a use case. @@ -115,7 +115,7 @@ export async function slackBotWorkflow(channelId: string) { } ``` -### Detecting Token Conflicts +### Detecting token conflicts Use `hook.getConflict()` when the workflow needs to claim a hook token before doing other work, but does not need a payload yet: @@ -141,7 +141,7 @@ async function processOrder(orderId: string) { } ``` -Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration — to receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`. +Because `createHook()` alone does not suspend the workflow, awaiting `hook.getConflict()` is what actually suspends the run and commits the hook registration. It only waits for registration. To receive payload data from a future `resumeHook()` call, await the hook itself or iterate it with `for await...of`. On a conflict, the resolved value is a `Run` handle for the run that owns the token, with durable step-backed accessors. The duplicate run can decide in code how to handle it: return or log `conflict.runId`, inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` and continue in the current run. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies in context. @@ -149,7 +149,7 @@ On a conflict, the resolved value is a `Run` handle for the run that owns the to Custom hook tokens are the recommended way to coordinate active workflow runs. Use a deterministic token from your domain, such as an order ID or conversation ID, create the hook near the beginning of the workflow, and check `await hook.getConflict()` before work that depends on owning the token. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). -### Keep a Token Unavailable After the Run Ends +### Keep a token unavailable after the run ends By default, another Hook can use the token after its workflow ends. Set `experimental_minRetention` to keep the token unavailable for at least a specific time after `createHook()` runs: @@ -190,7 +190,7 @@ After the workflow ends, [`getHookByToken()`](/docs/api-reference/workflow-api/g This option is experimental. Worlds can limit how long tokens are retained; see [World configuration](/docs/configuration/worlds) for each World's limit. If the configured World does not support minimum retention, the workflow fails when registering the Hook. `createWebhook()` does not accept this option. -### Waiting for Multiple Payloads +### Waiting for multiple payloads You can also wait for multiple payloads by using the `for await...of` syntax. @@ -213,7 +213,7 @@ export async function collectHookWorkflow() { } ``` -### Disposing Hooks Early +### Disposing hooks early You can dispose a hook early to release its token for reuse by another workflow. This is useful for handoff patterns where one workflow needs to transfer a hook token to another workflow while still running. @@ -242,7 +242,7 @@ export async function handoffWorkflow(channelId: string) { After calling `dispose()`, the hook will no longer receive events and its token becomes available for other workflows to use. -### Automatic Disposal with `using` +### Automatic disposal with `using` Hooks implement the [TC39 Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management) proposal, allowing automatic disposal with the `using` keyword: @@ -268,9 +268,9 @@ export async function scopedHookWorkflow(channelId: string) { This is equivalent to manually calling `dispose()` but ensures the hook is always cleaned up, even if an error occurs. -## Related Functions +## Related functions -- [`defineHook()`](/docs/api-reference/workflow/define-hook) - Type-safe hook helper -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload -- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) - Higher-level HTTP webhook abstraction -- [Idempotency](/docs/foundations/idempotency) - Deduplicate step side effects and workflow starts +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a hook with a payload +- [`createWebhook()`](/docs/api-reference/workflow/create-webhook): Higher-level HTTP webhook abstraction +- [Idempotency](/docs/foundations/idempotency): Deduplicate step side effects and workflow starts diff --git a/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx b/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx index cf8902d0ed..ca48a43fc7 100644 --- a/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx +++ b/docs/content/docs/v5/api-reference/workflow/create-webhook.mdx @@ -14,7 +14,7 @@ Creates a webhook that can be used to suspend and resume a workflow run upon rec Webhooks provide a way for external systems to send HTTP requests directly to your workflow. Unlike hooks which accept arbitrary payloads, webhooks work with standard HTTP `Request` objects and can return HTTP `Response` objects. -`createWebhook()` creates a public endpoint at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. This is convenient for prototypes and simple resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. +`createWebhook()` creates a public endpoint at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests resuming that webhook. This is convenient for prototypes and basic resume links because it avoids creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. ```ts lineNumbers @@ -31,7 +31,7 @@ export async function webhookWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -63,22 +63,22 @@ When using `createWebhook({ respondWith: 'manual' })`, the resolved request type Use the simplest option that satisfies the prompt: -- `createWebhook()` — generated callback URL, and the default `202 Accepted` response is fine -- `createWebhook({ respondWith: 'manual' })` — generated callback URL, but you must send a custom body, status, or headers -- `createHook()` + `resumeHook()` — the app resumes from server-side code with a deterministic business token instead of a generated callback URL +- `createWebhook()`: generated callback URL, and the default `202 Accepted` response is fine +- `createWebhook({ respondWith: 'manual' })`: generated callback URL, but you must send a custom body, status, or headers +- `createHook()` + `resumeHook()`: the app resumes from server-side code with a deterministic business token instead of a generated callback URL
Common wrong turns -- Do not use `respondWith: 'manual'` just because the flow has a callback URL. +- A callback URL alone does not require `respondWith: 'manual'`. - Do not use `RequestWithResponse` unless you chose manual mode. - Do not invent a custom callback route when `webhook.url` is the intended callback surface.
## Examples -### Basic Usage +### Basic usage Create a webhook that receives HTTP requests and logs the request details: @@ -101,11 +101,11 @@ export async function basicWebhookWorkflow() { } ``` -### Responding to Webhook Requests (Manual Mode) +### Responding to webhook requests (manual mode) Use this section only when the caller requires a non-default HTTP response. If `202 Accepted` is acceptable, use `createWebhook()` without `respondWith: "manual"`. -Pass `{ respondWith: "manual" }` to get a `RequestWithResponse` object with a `respondWith()` method. Note that `respondWith()` must be called from within a step function: +Pass `{ respondWith: "manual" }` to get a `RequestWithResponse` object with a `respondWith()` method. Call `respondWith()` from within a step function: ```typescript lineNumbers import { createWebhook, type RequestWithResponse } from "workflow" @@ -143,7 +143,7 @@ async function processData(data: any): Promise { } ``` -### Waiting for Multiple Requests +### Waiting for multiple requests You can also wait for multiple requests by using the `for await...of` syntax. @@ -182,9 +182,9 @@ export async function eventCollectorWorkflow() { } ``` -## Related Functions +## Related functions -- [`createHook()`](/docs/api-reference/workflow/create-hook) — Use when the app resumes from server-side code with a deterministic business token. -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — Pairs with `createHook()` for deterministic server-side resume. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — Type-safe hook helper. -- [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) — Low-level runtime API. Most integrations should call `webhook.url` directly instead of adding a custom callback route. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Use when the app resumes from server-side code with a deterministic business token. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Pairs with `createHook()` for deterministic server-side resume. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Type-safe hook helper. +- [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook): Low-level runtime API. Most integrations should call `webhook.url` directly instead of adding a custom callback route. diff --git a/docs/content/docs/v5/api-reference/workflow/define-hook.mdx b/docs/content/docs/v5/api-reference/workflow/define-hook.mdx index 8f94c1c59d..aa1feb5e20 100644 --- a/docs/content/docs/v5/api-reference/workflow/define-hook.mdx +++ b/docs/content/docs/v5/api-reference/workflow/define-hook.mdx @@ -33,7 +33,7 @@ export async function nameWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -59,11 +59,11 @@ interface TypedHook { export default TypedHook;`} /> -`create()` is called inside a `"use workflow"` function to create the hook; `resume()` is called from runtime code (an API route or server action). When a `schema` is provided, `resume()` accepts the raw input type (`TInput`) and the workflow receives the validated and possibly transformed output type (`TOutput`); without a schema, `TOutput` defaults to `TInput`. `resume()` throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error) if the token does not match an active hook — it does not return `null`. +`create()` is called inside a `"use workflow"` function to create the hook; `resume()` is called from runtime code (an API route or server action). When a `schema` is provided, `resume()` accepts the raw input type (`TInput`) and the workflow receives the validated and possibly transformed output type (`TOutput`); without a schema, `TOutput` defaults to `TInput`. `resume()` throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error) if the token does not match an active hook. It does not return `null`. ## Examples -### Basic Type-Safe Hook Definition +### Basic type-safe hook definition By defining the hook once with a specific payload type, you can reuse it in multiple workflows and API routes with automatic type safety. @@ -88,7 +88,7 @@ export async function workflowWithApproval() { } ``` -### Resuming with Type Safety +### Resuming with type safety Hooks can be resumed using the same defined hook and a token. By using the same hook, you can ensure that the payload matches the defined type when resuming a hook. `resume()` resolves to the resumed hook and throws [`HookNotFoundError`](/docs/api-reference/workflow-errors/hook-not-found-error) if the token does not match an active hook. @@ -116,7 +116,7 @@ export async function POST(request: Request) { } ``` -### Validate and Transform with Schema +### Validate and transform with schema You can provide runtime validation and transformation of hook payloads using the `schema` option. This option accepts any validator that conforms to the [Standard Schema v1](https://standardschema.dev) specification. @@ -174,7 +174,7 @@ export async function POST(request: Request) { } ``` -#### Using Other Standard Schema Libraries +#### Using other Standard Schema libraries The same pattern works with any Standard Schema v1 compliant library. Here's an example with [Valibot](https://valibot.dev): @@ -190,7 +190,7 @@ export const approvalHook = defineHook({ }); ``` -### Customizing Tokens +### Customizing tokens Tokens are used to identify a specific hook and for resuming a hook. You can customize the token to be more specific to a use case. @@ -211,7 +211,7 @@ export async function slackBotWorkflow(channelId: string) { } ``` -## Related Functions +## Related functions -* [`createHook()`](/docs/api-reference/workflow/create-hook) - Create a hook in a workflow. -* [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) - Resume a hook with a payload. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Create a hook in a workflow. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resume a hook with a payload. diff --git a/docs/content/docs/v5/api-reference/workflow/fatal-error.mdx b/docs/content/docs/v5/api-reference/workflow/fatal-error.mdx index 63ff5d5d91..cfe3f363df 100644 --- a/docs/content/docs/v5/api-reference/workflow/fatal-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow/fatal-error.mdx @@ -27,7 +27,7 @@ async function fallibleStep() { } ``` -## API Signature +## API signature ### Constructor @@ -50,7 +50,7 @@ interface FatalError { export default FatalError;`} /> -### Static Methods +### Static methods #### `FatalError.is(value)` diff --git a/docs/content/docs/v5/api-reference/workflow/fetch.mdx b/docs/content/docs/v5/api-reference/workflow/fetch.mdx index 0e60ad1b00..857f8c3e0e 100644 --- a/docs/content/docs/v5/api-reference/workflow/fetch.mdx +++ b/docs/content/docs/v5/api-reference/workflow/fetch.mdx @@ -34,7 +34,7 @@ async function apiWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -60,9 +60,9 @@ showSections={['returns']} ## Examples -### Basic Usage +### Basic usage -Here's a simple example of how you can use `fetch` inside your workflow. +Here's an example of how you can use `fetch` inside your workflow. ```typescript lineNumbers import { fetch } from "workflow" @@ -87,13 +87,13 @@ async function apiWorkflow() { } ``` -We call `fetch()` with a URL and optional request options, just like the standard fetch API. The workflow runtime automatically handles the response serialization. +We call `fetch()` with a URL and optional request options, like the standard fetch API. The workflow runtime automatically handles the response serialization. -This API is provided as a convenience to easily use `fetch` in workflow, but often, you might want to extend and implement your own fetch for more powerful error handing and retry logic. +This API lets you use `fetch` in a workflow. You can also implement a custom fetch for different error handling and retry logic. -### Customizing Fetch Behavior +### Customizing fetch behavior -Here's an example of a custom fetch wrapper that provides more sophisticated error handling with custom retry logic. Call `globalThis.fetch` inside your own `"use step"` function — calling the workflow `fetch` imported from `workflow` would nest a step inside a step: +The following custom fetch wrapper provides more advanced error handling with custom retry logic. Call `globalThis.fetch` inside your own `"use step"` function because calling the workflow `fetch` imported from `workflow` would nest a step inside a step: ```typescript lineNumbers import { FatalError, RetryableError } from "workflow" diff --git a/docs/content/docs/v5/api-reference/workflow/get-step-metadata.mdx b/docs/content/docs/v5/api-reference/workflow/get-step-metadata.mdx index 27cdfaa754..4198d5bd17 100644 --- a/docs/content/docs/v5/api-reference/workflow/get-step-metadata.mdx +++ b/docs/content/docs/v5/api-reference/workflow/get-step-metadata.mdx @@ -34,7 +34,7 @@ async function logStepId() { } ``` -### Example: Use `stepId` as an idempotency key +### Example: use `stepId` as an idempotency key ```typescript lineNumbers import { getStepMetadata } from "workflow"; @@ -61,7 +61,7 @@ async function chargeUser(userId: string, amount: number) { Idempotency guide. -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v5/api-reference/workflow/get-workflow-metadata.mdx b/docs/content/docs/v5/api-reference/workflow/get-workflow-metadata.mdx index c5c24c6b8d..0a17e9b842 100644 --- a/docs/content/docs/v5/api-reference/workflow/get-workflow-metadata.mdx +++ b/docs/content/docs/v5/api-reference/workflow/get-workflow-metadata.mdx @@ -30,7 +30,7 @@ async function testWorkflow() { } ``` -### Detecting Workflow Runtime +### Detecting workflow runtime You can use `getWorkflowMetadata` to detect whether your code is running inside a workflow context. This is useful when building shared utilities that need to behave differently inside and outside of workflows. @@ -64,7 +64,7 @@ function log(message: string) { } ``` -### Detecting Encryption +### Detecting encryption The `features` object indicates which capabilities are active for the current run. Library authors can use `features.encryption` to control whether sensitive data is included in step return values, which are serialized to the event log: @@ -90,7 +90,7 @@ async function fetchUserProfile(userId: string) { } ``` -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v5/api-reference/workflow/get-writable.mdx b/docs/content/docs/v5/api-reference/workflow/get-writable.mdx index a82257bcd4..341cc3ab6b 100644 --- a/docs/content/docs/v5/api-reference/workflow/get-writable.mdx +++ b/docs/content/docs/v5/api-reference/workflow/get-writable.mdx @@ -47,7 +47,7 @@ async function writeToStream(writable: WritableStream) { } ``` -## API Signature +## API signature ### Parameters @@ -69,7 +69,7 @@ export default getWritable;`} Returns a `WritableStream` where `W` is the type of data you plan to write to the stream. -## Good to Know +## Good to know - **Workflow functions can only obtain the stream** - Call `getWritable()` in a workflow to get the stream reference, but you cannot call methods like `getWriter()`, `write()`, or `close()` directly in the workflow context. - **Step functions can interact with streams** - Steps can receive the stream as an argument or call `getWritable()` directly, and they can freely interact with it (write, close, etc.). @@ -81,9 +81,9 @@ Returns a `WritableStream` where `W` is the type of data you plan to write to ## Examples -### Basic Text Streaming +### Basic text streaming -Here's a simple example streaming text data: +This example streams text data: ```typescript lineNumbers import { sleep, getWritable } from "workflow"; @@ -118,7 +118,7 @@ async function stepCloseOutputStream(writable: WritableStream) { } ``` -### Calling `getWritable()` Inside Steps +### Calling `getWritable()` inside steps You can also call `getWritable()` directly inside step functions without passing it as a parameter: @@ -157,7 +157,7 @@ async function stepCloseOutputStreamInside() { } ``` -### Using Namespaced Streams in Steps +### Using namespaced streams in steps You can also use namespaced streams when calling `getWritable()` from steps: @@ -201,7 +201,7 @@ async function closeStreams() { } ``` -### Advanced Chat Streaming +### Advanced chat streaming Here's a more complex example showing how you might stream AI chat responses: diff --git a/docs/content/docs/v5/api-reference/workflow/index.mdx b/docs/content/docs/v5/api-reference/workflow/index.mdx index 4ac0c22b55..ef387b0a76 100644 --- a/docs/content/docs/v5/api-reference/workflow/index.mdx +++ b/docs/content/docs/v5/api-reference/workflow/index.mdx @@ -52,7 +52,7 @@ Workflow SDK contains the following functions you can use inside your workflow f -## Error Classes +## Error classes Workflow SDK includes error classes that can be thrown in a workflow or step to change the error exit strategy of a workflow. diff --git a/docs/content/docs/v5/api-reference/workflow/retryable-error.mdx b/docs/content/docs/v5/api-reference/workflow/retryable-error.mdx index 7783e4c981..a4f5243885 100644 --- a/docs/content/docs/v5/api-reference/workflow/retryable-error.mdx +++ b/docs/content/docs/v5/api-reference/workflow/retryable-error.mdx @@ -31,7 +31,7 @@ async function retryStep() { The difference between `Error` and `RetryableError` may not be entirely obvious, since when both are thrown, they both retry. The difference is that `RetryableError` has an additional configurable `retryAfter` parameter. -## API Signature +## API signature ### Parameters diff --git a/docs/content/docs/v5/api-reference/workflow/set-attributes.mdx b/docs/content/docs/v5/api-reference/workflow/set-attributes.mdx index 287986cf24..d87f78702a 100644 --- a/docs/content/docs/v5/api-reference/workflow/set-attributes.mdx +++ b/docs/content/docs/v5/api-reference/workflow/set-attributes.mdx @@ -25,7 +25,7 @@ export async function orderWorkflow(orderId: string) { } ``` -## API Signature +## API signature ### Parameters @@ -58,4 +58,4 @@ Validation errors throw [`FatalError`](/docs/api-reference/workflow/fatal-error) Calls from both workflow and step bodies append a native `attr_set` event, which the World materializes onto `run.attributes`. Workflow-originated events record a workflow writer; step-originated events record the originating step ID and attempt. -Native attributes require spec version 4 or later. Step-body storage errors throw from `setAttributes`; catch them inside the step if the write should be best-effort. Workflow-body writes are committed when the workflow suspends: transient storage errors are retried with the suspension, while a write the World rejects as invalid — such as exceeding the per-run attribute cap across multiple calls — fails the run with the validation error. +Native attributes require spec version 4 or later. Step-body storage errors throw from `setAttributes`; catch them inside the step if the write should be best-effort. Workflow-body writes are committed when the workflow suspends: transient storage errors are retried with the suspension, while a write the World rejects as invalid (such as exceeding the per-run attribute cap across multiple calls) fails the run with the validation error. diff --git a/docs/content/docs/v5/api-reference/workflow/sleep.mdx b/docs/content/docs/v5/api-reference/workflow/sleep.mdx index 60844ba707..9e7ed6c0ea 100644 --- a/docs/content/docs/v5/api-reference/workflow/sleep.mdx +++ b/docs/content/docs/v5/api-reference/workflow/sleep.mdx @@ -26,7 +26,7 @@ async function testWorkflow() { } ``` -## API Signature +## API signature ### Parameters @@ -39,7 +39,7 @@ showSections={['parameters']} ## Examples -### Sleeping With a Duration +### Sleeping with a duration You can specify a duration for `sleep` to suspend the workflow for a fixed amount of time. @@ -52,7 +52,7 @@ async function testWorkflow() { } ``` -### Sleeping Until an End Date +### Sleeping until an end date You can specify a future `Date` object for `sleep` to suspend the workflow until a specific date. diff --git a/docs/content/docs/v5/changelog/attributes-mvp.mdx b/docs/content/docs/v5/changelog/attributes-mvp.mdx index 06ce42c6ba..99e1ca7fd5 100644 --- a/docs/content/docs/v5/changelog/attributes-mvp.mdx +++ b/docs/content/docs/v5/changelog/attributes-mvp.mdx @@ -5,7 +5,7 @@ description: A minimal, write-only subset of the planned Workflow Attributes fea # Workflow Attributes (MVP) -This is a minimal, **experimental** subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space. +This **experimental** minimum viable product (MVP) implements a subset of the [planned Workflow Attributes feature for 5.0.0](https://github.com/vercel/workflow/pull/1933). See [discussion #132](https://github.com/vercel/workflow/discussions/132) for broader background on the use cases and the full design space. The MVP lets workflow code attach plaintext `string → string` metadata to a run, viewable in any observability surface that reads the `WorkflowRun` entity. It is deliberately narrow: write-only, no reads from inside a run, no list/filter endpoints, no event-log representation. The wire format and SDK surface are chosen so the full 5.0.0 implementation replaces this without source-level breaking changes for end users. @@ -24,7 +24,7 @@ The remainder of this page documents the original MVP motivation and implementat ## What MVP supports - `experimental_setAttributes(record)` callable from a **workflow body** (`"use workflow"` function), dispatched via an internal `__builtin_set_attributes` step bridge so the mutation gets a `step_created → step_completed` event pair -- Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible via `world.runs.get()` / `world.runs.list()` and any observability UI built on top of those +- Attributes are materialized onto the `WorkflowRun` entity, plaintext, and visible through `world.runs.get()` / `world.runs.list()` and any observability user interface (UI) built on top of those - World implementations emit a side-channel observability record per successful write (in `world-vercel`, this hooks into the same observability/analytics pipeline already used for other run lifecycle events) Calling `experimental_setAttributes` from a step body was intentionally not supported in the MVP, but step-body calls are now supported as a follow-up. Plain host code remains unsupported because there is no active workflow run to attach attributes to. @@ -42,15 +42,15 @@ See [PR #1933](https://github.com/vercel/workflow/pull/1933) for the design of t ## Why the MVP defers an `attr_set` event type -The full design represents attribute changes as a new `attr_set` event type in the event log, replayed by the workflow runtime VM to reconstruct the attribute snapshot. That requires bumping `SPEC_VERSION_CURRENT`, because every world implementation (including community worlds) needs to handle the new event during replay, and the runtime's reconstruction logic gains a new case. +The full design represents attribute changes as a new `attr_set` event type in the event log, replayed by the workflow runtime virtual machine (VM) to reconstruct the attribute snapshot. That requires bumping `SPEC_VERSION_CURRENT`, because every world implementation (including community worlds) needs to handle the new event during replay, and the runtime's reconstruction logic gains a new case. -A spec version bump is expensive: it gates every world adapter and ties the rollout to coordinated upgrades. We do not want to pay that cost twice — once for the MVP, again for the full feature. +A spec version bump is expensive: it gates every world adapter and ties the rollout to coordinated upgrades. We do not want to pay that cost twice: once for the MVP, again for the full feature. The MVP instead writes attributes via a direct entity-mutation path (outside the event log) which does not require a spec version bump. The downside is that MVP-era attributes have **no representation in the event log** and will not be visible to event-based reconstruction (e.g. a materialization rebuild). When the full feature ships, new writes use `attr_set` events; old runs created during the MVP window retain whatever attributes were materialized at the time, but their history is not recoverable. ## Implementation plan -### 1. `@workflow/world` — Storage interface addition +### 1. `@workflow/world`: storage interface addition Add an `experimentalSetAttributes` method to `Storage.runs`: @@ -85,7 +85,7 @@ runs: { } ``` -The method is **optional** to avoid forcing every World implementation (especially community-maintained adapters such as Redis, MongoDB, Turso, and similar) to ship support before the API stabilises. World implementations that do support it return the post-merge attribute snapshot so callers — notably the SDK helper and world adapters emitting observability records — have it without a follow-up read. +The method is **optional** to avoid forcing every World implementation (especially community-maintained adapters such as Redis, MongoDB, Turso, and similar) to ship support before the API stabilizes. World implementations that do support it return the post-merge attribute snapshot so callers (notably the SDK helper and world adapters emitting observability records) have it without a follow-up read. Add `attributes?: Record` to `WorkflowRunBaseSchema` in `packages/world/src/runs.ts`. Optional for backward compatibility: runs created before this field landed have no `attributes` and read as `undefined`. @@ -93,7 +93,7 @@ Add `attributes?: Record` to `WorkflowRunBaseSchema` in `package `world-vercel` calls into a remote endpoint to persist attributes: -``` +```text POST /v2/runs/:runId/attributes { @@ -107,9 +107,9 @@ POST /v2/runs/:runId/attributes Response: `{ "attributes": { "phase": "done", "tenant": "t1" } }`. -The `changes` field **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. `allowReservedAttributes` is optional and framework-only; omit it for user-authored attributes. When the full feature ships, this endpoint goes away — the same `changes` shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. +The `changes` field **deliberately mirrors** the eventual `attr_set` event's `eventData.changes`. `allowReservedAttributes` is optional and framework-only; omit it for user-authored attributes. When the full feature ships, this endpoint goes away: the same `changes` shape is posted to `POST /v2/runs/:id/events` with `eventType: 'attr_set'` (plus a `writer` discriminator). No SDK signature change, no client-side migration. -### 3. `@workflow/core` — SDK surface +### 3. `@workflow/core`: SDK surface A new export from `@workflow/core` (re-exported by `workflow`): @@ -122,11 +122,11 @@ function experimental_setAttributes( ): Promise ``` -`undefined` is normalized to `null` (unset). An empty object is a no-op (no RPC, no events). +`undefined` is normalized to `null` (unset). An empty object is a no-op. It makes no remote procedure call (RPC) and creates no events. Validation (shared helper, applied both client-side and server-side): -- Key: 1–256 chars, must not start with `$` (reserved — see "Reserved `$` namespace" below) +- Key: 1–256 characters, must not start with `$` (reserved, see "Reserved `$` namespace" below) - Value: ≤ 256 bytes UTF-8 - Maximum 64 attributes per run (validated against the post-merge snapshot when the server applies) - SDK-side violations throw `FatalError` from `@workflow/errors` before the internal step is dispatched; worlds revalidate as the final authority before mutating storage @@ -146,7 +146,7 @@ await experimental_setAttributes( ); ``` -The flag is per-call (no run-level "this run accepts reserved keys" mode), so each framework call site explicitly declares intent. Don't enable it from user code — misuse can conflict with observability surfaces, agent dashboards, or future platform features that rely on the reserved namespace. +The flag is per-call (no run-level "this run accepts reserved keys" mode), so each framework call site explicitly declares intent. Don't enable it from user code: misuse can conflict with observability surfaces, agent dashboards, or future platform features that rely on the reserved namespace. `experimental_setAttributes` is callable from a workflow body: @@ -161,7 +161,7 @@ export async function myWorkflow(orderId: string) { } ``` -The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge — see "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body. +The workflow-body path validates input inside the VM and then dispatches the canonical `AttributeChange[]` through an internal `__builtin_set_attributes` step bridge. See "How workflow-body dispatch works" below. The mutation is materialized on the run entity by the step body. Step-body calls resolve to the host-side export. In a step context, that export validates the input and posts the attribute changes directly to `world.runs.experimentalSetAttributes(runId, changes)`. Plain host code still throws `FatalError`. @@ -180,7 +180,7 @@ const result = await processOrder(); await experimental_setAttributes({ phase: 'done', orderId: result.id }); ``` -**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive — a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write. +**Fire-and-forget (`void`).** Drop the `await` to let the workflow proceed without blocking. The pending step queues on the workflow's next suspension (any `await` on a runtime primitive: a step, `sleep`, a hook). This is the canonical pattern for observability / tracking metadata where the workflow doesn't depend on the write. {/*@skip-typecheck - snippet, not runnable code*/} @@ -195,18 +195,18 @@ return result; Two trade-offs to know about: -1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; LWW-by-arrival applies (see "Concurrent writes" below). +1. **Order of arrival at the world is not workflow-source order.** Fire-and-forget steps run out-of-band on the queue worker. A `void` write to one key followed by an `await` write to the same key may race; last-write-wins (LWW) by arrival applies (see "Concurrent writes" below). -2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, drain-on-completion commits the `step_created` event but the step body is not reliably dispatched before the run transitions to its terminal status — see the architectural note in the "Implementation notes" section. In practice workflows almost always have an `await` after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write. +2. **The last `void` before `return` may not land.** If you place a `void experimental_setAttributes(...)` immediately before returning, with no intervening `await` on a runtime primitive, drain-on-completion commits the `step_created` event but the step body is not reliably dispatched before the run transitions to its terminal status. See the architectural note in the "Implementation notes" section. In practice workflows almost always have an `await` after the last fire-and-forget call (a step, a sleep, a hook); if you don't, add `await sleep('0s')` before returning, or use the awaited form for that final write. -**Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls) — so don't use `Promise.all` for writes that must observe a specific order to the same key. +**Parallel (`Promise.all`).** Multiple calls dispatch concurrently. Writes to disjoint keys all land. Writes to the same key resolve last-write-wins by *arrival order at the world* (not the order the workflow body issued the calls), so don't use `Promise.all` for writes that must observe a specific order to the same key. {/*@skip-typecheck - snippet, not runnable code*/} ```ts 'use workflow'; await Promise.all([ - experimental_setAttributes({ phase: 'init' }), // disjoint keys — all land + experimental_setAttributes({ phase: 'init' }), // disjoint keys: all land experimental_setAttributes({ orderId: 'ord_123' }), experimental_setAttributes({ tenant: 't1' }), ]); @@ -224,11 +224,11 @@ Implement `experimentalSetAttributes(runId, changes)` by reading the run's JSON #### `world-postgres` -Add an `attributes JSONB` column to the runs table (default `'{}'::jsonb`, NOT NULL). Apply the merge in SQL using `jsonb_set` / `jsonb_strip_nulls` so the database does the merge atomically without a read-modify-write cycle, returning the post-merge map via `RETURNING attributes`. +Add an `attributes JSONB` column to the runs table (default `'{}'::jsonb`, NOT NULL). Apply the merge in Structured Query Language (SQL) using `jsonb_set` / `jsonb_strip_nulls` so the database does the merge atomically without a read-modify-write cycle, returning the post-merge map through `RETURNING attributes`. #### `world-vercel` -Pure HTTP wrapper. Calls the wire endpoint described in §2 and returns the response's `attributes`. The backing service materializes the attribute map onto its run-row storage; where the underlying data store supports atomic per-key map updates, the merge is a single atomic operation rather than a read-modify-write cycle — the same shape the future `attr_set` event handler will use, so the storage layout is forward-compatible. +Pure HTTP wrapper. Calls the wire endpoint described in §2 and returns the response's `attributes`. The backing service materializes the attribute map onto its run-row storage; where the underlying data store supports atomic per-key map updates, the merge is a single atomic operation rather than a read-modify-write cycle, the same shape the future `attr_set` event handler will use, so the storage layout is forward-compatible. After the persistence ack, the service emits a side-channel observability record carrying the post-merge attribute snapshot, decoupled from the request path so the runtime never waits on analytics emission. @@ -245,8 +245,8 @@ The MVP applies **last-write-wins by arrival order at the world**. Two concurren Writes from a single `await`-ed call chain are serialized by the workflow VM and land in workflow-source order. The concurrent / racy case applies to: - Multiple `experimental_setAttributes` calls inside one `Promise.all` writing the same key (the workflow VM dispatches them concurrently; the world sees them in scheduler order, not source order). -- `void experimental_setAttributes(...)` followed by another call to the same key — the fire-and-forget step may still be in flight when the next call lands. -- Multiple workflows writing the same key on the same run (rare — usually one workflow owns a run). +- `void experimental_setAttributes(...)` followed by another call to the same key: the fire-and-forget step may still be in flight when the next call lands. +- Multiple workflows writing the same key on the same run (rare: usually one workflow owns a run). Disjoint-key writes are unaffected: every call lands, regardless of pattern. Applications that need conditional semantics on a shared key should wait for the 5.0.0 release; we will not retrofit conditional writes onto the MVP path. @@ -254,11 +254,11 @@ Disjoint-key writes are unaffected: every call lands, regardless of pattern. App Attribute changes do not appear in `world.events.list(runId)`. There is no record of *when* a key changed or *which step attempt* set it. The current snapshot on the run entity is authoritative; the history is lost. -When the full feature ships, new writes carry writer attribution (`writer: { type: 'workflow' }` or `writer: { type: 'step', stepId, attempt }`) in their `attr_set` events. MVP-era writes will not have this — history starts at the `attr_set` cutover. +When the full feature ships, new writes carry writer attribution (`writer: { type: 'workflow' }` or `writer: { type: 'step', stepId, attempt }`) in their `attr_set` events. MVP-era writes will not have this. History starts at the `attr_set` cutover. ### MVP attributes do not survive materialization rebuild -Any tooling that reconstructs the run entity from the event log (disaster recovery, debugging, audit) will see no attributes on MVP-era runs, because the writes are not in the event log. This is the chief reason `experimentalSetAttributes` is named "experimental" — it is a known break from the otherwise-strict event-sourced model. +Any tooling that reconstructs the run entity from the event log (disaster recovery, debugging, audit) will see no attributes on MVP-era runs, because the writes are not in the event log. This is the chief reason `experimentalSetAttributes` is named "experimental": it is a known break from the otherwise-strict event-sourced model. The 5.0.0 path closes this gap. @@ -272,8 +272,8 @@ If you need behavior the MVP does not provide (read, list, filter, initial attri Unit tests in `@workflow/world` (validation surface) and `@workflow/core` (VM-side dispatch + host-side stub): -- Validation rules — key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap) -- Reserved `$` namespace — rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`) +- Validation rules: key length, value byte cap, `$` prefix, per-batch duplicates, post-merge count cap (with `existingKeys` so updates of present keys don't falsely trip the cap) +- Reserved `$` namespace: rejected by default, accepted when `allowReservedAttributes: true` is passed (both for `validateAttributeKey` and at the batch level via `validateAttributeChanges`) - `experimental_setAttributes({})` is a no-op (no dispatch, no events) - `undefined` value normalizes to a `null`-valued change on the wire - The `{ allowReservedAttributes: true }` opt-in is forwarded through the step bridge so the world receives the flag @@ -305,16 +305,16 @@ End-to-end in `workbench/nextjs-turbopack` (exercises the full SWC plugin + work - Awaited workflow-body calls dispatch through the `__builtin_set_attributes` step bridge and merge correctly (the test inspects the run's event log to confirm a `step_created` / `step_completed` pair was emitted) - Fire-and-forget (`void experimental_setAttributes`) attributes land before the run terminates -- `Promise.all` of disjoint-key writes — every key persists -- Workflow throws after an awaited `experimental_setAttributes` — the attribute persists on the now-`failed` run (the per-run file lock on `run_failed` re-reads inside the critical section so the attribute snapshot survives the lifecycle write) +- `Promise.all` of disjoint-key writes: every key persists +- Workflow throws after an awaited `experimental_setAttributes`: the attribute persists on the now-`failed` run (the per-run file lock on `run_failed` re-reads inside the critical section so the attribute snapshot survives the lifecycle write) ## Migration to native v4 The native attributes follow-up applies this migration: -- `experimental_setAttributes` (SDK) — unchanged signature, new dispatch path -- `runs.experimentalSetAttributes` (world interface) — deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })` -- Wire endpoint — `POST /v2/runs/:runId/attributes` is deprecated but temporarily retained for older clients; new calls post the same `changes` shape to events as `attr_set` +- `experimental_setAttributes` (SDK): unchanged signature, new dispatch path +- `runs.experimentalSetAttributes` (world interface): deprecated, then removed; replaced by `events.create(runId, { eventType: 'attr_set', eventData: { changes, writer } })` +- Wire endpoint: `POST /v2/runs/:runId/attributes` is deprecated but temporarily retained for older clients; new calls post the same `changes` shape to events as `attr_set` - Pre-existing attribute values on MVP-era runs remain on the run entity but are not represented in the event log Skew protection means workflows started under the MVP will continue to run with the MVP dispatch path on their original deployment. New deployments use the new path. No in-place data migration is needed. @@ -330,10 +330,10 @@ This section records concrete decisions taken while landing the MVP that weren't 1. The workflow VM bundle resolves `experimental_setAttributes` to `packages/core/src/workflow/set-attributes.ts` (via the `workflow` package-exports condition). 2. That helper validates the input record inline (no shared helper, no cross-file dependency from a 'use step' file) and produces canonical `AttributeChange[]`. 3. It dispatches through the standard workflow-VM step mechanism: `globalThis[WORKFLOW_USE_STEP]('__builtin_set_attributes')(changes)`. The `useStep` dispatcher is the same one used by every other step call from a workflow body, populated by `packages/core/src/workflow.ts` at VM bootstrap. -4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes, options)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world, current run id, and attempt number directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`) — populated by the host runtime — and calls `world.runs.experimentalSetAttributes(runId, changes, options)`. +4. The dispatch queues a step (`step_created`), the host runs `__builtin_set_attributes(changes, options)` from `packages/workflow/src/internal/builtins.ts`. The step body reads the active world, current run id, and attempt number directly from `globalThis` symbols (`Symbol.for('@workflow/world//cache')` and `Symbol.for('WORKFLOW_STEP_CONTEXT_STORAGE')`), populated by the host runtime, and calls `world.runs.experimentalSetAttributes(runId, changes, options)`. 5. The step completes (`step_completed`), the workflow resumes. -This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type — that stays for the full 5.0.0 cutover. +This puts the mutation on the event log as a normal `step_created → step_completed` pair without inventing a new event type. That stays for the full 5.0.0 cutover. The internal step is best-effort during the experimental phase. It sets `maxRetries = 2`, for three total attempts. If `world.runs.experimentalSetAttributes` fails on attempts 1 or 2, the error is rethrown so the runtime retries the step normally. If it still fails on attempt 3, the step logs `console.error` and returns; the workflow run continues instead of receiving a retry-exhaustion `FatalError` for failed tag posting. @@ -347,15 +347,15 @@ When the full 5.0.0 attributes feature lands, `__builtin_set_attributes` is repl ### Endpoint lives under `v2`, not a fresh namespace -The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter just for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds — just within the same namespace. +The initial draft placed the new endpoint at `POST /v3/runs/:runId/attributes`, on the assumption that introducing a new wire feature warranted a major namespace bump. In practice `world-vercel` mixes `/v1/...` and `/v2/...` endpoints already, and creating a `v3Api` subrouter for a single endpoint would have required duplicating the auth / flags / rate-limit middleware stack. The MVP endpoint is therefore mounted under the existing `v2Api`. The wire body shape is unchanged, so the migration path described above (rerouting from `/v2/runs/:runId/attributes` to `/v2/runs/:id/events`) still holds, only within the same namespace. ### Concurrent writes: read-modify-write, not per-key atomic The plan called for per-key atomic `UpdateExpression` updates (`SET #attrs.#k = :v` / `REMOVE #attrs.#k`) in the `world-vercel` backing store, on the basis that it eliminates the read-modify-write race. The MVP ships with the simpler read-modify-write path instead: -- **In `world-postgres`** the SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic. -- **In the `world-vercel` backing service** the attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map via the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved. -- **In `world-local`** an in-process per-run mutex serializes the read-merge-write sequence so parallel `experimental_setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run. +- **In `world-postgres`**: The SQL-side `jsonb_set` / `-` chain *is* used and is genuinely atomic on the run row, so the only race is the cap check (a separate `SELECT`). Documented as LWW-by-arrival for the cap; the merge itself is atomic. +- **In the `world-vercel` backing service**: The attributes column is laid out as a native key-addressable map so the atomic `UpdateItem` variant can be enabled later without a data migration. The MVP commits the merged map through the existing entity update path. Two concurrent writers therefore race; whichever lands second wins on shared keys, and any write to a non-overlapping key is preserved. +- **In `world-local`**: An in-process per-run mutex serializes the read-merge-write sequence so parallel `experimental_setAttributes` calls from concurrent steps do not lose writes within a single process. There is a corresponding test that exercises 20 parallel writes to the same run. This is consistent with the original "concurrent writes are LWW by arrival" caveat. Promoting to per-key atomic writes is a no-API-break change once the event-sourced path lands. @@ -367,7 +367,7 @@ For the MVP the endpoint reuses the existing `WORKFLOW_EVENT` fact with `eventTy ### Validation rules are shared between SDK and world -Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved) — any future change goes through one file. +Validation lives in a single helper exported from `@workflow/world` (`validateAttributeChanges`, `validateAttributeKey`, `validateAttributeValue`). Both the SDK `experimental_setAttributes` helper and the `world-local` / `world-postgres` implementations call it; the `world-vercel` backing service applies the same rules independently. The shared module is the authoritative spec for the limits (256-char keys, 256-byte values, max 64 attributes per run, `$`-prefixed keys reserved), so any future change goes through one file. ### Run row reconstruction had to thread `attributes` through @@ -375,6 +375,6 @@ Validation lives in a single helper exported from `@workflow/world` (`validateAt ### Optional world method: feature-detect, warn once -`runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code — calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. +`runs.experimentalSetAttributes` is optional on the `World` interface so community worlds (Redis, MongoDB, Turso, etc.) continue to build and run without adopting the experimental API. The SDK helper feature-detects the method's presence on first dispatch; if absent, it logs a single `console.warn` for the lifetime of the process and resolves silently for that call and all subsequent calls. Users do not need to feature-detect in their own code: calling `experimental_setAttributes` against an unsupporting world is safe but ineffective. See "Test coverage" above for the full test surface that ships with this change. diff --git a/docs/content/docs/v5/changelog/batched-event-writes.mdx b/docs/content/docs/v5/changelog/batched-event-writes.mdx index e79e96cadf..f2fd061426 100644 --- a/docs/content/docs/v5/changelog/batched-event-writes.mdx +++ b/docs/content/docs/v5/changelog/batched-event-writes.mdx @@ -7,7 +7,7 @@ description: An optional World API (events.createBatch) that appends an ordered ## Motivation -A workflow suspension that schedules several steps and waits previously wrote one event per entity — one `world.events.create` call per `step_created` and `wait_created`. Against a remote World each write is its own network round trip and its own crash boundary. Batching folds a suspension's schedule into **one durable write** with per-event outcomes, cutting request count and making the whole fan-out land atomically per attempt. +A workflow suspension that schedules several steps and waits previously wrote one event per entity: one `world.events.create` call per `step_created` and `wait_created`. Against a remote World each write is its own network round trip and its own crash boundary. Batching folds a suspension's schedule into **one durable write** with per-event outcomes, cutting request count and making the whole fan-out land atomically per attempt. ## The World spec addition @@ -34,7 +34,7 @@ The supporting types, excerpted (canonical definitions live in `@workflow/world` {/* @skip-typecheck illustrative excerpts of the canonical @workflow/world types */} ```ts interface BatchEventRequest { - /** The event — the same discriminated union the single `create` takes. */ + /** The event: the same discriminated union the single `create` takes. */ event: CreateEventRequest; /** Client event time; under slot identity, the source of the durable createdAt. */ occurredAt?: Date; @@ -54,26 +54,26 @@ interface EventBatchResult { The contract: -- **Ordered**: events land in the run's log in request order at consecutive slots. A concurrent writer may push the whole batch to slots above the caller's view; no skipped-event report accompanies the batch result, so a position-tracking caller compares committed slots against its expectation and reloads to observe what interleaved (its local view stays a strict prefix of the log — never a hole). -- **Per-event outcomes**: the batch is processed as a whole, and each event reports what its own single `create` would have returned — `200` plus the materialized entity, or the single-path status/code (`409`/`conflict` for an event an earlier delivery already applied). Callers reuse their single-path conflict handling per event. -- **Idempotent on retry — for entity-conditioned shapes**: creates, terminal transitions, and the born-running pair are each guarded by their own entity condition, so retrying a batch of them that (partially) committed converges to per-event `409`s with nothing written twice. A standalone bare `step_started` or a `step_retrying` re-patches its step instead of converging, so `world-vercel` only auto-retries batches whose every event is retry-convergent (everything the runtime folds today is), and rejects `hook_received` in a batch outright. -- **Method presence is the capability declaration.** A World that doesn't implement it keeps the single-event path; a World that implements it must make each attempt atomic (a lost race leaves nothing behind). `world-vercel` implements it against `POST /v4/runs/:runId/events/batch` (slot-identity runs only, i.e. specVersion ≥ 6). `world-local` and `world-postgres` deliberately do not — batching buys nothing for a local write. -- **Not batchable** (Worlds reject the request): `run_created`, `run_started`, `run_cancelled`, `hook_created`, `hook_disposed`, `attr_set`, and multiple events targeting one entity — except `step_created` followed by `step_started` for the same step, which creates the step born-running. +- **Ordered**: events land in the run's log in request order at consecutive slots. A concurrent writer may push the whole batch to slots above the caller's view; no skipped-event report accompanies the batch result, so a position-tracking caller compares committed slots against its expectation and reloads to observe what interleaved (its local view stays a strict prefix of the log, never a hole). +- **Per-event outcomes**: the batch is processed as a whole, and each event reports what its own single `create` would have returned: `200` plus the materialized entity, or the single-path status/code (`409`/`conflict` for an event an earlier delivery already applied). Callers reuse their single-path conflict handling per event. +- **Idempotent on retry, for entity-conditioned shapes**: creates, terminal transitions, and the born-running pair are each guarded by their own entity condition, so retrying a batch of them that (partially) committed converges to per-event `409`s with nothing written twice. A standalone bare `step_started` or a `step_retrying` re-patches its step instead of converging, so `world-vercel` only auto-retries batches whose every event is retry-convergent (everything the runtime folds today is), and rejects `hook_received` in a batch outright. +- **Method presence is the capability declaration.** A World that doesn't implement it keeps the single-event path; a World that implements it must make each attempt atomic (a lost race leaves nothing behind). `world-vercel` implements it against `POST /v4/runs/:runId/events/batch` for slot-identity runs with `specVersion >= 6`. `world-local` and `world-postgres` deliberately do not because batching provides no benefit for a local write. +- **Not batchable** (Worlds reject the request): `run_created`, `run_started`, `run_cancelled`, `hook_created`, `hook_disposed`, `attr_set`, and multiple events targeting one entity, except `step_created` followed by `step_started` for the same step, which creates the step born-running. ## The runtime integration (suspension fan-out fold) -**On by default.** The suspension handler folds a **clean fan-out** — the suspension's eager `step_created` and `wait_created` writes — into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions — not commit order — carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes, no hook writes, and no resilient step dispatch; everything else keeps the single-event path byte-for-byte. +**On by default.** The suspension handler folds a **clean fan-out** (the suspension's eager `step_created` and `wait_created` writes) into `createBatch` calls of at most 32 events (mirroring the server's transaction budgets). Chunks of a larger fan-out commit **concurrently**: slot assignment is the World's, so parallel chunks race for slot ranges exactly like the pre-fold path's parallel single writes did, and per-entity conditions, not commit order, carry correctness. The fold only engages when the World implements `createBatch`, the run is on slot identity, and the suspension carries no attribute writes, no hook writes, and no resilient step dispatch; everything else keeps the single-event path byte-for-byte. -**Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation — trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged. +**Per-chunk continuation.** Each chunk's follow-on work starts the moment **that chunk** commits, not when the whole fold does: a chunk's step-execution queue messages publish right off its own commit (publish-after-create holds per step), and only the chunk carrying the inline pairs gates the replay's continuation: trailing chunks' commits and publishes are joined before the invocation can acknowledge its message, so the durability contract ("every create durable before ack") is unchanged. -**Pre-claimed inline pairs.** When the fold engages and has company for them (at least two inline steps, or one plus other batchable events), the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs — the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and any trailing chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step with nothing else to batch keeps the optimistic lazy-start path, whose claim overlaps the body. +**Pre-claimed inline pairs.** When the fold engages and has company for them (at least two inline steps, or one plus other batchable events), the steps the runtime is about to execute inline join the batch as adjacent `[step_created, step_started]` pairs: the created row carrying the input, the started row a bare ownership-stamped claim the World folds into a born-running create. The inline bodies start straight off the pair chunk's commit (in parallel with the queue publishes and any trailing chunks) with no per-step claim POST at all, and a pair that loses its atomic create-claim to a concurrent delivery skips its body exactly as a lost lazy claim does. A lone inline step with nothing else to batch keeps the optimistic lazy-start path, whose claim overlaps the body. Per-event `409`s are tolerated the same way the single path tolerates `EntityConflictError` (a concurrent delivery already created the entity); any other per-event failure fails the suspension write the way a single-path rejection would. A batch carrying a `step_started` (that is, any batch with inline pairs) is **not** retried in-process on a transport blip: a pair's `409` cannot be told apart from the caller's own earlier attempt having committed it, so recovery goes through queue redelivery instead, where the step's ownership stamp routes it back to the same invocation. `createBatch` is optional, so today only the Vercel World folds at all: every other World keeps the single-event path and never sends a pair. -**Escape hatch:** set `WORKFLOW_BATCH_TRANSITIONS=0` (or `false`) to disable batching and restore the exact prior one-write-per-event path — see [`WORKFLOW_BATCH_TRANSITIONS`](/docs/configuration/worlds#workflow_batch_transitions). +**Escape hatch**: Set `WORKFLOW_BATCH_TRANSITIONS=0` (or `false`) to disable batching and restore the exact prior one-write-per-event path. See [`WORKFLOW_BATCH_TRANSITIONS`](/docs/configuration/worlds#workflow_batch_transitions). ## Follow-up -The deferred sequential transition — holding `step_completed(N)` across the replay turn and committing `[step_completed(N), step_created(N+1), step_started(N+1)]` as one batch at the next lazy start — builds on this contract and ships separately. +The deferred sequential transition (holding `step_completed(N)` across the replay turn and committing `[step_completed(N), step_created(N+1), step_started(N+1)]` as one batch at the next lazy start) builds on this contract and ships separately. diff --git a/docs/content/docs/v5/changelog/eager-processing.mdx b/docs/content/docs/v5/changelog/eager-processing.mdx index 58e1c40574..bac4526948 100644 --- a/docs/content/docs/v5/changelog/eager-processing.mdx +++ b/docs/content/docs/v5/changelog/eager-processing.mdx @@ -1,20 +1,20 @@ --- -title: Eager Processing of Steps & Incremental Event Replay -description: Combine workflow event replay and step bundles to do work inline where possible, only deferring to queue for parallelism +title: Eager processing of steps and incremental event replay +description: Combine workflow event replay and step bundles to do work inline where possible, only deferring to the queue for parallelism. type: overview --- -# Eager Processing of Steps & Incremental Event Replay +# Eager processing of steps and incremental event replay **Date**: March 2026 This is a major internal architecture change to how Workflow DevKit executes workflows and steps. It reduces function invocations and queue overhead by executing steps _inline_ within the same function invocation as the workflow replay, rather than dispatching every step to a separate function via the queue. -## Previous Architecture +## Previous architecture The previous architecture used two separate routes, each backed by its own queue trigger: -``` +```text Queue: __wkf_workflow_* --> /.well-known/workflow/v1/flow (workflow replay in VM) | suspension (step needed) @@ -30,15 +30,15 @@ Queue: __wkf_step_* --> /.well-known/workflow/v1/step (step execution in (cycle repeats for each step) ``` -Each step required **2 queue messages** (step invoke + workflow continuation) and **2 function invocations**, plus cold start overhead for each. A serial workflow with 10 steps needed ~21 function invocations. +Each step required **2 queue messages** (step invocation and workflow continuation) and **2 function invocations**, plus cold-start overhead for each. A serial workflow with 10 steps needed approximately 21 function invocations. -## New Architecture +## New architecture The two routes are merged into a single handler at `/.well-known/workflow/v1/flow` using `workflowEntrypoint()`. The step route is no longer generated. The handler runs an inline execution loop: -``` +```text receive queue message | +-- if message has stepId+stepName: execute that step, queue workflow continuation, exit @@ -68,9 +68,9 @@ suspension with pending operations A serial workflow with 10 steps now completes in **1 function invocation**. -## Background Steps (Parallel Execution) +## Background steps (parallel execution) -When a workflow suspends with multiple pending steps (e.g., from `Promise.all`), the handler creates `step_created` events for all of them, queues N-1 back to `__wkf_workflow_*` with `stepId` and `stepName` in the message payload, executes 1 step inline, and loops back to replay. +When a workflow suspends with multiple pending steps (for example, from `Promise.all`), the handler creates `step_created` events for all of them, queues N-1 back to `__wkf_workflow_*` with `stepId` and `stepName` in the message payload, executes 1 step inline, and loops back to replay. Each background step message is handled by a separate function invocation of the same handler. When a message arrives with `stepId` and `stepName`, the handler executes that specific step, then checks if all parallel steps from the batch are done by comparing `step_created` events against terminal events (`step_completed`/`step_failed`): @@ -78,56 +78,56 @@ Each background step message is handled by a separate function invocation of the - **Steps still pending**: The handler returns without queuing a continuation. The last handler to complete its step will see all steps done and replay inline. - **Pending ops (stream writes)**: The handler queues a continuation and returns, so `waitUntil` can flush the pending stream data. -### Convergence After Parallel Steps +### Convergence after parallel steps When multiple background steps complete near-simultaneously, multiple handlers may observe "all steps done" and attempt to advance the workflow concurrently. The event-sourced architecture plus the invariants below ensure safe convergence: -- **`step_created` idempotency** — duplicate creates return 409; exactly one handler owns each step -- **`step_completed` / `step_failed` idempotency** — only the first invocation to record a terminal result wins -- **Queue idempotency keys** — background step messages use `correlationId` as idempotency key -- **Deterministic replay** — all invocations produce the same result given the same event log +- **`step_created` idempotency**: Duplicate creates return 409; exactly one handler owns each step. +- **`step_completed` / `step_failed` idempotency**: Only the first invocation to record a terminal result wins. +- **Queue idempotency keys**: Background step messages use `correlationId` as the idempotency key. +- **Deterministic replay**: All invocations produce the same result given the same event log. -### Single Inline Executor Per Step +### Single inline executor per step Inline step execution combined with background-step dispatch introduces a new coordination requirement: when multiple handlers reach the same `Promise.all` batch concurrently, we need to guarantee that each step body runs at most once via the inline path. Without that guarantee, the event log accumulates duplicate `step_started` events (including some written *after* `step_completed`, which orphans them on replay) and step bodies run redundantly. -The design enforces a simple invariant: **exactly one handler owns each step, and only the owner may execute it inline**. Ownership is established by the atomicity of `step_created`: +The design enforces one invariant: **exactly one handler owns each step, and only the owner may execute it inline**. Ownership is established by the atomicity of `step_created`: -1. **Atomic `step_created`** — `events.create('step_created', correlationId=X)` is serialized per-correlationId in every world. Exactly one concurrent caller succeeds; the rest receive `EntityConflictError`. -2. **Suspension handler reports ownership** — only `step_created` writes that actually succeeded (not those that caught 409) count toward ownership. -3. **Inline execution is gated on ownership** — a handler that didn't win any `step_created` race performs no inline execution. -4. **Queueing is unconditional** — for every pending step except the one being inline-executed, the handler enqueues a background step message with `idempotencyKey: correlationId`. This is what makes crash recovery work: if a prior handler wrote `step_created` but crashed before enqueueing, a later handler will enqueue the orphaned step. Concurrent handlers' redundant enqueues dedupe on the idempotency key. +1. **Atomic `step_created`**: `events.create('step_created', correlationId=X)` is serialized per correlation ID in every world. Exactly one concurrent caller succeeds; the rest receive `EntityConflictError`. +2. **Suspension handler reports ownership**: Only `step_created` writes that succeeded (not those that caught 409) count toward ownership. +3. **Inline execution is gated on ownership**: A handler that didn't win any `step_created` race performs no inline execution. +4. **Queueing is unconditional**: For every pending step except the one being executed inline, the handler enqueues a background step message with `idempotencyKey: correlationId`. This makes crash recovery work: if a prior handler wrote `step_created` but crashed before enqueueing, a later handler will enqueue the orphaned step. Concurrent handlers' redundant enqueues deduplicate on the idempotency key. Together these give: every `step_created` event has exactly one inline executor **and** at least one queued dispatch. Step bodies are never executed concurrently, and `step_started` events never land in the log after `step_completed` for the same step. -**Retry semantics are preserved**: a step that is currently running (status=`running`) still accepts a second `step_started` write with an incremented attempt counter — this is how queue redelivery after a SIGKILL mid-execution legitimately re-runs the step. +**Retry semantics are preserved**: a step that is currently running (status=`running`) still accepts a second `step_started` write with an incremented attempt counter: this is how queue redelivery after a SIGKILL mid-execution legitimately re-runs the step. -## Incremental Event Loading +## Incremental event loading The handler caches the event log in memory across loop iterations. Instead of re-fetching the entire event log on each replay: -1. **First iteration**: full load, returning both the events and the final pagination cursor -2. **Subsequent iterations**: fetch only events created after the saved cursor and append them to the cached array +1. **First iteration**: Load all events and return the final pagination cursor. +2. **Subsequent iterations**: Fetch only events created after the saved cursor and append them to the cached array. For a 10-step serial workflow completing in one invocation, the 10th replay loads ~2 new events instead of re-fetching all ~30. Incremental loading depends on the World returning a cursor even on the final page of results. If a World implementation does not return a cursor after the initial load, the handler logs an error and falls back to a full reload. -## Timeout Handling +## Timeout handling -The inline execution loop checks wall-clock time before each replay iteration. If the elapsed time exceeds a configurable threshold (default: 110 seconds, for a 120-second function limit), the handler re-schedules itself via the queue and returns. Configurable via `WORKFLOW_V2_TIMEOUT_MS`. +The inline execution loop checks wall-clock time before each replay iteration. If the elapsed time exceeds a configurable threshold (110s by default for a 120-second function limit), the handler reschedules itself through the queue and returns. Configure the threshold with `WORKFLOW_V2_TIMEOUT_MS`. -If a single step takes longer than the timeout threshold, the step runs to completion (or SIGKILL) — there is no interruption mechanism for in-progress step execution. This is the same behavior as the previous architecture. +If a single step takes longer than the timeout threshold, the step runs to completion (or SIGKILL). There is no interruption mechanism for in-progress step execution. This is the same behavior as the previous architecture. -## Queue Message Changes +## Queue message changes -The `WorkflowInvokePayload` schema has two new optional fields: `stepId` and `stepName`. When `stepId` is present, the handler executes that specific step before (or instead of) replaying the workflow. Background steps are queued with both set, so the handler knows which step function to call without loading the event log. Previously, `stepName` was resolved by loading all events and searching for the `step_created` event matching the `stepId` — an O(N) operation on the full event history for every background step arrival. +The `WorkflowInvokePayload` schema has two new optional fields: `stepId` and `stepName`. When `stepId` is present, the handler executes that specific step before (or instead of) replaying the workflow. Background steps are queued with both set, so the handler knows which step function to call without loading the event log. Previously, `stepName` was resolved by loading all events and searching for the `step_created` event matching the `stepId`, an O(N) operation on the full event history for every background step arrival. The queue trigger configuration uses `WORKFLOW_QUEUE_TRIGGER` on the `__wkf_workflow_*` topic. The `__wkf_step_*` topic and its separate trigger are no longer generated. -## Generated File Layout +## Generated file layout -``` +```text .well-known/workflow/v1/ flow/ route.js # Handler (workflowEntrypoint) @@ -141,11 +141,11 @@ The queue trigger configuration uses `WORKFLOW_QUEUE_TRIGGER` on the `__wkf_work The `step/` directory is no longer generated. -## Design Notes and Tradeoffs +## Design notes and tradeoffs -### Parent→Child Polling Holds Worker Slots +### Parent→child polling holds worker slots -`Run#returnValue` is implemented as a polling step: the workflow awaits the child run's terminal status inside a step body. In worker-based worlds (notably `world-postgres`), each such poll occupies a queue worker slot until the child run finishes. Parent workflows that fan out to many child runs — recursive workflows like `fibonacciWorkflow` are the obvious case — can therefore consume a large fraction of available workers just holding positions in `Promise.all([...children.map(c => c.returnValue)])`. +`Run#returnValue` is implemented as a polling step: the workflow awaits the child run's terminal status inside a step body. In worker-based Worlds (notably `world-postgres`), each such poll occupies a queue worker slot until the child run finishes. Parent workflows that fan out to many child runs, such as recursive workflows like `fibonacciWorkflow`, can therefore consume a large fraction of available workers while holding positions in `Promise.all([...children.map(c => c.returnValue)])`. If `queueConcurrency` is smaller than the peak number of concurrent parent polls plus the workers needed for any in-flight children, the system deadlocks: every slot is held by a parent waiting on a child, but no child can acquire a slot to start. @@ -155,11 +155,11 @@ To prevent deadlock when polling is executed inline by the step executor, `Run#p **Follow-up**: Replace the worker-pool sizing requirement with a polling design that does not occupy a worker slot. Options under consideration: moving child-completion polling out of the step body into the suspension layer, or emitting a `run_completed` notification on the parent's stream/queue so the parent only resumes when the child actually finishes. -### Mixed Suspensions +### Mixed suspensions A suspension may contain steps, hooks, and waits simultaneously. The handler creates events for all, then dispatches everything we are not running inline as a single parallel batch of queue messages: -``` +```text ownedPendingSteps = pendingSteps.filter(owned by this handler) inlineStep = ownedPendingSteps[0] // optional @@ -187,71 +187,71 @@ The retry/throttle and hook-conflict paths still return `{ timeoutSeconds }` sin The unified dispatch requires `world-local` to honor `delaySeconds` on the queue (added in the same PR series). Without it, the wait continuation would fire instantly in dev and trigger a spurious replay before the wait elapsed (recoverable via redelivery, but inefficient and observable as duplicate `step_started` events under contention). -### VM Sandboxing +### VM sandboxing Workflow code still runs in a Node.js VM for determinism and sandboxing. Step code runs in the Node.js host context. The only change is that both happen within the same function invocation. -### Bundle Size and Cold Start +### Bundle size and cold start The combined bundle is larger (contains both step code and workflow VM code). Cold start time increases slightly. The reduction in total function invocations more than compensates. -### Step Retries +### Step retries When an inline step fails with retries remaining: -- `RetryableError` with explicit `retryAfter` delay: re-queue to self with `stepId` and delay -- Transient errors with immediate retry: re-queue to self with `stepId` (delay = 1s) -- `FatalError`: fail immediately +- `RetryableError` with explicit `retryAfter` delay: Requeue to self with `stepId` and a delay. +- Transient errors with immediate retry: Requeue to self with `stepId` and a 1s delay. +- `FatalError`: Fail immediately. -### Encryption Key Resolution +### Encryption key resolution Encryption keys are resolved once before the inline execution loop starts (after the run status is confirmed as `running`) and reused across all iterations. Background step executions resolve the key independently. The key does not change within a run. -### Module Scope Duplication in Re-Bundled Output +### Module scope duplication in re-bundled output -Builders that re-bundle the combined output into a single file (standalone CLI, Vercel Build Output API, NestJS) produce a layout where esbuild creates isolated module scopes for each source module, even within the same output file. Without intervention this means `registerStepFunction` and `getStepFunction` operate on different `Map` instances — steps are registered into one Map but looked up from another. +Builders that rebundle the combined output into a single file (standalone Workflow CLI, Vercel Build Output API, and NestJS) produce a layout where esbuild creates isolated module scopes for each source module, even within the same output file. Without intervention, `registerStepFunction` and `getStepFunction` operate on different `Map` instances: steps are registered into one `Map` but looked up from another. The step function registry and the step context storage are `globalThis` singletons (via `Symbol.for`) to ensure all module scopes share the same instances. The same pattern is used for the World singleton and the class serialization registry. -### Inline Step Execution with Pending Stream Operations +### Inline step execution with pending stream operations When a step's arguments or return value include serialized streams (e.g., `WritableStream` from `getWritable()`, or AI SDK streaming steps), the serialization layer creates background `flushablePipe` operations that pipe data to S3. These ops are tracked in an `ops` array and need to complete before the stream data is readable by external consumers. -In V1, each step ran in a separate function invocation. After the step completed, `waitUntil(ops)` kept the function alive to flush the ops. In V2, the inline execution loop continues immediately after the step body returns — so we need to know whether to keep looping or break out and let `waitUntil` flush. +In V1, each step ran in a separate function invocation. After the step completed, `waitUntil(ops)` kept the function alive to flush the ops. In V2, the inline execution loop continues immediately after the step body returns, so we need to know whether to keep looping or break out and let `waitUntil` flush. `executeStep()` attempts a 500ms `Promise.race` between the ops settling and a timeout. If ops settle in time (data confirmed on server), it returns `hasPendingOps: false` and the V2 handler continues the inline loop. If ops don't settle in 500ms (e.g., `WritableStream` kept open across steps), it returns `hasPendingOps: true` and the V2 handler breaks the loop and queues a continuation so `waitUntil` can flush them. **Follow-up**: Shrink the 500ms inline-ops budget once we have confidence that the flush-waiter path settles deterministically across all worlds. A signaled "ops drained" event from the world layer would let `executeStep()` proceed without the timeout in the common case. -### Buffered Stream Flush with Waiter Promises +### Buffered stream flush with waiter promises -`WorkflowServerWritableStream` buffers writes and flushes via a 10ms `setTimeout` for batching. Naively, `write()` could return immediately after buffering, but that would cause the `flushablePipe`'s `pendingOps` counter to reach 0 before data actually reached the server — the V2 inline loop would see ops as settled prematurely and produce data-loss races on every step with `WritableStream` serialization. +`WorkflowServerWritableStream` buffers writes and flushes via a 10ms `setTimeout` for batching. Naively, `write()` could return immediately after buffering, but that would cause the `flushablePipe`'s `pendingOps` counter to reach 0 before data actually reached the server: the V2 inline loop would see ops as settled prematurely and produce data-loss races on every step with `WritableStream` serialization. `write()` returns a promise that resolves only after the scheduled flush completes. Multiple writes within the 10ms window still share a single batched HTTP request (the batching optimization is preserved). Each write registers a `{resolve, reject}` pair in a `flushWaiters` array. When the `setTimeout` fires and `flush()` completes the HTTP round-trip, all waiters are resolved (or rejected on error). This makes `pendingOps` accurately reflect server-side data state while keeping network-efficient batching. -### Lock-Release Polling Interval +### Lock-release polling interval -`flushablePipe`'s `pollWritableLock` / `pollReadableLock` use `setInterval` to detect when a user releases their stream lock without closing the stream — the Web Streams API has no event for that state. The V2 step executor's `opsSettled` race waits for this poll to resolve after each writable-bearing step body returns, so the polling interval sits on the critical path of every streaming step. +`flushablePipe`'s `pollWritableLock` / `pollReadableLock` use `setInterval` to detect when a user releases their stream lock without closing the stream, since the Web Streams API has no event for that state. The V2 step executor's `opsSettled` race waits for this poll to resolve after each writable-bearing step body returns, so the polling interval sits on the critical path of every streaming step. -The interval was lowered from 100ms to 10ms. Per-step wait drops from ~50ms average to ~5ms (scaling linearly with the number of writable-bearing steps in a workflow). Per-tick work is just `writable.locked` plus a `getWriter()`/`releaseLock()` probe — microsecond-scale, so 10× more ticks is not measurable in practice. +The interval was lowered from 100ms to 10ms. Per-step wait drops from ~50ms average to ~5ms (scaling linearly with the number of writable-bearing steps in a workflow). Per-tick work is `writable.locked` plus a `getWriter()`/`releaseLock()` probe (microsecond-scale), so 10× more ticks is not measurable in practice. -**Follow-up**: Replace polling entirely with an event-driven release signal — wrap the writable returned from the `WritableStream` reviver with a writer that fires on `releaseLock()` — bringing the wait to ~0ms. This would also remove a source of timing drift between worlds with synchronous storage (`world-local`) and worlds with HTTP-deferred storage (`world-vercel`). +**Follow-up**: Replace polling entirely with an event-driven release signal (wrap the writable returned from the `WritableStream` reviver with a writer that fires on `releaseLock()`), bringing the wait to ~0ms. This would also remove a source of timing drift between worlds with synchronous storage (`world-local`) and worlds with HTTP-deferred storage (`world-vercel`). -### Concurrent `step_started` and Attempt Counter +### Concurrent `step_started` and attempt counter -When the V2 handler dispatches N parallel steps as background messages, each background step completion queues a workflow continuation. Up to N continuations may replay concurrently, and each may attempt to start the same not-yet-completed step (since `step_started` succeeds for already-running steps). Each call atomically increments the `attempt` counter — so with N=5 parallel steps, the counter can reach 5 on the first genuine execution. +When the V2 handler dispatches N parallel steps as background messages, each background step completion queues a workflow continuation. Up to N continuations may replay concurrently, and each may attempt to start the same not-yet-completed step (since `step_started` succeeds for already-running steps). Each call atomically increments the `attempt` counter, so with N=5 parallel steps, the counter can reach 5 on the first genuine execution. -The max retries check in `executeStep()` only enforces when `step.error` exists — distinguishing actual retries (failed → retry with error) from concurrent first-attempt races (multiple handlers start the same step simultaneously without any prior failure). Concurrent starts are harmless since `step_completed` idempotency ensures only the first completion wins. +The max retries check in `executeStep()` only enforces when `step.error` exists, distinguishing actual retries (failed → retry with error) from concurrent first-attempt races (multiple handlers start the same step simultaneously without any prior failure). Concurrent starts are harmless since `step_completed` idempotency ensures only the first completion wins. -### Unconsumed Event Check Two-Phase Drain +### Unconsumed event check two-phase drain The `EventsConsumer`'s unconsumed event check uses a two-phase promise queue drain: yield once after the first drain (via `setTimeout(0)`) so cross-VM promise chains can append follow-up async work, then re-drain before checking. This improves timing for scenarios like `step_completed` → for-await loop resume → next hook hydration. -The check additionally arms a `DEFERRED_CHECK_DELAY_MS = 100` `setTimeout` after the second drain, since Node.js does not guarantee that `setTimeout(0)` fires after all cross-context microtasks settle. Any `subscribe()` call arriving during that 100ms window cancels the check via version invalidation + `clearTimeout`, so the delay only adds latency to genuine corruption — never to the happy path. +The check additionally arms a `DEFERRED_CHECK_DELAY_MS = 100` `setTimeout` after the second drain, since Node.js does not guarantee that `setTimeout(0)` fires after all cross-context microtasks settle. Any `subscribe()` call arriving during that 100ms window cancels the check via version invalidation + `clearTimeout`, so the delay only adds latency to genuine corruption, never to the happy path. -**Follow-up**: 100ms is a heuristic chosen empirically. A deterministic settlement signal — for example, a "VM idle" callback exposed by the workflow VM bridge that fires only after all pending cross-context promise chains have resolved — would let the consumer fire the unconsumed-event check immediately on quiescence instead of waiting for a wall-clock timeout. +**Follow-up**: 100ms is a heuristic chosen empirically. A deterministic settlement signal (for example, a "VM idle" callback exposed by the workflow VM bridge that fires only after all pending cross-context promise chains have resolved) would let the consumer fire the unconsumed-event check immediately on quiescence instead of waiting for a wall-clock timeout. -### Lazy World Loading +### Lazy World loading Static imports of `world-local` and `world-vercel` from the runtime caused two distinct build-time issues: Next.js production builds pulled both worlds (including their Node-only deps like `debug`'s `tty` requires) into the route module, and Turbopack's NFT (Node File Trace) errored on `process.cwd()` and dynamic `import()` patterns it couldn't statically analyze. @@ -259,8 +259,8 @@ A `getWorldLazy()` accessor (backed by a `globalThis` `Symbol.for` cache) replac Because tree-shaking can otherwise drop `world.ts`'s module-load registration entirely, a server-only side-effect module (`@workflow/core/runtime/world-init`) imports `./world.js` purely for its module-load side effect. It's wired via package conditions: -- `default` → real, loads `world.ts` -- `workflow` → empty stub, used by VM/step bundles +- `default` maps to the real module and loads `world.ts`. +- `workflow` maps to an empty stub used by VM and step bundles. This guarantees the world is loaded for routes that consume `start()` without going through the queue-driven flow handler first, while keeping `world.ts` and its server-only deps out of the workflow sandbox bundle. diff --git a/docs/content/docs/v5/changelog/index.mdx b/docs/content/docs/v5/changelog/index.mdx index e1c9d6c95e..37a3b2901e 100644 --- a/docs/content/docs/v5/changelog/index.mdx +++ b/docs/content/docs/v5/changelog/index.mdx @@ -12,6 +12,6 @@ Stay up to date with the latest changes to Workflow SDK. ## 2026 -- [Resilient hook resume](/docs/changelog/resilient-resume) — July 2026 -- [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) — March 2026 -- Serializable AbortController and AbortSignal — March 12, 2026 +- [Resilient hook resume](/docs/changelog/resilient-resume) (July 2026) +- [Eager processing of steps and incremental event replay](/docs/changelog/eager-processing) (March 2026) +- Serializable AbortController and AbortSignal (March 12, 2026) diff --git a/docs/content/docs/v5/changelog/lazy-event-creation.md b/docs/content/docs/v5/changelog/lazy-event-creation.md index f7872b8c26..68ecde79ec 100644 --- a/docs/content/docs/v5/changelog/lazy-event-creation.md +++ b/docs/content/docs/v5/changelog/lazy-event-creation.md @@ -9,13 +9,13 @@ description: Defer step_created for the inline step and fold it into a single st The owned-inline runtime path used to write two separate world events for a step it already owns and is about to run inline: -1. `step_created` — written by the suspension handler (`suspension-handler.ts`) -2. `step_started` — written by `executeStep` (`step-executor.ts`) -3. `step_completed` / `step_failed` — written by `executeStep` +1. `step_created`, written by the suspension handler (`suspension-handler.ts`) +2. `step_started`, written by `executeStep` (`step-executor.ts`) +3. `step_completed` / `step_failed`, written by `executeStep` -On the Vercel world each `world.events.create` is a network round-trip, so for a simple sequential `"use step"` workflow this is pure latency between steps. Steps (1) and (2) are two round-trips for a step we already own and are about to execute in the same invocation. +On the Vercel world each `world.events.create` is a network round-trip, so for a sequential `"use step"` workflow this is pure latency between steps. Steps (1) and (2) are two round-trips for a step we already own and are about to execute in the same invocation. -This change defers `step_created` for that one inline step: `executeStep` sends a single `step_started` carrying the step input, and the world creates the step on the fly — materializing the step entity **and** a synthetic `step_created` event so replay still observes it. **Two writes per inline step instead of three.** It mirrors the existing [resilient `run_started` → `run_created`](./resilient-start) pattern. +This change defers `step_created` for that one inline step: `executeStep` sends a single `step_started` carrying the step input, and the world creates the step on the fly. This materializes the step entity **and** a synthetic `step_created` event so replay still observes it. **Two writes per inline step instead of three.** It mirrors the existing [resilient `run_started` → `run_created`](./resilient-start) pattern. Steps that are *queued* (not run inline) keep their eager `step_created` and are unchanged. Only the single inline step per suspension is made lazy. @@ -23,8 +23,8 @@ Steps that are *queued* (not run inline) keep their eager `step_created` and are ### Suspension handler -- `handleSuspension` selects exactly one step to defer — the first uncreated step (`stepItems.find(item => stepsNeedingCreation.has(...))`), which matches the inline candidate the caller would have picked. -- For that step it **skips** the `step_created` write and instead returns it as `lazyInlineStep = { correlationId, stepName, dehydratedInput }`. It is **not** added to `createdStepCorrelationIds` — ownership is no longer decided here. +- `handleSuspension` selects exactly one step to defer: the first uncreated step (`stepItems.find(item => stepsNeedingCreation.has(...))`), which matches the inline candidate the caller would have picked. +- For that step it **skips** the `step_created` write and instead returns it as `lazyInlineStep = { correlationId, stepName, dehydratedInput }`. It is **not** added to `createdStepCorrelationIds` because ownership is no longer decided here. - A `lazyInlineStep` is designated only when there is no `hook.getConflict()` awaiter (`hasAwaitedHookCreation === false`). With an awaiter present nothing runs inline, so nothing is deferred. ### `executeStep` @@ -34,7 +34,7 @@ Steps that are *queued* (not run inline) keep their eager `step_created` and are ### World contract -- `step_started` accepts an optional `input`. When provided for a non-existent step, the world creates the step entity plus a **synthetic `step_created`** event (so the event log reads `created → started → completed`), then records `step_started` — atomically. +- `step_started` accepts an optional `input`. When provided for a non-existent step, the world atomically creates the step entity plus a **synthetic `step_created`** event (so the event log reads `created → started → completed`), then records `step_started`. - A `stepCreated` signal is added to the event result so callers can tell whether the lazy `step_started` created the step or attached to an existing one. - Worlds updated: `world-local`, `world-postgres`, `world-vercel`. @@ -42,12 +42,12 @@ Steps that are *queued* (not run inline) keep their eager `step_created` and are ### Exactly-one-owner is preserved (the race moved) -The guarantee that exactly one handler runs a step's body inline is intact — it just resolves at a different event: +The guarantee that exactly one handler runs a step's body inline is intact, but it resolves at a different event: - **Before:** ownership was won at the atomic `step_created` claim in the suspension handler; the loser caught `EntityConflictError` and queued instead. - **After:** two concurrent handlers may both *select* the same `lazyInlineStep` (selection is optimistic, before any race). The race is now the world's atomic create-claim inside the lazy `step_started` (lock file in world-local, `onConflictDoNothing` + unique index in world-postgres, `attribute_not_exists` on the server). The loser gets `EntityConflictError`, which `executeStep` maps to `{ type: 'skipped' }`, so it never runs the body. -This is safe **because a lazy `step_started` is only ever sent for a brand-new step** — the suspension handler defers only steps with no prior `step_created` (`!hasCreatedEvent`). +This is safe **because a lazy `step_started` is only ever sent for a brand-new step**. The suspension handler defers only steps with no prior `step_created` (`!hasCreatedEvent`). ### Crash recovery is unchanged @@ -55,9 +55,9 @@ On crash recovery the step already has a `step_created` event in the log (`hasCr ### Materialize before failing unregistered steps -The inline path previously assumed an invariant: *by the time `executeStep` runs, the step entity already exists* (the suspension handler created it). The unregistered-step branch relied on this — it writes `step_failed` directly, with no preceding `step_started`. +The inline path previously assumed an invariant: *by the time `executeStep` runs, the step entity already exists* (the suspension handler created it). The unregistered-step branch relied on this and writes `step_failed` directly, with no preceding `step_started`. -With the `step_created` deferred, the entity no longer exists when `executeStep` bails for an unregistered step, so the `step_failed` write hits the world's "step must exist" ordering guard and is rejected — wedging the run until it times out. The fix: on the lazy path, send the lazy `step_started` first (creating the entity + synthetic `step_created`), then write `step_failed`. The lazy `step_started`'s atomic create-claim still preserves exactly-one-owner — a concurrent winner makes our create reject with `EntityConflictError` → `skipped`, so the failure is never written twice. +With the `step_created` deferred, the entity no longer exists when `executeStep` bails for an unregistered step, so the `step_failed` write hits the world's "step must exist" ordering guard and is rejected, wedging the run until it times out. On the lazy path, send the lazy `step_started` first (creating the entity + synthetic `step_created`), then write `step_failed`. The lazy `step_started`'s atomic create-claim still preserves exactly-one-owner. A concurrent winner makes our create reject with `EntityConflictError` → `skipped`, so the failure is never written twice. This is the general rule the deferral introduces: **any inline path that writes a terminal step event must first ensure the deferred step has been materialized.** @@ -65,25 +65,25 @@ This is the general rule the deferral introduces: **any inline path that writes The client step consumer (`step.ts`) sets `hasCreatedEvent` only when it observes a `step_created` event, and checks step-name divergence against `stepName`. The lazy path stays replay-correct only because the world writes a **synthetic `step_created`**: replay still sees `created → started → completed`. The input lives on the synthetic `step_created`; the `step_started` row drops the input but keeps `stepName` for the divergence check. -This intersects with the inline-delta optimization. The delta returned on the `step_completed` write is consumed by the *next* replay in place of an `events.list`, diffed against `preInlineWriteCursor` (snapshotted before replay). Because the synthetic `step_created`, `step_started`, and `step_completed` are all written *after* that cursor, the world's "events since cursor" delta carries the full triple — so the next replay does not diverge. The delta gate (one step, no hooks/waits, the lone pending step is the inline one) is unchanged. +This intersects with the inline-delta optimization. The delta returned on the `step_completed` write is consumed by the *next* replay in place of an `events.list`, diffed against `preInlineWriteCursor` (snapshotted before replay). Because the synthetic `step_created`, `step_started`, and `step_completed` are all written *after* that cursor, the world's "events since cursor" delta carries the full triple, so the next replay does not diverge. The delta gate (one step, no hooks/waits, the lone pending step is the inline one) is unchanged. ### Pre-emption by attributes / hook conflicts When `attr_set` events force an immediate in-process replay, or a hook conflict forces a re-invocation, the handler skips the dispatch loop for that pass. The deferred step is therefore **neither created nor queued** on that pass; it is recreated and run on the following replay (where it is still a lazy candidate). -This is a small behavioral improvement: previously the eager `step_created` left an orphan "created but never started" event when a step lost an attribute/hook race (e.g. `Promise.race([setAttributes(), step()])` where the attribute write wins and completes the run). With deferral, a step that loses the race is never created at all — less event-log garbage. +This is a small behavioral improvement: previously the eager `step_created` left an orphan "created but never started" event when a step lost an attribute/hook race (e.g. `Promise.race([setAttributes(), step()])` where the attribute write wins and completes the run). With deferral, a step that loses the race is never created, reducing event-log garbage. ### `hook.getConflict()` awaiter -When a `hook.getConflict()` awaiter is present, no `lazyInlineStep` is designated, nothing runs inline, every step gets its eager `step_created` and is queued, and the handler re-invokes immediately so replay resolves the awaiter. This is identical to the pre-change behavior — the deferral never serializes the awaiter's parallel continuation behind an inline step. +When a `hook.getConflict()` awaiter is present, no `lazyInlineStep` is designated, nothing runs inline, every step gets its eager `step_created` and is queued, and the handler re-invokes immediately so replay resolves the awaiter. This is identical to the pre-change behavior. The deferral never serializes the awaiter's parallel continuation behind an inline step. ## Rollout and compatibility -Server-first. The matching world-vercel backend change must deploy before this ships; the Vercel world targets a single backend whose spec version is always at least the SDK's, so the new SDK only ever talks to an already-upgraded backend. An old SDK against a new backend is safe because the lazy path is strictly additive — it triggers only when `step_started` carries both `stepName` and `input`, which old SDKs never send. For `world-local` / `world-postgres` the world ships in the same package as the runtime, so there is no version skew. Detection is by `input` presence on the event, mirroring resilient `run_started` — no capability negotiation is needed. +Server-first. The matching world-vercel backend change must deploy before this ships; the Vercel world targets a single backend whose spec version is always at least the SDK's, so the new SDK only ever talks to an already-upgraded backend. An old SDK against a new backend is safe because the lazy path is strictly additive. It triggers only when `step_started` carries both `stepName` and `input`, which old SDKs never send. For `world-local` / `world-postgres` the world ships in the same package as the runtime, so there is no version skew. Detection is by `input` presence on the event, mirroring resilient `run_started`, so no capability negotiation is needed. # Parallel inline steps + optimistic start -A follow-up builds two more latency wins on top of lazy inline start. Both are client-side only — they reuse the world's lazy create-on-`step_started` support and need no further world/backend changes. +A follow-up builds two more latency wins on top of lazy inline start. Both are client-side only; they reuse the world's lazy create-on-`step_started` support and need no further world/backend changes. ## Inline up to N steps in parallel @@ -92,7 +92,7 @@ Previously the owned-inline path ran **exactly one** step inline per suspension The suspension handler now defers `step_created` for up to **`WORKFLOW_MAX_INLINE_STEPS` (default 3)** steps and returns them as `lazyInlineSteps`. The runtime runs that batch inline **in parallel** (`Promise.all`), each via its own lazy `step_started`, and queues only the steps beyond the cap. - **Selection:** the first N uncreated steps, matching the previous single-step inline candidate. Steps beyond N keep their eager `step_created` and are queued exactly as before. -- **Result aggregation:** `retry` steps (whose `step_started` succeeded, so the step exists) are re-queued per-step as background steps with their own delay. `throttled` steps are different: a throttle rejects the lazy `step_started` on the create-claim, so the step was *never created* and has no recoverable input — re-queuing it as an input-less background step would make the world reject the bare `step_started` with "Step not found" and redeliver until it fails. So any throttle instead **defers redelivery of the orchestrator** (by the longest throttle backoff in the batch), which re-runs the throttled step inline *with its input* on replay. The runtime only loops back to replay in-process once every inline step has reached a terminal state. +- **Result aggregation:** `retry` steps (whose `step_started` succeeded, so the step exists) are re-queued per-step as background steps with their own delay. `throttled` steps are different: a throttle rejects the lazy `step_started` on the create-claim, so the step was *never created* and has no recoverable input. Re-queuing it as an input-less background step would make the world reject the bare `step_started` with "Step not found" and redeliver until it fails. Any throttle instead **defers redelivery of the orchestrator** (by the longest throttle backoff in the batch), which re-runs the throttled step inline *with its input* on replay. The runtime only loops back to replay in-process once every inline step has reached a terminal state. - **Inline-delta fast path:** still used only for the single-step sequential case (`lazyInlineSteps.length === 1`). With more than one inline step each writes its own events, so a per-write delta would be partial; multi-step batches fall back to a normal incremental `events.list`. - **Config:** `WORKFLOW_MAX_INLINE_STEPS` is clamped to 1..16. Setting it to `1` reproduces the previous single-inline-step behavior exactly (a useful kill-switch). Inline bodies run in parallel within one function invocation, so the cap also bounds per-handler memory/CPU fan-out. @@ -100,28 +100,28 @@ The suspension handler now defers `step_created` for up to **`WORKFLOW_MAX_INLIN Normally `executeStep` awaits `step_started` (the lazy create-claim round-trip) before running the body. Because the inline path already holds the step input locally, it doesn't actually need that round-trip to begin. -When `WORKFLOW_OPTIMISTIC_INLINE_START` is enabled (set it to `1`/`true` — it is **off by default**), an inline step fires `step_started` **without awaiting it** and starts running the body immediately against locally-synthesized state. A lazy step is always brand-new, so attempt is 1, there is no prior error, and `startedAt` is now — all known without the server. The in-flight `step_started` is reconciled just before the terminal write: +When `WORKFLOW_OPTIMISTIC_INLINE_START` is enabled (set it to `1`/`true`; it is **off by default**), an inline step fires `step_started` **without awaiting it** and starts running the body immediately against locally-synthesized state. A lazy step is always brand-new, so attempt is 1, there is no prior error, and `startedAt` is now. All values are known without the server. The in-flight `step_started` is reconciled immediately before the terminal write: - **Lost the create-claim (409 / `EntityConflictError`)** → discard the body result and return `skipped`; the winning handler owns the terminal write. - **Run gone / throttled / too-early** → discard the body result and surface `gone` / `throttled` / `retry`. -- **Transient (non-translatable) failure** → propagate it, so the queue redelivers — exactly as the await path does today. +- **Transient (non-translatable) failure** → propagate it, so the queue redelivers exactly as the await path does today. - **Success** → write `step_completed` / `step_failed` / `step_retrying` as usual. Awaiting `step_started` before the terminal write keeps the event log ordered (`created → started → completed`). ### Safety and the idempotency tradeoff -- **Exactly-one terminal write is preserved.** Optimistic start changes only *when the body runs*, never who writes the terminal event — that is still gated by the lazy `step_started` create-claim, which is awaited before the terminal write. Losers return `skipped`. +- **Exactly-one terminal write is preserved.** Optimistic start changes only *when the body runs*, never who writes the terminal event. The terminal event is still gated by the lazy `step_started` create-claim, which is awaited before the terminal write. Losers return `skipped`. - **Bounded to attempt 1.** Only brand-new (`!hasCreatedEvent`) steps are lazy; a retried step already has a `step_created`, so it takes the normal await-then-run path with the real attempt counter. Synthesizing `attempt = 1` locally is therefore always correct. -- **Wider double-execution — why it's off by default.** Running the body before confirming ownership means two handlers racing for the same step's create-claim can *both* run the side effects before either wins (previously the loser 409'd on `step_created` and skipped before running anything). This is unsafe for non-idempotent steps: in particular, two concurrent runs of a step that writes to the **workflow stream** (e.g. an AI agent streaming tokens) can interleave and **corrupt the stream data**. So the optimization ships **disabled**; enable it (`WORKFLOW_OPTIMISTIC_INLINE_START=1`) only for deployments whose inline step bodies are idempotent and stream-safe. +- **Why wider double-execution is off by default.** Running the body before confirming ownership means two handlers racing for the same step's create-claim can *both* run the side effects before either wins (previously the loser 409'd on `step_created` and skipped before running anything). This is unsafe for non-idempotent steps. In particular, two concurrent runs of a step that writes to the **workflow stream** (e.g. an AI agent streaming tokens) can interleave and **corrupt the stream data**. The optimization therefore ships **disabled**; enable it (`WORKFLOW_OPTIMISTIC_INLINE_START=1`) only for deployments whose inline step bodies are idempotent and stream-safe. ## Queue messages: inline steps don't pay a round-trip -Inline steps that **complete** never enqueue a per-step flow-route message. When every step in an inline batch reaches a terminal state with no pending background ops, the runtime simply continues its in-process loop and replays — so a sequential chain (or a clean parallel fan-out) of inline steps runs entirely within one invocation with **zero** queue messages. Verified: a workflow whose only work is three parallel inline steps issues no `queue()` calls. +Inline steps that **complete** never enqueue a per-step flow-route message. When every step in an inline batch reaches a terminal state with no pending background ops, the runtime continues its in-process loop and replays. A sequential chain (or a clean parallel fan-out) of inline steps therefore runs entirely within one invocation with **zero** queue messages. A workflow whose only work is three parallel inline steps issues no `queue()` calls. The only flow-route messages produced around an inline batch are: -- **Pre-batch dispatch** — the steps *beyond* the inline cap (and any pending wait/sleep continuation). Inline steps are explicitly excluded from this dispatch. -- **`retry` results** — one delayed message per retried step. A retry *is* the step becoming its own background invocation, so this is expected. -- **`throttled` results** — a single deferral of the orchestrator message (see above). -- **Pending background ops** — if any inline step left unflushed stream writes (e.g. output streams to blob storage), the loop breaks and enqueues **one** continuation (aggregated across the batch, not per-step) so `waitUntil` can flush before the next replay reads them. +- **Pre-batch dispatch:** The steps *beyond* the inline cap (and any pending wait/sleep continuation). Inline steps are explicitly excluded from this dispatch. +- **`retry` results:** One delayed message per retried step. A retry *is* the step becoming its own background invocation, so this is expected. +- **`throttled` results:** A single deferral of the orchestrator message (see above). +- **Pending background ops:** If any inline step left unflushed stream writes (e.g. output streams to blob storage), the loop breaks and enqueues **one** continuation (aggregated across the batch, not per-step) so `waitUntil` can flush before the next replay reads them. In other words: completed inline steps cost no queue round-trips; only steps that genuinely run as their own background invocations create new flow-route messages. diff --git a/docs/content/docs/v5/changelog/resilient-resume.mdx b/docs/content/docs/v5/changelog/resilient-resume.mdx index cda1d080a2..6a61a5f85a 100644 --- a/docs/content/docs/v5/changelog/resilient-resume.mdx +++ b/docs/content/docs/v5/changelog/resilient-resume.mdx @@ -1,22 +1,22 @@ --- title: Resilient hook resume -description: resumeHook() now tolerates transient event storage failures, as long as the queue is healthy, instead of failing the resume. +description: resumeHook() now tolerates transient event storage failures when the queue is healthy instead of failing the resume. --- # Resilient `resumeHook()` ## Motivation -`resumeHook()` used to write the `hook_received` event and dispatch the workflow queue message strictly one after the other, so every resume paid two sequential round trips and a transient event-storage failure failed the whole resume even when the queue was healthy. This change runs both writes **concurrently** — cutting a round trip off resume latency — and, on the same path, brings `resumeHook()` to parity with [resilient `start()`](/docs/changelog/resilient-start): a transient event-write failure no longer fails the resume when the payload can still be delivered through the queue. +`resumeHook()` used to write the `hook_received` event and dispatch the workflow queue message strictly one after the other, so every resume paid two sequential round trips and a transient event-storage failure failed the whole resume even when the queue was healthy. This change runs both writes **concurrently** (cutting a round trip off resume latency) and, on the same path, brings `resumeHook()` to parity with [resilient `start()`](/docs/changelog/resilient-start): a transient event-write failure no longer fails the resume when the payload can still be delivered through the queue. ## Design -- On the fast path, `resumeHook()` writes the `hook_received` event and dispatches the workflow queue message **concurrently** (`Promise.allSettled`). The queue message carries a `hookInput` payload — the dehydrated hook payload plus a client-minted `resumeId` idempotency key, the hook token, and a payload digest. +- On the fast path, `resumeHook()` writes the `hook_received` event and dispatches the workflow queue message **concurrently** (`Promise.allSettled`). The queue message carries a `hookInput` payload: the dehydrated hook payload plus a client-minted `resumeId` idempotency key, the hook token, and a payload digest. - A `(runId, resumeId)` dedup constraint keeps the two writers converging on **exactly one** `hook_received` event: whichever lands first wins, and the other is resolved server-side as success rather than a duplicate. The queue consumer idempotently re-ensures the event from `hookInput` before replay, so the resume is guaranteed even if the direct write never commits. - Replay also deduplicates: `hook_received` events sharing a `resumeId` belong to the same resume attempt, and only the first in the event log is delivered to workflow code. Even if a redelivery materializes the event twice, the payload reaches the workflow exactly once. -- **Queue dispatch failure is fatal** — the run was not re-triggered, so no consumer will re-ensure the event, and `resumeHook()` throws. A transient event-write failure (429/5xx, a transport error, or an expected `(runId, resumeId)` conflict with the consumer's own re-ensure) is swallowed because the queue delivery still guarantees the resume; a terminal run surfaces as `HookNotFoundError`, and any other event-write error is rethrown. +- **Queue dispatch failure is fatal**: The run was not retriggered, so no consumer will re-ensure the event, and `resumeHook()` throws. A transient event-write failure (429/5xx, a transport error, or an expected `(runId, resumeId)` conflict with the consumer's own re-ensure) is swallowed because the queue delivery still guarantees the resume; a terminal run surfaces as `HookNotFoundError`, and any other event-write error is rethrown. - `resumeHook()` returns `ResumedHook` (exported from `workflow/api`), which extends `Hook` with an optional `resilientResume` flag. The flag is `true` only when the direct write failed transiently and the resume was recovered through the queue; on the happy path and the sequential fallback it is absent. ## Compatibility -The parallel fast path is gated per resume: it activates only when both the target run's queue consumer and the live backend independently attest dedup support (re-checked on every resume, so rollout and rollback both degrade safely). Otherwise — for oversized payloads, legacy runs, or with `WORKFLOW_DISABLE_LAZY_HOOK_RESUME=1` — `resumeHook()` falls back to the original sequential write-then-dispatch path. Because runs keep executing on the deployment they were created on, a resume targeting a run from an older deployment simply uses the sequential path. +The parallel fast path is gated per resume: it activates only when both the target run's queue consumer and the live backend independently attest dedup support (re-checked on every resume, so rollout and rollback both degrade safely). Otherwise (for oversized payloads, legacy runs, or with `WORKFLOW_DISABLE_LAZY_HOOK_RESUME=1`), `resumeHook()` falls back to the original sequential write-then-dispatch path. Because runs keep executing on the deployment they were created on, a resume targeting a run from an older deployment uses the sequential path. diff --git a/docs/content/docs/v5/changelog/resilient-start.mdx b/docs/content/docs/v5/changelog/resilient-start.mdx index 8560762807..a2fd3f7cd0 100644 --- a/docs/content/docs/v5/changelog/resilient-start.mdx +++ b/docs/content/docs/v5/changelog/resilient-start.mdx @@ -1,6 +1,6 @@ --- title: Resilient run start -description: Overhaul run start logic to tolerate world storage unavailability, as long as the queue is healthy, and significantly speeds up run start. +description: Run start logic tolerates World storage unavailability when the queue is healthy and reduces run start latency. --- # Resilient `start()` @@ -9,18 +9,18 @@ description: Overhaul run start logic to tolerate world storage unavailability, When `world` storage is unavailable but the queue is up, `start()` previously failed entirely because `world.events.create(run_created)` is called before `world.queue()`. This change decouples run creation from queue dispatch so that runs can still be accepted when storage is degraded. -Additionally, the runtime previously called `world.runs.get(runId)` before `run_started`, adding an extra round-trip. By always calling `run_started` directly, we save that round-trip and can return pre-loaded events in the response to skip the initial `events.list` call, reducing TTFB. +The runtime also previously called `world.runs.get(runId)` before `run_started`, adding an extra round trip. Calling `run_started` directly removes that round trip and can return preloaded events in the response to skip the initial `events.list` call, reducing time to first byte (TTFB). ## Design ### `start()` changes - `world.events.create` (run_created) and `world.queue` are now called **in parallel** via `Promise.allSettled`. -- If `events.create` errors with **429 or 5xx**, we log a warning saying that run creation failed but the run was accepted — creation will be re-tried async by the runtime when it processes the queue message. The returned `Run` instance is marked with `resilientStart = true`. +- If `events.create` returns a **429 or 5xx** error, the runtime logs a warning that run creation failed but the run was accepted. The runtime retries creation asynchronously when it processes the queue message. The returned `Run` instance is marked with `resilientStart = true`. - If `events.create` errors with **409** (EntityConflictError), the run already exists (e.g., the queue handler's resilient start path created it first due to a cold-start race). This is treated as success. -- If `world.queue` fails, we still throw — the run truly failed and was not enqueued. +- If `world.queue` fails, we still throw: the run truly failed and was not enqueued. - The queue invocation now receives all the run inputs (`input`, `deploymentId`, `workflowName`, `specVersion`, `executionContext`) via `runInput` so the runtime can create the run later if needed. -- When the runtime re-enqueues itself, it does **not** pass these inputs — only the first queue cycle carries them. +- When the runtime re-enqueues itself, it does **not** pass these inputs: only the first queue cycle carries them. ### `workflowEntrypoint` changes @@ -29,7 +29,7 @@ Additionally, the runtime previously called `world.runs.get(runId)` before `run_ ### `Run.returnValue` polling - When `resilientStart` is true on the Run instance (run_created failed), the `pollReturnValue` loop retries on `WorkflowRunNotFoundError` up to 3 times (1s + 3s + 6s = 10s total) to give the queue time to deliver and the runtime to create the run via `run_started`. -- When `resilientStart` is false (normal path), 404 fails immediately — no delay for the common case of a wrong run ID. +- When `resilientStart` is false (normal path), 404 fails immediately: no delay for the common case of a wrong run ID. ### World contract changes @@ -40,15 +40,15 @@ Additionally, the runtime previously called `world.runs.get(runId)` before `run_ `Uint8Array` values (the serialized workflow input in `runInput`) don't survive plain JSON serialization. Each world uses a transport that preserves binary data: -- **world-vercel**: CBOR transport — CBOR-encodes the entire queue payload into a `Buffer` and uses `BufferTransport` from `@vercel/queue`. Uint8Array survives natively. -- **world-local**: `TypedJsonTransport` — encodes Uint8Array as `{ __type: 'Uint8Array', data: '' }`. -- **world-postgres**: Inline typed JSON transport — same tagged-envelope approach as world-local. +- **world-vercel**: Uses Concise Binary Object Representation (CBOR) transport, which CBOR-encodes the entire queue payload into a `Buffer` and uses `BufferTransport` from `@vercel/queue`. `Uint8Array` survives natively. +- **world-local**: Uses `TypedJsonTransport`, which encodes `Uint8Array` as `{ __type: 'Uint8Array', data: '' }`. +- **world-postgres**: Uses inline typed JSON transport, the same tagged-envelope approach as world-local. ## Decisions -1. **Parallel not sequential**: We chose `Promise.allSettled` over sequential calls to minimize latency in the happy path. +1. **Parallel, not sequential**: We chose `Promise.allSettled` over sequential calls to minimize latency in the successful path. -2. **Already-running returns run without event**: When `run_started` encounters an already-running run, all worlds return `{ run }` with `event: undefined` (no `events` array) instead of throwing. The runtime detects this by checking for `result.event === undefined`. This avoids an extra `world.runs.get` round-trip. +2. **Already-running returns run without event**: When `run_started` encounters an already-running run, all worlds return `{ run }` with `event: undefined` (no `events` array) instead of throwing. The runtime detects this by checking for `result.event === undefined`. This avoids an extra `world.runs.get` round trip. 3. **Events in 200 response**: We only return events on the 200 path (first caller). On the already-running path, we fall back to the normal `events.list` call. This is correct because only on 200 can we be certain we know the full event history. @@ -64,12 +64,12 @@ On Vercel, the parallel dispatch can cause the queue message to be processed bef 2. The original `run_created` arrives and gets 409 (EntityConflictError). 3. `start()` treats the 409 as success (the run exists). -The `resilientStart` flag is NOT set on the Run instance in this case (409 is not a retryable error), so `returnValue` fails fast on 404. +The `resilientStart` flag is not set on the `Run` instance in this case (409 is not a retryable error), so `returnValue` fails immediately on 404. ### Atomicity of run entity creation -The normal `run_created` path and the resilient start path can race on creating the run entity. In `world-local`, both paths use `writeExclusive` (O_CREAT|O_EXCL) — atomic at the OS level, so exactly one writer wins and the other gets EEXIST. The normal path throws `EntityConflictError` on conflict (handled by `start()` as 409); the resilient start path re-reads the run from disk on conflict. +The normal `run_created` path and the resilient start path can race on creating the run entity. In `world-local`, both paths use `writeExclusive` (O_CREAT|O_EXCL), atomic at the OS level, so exactly one writer wins and the other gets EEXIST. The normal path throws `EntityConflictError` on conflict (handled by `start()` as 409); the resilient start path re-reads the run from disk on conflict. In `world-postgres`, the resilient start path uses `onConflictDoNothing` plus a re-read on conflict for the same effect, with the same outcome on either side of the race. -The narrow crash window in `world-postgres` between the run insert and the event insert is acceptable — if the run insert succeeds but the event insert crashes, the run exists and `run_started` will still proceed normally (the event log will be missing a `run_created` entry, but the run itself is functional). +The narrow crash window in `world-postgres` between the run insert and the event insert is acceptable: if the run insert succeeds but the event insert crashes, the run exists and `run_started` will still proceed normally (the event log will be missing a `run_created` entry, but the run itself is functional). diff --git a/docs/content/docs/v5/changelog/step-message-ownership.mdx b/docs/content/docs/v5/changelog/step-message-ownership.mdx index 8dcdd639ae..26ec7edf81 100644 --- a/docs/content/docs/v5/changelog/step-message-ownership.mdx +++ b/docs/content/docs/v5/changelog/step-message-ownership.mdx @@ -1,6 +1,6 @@ --- title: Inline step message ownership -description: Why inline step_started now records its owning queue message ID, why ownership is bounded by a lease, and why the alternatives — from queue serialization to heartbeats — were rejected. Fixes duplicate inline step execution (issue #2780). +description: Why inline step_started now records its owning queue message ID, why ownership is bounded by a lease, and why the alternatives, from queue serialization to heartbeats, were rejected. Fixes duplicate inline step execution (issue #2780). --- # Inline step message ownership @@ -17,7 +17,7 @@ description: Why inline step_started now records its owning queue message ID, wh The lazy `step_started` that creates an inline step now also records the queue message ID of the invocation running its body (`eventData.ownerMessageId`). While that ownership is -active — until the step's first `step_retrying` or terminal event — a replay triggered by +active (until the step's first `step_retrying` or terminal event), a replay triggered by anything other than the owning message does **not** requeue the step. It instead ensures a *delayed backstop wake* exists, timed to the remainder of an ownership lease. Only a handler processing the owning message (the original invocation, or the queue's redelivery @@ -25,12 +25,12 @@ of it after a crash) may re-execute the step before that lease expires. ## The bug being fixed -An inline step has no queue message — that is the point of inline execution (it saves the +An inline step has no queue message: that is the point of inline execution (it saves the dispatch round-trip). But the runtime's crash-recovery rule queued every created, non-terminal step **unconditionally** on each replay, relying on the queue's `idempotencyKey = correlationId` to dedupe repeats. For inline steps there is no prior -message to dedupe against, so any wake that replayed the run mid-step — `hook_received`, -an elapsed wait continuation, a cancellation — enqueued a *first* message for that +message to dedupe against, so any wake that replayed the run mid-step (`hook_received`, +an elapsed wait continuation, a cancellation) enqueued a *first* message for that correlation ID. Its consumer sent a bare `step_started` on the `running` step (which is allowed: retries legitimately re-start non-terminal steps) and ran the body a second time, concurrently with the original. One `step_completed` won the terminal write; every side @@ -39,7 +39,7 @@ effect had already happened twice. Note what is *not* broken: the unconditional requeue is correct for eager steps and is itself the crash-recovery mechanism for a handler that wrote `step_created` and died before enqueueing. Any fix must suppress the requeue *only* while a live invocation is -demonstrably running the body — which turns the bug into a liveness problem. +demonstrably running the body, which turns the bug into a liveness problem. ## The core problem is liveness, and why we did not build a liveness mechanism @@ -51,38 +51,38 @@ signal: a crashed function emits nothing, and the event log looks identical eith The classic answers are heartbeats or a lock store: the executing invocation periodically renews a claim, and recovery waits for the claim to go stale. We rejected building one: -- **It adds World API surface.** Every backend — Vercel, local filesystem, community - Postgres/Turso/Redis worlds — would have to implement a renewable-claim store with +- **It adds World API surface.** Every backend (Vercel, local filesystem, community + Postgres/Turso/Redis worlds) would have to implement a renewable-claim store with expiry semantics. The event log is the World contract's one source of truth; a second, mutable liveness store beside it is a large contract change for a race fix. - **It adds steady-state write load.** Heartbeats cost a write per interval per in-flight step, paid by every healthy run to detect the rare crashed one. - **It does not remove the hard part.** A heartbeat still needs an expiry to survive a - crashed heartbeater — that expiry *is* a lease. Any liveness design degenerates to + crashed heartbeater: that expiry *is* a lease. Any liveness design degenerates to "claim + bounded staleness"; the machinery around it is overhead. Instead, the design reuses a liveness signal the system already has: **the queue's delivery state**. An un-acked queue message is precisely "work that a live invocation may be processing, which will be redelivered if the processor died." Stamping the owning message ID on the step makes the queue's own at-least-once machinery serve as the claim, -the crash detector, and the recovery driver — with zero new World surface and zero +the crash detector, and the recovery driver, with zero new World surface and zero steady-state writes beyond one field on an event we already write. ### Why the identity is the queue `messageId` `createQueueHandler` already delivers `meta.messageId`, and one enqueued message keeps its ID across redelivery attempts. That stability is exactly the property recovery needs: "a -delivery whose ID matches the stamp" means "the queue redelivered the work that crashed" — +delivery whose ID matches the stamp" means "the queue redelivered the work that crashed": permission to re-execute. The requirement is now documented in the [Queue contract](/docs/api-reference/workflow-runtime/world/queue); a World whose queue mints fresh IDs per delivery degrades gracefully (the owner check never matches, so -crashed steps recover via the delayed backstop instead of immediately) — it never wedges +crashed steps recover via the delayed backstop instead of immediately): it never wedges and never duplicates. ### Why ownership lives in the event log Ownership state is derived per-replay from the step's events, not held in memory or in a -side store. Every replayer — the owner's redelivery, a hook wake, the backstop — computes +side store. Every replayer (the owner's redelivery, a hook wake, the backstop) computes the same answer from the same log, which is the workflow runtime's existing consistency model. The rules are chosen so the log alone is sufficient: @@ -90,12 +90,12 @@ model. The rules are chosen so the log alone is sufficient: sets the owner; an unstamped bare start (a retry attempt driven by a queued step message, or an older runtime) clears it. This is why owner recovery must *re-stamp* its bare start: an unstamped recovery start would read as "unowned" to a later wake, which - would immediately requeue the step the owner is re-running — reintroducing the bug on + would immediately requeue the step the owner is re-running, reintroducing the bug on the recovery path. - **`step_retrying` lapses ownership permanently** for the correlation ID. From the first retry on, the step is queue-owned: the retry handoff enqueues a real step message, and the ordinary `idempotencyKey = correlationId` dedupe works again. Extending ownership - across retries was rejected deliberately — retry backoffs are delay-dominated (the + across retries was rejected deliberately: retry backoffs are delay-dominated (the owning invocation would hold compute open doing nothing), each attempt would pay a replay to re-derive state, and transferring ownership between attempts adds a state machine where the queue-owned path already recovers correctly. @@ -106,8 +106,8 @@ model. The rules are chosen so the log alone is sufficient: ## Why ownership requires a lease Ownership cannot be unconditional. Note first what the lease is *not* for: an owner that -crash-loops through the SDK's delivery budget does not wedge the run even without one — -the flow handler fails the run when it receives an over-budget delivery +crash-loops through the SDK's delivery budget does not wedge the run even without one. +The flow handler fails the run when it receives an over-budget delivery (`metadata.attempt > maxQueueDeliveries`). The lease exists because that check, and owner recovery itself, both depend on assumptions the World contract does not actually promise: @@ -117,13 +117,13 @@ recovery itself, both depend on assumptions the World contract does not actually community SQS world with a small `maxReceiveCount`), retention can expire it, an operator can purge it. The run is then still `running`, the step stamped and non-terminal, and no message exists. With unbounded ownership every future wake defers - to a ghost — a permanent wedge; with the lease, the already-armed backstop (or the + to a ghost, a permanent wedge; with the lease, the already-armed backstop (or the first wake after expiry) recovers the step. -- **Worlds with unstable message IDs — there the lease is the *entire* recovery +- **Worlds with unstable message IDs: there the lease is the *entire* recovery mechanism.** If a queue mints a fresh ID per delivery, the owner check never matches, including on the crashed owner's own redelivery. Unbounded ownership would defer forever to a stamp no delivery can ever match, while each deferring replay acks its own - message — the run drains to zero messages while still `running`. The lease is what + message: the run drains to zero messages while still `running`. The lease is what makes the graceful-degradation claim in the Queue contract true. - **Insurance on the ack invariant.** Correctness leans on "no path acks the owning message while an owned step is non-terminal" (see the decision-table invariants). That @@ -131,7 +131,7 @@ recovery itself, both depend on assumptions the World contract does not actually queue implementation bug could violate it. The lease caps the cost of any such bug at one bounded stall instead of a permanent wedge. - **Failure granularity for poison steps.** Even on the well-behaved exhaustion path, - lease expiry lets a poison step execute and fail on the background path — a + lease expiry lets a poison step execute and fail on the background path, a *step-level*, `catch`-able failure the workflow can handle. Unbounded ownership funnels the same poison into run-level "exceeded max deliveries", which kills the whole run uncatchably, and only after the full backed-off delivery budget. @@ -149,12 +149,12 @@ live owner is protected from duplicates." The upper clamp exists for clock skew: `lastStartedAt` is server-stamped while `now` is the local clock, so a client running behind the server would otherwise compute a remainder -*longer* than the lease — and above 900s, a `delaySeconds` that SQS-backed queues reject +*longer* than the lease, and above 900s, a `delaySeconds` that SQS-backed queues reject outright. ### Why a fixed constant, and why 860 seconds -The correct lease is "longer than any invocation can possibly live" — beyond that point +The correct lease is "longer than any invocation can possibly live". Beyond that point the owner is provably dead on platforms that kill invocations. Ideally we would derive it from the workflow route's resolved `maxDuration`. **No such signal exists**: builders emit `maxDuration: 'max'`, which the platform resolves per-plan at deploy time; there is no @@ -164,18 +164,18 @@ lease was rejected because there is nothing to derive it from. 860s is justified by a platform rule rather than a measurement: durations above 800s require explicit per-function numeric configuration, so `'max'` resolves to ≤ 800s for any builder-emitted workflow route, and 860 dominates it with headroom. The constant is -env-tunable (`WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS`, clamped to 1..900 — 900 being the +configurable through an environment variable (`WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS`, clamped from 1 to 900, where 900 is the queue's maximum per-message delay, so a single delayed backstop message always suffices and no delay chaining is needed). The code comment carries the 30-minute-`maxDuration` beta caveat so the constant is revisited when the platform ceiling moves. On worlds with **no** invocation kill bound (world-local's single process, self-hosted -deployments), no constant is a death proof — which is why the in-process single-flight +deployments), no constant is a death proof, which is why the in-process single-flight below is a required layer, not an optimization. -## Why the non-owner action is a delayed backstop wake — not a skip, and not a step message +## Why the non-owner action is a delayed backstop wake: not a skip, and not a step message -The naive non-owner behavior is to simply *skip* the requeue. Rejected: a pure skip makes +The naive non-owner behavior is to *skip* the requeue. Rejected: a pure skip makes lease expiry useless, because nothing is scheduled to *observe* the expiry. If the owner dies and no external wake happens to arrive later, the run wedges. The escape hatch has to be folded into the suppression itself. @@ -184,7 +184,7 @@ So the non-owner enqueues a **plain run continuation** (no `stepId`) with `delay leaseRemaining`. When it fires, it replays the run and re-enters the same dispatch decision table, which handles every state the step can be in by then: terminal → nothing pending; queue-owned after `step_retrying` → normal keyed dispatch; owner dead with lease -expired → immediate dispatch — preserving *step-level* failure semantics for poison steps +expired → immediate dispatch, preserving *step-level* failure semantics for poison steps (the step fails and the workflow's `catch` sees it, rather than the run dying on a delivery-budget backstop); lease refreshed by owner recovery → re-arm for the new remainder. @@ -194,11 +194,11 @@ now encoded in `backstopIdempotencyKey`: 1. **The backstop must not be the step's own message.** The first implementation enqueued the step message itself (keyed `correlationId`) with the lease delay. But the owner's - retry handoff enqueues the step under that *same* key with a ~1s backoff — and the + retry handoff enqueues the step under that *same* key with a ~1s backoff, and the pending backstop absorbed it, turning a 1-second retry into a full-lease stall. Caught by the abort-mid-flight e2e wedging on every world-local lane. 2. **The backstop key must change when ownership is re-stamped.** Queues dedupe an - idempotency key for the original message's lifetime — *including while a delivery is + idempotency key for the original message's lifetime, *including while a delivery is in flight*. With a fixed `${correlationId}:backstop` key, a backstop firing during a lease that owner recovery had refreshed could never publish its own replacement (the re-arm deduped against the in-flight backstop itself and was dropped); if the @@ -229,7 +229,7 @@ Two invariants keep the table sound: full lease. All inline bodies are awaited before any ack path, a dev assertion (`error`-level log) guards the ordering against refactors, and turbo's `reinvoke()` paths are safe by construction: turbo requires delivery attempt 1, while owned-recovery - steps can only exist on redeliveries (attempt ≥ 2) — a previous delivery of the same + steps can only exist on redeliveries (attempt ≥ 2), since a previous delivery of the same message must have stamped them. That mutual exclusion is documented at turbo's engagement gate. - **Owned recovery must not be silently orphaned by early returns.** The background-step @@ -242,7 +242,7 @@ Two invariants keep the table sound: The lease bounds *cross-instance* duplication only on platforms that kill invocations. On world-local (one process, no kill bound) a delayed backstop can fire while the owning -execution is still mid-body *in the same process* — and on Fluid compute, an owner +execution is still mid-body *in the same process*, and on Fluid Compute, an owner redelivery and a backstop can land on the same instance. A module-level map keyed `runId:correlationId` absorbs both: the loser awaits the winner's settlement and then acks **without executing**. @@ -250,25 +250,25 @@ redelivery and a backstop can land on the same instance. A module-level map keye The loser must not ack-and-skip early (before the winner settles): a crash after an early ack would consume the loser's message while the winner's outcome is unknown, potentially orphaning the step with no message left to drive it. Awaiting settlement first keeps the -at-least-once envelope intact — if the loser's own invocation hits its deadline while +at-least-once envelope intact: if the loser's own invocation hits its deadline while waiting, its message redelivers and re-checks, degrading gracefully to polling. Cross-instance duplicates on multi-instance *self-hosted* worlds during steps longer than -the lease remain the documented residual (mitigate by raising the lease env). This equals +the lease remain the documented residual (mitigate by raising the lease environment variable). This equals the trade-off every lease-based system makes; eliminating it entirely requires the heartbeat machinery rejected above. ## Alternatives considered and rejected -- **Flow-route queue concurrency = 1 (serialize all run messages).** Kills the very +- **Flow-route queue concurrency = 1 (serialize all run messages)**: Removes the parallelism the wake mechanism exists for: `Promise.race(step, sleep)` works because the wait continuation fires in a *separate* invocation while the inline step blocks - its handler — with one slot, the sleep could never win. It is also a queue-backend + its handler. With one slot, the sleep could never win. It is also a queue-backend feature the World contract does not guarantee, and it serializes unrelated work (hooks, cancellations) behind long step bodies. Noted as a long-term option only if ownership proves unmaintainable. - **Inline-eligibility latch (never inline while hooks/waits are open).** The cheapest - hotfix — the condition is already computed for turbo's latch — but it permanently + hotfix (the condition is already computed for turbo's latch), but it permanently forfeits inline execution for exactly the workflows that use hooks, taxing every run to prevent a race that needs an in-flight step to matter. And it is incomplete: cancellation can wake *any* run mid-step, hooks or not. @@ -278,7 +278,7 @@ heartbeat machinery rejected above. `step_retrying`. - **Heartbeat / lock-store liveness.** Rejected above: new World surface for every backend, steady-state write amplification, and it still needs a lease to survive a - crashed heartbeater — all cost, same bound. + crashed heartbeater. All cost, same bound. - **Deriving the lease from the route's `maxDuration`.** Nothing to derive from: builders emit `'max'`, resolved per-plan by the platform at deploy; no runtime or build-time API exposes the resolved value. @@ -300,7 +300,7 @@ heartbeat machinery rejected above. replays see unowned steps → exactly today's behavior. Safe, but a pointless window to ship into. - **Version skew is a non-issue**: runs are pinned to their deployment, and old events - simply lack the field → unowned → current behavior. Nothing to migrate. + lack the field → unowned → current behavior. Nothing to migrate. - **Kill switch**: `WORKFLOW_INLINE_OWNERSHIP=0` reverts dispatch to the unconditional immediate requeue. Stamping continues (it is inert data), so the switch is purely a dispatch-behavior toggle. @@ -312,11 +312,11 @@ heartbeat machinery rejected above. - **Redelivery-while-alive** (visibility lapse or heartbeat partition inside the queue): the owner check passes on a redelivery racing the live owner → duplicate. This equals - the queue's at-least-once envelope — the floor for any client-side design — and is + the queue's at-least-once envelope (the floor for any client-side design) and is strictly rarer than the every-wake duplication being fixed. The in-process single-flight absorbs the same-instance case. - **Multi-instance self-hosted worlds with steps longer than the lease** (see - single-flight section): raise the lease env. + single-flight section): raise the lease environment variable. - **A backstop per ownership epoch**: an owner crash-looping through its redelivery budget arms up to one delayed wake per re-stamp. Bounded by the queue's delivery budget; each fires as a cheap replay no-op if the step completed. @@ -329,7 +329,7 @@ heartbeat machinery rejected above. immediate requeue). - Always-printed `warn` logs when owned recovery runs (a prior delivery died mid-body) and when the single-flight absorbs a would-be duplicate (a burst of these means leases - are expiring under live executions — raise the lease env). Invariant violations log at + are expiring under live executions: raise the lease environment variable). Invariant violations log at `error`. Backstop arming logs at `debug` (`DEBUG=workflow:runtime:*`), since it can legitimately fire on every wake replay during a long inline step. @@ -340,14 +340,14 @@ heartbeat machinery rejected above. with `WORKFLOW_INLINE_OWNERSHIP=0` the marker fires twice. - **Unit**: the ownership state machine (stamp → wake sees owner → retrying clears → bare start clears → re-stamp restores), lease math including the clock-skew clamp, and the - backstop key's epoch behavior — including a regression test walking the full + backstop key's epoch behavior, including a regression test walking the full owner-recovery re-arm sequence against a dedupe model matching world-local's in-flight key retention; single-flight winner/loser semantics. - **Wire**: schema round-trip tests on both sides, plus backend integration tests asserting the field survives materialization and the lazy-input strip, and never leaks into synthetic `step_created`. -- **E2E**: the full suite including the abort/cancellation lanes that caught backstop - shape #1 — world-vercel prod lanes are mandatory for sign-off, since world-local's +- **End-to-end (E2E)**: The full suite including the abort/cancellation lanes that caught backstop + shape #1: world-vercel prod lanes are mandatory for sign-off, since world-local's synchronous single-process behavior masks distributed races. ## Open questions diff --git a/docs/content/docs/v5/changelog/turbo-mode.md b/docs/content/docs/v5/changelog/turbo-mode.md index 3d27bcf0aa..41f150f1f9 100644 --- a/docs/content/docs/v5/changelog/turbo-mode.md +++ b/docs/content/docs/v5/changelog/turbo-mode.md @@ -1,6 +1,6 @@ --- title: Turbo mode (fast first invocation) -description: Fast-path the very first delivery of the first invocation — background run_started, skip the initial event-log load, and force optimistic inline start — so a run blazes through its first steps. A no-op for everything else. +description: Fast-path the first delivery of the first invocation by backgrounding run_started, skipping the initial event-log load, and forcing optimistic inline start. A no-op for everything else. --- # Turbo mode @@ -10,7 +10,7 @@ description: Fast-path the very first delivery of the first invocation — backg The first invocation of a workflow run is where time-to-first-step matters most, yet it pays the most fixed network latency before any user code runs. Three round-trips sit on that critical path today: 1. **`run_started` is awaited.** The handler writes `run_started` and waits for it to return the run entity before doing anything else. -2. **The event log is loaded.** A full `events.list` runs before the first replay — even though on the very first delivery nothing has written any events yet. +2. **The event log is loaded.** A full `events.list` runs before the first replay, even though on the first delivery nothing has written any events yet. 3. **Optimistic inline start is off by default.** The [optimistic inline start](./lazy-event-creation#optimistic-inline-start-opt-in-off-by-default) optimization (running a step body before its `step_started` is confirmed) is off by default because under contention two handlers can both run a body and corrupt non-idempotent side effects. Turbo mode removes all three costs **for the first delivery of the first invocation only**, where each is provably safe to remove, then gets out of the way. For every subsequent invocation it is a complete no-op. @@ -19,69 +19,69 @@ Turbo mode removes all three costs **for the first delivery of the first invocat When the handler detects the first delivery of the first invocation, it: -1. **Backgrounds `run_started`.** The event is written without awaiting; the run entity is synthesized locally from the queued run input (status `running`, `startedAt` now) so replay can begin immediately. The `run_started` round-trip overlaps replay instead of blocking it. This reuses the [resilient start](./resilient-start) contract — `run_started` carrying the run input creates the run on the fly (synthetic `run_created`) if it doesn't exist yet. Because turbo uses this `run_started` purely as a write barrier and never reads its response, it also asks the World to **skip the `run_started` event-log preload** (the list+resolve the World normally returns so a run can skip its first `events.list`). That preload would be wasted work here — and, since the first `step_started` is chained on the `run_started` barrier, trimming the `run_started` request directly shortens the wait before the first durable `step_started` (and therefore time-to-second-step). A World that ignores the hint stays correct; the runtime simply falls back to `events.list` if it ever needs the log. +1. **Backgrounds `run_started`.** The event is written without awaiting; the run entity is synthesized locally from the queued run input (status `running`, `startedAt` now) so replay can begin immediately. The `run_started` round-trip overlaps replay instead of blocking it. This reuses the [resilient start](./resilient-start) contract: `run_started` carrying the run input creates the run on the fly (synthetic `run_created`) if it doesn't exist yet. Because turbo uses this `run_started` purely as a write barrier and never reads its response, it also asks the World to **skip the `run_started` event-log preload** (the list+resolve the World normally returns so a run can skip its first `events.list`). That preload would be wasted work here. Since the first `step_started` is chained on the `run_started` barrier, trimming the `run_started` request directly shortens the wait before the first durable `step_started` (and therefore time-to-second-step). A World that ignores the hint stays correct; the runtime falls back to `events.list` if it ever needs the log. 2. **Skips the initial event-log load.** Nothing has been written, so the first replay runs against an empty log. The second loop iteration does a normal incremental load once the first step's events exist. 3. **Forces optimistic inline start** for that invocation, independent of `WORKFLOW_OPTIMISTIC_INLINE_START`. The step body runs immediately against locally-synthesized state; only the `step_started` network write waits for the backgrounded `run_started`. -The net effect: the first step body starts after just the in-process replay, with `run_started` and `step_started` happening in the background around it, and no `events.list` before it. +The first step body starts after the in-process replay, with `run_started` and `step_started` happening in the background around it and no `events.list` before it. ## Why this is safe (and where it stops) ### Detection -The first-invocation message is the only one that carries the queued **run input**, and the queue delivery **attempt is 1** (a redelivery is attempt ≥ 2). Together with "not a background-step invocation" and "not a divergence recovery", that uniquely identifies the first delivery of the first invocation — with no new message field and no world/backend change. +The first-invocation message is the only one that carries the queued **run input**, and the queue delivery **attempt is 1** (a redelivery is attempt ≥ 2). Together with "not a background-step invocation" and "not a divergence recovery", that uniquely identifies the first delivery of the first invocation without a new message field or world/backend change. ### The single-handler guarantee -Forcing optimistic start is unsafe *in general* because two handlers racing the same step's create-claim can both run the body before one wins. On the first delivery of the first invocation there is **no concurrent peer handler** — the run was created moments ago by `start()` and only this one message is in flight. So the body runs exactly once, and forcing optimistic start is safe here even though the global flag is off. +Forcing optimistic start is unsafe *in general* because two handlers racing the same step's create-claim can both run the body before one wins. On the first delivery of the first invocation there is **no concurrent peer handler**. The run was created moments ago by `start()`, and only this one message is in flight. The body therefore runs exactly once, and forcing optimistic start is safe here even though the global flag is off. ### Turbo exits on the first hook or wait -That single-handler guarantee ends the moment the run creates a **hook** or **wait** (or writes attributes): those introduce later resume/parallel invocations that *can* race. So turbo stops forcing optimistic start as soon as a suspension creates any of them — the inline steps of that suspension fall back to the normal await-then-run path, and the rest of the run behaves exactly as it does today. A pure-step suspension (the common hot path) stays on the fast path. +That single-handler guarantee ends the moment the run creates a **hook** or **wait** (or writes attributes) because those introduce later resume/parallel invocations that *can* race. Turbo stops forcing optimistic start as soon as a suspension creates any of them. The inline steps of that suspension fall back to the normal await-then-run path, and the rest of the run behaves exactly as it does today. A pure-step suspension (the common hot path) stays on the fast path. ### Write ordering is preserved Because `run_started` is backgrounded, every event write is gated on a run-ready barrier so nothing is written before the run exists: -- The optimistic `step_started` is **chained** on the barrier — the body still runs immediately, only the network write waits. +- The optimistic `step_started` is **chained** on the barrier. The body still runs immediately; only the network write waits. - The suspension handler **awaits** the barrier before any eager write (`hook_created`, `wait_created`, overflow `step_created`). The pure inline hot path defers all its steps and writes nothing here, so it never blocks on the barrier. - Terminal run writes (`run_completed` / `run_failed`) await the barrier too, so a workflow that finishes with no steps still orders its completion after `run_started`. -The event log therefore still reads `run_created → run_started → step_created → step_started → step_completed`. If the backgrounded `run_started` genuinely fails (e.g. the run was cancelled in the meantime), the chained writes surface the real error (`gone` / run-not-found) and the message redelivers as a normal, non-turbo attempt. +The event log therefore still reads `run_created → run_started → step_created → step_started → step_completed`. If the backgrounded `run_started` genuinely fails (e.g. the run was canceled in the meantime), the chained writes surface the real error (`gone` / run-not-found) and the message redelivers as a normal, non-turbo attempt. -The barrier orders **event** writes. The forced-optimistic first step **body** runs immediately, so any side effects it performs *before* the terminal write — stream writes via `getWritable()` and the per-step ops flush — are **not** gated on the barrier and can reach the world before the backgrounded `run_started` lands (and are orphaned if it ultimately fails). This is the same exposure as optimistic inline start and is covered by the stream-safety caveat below; deployments whose first step writes to the workflow stream and require strict `run_created → run_started` ordering of stream data should set `WORKFLOW_TURBO=0`. +The barrier orders **event** writes. The forced-optimistic first step **body** runs immediately, so any side effects it performs *before* the terminal write (stream writes via `getWritable()` and the per-step ops flush) are **not** gated on the barrier and can reach the world before the backgrounded `run_started` lands (and are orphaned if it ultimately fails). This is the same exposure as optimistic inline start and is covered by the stream-safety caveat below; deployments whose first step writes to the workflow stream and require strict `run_created → run_started` ordering of stream data should set `WORKFLOW_TURBO=0`. ### A run cancelled before its first delivery still runs the first step body -The non-turbo path awaits `run_started` up front and, if the run was cancelled or expired between `start()` and this delivery, returns before any workflow/step code runs. Turbo synthesizes `status: 'running'` and runs the first step body optimistically, so such a cancellation is only observed when the backgrounded `run_started` (and the barrier-chained `step_started`) rejects — *after* the body's side effects have executed (they are then discarded via reconciliation). For non-idempotent first steps this is the same "body runs before ownership is confirmed" tradeoff as optimistic inline start; `WORKFLOW_TURBO=0` restores the up-front skip. +The non-turbo path awaits `run_started` up front and, if the run was canceled or expired between `start()` and this delivery, returns before any workflow/step code runs. Turbo synthesizes `status: 'running'` and runs the first step body optimistically, so such a cancellation is only observed when the backgrounded `run_started` (and the barrier-chained `step_started`) rejects, *after* the body's side effects have executed (they are then discarded via reconciliation). For non-idempotent first steps this is the same "body runs before ownership is confirmed" tradeoff as optimistic inline start; `WORKFLOW_TURBO=0` restores the up-front skip. ### `workflowStartedAt` reflects the first delivery's clock -Replay matching — step/wait/hook correlation IDs, the VM seed, and the in-VM `Date.now()` — is derived from a replay-stable timestamp recovered from the run ID, so it does **not** depend on `startedAt` and is identical on every delivery. The one value that still tracks `startedAt` is the user-facing `getWorkflowMetadata().workflowStartedAt`: under turbo the first delivery synthesizes it from the local clock, while a later (non-turbo) delivery loads the server-canonical `startedAt`, so the two can differ by the start→first-delivery latency. Treat `workflowStartedAt` as an approximate, human-facing timestamp — do **not** branch workflow control flow on it (e.g. `Date.now() - +workflowStartedAt > threshold`), since that can take different paths across deliveries and diverge on replay. For timing logic that must survive replay, use the in-VM `Date.now()` / `new Date()`, which is replay-stable. +Replay matching (step/wait/hook correlation IDs, the VM seed, and the in-VM `Date.now()`) is derived from a replay-stable timestamp recovered from the run ID, so it does **not** depend on `startedAt` and is identical on every delivery. The one value that still tracks `startedAt` is the user-facing `getWorkflowMetadata().workflowStartedAt`: under turbo the first delivery synthesizes it from the local clock, while a later (non-turbo) delivery loads the server-canonical `startedAt`, so the two can differ by the start→first-delivery latency. Treat `workflowStartedAt` as an approximate, human-facing timestamp. **Do not** branch workflow control flow on it (e.g. `Date.now() - +workflowStartedAt > threshold`), since that can take different paths across deliveries and diverge on replay. For timing logic that must survive replay, use the in-VM `Date.now()` / `new Date()`, which is replay-stable. ### Attributes seeded at `start()` survive the skipped event load -`start({ attributes })` does **not** disable turbo, and it needs no synthetic event in the empty log. Seed attributes are folded into the `run_created` event's data (not separate `attr_set` events) and ride along in the queued run input, so the locally-synthesized run snapshot carries them — turbo skipping the initial `events.list` loses nothing. +`start({ attributes })` does **not** disable turbo, and it needs no synthetic event in the empty log. Seed attributes are folded into the `run_created` event's data (not separate `attr_set` events) and ride along in the queued run input, so the locally-synthesized run snapshot carries them. Turbo loses nothing by skipping the initial `events.list`. This is safe specifically because **attributes are write-only inside a workflow**: there is no in-workflow read API today, and `run_created` is consumed structurally during replay without inspecting its attributes. So an empty initial event log replays identically whether or not the run was seeded with attributes. -That safety is a standing invariant for any future change: if an in-workflow attribute *read* API is ever added, it MUST read from the run snapshot (which turbo populates from the run input) and **not** by replaying `run_created` / `attr_set` events. Reading from the event log would surface seed attributes as empty on the first turbo delivery only — a turbo-exclusive divergence from the non-turbo path. `start()` cannot seed hooks or waits, so there is no start-seeded suspension state for the skipped load to miss. +That safety is a standing invariant for any future change: if an in-workflow attribute *read* API is ever added, it MUST read from the run snapshot (which turbo populates from the run input) and **not** by replaying `run_created` / `attr_set` events. Reading from the event log would surface seed attributes as empty on the first turbo delivery only, causing a turbo-exclusive divergence from the non-turbo path. `start()` cannot seed hooks or waits, so there is no start-seeded suspension state for the skipped load to miss. ## Configuration -Turbo mode is **on by default**. Set `WORKFLOW_TURBO=0` (or `false`) to disable it — every invocation then takes the existing awaited path. This is a useful kill-switch for deployments whose first-step bodies are not idempotent and stream-safe (the same caveat as optimistic inline start), or for isolating behavior while debugging. +Turbo mode is **on by default**. Set `WORKFLOW_TURBO=0` (or `false`) to disable it. Every invocation then takes the existing awaited path. This is a useful kill-switch for deployments whose first-step bodies are not idempotent and stream-safe (the same caveat as optimistic inline start), or for isolating behavior while debugging. -Turbo forces optimistic inline start on the first invocation regardless of `WORKFLOW_OPTIMISTIC_INLINE_START` (its single-handler guarantee removes the double-execution race that flag guards against). It does, however, **honor an explicit `WORKFLOW_OPTIMISTIC_INLINE_START=0`**: because forced optimistic start still runs the body before `step_started`/`run_started` is confirmed, an operator who has explicitly disabled optimistic start keeps the await-then-run path even under turbo (the rest of turbo — backgrounded `run_started`, skipped initial load — still applies). With the flag unset (the default), turbo forces it on. +Turbo forces optimistic inline start on the first invocation regardless of `WORKFLOW_OPTIMISTIC_INLINE_START` (its single-handler guarantee removes the double-execution race that flag guards against). It does, however, **honor an explicit `WORKFLOW_OPTIMISTIC_INLINE_START=0`**: because forced optimistic start still runs the body before `step_started`/`run_started` is confirmed, an operator who has explicitly disabled optimistic start keeps the await-then-run path even under turbo (the rest of turbo, including backgrounded `run_started` and the skipped initial load, still applies). With the flag unset (the default), turbo forces it on. -Turbo mode is purely client-side and builds on the lazy/optimistic inline start support already shipped — it requires no world or backend changes. +Turbo mode is purely client-side and builds on the lazy/optimistic inline start support already shipped, so it requires no world or backend changes. ## Considered: running ahead of durable writes (not implemented) -Turbo overlaps the *start* round-trips with a step's body, but it still **awaits each `step_completed` before advancing** to the next step. We explored going further — "run-ahead": within a single invocation, execute the workflow forward across a sequential chain *without* awaiting each step's event writes, draining `step_started`/`step_completed` through a background FIFO queue and only blocking on a full drain before acking. A run of three sub-millisecond steps would then fire all the bodies back-to-back while the six event posts caught up in the background, turning per-step latency into `max(Σ body, Σ post)` instead of `Σ(body + post)`. +Turbo overlaps the *start* round-trips with a step's body, but it still **awaits each `step_completed` before advancing** to the next step. We explored going further with "run-ahead": within a single invocation, execute the workflow forward across a sequential chain *without* awaiting each step's event writes, draining `step_started`/`step_completed` through a background FIFO queue and only blocking on a full drain before acking. A run of three sub-millisecond steps would then fire all the bodies back-to-back while the six event posts caught up in the background, turning per-step latency into `max(Σ body, Σ post)` instead of `Σ(body + post)`. We decided **not** to ship it, for two reasons: -1. **Re-execution blast radius on failure.** Awaiting each completion means a crash re-runs essentially one in-flight step. Running ahead leaves many completions undrained at once, so a crash or `maxDuration` SIGTERM re-runs *all* of them on redelivery — a much larger at-least-once blast radius, precisely on the latency-sensitive runs most likely to pack many steps into one invocation. -2. **Divergent branches from non-durable results.** Advancing past a step before its result is durable lets the workflow commit to a forward path that a crash-and-redeliver can re-decide differently. A `Promise.race([B, C])` resolved by local timing can pick `B`, run `D(B)`, then crash before `step_completed_B` is durable — and the redelivery may re-resolve to `C`, so `D` executed against a winner the durable history never records. The same shape appears for a branch on a non-deterministic step output (`B(v1)` runs, crash, redelivery commits `B(v2)`). Idempotency doesn't cover these — `D(B)`/`D(C)` and `B(v1)`/`B(v2)` are *different* operations, not retries of one. A "run ahead only while at most one result is undurable" gate would contain the race case (a race needs ≥2 concurrent undurable steps) but not the non-deterministic-output case, and that residual hazard plus the re-execution blast radius outweighed the gain. +1. **Re-execution blast radius on failure.** Awaiting each completion means a crash re-runs essentially one in-flight step. Running ahead leaves many completions undrained at once, so a crash or `maxDuration` SIGTERM re-runs *all* of them on redelivery. This creates a much larger at-least-once blast radius, precisely on the latency-sensitive runs most likely to pack many steps into one invocation. +2. **Divergent branches from non-durable results.** Advancing past a step before its result is durable lets the workflow commit to a forward path that a crash-and-redeliver can re-decide differently. A `Promise.race([B, C])` resolved by local timing can pick `B`, run `D(B)`, then crash before `step_completed_B` is durable. The redelivery may re-resolve to `C`, so `D` executed against a winner the durable history never records. The same shape appears for a branch on a non-deterministic step output (`B(v1)` runs, crash, redelivery commits `B(v2)`). Idempotency doesn't cover these because `D(B)`/`D(C)` and `B(v1)`/`B(v2)` are *different* operations, not retries of one. A "run ahead only while at most one result is undurable" gate would contain the race case (a race needs ≥2 concurrent undurable steps) but not the non-deterministic-output case, and that residual hazard plus the re-execution blast radius outweighed the gain. So turbo deliberately stops at forced-optimistic *start* and awaits each `step_completed` before moving on: re-execution after a crash stays deterministic (each step re-runs against the same durable inputs) and bounded (roughly one step, not the whole chain). The idea is recorded here in case a future change (e.g. a determinism signal on steps, or deterministic race resolution) makes run-ahead safe enough to revisit. diff --git a/docs/content/docs/v5/comparisons/index.mdx b/docs/content/docs/v5/comparisons/index.mdx index 72b30efb48..ccbe99177e 100644 --- a/docs/content/docs/v5/comparisons/index.mdx +++ b/docs/content/docs/v5/comparisons/index.mdx @@ -1,6 +1,6 @@ --- title: Comparisons -description: How the Workflow SDK compares to other durable execution, workflow, and AI-agent frameworks — Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev. +description: 'How the Workflow SDK compares to other durable execution, workflow, and AI-agent frameworks: Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev.' type: overview summary: Side-by-side comparisons of the Workflow SDK against Temporal, Cloudflare Workflows, AWS Step Functions, AWS Bedrock AgentCore, Inngest, and trigger.dev. related: @@ -13,26 +13,26 @@ The Workflow SDK overlaps with several categories: durable execution engines, ba ## What makes the Workflow SDK different -- **It's an open-source SDK, not a hosted product.** Your workflows are plain TypeScript in your existing app. Run them on the managed [Vercel World](/worlds/vercel), or self-host on the [Postgres World](/worlds/postgres) — the [World abstraction](/worlds/building-a-world) lets you own and swap the storage, queue, and streaming layers independently. +- **It's an open-source SDK, not a hosted product.** Your workflows are plain TypeScript in your existing app. Run them on the managed [Vercel World](/worlds/vercel), or self-host on the [Postgres World](/worlds/postgres). The [World abstraction](/worlds/building-a-world) lets you own and swap the storage, queue, and streaming layers independently. - **Versioning is safe by default.** Runs are pinned to the immutable deployment that started them, so shipping new code never disturbs in-flight runs. Upgrading a run is explicit and opt-in. See [Versioning](/docs/comparisons/workflow-sdk-vs-temporal#versioning). -- **Realtime durable streaming is built in.** Stream partial output (LLM tokens, progress) to clients with [streams](/docs/foundations/streaming) that survive reconnects, cold starts, and replays — essential for chat and agent UIs. +- **Real-time durable streaming is built in.** Stream partial output, such as large language model (LLM) tokens and progress, to clients with [streams](/docs/foundations/streaming) that survive reconnects, cold starts, and replays. This durability supports chat and agent UIs. - **First-class AI agents.** `WorkflowAgent` ships inside the [AI SDK](/docs/ai), turning an agent loop into a durable workflow with automatic step retries and human-in-the-loop pauses. ## Snapshot -These comparisons are compiled from each product's public documentation and are **not** based on head-to-head benchmarks — durable engines differ enough that a single number rarely compares cleanly. Treat them as directional and verify current pricing and limits against each vendor's docs. +These comparisons are compiled from each product's public documentation and are **not** based on head-to-head benchmarks: durable engines differ enough that a single number rarely compares cleanly. Treat them as directional and verify current pricing and limits against each vendor's docs. | Tool | Category | Durability model | Open source / self-host | Language(s) | | --- | --- | --- | --- | --- | -| **Workflow SDK** | Durable functions SDK | Event-log + deterministic replay | ✅ Apache-2.0 — self-host (Postgres) or Vercel | TypeScript (Python beta) | -| [Temporal](/docs/comparisons/workflow-sdk-vs-temporal) | Durable execution platform | Event-sourced replay | ✅ MIT server — self-host or Temporal Cloud | Go, Java, TS, Python, .NET, PHP, Ruby | +| **Workflow SDK** | Durable functions SDK | Event-log + deterministic replay | ✅ Apache-2.0: self-host (Postgres) or Vercel | TypeScript (Python beta) | +| [Temporal](/docs/comparisons/workflow-sdk-vs-temporal) | Durable execution platform | Event-sourced replay | ✅ MIT server: self-host or Temporal Cloud | Go, Java, TypeScript, Python, .NET, PHP, Ruby | | [Cloudflare Workflows](/docs/comparisons/workflow-sdk-vs-cloudflare-workflows) | Durable execution engine | Step-result memoization + replay | ❌ Cloudflare-only | TypeScript (Python beta) | | [AWS Step Functions](/docs/comparisons/workflow-sdk-vs-aws-step-functions) | Managed state-machine orchestrator | Declarative ASL state machine | ❌ AWS-only | ASL JSON (tasks: any language) | | [AWS Bedrock AgentCore](/docs/comparisons/workflow-sdk-vs-aws-agentcore) | AI-agent hosting platform | Not durable execution (ephemeral sessions) | ❌ AWS-only | Python, Node.js | -| [Inngest](/docs/comparisons/workflow-sdk-vs-inngest) | Durable functions / event platform | Step-result memoization | ◑ SSPL — self-host (community/best-effort) or SaaS | TypeScript (Python/Go pre-1.0) | -| [trigger.dev](/docs/comparisons/workflow-sdk-vs-trigger-dev) | Durable task platform | Process checkpoint/restore (CRIU) | ✅ Apache-2.0 — self-host or Cloud | TypeScript only | +| [Inngest](/docs/comparisons/workflow-sdk-vs-inngest) | Durable functions / event platform | Step-result memoization | ◑ SSPL: self-host (community/best-effort) or SaaS | TypeScript (Python/Go pre-1.0) | +| [trigger.dev](/docs/comparisons/workflow-sdk-vs-trigger-dev) | Durable task platform | Process checkpoint/restore (CRIU) | ✅ Apache-2.0: self-host or Cloud | TypeScript only | ## Deep dives @@ -41,16 +41,16 @@ These comparisons are compiled from each product's public documentation and are The mature, language-agnostic durable-execution platform. You run the workers; Workflow SDK runs in your app. - A durable engine on Workers + Durable Objects. Both replay; they handle versioning and encryption very differently. + A durable engine on Workers and Durable Objects. Both replay; they handle versioning and encryption differently. - Declarative ASL JSON state machines vs. plain TypeScript control flow. + Declarative ASL JSON state machines compared with plain TypeScript control flow. - An AI-agent hosting platform — not a durable-execution engine. Different axis. + An AI-agent hosting platform, not a durable-execution engine. Different axis. - Event-driven durable functions that run on your own infra over HTTP. + Event-driven durable functions that run on your own infrastructure over HTTP. A TypeScript task platform that achieves durability by snapshotting the process (CRIU). @@ -58,7 +58,7 @@ These comparisons are compiled from each product's public documentation and are -Moving an existing system over? Each deep dive includes a concept-mapping section, and the Workflow SDK migration skill can translate code for you: +Each deep dive includes a concept-mapping section for moving an existing system. The Workflow SDK migration skill can translate code for you: ```bash npx skills add https://github.com/vercel/workflow --skill migrating-to-workflow-sdk diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-agentcore.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-agentcore.mdx index 619a866ccf..daf880e7c2 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-agentcore.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-agentcore.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs AWS Bedrock AgentCore -description: How the Workflow SDK compares to AWS Bedrock AgentCore — a durable-execution framework versus an AI-agent hosting platform. They solve different problems. +description: How the Workflow SDK compares to AWS Bedrock AgentCore, a durable-execution framework versus an AI-agent hosting platform. They solve different problems. type: conceptual summary: AgentCore hosts and operates AI agents in isolated microVMs but is not a durable-execution engine. The Workflow SDK provides durable orchestration and resumable streaming for agents. prerequisites: @@ -11,10 +11,10 @@ related: - /docs/foundations/streaming --- -[Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html) is AWS's platform for **hosting and operating AI agents** — secure microVM runtime, plus building blocks for Memory, tool Gateways, and Identity. It is *not* a durable-execution engine, which makes this less a head-to-head and more a "different axis" comparison. +[Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html) is AWS's platform for **hosting and operating AI agents**, with a secure microVM runtime and building blocks for Memory, tool Gateways, and Identity. AgentCore is not a durable-execution engine, so it addresses a different part of the AI agent stack. -**These solve different problems.** AgentCore answers "where do I securely run and operate an agent on AWS?" The Workflow SDK answers "how do I make a multi-step, tool-calling agent loop durable, resumable, and streamable?" AWS itself pairs AgentCore with a durable layer (Step Functions, or Temporal) for resumability — the Workflow SDK provides that durable layer natively, in your own app. +**These solve different problems.** AgentCore answers "where do I securely run and operate an agent on AWS?" The Workflow SDK answers "how do I make a multi-step, tool-calling agent loop durable, resumable, and streamable?" AWS itself pairs AgentCore with a durable layer (Step Functions, or Temporal) for resumability. The Workflow SDK provides that durable layer natively, in your own app. ## At a glance @@ -22,22 +22,22 @@ related: | | Workflow SDK | AWS Bedrock AgentCore | | --- | --- | --- | | **Category** | Open-source durable-functions SDK | AI-agent hosting & operations platform | -| **Durable execution?** | ✅ Event-log replay; the agent loop resumes from its last checkpoint after a crash | ❌ Not built-in — sessions are ephemeral microVMs; durability is opt-in via Memory or a framework checkpointer | +| **Durable execution?** | ✅ Event-log replay; the agent loop resumes from its last checkpoint after a crash | ❌ Not built-in: sessions are ephemeral microVMs; durability is opt-in via Memory or a framework checkpointer | | **What it gives you** | Durable orchestration, steps, hooks, streaming, observability | Runtime (microVM hosting), Harness (managed agent loop), Memory, Gateway (tools/MCP), Identity, Browser, Code Interpreter, Observability, Policy, Evaluations | -| **Languages** | TypeScript / JS (Python beta) | Python-first for authoring; TypeScript or Python project scaffolding via the `@aws/agentcore` CLI; framework-agnostic (LangGraph, CrewAI, Strands, etc.) | +| **Languages** | TypeScript / JavaScript (Python beta) | Python-first for authoring; TypeScript or Python project scaffolding via the `@aws/agentcore` command-line interface (CLI); framework-agnostic (LangGraph, CrewAI, Strands, and others) | | **Isolation** | VM-sandboxed workflow code + full-Node steps | Dedicated Firecracker microVM per session (strong hardware isolation) | | **Max duration** | No limit ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 8-hour hard cap per session | -| **AI streaming** | Native **durable, resumable** streaming (survives reconnect, cold start, replay) | Live SSE / WebSocket only — no buffering or replay of missed tokens | -| **Versioning** | Runs pinned to immutable deployment | Immutable runtime versions + endpoints; in-flight sessions stay on their launch version (deployment/rollback only — no replay) | +| **AI streaming** | Native **durable, resumable** streaming (survives reconnect, cold start, replay) | Live server-sent events (SSE) / WebSocket only: no buffering or replay of missed tokens | +| **Versioning** | Runs pinned to immutable deployment | Immutable runtime versions + endpoints; in-flight sessions stay on their launch version (deployment/rollback only, no replay) | | **Portability** | Apache-2.0; runs anywhere Node runs; World abstraction | AWS-only; agent *code* is portable, the operating platform is not | -| **Compliance** | Inherits your platform | HIPAA-eligible; SOC/PCI/ISO not third-party certified; not FedRAMP-authorized | +| **Compliance** | Inherits your platform | HIPAA-eligible; SOC, PCI, and ISO not third-party certified; not FedRAMP-authorized | | **Pricing** | SDK free; pay your platform | Per-module consumption (vCPU-hr / GB-hr, etc.) + model inference billed via Bedrock; no minimums | -**What the limits mean in practice:** AgentCore's 8-hour session cap means an agent that waits on a human, a long-running job, or a slow external system can't span that wait in one session. Workflow SDK runs have [no duration cap](https://vercel.com/docs/workflows/pricing) — they suspend durably at `sleep()` and hooks for hours or weeks. +**What the limits mean in practice**: AgentCore's 8-hour session cap means an agent that waits on a human, a long-running job, or a slow external system can't span that wait in one session. Workflow SDK runs have [no duration cap](https://vercel.com/docs/workflows/pricing): they suspend durably at `sleep()` and hooks for hours or weeks. ## The core distinction: durability -AgentCore Runtime gives each session an isolated microVM with up to 8 hours of runtime, but the compute is **ephemeral** — on a crash or stop, the next invocation gets a fresh microVM with no automatic replay of the agent loop. AWS's own guidance is to use AgentCore Memory or a framework checkpointer for state durability, and to layer a workflow engine (Step Functions or Temporal) on top when you need durable orchestration. +AgentCore Runtime gives each session an isolated microVM with up to 8 hours of runtime, but the compute is **ephemeral**: on a crash or stop, the next invocation gets a fresh microVM with no automatic replay of the agent loop. AWS's own guidance is to use AgentCore Memory or a framework checkpointer for state durability, and to layer a workflow engine (Step Functions or Temporal) on top when you need durable orchestration. The Workflow SDK *is* that durable layer. With `WorkflowAgent` (in the [AI SDK](/docs/ai)), the agent loop becomes a durable workflow: each model call and tool execution is a checkpointed step, the run resumes mid-loop after a failure, and partial output streams to the client through [resumable streams](/docs/ai/resumable-streams) that survive disconnects and cold starts. @@ -45,11 +45,11 @@ The Workflow SDK *is* that durable layer. With `WorkflowAgent` (in the [AI SDK]( AgentCore is purpose-built for operating agents on AWS, and brings things the Workflow SDK doesn't try to be: -- **MicroVM isolation per session** — the strongest hardware isolation among the tools in this section. -- **Managed agent infrastructure** — Memory, a tool Gateway (turn APIs/Lambda/MCP servers into tools), and Identity (credential vaulting, OAuth) as first-class managed services. -- **Enterprise/AWS compliance breadth** and VPC/PrivateLink networking. +- **MicroVM isolation per session**: the strongest hardware isolation among the tools in this section. +- **Managed agent infrastructure**: Memory, a tool Gateway (turn APIs/Lambda/MCP servers into tools), and Identity (credential vaulting, OAuth) as first-class managed services. +- **Enterprise/AWS compliance breadth** and Virtual Private Cloud (VPC)/PrivateLink networking. -If your priority is running agents inside AWS with managed memory, tools, and identity, AgentCore is a strong fit. If your priority is **durable, resumable, streamable** agent execution that lives in your own TypeScript app and isn't tied to AWS, the Workflow SDK fits better — and the two can be combined (host on AgentCore, orchestrate durably with the Workflow SDK). +AgentCore fits applications that run agents inside AWS with managed memory, tools, and identity. The Workflow SDK fits **durable, resumable, streamable** agent execution in your own TypeScript app without an AWS dependency. You can also combine them by hosting on AgentCore and orchestrating durably with the Workflow SDK. --- -*Compiled from public documentation. AgentCore cold-start figures are community-sourced (no published SLA). Verify against [the AgentCore docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html). Not based on head-to-head benchmarks.* +*Compiled from public documentation. AgentCore cold-start figures are community-sourced (no published service-level agreement). Verify against [the AgentCore docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/what-is-bedrock-agentcore.html). Not based on head-to-head benchmarks.* diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx index 0138304e3f..1046e933ba 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-aws-step-functions.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs AWS Step Functions -description: How the Workflow SDK compares to AWS Step Functions — plain TypeScript control flow versus declarative Amazon States Language JSON, plus a concept-mapping migration guide. +description: How the Workflow SDK compares to AWS Step Functions, including plain TypeScript control flow versus declarative Amazon States Language JSON and a concept-mapping migration guide. type: conceptual summary: AWS Step Functions is a managed state-machine orchestrator authored in declarative ASL JSON. The Workflow SDK expresses the same orchestration as plain TypeScript. prerequisites: @@ -11,7 +11,7 @@ related: - /worlds/vercel --- -[AWS Step Functions](https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html) is a mature, managed orchestrator that runs **state machines defined in Amazon States Language (ASL)** — a declarative JSON DSL. The headline contrast with the Workflow SDK is the authoring model: you assemble a state machine (JSON or a visual editor) instead of writing plain control-flow code. +[AWS Step Functions](https://docs.aws.amazon.com/step-functions/latest/dg/welcome.html) is a mature, managed orchestrator that runs **state machines defined in Amazon States Language (ASL)**, a declarative JSON DSL. The authoring model is the main contrast with the Workflow SDK: you assemble a state machine (JSON or a visual editor) instead of writing plain control-flow code. **Choose the Workflow SDK** when you want orchestration as ordinary TypeScript (`await`, `if`, `Promise.all`, `try/catch`) that lives in your app, portable off a single cloud, with built-in streaming. **Choose Step Functions** when you're deep in the AWS ecosystem and want a managed engine with native optimized integrations to 200+ AWS services and the broadest compliance footprint. @@ -21,7 +21,7 @@ related: | | Workflow SDK | AWS Step Functions | | --- | --- | --- | -| **Authoring** | Plain TypeScript: `"use workflow"` orchestrators calling `"use step"` functions | **Declarative ASL JSON** (or Workflow Studio visual editor / CDK) — not plain code | +| **Authoring** | Plain TypeScript: `"use workflow"` orchestrators calling `"use step"` functions | **Declarative ASL JSON** (or Workflow Studio visual editor / CDK), not plain code | | **Durability model** | Event log + deterministic replay | Managed state machine. **Standard** = exactly-once, up to 1 year; **Express** = at-least-once, up to 5 minutes | | **Control flow** | `await`, `if`/`switch`, `Promise.all`, `try/catch` | `Task` / `Choice` / `Wait` / `Parallel` / `Map` states wired with `Next` | | **Where it runs** | Your platform (Vercel managed or self-host) | AWS-managed; tasks run in Lambda or 200+ integrated AWS services | @@ -29,18 +29,18 @@ related: | **Human-in-the-loop** | `createHook()` / `createWebhook()` | `.waitForTaskToken` callback (Standard only) | | **Streaming** | Native durable, resumable streaming to clients | No native client streaming | | **Versioning** | Runs pinned to immutable deployment | Published versions are immutable; aliases route (≤2 versions) for canary/rollback; **in-flight executions keep their start-time definition** | -| **Portability** | Apache-2.0; runs anywhere Node runs | Proprietary, AWS-only; ASL is AWS-specific — high lock-in | +| **Portability** | Apache-2.0; runs anywhere Node runs | Proprietary and AWS-only; ASL is AWS-specific, resulting in high lock-in | | **Pricing** | SDK free; pay your platform | **Standard:** $0.025 / 1K state transitions. **Express:** $1 / M requests + GB-second duration | | **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 256 KB payload between states; Standard 25K history events / 1 year; Express 5 minutes | | **AI** | `WorkflowAgent` in the AI SDK; durable streaming | Bedrock integration + a preview AgentCore "InvokeHarness" task; no native client streaming | -**What the limits mean in practice:** the 256 KB cap on payloads between states is the binding constraint for AI workloads — virtually any model context or tool transcript has to round-trip through S3 with claim-check plumbing — and Standard executions cap history at 25K events. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. +**What the limits mean in practice:** The 256 KB cap on payloads between states is the binding constraint for AI workloads. Virtually any model context or tool transcript has to round-trip through S3 with claim-check plumbing, and Standard executions cap history at 25K events. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. ## Code vs. JSON -The defining difference: in Step Functions even "call one Lambda" requires a state-machine shell, and orchestration logic is expressed as ASL states (`Choice`, `Wait`, `Parallel`, `Map`). In the Workflow SDK it's ordinary TypeScript — transitions are `await`, branches are `if`, parallelism is `Promise.all`, and error handling is `try/catch`. That keeps orchestration in the same language, repo, and tests as the rest of your app, and removes the orchestrator/compute split (per-task Lambdas, IAM roles, callback queues). +In Step Functions, even "call one Lambda" requires a state-machine shell, and ASL states (`Choice`, `Wait`, `Parallel`, `Map`) express the orchestration logic. In the Workflow SDK, ordinary TypeScript handles orchestration: transitions are `await`, branches are `if`, parallelism is `Promise.all`, and error handling is `try/catch`. This keeps orchestration in the same language, repo, and tests as the rest of your app, and removes the orchestrator/compute split (per-task Lambdas, IAM roles, callback queues). -The trade-off: Step Functions' optimized service integrations call AWS services (DynamoDB, SQS, EventBridge, Bedrock, `ecs:runTask.sync`, …) declaratively. In the Workflow SDK those become ordinary SDK calls inside `"use step"` functions — you own the credentials, retries, and any polling. +The tradeoff is that Step Functions' optimized service integrations call AWS services (DynamoDB, SQS, EventBridge, Bedrock, `ecs:runTask.sync`, and others) declaratively. In the Workflow SDK, these become ordinary SDK calls inside `"use step"` functions, and you own the credentials, retries, and any polling. ## Migrating from Step Functions @@ -54,7 +54,7 @@ This guide assumes **Standard** workflows. Express workflows have different sema | Wait state | `sleep()` | `sleep('1m')` or `sleep(date)`. | | Parallel state | `Promise.all()` | Standard concurrency. | | Map state | `for` loop / bounded `Promise.all` (e.g. `p-limit`) / [`start()`](/docs/foundations/starting-workflows) per item for large fan-out | Match the original concurrency mode. | -| Retry / Catch (`MaxAttempts`, `BackoffRate`, `IntervalSeconds`) | `maxRetries`, `RetryableError`, `FatalError`; `try/catch` for compensation | Retry logic moves to step boundaries; backoff curves via `retryAfter` — see [Errors & Retrying](/docs/foundations/errors-and-retries#advanced-example). | +| Retry / Catch (`MaxAttempts`, `BackoffRate`, `IntervalSeconds`) | `maxRetries`, `RetryableError`, `FatalError`; `try/catch` for compensation | Retry logic moves to step boundaries; backoff curves use `retryAfter`. See [Errors & Retrying](/docs/foundations/errors-and-retries#advanced-example). | | `.waitForTaskToken` | `createHook()` / `createWebhook()` | Hooks for typed signals; webhooks for HTTP. | | Child state machine (`StartExecution`) | Call [`start()`](/docs/foundations/starting-workflows) directly from the workflow | Child runs are tagged with `$parentRunId` / `$rootRunId` automatically. | @@ -74,7 +74,7 @@ async function loadOrder(id: string) { } ``` -A `.waitForTaskToken` callback becomes a hook — no SQS queue, task token, or callback Lambda: +A `.waitForTaskToken` callback becomes a hook without an SQS queue, task token, or callback Lambda: ```typescript title="workflows/refund.ts" import { createHook } from 'workflow'; diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx index e117e6c9df..e484b50e7e 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-cloudflare-workflows.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs Cloudflare Workflows -description: How the Workflow SDK compares to Cloudflare Workflows — both are durable replay engines, but they handle versioning, encryption, portability, and global distribution very differently. +description: How the Workflow SDK compares to Cloudflare Workflows. Both are durable replay engines, but they handle versioning, encryption, portability, and global distribution differently. type: conceptual summary: Cloudflare Workflows is a durable engine on Workers and Durable Objects. It and the Workflow SDK both replay, but differ on versioning safety, encryption, and lock-in. prerequisites: @@ -11,7 +11,7 @@ related: - /worlds/building-a-world --- -[Cloudflare Workflows](https://developers.cloudflare.com/workflows/) is a durable-execution engine built on Cloudflare Workers and SQLite-backed Durable Objects. It's the closest architectural peer to the Workflow SDK — both persist progress and replay to survive failures — which makes the differences in **versioning safety, encryption, and portability** the deciding factors. +[Cloudflare Workflows](https://developers.cloudflare.com/workflows/) is a durable-execution engine built on Cloudflare Workers and SQLite-backed Durable Objects. It's the closest architectural peer to the Workflow SDK because both persist progress and replay to survive failures. The differences in **versioning safety, encryption, and portability** are the deciding factors. **Choose the Workflow SDK** when you want an open-source, portable engine that runs in your existing app (and off a single vendor), deployment-pinned versioning, and application-level E2E encryption. **Choose Cloudflare Workflows** when you're already all-in on Cloudflare. @@ -24,27 +24,27 @@ related: | **Category** | Open-source durable-functions SDK; portable backends | Durable execution engine, hosted on Cloudflare | | **Durability model** | Event log + deterministic replay | Step-result **memoization** in SQLite-backed Durable Objects + deterministic re-calculation ("game-loop") | | **Authoring** | `"use workflow"` / `"use step"` in plain async TS, in your app | Class extends `WorkflowEntrypoint`; explicit `step.do(name, cb)` wrapping; Cloudflare Workers only | -| **Where it runs** | Your platform (Vercel managed, or self-host) | Cloudflare only — both orchestration and execution run on-network (engine ↔ step over internal RPC) | -| **Versioning** | Runs pinned to their immutable deployment — safe by default | **No version pinning** — running instances resume on the *latest* deployed code; changing step names/order can desync the cached replay. No patching API | +| **Where it runs** | Your platform (Vercel managed, or self-host) | Cloudflare only; both orchestration and execution run on-network (engine ↔ step over internal RPC) | +| **Versioning** | Runs pinned to their immutable deployment, making them safe by default | **No version pinning**; running instances resume on the *latest* deployed code, and changing step names/order can desync the cached replay. No patching API | | **Encryption** | Per-run AES-256-GCM **end-to-end** encryption | **At-rest only** (AES-256, Cloudflare-managed keys) + TLS; no E2E, no customer-managed keys | | **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | Durable agents via Workflows + the Agents SDK; resumable streaming buffered in SQLite (mid-call eviction needs opt-in `chatRecovery`) | -| **Performance** | No-penalty resume; scale-to-zero; up to 100K concurrency (Vercel) | **~0 ms isolate cold starts**; 50K concurrent instances; global Anycast (330+ cities) — strongest cold-start and edge story | +| **Performance** | No-penalty resume; scale-to-zero; up to 100K concurrency (Vercel) | **~0 ms isolate cold starts**; 50K concurrent instances; global Anycast (330+ cities), providing the strongest cold-start and edge story | | **Portability** | Apache-2.0; World abstraction; self-hostable | Engine proprietary; tied to Durable Objects; **highest lock-in** of these tools | | **Pricing** | SDK free; pay your platform | Workers Standard: requests + CPU-time + storage + per-step ($0.80 / 100K steps); idle/sleep not billed | | **Limits** | 50 MB payloads; 2 GB/run; 10K steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | **1 MiB** step result & event payload; 1 GB state/instance; 10K steps (up to 25K) | -**What the limits mean in practice:** Cloudflare's 1 MiB cap on step results and event payloads is the tightest in this section — a single large model response or document can exceed it, pushing anything sizable into R2/KV indirection — and instance state is capped at 1 GB. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. +**What the limits mean in practice:** Cloudflare's 1 MiB cap on step results and event payloads is the tightest in this section. A single large model response or document can exceed it, pushing anything sizable into R2/KV indirection, and instance state is capped at 1 GB. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. ## Versioning: pinned vs. live code -Both engines replay, so changing code mid-run is the key hazard — and they take opposite approaches: +Both engines replay, so changing code mid-run is the key hazard. They take opposite approaches: -- **Cloudflare** does not pin a running instance to a code version. When an instance resumes (after a sleep, a wait, or a deploy), it runs against whatever code is currently deployed. Because step names act as the replay cache key, reordering, renaming, or inserting steps before already-completed ones can desync the replay of an in-flight instance. There is no patching API — just the documented "[Rules of Workflows](https://developers.cloudflare.com/workflows/build/rules-of-workflows/)" you must follow by hand. +- **Cloudflare** does not pin a running instance to a code version. When an instance resumes (after a sleep, a wait, or a deploy), it runs against whatever code is currently deployed. Because step names act as the replay cache key, reordering, renaming, or inserting steps before already-completed ones can desync the replay of an in-flight instance. There is no patching API, only the documented "[Rules of Workflows](https://developers.cloudflare.com/workflows/build/rules-of-workflows/)" you must follow manually. - **Workflow SDK** pins each run to the immutable deployment that started it, so a deploy never disturbs in-flight runs. Evolving code is safe by default and upgrades are explicit. ## Encryption and portability -Cloudflare encrypts Durable Object data at rest with Cloudflare-managed keys, but there is no application-level / end-to-end encryption and no customer-managed-key option — payloads are visible to the platform. The Workflow SDK encrypts each run's inputs, outputs, step I/O, and streams with a per-run AES-256-GCM key. +Cloudflare encrypts Durable Object data at rest with Cloudflare-managed keys, but there is no application-level / end-to-end encryption or customer-managed-key option. The platform can view payloads. The Workflow SDK encrypts each run's inputs, outputs, step I/O, and streams with a per-run AES-256-GCM key. On portability, Cloudflare Workflows is the most locked-in of the tools in this section: the API and Durable-Object-bound state are Cloudflare-specific, so moving means a rewrite. The Workflow SDK is Apache-2.0 and its [World abstraction](/worlds/building-a-world) lets you run the same code on Vercel, on Postgres, or on a backend you build. @@ -62,7 +62,7 @@ There's no automated migration skill for Cloudflare specifically, but the mappin | `step.do(name, cb)` | `"use step"` function called with `await` | | `step.sleep` / `step.sleepUntil` | `sleep('1h')` / `sleep(date)` | | `step.waitForEvent` | `createHook()` / `createWebhook()` | -| Per-step `retries` config (`limit`, `delay`, `backoff`) | `maxRetries` caps attempts; any backoff curve via [`RetryableError`](/docs/api-reference/workflow/retryable-error)'s `retryAfter` derived from [`getStepMetadata().attempt`](/docs/api-reference/workflow/get-step-metadata); `FatalError` stops retries — see [Errors & Retrying](/docs/foundations/errors-and-retries#advanced-example) | +| Per-step `retries` config (`limit`, `delay`, `backoff`) | `maxRetries` caps attempts; any backoff curve via [`RetryableError`](/docs/api-reference/workflow/retryable-error)'s `retryAfter` derived from [`getStepMetadata().attempt`](/docs/api-reference/workflow/get-step-metadata); `FatalError` stops retries. See [Errors & Retrying](/docs/foundations/errors-and-retries#advanced-example) | | `env.MY_WORKFLOW.create(...)` binding | `start(workflow, [args])` from your app | Side effects that lived in `step.do` callbacks move into named `"use step"` functions; the orchestration becomes plain `await` / `if` / `Promise.all` instead of the `WorkflowEntrypoint` class. diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx index fc7aac370d..5ea67c49b0 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-inngest.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs Inngest -description: How the Workflow SDK compares to Inngest — event-driven durable functions that run on your own infrastructure over HTTP, with a concept-mapping migration guide. +description: How the Workflow SDK compares to Inngest, event-driven durable functions that run on your own infrastructure over HTTP, with a concept-mapping migration guide. type: conceptual summary: Inngest is an event-driven durable-functions platform that invokes your code over HTTP and memoizes step results. The Workflow SDK co-locates orchestration and execution and replays from an event log. prerequisites: @@ -11,10 +11,10 @@ related: - /docs/ai --- -[Inngest](https://www.inngest.com) is a durable-functions platform with an **event-driven** core: functions trigger on events or cron, and Inngest invokes your code — running on your own infrastructure — one step at a time over HTTP, memoizing each step's result. It overlaps heavily with the Workflow SDK, with a different execution topology and a strong AI/agent story on both sides. +[Inngest](https://www.inngest.com) is a durable-functions platform with an **event-driven** core: functions trigger on events or cron, and Inngest invokes your code (running on your own infrastructure) one step at a time over HTTP, memoizing each step's result. It overlaps heavily with the Workflow SDK, with a different execution topology and a strong AI/agent story on both sides. -**Choose the Workflow SDK** when you want orchestration and execution co-located (no per-step HTTP round-trips), deployment-pinned versioning, and a fully-supported open-source self-host path. **Choose Inngest** when an event-bus model fits your architecture and you want its mature flow-control suite (concurrency, throttling, debounce, batching, priority) out of the box. +**Choose the Workflow SDK** when you want orchestration and execution co-located (no per-step HTTP round trips), deployment-pinned versioning, and a supported open-source self-hosting path. **Choose Inngest** when an event-bus model fits your architecture and you want built-in flow controls for concurrency, throttling, debounce, batching, and priority. ## At a glance @@ -22,34 +22,34 @@ related: | | Workflow SDK | Inngest | | --- | --- | --- | | **Category** | Open-source durable-functions SDK | Durable functions on an event-driven platform | -| **Durability model** | Event log + deterministic replay | **Step-result memoization** (each `step.run` runs once; completed steps are skipped) — not whole-function replay | +| **Durability model** | Event log + deterministic replay | **Step-result memoization** (each `step.run` runs once; completed steps are skipped), not whole-function replay | | **Trigger model** | Direct `start(workflow, [args])` (import the function) | Event bus (`inngest.send`) + cron; loosely coupled publishers/consumers | -| **Where execution runs** | Orchestration + execution co-located on your platform | **Your code runs on your infra**; Inngest invokes it per step over HTTP (Serve) or a persistent worker connection (Connect, in public beta) | -| **Languages** | TypeScript / JS (Python beta) | TypeScript; Python & Go (production, pre-1.0) | -| **Versioning** | Runs pinned to immutable deployment | Tracked by step-ID hashes — hot-edit functions, but editing a step's logic under the **same ID** reuses the old memoized result for in-flight runs; rename the ID or route a new function by timestamp for rewrites | -| **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | Native AI SDK via `step.ai.wrap`; **AgentKit** multi-agent framework and durable **Realtime** (`step.realtime.publish`) + `useAgent` hook — both Developer Preview | -| **Security** | Zero-config per-run E2E encryption by default; platform security is per-World (the Vercel World inherits Vercel's security posture) | Runs on your infra; signed requests; first-party E2E encryption middleware (TS + Python); SOC 2 Type II, HIPAA add-on | -| **Portability** | Apache-2.0; World abstraction; **self-host supported** | Engine open source (SSPL); self-host via single Go binary + Postgres, but **self-hosting is community/best-effort** — no support SLA, and the `inngest start` binary is Beta (SaaS is the default) | +| **Where execution runs** | Orchestration + execution co-located on your platform | **Your code runs on your infrastructure**; Inngest invokes it per step over HTTP (Serve) or a persistent worker connection (Connect, in public beta) | +| **Languages** | TypeScript / JavaScript (Python beta) | TypeScript; Python and Go (production, pre-1.0) | +| **Versioning** | Runs pinned to immutable deployment | Tracked by step-ID hashes: hot-edit functions, but editing a step's logic under the **same ID** reuses the old memoized result for in-flight runs; rename the ID or route a new function by timestamp for rewrites | +| **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | Native AI SDK via `step.ai.wrap`; **AgentKit** multi-agent framework and durable **Realtime** (`step.realtime.publish`) + `useAgent` hook, both Developer Preview | +| **Security** | Zero-config per-run end-to-end (E2E) encryption by default; platform security is per World (the Vercel World inherits Vercel's security posture) | Runs on your infrastructure; signed requests; first-party E2E encryption middleware (TypeScript and Python); SOC 2 Type II, HIPAA add-on | +| **Portability** | Apache-2.0; World abstraction; **self-host supported** | Engine open source (Server Side Public License); self-host via single Go binary + Postgres, but **self-hosting is community/best-effort**: no support service-level agreement, and the `inngest start` binary is Beta (the hosted service is the default) | | **Pricing** | SDK free; pay your platform | Per-execution: billed for the run **plus each step plus retries**; Pro from $99/mo, then ~$50 per 1M executions | -| **Limits** | 50 MB payload; 2 GB/run; 10K steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 1,000 steps/function; 4 MiB step payload; 32 MiB run state; runs up to 366 days | +| **Limits** | 50 MB payload; 2 GB/run; 10,000 steps ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 1,000 steps/function; 4 MiB step payload; 32 MiB run state; runs up to 366 days | -**What the limits mean in practice:** Inngest's caps are restrictive for real long-running AI workloads. An agent loop spends steps on every model and tool call, so 1,000 steps per function goes quickly; a single large LLM response can approach the 4 MiB step-payload cap; and an accumulated conversation or context easily outgrows 32 MiB of run state. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. +**What the limits mean in practice**: Inngest's caps are restrictive for real long-running AI workloads. An agent loop spends steps on every model and tool call, so it can consume 1,000 steps per function; a single large language model response can approach the 4 MiB step-payload cap; and an accumulated conversation or context can outgrow 32 MiB of run state. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB per payload and 2 GB of state per run. ## Topology: co-located vs. invoked-over-HTTP -Inngest's engine lives outside your code and calls your functions step-by-step over HTTP (or a Connect worker). That keeps your code on your own infra (a portability and data-locality plus), but adds a network round-trip per step — relevant for workflows with many small sequential steps. The Workflow SDK co-locates orchestration and execution on one platform, so steps don't pay a per-step HTTP hop. +Inngest's engine lives outside your code and calls your functions step-by-step over HTTP (or a Connect worker). That keeps your code on your own infrastructure for portability and data locality, but adds a network round trip per step. This overhead matters for workflows with many small sequential steps. The Workflow SDK co-locates orchestration and execution on one platform, so steps don't require a per-step HTTP request. -Triggering differs too: Inngest is event-driven (publishers `send` events; functions subscribe), which is great for loosely-coupled fan-out. The Workflow SDK's `start()` imports the workflow function directly — tighter coupling, stronger type safety. For event-bus-style fan-out, wrap `start()` in a shared publisher. +Triggering also differs. Inngest is event-driven (publishers `send` events; functions subscribe), which supports loosely coupled fan-out. The Workflow SDK's `start()` imports the workflow function directly, providing tighter coupling and stronger type safety. For event-bus-style fan-out, wrap `start()` in a shared publisher. ## Versioning -Inngest doesn't use version numbers; it keys state by **step-ID hash**, so you can edit functions while runs are in flight. The catch: if you change the logic *inside* a step but keep the same ID, in-flight runs that already completed that step silently reuse the **old** memoized result — only new runs see the change. To force re-execution you rename the step ID, and for incompatible rewrites the recommended pattern is a new function with timestamp-based event routing. +Inngest doesn't use version numbers; it keys state by **step-ID hash**, so you can edit functions while runs are in flight. The catch: if you change the logic *inside* a step but keep the same ID, in-flight runs that already completed that step silently reuse the **old** memoized result: only new runs see the change. To force re-execution you rename the step ID, and for incompatible rewrites the recommended pattern is a new function with timestamp-based event routing. -The Workflow SDK pins each run to its immutable deployment, so in-flight runs always finish on the exact code they started with, and upgrades are explicit — no per-step-ID reasoning required. +The Workflow SDK pins each run to its immutable deployment, so in-flight runs always finish on the exact code they started with, and upgrades are explicit: no per-step-ID reasoning required. ## AI agents -Both are strong here. Inngest offers `step.ai.wrap()` (wrap Vercel AI SDK calls as durable steps), **AgentKit** (a multi-agent framework with MCP tools), and durable **Realtime** streaming with a `useAgent` React hook — though AgentKit and Realtime are both Developer Preview. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK and native [resumable streaming](/docs/ai/resumable-streams). If you're already event-driven and want a batteries-included agent framework, AgentKit is compelling; if you want the agent loop to *be* a durable workflow in your app with streaming built into the runtime, the Workflow SDK fits. +Inngest offers `step.ai.wrap()` (wrap Vercel AI SDK calls as durable steps), **AgentKit** (a multi-agent framework with Model Context Protocol (MCP) tools), and durable **Realtime** streaming with a `useAgent` React hook, though AgentKit and Realtime are both Developer Preview. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK and native [resumable streaming](/docs/ai/resumable-streams). AgentKit fits event-driven applications that need an integrated agent framework. The Workflow SDK fits applications that need the agent loop to be a durable workflow with streaming built into the runtime. ## Migrating from Inngest @@ -61,7 +61,7 @@ Both are strong here. Inngest offers `step.ai.wrap()` (wrap Vercel AI SDK calls | `step.waitForEvent()` | `createHook()` / `createWebhook()` | Token encodes the routing; no event schema. | | `step.invoke()` | [`start()`](/docs/foundations/starting-workflows) called directly from the workflow | Spawn a child run. | | `inngest.send()` / event triggers | `start()` from your app boundary | Start workflows directly. | -| Retry config (`retries`) / `RetryAfterError` | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary; custom backoff via `retryAfter` — see [Errors & Retrying](/docs/foundations/errors-and-retries#customize-retry-behavior). | +| Retry config (`retries`) / `RetryAfterError` | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary. For custom backoff via `retryAfter`, see [Errors & Retrying](/docs/foundations/errors-and-retries#customize-retry-behavior). | | `step.realtime.publish()` / Realtime | `getWritable()` / named streams | Clients read from the stream. | The `createFunction` factory collapses into a plain exported function: diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx index 1cbdf12b37..c4ff09a48b 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-temporal.mdx @@ -1,6 +1,6 @@ --- title: Workflow SDK vs Temporal -description: How the Workflow SDK compares to Temporal — execution model, where workers run, versioning, AI agents, pricing, and a concept-mapping migration guide. +description: 'How the Workflow SDK compares to Temporal: execution model, where workers run, versioning, AI agents, pricing, and a concept-mapping migration guide.' type: conceptual summary: Temporal is a mature, language-agnostic durable-execution platform where you run the workers. The Workflow SDK runs in your existing app and pins runs to immutable deployments. prerequisites: @@ -12,10 +12,10 @@ related: - /worlds/vercel --- -[Temporal](https://temporal.io) is the most mature durable-execution platform — battle-tested at large scale, with seven language SDKs. It and the Workflow SDK share the same core idea (durable orchestration via event-sourced replay), so the real differences are operational: **where your code runs, how you version it, and how it streams to clients.** +[Temporal](https://temporal.io) is a durable-execution platform with seven language SDKs and large-scale production use. Temporal and the Workflow SDK share the same core model of durable orchestration through event-sourced replay. The operational differences are **where your code runs, how you version it, and how it streams to clients.** -**Choose the Workflow SDK** when you want durable execution inside your existing TypeScript app with nothing extra to operate, deployment-pinned versioning, and native streaming for AI apps. **Choose Temporal** when you need polyglot SDKs (Go/Java/etc.), want a self-hostable control plane you fully own, or are standardizing a large org on one orchestration backend across many languages. +**Choose the Workflow SDK** when you want durable execution inside your existing TypeScript app with no additional infrastructure to operate, deployment-pinned versioning, and native streaming for AI apps. **Choose Temporal** when you need SDKs for languages such as Go and Java, want a self-hostable control plane you own, or are standardizing a large organization on one orchestration backend across many languages. ## At a glance @@ -23,22 +23,22 @@ related: | | Workflow SDK | Temporal | | --- | --- | --- | | **Category** | Open-source durable-functions SDK; managed on Vercel or self-hosted | Durable-execution platform; Temporal Cloud or self-hosted cluster | -| **Durability model** | Event log + deterministic replay (`"use workflow"` orchestrators, `"use step"` functions) | Event-sourced replay (Workflows + Activities). Same model — `"use workflow"` ≈ Workflow, `"use step"` ≈ Activity | -| **Languages** | TypeScript / JS (Python beta) | Go, Java, TypeScript, Python, .NET, PHP, Ruby (7 SDKs) | -| **Where execution runs** | Orchestration + execution + observability co-located on your platform; private networking and E2E encryption out of the box on Vercel | **You run and scale your own Workers.** Temporal Cloud hosts orchestration only; workers connect outbound over the public internet (PrivateLink optional) | -| **Versioning** | Runs pinned to their immutable deployment — safe by default; opt-in `deploymentId: 'latest'` to upgrade | Editing workflow code can break in-flight runs (non-determinism errors); evolve safely via patch APIs or Worker Versioning (keep old worker fleets draining) | -| **AI SDK & agents** | `WorkflowAgent` ships in the AI SDK; durable agent loop; **native resumable streaming** (`getWritable`/`getReadable`, `WorkflowChatTransport`) | First-party `@temporalio/ai-sdk` and "Workflow Streams" — both **Public Preview**; streaming rides on Signals/Updates (batched, history-bound) | -| **Security** | Zero-config per-run AES-256-GCM E2E encryption by default; platform security is per-World (the Vercel World inherits Vercel's security posture) | Workers run your code on your infra (never enters Temporal's plane); client-side E2E via a Codec Server you operate. Cloud: SOC 2 II, HIPAA, GDPR | -| **Performance** | No-penalty resume; serverless scale-to-zero (true suspension); up to 100K concurrency on Vercel | Self-managed workers are long-running; Cloud namespace default 500 actions/sec (auto-scales) | +| **Durability model** | Event log + deterministic replay (`"use workflow"` orchestrators, `"use step"` functions) | Event-sourced replay (Workflows + Activities). Same model: `"use workflow"` ≈ Workflow, `"use step"` ≈ Activity | +| **Languages** | TypeScript / JavaScript (Python beta) | Go, Java, TypeScript, Python, .NET, PHP, Ruby (7 SDKs) | +| **Where execution runs** | Orchestration + execution + observability co-located on your platform; private networking and end-to-end (E2E) encryption included on Vercel | **You run and scale your own Workers.** Temporal Cloud hosts orchestration only; workers connect outbound over the public internet (PrivateLink optional) | +| **Versioning** | Runs pinned to their immutable deployment, safe by default; opt-in `deploymentId: 'latest'` to upgrade | Editing workflow code can break in-flight runs (non-determinism errors); evolve safely via patch APIs or Worker Versioning (keep old worker fleets draining) | +| **AI SDK & agents** | `WorkflowAgent` ships in the AI SDK; durable agent loop; **native resumable streaming** (`getWritable`/`getReadable`, `WorkflowChatTransport`) | First-party `@temporalio/ai-sdk` and "Workflow Streams", both **Public Preview**; streaming rides on Signals/Updates (batched, history-bound) | +| **Security** | Zero-config per-run AES-256-GCM E2E encryption by default; platform security is per World (the Vercel World inherits Vercel's security posture) | Workers run your code on your infrastructure (never enters Temporal's plane); client-side E2E via a Codec Server you operate. Cloud: SOC 2 II, HIPAA, GDPR | +| **Performance** | No-penalty resume; serverless scale-to-zero (true suspension); up to 100,000 concurrency on Vercel | Self-managed workers are long-running; Cloud namespace default 500 actions/sec (auto-scales) | | **Portability** | Apache-2.0 SDK; World abstraction swaps storage/queue/streams independently | MIT server; pluggable persistence (Cassandra/Postgres/MySQL), but an opinionated monolithic backend you run or pay for | -| **Pricing** | SDK free; pay your platform (Vercel: events + data) or just your infra if self-hosted | Self-host = free software; Temporal Cloud bills per Action (from $50/M) + storage | -| **Limits** | No run/sleep cap; 10K steps, 50 MB payload, 2 GB/run ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | No run cap (Continue-As-New for long histories); event history capped at 51,200 events / 50 MB; 2 MB payloads | +| **Pricing** | SDK free; pay your platform (Vercel: events + data) or only your infrastructure if self-hosted | Self-host = free software; Temporal Cloud bills per Action (from $50 per million) + storage | +| **Limits** | No run/sleep cap; 10,000 steps, 50 MB payload, 2 GB/run ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | No run cap (Continue-As-New for long histories); event history capped at 51,200 events / 50 MB; 2 MB payloads | -**What the limits mean in practice:** Temporal caps payloads at 2 MB and event history at 51,200 events, which binds quickly for AI workloads — a large model context, tool transcript, or embedding batch routinely exceeds 2 MB, forcing external blob storage and claim-check plumbing, and long agent loops must be split with Continue-As-New before the history fills. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) (50 MB payloads, 2 GB of state per run, no run or sleep caps) leave room to keep full contexts in the run itself. +**What the limits mean in practice**: Temporal caps payloads at 2 MB and event history at 51,200 events. A model context, tool transcript, or embedding batch that exceeds 2 MB requires external blob storage and claim-check plumbing. Long agent loops must use Continue-As-New before the history fills. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) (50 MB payloads, 2 GB of state per run, and no run or sleep caps) provide more space for contexts in the run itself. ## The biggest difference: what you operate -Temporal Cloud manages the durable engine, but **you still build, deploy, and scale a fleet of Workers** that poll task queues and run your Workflow and Activity code. Those workers connect *out* to Temporal Cloud, typically over the public internet (AWS PrivateLink / GCP Private Service Connect are available as same-region options). Readable observability requires you to stand up a **Codec Server** so the Web UI can decrypt payloads your data converter encrypted. +Temporal Cloud manages the durable engine, but **you still build, deploy, and scale a fleet of Workers** that poll task queues and run your Workflow and Activity code. Those workers connect *out* to Temporal Cloud, typically over the public internet (AWS PrivateLink and Google Cloud Private Service Connect are available as same-region options). To make encrypted payloads readable in the Web UI, you must run a **Codec Server**. With the Workflow SDK on Vercel, orchestration, execution, and observability are co-located on one platform with internal networking, and per-run E2E encryption is on by default with no codec server to run. There are no workers, task queues, or a control plane to operate. (Self-hosting via the [Postgres World](/worlds/postgres) is the closest analog to running your own Temporal cluster.) @@ -46,14 +46,14 @@ With the Workflow SDK on Vercel, orchestration, execution, and observability are This is where the two differ most in day-to-day risk. Because both replay code against history, *changing* workflow code mid-flight is the hazard. -- **Temporal:** simply editing a workflow can produce a non-determinism error that breaks or stalls open executions. You evolve safely with **patch APIs** (`patched()` / `GetVersion()`, which accumulate branch cruft) or **Worker Versioning** (Build IDs / Worker Deployments pin workflows to a build and keep the old worker fleet running until it drains). It works, but the burden is on you for every long-running workflow. -- **Workflow SDK:** runs are **pinned to the immutable deployment that started them**. Shipping new code never touches in-flight runs — they keep replaying against the exact code they began on. Upgrading a run is explicit and opt-in (start it with `deploymentId: 'latest'`, or self-restart at a checkpoint). Safe by default, no patch branches, no draining worker fleets. +- **Temporal**: Editing a workflow can produce a non-determinism error that breaks or stalls open executions. You can evolve workflows with **patch APIs** (`patched()` / `GetVersion()`, which accumulate patch branches) or **Worker Versioning** (Build IDs / Worker Deployments pin workflows to a build and keep the old worker fleet running until it drains). You must manage this process for every long-running workflow. +- **Workflow SDK**: Runs are **pinned to the immutable deployment that started them**. Shipping new code never touches in-flight runs because they keep replaying against the exact code they began on. Upgrading a run is explicit and opt-in (start it with `deploymentId: 'latest'`, or self-restart at a checkpoint). This model requires no patch branches or draining worker fleets. ## AI agents and streaming Both target AI agents, but the integration depth differs. The Workflow SDK's `WorkflowAgent` is a first-class construct **inside the AI SDK** (`@ai-sdk/workflow`): the agent loop becomes a durable workflow, each tool `execute` marked `"use step"` is an auto-retried durable step, and partial output streams through [durable, resumable streams](/docs/ai/resumable-streams) that survive reconnects and cold starts. -Temporal ships a first-party `@temporalio/ai-sdk` plugin and a "Workflow Streams" library, but both are **Public Preview**, and streaming is built on Signals/Updates — every chunk is written to history, so it's batched rather than per-token. +Temporal ships a first-party `@temporalio/ai-sdk` plugin and a "Workflow Streams" library, but both are **Public Preview**, and streaming is built on Signals/Updates: every chunk is written to history, so it's batched rather than per-token. ## Migrating from Temporal @@ -67,11 +67,11 @@ The model maps closely. Keep your orchestration logic; drop the workers, task qu | Signal | `createHook()` / `createWebhook()` | Hooks for typed resume signals; webhooks for HTTP callbacks. | | Query | `getWritable({ namespace: 'status' })` stream | Stream status durably; clients read the stream instead of polling. | | Child Workflow | Call [`start()`](/docs/foundations/starting-workflows) directly from the workflow | Child runs are tagged with `$parentRunId` / `$rootRunId` automatically. | -| Activity retry policy | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary — see [Errors & Retrying](/docs/foundations/errors-and-retries). | -| Search attributes / visibility queries | [`setAttributes()`](/docs/api-reference/workflow/set-attributes) / `attributes` option on `start()` | Filter runs by `key=value` — see [Attributes](/docs/observability/attributes). | +| Activity retry policy | `maxRetries`, `RetryableError`, `FatalError` | Retries live at the step boundary. See [Errors & Retrying](/docs/foundations/errors-and-retries). | +| Search attributes / visibility queries | [`setAttributes()`](/docs/api-reference/workflow/set-attributes) / `attributes` option on `start()` | Filter runs by `key=value`. See [Attributes](/docs/observability/attributes). | | Event History | Workflow event log / run timeline | Same durable replay; built-in observability UI. | -A minimal translation — the orchestrator loses `proxyActivities` and becomes plain TypeScript: +A minimal translation, where the orchestrator loses `proxyActivities` and becomes plain TypeScript: ```typescript title="workflows/order.ts" export async function processOrder(orderId: string) { @@ -86,7 +86,7 @@ async function chargePayment(orderId: string) { } ``` -Signals become hooks — one `createHook()` + `await` replaces a signal definition, handler, and `condition()` guard: +Signals become hooks, where one `createHook()` + `await` replaces a signal definition, handler, and `condition()` guard: ```typescript title="workflows/refund.ts" import { createHook } from 'workflow'; @@ -116,7 +116,7 @@ Each row is a Temporal capability the Workflow SDK does not replicate one-to-one | Temporal feature | How to cover it with the Workflow SDK | | --- | --- | | Per-activity timeouts (`startToCloseTimeout`, etc.) | Enforce deadlines inside a step with `AbortSignal.timeout(ms)`, or wrap a call in `Promise.race(step(), sleep('5m'))` | -| Declarative retry policy (`backoffCoefficient`, `nonRetryableErrorTypes`, `maximumAttempts`) | The Workflow SDK expresses the same policies idiomatically in code instead of a config object: cap attempts with [`maxRetries`](/docs/foundations/errors-and-retries#default-retrying), mark errors non-retryable with [`FatalError`](/docs/api-reference/workflow/fatal-error), and derive any backoff curve from [`getStepMetadata().attempt`](/docs/api-reference/workflow/get-step-metadata) via [`RetryableError`](/docs/api-reference/workflow/retryable-error)'s `retryAfter` — see the [exponential backoff example](/docs/foundations/errors-and-retries#advanced-example) | +| Declarative retry policy (`backoffCoefficient`, `nonRetryableErrorTypes`, `maximumAttempts`) | The Workflow SDK expresses the same policies idiomatically in code instead of a config object: cap attempts with [`maxRetries`](/docs/foundations/errors-and-retries#default-retrying), mark errors non-retryable with [`FatalError`](/docs/api-reference/workflow/fatal-error), and derive any backoff curve from [`getStepMetadata().attempt`](/docs/api-reference/workflow/get-step-metadata) via [`RetryableError`](/docs/api-reference/workflow/retryable-error)'s `retryAfter`. See the [exponential backoff example](/docs/foundations/errors-and-retries#advanced-example). | | Polyglot workers | The Workflow SDK is TypeScript-first (Python in beta); for Go/Java/etc. in the same orchestrator, Temporal remains the better fit | --- diff --git a/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx b/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx index dd5f406653..8bc8ca3c1b 100644 --- a/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx +++ b/docs/content/docs/v5/comparisons/workflow-sdk-vs-trigger-dev.mdx @@ -1,8 +1,8 @@ --- title: Workflow SDK vs trigger.dev -description: How the Workflow SDK compares to trigger.dev — deterministic event-log replay versus CRIU process checkpoint/restore, plus a concept-mapping migration guide. +description: How the Workflow SDK compares to trigger.dev, including deterministic event-log replay versus CRIU process checkpoint/restore and a concept-mapping migration guide. type: conceptual -summary: trigger.dev achieves durability by snapshotting the process (CRIU), so code has no determinism constraints. The Workflow SDK uses event-log replay and runs in your existing app. +summary: trigger.dev achieves durability by snapshotting the process with Checkpoint/Restore in Userspace (CRIU), so code has no determinism constraints. The Workflow SDK uses event-log replay and runs in your existing app. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -11,10 +11,10 @@ related: - /docs/ai --- -[trigger.dev](https://trigger.dev) is an open-source, TypeScript-first durable task platform. Its defining trait is *how* it achieves durability: instead of replaying code, it **snapshots the whole process** (CRIU checkpoint/restore) at each wait point and restores it later. That single design choice drives most of the differences with the Workflow SDK. +[trigger.dev](https://trigger.dev) is an open-source, TypeScript-first durable task platform. Instead of replaying code, it **snapshots the whole process** with Checkpoint/Restore in Userspace (CRIU) at each wait point and restores it later. This design drives most of the differences with the Workflow SDK. -**Choose the Workflow SDK** when you want durable orchestration that runs in your existing app, a portable open-source backend you can fully self-host, TypeScript *and* Python, and broad framework support. **Choose trigger.dev** when you want a managed task platform with no determinism constraints (code runs as-is), and you're TypeScript-only. +**Choose the Workflow SDK** when you want durable orchestration that runs in your existing app, a portable open-source backend you can self-host, TypeScript and Python, and broad framework support. **Choose trigger.dev** when you want a managed task platform with no determinism constraints and use only TypeScript. ## At a glance @@ -22,30 +22,30 @@ related: | | Workflow SDK | trigger.dev | | --- | --- | --- | | **Category** | Open-source durable-functions SDK that runs in your app | Durable task platform with its own runtime (Cloud or self-hosted) | -| **Durability model** | Event log + **deterministic replay** (workflow body must be deterministic) | **Process checkpoint/restore (CRIU)** — snapshots memory/CPU/FDs; **no determinism constraints**, code runs as-is | +| **Durability model** | Event log + **deterministic replay** (workflow body must be deterministic) | **Process checkpoint/restore (CRIU)**: snapshots memory, CPU, and file descriptors; **no determinism constraints**, code runs as-is | | **Authoring** | `"use workflow"` / `"use step"` in your existing app | `task()` / `schemaTask()` deployed to trigger.dev as a separate target (Docker image) | -| **Languages** | TypeScript / JS (Python beta) | **TypeScript / JS only** | +| **Languages** | TypeScript / JavaScript (Python beta) | **TypeScript / JavaScript only** | | **Where it runs** | Co-located with your app (Vercel managed or self-host) | trigger.dev's run engine (isolated containers) | -| **Versioning** | Runs pinned to immutable deployment — safe by default | **Atomic versioning** — runs lock to their deploy version; new deploys never touch in-flight runs (same safety property) | -| **AI & streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | First-class: AI SDK tools, native `useChat` transport, resumable Realtime, durable multi-turn Sessions, HITL via `wait.forToken` | +| **Versioning** | Runs pinned to immutable deployment, safe by default | **Atomic versioning**: runs lock to their deploy version; new deploys never touch in-flight runs (same safety property) | +| **AI and streaming** | `WorkflowAgent` in the AI SDK; native resumable streaming | AI SDK tools, native `useChat` transport, resumable Realtime, durable multi-turn Sessions, human-in-the-loop (HITL) via `wait.forToken` | | **Concurrency control** | Enforce in steps / at the publisher | First-class queues + concurrency keys | -| **Portability** | Apache-2.0; World abstraction; runs anywhere Node runs | Apache-2.0; self-host on Docker/K8s — but CRIU needs a compatible host (heavier than plain Docker); TS-only | +| **Portability** | Apache-2.0; World abstraction; runs anywhere Node runs | Apache-2.0; self-host on Docker/Kubernetes, but CRIU needs a compatible host (heavier than plain Docker); TypeScript-only | | **Pricing** | SDK free; pay your platform | Cloud: compute-seconds + per-run ($0.0000338/s Small + $0.000025/run); no charge while checkpointed | -| **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 3 MB payload / 10 MB output; 14-day max run TTL; CPU-time-based max duration | +| **Limits** | 50 MB payload; 2 GB/run; no duration cap ([Vercel World limits](https://vercel.com/docs/workflows/pricing)) | 3 MB payload / 10 MB output; 14-day maximum run lifetime; CPU-time-based maximum duration | -**What the limits mean in practice:** trigger.dev caps task payloads at 3 MB and outputs at 10 MB — large model contexts and transcripts need external storage — and the 14-day run TTL means human-in-the-loop flows that wait longer than two weeks can't complete in one run. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB payloads, 2 GB of state per run, and no run-duration cap. +**What the limits mean in practice**: trigger.dev caps task payloads at 3 MB and outputs at 10 MB (large model contexts and transcripts need external storage), and the 14-day maximum run time means human-in-the-loop flows that wait longer than two weeks can't complete in one run. The [Vercel World limits](https://vercel.com/docs/workflows/pricing) are 50 MB payloads, 2 GB of state per run, and no run-duration cap. ## The core difference: checkpoint/restore vs. replay -trigger.dev freezes the entire OS process with CRIU when a task hits a wait point, then restores it later — so there's **no replay and no determinism rule**: you can call `Date.now()` or `Math.random()` anywhere, and prior steps don't re-execute. The cost is an execution model that requires CRIU-capable infrastructure (which makes self-hosting heavier than a plain container) and runs on trigger.dev's runtime as a separate deploy target. +trigger.dev freezes the entire operating system process with CRIU when a task hits a wait point, then restores it later, so there's **no replay and no determinism rule**: you can call `Date.now()` or `Math.random()` anywhere, and prior steps don't re-execute. The cost is an execution model that requires CRIU-capable infrastructure (which makes self-hosting heavier than a plain container) and runs on trigger.dev's runtime as a separate deploy target. The Workflow SDK reconstructs state by **replaying the workflow function** against its event log. That requires the workflow body to be deterministic (side effects go in `"use step"` functions), but it runs inside your existing app and deployment with no special host requirements, and the [World abstraction](/worlds/building-a-world) lets you swap the storage/queue/stream layers. -Notably, **both pin runs to a version** so deploys never corrupt in-flight work — trigger.dev via atomic version-locking, the Workflow SDK via immutable-deployment pinning. +Notably, **both pin runs to a version** so deploys never corrupt in-flight work: trigger.dev via atomic version-locking, the Workflow SDK via immutable-deployment pinning. ## AI agents -Both invest heavily in AI. trigger.dev offers AI SDK tool wrapping, a native `useChat` transport over its Realtime layer, resumable streaming, and durable multi-turn Sessions. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK plus native [resumable streaming](/docs/ai/resumable-streams). Both support human-in-the-loop (trigger.dev's `wait.forToken`, the Workflow SDK's hooks). The deciding factors are usually language (trigger.dev is TS-only; the Workflow SDK adds Python) and whether you want the agent to run in your app vs. on a dedicated platform. +trigger.dev offers AI SDK tool wrapping, a native `useChat` transport over its Realtime layer, resumable streaming, and durable multi-turn Sessions. The Workflow SDK offers `WorkflowAgent` directly inside the AI SDK plus native [resumable streaming](/docs/ai/resumable-streams). Both support human-in-the-loop workflows (trigger.dev's `wait.forToken` and the Workflow SDK's hooks). Key differences are language support (trigger.dev is TypeScript-only; the Workflow SDK adds Python) and whether the agent runs in your app or on a dedicated platform. ## Migrating from trigger.dev @@ -59,10 +59,10 @@ Both invest heavily in AI. trigger.dev offers AI SDK tool wrapping, a native `us | `triggerAndWait()` | [`start()`](/docs/foundations/starting-workflows) called directly from the workflow, then await the returned `Run` | Spawn + collect. | | `batch.triggerAndWait()` | `Promise.all` over collected `Run` handles | Standard concurrency. | | `metadata.stream()` / Realtime | `getWritable()` / named streams | Clients read from the stream. | -| Run tags / `metadata.set()` | [`setAttributes()`](/docs/api-reference/workflow/set-attributes) / `attributes` option on `start()` | Filter runs by `key=value` — see [Attributes](/docs/observability/attributes). | +| Run tags / `metadata.set()` | [`setAttributes()`](/docs/api-reference/workflow/set-attributes) / `attributes` option on `start()` | Filter runs by `key=value`. See [Attributes](/docs/observability/attributes). | | `AbortTaskRunError` | `FatalError` | Stops retries immediately. | -The `task()` factory collapses into a plain function — and because the workflow body is replayed, move side effects into steps: +The `task()` factory collapses into a plain function, and because the workflow body is replayed, move side effects into steps: ```typescript title="workflows/order.ts" export async function processOrder(orderId: string) { @@ -79,7 +79,7 @@ async function loadOrder(orderId: string) { ``` -trigger.dev's `run` body has full Node.js access. The Workflow SDK's `"use workflow"` body runs in a sandboxed VM — side effects (`fetch`, `Date.now()`, `Math.random()`, DB access) must live inside `"use step"` functions. Orchestration stays in the workflow body. +trigger.dev's `run` body has full Node.js access. The Workflow SDK's `"use workflow"` body runs in a sandboxed virtual machine (VM). Side effects (`fetch`, `Date.now()`, `Math.random()`, and database access) must live inside `"use step"` functions. Orchestration stays in the workflow body. diff --git a/docs/content/docs/v5/configuration/build-and-diagnostics.mdx b/docs/content/docs/v5/configuration/build-and-diagnostics.mdx index 2f04e6cdfe..f1ca6a0e45 100644 --- a/docs/content/docs/v5/configuration/build-and-diagnostics.mdx +++ b/docs/content/docs/v5/configuration/build-and-diagnostics.mdx @@ -40,7 +40,7 @@ Accepted values: - Default: disabled - Set `1` to expose the workflow manifest at `/.well-known/workflow/v1/manifest.json`. -- Useful for e2e tests and tools that need to discover workflows over HTTP. +- Useful for end-to-end tests and tools that need to discover workflows over HTTP. ## Discovery @@ -49,8 +49,8 @@ Accepted values: - Framework option: `discoverWorkflowsInNodeModules` where supported - Default: enabled - Controls whether workflow discovery descends into `node_modules`. By default, dependencies that declare a `workflow`/`@workflow/*` dependency can ship `"use workflow"`/`"use step"` files that are discovered and compiled into your app's bundles. -- Set `0` or `false` to opt out — imports from your application code that resolve into `node_modules` are not followed, so the build never reads, scans, or descends into dependency file graphs. This skips the cost of scanning `node_modules` and stops third-party workflow/step/serde code from being discovered. Useful when a dependency ships workflow code you don't want compiled into your app, or trips discovery with directive strings you don't intend to run. -- The SDK's own runtime serde classes (e.g. `Run`) stay registered — they are reached through a seeded entry point, and imports *within* `node_modules` are still followed. +- Set `0` or `false` to opt out: imports from your application code that resolve into `node_modules` are not followed, so the build never reads, scans, or descends into dependency file graphs. This skips the cost of scanning `node_modules` and stops third-party workflow/step/serde code from being discovered. Useful when a dependency ships workflow code you don't want compiled into your app, or trips discovery with directive strings you don't intend to run. +- The SDK's own runtime serde classes (for example, `Run`) stay registered because they are reached through a seeded entry point, and imports *within* `node_modules` are still followed. - Explicit framework config wins over this environment variable. ## Development diagnostics @@ -59,12 +59,12 @@ Accepted values: - Default: disabled - Set `1` to log workflow rebuild activity during `next dev`. -- Useful for diagnosing watch and HMR issues. +- Useful for diagnosing watch and hot module replacement (HMR) issues. ### `WORKFLOW_DEV_WATCH_IGNORED_PATHS` - Default: unset - Dev-mode only (`next dev`). Comma-separated list of path fragments the file watcher should never watch, in addition to the built-in ignores and your project's `.gitignore`. -- Each entry is matched as a substring of the absolute path (e.g. `/fixtures/,/generated/`). +- Each entry is matched as a substring of the absolute path (for example, `/fixtures/,/generated/`). - The watcher already respects `.gitignore` (walking from the app directory up to the workspace root). Use this variable only for large directories you cannot or do not want to add to `.gitignore`. - Useful when a project has thousands of non-ignored directories and `next dev` fails with `EMFILE: too many open files, watch`. diff --git a/docs/content/docs/v5/configuration/cli-and-web-ui.mdx b/docs/content/docs/v5/configuration/cli-and-web-ui.mdx index 6694e487ca..d7ba561331 100644 --- a/docs/content/docs/v5/configuration/cli-and-web-ui.mdx +++ b/docs/content/docs/v5/configuration/cli-and-web-ui.mdx @@ -8,7 +8,7 @@ related: - /docs/configuration/worlds --- -The `workflow` CLI uses flags first, then environment variables, then defaults or local inference. +The Workflow CLI uses flags first, then environment variables, then defaults or local inference. Vercel project and auth settings can often be inferred from `.vercel/project.json` and your Vercel CLI login. @@ -57,7 +57,7 @@ Vercel project and auth settings can often be inferred from `.vercel/project.jso - Environment variable: none - Default: disabled -- Opens the relevant dashboard or web UI instead of printing terminal output. +- Opens the relevant dashboard or web user interface (UI) instead of printing terminal output. ### `--webPort` @@ -123,7 +123,7 @@ Vercel project and auth settings can often be inferred from `.vercel/project.jso - Command: `workflow cancel` - Default: unset -- Restricts the batch to this status. Only `pending` and `running` are accepted — terminal runs cannot be cancelled. +- Restricts the batch to this status. Only `pending` and `running` are accepted; terminal runs cannot be canceled. ### `--workflowName` / `-n` @@ -135,7 +135,7 @@ Vercel project and auth settings can often be inferred from `.vercel/project.jso - Command: `workflow cancel` - Default: `50` -- Maximum runs to cancel in one batch (1–500). Only one batch is cancelled per invocation; re-run to cancel the next. +- Maximum runs to cancel in one batch (1–500). Only one batch is canceled per invocation; run the command again to cancel the next batch. ### `--confirm` / `-y` diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index afa02e0d9e..fb7cfcb0ad 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -1,5 +1,5 @@ --- -title: Runtime Tuning +title: Runtime tuning description: Runtime environment variables for replay, inline execution, queue delivery, compression, tracing, and advanced limits. type: reference summary: Tune Workflow runtime behavior where workflows execute. @@ -8,7 +8,7 @@ related: - /docs/how-it-works/event-sourcing --- -Runtime variables are read where workflows execute. Set them on the deployment or dev server. +The runtime reads these variables where workflows execute. Set them on the deployment or dev server. ## Client polling @@ -34,7 +34,7 @@ Runtime variables are read where workflows execute. Set them on the deployment o - Default: `10` - Minimum: `1` - How many consecutive waits `Run.returnValue` issues before falling back to interval polling for the rest of the await. -- It never ends the await. A run that outlives the cap keeps being waited for, just on `WORKFLOW_RETURN_VALUE_POLL_INTERVAL_MS` instead. +- It never ends the await. A run that outlives the cap continues waiting on `WORKFLOW_RETURN_VALUE_POLL_INTERVAL_MS` instead. - Runs longer than roughly `WORKFLOW_RETURN_VALUE_MAX_LONG_POLLS` x `WORKFLOW_RETURN_VALUE_WAIT_MS` therefore spend most of their life interval polling, which costs more requests than continuing to wait would. Raise it if you routinely await long runs. - Read by the process awaiting `Run.returnValue`, like the interval above. @@ -66,7 +66,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: `48` - Delivery attempts before a run or step is failed gracefully. -- Can only be lowered. The default is calibrated so Workflow can record failure before the queue expires the message. +- Can only be lowered. Workflow calibrates the default to record failure before the queue expires the message. ### `WORKFLOW_MAX_EVENTS` @@ -84,8 +84,8 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### `WORKFLOW_DISABLE_LAZY_HOOK_RESUME` - Default: enabled (lazy hook resume on) -- Resuming a hook persists the `hook_received` event and publishes the workflow invocation concurrently, cutting a round trip off resume latency. On this parallel path the queue message also carries the payload, so a transient event-write failure still resumes the run — the queue consumer re-ensures the `hook_received` event before replay. A backend `(runId, resumeId)` constraint keeps the two writers converging on exactly one event. -- The runtime falls back to the sequential path automatically when the consumer or backend does not attest dedup support (or the payload is too large to inline on the queue message). On the sequential path the event is written *before* dispatch and its failure fails the resume — the fallback trades that resilience away to stay safe when dedup is not enforced, it does not preserve it. +- Resuming a hook persists the `hook_received` event and publishes the workflow invocation concurrently, cutting a round trip off resume latency. On this parallel path, the queue message also carries the payload, so a transient event-write failure still resumes the run. The queue consumer re-ensures the `hook_received` event before replay. A backend `(runId, resumeId)` constraint keeps the two writers converging on exactly one event. +- The runtime falls back to the sequential path when the consumer or backend does not attest dedup support (or the payload is too large to inline on the queue message). On the sequential path, the event is written *before* dispatch, and its failure fails the resume. The fallback trades away that resilience to stay safe when dedup is not enforced; it does not preserve it. - Set `1` to force the sequential path as a kill switch. The chosen strategy is reported on the resume span as `workflow.hook.resume_strategy`. ### `WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES` @@ -99,7 +99,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### `WORKFLOW_RESILIENT_STEP_DISPATCH` - Default: disabled -- When a suspension hands newly created steps to the queue, the runtime publishes each step's execution message in parallel with its `step_created` event write instead of sequencing them, cutting a round trip per dispatched step. The message also carries the serialized step input (`stepInput`), so a transient `step_created` write failure (429 / 5xx / transport) still executes the step — the queue consumer idempotently re-ensures the event before running it, converging with the producer's write on the step's correlation ID. This mirrors resilient start (`runInput`) and the lazy hook resume (`hookInput`). +- When a suspension hands newly created steps to the queue, the runtime publishes each step's execution message in parallel with its `step_created` event write instead of sequencing them, cutting a round trip per dispatched step. The message also carries the serialized step input (`stepInput`), so a transient `step_created` write failure (429 / 5xx / transport) still executes the step. The queue consumer idempotently re-ensures the event before running it, converging with the producer's write on the step's correlation ID. This mirrors resilient start (`runInput`) and the lazy hook resume (`hookInput`). - It is off by default because the publish races the create's verdict, and a create can come back refused: as a duplicate this replay should stop pursuing, or as a [stale write](#stale-reads-and-why-nothing-has-to-be-rejected) on a World that refuses rather than reports. Either way the message carrying the payload is already out, so the consumer can materialize a step whose create was refused, and nothing orders the verdict before the consumer's redelivery re-ensure. The sequential path is the only one that gives the message a happens-after edge over it. - Even when enabled, the runtime falls back to the sequential create-then-publish dispatch when the step input is too large to inline on the queue message, or when the run's queue transport cannot carry binary payloads (pre-CBOR spec versions). - Producer-side recoveries are reported on the suspension span as `workflow.step.resilient_dispatch_recovered`; a consumer that materialized the event reports `workflow.step.resilient_dispatch_materialized`. @@ -108,11 +108,11 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### Stale reads, and why nothing has to be rejected - Not a variable: this is how a replay working from an out-of-date event log stays correct, and why no World needs a precondition guard to make it so. -- Three properties do it together. A reader's log is always a **prefix** of the run's log, never a prefix with a hole in it — positions are allocated by the World at commit, so nothing lands behind a position a reader has already passed. Replay is **deterministic on a prefix**: the same prefix always yields the same decisions, so a shorter log does not mean a different run, only a run that has not caught up. And every write **reports what it missed**: a creation names the position it replayed from (`eventCount`), and the World returns the events occupying the positions it was pushed past. The replay merges those and continues, correcting itself on the write rather than on a read. +- Three properties work together. A reader's log is always a **prefix** of the run's log, never a prefix with a hole in it. The World allocates positions at commit, so nothing lands behind a position a reader has already passed. Replay is **deterministic on a prefix**: the same prefix always yields the same decisions, so a shorter log does not mean a different run, only a run that has not caught up. Every write **reports what it missed**: a creation names the position it replayed from (`eventCount`), and the World returns the events occupying the positions it was pushed past. The replay merges those and continues, correcting itself on the write rather than on a read. - So a stale replay costs a merge, not a rejection. None of the shipped Worlds refuses a write for being stale. -- A World *may* refuse instead, with 412 ([`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error)) — appropriate when it allocates positions somewhere other than the commit and cannot report a gap reliably. The runtime handles that: it restarts the replay in the same invocation from a corrected event log, and falls back to a re-invocation with a fresh replay once the restart budget is spent. The rejected write is never retried as-is, because a replay working from a corrected log derives different events. -- A World that does refuse should only ever do so on evidence, and accept the write in every other case. A rejection then always means the position really was stale, while the absence of one proves nothing about currency. -- Two runtime behaviors follow from the properties above rather than from any fence. The per-step event-log delta optimization (consuming the delta returned by a step's terminal write instead of issuing an extra `events.list` per step) stays active while the run has an open hook: a `hook_received` missed by the delta window is observed one iteration later, and the next write brings it back. And while a hook is open, inline steps take the await-then-run path even when optimistic inline start is enabled — several invocations race for one step's claim there, and awaiting it means the body runs only for the writer that won. +- A World *may* refuse instead, with 412 ([`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error)). This is appropriate when it allocates positions somewhere other than the commit and cannot report a gap reliably. The runtime restarts the replay in the same invocation from a corrected event log and falls back to a re-invocation with a fresh replay after spending the restart budget. It never retries the rejected write as-is because a replay working from a corrected log derives different events. +- A World that does refuse should only ever do so on evidence, and accept the write in every other case. A rejection then always means the position was stale, while the absence of one proves nothing about currency. +- Two runtime behaviors follow from the properties above rather than from any fence. The per-step event-log delta optimization (consuming the delta returned by a step's terminal write instead of issuing an extra `events.list` per step) stays active while the run has an open hook. A `hook_received` event missed by the delta window is observed one iteration later, and the next write brings it back. While a hook is open, inline steps take the await-then-run path even when optimistic inline start is enabled. Several invocations race for one step's claim there, and awaiting it means the body runs only for the writer that won. ### `WORKFLOW_SLOT_GAP_CHECK` @@ -126,10 +126,10 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: enabled - New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap. -- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow and without advancing the deterministic clock — its timestamp belongs to whichever reader sealed it, not to the run. +- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow or advancing the deterministic clock. Its timestamp belongs to whichever reader sealed it, not to the run. - Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems. - Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting. -- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That is every runtime on this release train, but a runtime that pins its own accepted spec range separately — the Python runtime, for one — has to have caught up before it can read these runs. Switch this off in an environment where it has not. +- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That includes every runtime on this release train, but a runtime that pins its own accepted spec range separately, such as the Python runtime, has to catch up before it can read these runs. Switch this off in an environment where it has not. - Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec. ### `WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS` @@ -148,14 +148,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: `2` - Delay before a re-invocation caused by a rejected event creation. -- Unlike an in-process restart, which re-reads immediately, a re-invocation only happens once the in-process budget failed to catch up — so the delay gives the other writers a moment to quiesce. +- Unlike an in-process restart, which re-reads immediately, a re-invocation only happens after the in-process budget fails to catch up. The delay gives the other writers time to quiesce. ### `WORKFLOW_LOG_ORDER_DRAWS` - Default: enabled -- Experimental. Pins correlation-ID draw order to event-log order: a branch-deciding delivery (a step result, hook payload, or wait completion) resolves to the workflow only after every earlier-in-log delivery's continuation has fully quiesced, and never ahead of a lower-slot delivery that is committed to happening. -- Without it, a delivery that resolves while an earlier delivery's continuation is still a few microtask hops from its next step/hook/wait call can overtake it on the run's shared correlation-ID sequence. Draw order — and therefore correlation IDs — then depends on how much of the event log a replay had loaded, and two concurrent replays holding different-length prefixes can bind one ID to two different entities, failing the run with `CORRUPTED_EVENT_LOG`. -- Costs one event-loop turn (roughly 15-20 microseconds via `setImmediate`) per branch-deciding delivery during replay, more when continuations genuinely overlap. Measured on a 100-step sequential replay: about 2ms added end to end. +- Experimental. Pins correlation-ID draw order to event-log order. A branch-deciding delivery (a step result, hook payload, or wait completion) resolves to the workflow only after every earlier-in-log delivery's continuation has fully quiesced, and never ahead of a lower-slot delivery that is committed to happening. +- Without it, a delivery that resolves while an earlier delivery's continuation is still a few microtask hops from its next step/hook/wait call can overtake it on the run's shared correlation-ID sequence. Draw order, and therefore correlation IDs, then depends on how much of the event log a replay had loaded. Two concurrent replays holding different-length prefixes can bind one ID to two different entities, failing the run with `CORRUPTED_EVENT_LOG`. +- Costs one event-loop turn (roughly 15 to 20 microseconds via `setImmediate`) per branch-deciding delivery during replay, and more when continuations genuinely overlap. Measurements on a 100-step sequential replay show about 2 ms added end to end. - Only applies to the default Node.js VM engine. `WORKFLOW_VM=quickjs` has its own event feed and correlation-ID sequence and is unaffected by this setting. - Correlation IDs of runs created before the setting changed are not affected on platforms where a run keeps replaying on the deployment it started on. Elsewhere, only change it while no runs are in flight. - Set `0` to opt back into arrival-order delivery resolution. Only the literal value `0` opts out; `false` or `off` leave it enabled. @@ -164,7 +164,7 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL ### `WORKFLOW_V2_TIMEOUT_MS` -- Default: derived from the runtime deadline — `600000` when the invocation has 25 minutes or more left, `300000` when it has 10 minutes or more, otherwise `120000` +- Default: derived from the runtime deadline. The value is `600000` when the invocation has 25 minutes or more left, `300000` when it has 10 minutes or more, and `120000` otherwise. - Wall-clock guard for the inline replay loop. - Once elapsed, the handler requeues the workflow instead of continuing to run more inline work in the same invocation. - The default comes from `World.getRuntimeDeadline()`, so raising a function's `maxDuration` widens the inline budget without configuration. Worlds that do not report a deadline get the flat `120000`. Setting this variable to a finite positive number overrides the tiering entirely. @@ -193,14 +193,14 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: enabled - Keeps the suspended workflow VM alive across inline steps within one invocation, so each iteration of the inline loop appends only the newly written events instead of replaying the whole event log in a fresh VM. -- Suspensions involving hooks, waits, or attributes — and any replay divergence — always fall back to a full replay. -- Step inputs made of plain data (objects, arrays, primitives) and standard built-ins (`Map`, `Set`, `Date`, `RegExp`, typed arrays, `ArrayBuffer`, `URL`, `Headers`) keep the VM retained. Patching or polyfilling built-in prototypes doesn't change that: serialization never calls them. A boundary falls back to a full replay only when serializing its arguments runs code the workflow controls — a getter, a proxy, a custom class serializer — or computes an `Error`'s stack trace. +- Suspensions involving hooks, waits, or attributes, as well as any replay divergence, always fall back to a full replay. +- Step inputs made of plain data (objects, arrays, primitives) and standard built-ins (`Map`, `Set`, `Date`, `RegExp`, typed arrays, `ArrayBuffer`, `URL`, `Headers`) keep the VM retained. Patching or polyfilling built-in prototypes doesn't change that because serialization never calls them. A boundary falls back to a full replay only when serializing its arguments runs code the workflow controls, such as a getter, a proxy, or a custom class serializer, or computes an `Error`'s stack trace. - Set `0` or `false` to replay from scratch in a fresh VM on every iteration. ### `WORKFLOW_INLINE_OWNERSHIP` - Default: enabled -- Records which queue message owns each inline step execution, so a wake (hook resume, elapsed wait) that replays the run mid-step schedules a delayed backstop instead of immediately re-dispatching — and re-executing — the step. See [Inline step message ownership](/docs/changelog/step-message-ownership). +- Records which queue message owns each inline step execution, so a wake (hook resume, elapsed wait) that replays the run mid-step schedules a delayed backstop instead of immediately redispatching and reexecuting the step. See [Inline step message ownership](/docs/changelog/step-message-ownership). - Set `0` or `false` to revert to the previous unconditional immediate re-dispatch. ### `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS` @@ -216,12 +216,12 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: `node` - Values: `node` or `quickjs` -- Selects the sandboxed VM engine that executes workflow functions (`"use workflow"`). Step functions are unaffected — they always run with full Node.js access. +- Selects the sandboxed VM engine that executes workflow functions (`"use workflow"`). Step functions are unaffected and always run with full Node.js access. - `node` (default) runs workflow code in a [`node:vm`](https://nodejs.org/api/vm.html) context. -- `quickjs` (experimental) runs workflow code in a [QuickJS](https://github.com/quickjs-ng/quickjs) VM compiled to WebAssembly (via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi)). Both engines implement the same event-replay execution model (seeded PRNG, deterministic clock, and correlation-ID sequences are identical), but the **global surface is not identical** — see the differences below before switching an existing deployment. The QuickJS engine is intended for platforms that do not implement `node:vm`, and is the foundation for future VM-memory snapshotting. -- Global-surface differences under `quickjs` (workflow functions only — step functions always have full Node.js): - - `crypto.getRandomValues()` and `crypto.randomUUID()` are provided and deterministic (seeded like the node engine's). All `crypto.subtle.*` methods throw with guidance to move to a step function — including `digest`, which the node engine supports. - - `Intl` is not available (QuickJS has no ICU). The `Intl.*` constructors throw, and `toLocaleString`-family methods (including `localeCompare`) throw when called **with an explicit locale** — calling them without arguments keeps the engine default. Perform locale-sensitive formatting in a step function. +- `quickjs` (experimental) runs workflow code in a [QuickJS](https://github.com/quickjs-ng/quickjs) VM compiled to WebAssembly (via [`quickjs-wasi`](https://github.com/vercel-labs/quickjs-wasi)). Both engines implement the same event-replay execution model (seeded PRNG, deterministic clock, and correlation-ID sequences are identical), but the **global surface is not identical**. Review the differences below before switching an existing deployment. The QuickJS engine is intended for platforms that do not implement `node:vm`, and is the foundation for future VM-memory snapshotting. +- Global-surface differences under `quickjs` apply to workflow functions only. Step functions always have full Node.js: + - `crypto.getRandomValues()` and `crypto.randomUUID()` are provided and deterministic (seeded like the node engine's). All `crypto.subtle.*` methods, including `digest`, throw with guidance to move to a step function. The node engine supports `digest`. + - `Intl` is not available (QuickJS has no ICU). The `Intl.*` constructors throw, and `toLocaleString`-family methods (including `localeCompare`) throw when called **with an explicit locale**. Calling them without arguments keeps the engine default. Perform locale-sensitive formatting in a step function. - `WebAssembly` and `Atomics` are not available. - `process` exposes only a frozen copy of `env`, matching the node engine. - The engine choice is stamped into the run's `executionContext` when the run starts, so a run keeps executing on the engine it started on even if the deployment's `WORKFLOW_VM` changes. Runs without a stamped engine use the handler's `WORKFLOW_VM` value. @@ -231,8 +231,8 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - Default: enabled - Only read when `WORKFLOW_VM=quickjs`. -- Evaluates the workflow bundle once per function instance, snapshots the resulting VM, and restores that snapshot at the start of every invocation instead of re-evaluating the bundle. This is the dominant share of QuickJS VM startup: roughly 77ms to 3ms to first suspension for a 1.3MB bundle. -- A bundle whose module scope consumes randomness, reads the clock, or replaces a serialization intrinsic cannot be snapshotted safely. Those are detected when the snapshot is prepared and fall back to per-invocation evaluation automatically. +- Evaluates the workflow bundle once per function instance, snapshots the resulting VM, and restores that snapshot at the start of every invocation instead of re-evaluating the bundle. This is the dominant share of QuickJS VM startup: roughly 77 ms to 3 ms to first suspension for a 1.3 MB bundle. +- A bundle whose module scope consumes randomness, reads the clock, or replaces a serialization intrinsic cannot be snapshotted safely. The runtime detects these cases when preparing the snapshot and falls back to per-invocation evaluation. - Set `0` or `false` to always evaluate the bundle per invocation. ## Compression and tracing @@ -283,7 +283,7 @@ Node's own modules do less than the client they replace, so enabling this drops - Requests lose their transport-level retry. Failures still surface to the layers above, which retry event writes and redeliver queue messages, so nothing is silently dropped, but a failure that a same-connection retry would have hidden now costs a full redelivery. - Stream close loses its retry of retriable server errors. A transient failure at close can leave a stream marked closing until the run expires, where it would previously have resolved on the retry. -Connection pooling, keep-alive, and the request, header, and body deadlines are preserved: pooling and keep-alive are configured on Node's agents, and the deadlines are passed per request — from the Local World's two queue timeouts, and on the Vercel World from the same defaults its HTTP client applies today. Queue sends are the exception in the other direction: that client takes no transport override, so its requests keep using the library either way. +Connection pooling, keep-alive, and the request, header, and body deadlines are preserved. Node's agents configure pooling and keep-alive, and each request receives the deadlines from the Local World's two queue timeouts or the same defaults that the Vercel World's HTTP client currently applies. Queue sends are the exception in the other direction: that client takes no transport override, so its requests keep using the library either way. A `dispatcher` passed to `createVercelWorld()` still wins over this variable. The variable chooses which transport the World builds when you have not supplied one. @@ -303,7 +303,7 @@ These variables are primarily for tests, debugging, or unusual deployments. ### `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` - Default: `0` (dispatch the first chunk of an idle stream immediately) -- Group-commit window for the *leading* chunk of an idle stream. `0` sends it at once; a positive value holds it up to that many milliseconds to collect a group — an opt-in trade of first-chunk latency for larger batches, useful for slow-but-steady producers. Chunks arriving while a request is already in flight always coalesce into the next group regardless of this setting. +- Group-commit window for the *leading* chunk of an idle stream. `0` sends it at once; a positive value holds it up to that many milliseconds to collect a group. This opt-in setting trades first-chunk latency for larger batches and can benefit slow-but-steady producers. Chunks arriving while a request is already in flight always coalesce into the next group regardless of this setting. - Also available as `streamFlushIntervalMs` on Worlds that expose it (the env var, when set, takes precedence over the World option). ### `WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS` diff --git a/docs/content/docs/v5/configuration/worlds.mdx b/docs/content/docs/v5/configuration/worlds.mdx index 2c195e9f09..fda929877b 100644 --- a/docs/content/docs/v5/configuration/worlds.mdx +++ b/docs/content/docs/v5/configuration/worlds.mdx @@ -23,16 +23,16 @@ Outside Vercel, Workflow defaults to the Local World. On Vercel, leave `WORKFLOW The World is selected when your app **runs**, from the environment of the process serving it, so changing `WORKFLOW_TARGET_WORLD` takes effect on the next start without a rebuild. Detection keys off `VERCEL_DEPLOYMENT_ID`, which Vercel sets in every deployed function and nothing else sets: with it, the Vercel World; without it, the Local World. -Broader signals are deliberately ignored. `vercel env pull` writes `VERCEL=1` into `.env.local`, so a dev server or a production server started on your own machine sees it while running against a writable filesystem — where the Local World is the right choice. Set `WORKFLOW_TARGET_WORLD=vercel` explicitly if you want such a process to talk to the Vercel World; starting a run then fails with an error naming the missing `VERCEL_DEPLOYMENT_ID`. +Broader signals are deliberately ignored. `vercel env pull` writes `VERCEL=1` into `.env.local`, so a dev server or a production server started on your own machine sees it while running against a writable filesystem, where the Local World is the right choice. Set `WORKFLOW_TARGET_WORLD=vercel` explicitly if you want such a process to talk to the Vercel World; starting a run then fails with an error naming the missing `VERCEL_DEPLOYMENT_ID`. A deployment that pins `WORKFLOW_TARGET_WORLD=local` warns at startup and fails on its first write, because a Vercel deployment's filesystem is read-only. Set `WORKFLOW_TARGET_WORLD` only when you want to use a custom or self-hosted World: -- `local` - alias for `@workflow/world-local`. -- `@workflow/world-postgres` - Postgres World package. -- `./my-world.ts` - local module exporting a World, `createWorld()`, or a default factory. -- Any package specifier - custom World package. +- `local`: Alias for `@workflow/world-local`. +- `@workflow/world-postgres`: Postgres World package. +- `./my-world.ts`: Local module exporting a World, `createWorld()`, or a default factory. +- Any package specifier: Custom World package. The `vercel` alias exists for manual selection and tooling, but deployed Vercel apps do not need to set it. @@ -121,7 +121,7 @@ The Local World is the default outside Vercel and is intended for development. - Environment variable fallback: `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` - Default: `0` (dispatch the leading chunk of an idle stream immediately) -- Group-commit window for the leading chunk of an idle stream; a positive value trades first-chunk latency for larger groups. The `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable, when set, overrides this option; otherwise the World option governs, including the very first chunk. +- Group-commit window for the leading chunk of an idle stream; a positive value trades first-chunk latency for larger groups. The `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable, when set, overrides this option; otherwise the World option governs, including the first chunk. ### `WORKFLOW_MAX_EVENTS` @@ -189,13 +189,13 @@ The Postgres World is a self-hosted durable backend for long-running server proc - Environment variable fallback: `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` - Default: `0` (dispatch the leading chunk of an idle stream immediately) -- Group-commit window for the leading chunk of an idle stream; a positive value trades first-chunk latency for larger groups. The `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable, when set, overrides this option; otherwise the World option governs, including the very first chunk. +- Group-commit window for the leading chunk of an idle stream; a positive value trades first-chunk latency for larger groups. The `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable, when set, overrides this option; otherwise the World option governs, including the first chunk. ## Vercel World The Vercel World is configured automatically inside Vercel deployments. The platform provides the deployment ID, project ID, request authentication, queue integration, storage, and encryption material. -Most applications should not set `WORKFLOW_VERCEL_*` variables on Vercel. They configure tooling that talks to a Vercel Workflow project from outside a deployment, such as the `workflow` CLI, the web UI, CI, or tests. The runtime warns if these variables are set in a deployed Vercel function because they do not control runtime configuration there. +Most applications should not set `WORKFLOW_VERCEL_*` variables on Vercel. They configure tooling that talks to a Vercel Workflow project from outside a deployment, such as the Workflow CLI, the web user interface (UI), continuous integration (CI), or tests. The runtime warns if these variables are set in a deployed Vercel Function because they do not control runtime configuration there. Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, and `VERCEL_DEPLOYMENT_KEY` are read by the runtime inside Vercel deployments. Do not set them yourself. @@ -245,7 +245,7 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an - Default: disabled - Set `1` to serialize orchestrator (flow) invocations per run: each run's replays get their own queue topic and the flow trigger is generated with `maxConcurrency: 1`. Inline step executions get per-step topics and keep full parallelism. -- Read at **both build time and runtime** — set it as a project-level environment variable so the generated trigger and the runtime queue routing agree. +- Read at **both build time and runtime**: set it as a project-level environment variable so the generated trigger and the runtime queue routing agree. - Routing each run through a dedicated `maxConcurrency: 1` topic might lead to higher queue performance overhead. See [Vercel World](/worlds/vercel#workflow_sequential_replays) for details. ### `VERCEL_WORKFLOW_SERVER_URL` @@ -292,9 +292,9 @@ Platform-provided values such as `VERCEL_DEPLOYMENT_ID`, `VERCEL_PROJECT_ID`, an - Surface: environment variable - Default: on -- Set to `0` (or `false`) to **disable** batched event writes — the escape hatch that restores the exact prior one-write-per-event path. +- Set to `0` (or `false`) to **disable** batched event writes, the escape hatch that restores the exact prior one-write-per-event path. -When enabled (the default), a suspension's eager `step_created` and `wait_created` writes fold into batched `events.createBatch` calls (one durable write with per-event outcomes) on Worlds that implement the optional batch API. The fold only engages when the World implements `events.createBatch` (the Vercel World does; Local and Postgres do not), the run's spec version supports slot identity (≥ 6), and the suspension carries no attribute writes, hook writes, or resilient step dispatch — everything else keeps the single-event path unchanged, so disabling is only needed as an operational escape hatch. Batches are capped at 32 events; larger fan-outs commit in successive batches. See the [batched event writes changelog](/docs/changelog/batched-event-writes) for the World API contract. +When enabled (the default), a suspension's eager `step_created` and `wait_created` writes fold into batched `events.createBatch` calls (one durable write with per-event outcomes) on Worlds that implement the optional batch API. The fold only engages when the World implements `events.createBatch` (the Vercel World does; Local and Postgres do not), the run's spec version supports slot identity (≥ 6), and the suspension carries no attribute writes, hook writes, or resilient step dispatch. Everything else keeps the single-event path unchanged, so disabling is only needed as an operational escape hatch. Batches are capped at 32 events; larger fan-outs commit in successive batches. See the [batched event writes changelog](/docs/changelog/batched-event-writes) for the World API contract. ### `WORKFLOW_EVENTS_TRANSPORT` @@ -302,4 +302,4 @@ When enabled (the default), a suspension's eager `step_created` and `wait_create - CLI flag: none - Default: `http` - Experimental. Set to `ws` to ship workflow run events to the Vercel World over a WebSocket instead of one HTTP request each. -- Ignored when the World is configured with `projectConfig` and routes through the `api-workflow` proxy — that endpoint is an HTTP-only REST gateway and does not forward a WebSocket upgrade, so events stay on HTTP. +- Ignored when the World is configured with `projectConfig` and routes through the `api-workflow` proxy: that endpoint is an HTTP-only REST gateway and does not forward a WebSocket upgrade, so events stay on HTTP. diff --git a/docs/content/docs/v5/cookbook/advanced/child-workflows.mdx b/docs/content/docs/v5/cookbook/advanced/child-workflows.mdx index 17a34abb5c..7fcf77a35e 100644 --- a/docs/content/docs/v5/cookbook/advanced/child-workflows.mdx +++ b/docs/content/docs/v5/cookbook/advanced/child-workflows.mdx @@ -2,7 +2,7 @@ title: Child Workflows description: Spawn child workflows from a parent and wait for completion via hook resume. type: guide -summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait() — the child resumes the parent's hook when done instead of polling getRun().status. +summary: Orchestrate independent child workflows from a parent using start(), defineHook(), and startAndWait(). The child resumes the parent's hook when done instead of polling getRun().status. related: - /docs/api-reference/workflow-api/start --- @@ -11,16 +11,16 @@ related: text="Refactor this workflow to use child workflows. Keep the parent as an exported `"use workflow"` function. Move independent units of durable work into separate exported child workflow functions. From the parent, call `start(childWorkflow, [args])` from `workflow/api` or the documented `startAndWait`/hook pattern where completion must resume the parent. Pass only serializable state to children. For fan-out, start children in parallel with `Promise.all` or bounded batches, collect run IDs, handle partial failures with `Promise.allSettled`, and use `getRun(runId)` when status, cancellation, streams, or return values are needed. Verify child start, completion, failure, and parent resume behavior." /> -Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope -- if one child fails, it doesn't take down the parent or siblings. +Use child workflows when a single workflow needs to orchestrate many independent units of work. Each child runs as its own workflow with a separate event log, retry boundary, and failure scope. If one child fails, it doesn't take down the parent or siblings. ## When to use child workflows Child workflows are the right choice when: -- **Work units are independent.** Each child can run without knowing about the others (e.g., processing individual documents, generating separate reports). -- **You need isolated failure boundaries.** A failing child should not abort unrelated work. The parent decides how to handle failures. -- **You want massive fan-out.** Spawning 50 or 500 children is practical because each runs on its own infrastructure. -- **You need per-item observability.** Each child workflow has its own run ID, status, and event log for monitoring. +- **Work units are independent**: Each child can run without knowing about the others (for example, when processing individual documents or generating separate reports). +- **You need isolated failure boundaries**: A failing child should not abort unrelated work. The parent decides how to handle failures. +- **You want large fan-out**: Spawning 50 or 500 children is practical because each runs on its own infrastructure. +- **You need per-item observability**: Each child workflow has its own run ID, status, and event log for monitoring. For simpler cases where steps share a single event log, use [direct await composition](/cookbook/common-patterns/workflow-composition#direct-await-flattening) instead. @@ -28,7 +28,7 @@ For simpler cases where steps share a single event log, use [direct await compos The recommended pattern has four parts: -1. A **completion hook** the parent creates and awaits — zero compute while waiting +1. A **completion hook** the parent creates and awaits, with zero compute while waiting 2. A **wrapped child export** that runs the real child in try/catch/finally and resumes the parent's hook from a step in `finally` 3. A **`start()` call** that spawns the wrapped child with the hook token (directly from the workflow in v5) 4. A **`startAndWait()` helper** that ties the hook, spawn, and typed result together @@ -148,12 +148,12 @@ export async function processDocumentBatch(documentIds: string[]) { Polling with `getRun().status` in a `sleep()` loop works, but hook resume is preferable because: -- **Zero compute while waiting** — the parent suspends on the hook instead of waking every poll interval -- **Immediate wake-up** — the parent resumes as soon as the child finishes, not on the next poll tick -- **Typed payloads** — the child sends `{ status, value | error }` directly; no separate `returnValue` fetch step -- **No worker-pool pressure** — `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/docs/changelog/eager-processing)) +- **Zero compute while waiting**: The parent suspends on the hook instead of waking every poll interval. +- **Immediate wake-up**: The parent resumes as soon as the child finishes, not on the next poll tick. +- **Typed payloads**: The child sends `{ status, value | error }` directly, with no separate `returnValue` fetch step. +- **No worker-pool pressure**: `Run#returnValue` polling inside steps can hold worker slots while waiting for children (see [Eager Processing](/docs/changelog/eager-processing)). -When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works — pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`. +When a parent calls a child workflow inline with `await` (flattened into the same run), the same wrapper and hook handshake still works: pass the token and `await processDocumentWithCompletion(...)` inside `startAndWait()` instead of calling `start()`. ## Fan-out pattern: chunked spawning @@ -227,7 +227,7 @@ declare function withChildCompletionHook( ### Tolerating partial failures -Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }` — no status polling required. +Use `Promise.allSettled` with `startAndWait()` so one failing child doesn't abort siblings. The hook payload already carries `{ status: "failed", error }`, so no status polling is required. ```typescript import { start } from "workflow/api"; @@ -302,18 +302,18 @@ async function startAndWaitWithRetries( ## Tips -- **`defineHook().resume()` must be called from a step.** The wrapped child's `finally` block calls a step that resumes the parent hook. -- **Export wrapped children at module scope.** The SDK registers `"use workflow"` functions statically — a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`. -- **Use stable hook keys** — document ID, job ID, or index — so parallel children inside one parent run don't collide on tokens. -- **Use chunked spawning for large batches.** Starting 500 children at once can create a large burst of work. Break it into chunks of 10-50. -- **Each child has its own retry semantics.** Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook. -- **Use `deploymentId: "latest"`** if children should run on the most recent deployment. See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations. +- **Call `defineHook().resume()` from a step**: The wrapped child's `finally` block calls a step that resumes the parent hook. +- **Export wrapped children at module scope**: The SDK registers `"use workflow"` functions statically, so a runtime higher-order function returned from `withChildCompletionHook()` cannot be passed to `start()`. +- **Use stable hook keys**: Document IDs, job IDs, or indexes prevent token collisions between parallel children in one parent run. +- **Use chunked spawning for large batches**: Starting 500 children at once can create a large burst of work. Break the work into chunks of 10–50. +- **Account for each child's retry semantics**: Steps inside child workflows retry independently. The parent sees the final `{ status, value | error }` payload from the hook. +- **Use `deploymentId: "latest"` when children should run on the most recent deployment**: See [Versioning](/docs/foundations/versioning) for the full model and the [`start()` API reference](/docs/api-reference/workflow-api/start#using-deploymentid-latest) for compatibility considerations. ## Key APIs -- [`start()`](/docs/api-reference/workflow-api/start) -- spawn a new workflow run and get its run ID -- [`defineHook()`](/docs/api-reference/workflow/define-hook) -- typed hook for parent/child completion handshakes -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resume a waiting parent from a step (called by the child wrapper) -- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) -- read the parent run ID for deterministic hook tokens -- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions with full Node.js access +- [`start()`](/docs/api-reference/workflow-api/start): Spawns a new workflow run and returns its run ID. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a typed hook for parent-child completion handshakes. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resumes a waiting parent from a step called by the child wrapper. +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Returns the parent run ID for deterministic hook tokens. +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions with full Node.js access. diff --git a/docs/content/docs/v5/cookbook/advanced/publishing-libraries.mdx b/docs/content/docs/v5/cookbook/advanced/publishing-libraries.mdx index 71fc8294af..3bc30ae967 100644 --- a/docs/content/docs/v5/cookbook/advanced/publishing-libraries.mdx +++ b/docs/content/docs/v5/cookbook/advanced/publishing-libraries.mdx @@ -2,7 +2,7 @@ title: Publishing Libraries description: Structure and publish npm packages that export workflow functions for consumers to use with Workflow SDK. type: guide -summary: Learn how to build, export, and test npm packages that ship workflow and step functions — including package.json exports, re-exporting so the consumer's compiler discovers your workflows, keeping step I/O clean, and integration testing. +summary: Learn how to build, export, and test npm packages that ship workflow and step functions, including package.json exports, re-exporting so the consumer's compiler discovers your workflows, keeping step I/O clean, and integration testing. --- -## Package Structure +## Package structure A workflow library follows a standard TypeScript package layout with a dedicated `workflows/` directory. Each workflow file exports one or more workflow functions that consumers can import and pass to `start()`. @@ -44,14 +44,14 @@ A workflow library follows a standard TypeScript package layout with a dedicated Key files: -- **`src/index.ts`** — Package entry point. Exports the public API. -- **`src/types.ts`** — Shared TypeScript types. -- **`src/workflows/index.ts`** — Re-exports every workflow so consumers can pull them in under one specifier (see [Entry Points and Exports](#entry-points-and-exports)). -- **`src/workflows/*.ts`** — One file per workflow function (e.g. `transcode.ts`, `generate-thumbnails.ts`). -- **`src/lib/`** — Internal helpers. Plain async code, *not* marked with `"use workflow"` or `"use step"`. -- **`test-server/workflows.ts`** — Re-export file used by integration tests (see [Testing Workflow Libraries](#testing-workflow-libraries)). +- **`src/index.ts`**: Package entry point that exports the public API. +- **`src/types.ts`**: Shared TypeScript types. +- **`src/workflows/index.ts`**: Re-exports every workflow so consumers can pull them in under one specifier (see [Entry points and exports](#entry-points-and-exports)). +- **`src/workflows/*.ts`**: One file per workflow function (for example, `transcode.ts` or `generate-thumbnails.ts`). +- **`src/lib/`**: Internal helpers with plain async code that is not marked with `"use workflow"` or `"use step"`. +- **`test-server/workflows.ts`**: Re-export file used by integration tests (see [Test workflow libraries](#test-workflow-libraries)). -### Entry Points and Exports +### Entry points and exports Use the `exports` field in `package.json` to expose separate entry points for the main API and the raw workflow functions: @@ -75,7 +75,7 @@ Use the `exports` field in `package.json` to expose separate entry points for th The main entry point (`@acme/media`) exports types, utilities, and convenience wrappers. The `./workflows` entry point (`@acme/media/workflows`) exports the raw workflow functions that consumers need for the build system. -### Source Files +### Source files The package entry re-exports workflows alongside any utilities: @@ -93,7 +93,7 @@ export * from "./transcode"; export * from "./generate-thumbnails"; ``` -### Build Configuration +### Build configuration Use a bundler like `tsup` with separate entry points for each export. Mark `workflow` as external so it's resolved from the consumer's project: @@ -114,13 +114,13 @@ export default defineConfig({ }); ``` -## Re-Exporting for Compiler Discovery +## Re-exporting for compiler discovery The workflow compiler only transforms files it discovers, and discovery starts from the consumer's `workflows/` directory and follows imports out from there. A library's workflow functions are not on that graph by default, so nothing compiles them and the runtime has no definition to run. The fix is a **re-export file**. The consumer creates a file in their `workflows/` directory that re-exports the library's workflows, which pulls the library's source onto the discovery graph and gives its entry point an address the runtime can resolve. -### Consumer Setup +### Consumer setup ```typescript lineNumbers // workflows/media.ts (in the consumer's project) @@ -130,7 +130,7 @@ export * from "@acme/media/workflows"; // [!code highlight] This one-line file is all that's needed. The compiler follows the re-export into the package, transforms the workflow and step functions it finds, and registers them under IDs the runtime can resolve. -### Why This Is Necessary +### Why this is necessary Without re-exporting, the workflow runtime cannot match a running workflow to its function definition. When a run is replayed after a cold start, the runtime looks up functions by their compiler-assigned IDs. If those functions were never compiled, the IDs don't exist and replay fails. @@ -144,13 +144,13 @@ That is safe on worlds with deployment pinning, such as Vercel, because runs are The re-export file does not change any of this. An ID is derived from where the file lives, not from how it was imported, so a package file keeps its `name@version` ID whether or not a consumer re-exports it. -## Keeping Step I/O Clean +## Keeping step I/O clean When you publish a workflow library, every step function's inputs and outputs are recorded in the event log. This has two implications: -### 1. Everything Must Be Serializable +### 1. Everything must be serializable -Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON — including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/foundations/serialization#custom-class-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return: +Step inputs and outputs must be serializable. The workflow runtime supports a rich set of types beyond plain JSON, including `Date`, `RegExp`, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `Error`, and class instances that implement [custom class serialization](/docs/foundations/serialization#custom-class-serialization). See the [serialization reference](/docs/foundations/serialization) for the full list of supported types. Do not pass or return: - Functions or closures - `WeakRef`, `WeakMap`, or `WeakSet` @@ -169,7 +169,7 @@ async function callExternalApi(endpoint: string, params: Record) // Bad: pass a pre-constructed client object async function callExternalApi(client: ApiClient, params: Record) { "use step"; - // ApiClient is not serializable — this will fail on replay + // ApiClient is not serializable, so this will fail on replay return await client.request(params); } ``` @@ -199,13 +199,13 @@ async function fetchData(apiKey: string, query: string) { The choice is a matter of library API design preference. Resolving from environment variables keeps the step signature simpler, while passing credentials explicitly makes dependencies visible and can be easier to test. -## Testing Workflow Libraries +## Testing workflow libraries -Library authors need integration tests that exercise workflows through the full Workflow SDK runtime — not just unit tests of individual functions. +Library authors need integration tests that exercise workflows through the full Workflow SDK runtime, rather than only unit tests of individual functions. -### Test Server Pattern +### Test server pattern -Create a minimal test server that re-exports your library's workflows, just like a consumer would: +Create a minimal test server that re-exports your library's workflows, like a consumer would: ```typescript lineNumbers // test-server/workflows.ts @@ -214,7 +214,7 @@ export * from "@acme/media/workflows"; // [!code highlight] This test server acts as a stand-in consumer app. Point your test runner at it to exercise the full workflow lifecycle: start, replay, and completion. -### Vitest Configuration +### Vitest configuration Use a dedicated Vitest config for integration tests that run against the Workflow SDK runtime: @@ -241,22 +241,22 @@ pnpm vitest run tests/unit pnpm vitest run --config vitest.workflowsdk.config.ts ``` -### What to Test +### What to test -- **Happy path**: workflow starts, all steps execute, and the final result is correct -- **Serialization round-trip**: inputs and outputs survive the event log -- **Replay**: kill and restart a workflow mid-execution to verify deterministic replay -- **Error handling**: verify that step failures produce the expected errors +- **Happy path**: The workflow starts, all steps execute, and the final result is correct. +- **Serialization round-trip**: Inputs and outputs survive the event log. +- **Replay**: Stop and restart a workflow during execution to verify deterministic replay. +- **Error handling**: Step failures produce the expected errors. -## Working With and Without Workflow Installed +## Working with and without Workflow installed -Some libraries want to be useful to consumers who *aren't* using Workflow SDK at all — the library picks up durable behavior when a workflow runtime is present and falls back to plain async execution otherwise. +Some libraries need to support consumers who *aren't* using Workflow SDK. The library gains durable behavior when a workflow runtime is present and falls back to plain async execution otherwise. Two rules for isomorphic packages: -1. **Any runtime reference to the `workflow` package must be loaded via dynamic `import("workflow")` inside a try/catch.** A static top-level import makes the module fail to load for consumers who haven't installed workflow. -2. **The `"use workflow"` and `"use step"` directives are safe to keep in your library source.** When a consumer compiles your code with the Workflow SDK toolchain (via the [re-export pattern](#re-exporting-for-compiler-discovery) above), the SWC plugin transforms them into durable-execution glue. When they're not compiled — plain Node, plain tests, a consumer without the runtime — they are just string expression statements and run as no-ops. +1. **Load any runtime reference to the `workflow` package through dynamic `import("workflow")` inside a try/catch.** A static top-level import makes the module fail to load for consumers who haven't installed Workflow. +2. **Keep the `"use workflow"` and `"use step"` directives in your library source.** When a consumer compiles your code with the Workflow SDK toolchain (via the [re-export pattern](#re-export-for-compiler-discovery) above), the SWC plugin transforms the directives into durable-execution glue. When the directives aren't compiled (plain Node.js, plain tests, or a consumer without the runtime), they are string expression statements and run as no-ops. ### Optional peer dependency @@ -294,7 +294,7 @@ async function getWorkflowStepId(): Promise { // [!code highlight ### A concrete use case: replay-safe idempotency keys -A payments utility that uses the current workflow step ID as a Stripe idempotency key when available, and a fresh UUID otherwise: +A payments utility can use the current workflow step ID as a Stripe idempotency key when available and a fresh universally unique identifier (UUID) otherwise: ```typescript lineNumbers declare function getWorkflowStepId(): Promise; // @setup (defined in the previous block) @@ -315,14 +315,14 @@ export async function processPayment(amount: number, currency: string) { } ``` -When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries — Stripe dedupes retries for free. When called from a plain Node.js process, it behaves like any other function and a fresh UUID is generated. For more patterns, see [Idempotency](/docs/foundations/idempotency). +When called from inside a workflow step, the utility gets a stable idempotency key for that step across retries, so Stripe deduplicates retries. When called from a plain Node.js process, it behaves like any other function and generates a fresh UUID. For more patterns, see [Idempotency](/docs/foundations/idempotency). ### In production Packages in the wild built on Workflow SDK: -- **[`@mux/ai`](https://github.com/muxinc/ai)** — Reusable video AI workflows (summaries, chapters, content moderation, translation, embeddings) exported with `"use workflow"` / `"use step"` directives. In a standard Node environment the directives are no-ops and the SDK runs as a plain async library; in a Workflow SDK environment the consumer's compiler transforms them into durable, resumable steps with automatic retries and observability. Written up in detail in [*How Mux shipped durable video workflows with their @mux/ai SDK*](https://vercel.com/blog/how-mux-shipped-durable-video-workflows-with-their-mux-ai-sdk) on the Vercel blog. -- **World ID** — Human-in-the-loop "proof of human" primitive for agent workflows. Developers drop a World ID step into any workflow to require a zero-knowledge cryptographic proof that a real, unique human authorized a specific action (deploy approvals, large payments, sensitive data access, etc.). Because it runs as a workflow step, every verification is durable, replay-safe, and viewable inside the run's execution timeline — giving you a provable audit record of which human approved what. Available on npm and announced in [*World ID for agents: Browserbase, Exa, Okta, and Vercel*](https://world.org/blog/announcements/browserbase-exa-okta-world-id-for-agentic-web) on the World blog. +- **[`@mux/ai`](https://github.com/muxinc/ai)**: Reusable video AI workflows (summaries, chapters, content moderation, translation, embeddings) exported with `"use workflow"` / `"use step"` directives. In a standard Node environment the directives are no-ops and the SDK runs as a plain async library; in a Workflow SDK environment the consumer's compiler transforms them into durable, resumable steps with automatic retries and observability. Written up in detail in [*How Mux shipped durable video workflows with their @mux/ai SDK*](https://vercel.com/blog/how-mux-shipped-durable-video-workflows-with-their-mux-ai-sdk) on the Vercel blog. +- **World ID**: Human-in-the-loop "proof of human" primitive for agent workflows. Developers drop a World ID step into any workflow to require a zero-knowledge cryptographic proof that a real, unique human authorized a specific action (deploy approvals, large payments, sensitive data access, etc.). Because it runs as a workflow step, every verification is durable, replay-safe, and viewable inside the run's execution timeline, giving you a provable audit record of which human approved what. Available on npm and announced in [*World ID for agents: Browserbase, Exa, Okta, and Vercel*](https://world.org/blog/announcements/browserbase-exa-okta-world-id-for-agentic-web) on the World blog. ## Checklist @@ -339,7 +339,7 @@ Before publishing a workflow library: ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks functions for durable execution -- [`start`](/docs/api-reference/workflow-api/start) — starts a workflow run -- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata) — runtime detection and run ID access +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Marks functions for durable execution. +- [`start`](/docs/api-reference/workflow-api/start): Starts a workflow run. +- [`getWorkflowMetadata`](/docs/api-reference/workflow/get-workflow-metadata): Provides runtime detection and run ID access. diff --git a/docs/content/docs/v5/cookbook/advanced/serializable-steps.mdx b/docs/content/docs/v5/cookbook/advanced/serializable-steps.mdx index ddd04ab6e5..f7559e3d4c 100644 --- a/docs/content/docs/v5/cookbook/advanced/serializable-steps.mdx +++ b/docs/content/docs/v5/cookbook/advanced/serializable-steps.mdx @@ -10,7 +10,7 @@ related: --- @@ -21,12 +21,12 @@ This is an advanced guide. It dives into workflow internals and is not required Workflow functions run inside a sandboxed VM where every value that crosses a function boundary must be serializable. There are two ways to get a non-serializable object across that boundary, depending on whether you own the class: -- **You own the class** — implement the [`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` protocol](/docs/foundations/serialization#custom-class-serialization). The instance becomes a first-class serializable value: you can pass it as a workflow input, return it from a step, and call `"use step"` instance methods on it directly. This is the right tool when the class is yours to modify. -- **You don't own the class** — you can't add methods to `openai("gpt-4o")` from `@ai-sdk/openai` or `new S3Client({...})` from `@aws-sdk/client-s3`. Instead, wrap construction in a `"use step"` factory function and pass the factory across the boundary. That's what this page covers. +- **You own the class**: implement the [`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE` protocol](/docs/foundations/serialization#custom-class-serialization). The instance becomes a first-class serializable value: you can pass it as a workflow input, return it from a step, and call `"use step"` instance methods on it directly. This is the right tool when the class is yours to modify. +- **You don't own the class**: you can't add methods to `openai("gpt-4o")` from `@ai-sdk/openai` or `new S3Client({...})` from `@aws-sdk/client-s3`. Instead, wrap construction in a `"use step"` factory function and pass the factory across the boundary. That's what this page covers. -## The Problem +## The problem -AI SDK model providers — `openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc. — return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error, and you can't bolt `WORKFLOW_SERIALIZE` onto a third-party class. +AI SDK model providers (`openai("gpt-4o")`, `anthropic("claude-sonnet-4-20250514")`, etc.) return complex objects with methods, closures, and internal state. Passing one directly into a step causes a serialization error, and you can't bolt `WORKFLOW_SERIALIZE` onto a third-party class. ```typescript lineNumbers import { openai } from "@ai-sdk/openai"; @@ -39,7 +39,7 @@ export async function brokenAgent(prompt: string) { const writable = getWritable(); const agent = new DurableAgent({ - // This fails — the model object is not serializable + // This fails: the model object is not serializable model: openai("gpt-4o"), }); @@ -47,9 +47,9 @@ export async function brokenAgent(prompt: string) { } ``` -## The Solution: Step-as-Factory +## The solution: step-as-factory -Instead of passing the model object, pass a **callback function** that returns the model. Marking that callback with `"use step"` tells the compiler to serialize the *function reference* (which is just a string identifier) rather than its return value. The provider is only instantiated at execution time, inside the step's full Node.js runtime. +Instead of passing the model object, pass a **callback function** that returns the model. Marking that callback with `"use step"` tells the compiler to serialize the *function reference* (which is a string identifier) rather than its return value. The provider is only instantiated at execution time, inside the step's full Node.js runtime. ```typescript lineNumbers import { openai as openaiProvider } from "@ai-sdk/openai"; @@ -63,12 +63,12 @@ export function openai(...args: Parameters) { } ``` -The `DurableAgent` receives a function (`() => Promise`) instead of a model object. When the agent needs to call the LLM, it invokes the factory inside a step where the real provider can be constructed with full Node.js access. +The `DurableAgent` receives a function (`() => Promise`) instead of a model object. When the agent needs to call the large language model (LLM), it invokes the factory inside a step where the real provider can be constructed with full Node.js access. -## How `@workflow/ai` Uses This +## How `@workflow/ai` uses this -`@workflow/ai`'s pre-wrapped providers and `DurableAgent` are deprecated. AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) resolves models from AI Gateway model strings (e.g. `"openai/gpt-4o"`), which usually removes the need for a model factory — see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The serialization pattern on this page still applies to any non-serializable dependency you own (for example, cloud SDK clients). +`@workflow/ai`'s pre-wrapped providers and `DurableAgent` are deprecated. AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) resolves models from AI Gateway model strings (for example, `"openai/gpt-4o"`), which usually removes the need for a model factory; see the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The serialization pattern on this page still applies to any non-serializable dependency you own (for example, cloud SDK clients). The `@workflow/ai` package ships pre-wrapped providers for all major AI SDK backends. Each one follows the same pattern: @@ -105,14 +105,14 @@ export async function chatAgent(prompt: string) { } ``` -## Writing Your Own Serializable Wrapper +## Writing your own serializable wrapper Apply the same pattern to any non-serializable dependency. The key rule: **the outer function captures serializable arguments, and the inner `"use step"` function constructs the real object at runtime**. ```typescript lineNumbers import type { S3Client as S3ClientType } from "@aws-sdk/client-s3"; -// The arguments (region, bucket) are plain strings — serializable +// The arguments (region, bucket) are plain strings, which are serializable export function createS3Client(region: string) { return async (): Promise => { "use step"; @@ -141,15 +141,15 @@ async function uploadFile( } ``` -## Why This Works +## Why this works -1. **Compiler transformation**: `"use step"` tells the SWC plugin to extract the function into a separate bundle. The workflow VM only sees a serializable reference (function ID + captured arguments). -2. **Closure tracking**: The compiler tracks which variables the step function closes over. Only serializable values (strings, numbers, plain objects) can be captured. -3. **Deferred construction**: The actual provider/client is only constructed when the step executes in the Node.js runtime — never in the sandboxed workflow VM. +1. **Compiler transformation**: `"use step"` tells the SWC plugin to extract the function into a separate bundle. The workflow VM only sees a serializable reference (function ID and captured arguments). +2. **Closure tracking**: The compiler tracks which variables the step function closes over. The function can capture only serializable values, such as strings, numbers, and plain objects. +3. **Deferred construction**: The step constructs the provider or client only when it executes in the Node.js runtime, never in the sandboxed workflow VM. ## Key APIs -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — marks a function for extraction and serialization -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent (resolves models via AI Gateway strings; replaces `DurableAgent`) -- [Custom class serialization](/docs/foundations/serialization#custom-class-serialization) — the companion pattern for classes you own (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`) +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Marks a function for extraction and serialization. +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Provides AI SDK's durable agent, resolves models through AI Gateway strings, and replaces `DurableAgent`. +- [Custom class serialization](/docs/foundations/serialization#custom-class-serialization): Provides the companion pattern for classes you own (`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`). diff --git a/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx b/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx index b94dd6702d..3d94e260f2 100644 --- a/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx +++ b/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx @@ -2,7 +2,7 @@ title: Upgrading Workflows description: Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward. type: guide -summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })` — either automatically on every iteration, or on demand via a dedicated upgrade hook.' +summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })`, either automatically on every iteration, or on demand via a dedicated upgrade hook.' related: - /docs/foundations/versioning - /cookbook/common-patterns/workflow-composition @@ -14,27 +14,27 @@ related: text="Add a safe self-upgrade point to this long-running workflow. Identify the loop boundary where no step is mid-side-effect. Define a serializable state object that contains all progress needed to continue. At the boundary, call `start(self, [state], { deploymentId: "latest" })` or the documented replacement workflow with the carried state, then return from the old run. If upgrades should be manual, add a `defineHook()` upgrade signal and resume it from an API route with `resumeHook()` from `workflow/api`. Make the handoff idempotent so retries do not start duplicate successor runs, and verify old-to-new handoff plus duplicate prevention." /> -Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** — a moment where it's safe to checkpoint state and start fresh — and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run. +Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** (a moment where it's safe to checkpoint state and start fresh) and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run. -For the underlying model — why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary — see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments. +For the underlying model (why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary), see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments. A clean upgrade point is any spot in the workflow where: -- All in-progress side effects have completed (or aren't needed by the next iteration) -- The relevant state can be serialized into the workflow's input arguments -- It's natural for the workflow to "checkpoint" — typically right after handling an external event, completing a batch, or finishing a logical phase +- All in-progress side effects have completed or aren't needed by the next iteration. +- The relevant state can be serialized into the workflow's input arguments. +- The workflow can create a checkpoint after handling an external event, completing a batch, or finishing a logical phase. There are two ways to apply this: -1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)). Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. Simple — no extra triggers — but every event pays the respawn cost. -2. **Upgrade on demand via a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)). A single long-lived run handles many events in a loop and only respawns when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane (e.g. after a deploy). More control and fewer respawns, at the cost of an explicit trigger. +1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)): Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. This method needs no extra triggers, but every event incurs the respawn cost. +2. **Upgrade on demand through a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)): A single long-lived run handles many events in a loop and respawns only when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane, for example, after a deployment. This method provides more control and fewer respawns at the cost of an explicit trigger. ### When to use each -- **Method 1** when iterations are short and frequent, the work is cheap to checkpoint, and you want shipped fixes to apply on the very next event. Long-lived "session" workflows (subscriptions, queues, FSMs) that already process events one at a time fit this naturally. -- **Method 2** when iterations are infrequent or expensive (you don't want to respawn on every event), or when you need to roll out a fix to a fleet of in-flight runs after a deploy by fanning out to a control-plane endpoint. Also fits when "upgrade" should be an explicit operation rather than a side effect of handling each event. +- **Use Method 1** when iterations are short and frequent, the work is inexpensive to checkpoint, and you want shipped fixes to apply on the next event. Long-lived session workflows, such as subscriptions, queues, and finite-state machines (FSMs), that already process events one at a time fit this method. +- **Use Method 2** when iterations are infrequent or expensive, or when you need to roll out a fix to a fleet of in-flight runs after a deployment by fanning out to a control-plane endpoint. This method also fits when an upgrade should be an explicit operation rather than a side effect of handling each event. ## Method 1: Upgrade on every iteration @@ -60,7 +60,7 @@ export async function longRunningQueue( const { workflowRunId } = getWorkflowMetadata(); - // Block until something fires the hook — could be hours, days, or longer. + // Block until something fires the hook. Could be hours, days, or longer. // Per-run hook tokens (workflowRunId) keep concurrent chains isolated. const { itemId } = await nextItemHook.create({ token: workflowRunId }); // [!code highlight] @@ -68,7 +68,7 @@ export async function longRunningQueue( // Hand off to a fresh run on the latest deployment. THIS run ends here. // `deploymentId: "latest"` resolves to whichever deployment is current - // when this spawn lands — NOT the deployment running this code. + // when this spawn lands, NOT the deployment running this code. await start( // [!code highlight] longRunningQueue, // [!code highlight] [{ processed: state.processed + 1, cursor: itemId }], // [!code highlight] @@ -93,11 +93,11 @@ export async function POST(req: Request) { } ``` -The caller tracks the active `runId` (e.g. in a database, KV, or returned from the previous iteration) and updates it whenever the chain advances. +The caller tracks the active `runId`, such as in a database or returned from the previous iteration, and updates it whenever the chain advances. ## Method 2: Upgrade on demand via a dedicated hook -Use a single long-running workflow that handles events in a loop. Define a second hook — `upgradeHook` — alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits. +Use a single long-running workflow that handles events in a loop. Define a second hook, `upgradeHook`, alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits. ```typescript lineNumbers import { defineHook, getWorkflowMetadata } from "workflow"; @@ -151,7 +151,7 @@ export async function longRunningQueue( ### Triggering the upgrade -Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deploy pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix. +Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deployment pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix. ```typescript import { upgradeHook } from "@/workflows/long-running-queue"; @@ -167,33 +167,33 @@ export async function POST(req: Request) { } ``` -To upgrade a fleet of runs after a deploy, list active runs (e.g. from a tracking store) and call this endpoint for each. +To upgrade a fleet of runs after a deployment, list active runs from a tracking store and call this endpoint for each run. ## How it works -1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up — so any shipped fix applies starting from that respawn. Both methods rely on this. -2. **`start()` runs directly from the workflow body.** In v5, [`start()`](/docs/api-reference/workflow-api/start) is step-backed, so it can be called from a workflow function and still records a deterministic step boundary in the event log — no manual `"use step"` wrapper is required. +1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up, so any shipped fix applies starting from that respawn. Both methods rely on this. +2. **`start()` runs directly from the workflow body.** In v5, [`start()`](/docs/api-reference/workflow-api/start) is step-backed, so it can be called from a workflow function and still records a deterministic step boundary in the event log, so no manual `"use step"` wrapper is required. 3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself. 4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering. -5. **Method 1 vs Method 2 is just where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting — there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run. +5. **Method 1 vs Method 2 is only where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting; there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run. ## Adapting to your use case -- **Combine with a sleep.** Race the hook against `sleep()` so iterations also tick on a timer: `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives. -- **Stateless successors.** If the next iteration doesn't need the previous state (e.g. a pure event router), call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing. -- **Persist state externally.** If state needs to be readable from outside the workflow (dashboards, debugging, recovery), write it to a database in a step before spawning the next run. -- **Track the active runId externally.** Whatever resumes the hook needs to know the current run. Capture the `runId` returned by `start()` and write it to a KV/database keyed by a stable session identifier (in a step) so resumers always look up the latest one. +- **Combine with a sleep**: Race the hook against `sleep()` so iterations also tick on a timer. `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives. +- **Use stateless successors**: If the next iteration doesn't need the previous state, such as for a pure event router, call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing. +- **Persist state externally**: If state needs to be readable from outside the workflow for dashboards, debugging, or recovery, write it to a database in a step before spawning the next run. +- **Track the active `runId` externally**: The system that resumes the hook needs to know the current run. Capture the `runId` returned by `start()` and write it to a database keyed by a stable session identifier in a step, so resumers always look up the latest run. ## Caveats -- **Backward compatibility matters.** Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest). -- **Workflow identity is the function name + file path.** Renaming the function or moving the file across a deployment changes the workflow ID — the next iteration will fail to resolve. Treat the workflow's name and location as stable interfaces. -- **There is a tiny gap between iterations.** The current run ends as soon as `start()` returns; the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready. -- **Method 2: track active runs externally.** Because Method 2's runs are long-lived, the set of in-flight runs only changes when one starts, completes, or upgrades. Persist run IDs (and clean them up on completion or upgrade) so a rollout script can fan out reliably. After resuming `upgradeHook`, also update the tracked run ID once the new run reports back, the same way you would in Method 1. +- **Maintain backward compatibility**: Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest). +- **Keep the workflow identity stable**: The function name and file path form the workflow identity. Renaming the function or moving the file across a deployment changes the workflow ID, so the next iteration will fail to resolve. +- **Account for the gap between iterations**: The current run ends as soon as `start()` returns, and the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready. +- **Track active Method 2 runs externally**: Because Method 2's runs are long-lived, the set of in-flight runs changes only when one starts, completes, or upgrades. Persist run IDs and clean them up on completion or upgrade so a rollout script can fan out reliably. After resuming `upgradeHook`, update the tracked run ID once the new run reports back, as you would in Method 1. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) — spawn the successor on the newest deployment -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspend the workflow until an external event resumes it -- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — exposes `workflowRunId` for per-run hook tokens +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest): Spawns the successor on the newest deployment. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Suspends the workflow until an external event resumes it. +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Exposes `workflowRunId` for per-run hook tokens. diff --git a/docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx b/docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx index 11d22bb02e..de1fbf7982 100644 --- a/docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx +++ b/docs/content/docs/v5/cookbook/agent-patterns/agent-cancellation.mdx @@ -1,15 +1,15 @@ --- title: Agent Cancellation -description: Cancel a running agent from the outside using AbortSignal — a hook fires the abort, the agent step bails out of the model stream, and the client gets a clean stop notification. +description: Cancel a running agent from the outside using AbortSignal. A hook fires the abort, the agent step bails out of the model stream, and the client gets a clean stop notification. type: guide summary: Cancel a running agent cooperatively with AbortController. A stop hook fires controller.abort(), the signal propagates into the agent step to cancel the model stream, and a data-stopped part is emitted to streaming clients before the workflow returns. --- -Cancel a running agent from the outside — for example, a "Stop" button in a chat UI, an admin cancellation endpoint, or a timeout fallback. +Cancel a running agent from the outside through a **Stop** button in a chat user interface (UI), an admin cancellation endpoint, or a timeout fallback. This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The cancellation patterns here (`run.cancel()`, stop-signal hook + `Promise.race`, `AbortController`) apply to either API. @@ -17,7 +17,7 @@ This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's ## Pattern -Create an `AbortController` in the workflow and race the agent (passing its signal) against a stop hook. When the hook fires, `controller.abort()` is called — the signal propagates into the agent step and cancels the underlying model stream. Before returning, a `data-stopped` part is written to the stream so any streaming clients can render a clean end state. +Create an `AbortController` in the workflow and race the agent (passing its signal) against a stop hook. When the hook fires, `controller.abort()` is called: the signal propagates into the agent step and cancels the underlying model stream. Before returning, a `data-stopped` part is written to the stream so any streaming clients can render a clean end state. ```typescript lineNumbers import { DurableAgent } from "@workflow/ai/agent"; @@ -98,7 +98,7 @@ export async function stoppableAgent(messages: ModelMessage[]) { } ``` -### API Route to Trigger Stop +### API route to trigger stop ```typescript lineNumbers import { stopHook } from "@/workflows/stoppable-agent"; @@ -118,7 +118,7 @@ export async function POST( } ``` -### Client Stop Button +### Client stop button ```tsx lineNumbers "use client"; @@ -142,23 +142,23 @@ export function StopButton({ runId }: { runId: string }) { ## How it works -1. An `AbortController` is created at the start of the workflow -2. A hook is created with token `stop:${workflowRunId}` -3. `Promise.race` runs the agent stream and the stop hook concurrently -4. The agent receives `controller.signal` — when aborted, the underlying model stream is cancelled -5. When the stop API resumes the hook, `controller.abort()` is called — the race resolves and the workflow exits -6. `emitStopSignal` writes a `data-stopped` part to the stream so the client renders a clean stop state +1. The workflow creates an `AbortController` when it starts. +2. The workflow creates a hook with the token `stop:${workflowRunId}`. +3. `Promise.race` runs the agent stream and the stop hook concurrently. +4. The agent receives `controller.signal`. When aborted, the signal cancels the underlying model stream. +5. When the stop API resumes the hook, the workflow calls `controller.abort()`, resolves the race, and exits. +6. `emitStopSignal` writes a `data-stopped` part to the stream so the client renders a clean stop state. ## Adapting this -* **Add a timeout** — race a third `sleep()` promise to auto-stop after a deadline -* **Audit logging** — include a `reason` field in the stop schema to record who stopped and why -* **Cross-process** — the hook token is deterministic, so any process can call `stopHook.resume()` with the run ID -* **Step limits** — combine with `maxSteps` on the agent to cap execution even without manual stop +- **Add a timeout**: Race a third `sleep()` promise to stop automatically after a deadline. +- **Audit logging**: Include a `reason` field in the stop schema to record who stopped the agent and why. +- **Cross-process**: The hook token is deterministic, so any process can call `stopHook.resume()` with the run ID. +- **Step limits**: Combine the pattern with `maxSteps` on the agent to cap execution without a manual stop. ## Key APIs -* [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook for the stop signal -* [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — access the run ID for deterministic hook tokens -* [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream output and the stop notification to the client -* [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent that respects the abort signal (replaces `DurableAgent`) +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a type-safe hook for the stop signal. +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata): Provides the run ID for deterministic hook tokens. +- [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams output and the stop notification to the client. +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): AI SDK's durable agent that respects the abort signal and replaces `DurableAgent`. diff --git a/docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx b/docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx index 582ccc3ca3..1f0933b4c4 100644 --- a/docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx +++ b/docs/content/docs/v5/cookbook/agent-patterns/human-in-the-loop.mdx @@ -13,14 +13,14 @@ summary: Use defineHook with the tool call ID to suspend an agent for human appr This recipe uses the deprecated `DurableAgent` API. For new agents, use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) and follow the [migration guide](https://ai-sdk.dev/v7/docs/agents/workflow-agent#migrating-from-durableagent). The human-in-the-loop pattern here (hooks, `Promise.race`, approval gating) applies to either API. -Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds. +Use this pattern when an AI agent needs human confirmation before performing a consequential action like booking, purchasing, or publishing. The workflow suspends without consuming resources until the human responds through a user interface (UI) or API. ## When to use this - Booking confirmations where users must approve before charges are made - Content publishing gates where an editor must sign off -- Any agent action where the cost of getting it wrong justifies a human check -- Actions with side effects that can't be easily undone +- Agent actions where the cost of an error justifies a human check +- Actions with side effects that are difficult to reverse ## Pattern @@ -67,7 +67,7 @@ async function confirmBooking({ flightId, passenger }: { } // Stream a custom data part so the client can render the approval UI. -// This MUST run before the hook suspends the workflow — otherwise +// This MUST run before the hook suspends the workflow, otherwise // the tool-invocation won't appear in the stream until the tool returns, // and the client would have no way to show approval buttons. async function emitApprovalRequest(details: { @@ -107,7 +107,7 @@ async function emitApprovalResolved(details: { } } -// No "use step" — hooks are workflow-level primitives +// No "use step": hooks are workflow-level primitives async function requestBookingApproval( { flightId, passenger, price }: { flightId: string; @@ -238,26 +238,26 @@ const approvalResult = messages ## How it works -1. **`defineHook()` with schema** — creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it. -2. **`toolCallId` as token** — the approval tool uses the tool call ID as the hook token, naturally linking the hook to the specific tool invocation. -3. **`emitApprovalRequest` step** — writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this, the client would never see the approval controls because tool invocations don't stream until the tool returns. -4. **No `"use step"` on the approval tool** — the tool runs at the workflow level because `defineHook().create()` is a workflow primitive. It calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, `confirmBooking`) for I/O. -5. **`Promise.race` with sleep** — the approval races against a durable timeout. If nobody responds, the workflow continues with an expiration message. -6. **`emitApprovalResolved` step** — writes the outcome to the stream so the client can update the card immediately, without waiting for the tool-invocation result. +1. **`defineHook()` with schema**: Creates a typed hook with Zod validation. The approval payload is validated before the workflow receives it. +2. **`toolCallId` as token**: Uses the tool call ID as the hook token, linking the hook to the specific tool invocation. +3. **`emitApprovalRequest` step**: Writes a `data-approval-needed` custom data part to the stream *before* the hook suspends. Without this step, the client wouldn't see the approval controls because tool invocations don't stream until the tool returns. +4. **No `"use step"` on the approval tool**: Runs the tool at the workflow level because `defineHook().create()` is a workflow primitive. The tool calls step functions (`emitApprovalRequest`, `emitApprovalResolved`, and `confirmBooking`) for I/O. +5. **`Promise.race` with sleep**: Races the approval against a durable timeout. If nobody responds, the workflow continues with an expiration message. +6. **`emitApprovalResolved` step**: Writes the outcome to the stream so the client can update the card immediately without waiting for the tool-invocation result. ## Adapting to your use case -- **Change the approval schema** — add fields like `reason`, `amount`, `reviewerEmail` to match your domain. -- **Multiple approval gates** — the pattern works for any number of tools. Each tool creates its own hook with its own `toolCallId`. -- **Escalation** — if the first approver doesn't respond, use `sleep()` + another hook to escalate to a backup reviewer. -- **Adjust timeout** — use `"24h"` for production, shorter durations for demos. -- **Workflow-level vs step tools** — tools that use `sleep()`, `defineHook()`, or other workflow primitives must NOT use `"use step"`. Tools with only I/O (API calls, DB queries) should use `"use step"` for retries. +- **Change the approval schema**: Add fields such as `reason`, `amount`, and `reviewerEmail` to match your domain. +- **Multiple approval gates**: Apply the pattern to any number of tools. Each tool creates its own hook with its own `toolCallId`. +- **Escalation**: If the first approver doesn't respond, use `sleep()` and another hook to escalate to a backup reviewer. +- **Adjust the timeout**: Use `"24h"` for production and shorter durations for demos. +- **Workflow-level versus step tools**: Tools that use `sleep()`, `defineHook()`, or other workflow primitives must not use `"use step"`. Tools with only I/O, such as API calls and database queries, should use `"use step"` for retries. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) — declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — declares step functions with retries -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — type-safe hook with schema validation -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timeout for approval expiry -- [`getWritable()`](/docs/api-reference/workflow/get-writable) — stream custom data parts from steps -- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) — AI SDK's durable agent (replaces `DurableAgent`) +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with retries. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Defines a type-safe hook with schema validation. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides a durable timeout for approval expiration. +- [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams custom data parts from steps. +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Provides AI SDK's durable agent and replaces `DurableAgent`. diff --git a/docs/content/docs/v5/cookbook/common-patterns/batching.mdx b/docs/content/docs/v5/cookbook/common-patterns/batching.mdx index 962c62dcaa..d255a183df 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/batching.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/batching.mdx @@ -13,7 +13,7 @@ Use batching when you need to process a large list of items in parallel while co ## When to use this -- Bulk data imports (contacts, orders, products from a CSV) +- Bulk data imports (contacts, orders, or products from a comma-separated values (CSV) file) - Processing hundreds or thousands of items against external APIs - Calling rate-limited APIs where you need to control concurrency - Any fan-out where you want failure isolation between groups @@ -21,7 +21,7 @@ Use batching when you need to process a large list of items in parallel while co ## How it works 1. Records are split into fixed-size batches. -2. Each batch runs in parallel via `Promise.allSettled` — failures in one record don't affect others. +2. Each batch runs in parallel through `Promise.allSettled`, so failures in one record don't affect others. 3. A `sleep()` between batches paces requests to avoid overloading downstream services. 4. After all batches, a summary is returned with succeeded/failed counts. @@ -45,7 +45,7 @@ export async function batchImport(records: Record[], batchSize: number) { for (let i = 0; i < records.length; i += batchSize) { const batch = records.slice(i, i + batchSize); - // Run batch in parallel — failures are isolated per record + // Run batch in parallel: failures are isolated per record const outcomes = await Promise.allSettled( // [!code highlight] batch.map((record) => processRecord(record)) ); @@ -89,21 +89,21 @@ async function processRecord(record: Record): Promise { ## Adapting to your use case -- Replace the `Record` type with your actual data shape (orders, images, products, etc.). -- Replace `processRecord()` with your real import logic — DB upserts, API calls, file processing. +- Replace the `Record` type with your actual data shape, such as orders, images, or products. +- Replace `processRecord()` with your import logic, such as database upserts, API calls, or file processing. - Tune `batchSize` and the `sleep()` duration to match your downstream rate limits. -- Add or remove tracking as needed — the pattern works with any item type. +- Add or remove tracking as needed; the pattern works with any item type. ## Tips -- **Use `Promise.allSettled` over `Promise.all`** when you want to continue even if some items fail. `Promise.all` rejects on the first failure; `allSettled` waits for everything and tells you what failed. -- **Tune batch size to your downstream API limits.** If the API allows 10 concurrent requests, use `batchSize: 10`. -- **Add pacing with `sleep()`** between batches to respect rate limits. The sleep is durable — it survives cold starts. -- **Each `processRecord` call is an independent step.** If one fails, it retries up to 3 times without affecting other items in the batch. +- **Use `Promise.allSettled` instead of `Promise.all`**: Use this pattern when you want to continue even if some items fail. `Promise.all` rejects on the first failure, while `allSettled` waits for everything and identifies failures. +- **Tune batch size to your downstream API limits**: If the API allows 10 concurrent requests, use `batchSize: 10`. +- **Add pacing with `sleep()`**: Add a delay between batches to respect rate limits. The sleep is durable and survives cold starts. +- **Treat each `processRecord` call as an independent step**: If one call fails, it retries up to three times without affecting other items in the batch. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) -- marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) -- marks functions that run with full Node.js access -- [`sleep()`](/docs/api-reference/workflow/sleep) -- pacing delay between batches -- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) -- runs items in parallel, isolating failures +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions that run with full Node.js access. +- [`sleep()`](/docs/api-reference/workflow/sleep): Adds a pacing delay between batches. +- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled): Runs items in parallel and isolates failures. diff --git a/docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx b/docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx index b62d5bfb5c..a72b09fa43 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/idempotency.mdx @@ -6,7 +6,7 @@ summary: Use step IDs for retry-safe external calls, and use deterministic hook --- Use idempotency when a retry or duplicate request should not repeat the underlying work. In Workflow, there are two common patterns: use the step ID for retry-safe external calls, and use hook tokens to coordinate duplicate workflow starts. @@ -82,14 +82,14 @@ export async function POST(request: Request) { } ``` -The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work — awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration. +The workflow should create the deterministic hook and check `await hook.getConflict()` before duplicate-sensitive work: awaiting `getConflict()` suspends the workflow to commit the hook registration and resolves with the conflicting run when another active run already owns the token (or `null` once the hook is registered). See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for the full pattern, including how to steer an active run with `resumeHook()` and how to handle the current race between `start()` and hook registration. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access -- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) -- provides the deterministic `stepId` for idempotency keys -- [`createHook()`](/docs/api-reference/workflow/create-hook) -- creates a hook with an optional deterministic token -- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) -- finds the Hook that owns a token -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) -- resumes the active hook when the duplicate request carries data -- [`start()`](/docs/api-reference/workflow-api/start) -- starts a new workflow run +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with full Node.js access. +- [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata): Provides the deterministic `stepId` for idempotency keys. +- [`createHook()`](/docs/api-reference/workflow/create-hook): Creates a hook with an optional deterministic token. +- [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token): Finds the hook that owns a token. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): Resumes the active hook when the duplicate request carries data. +- [`start()`](/docs/api-reference/workflow-api/start): Starts a new workflow run. diff --git a/docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx b/docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx index fa6a673ecd..92726125d3 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/rate-limiting.mdx @@ -67,7 +67,7 @@ async function upsertToWarehouse(contactId: string, contact: unknown) { } ``` -## Pattern: Exponential backoff +## Pattern: exponential backoff Use `getStepMetadata()` to access the current attempt number and calculate increasing delays: @@ -90,7 +90,7 @@ async function callFlakeyApi(endpoint: string) { } ``` -## Pattern: Circuit breaker with sleep +## Pattern: circuit breaker with sleep When a dependency is completely down, stop hitting it for a cooldown period using `sleep()`, then probe with a single test request: @@ -138,7 +138,7 @@ async function callService(requestNum: number): Promise { } ``` -## Pattern: Custom max retries +## Pattern: custom max retries Override the default retry count (3) for steps that need more or fewer attempts: diff --git a/docs/content/docs/v5/cookbook/common-patterns/saga.mdx b/docs/content/docs/v5/cookbook/common-patterns/saga.mdx index 834816c961..9abe7390a6 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/saga.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/saga.mdx @@ -20,8 +20,8 @@ Use the saga pattern when a business transaction spans multiple services and you ## How it works 1. Each forward step does work and registers a compensation function. -2. If any step throws `FatalError`, the catch block runs compensations in reverse (LIFO) order to restore consistency. -3. Regular errors are retried automatically (up to 3x by default). Use `FatalError` only for permanent failures where retrying won't help. +2. If any step throws `FatalError`, the catch block runs compensations in reverse, or last in, first out (LIFO), order to restore consistency. +3. Regular errors are retried automatically, up to three times by default. Use `FatalError` only for permanent failures where retrying won't help. ## Pattern @@ -53,7 +53,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) const entitlementId = await provisionSeats(accountId, seats); compensations.push(() => deprovisionSeats(accountId, entitlementId)); // [!code highlight] - // No compensation — notifications are fire-and-forget + // No compensation: notifications are fire-and-forget await sendConfirmation(accountId, invoiceId, entitlementId); return { status: "completed" }; @@ -70,7 +70,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) ### Step functions -Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent — safe to call multiple times if the workflow restarts mid-rollback. +Each step is a `"use step"` function with full Node.js access (fetch, fs, npm packages). Forward steps do the work and throw `FatalError` on permanent failure; compensation steps undo it and must be idempotent: safe to call multiple times if the workflow restarts mid-rollback. ```typescript import { FatalError } from "workflow"; @@ -122,7 +122,7 @@ async function sendConfirmation( }); } -// Compensation steps — must be idempotent +// Compensation steps: must be idempotent async function releaseSeats(accountId: string, reservationId: string): Promise { "use step"; @@ -151,7 +151,7 @@ async function deprovisionSeats(accountId: string, entitlementId: string): Promi ### Streaming step progress (optional) -Use `getWritable()` to stream progress events to a UI so users can see each step execute in real time. +Use `getWritable()` to stream progress events to a user interface (UI) so users can see each step execute in real time. ```typescript import { FatalError } from "workflow"; @@ -204,7 +204,7 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) compensations.push({ name: "Deprovision Seats", execute: () => deprovisionSeats(accountId, entitlementId) }); await emit({ type: "step_done", step: "Provision Seats", detail: entitlementId }); - // No compensation — notifications are fire-and-forget + // No compensation: notifications are fire-and-forget await emit({ type: "step_start", step: "Send Confirmation" }); await sendConfirmation(accountId, invoiceId, entitlementId); await emit({ type: "step_done", step: "Send Confirmation", detail: "sent" }); @@ -231,21 +231,21 @@ export async function subscriptionUpgradeSaga(accountId: string, seats: number) ## Adapting to your use case - Replace the step functions with real API calls. Each `"use step"` function has full Node.js access. -- Add or remove steps as needed — the pattern scales to any number of steps. -- Make compensations idempotent — they may be retried if the workflow restarts mid-rollback. -- The `emit()` calls and `SagaEvent` type are optional — remove them if you don't need real-time UI progress. +- Add or remove steps as needed; the pattern scales to any number of steps. +- Make compensations idempotent, since they may be retried if the workflow restarts mid-rollback. +- The `emit()` calls and `SagaEvent` type are optional; remove them if you don't need real-time UI progress. ## Tips -- **Use `FatalError` for permanent failures.** Regular errors trigger automatic retries (up to 3 by default). Throw `FatalError` when retrying won't help (e.g., insufficient funds, invalid input). -- **Make compensations idempotent.** If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again. -- **Compensation steps are also `"use step"` functions.** This makes them durable — if the workflow restarts mid-rollback, it resumes where it left off. -- **Capture values in closures carefully.** Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state. -- **Notifications don't need compensations.** Fire-and-forget steps like sending emails or Slack messages typically don't register a compensation. +- **Use `FatalError` for permanent failures**: Regular errors trigger automatic retries, up to three times by default. Throw `FatalError` when retrying won't help, such as for insufficient funds or invalid input. +- **Make compensations idempotent**: If a compensation step is retried, it should produce the same result. Check whether the resource was already released before releasing it again. +- **Use `"use step"` functions for compensation steps**: This makes them durable. If the workflow restarts during rollback, it resumes where it left off. +- **Capture values in closures carefully**: Use block-scoped variables or copy values before pushing compensations to avoid referencing stale state. +- **Skip compensations for notifications**: Fire-and-forget steps such as sending emails or Slack messages typically don't register a compensation. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions) -- declares the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) -- declares step functions with full Node.js access -- [`FatalError`](/docs/api-reference/workflow/fatal-error) -- non-retryable error that triggers compensation -- [`getWritable()`](/docs/api-reference/workflow/get-writable) -- streams data from workflows for real-time UI updates +- [`"use workflow"`](/docs/foundations/workflows-and-steps#workflow-functions): Declares the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): Declares step functions with full Node.js access. +- [`FatalError`](/docs/api-reference/workflow/fatal-error): Represents a non-retryable error that triggers compensation. +- [`getWritable()`](/docs/api-reference/workflow/get-writable): Streams data from workflows for real-time UI updates. diff --git a/docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx b/docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx index 41e16ce44d..c749068342 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/scheduling.mdx @@ -6,10 +6,10 @@ summary: Schedule future actions with durable sleep that survives cold starts, a --- -Workflow's `sleep()` is durable — it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences. +Workflow's `sleep()` is durable: it survives cold starts, restarts, and deployments. Combined with `defineHook()` and `Promise.race()`, it becomes the foundation for interruptible scheduled workflows like drip campaigns, reminders, and timed sequences. Scheduled workflows are still pinned to the deployment that started them. If you are building recurring or indefinitely running schedules that should adopt newer code over time, see [Versioning](/docs/foundations/versioning) for the explicit `deploymentId: "latest"` continuation pattern. @@ -17,13 +17,13 @@ Scheduled workflows are still pinned to the deployment that started them. If you ## When to use this -- Sending emails on a schedule (drip campaigns, onboarding sequences, reminders) -- Waiting for a deadline but allowing early cancellation -- Any pattern where "do X, wait N hours, then do Y" needs to be both reliable and interruptible +- Sending emails on a schedule, such as drip campaigns, onboarding sequences, or reminders +- Waiting for a deadline while allowing early cancellation +- Reliable, interruptible patterns that perform one action, wait for a specified time, and then perform another action ## Drip campaign with cancellation -A drip campaign sends emails at intervals, sleeping between each. Each sleep races against a cancellation hook — if an external event fires the hook (e.g. user converts, unsubscribes), the campaign stops immediately. +A drip campaign sends emails at intervals, sleeping between each message. Each sleep races against a cancellation hook. If an external event fires the hook, such as when a user converts or unsubscribes, the campaign stops immediately. ```typescript import { defineHook, sleep } from "workflow"; @@ -105,10 +105,10 @@ export async function POST(req: Request) { ## How it works -1. **Durable sleep** — `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires. -2. **Hook creation** — `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token. -3. **Race** — `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first. -4. **Fresh hooks per window** — after a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token. +1. **Durable sleep**: `sleep("2d")` persists through restarts at zero compute cost. The workflow resumes when the timer fires. +2. **Hook creation**: `cancelDrip.create({ token })` registers a hook that resolves when any external system calls `.resume()` with the same token. +3. **Race**: `Promise.race([sleep(...), hook])` blocks until either the timer fires or the hook is resumed, whichever comes first. +4. **Fresh hooks per window**: After a sleep completes normally, the previous hook instance is consumed. A new `.create()` call registers a fresh hook for the next sleep window, reusing the same token. Deterministic hook tokens can also serve as the idempotency point for scheduled runs. If duplicate schedule starts would send duplicate campaigns or reminders, create a hook with a token derived from the campaign key near the beginning of the workflow and route retries through that hook. If two scheduled starts race, the duplicate run can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the duplicate can defer to it. See [Idempotency](/docs/foundations/idempotency). @@ -116,22 +116,22 @@ Deterministic hook tokens can also serve as the idempotency point for scheduled ## Adapting to your use case -- **Change durations** — replace `"2d"` with any duration string (`"1h"`, `"7d"`, `"30m"`) or a `Date` object for absolute times. -- **Add more steps** — the pattern scales to any number of email-then-sleep pairs. -- **Snooze instead of cancel** — resolve the hook with a `snooze` payload and sleep again: `sleep(new Date(Date.now() + payload.snoozeMs))`. -- **Timeout any operation** — the same `Promise.race(sleep, work)` pattern works for adding deadlines to slow steps. -- **Real providers** — swap the `sendEmail` step body for Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access. +- **Change durations**: Replace `"2d"` with any duration string (`"1h"`, `"7d"`, or `"30m"`) or a `Date` object for absolute times. +- **Add more steps**: The pattern scales to any number of email-then-sleep pairs. +- **Snooze instead of cancel**: Resolve the hook with a `snooze` payload and sleep again with `sleep(new Date(Date.now() + payload.snoozeMs))`. +- **Set a timeout for any operation**: Use the same `Promise.race(sleep, work)` pattern to add deadlines to slow steps. +- **Use production providers**: Replace the `sendEmail` step body with Resend, Postmark, or any HTTP API. The `"use step"` function has full Node.js access. ## Tips -- **`sleep()` accepts** duration strings (`"1d"`, `"2h"`, `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time. -- **Durable means durable.** A `sleep("7d")` workflow costs nothing while sleeping — no compute, no memory. -- **Use `sleep()` in workflow context only.** Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step. +- **Pass supported values to `sleep()`**: Use duration strings (`"1d"`, `"2h"`, or `"30s"`), milliseconds, or `Date` objects for sleeping until a specific time. +- **Sleeping consumes no compute or memory**: A workflow waiting on `sleep("7d")` consumes no compute or memory while sleeping. +- **Use `sleep()` only in workflow context**: Step functions cannot call `sleep()` directly. If a step needs a delay, use `setTimeout` inside the step. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions that run with full Node.js access -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost) -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — creates a typed hook that external systems can fire -- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — races sleep against hooks for interruptible waits +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions that run with full Node.js access. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides a durable wait that survives restarts with zero compute cost. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): Creates a typed hook that external systems can fire. +- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race): Races sleep against hooks for interruptible waits. diff --git a/docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx b/docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx index 639857c991..6f90a8475d 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/sequential-and-parallel.mdx @@ -1,8 +1,8 @@ --- title: Sequential & Parallel Execution -description: Compose steps with familiar async/await patterns — sequential await, Promise.all, and Promise.race. +description: Compose steps with familiar async/await patterns, sequential await, Promise.all, and Promise.race. type: guide -summary: Workflows are just async functions, so all the standard composition primitives (await, Promise.all, Promise.race) apply unchanged — including racing webhooks against durable sleeps. +summary: Workflows are plain async functions, so all the standard composition primitives (await, Promise.all, Promise.race) apply unchanged, including racing webhooks against durable sleeps. related: - /docs/foundations/workflows-and-steps - /cookbook/common-patterns/timeouts @@ -10,17 +10,17 @@ related: --- -Workflows are written in plain async/await — there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also just promises. +Workflows are written in plain async/await: there's no new control-flow API to learn. Sequential awaits chain steps that depend on each other, `Promise.all` runs independent steps in parallel, and `Promise.race` returns whichever finishes first. These compose with workflow primitives like [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) since those are also promises. ## When to use this -- **Pipelines** — each step depends on the previous step's output (validate → process → store) -- **Independent fan-out** — fetch multiple resources or perform multiple actions that don't depend on each other -- **Race conditions** — return as soon as one of N operations completes (timeout, first-responder, deadline) -- **Mixing primitives** — running steps, sleeps, and webhooks side-by-side in the same control-flow expression +- **Pipelines**: Each step depends on the previous step's output (validate → process → store). +- **Independent fan-out**: Fetch multiple resources or perform multiple actions that don't depend on each other. +- **Race conditions**: Return as soon as one operation completes, such as for a timeout, first responder, or deadline. +- **Mixing primitives**: Run steps, sleeps, and webhooks side by side in the same control-flow expression. ## Pattern @@ -68,7 +68,7 @@ export async function fetchUserData(userId: string) { ### Race with `Promise.race` -`Promise.race` resolves as soon as the first promise settles. Since [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) return promises, they compose naturally — for example, waiting for a webhook callback with a deadline: +`Promise.race` resolves as soon as the first promise settles. Since [`sleep()`](/docs/api-reference/workflow/sleep) and [`createWebhook()`](/docs/api-reference/workflow/create-webhook) return promises, they compose naturally. For example, waiting for a webhook callback with a deadline: ```typescript lineNumbers import { sleep, createWebhook } from "workflow"; @@ -90,11 +90,11 @@ export async function runExternalTask(userId: string) { } ``` -For racing operations against deadlines specifically (timeouts), see the dedicated [Timeouts](/cookbook/common-patterns/timeouts) recipe — it covers result discrimination, `FatalError` semantics, and the "loser keeps running" caveat. +For racing operations against deadlines specifically (timeouts), see the dedicated [Timeouts](/cookbook/common-patterns/timeouts) recipe, which covers result discrimination, `FatalError` semantics, and the "loser keeps running" caveat. ### Combining sequential, parallel, and durable primitives -Most real workflows combine all three. Here's a simplified version of the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator) — sequential card generation, parallel RSVP fan-out, non-blocking webhook collection, and a durable sleep until the birthday: +Most workflows combine all three patterns. The following simplified version of the [birthday card generator demo](https://github.com/vercel/workflow-examples/tree/main/birthday-card-generator) uses sequential card generation, parallel invitation-response fan-out, non-blocking webhook collection, and a durable sleep until the birthday: ```typescript lineNumbers import { createWebhook, sleep, type Webhook } from "workflow"; @@ -136,24 +136,24 @@ export async function birthdayWorkflow( ## How it works -1. **`await` is durable.** When the workflow awaits a step, the runtime persists the step's input, suspends the workflow, runs the step, and replays the workflow with the step's result on resume. The same applies to `sleep()` and `createWebhook()`. -2. **`Promise.all` runs steps concurrently.** Each promise in the array is suspended on its own and the workflow resumes only when all have settled. Failures propagate — if any promise rejects, the whole `Promise.all` rejects. -3. **`Promise.race` resolves on the first settle.** The losing promises keep running in the background but their results are discarded by the workflow. -4. **All primitives are promises.** `sleep("1 day")` and `createWebhook()` return promises, so they compose with `Promise.all` / `Promise.race` exactly like steps do — this is what makes patterns like "race a webhook against a 24-hour deadline" a one-liner. +1. **`await` is durable**: When the workflow awaits a step, the runtime persists the step's input, suspends the workflow, runs the step, and replays the workflow with the step's result on resume. The same applies to `sleep()` and `createWebhook()`. +2. **`Promise.all` runs steps concurrently**: Each promise in the array is suspended on its own, and the workflow resumes only when all have settled. If any promise rejects, the whole `Promise.all` rejects. +3. **`Promise.race` resolves when the first promise settles**: The losing promises keep running in the background, but the workflow discards their results. +4. **All primitives are promises**: `sleep("1 day")` and `createWebhook()` return promises, so they compose with `Promise.all` and `Promise.race` like steps do. This behavior enables patterns such as racing a webhook against a 24-hour deadline. ## Adapting to your use case -- **Replace `Promise.all` with `Promise.allSettled`** when partial failures should not abort the rest. You'll get an array of `{ status, value | reason }` instead of throwing on the first rejection. -- **Bound the parallelism** — `Promise.all` over 1000 items will fan out 1000 concurrent steps. If your downstream APIs can't handle that, batch the array into chunks (see [Batching](/cookbook/common-patterns/batching)). -- **Add a deadline to any race** — pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts). -- **Mix steps and hooks in a race** — wait for an external signal *or* a deadline *or* a step result, all in the same `Promise.race`. The first one to resolve wins. +- **Replace `Promise.all` with `Promise.allSettled`**: Use this option when partial failures shouldn't abort the remaining operations. You'll get an array of `{ status, value | reason }` instead of an error on the first rejection. +- **Bound the parallelism**: `Promise.all` over 1,000 items will fan out 1,000 concurrent steps. If your downstream APIs can't handle that, batch the array into chunks (see [Batching](/cookbook/common-patterns/batching)). +- **Add a deadline to any race**: Pair the operation with `sleep("30s").then(() => "timeout" as const)` and check the discriminated result. See [Timeouts](/cookbook/common-patterns/timeouts). +- **Mix steps and hooks in a race**: Wait for an external signal, a deadline, or a step result in the same `Promise.race`. The first promise to resolve wins. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable sleep that survives restarts -- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — webhook URL the workflow can race against -- [`Promise.all()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all) — wait for all promises -- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — wait for the first to settle -- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) — wait for all, including failures +- [`"use workflow"`](/docs/foundations/workflows-and-steps): Marks the orchestrator function. +- [`"use step"`](/docs/foundations/workflows-and-steps): Marks functions with full Node.js access. +- [`sleep()`](/docs/api-reference/workflow/sleep): Provides durable sleep that survives restarts. +- [`createWebhook()`](/docs/api-reference/workflow/create-webhook): Provides a webhook URL that the workflow can race against. +- [`Promise.all()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all): Waits for all promises. +- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race): Waits for the first promise to settle. +- [`Promise.allSettled()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled): Waits for all promises, including failures. diff --git a/docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx b/docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx index 09777b3190..f93f05b830 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/timeouts.mdx @@ -2,7 +2,7 @@ title: Timeouts description: Add deadlines to slow operations by racing them against a durable sleep. type: guide -summary: Use `Promise.race` with `sleep()` to bound the time any step, hook, or webhook is allowed to take — and recover gracefully when the deadline fires first. +summary: Use `Promise.race` with `sleep()` to bound the time any step, hook, or webhook is allowed to take, and recover gracefully when the deadline fires first. related: - /docs/api-reference/workflow/sleep - /docs/foundations/hooks @@ -11,17 +11,17 @@ related: --- -A common requirement is bounding how long a workflow waits for something to finish — a slow step, an external webhook, a human approval. Race the operation against a durable `sleep()` with `Promise.race()` — whichever finishes first wins, and the loser keeps running but its result is ignored. +Workflows often need to limit how long they wait for a slow step, an external webhook, or human approval. Race the operation against a durable `sleep()` with `Promise.race()`. The first operation to finish wins, while the other keeps running and its result is ignored. ## When to use this -- **Slow steps** — bound the time spent waiting on third-party APIs, model calls, or expensive computation -- **External callbacks** — give webhooks a deadline so the workflow doesn't hang forever waiting for an event that may never arrive -- **Human approvals** — auto-decline or escalate when a hook isn't resumed within a window -- **Polling loops** — give an outer poll-until-ready loop an overall budget +- **Slow steps**: bound the time spent waiting on third-party APIs, model calls, or expensive computation +- **External callbacks**: give webhooks a deadline so the workflow doesn't hang forever waiting for an event that may never arrive +- **Human approvals**: auto-decline or escalate when a hook isn't resumed within a window +- **Polling loops**: give an outer poll-until-ready loop an overall budget ## Pattern @@ -50,7 +50,7 @@ export async function processWithTimeout(data: string) { ### Timeout on a webhook -The same pattern works for any promise — including hooks and webhooks. Here a webhook waits for an external service to call back, with a hard deadline of 7 days: +The same pattern works for any promise, including hooks and webhooks. Here a webhook waits for an external service to call back, with a hard deadline of 7 days: ```typescript lineNumbers import { sleep, createWebhook } from "workflow"; @@ -78,27 +78,27 @@ export async function waitForApproval(requestId: string) { ## How it works -1. **Durable sleep** — `sleep("30s")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires. -2. **Race** — `Promise.race([work, sleep(...)])` returns the value of whichever promise resolves first. The loser keeps running in the background but its result is ignored by the workflow. -3. **Discriminated result** — tagging the sleep branch with a sentinel value (`"timeout" as const`, `{ timedOut: true }`) lets TypeScript narrow the result and pick the right branch. -4. **Throw to fail the workflow** — inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows. +1. **Durable sleep**: `sleep("30s")` persists through restarts at zero compute cost. The workflow resumes precisely when the timer fires. +2. **Race**: `Promise.race([work, sleep(...)])` returns the value of whichever promise resolves first. The loser keeps running in the background but its result is ignored by the workflow. +3. **Discriminated result**: tagging the sleep branch with a sentinel value (`"timeout" as const`, `{ timedOut: true }`) lets TypeScript narrow the result and pick the right branch. +4. **Throw to fail the workflow**: inside a workflow function, throwing an `Error` exits the run with that error. Use `FatalError` inside steps; throw plain errors inside workflows. -**The losing operation keeps running.** `Promise.race` doesn't cancel — when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Pass an `AbortSignal` into the step to cancel it cooperatively, and use idempotency keys for non-idempotent side effects. See the [Cancellation Guide](/docs/foundations/cancellation) and [Idempotency](/docs/foundations/idempotency) for patterns. +**The losing operation keeps running.** `Promise.race` doesn't cancel: when the sleep wins, the underlying step (or model call, or HTTP request) continues to completion in the background. This is fine for idempotent reads but matters when the operation has side effects or costs money. Pass an `AbortSignal` into the step to cancel it cooperatively, and use idempotency keys for non-idempotent side effects. See the [Cancellation Guide](/docs/foundations/cancellation) and [Idempotency](/docs/foundations/idempotency) for patterns. ## Adapting to your use case -- **Different durations** — `sleep()` accepts duration strings (`"30s"`, `"5m"`, `"7 days"`), milliseconds, or `Date` objects for absolute deadlines. -- **Soft timeout (retry)** — instead of throwing, loop and retry with a fresh `Promise.race` and a backoff. -- **Soft timeout (fallback)** — return a default value when the timer wins instead of throwing: `if (result === "timeout") return cachedFallback`. -- **Combine with cancellation** — race three promises: the operation, a deadline `sleep()`, and a cancellation hook. See the [Scheduling cookbook](/cookbook/common-patterns/scheduling) for the cancellation half of this pattern. -- **Per-step deadlines** — wrap each step in its own `Promise.race` for independent budgets, or use a single outer race for an overall workflow deadline. +- **Different durations**: `sleep()` accepts duration strings (`"30s"`, `"5m"`, `"7 days"`), milliseconds, or `Date` objects for absolute deadlines. +- **Soft timeout (retry)**: instead of throwing, loop and retry with a fresh `Promise.race` and a backoff. +- **Soft timeout (fallback)**: return a default value when the timer wins instead of throwing: `if (result === "timeout") return cachedFallback`. +- **Combine with cancellation**: race three promises: the operation, a deadline `sleep()`, and a cancellation hook. See the [Scheduling cookbook](/cookbook/common-patterns/scheduling) for the cancellation half of this pattern. +- **Per-step deadlines**: wrap each step in its own `Promise.race` for independent budgets, or use a single outer race for an overall workflow deadline. ## Key APIs -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable wait (survives restarts, zero compute cost) -- [`createWebhook()`](/docs/api-reference/workflow/create-webhook) — create a webhook URL the workflow can race against -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — typed hook for in-process cancellation -- [Idempotency](/docs/foundations/idempotency) — protect side effects that may keep running after a timeout -- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) — race operations against deadlines +- [`sleep()`](/docs/api-reference/workflow/sleep): durable wait (survives restarts, zero compute cost) +- [`createWebhook()`](/docs/api-reference/workflow/create-webhook): create a webhook URL the workflow can race against +- [`defineHook()`](/docs/api-reference/workflow/define-hook): typed hook for in-process cancellation +- [Idempotency](/docs/foundations/idempotency): protect side effects that may keep running after a timeout +- [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race): race operations against deadlines diff --git a/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx b/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx index 7a5f338d8d..a983b51276 100644 --- a/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx +++ b/docs/content/docs/v5/cookbook/common-patterns/webhooks.mdx @@ -2,7 +2,7 @@ title: Webhooks & External Callbacks description: Receive HTTP callbacks from external services, process them durably, and respond inline. type: guide -summary: Create webhook endpoints that your workflow can await, process incoming requests in steps, and respond to the caller — all within durable workflow context. +summary: Create webhook endpoints that your workflow can await, process incoming requests in steps, and respond to the caller, all within durable workflow context. --- -Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or wants to fire the child off as an independent run (background spawn). For massive fan-out with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows). +Workflows can call other workflows. Choose between two composition modes depending on whether the parent needs the child's result inline (direct await) or starts the child as an independent run (background spawn). For large fan-out operations with hook-based waiting and partial-failure handling, see [Child Workflows](/cookbook/advanced/child-workflows). ## When to use this -- **Direct await** — the parent needs the child's result before continuing, and you want a single unified event log -- **Background spawn** — the parent doesn't need to wait, and you want the child to be observable as a separate run with its own `runId` +- **Direct await**: the parent needs the child's result before continuing, and you want a single unified event log +- **Background spawn**: the parent doesn't need to wait, and you want the child to be observable as a separate run with its own `runId` ## Pattern ### Direct await (flattening) -Call a child workflow with `await` and the child's steps execute inline within the parent — they appear in the parent's event log as if you'd called them directly. +Call a child workflow with `await` and the child's steps execute inline within the parent. They appear in the parent's event log as if you'd called them directly. ```typescript lineNumbers declare function sendEmail(userId: string): Promise; // @setup @@ -80,21 +80,21 @@ export async function processOrder(orderId: string) { } ``` -The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId` (e.g., via [`getRun()`](/docs/api-reference/workflow-api/get-run)). +The parent continues immediately after `start()` returns. The child runs independently and can be monitored separately using the returned `runId`, such as through [`getRun()`](/docs/api-reference/workflow-api/get-run). Each background spawn creates a separate run. If duplicate requests must route to one active child workflow, have the child create a deterministic hook token from the business key and use that hook as the idempotency point. If concurrent starts race, the losing child can detect the conflict early with `await hook.getConflict()`, which resolves with the active owner so the child can point callers at it. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). -If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments — renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures. +If you want the child workflow to run on the latest deployment rather than the current one, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) in the `start()` options. See [Versioning](/docs/foundations/versioning) for the full model. This is currently a Vercel-specific feature, and other Worlds may map the concept to their own deployment runtimes. Be aware that the child workflow's function name, file path, argument types, and return type must remain compatible across deployments. Renaming the function or changing its location will change the workflow ID, and modifying expected inputs or outputs can cause serialization failures. ## How it works -1. **Direct await flattens.** When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline — the child's steps emit into the parent's event log and share the parent's run ID. -2. **`start()` mints a new run.** The child gets its own `runId`, its own event log, and its own retry boundary. The parent only sees the `runId` returned by `start()`. -3. **`start()` can run inside workflows.** In v5, `start()` is step-backed, so it can be called directly from a workflow function and still records a deterministic step boundary in the event log. +1. **Direct await flattens the child workflow**: When a workflow function awaits another workflow function, the child's `"use workflow"` directive is treated as inline. The child's steps emit into the parent's event log and share the parent's run ID. +2. **`start()` creates a new run**: The child gets its own `runId`, event log, and retry boundary. The parent only sees the `runId` returned by `start()`. +3. **`start()` can run inside workflows**: In v5, `start()` is step-backed, so it can be called directly from a workflow function and still records a deterministic step boundary in the event log. ## Choosing between the two modes @@ -108,14 +108,14 @@ If you want the child workflow to run on the latest deployment rather than the c ## Adapting to your use case -- **Spawn many children at once** — call `start()` in a loop from the workflow. For more advanced fan-out (chunking, hook-based waiting, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe. -- **Wait for a background child to finish** — combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern. -- **Pass results back from background children** — the wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`. +- **Spawn many children at once**: call `start()` in a loop from the workflow. For more advanced fan-out (chunking, hook-based waiting, partial-failure handling), graduate to the [Child Workflows](/cookbook/advanced/child-workflows) recipe. +- **Wait for a background child to finish**: combine `start()` with a completion hook the child resumes when done. The [Child Workflows](/cookbook/advanced/child-workflows) page covers the recommended `startAndWait()` pattern. +- **Pass results back from background children**: the wrapped child resumes the parent's hook in `finally` with `{ status, value | error }`; the parent awaits the hook instead of polling `getRun().status`. ## Key APIs -- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function -- [`"use step"`](/docs/foundations/workflows-and-steps) — marks functions with full Node.js access -- [`start()`](/docs/api-reference/workflow-api/start) — spawn a child workflow as a separate run -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — retrieve a workflow run's status and return value -- [Idempotency](/docs/foundations/idempotency) — deduplicate step side effects and workflow starts +- [`"use workflow"`](/docs/foundations/workflows-and-steps): marks the orchestrator function +- [`"use step"`](/docs/foundations/workflows-and-steps): marks functions with full Node.js access +- [`start()`](/docs/api-reference/workflow-api/start): spawn a child workflow as a separate run +- [`getRun()`](/docs/api-reference/workflow-api/get-run): retrieve a workflow run's status and return value +- [Idempotency](/docs/foundations/idempotency): deduplicate step side effects and workflow starts diff --git a/docs/content/docs/v5/cookbook/index.mdx b/docs/content/docs/v5/cookbook/index.mdx index 027cf5c368..dc2ed6dae4 100644 --- a/docs/content/docs/v5/cookbook/index.mdx +++ b/docs/content/docs/v5/cookbook/index.mdx @@ -4,35 +4,35 @@ description: Best-practice workflow patterns with copy-paste code examples. type: overview --- -A curated collection of workflow patterns with clean, copy-paste code examples for real use cases. +Use these workflow patterns and copy-paste code examples to implement common use cases. -## Agent Patterns +## Agent patterns -- [**WorkflowAgent**](/cookbook/agent-patterns/durable-agent) — Build durable, resumable AI agents with AI SDK's WorkflowAgent -- [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop) — Pause an agent for human approval, then resume based on the decision -- [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation) — Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race` +- [**WorkflowAgent**](/cookbook/agent-patterns/durable-agent): Build durable, resumable AI agents with AI SDK's WorkflowAgent +- [**Human-in-the-Loop**](/cookbook/agent-patterns/human-in-the-loop): Pause an agent for human approval, then resume based on the decision +- [**Agent Cancellation**](/cookbook/agent-patterns/agent-cancellation): Stop a running agent immediately via `run.cancel()` or gracefully via a hook + `Promise.race` -## Common Patterns +## Common patterns -- [**Sequential & Parallel Execution**](/cookbook/common-patterns/sequential-and-parallel) — Compose steps with `await`, `Promise.all`, and `Promise.race` against durable sleeps and webhooks -- [**Workflow Composition**](/cookbook/common-patterns/workflow-composition) — Call workflows from other workflows by direct await or background spawn via `start()` -- [**Saga**](/cookbook/common-patterns/saga) — Coordinate multi-step transactions with automatic rollback when a step fails -- [**Batching**](/cookbook/common-patterns/batching) — Process large collections in parallel batches with failure isolation -- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting) — Handle 429 responses and transient failures with RetryableError and backoff -- [**Scheduling**](/cookbook/common-patterns/scheduling) — Use durable sleep to schedule actions minutes, hours, or weeks ahead -- [**Timeouts**](/cookbook/common-patterns/timeouts) — Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep -- [**Idempotency**](/cookbook/common-patterns/idempotency) — Ensure side effects and duplicate starts are safe to retry -- [**Webhooks**](/cookbook/common-patterns/webhooks) — Receive HTTP callbacks from external services and process them durably +- [**Sequential & Parallel Execution**](/cookbook/common-patterns/sequential-and-parallel): Compose steps with `await`, `Promise.all`, and `Promise.race` against durable sleeps and webhooks +- [**Workflow Composition**](/cookbook/common-patterns/workflow-composition): Call workflows from other workflows by direct await or background spawn via `start()` +- [**Saga**](/cookbook/common-patterns/saga): Coordinate multi-step transactions with automatic rollback when a step fails +- [**Batching**](/cookbook/common-patterns/batching): Process large collections in parallel batches with failure isolation +- [**Rate Limiting**](/cookbook/common-patterns/rate-limiting): Handle 429 responses and transient failures with RetryableError and backoff +- [**Scheduling**](/cookbook/common-patterns/scheduling): Use durable sleep to schedule actions minutes, hours, or weeks ahead +- [**Timeouts**](/cookbook/common-patterns/timeouts): Add deadlines to slow steps, hooks, and webhooks by racing them against a durable sleep +- [**Idempotency**](/cookbook/common-patterns/idempotency): Ensure side effects and duplicate starts are safe to retry +- [**Webhooks**](/cookbook/common-patterns/webhooks): Receive HTTP callbacks from external services and process them durably ## Integrations -- [**AI SDK**](/cookbook/integrations/ai-sdk) — Use streamText() directly inside a workflow for lower-level control over model calls and tool execution -- [**Chat SDK**](/cookbook/integrations/chat-sdk) — Build durable chat sessions with workflow persistence and AI SDK chat primitives -- [**Sandbox**](/cookbook/integrations/sandbox) — Orchestrate Vercel Sandbox lifecycle inside durable workflows +- [**AI SDK**](/cookbook/integrations/ai-sdk): Use streamText() directly inside a workflow for lower-level control over model calls and tool execution +- [**Chat SDK**](/cookbook/integrations/chat-sdk): Build durable chat sessions with workflow persistence and AI SDK chat primitives +- [**Sandbox**](/cookbook/integrations/sandbox): Orchestrate Vercel Sandbox lifecycle inside durable workflows ## Advanced -- [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent -- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward -- [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary -- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions +- [**Child Workflows**](/cookbook/advanced/child-workflows): Spawn and orchestrate child workflows from a parent +- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows): Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward +- [**Serializable Steps**](/cookbook/advanced/serializable-steps): Wrap non-serializable third-party objects so they cross the workflow boundary +- [**Publishing Libraries**](/cookbook/advanced/publishing-libraries): Ship npm packages that export reusable workflow functions diff --git a/docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx b/docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx index f3aa397692..4896151b56 100644 --- a/docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx +++ b/docs/content/docs/v5/cookbook/integrations/ai-sdk.mdx @@ -2,7 +2,7 @@ title: AI SDK description: Use AI SDK's streamText directly inside durable workflows when you need the raw AI SDK API or a per-turn durability boundary. type: guide -summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by WorkflowAgent. Individual tool calls and LLM calls inside a turn are not separately durable. +summary: Use streamText() inside a workflow when the durability boundary is an entire user turn, or when you need AI SDK APIs not exposed by WorkflowAgent. Individual tool calls and large language model (LLM) calls inside a turn are not separately durable. related: - /docs/ai - /docs/ai/chat-session-modeling @@ -15,27 +15,27 @@ related: text="Implement the durable AI SDK multi-turn pattern. Use `streamText`, `stepCountIs`, and `createUIMessageStreamResponse` from `ai`; `defineHook`, `getWritable`, and `getWorkflowMetadata` from `workflow`; and `start`/`getRun` from `workflow/api`. Put the model call in a `"use step"` function such as `runTurn(messages)` and pipe `result.toUIMessageStream()` to `getWritable()` with `{ preventClose: true }`. In the workflow, create one hook with `turnHook.create({ token: workflowRunId })`, loop over turns, and await the hook between user messages. Add an API route that starts a run on first message, stores/returns the run ID in `x-workflow-run-id`, resumes the hook for follow-up messages, reads from `run.getReadable({ startIndex })`, and handles stale run IDs by starting fresh. Wire the client transport to send `runId` with each request and verify first turn, follow-up turn, `/done`, and reconnect behavior." /> -[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents — unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK complements it by making the multi-turn loop durable: the conversation state, hooks, and per-turn responses survive restarts and timeouts. Note that in this pattern the durability boundary is the entire turn — individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable) below). +[AI SDK](https://ai-sdk.dev/) is Vercel's framework-agnostic TypeScript toolkit for building AI-powered apps and agents. It provides unified provider access, streaming, tool calling, structured output, and UI hooks. Workflow SDK makes the multi-turn loop durable, so the conversation state, hooks, and per-turn responses survive restarts and timeouts. In this pattern, the durability boundary is the entire turn, and individual tool calls inside a turn are **not** durable on their own (see [Pitfalls](#tools-are-not-individually-durable)). -For the full AI SDK reference (providers, `streamText`, `generateObject`, `useChat`, tool calling, etc.) see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points. +For the full AI SDK reference, including providers, `streamText`, `generateObject`, `useChat`, and tool calling, see the [AI SDK docs](https://ai-sdk.dev/docs). This page covers the Workflow-specific integration points. -For most agent use cases, prefer AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope — each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step — accepting that tool calls inside that turn are not individually durable. +For most agent use cases, prefer AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which implements the same agent loop as [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text), manages tool calling automatically, and runs tools at workflow scope: each tool can be marked `"use step"` for per-call durability and retries, or stay at workflow level to use primitives like `sleep()` and hooks. Use this page's raw `streamText()` pattern when you want the exact AI SDK API (for example `toUIMessageStream()`, `onChunk`, or `generateText`), or when the durability boundary should be an entire user turn in one step, accepting that tool calls inside that turn are not individually durable. ## When to use streamText directly Use [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) instead of `WorkflowAgent` when you need: -* **The raw AI SDK API** — `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `WorkflowAgent.stream()` -* **Per-turn durability** — wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together -* **Custom multi-turn orchestration** — manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `WorkflowAgent` +* **The raw AI SDK API**: `streamText().toUIMessageStream()`, `onChunk`, `smoothStream`, or other options that map directly to the [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) return value rather than `WorkflowAgent.stream()` +* **Per-turn durability**: wrap the entire agent response (model + tools) in a single `"use step"` function so one user turn is the atomic retry unit; useful when you want all tool calls inside a turn to re-execute together +* **Custom multi-turn orchestration**: manual hook loops, per-turn stream slicing (`sliceUntilFinish`), or other workflow patterns shown below that don't map cleanly to `WorkflowAgent` `WorkflowAgent` already supports `stopWhen`, `prepareStep`, lifecycle callbacks, structured output (`output`), per-step model switching, and [provider options](https://ai-sdk.dev/docs/ai-sdk-core/provider-options). See the [`WorkflowAgent` docs](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). ## Multi-turn pattern -One workflow run = one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run. +One workflow run represents one full conversation. The workflow suspends between turns on a hook and resumes when the next user message arrives. Conversation state, tool history, and intermediate computation all live inside the run. Because the conversation is one workflow run, it stays on the deployment that started it. If each turn should run on the latest deployment while preserving selected state or streams, see [Versioning](/docs/foundations/versioning) for the child-run continuation pattern. @@ -57,8 +57,8 @@ export const turnHook = defineHook({ // [!code highlight] schema: z.object({ message: z.string() }), }); -// `streamText` runs tool executes inside `runTurn` (a step), so tool calls -// are not individually durable — the entire turn retries together. See +// `streamText` runs tool execution inside `runTurn` (a step), so tool calls +// are not individually durable: the entire turn retries together. See // "Tools are not individually durable" below. Make side-effectful tools idempotent. async function lookupOrder({ orderId }: { orderId: string }) { const res = await fetch(`https://api.store.com/orders/${orderId}`); @@ -86,7 +86,7 @@ const TOOLS = { }, }; -// Per-turn step — streams one agent response to the durable writable // [!code highlight] +// Per-turn step: streams one agent response to the durable writable // [!code highlight] async function runTurn(messages: ModelMessage[]) { "use step"; @@ -99,7 +99,8 @@ async function runTurn(messages: ModelMessage[]) { }); const writable = getWritable(); - // preventClose keeps the durable writable open so the next turn can // write to it. Each turn still emits its own start + finish chunks. + // preventClose keeps the durable writable open so the next turn can write + // to it. Each turn still emits its own start and finish chunks. await result.toUIMessageStream().pipeTo(writable, { preventClose: true }); // [!code highlight] const response = await result.response; @@ -110,7 +111,7 @@ export async function supportWorkflow(initialMessages: ModelMessage[]) { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); - // Create the hook once, outside the loop — same token = HookConflictError // [!code highlight] + // Create the hook once, outside the loop: same token = HookConflictError // [!code highlight] const hook = turnHook.create({ token: workflowRunId }); // [!code highlight] let allMessages = initialMessages; @@ -144,7 +145,8 @@ import { convertToModelMessages, createUIMessageStreamResponse } from "ai"; import { start, getRun } from "workflow/api"; import { supportWorkflow, turnHook } from "@/workflows/support"; -// Pump the durable stream until this turn's `finish` chunk, then close // the HTTP response. The source reader is released (not cancelled) so the +// Pump the durable stream until this turn's `finish` chunk, then close the +// HTTP response. The source reader is released (not canceled) so the // workflow's durable stream keeps flowing for the next turn. function sliceUntilFinish( // [!code highlight] source: ReadableStream @@ -229,7 +231,7 @@ export async function POST(req: Request) { } catch (e: unknown) { const msg = e instanceof Error ? e.message.toLowerCase() : ""; if (!msg.includes("not found") && !msg.includes("expired")) throw e; - // Stale runId — fall through to start fresh + // Stale runId: fall through to start fresh } } @@ -303,21 +305,21 @@ export function SupportChat() { ## How it works -1. **One workflow = one conversation.** The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns. -2. **`runTurn` is the durability boundary.** Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries — individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable). -3. **Hook is created once.** `turnHook.create({ token: workflowRunId })` outside the loop — calling it twice with the same token throws `HookConflictError`. -4. **`preventClose: true`** on `pipeTo` keeps the durable writable open so the next turn can write to it. -5. **`sliceUntilFinish`** in the API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released — not cancelled — so the workflow stream keeps flowing. -6. **`startIndex: tailIndex + 1`** gives each follow-up response only the new chunks, avoiding replay of previous turns. -7. **`/done`** resumes the hook so the workflow exits cleanly, then returns a synthetic `start` + `finish` so `useChat` transitions out of "streaming". +1. **One workflow represents one conversation**: The workflow loops on a hook, keeping `allMessages`, tool history, and state alive across turns. +2. **`runTurn` is the durability boundary**: Each turn is one step. The model request and all tool calls inside it run as plain inline functions within that step. If anything throws mid-turn, the whole `runTurn` retries. Individual tool calls are not separately durable. See [Pitfalls](#tools-are-not-individually-durable). +3. **The hook is created once**: Call `turnHook.create({ token: workflowRunId })` outside the loop. Calling it twice with the same token throws `HookConflictError`. +4. **`preventClose: true` keeps the writable open**: Set this option on `pipeTo` so the next turn can write to the durable writable. +5. **`sliceUntilFinish` closes each HTTP response**: The API reads chunks until `type === "finish"`, then closes the HTTP response. The source reader is released, not canceled, so the workflow stream keeps flowing. +6. **`startIndex: tailIndex + 1` returns only new chunks**: Each follow-up response avoids replaying previous turns. +7. **`/done` exits the workflow**: The route resumes the hook so the workflow exits cleanly, then returns synthetic `start` and `finish` chunks so `useChat` transitions out of "streaming". ## Pitfalls -Non-obvious correctness details worth knowing before adapting this pattern. +Review these correctness details before adapting this pattern. ### Tools are not individually durable -`streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions) — the function just runs inline. +`streamText()` is invoked from inside `runTurn` (a `"use step"` function), and the AI SDK calls each tool by directly invoking its `execute` function in that same step. Even if a tool body has its own `"use step"` directive, that directive is a [no-op when called from another step](/docs/foundations/workflows-and-steps#step-functions): the function runs inline. The consequences: @@ -327,8 +329,8 @@ The consequences: **Mitigations:** -- Make side-effectful tool implementations idempotent — dedupe server-side on a stable key (e.g. `orderId`, an `Idempotency-Key` header, etc.). -- Or use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which runs tools at workflow scope — each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks. +- Make side-effectful tool implementations idempotent: deduplicate server-side on a stable key, such as `orderId` or an `Idempotency-Key` header. +- Or use AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent), which runs tools at workflow scope: each tool can be marked `"use step"` to become its own durable, retryable step, or stay at workflow level to use primitives like `sleep()` and hooks. ### Snapshot `tailIndex` *before* resuming the hook @@ -352,11 +354,11 @@ A `TransformStream` with `controller.terminate()` on the `finish` chunk seems li ### Release the source reader, don't cancel it -In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Cancelling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock just detaches our reader; the durable stream keeps flowing. +In `sliceUntilFinish`, use `reader.releaseLock()` in the `finally` block rather than `source.cancel()`. Canceling propagates upstream and closes the durable writable, breaking the next turn. Releasing the lock only detaches our reader; the durable stream keeps flowing. ### Handle stale `runId` gracefully -Clients can send a `runId` from a long-gone workflow (localStorage, back button, server restart). Wrap the follow-up path in a try/catch for `not found` / `expired` and fall through to the first-turn code path to start a fresh workflow. +Clients can send a `runId` from a workflow that no longer exists, such as after using local storage, navigating back, or restarting the server. Wrap the follow-up path in a `try/catch` for `not found` or `expired`, then use the first-turn code path to start a new workflow. ### Make the first turn idempotent when needed @@ -368,10 +370,10 @@ This example stores the `runId` after the first response. For strict one-session |---|---|---| | **Tool loop** | AI SDK handles via `stopWhen` | Handles internally (AI SDK–compatible options) | | **LLM call durability** | Re-executes with the parent turn | Each LLM call is a durable step | -| **Tool call durability** | Not individually durable — re-executes with the parent turn | Per tool — mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks | +| **Tool call durability** | Not individually durable: re-executes with the parent turn | Per tool: mark `"use step"` for a durable, retryable step, or keep at workflow level for `sleep()` / hooks | | **Stop conditions** | `stopWhen`, `prepareStep` | `stopWhen`, `prepareStep` | | **Structured output** | `Output.object()`, `Output.array()` | `output` (`Output.object()`, `Output.text()`) | -| **Step callbacks** | `onStepFinish`, `onChunk`, etc. | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available) | +| **Step callbacks** | `onStepFinish`, `onChunk`, and others | `onStepFinish`, `onFinish`, `onError`, `onAbort` (`onChunk` not available) | | **Setup** | Manual stream piping and turn slicing | Automatic | Use `WorkflowAgent` for most agent use cases. Use `streamText` when you need the raw AI SDK surface or a per-turn durability boundary. @@ -380,17 +382,17 @@ Use `WorkflowAgent` for most agent use cases. Use `streamText` when you need the **AI SDK** ([docs](https://ai-sdk.dev/docs)) -* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) — core streaming function; `toUIMessageStream()` pipes into the durable writable -* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable)) -* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions) — bound the agent loop inside each turn -* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response) — UI ↔ model message conversion at the API boundary -* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat) — React hook that consumes the UI message stream on the client +* [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text): core streaming function; `toUIMessageStream()` pipes into the durable writable +* [`tool()` / tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling): tools are plain async functions invoked by `streamText` inside the turn step; they are **not** individually durable in this pattern (see [Pitfalls](#tools-are-not-individually-durable)) +* [`stepCountIs()` / `stopWhen`](https://ai-sdk.dev/docs/ai-sdk-core/agents#stop-conditions): bound the agent loop inside each turn +* [`convertToModelMessages()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/convert-to-model-messages) / [`createUIMessageStreamResponse()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/create-ui-message-stream-response): UI ↔ model message conversion at the API boundary +* [`useChat()`](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat): React hook that consumes the UI message stream on the client **Workflow SDK** -* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions) — applied to `runTurn` to make each turn a durable, retryable unit -* [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for follow-up messages -* [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable stream output -* [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.getReadable({ startIndex })` for slicing per-turn streams -* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport) — passes `runId` between turns -* [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first turns and side effects +* [`"use step"`](/docs/foundations/workflows-and-steps#step-functions): applied to `runTurn` to make each turn a durable, retryable unit +* [`defineHook()`](/docs/api-reference/workflow/define-hook): suspension point for follow-up messages +* [`getWritable()`](/docs/api-reference/workflow/get-writable): resumable stream output +* [`getRun()`](/docs/api-reference/workflow-api/get-run): `run.getReadable({ startIndex })` for slicing per-turn streams +* [`WorkflowChatTransport`](/docs/api-reference/workflow-ai/workflow-chat-transport): passes `runId` between turns +* [Idempotency](/docs/foundations/idempotency): protect duplicate-sensitive first turns and side effects diff --git a/docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx b/docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx index 98f35d35f4..72f9b8ccb9 100644 --- a/docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx +++ b/docs/content/docs/v5/cookbook/integrations/chat-sdk.mdx @@ -1,8 +1,8 @@ --- title: Chat SDK -description: Make Chat SDK bot sessions durable — one workflow run per conversation thread, with hooks bridging inbound platform events into long-running agent logic. +description: Make Chat SDK bot sessions durable, with one workflow run per conversation thread and hooks bridging inbound platform events into long-running agent logic. type: guide -summary: Chat SDK normalizes Slack, Teams, Discord, Telegram and friends into one thread/message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts. +summary: Chat SDK normalizes Slack, Teams, Discord, Telegram, and similar platforms into one thread and message model. Workflow SDK gives each thread a durable run that owns multi-turn state, can sleep for hours, and survives restarts. related: - /docs/cookbook/integrations/ai-sdk - /docs/cookbook/integrations/sandbox @@ -15,13 +15,13 @@ related: text="Make this Chat SDK bot durable with Workflow SDK. Install/use `workflow`. Create one exported workflow function with "use workflow" per chat thread. Store the Chat SDK thread ID, Workflow run ID, and any serialized conversation state in the project data store. Use `defineHook()` from `workflow` for incoming turns and call `resumeHook()` from `workflow/api` from the Chat SDK webhook or message handler. Put provider calls, database writes, and outbound platform messages in "use step" helper functions. Start a new run with `start(workflowFn, [initialThreadState])` when no run exists, otherwise resume the existing hook. Use `getRun(runId)` for status, cancellation, or stream reads. Verify first message, follow-up message, restart/reconnect, duplicate webhook, and failed-send retry behavior." /> -[Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. Write the bot once, deploy to every platform. It handles webhook verification, event normalization, subscriptions, and cross-platform features like cards and modals. +[Chat SDK](https://chat-sdk.dev/) is a unified TypeScript SDK for building bots across Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. A single bot can support each platform. Chat SDK handles webhook verification, event normalization, subscriptions, and cross-platform features such as cards and modals. Workflow SDK complements it by making bot **sessions** durable. Each conversation thread maps to a long-running workflow run that: - Owns multi-turn state in the durable event log instead of Redis-by-hand bookkeeping - Can `sleep()` for hours or days waiting for a user reply, an approval, or a scheduled follow-up -- Survives deploys, cold starts, and crashes — the session picks up from the last step on replay +- Survives deploys, cold starts, and crashes: the session picks up from the last step on replay - Receives follow-up messages via hooks, so the bot stays responsive while the workflow is still running @@ -30,9 +30,9 @@ One thread mapped to one workflow run also means the thread stays on the deploym The rest of this page covers the integration pattern. For a full Slack + Next.js + Redis walkthrough, see the [Durable chat sessions guide](https://chat-sdk.dev/docs/guides/durable-chat-sessions-nextjs) on chat-sdk.dev. -## How It Fits Together +## How it fits together -Chat SDK owns the edge — webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session — state, loops, sleeps, retries. They meet at exactly two points: +Chat SDK owns the edge: webhook verification, event routing, `thread.post()` / `thread.stream()`. Workflow owns the session: state, loops, sleeps, retries. They meet at exactly two points: ```mermaid flowchart TD @@ -44,8 +44,8 @@ flowchart TD E --> F[""use step" helpers
thread.post(), thread.subscribe(), thread.setState(), …"] ``` -- **Inbound** — Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter). -- **Outbound** — the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file — adapter packages use Node-only modules that aren't available in the workflow sandbox. +- **Inbound**: Chat SDK handlers decide whether to `start(workflow, [thread, message])` or `resumeHook(runId, { message })`. The `runId` lives in Chat SDK's thread state (Redis, Postgres, or any state adapter). +- **Outbound**: the workflow calls Chat SDK APIs (`thread.post()`, `thread.subscribe()`, `thread.setState()`) from inside step functions. Never from the top level of a workflow file, since adapter packages use Node-only modules that aren't available in the workflow sandbox. ## Why Workflow + Chat SDK @@ -60,11 +60,11 @@ Workflow replaces all of that with a single durable function. The bot can: - Schedule a follow-up message 24 hours later via `sleep("24h")` - Pause on sandbox snapshot, resume when the user sends the next command (see the [Sandbox integration](/docs/cookbook/integrations/sandbox)) -Because the session *is* a workflow run, its history is recoverable from the event log — no separate message store to keep in sync. +Because the session *is* a workflow run, its history is recoverable from the event log, so there's no separate message store to keep in sync. -## The Pattern: One Thread = One Workflow Run +## The pattern: one thread = one workflow run -Three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox. +This pattern uses three files. The bot definition is separate from the workflow so adapter packages stay out of the workflow sandbox. @@ -125,7 +125,7 @@ async function postAssistantMessage( async function runTurn(text: string) { "use step"; - // Your AI SDK call, database lookup, tool loop, etc. + // Your AI SDK call, database lookup, tool loop, and other operations. return `You said: ${text}`; } @@ -157,7 +157,7 @@ export async function durableChatSession(payload: string) { if (!(await handleMessage(thread, message))) return; // Each hook resumption is one turn. The workflow stays suspended between - // messages — zero compute cost while idle. + // messages: zero compute cost while idle. while (true) { const { message: nextRaw } = await hook; // [!code highlight] const next = Message.fromJSON(nextRaw); @@ -204,7 +204,7 @@ async function startSession(thread: Thread, message: Message) { async function routeTurn(thread: Thread, message: Message) { const state = await thread.state; - // No run yet, or the previous run finished — start fresh. + // No run yet, or the previous run finished: start fresh. if (!state?.runId || !(await getRun(state.runId).exists)) { await startSession(thread, message); return; @@ -217,7 +217,7 @@ async function routeTurn(thread: Thread, message: Message) { } catch (err) { const msg = err instanceof Error ? err.message.toLowerCase() : ""; if (msg.includes("not found") || msg.includes("expired")) { - // Stale runId — start a new session rather than dropping the message. + // Stale runId: start a new session rather than dropping the message. await startSession(thread, message); return; } @@ -260,30 +260,30 @@ export async function POST( -## How It Works +## How it works -1. **Thread state stores the `runId`.** Chat SDK's state adapter (Redis, Postgres, memory) holds `{ runId }` per thread. That's the only piece of glue between the two SDKs. -2. **First mention → `start()`.** Handler serializes `thread` + `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, stashes the returned `runId` in thread state. -3. **Subsequent messages → `resumeHook()`.** Handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow picks up on the next `await hook` iteration. -4. **Workflow posts back via steps.** All Chat SDK side effects (`thread.post`, `thread.subscribe`, `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox. -5. **Session ends — two ways.** The workflow returns normally (user said `done`, approval granted, etc.), or the workflow throws. Either way the run completes; the next inbound message with the stale `runId` falls through to `startSession()`. +1. **Thread state stores the `runId`**: Chat SDK's state adapter (Redis, Postgres, or memory) holds `{ runId }` per thread. This state connects the two SDKs. +2. **The first mention calls `start()`**: The handler serializes `thread` and `message` with `toJSON()`, passes them through `start(durableChatSession, [payload])`, and stores the returned `runId` in thread state. +3. **Subsequent messages call `resumeHook()`**: The handler looks up the `runId`, serializes the new message, and resumes the workflow's hook. The workflow continues on the next `await hook` iteration. +4. **The workflow posts through steps**: All Chat SDK side effects (`thread.post`, `thread.subscribe`, and `thread.setState`) happen inside `"use step"` helpers that dynamically import the bot. This keeps adapter packages outside the workflow sandbox. +5. **The session ends in two ways**: The workflow returns normally when the user sends `done` or an approval is granted, or the workflow throws. Either way, the run completes. The next inbound message with the stale `runId` falls through to `startSession()`. The workflow is fully durable between turns: `await hook` suspends with zero compute cost, and platform webhooks can fire from anywhere without concern for which server instance handled the previous turn. -## Extending the Pattern +## Extending the pattern -Because the session is just a workflow, everything else from the cookbook composes naturally: +Because the session is a workflow, everything else from the cookbook composes naturally: -- **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/docs/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()` — Chat SDK handles platform-specific streaming (Slack edit-in-place, Telegram message-per-chunk, etc.). +- **Stream AI SDK responses into the thread.** Use the [AI SDK integration](/docs/cookbook/integrations/ai-sdk) pattern inside a step, then pass `result.fullStream` to `thread.post()`. Chat SDK handles platform-specific streaming, including Slack edit-in-place and Telegram message-per-chunk. - **Give the bot a sandbox.** Combine with the [Sandbox integration](/docs/cookbook/integrations/sandbox): each thread gets its own persistent sandbox session, snapshots on idle, resumes on the next message. That's effectively a coding-agent bot. - **Human-in-the-loop approvals.** `Promise.race([hook, approvalHook])` inside the workflow, post buttons in the thread via [cards](https://chat-sdk.dev/docs/cards), resume `approvalHook` from `bot.onAction(...)`. -- **Scheduled follow-ups.** `sleep("24h")` before a proactive check-in. Surviving restarts is free. +- **Scheduled follow-ups.** Call `sleep("24h")` before a proactive check-in. The workflow preserves the timer across restarts. ## Pitfalls ### Don't import the bot at the top of workflow files -Adapter packages (`@chat-adapter/slack`, `@chat-adapter/telegram`, etc.) depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow — it's standalone and has no adapter dependencies. +Adapter packages such as `@chat-adapter/slack` and `@chat-adapter/telegram` depend on Node-only modules that aren't available in the workflow bundler's sandbox. Keep `import { bot } from "@/lib/bot"` inside `"use step"` functions with `await import(...)`. Use `reviver` from `chat` for deserialization inside the workflow: it's standalone and has no adapter dependencies. ### Register the bot as a singleton @@ -307,14 +307,14 @@ One `chatTurnHook.create({ token: workflowRunId })` per workflow run, reused eve ### Platform timeouts are separate from workflow timeouts -Slack wants a 200 within 3 seconds. The webhook handler returns immediately after `resumeHook` (which is fast) — the workflow then runs in the background and posts back via `thread.post`. Don't try to `await` the whole turn inside the webhook handler; that's what breaks in the naive integration. +Slack requires an HTTP 200 response within 3s. The webhook handler returns after `resumeHook`, then the workflow runs in the background and posts through `thread.post`. Don't `await` the whole turn inside the webhook handler because that synchronous integration exceeds the platform timeout. ## Key APIs -- [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message) — Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer. -- [`start()`](/docs/api-reference/workflow-api/start) — start a new session workflow. Store the returned `runId` in thread state. -- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) — forward a new platform message to the running workflow. -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — `run.exists` before resuming, to detect stale `runId`s. -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — per-turn suspension point inside the workflow. -- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat) — makes the bot resolvable from inside step functions. -- [Idempotency](/docs/foundations/idempotency) — protect duplicate-sensitive first messages and side effects. +- [`Chat`](https://chat-sdk.dev/docs/api/chat) / [`Thread`](https://chat-sdk.dev/docs/api/thread) / [`Message`](https://chat-sdk.dev/docs/api/message): Chat SDK primitives. `toJSON()` / `fromJSON()` / `reviver` are the serialization layer. +- [`start()`](/docs/api-reference/workflow-api/start): start a new session workflow. Store the returned `runId` in thread state. +- [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook): forward a new platform message to the running workflow. +- [`getRun()`](/docs/api-reference/workflow-api/get-run): `run.exists` before resuming, to detect stale `runId`s. +- [`defineHook()`](/docs/api-reference/workflow/define-hook): per-turn suspension point inside the workflow. +- [`registerSingleton()`](https://chat-sdk.dev/docs/api/chat): makes the bot resolvable from inside step functions. +- [Idempotency](/docs/foundations/idempotency): protect duplicate-sensitive first messages and side effects. diff --git a/docs/content/docs/v5/cookbook/integrations/sandbox.mdx b/docs/content/docs/v5/cookbook/integrations/sandbox.mdx index ad3768b67f..65b2a6befc 100644 --- a/docs/content/docs/v5/cookbook/integrations/sandbox.mdx +++ b/docs/content/docs/v5/cookbook/integrations/sandbox.mdx @@ -1,8 +1,8 @@ --- title: Sandbox -description: Model one Vercel Sandbox per workflow run — durable, idle-efficient, and not bound by the 5-hour sandbox hard cap. +description: Model one Vercel Sandbox per workflow run, durable, idle-efficient, and not bound by the 5-hour sandbox hard cap. type: guide -summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by runId — so one logical session can run effectively forever. +summary: Own a sandbox for the lifetime of a workflow run. Hibernate on idle via snapshot(), proactively refresh before the sandbox hard cap, and reconnect by `runId`, so one logical session can run effectively forever. related: - /docs/ai/defining-tools - /docs/foundations/errors-and-retries @@ -14,34 +14,34 @@ related: text="Implement a durable Vercel Sandbox-backed coding-agent workflow. Install the Sandbox package used by this project and `workflow`. Create an exported workflow function with "use workflow" that owns the agent session. Put sandbox creation, command execution, snapshot, refresh, and cleanup into helper functions with "use step". Persist the sandbox ID, snapshot ID, and workflow run ID in the project data store so clients can reconnect. Use `getWritable()` from `workflow` to stream agent progress and command output. Use `sleep()` to hibernate, refresh, or enforce idle timeouts. Add API routes to start a session, reconnect by run ID, and stop/cleanup. Verify first run, reconnect after reload, snapshot restore, timeout, and cleanup behavior." /> -[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK — the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function. +[Vercel Sandbox](https://vercel.com/docs/sandbox) provides isolated code execution environments. The `@vercel/sandbox` package has first-class support for the Workflow SDK: the `Sandbox` class is serializable, and its methods (`create`, `runCommand`, `stop`, `snapshot`) implicitly run as steps. You can use `Sandbox` directly inside a workflow function without wrapping each call in a separate `"use step"` function. ## Why Workflow + Sandbox -A sandbox alone gets you an isolated VM. A workflow around it gets you a **durable controller** for that VM's entire lifetime: +A sandbox alone provides an isolated virtual machine (VM). A workflow provides a **durable controller** for that VM's entire lifetime: - **One workflow run = one sandbox session.** The `runId` is the only state you need to persist on the client. Close the tab, come back a week later, POST the same `runId` and you're back in the same session. -- **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer — when idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. Next command → spin a new sandbox from the snapshot with filesystem, installed packages, and git history intact. -- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively snapshots + recreates *before* the cap, so the logical session outlives any one VM. Effectively unbounded session duration on top of time-bounded infrastructure. +- **Efficient resource use.** Active sandboxes cost money; hibernated workflows cost nothing. The workflow races a command hook against a `sleep()` timer. When idle, it calls `sandbox.snapshot()` (which also stops the VM) and waits indefinitely. When the next command arrives, the workflow starts a new sandbox from the snapshot with the filesystem, installed packages, and git history intact. +- **Beyond the 5-hour hard cap.** Every Vercel Sandbox has a maximum lifetime. The workflow tracks that deadline and proactively creates a snapshot and replacement before the cap, so the logical session outlives any one VM. This provides an effectively unbounded session on time-bounded infrastructure. - **Automatic cleanup.** `try/finally` in the workflow guarantees the VM is stopped on failure or destroy. An effectively unbounded sandbox session is still one workflow run, so it stays on the deployment that started it. If the controller or agent code should upgrade over time, use an explicit version boundary and pass the serialized state or stream handles forward. See [Versioning](/docs/foundations/versioning). -## Use Case: Coding Agents +## Use case: coding agents -This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox — full filesystem, network, and runtime access — and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return. +This is the pattern [Open Agents](https://open-agents.dev/) uses to spawn coding agents that run "infinitely in the cloud." Each agent session gets its own sandbox (full filesystem, network, and runtime access), and the durable workflow keeps the agent loop resumable across restarts, auto-hibernates when the user walks away, and reconnects instantly when they return. Most coding-agent workloads look like this: -- User sends a task → agent plans, reads files, runs shell commands, commits. -- User walks away mid-run → agent keeps going, eventually goes idle waiting for input. -- User comes back days later → same branch, same filesystem, same conversation history. +- The user sends a task, and the agent plans, reads files, runs shell commands, and commits. +- If the user leaves mid-run, the agent continues and eventually waits for input. +- When the user returns, the same branch, filesystem, and conversation history remain available. -Without durable workflows you'd need a separate state store for the agent loop, a separate job queue for retries, a separate scheduler for idle cleanup, and bespoke reconnection logic. With the pattern below, all of it is one file. +Without durable workflows, you'd need a separate state store for the agent loop, a job queue for retries, a scheduler for idle cleanup, and custom reconnection logic. The pattern below keeps the workflow controller in one file. -## Quickstart: One-shot Pipeline +## Quickstart: one-shot pipeline Before the full session pattern, the simplest shape. Each sandbox method is an implicit step, so the event log records every command and the workflow replays from the last completed call on restart. @@ -74,17 +74,17 @@ export async function sandboxPipeline(input: { commands: string[] }) { } ``` -## Session Pattern: Persistent Sandbox Beyond the Hard Cap +## Session pattern: persistent sandbox beyond the hard cap One workflow run owns a sandbox for its whole lifetime. The workflow's loop does two jobs simultaneously: -1. **Command pipeline** — await a hook, run the next user command, stream output, loop. -2. **Sandbox lifecycle** — race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap). +1. **Command pipeline**: await a hook, run the next user command, stream output, loop. +2. **Sandbox lifecycle**: race the hook against a `sleep()` timer armed for whichever comes first: the idle deadline or the sandbox's refresh deadline (a safety margin before its hard cap). When the timer wins: -- **Idle** → `sandbox.snapshot()` and wait indefinitely for the next command. No compute while asleep. -- **Near sandbox hard cap** → `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session appears continuous; the underlying VM just rotated. +- **Idle**: Call `sandbox.snapshot()` and wait indefinitely for the next command. The workflow uses no compute while suspended. +- **Near the sandbox hard cap**: Call `sandbox.snapshot()` and immediately create a new sandbox from the snapshot. The session remains continuous while the underlying VM rotates. The only way out is an explicit `/destroy` command. @@ -170,7 +170,7 @@ export async function sandboxSessionWorkflow() { "use workflow"; const { workflowRunId } = getWorkflowMetadata(); - // Create the hook once, outside the loop — reusing the same token from inside // [!code highlight] + // Create the hook once, outside the loop: reusing the same token from inside // [!code highlight] // the loop would throw HookConflictError. // [!code highlight] const hook = commandHook.create({ token: workflowRunId }); @@ -205,8 +205,8 @@ export async function sandboxSessionWorkflow() { try { while (!destroyed) { if (hibernated && snapshot) { - // While hibernated, the VM is already stopped. Just wait for the next - // command — no idle timer, no compute cost. + // While hibernated, the VM is already stopped. Wait for the next + // command: no idle timer, no compute cost. const payload = await hook; if (payload.command === "/destroy") { destroyed = true; break; } @@ -231,7 +231,7 @@ export async function sandboxSessionWorkflow() { continue; } - // Active — wake at whichever comes first: idle-deadline or refresh-deadline. + // Active. Wake at whichever comes first: idle-deadline or refresh-deadline. const idleDeadline = lastActivityAt + HIBERNATE_AFTER_MS; const refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS; const wakeAt = Math.min(idleDeadline, refreshDeadline); @@ -246,7 +246,7 @@ export async function sandboxSessionWorkflow() { const nearExpiry = Date.now() >= refreshDeadline; if (nearExpiry) { - // Proactive refresh — snapshot and immediately recreate so the + // Proactive refresh: snapshot and immediately recreate so the // session outlives the sandbox hard cap. await emit({ type: "status", state: "refreshing", at: Date.now() }); const snap = await sandbox.snapshot(); // [!code highlight] @@ -263,7 +263,7 @@ export async function sandboxSessionWorkflow() { }); lastActivityAt = Date.now(); } else { - // Idle — snapshot and hibernate indefinitely. + // Idle: snapshot and hibernate indefinitely. await emit({ type: "status", state: "hibernating", at: Date.now() }); snapshot = await sandbox.snapshot(); // [!code highlight] hibernated = true; @@ -302,7 +302,7 @@ export async function sandboxSessionWorkflow() { -Two endpoints. `/start` accepts an optional `{ runId }` — if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream. +Two endpoints manage the session. `/start` accepts an optional `{ runId }`: if the run still exists, it replays the event log from index 0 so a returning client fully rehydrates. `/command` resumes the hook and returns immediately; command output lands on the `/start` stream. This example starts a fresh sandbox session when no `runId` is provided. If your product needs one sandbox session per user, project, or task, use a deterministic hook token derived from that session key and route retries through the active hook. See [Run idempotency](/docs/foundations/idempotency#run-idempotency). @@ -334,7 +334,7 @@ export async function POST(req: Request) { }, }); } - // Stale runId — fall through to start fresh. + // Stale runId: fall through to start fresh. } const run = await start(sandboxSessionWorkflow, []); @@ -383,7 +383,7 @@ export async function POST(req: Request) { -On mount, if a `runId` is stashed in `localStorage`, reconnect to the existing run. Otherwise start fresh. Commands are POSTed to `/command` — output lands on the `/start` stream. +On mount, reconnect to the existing run if `localStorage` contains a `runId`. Otherwise, start a new run. Send commands to `/command` with POST requests. Output arrives on the `/start` stream. ```tsx title="components/sandbox-runner.tsx" lineNumbers "use client"; @@ -476,22 +476,22 @@ export function SandboxRunner() {
-## How It Works +## How it works -1. **One workflow = one session.** The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember. -2. **Hook created once.** `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`. -3. **Two timer branches.** The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone — no timer, no compute. -4. **Proactive refresh.** `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS`. Hitting this triggers a snapshot + immediate new sandbox from that snapshot, rolling over the hard cap without user intervention. -5. **`sandbox.snapshot()` stops the VM.** It's documented as part of the snapshot process — don't call `stop()` separately. -6. **Resume = new sandbox.** `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a fresh VM from the snapshot. The new sandbox has a different `sandboxId`; filesystem, installed packages, and git history are preserved. -7. **Reconnect by runId.** `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, who rebuilds UI state from the replay. -8. **Exit only on `/destroy`.** The workflow loop has no hard deadline of its own. Individual sandboxes time out; the session doesn't. +1. **One workflow represents one session**: The workflow owns a sandbox for its entire lifetime. The `runId` is the only state the client has to remember. +2. **Create the hook once**: Call `commandHook.create({ token: workflowRunId })` outside the loop. Creating it twice with the same token throws `HookConflictError`. +3. **Two timer branches control wake-up**: The active-state race wakes on the earlier of `idleDeadline` and `refreshDeadline`. The hibernated state awaits the hook alone, with no timer or compute. +4. **Proactive refresh replaces the sandbox**: When `refreshDeadline = sandboxExpiresAt - REFRESH_SAFETY_MS` arrives, the workflow takes a snapshot and immediately creates a new sandbox from it. This rolls over the hard cap without user intervention. +5. **`sandbox.snapshot()` stops the VM**: The snapshot process stops the VM, so don't call `stop()` separately. +6. **Resume creates a new sandbox**: `Sandbox.create({ source: { type: "snapshot", snapshotId } })` creates a new VM from the snapshot. The new sandbox has a different `sandboxId`; the filesystem, installed packages, and git history are preserved. +7. **Reconnect by `runId`**: `getRun(runId).getReadable({ startIndex: 0 })` replays the durable event log to a returning client, which rebuilds UI state from the replay. +8. **Exit only on `/destroy`**: The workflow loop has no hard deadline of its own. Individual sandboxes time out, but the session doesn't. ## Pitfalls ### `sandbox.stop()` is terminal -A stopped sandbox cannot be restarted — you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later. +A stopped sandbox cannot be restarted: you have to create a new one. Hibernation is only possible via `snapshot()` + new-sandbox-from-snapshot. Don't try to "pause" an active sandbox with `stop()` and resume later. ### `snapshot()` already stops the VM @@ -503,7 +503,7 @@ Both `resuming` (idle → command) and `refreshing` (near-hard-cap rotation) cre ### Keep the refresh margin generous -`snapshot()` + `Sandbox.create({ source })` takes real time (typically tens of seconds). If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; 5 minutes is comfortable. +`snapshot()` followed by `Sandbox.create({ source })` takes time, typically tens of seconds. If `REFRESH_SAFETY_MS` is too small, the old sandbox hits its hard cap mid-snapshot. Leave at least 60–90 seconds; the example uses 5 minutes. ### Don't call `writable.close()` inside a workflow function @@ -523,11 +523,11 @@ Each iteration's `hook.then(...)` attaches a listener to the same hook instance. ## Key APIs -- [`Sandbox.create`](https://vercel.com/docs/sandbox) — provision a VM (runtime, source, timeout) -- [`sandbox.runCommand`](https://vercel.com/docs/sandbox) — execute a command; implicit step -- [`sandbox.snapshot`](https://vercel.com/docs/sandbox) — save state and stop the VM; returns `Snapshot` -- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspension point for user commands -- [`sleep()`](/docs/api-reference/workflow/sleep) — durable timer that powers both idle hibernation and proactive refresh -- [`getRun()`](/docs/api-reference/workflow-api/get-run) — look up a run and replay its event log for reconnection -- [`getWritable()`](/docs/api-reference/workflow/get-writable) — resumable NDJSON event stream -- [Idempotency](/docs/foundations/idempotency) — choose when `/start` should reuse an existing run +- [`Sandbox.create`](https://vercel.com/docs/sandbox): provision a VM (runtime, source, timeout) +- [`sandbox.runCommand`](https://vercel.com/docs/sandbox): execute a command; implicit step +- [`sandbox.snapshot`](https://vercel.com/docs/sandbox): save state and stop the VM; returns `Snapshot` +- [`defineHook()`](/docs/api-reference/workflow/define-hook): suspension point for user commands +- [`sleep()`](/docs/api-reference/workflow/sleep): durable timer that powers both idle hibernation and proactive refresh +- [`getRun()`](/docs/api-reference/workflow-api/get-run): look up a run and replay its event log for reconnection +- [`getWritable()`](/docs/api-reference/workflow/get-writable): resumable newline-delimited JSON (NDJSON) event stream +- [Idempotency](/docs/foundations/idempotency): choose when `/start` should reuse an existing run diff --git a/docs/content/docs/v5/deploying.mdx b/docs/content/docs/v5/deploying.mdx index 7e3f42d130..d4b49bc4d0 100644 --- a/docs/content/docs/v5/deploying.mdx +++ b/docs/content/docs/v5/deploying.mdx @@ -10,14 +10,14 @@ related: - /worlds/building-a-world --- -Workflows are designed to be highly portable. The same workflow code can run locally during development, on Vercel with zero configuration, or on any infrastructure using **Worlds** — pluggable adapters that handle storage, queuing, and communication. +The same workflow code can run locally during development, on Vercel with zero configuration, or on any infrastructure using **Worlds**, which are pluggable adapters that handle storage, queuing, and communication. -## Local Development +## Local development -During local development, workflows automatically use the **Local World** — no configuration required. The Local World stores workflow data in a `.workflow-data/` directory and processes steps synchronously, making it perfect for development and testing. +During local development, workflows use the **Local World** without configuration. The Local World stores workflow data in a `.workflow-data/` directory and processes steps synchronously for development and testing. ```bash -# Just run your dev server - workflows work out of the box +# Run your dev server to use workflows locally npm run dev ``` @@ -33,17 +33,17 @@ npx workflow inspect runs ## Deploying to Vercel -The easiest way to deploy workflows to production is on Vercel. When you deploy to Vercel, workflows automatically use the **Vercel World** — again, with zero configuration. +Deploy workflows to production on Vercel without configuration. Workflows deployed to Vercel use the **Vercel World**. The Vercel World provides: -- **Durable storage** - Workflow state persists across function invocations -- **Managed queuing** - Steps are processed reliably with automatic retries -- **Automatic scaling** - Workflows scale with your application -- **Built-in observability** - View workflow runs in the Vercel dashboard -- **Multi-region** - Runs are pinned to the region that creates them, keeping workflow data, queuing, and streaming close to your users +- **Durable storage**: Workflow state persists across function invocations +- **Managed queuing**: The queue processes steps reliably with automatic retries +- **Automatic scaling**: Workflows scale with your application +- **Built-in observability**: View workflow runs in the Vercel dashboard +- **Multi-region**: The region that creates each run also hosts its workflow data, queue, and streams, keeping them close to your users -Simply deploy your application: +Deploy your application: ```bash vercel deploy @@ -55,7 +55,7 @@ vercel deploy Learn more about the [Vercel World](/worlds/vercel) and its capabilities, including [multi-region](/worlds/vercel#multi-region). -## Self-Hosting & Other Providers +## Self-hosting & other providers For self-hosting or deploying to other cloud providers, you can use community-maintained Worlds or build your own. @@ -68,7 +68,7 @@ For self-hosting or deploying to other cloud providers, you can use community-ma -### Using a Third-Party World +### Using a third-party World To use a different World implementation, set the `WORKFLOW_TARGET_WORLD` environment variable: @@ -78,11 +78,11 @@ export WORKFLOW_TARGET_WORLD=@workflow/world-postgres export DATABASE_URL=postgres://... ``` -Each World may have its own configuration requirements — refer to the specific World's documentation for details. +Each World may have its own configuration requirements. Refer to that World's documentation for details. ## Observability -The [Observability tools](/docs/observability) work with any World backend. By default they connect to your local environment, but can be configured to inspect remote deployments: +The [Observability tools](/docs/observability) work with any World backend. By default, they connect to your local environment, but you can configure them to inspect remote deployments: ```bash # Inspect local workflows diff --git a/docs/content/docs/v5/errors/abort-signal-timeout-in-workflow.mdx b/docs/content/docs/v5/errors/abort-signal-timeout-in-workflow.mdx index 737048a997..311e4cc363 100644 --- a/docs/content/docs/v5/errors/abort-signal-timeout-in-workflow.mdx +++ b/docs/content/docs/v5/errors/abort-signal-timeout-in-workflow.mdx @@ -17,21 +17,21 @@ related: ## Error -``` +```text AbortSignal.timeout() is not supported in workflow functions. Use sleep() with an AbortController instead. ``` -## Why This Happens +## Why this happens -`AbortSignal.timeout()` creates a signal that aborts after a real-time delay using an internal timer. Workflow functions must be [deterministic](/docs/foundations/workflows-and-steps) to support replay — they run the same code multiple times during the workflow's lifecycle, using the [event log](/docs/how-it-works/event-sourcing) to resume execution to the correct point. +`AbortSignal.timeout()` creates a signal that aborts after a real-time delay using an internal timer. Workflow functions must be [deterministic](/docs/foundations/workflows-and-steps) to support replay: they run the same code multiple times during the workflow's lifecycle, using the [event log](/docs/how-it-works/event-sourcing) to resume execution to the correct point. Real-time timers break this determinism because: -- On the first execution, the timer might fire after 10 seconds -- On replay, the timer would fire again, but the event log may have already advanced past that point -- The timer's behavior depends on wall-clock time, which varies between executions +- On the first execution, the timer might fire after 10 seconds. +- On replay, the timer would fire again, but the event log may have already advanced past that point. +- The timer's behavior depends on wall-clock time, which varies between executions. -## How to Fix +## How to fix Use [`sleep()`](/docs/api-reference/workflow/sleep) with an `AbortController` to create a deterministic timeout that cancels in-flight work: @@ -70,7 +70,7 @@ async function fetchData(signal: AbortSignal) { } ``` -The `sleep()` + `AbortController` pattern is the durable equivalent of `AbortSignal.timeout()`. The sleep is recorded in the event log, so it replays deterministically. If `fetchData` finishes within 10 seconds you get the response; if not, the timer fires `controller.abort()`, `fetch` rejects with an `AbortError`, and the step's failure propagates to the workflow as a `FatalError` (no retries — abort is intentional cancellation). +The `sleep()` and `AbortController` pattern is the durable equivalent of `AbortSignal.timeout()`. The sleep is recorded in the event log, so it replays deterministically. If `fetchData` finishes within 10 seconds, you get the response. Otherwise, the timer calls `controller.abort()`, `fetch` rejects with an `AbortError`, and the step's failure propagates to the workflow as a `FatalError` (no retries because the abort is intentional cancellation). `AbortSignal.timeout()` works normally inside step functions, since steps have full Node.js runtime access and are not replayed. @@ -78,7 +78,7 @@ The `sleep()` + `AbortController` pattern is the durable equivalent of `AbortSig ## Related -- [Cancellation](/docs/foundations/cancellation) — Patterns for cancelling in-flight work -- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) — Durable sleep primitive -- [Workflows and Steps](/docs/foundations/workflows-and-steps) — Why workflow functions must be deterministic -- [`setTimeout` in Workflow](/docs/errors/timeout-in-workflow) — Similar restriction on `setTimeout` +- [Cancellation](/docs/foundations/cancellation): Patterns for canceling in-flight work +- [`sleep()` API Reference](/docs/api-reference/workflow/sleep): Durable sleep primitive +- [Workflows and Steps](/docs/foundations/workflows-and-steps): Why workflow functions must be deterministic +- [`setTimeout` in Workflow](/docs/errors/timeout-in-workflow): Similar restriction on `setTimeout` diff --git a/docs/content/docs/v5/errors/corrupted-event-log.mdx b/docs/content/docs/v5/errors/corrupted-event-log.mdx index d57f6b53f7..61543a7516 100644 --- a/docs/content/docs/v5/errors/corrupted-event-log.mdx +++ b/docs/content/docs/v5/errors/corrupted-event-log.mdx @@ -13,15 +13,15 @@ This error occurs when the Workflow runtime repeatedly cannot replay events in t This is a **workflow-level fatal error**. It cannot be caught or handled inside your workflow code. The runtime first retries transient replay divergence automatically; it marks the run as failed with this error only after replay still cannot recover. -## Error Message +## Error message -``` +```text Workflow replay diverged times after recovery replays; latest divergent event was . Last divergence:
``` -## Why This Happens +## Why this happens -Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence — every event must be consumed by a matching callback (e.g., a step or sleep waiting for its result). An event no callback ever claims is one the runtime would have to drop to finish the run, so it fails the run instead of returning a result that silently ignored it. +Workflows persist their progress as an ordered event log. During replay, the runtime processes each event in sequence. Every event must be consumed by a matching callback, such as a step or sleep waiting for its result. An event no callback ever claims is one the runtime would have to drop to finish the run, so it fails the run instead of returning a result that silently ignored it. A delivery written from outside the replay, such as a hook firing or a step completing on another invocation, can land ahead of the events the replay is writing itself. That is ordinary concurrency rather than corruption, so the runtime holds such an event and offers it to each consumer the replay registers afterwards. The failure comes only when the workflow function returns while an event is still held, at which point no consumer can ever appear. A replay that suspends still holding one reports it on the span (`workflow.events.parked.count`, `.event_id`, `.event_type`) and leaves the decision to the replay that follows. @@ -29,13 +29,13 @@ Before failing, the runtime retries a divergent replay and surfaces this termina Common scenarios that produce this error: -1. **An unclaimed event that repeats nothing** — A duplicate of a kind the log already records for that entity is read past rather than failing the run, so a second `step_completed` or `wait_completed` is not this error (see [Duplicate Events](/docs/how-it-works/event-sourcing#duplicate-events)). What fails is an unclaimed event with no earlier counterpart to defer to: a `step_started` behind a `step_completed` on a log that never recorded a `step_started`, for instance. No consumer remains for the step, and there is no earlier event of that kind the replay could be reading instead. -2. **Orphaned events** — A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code, so the replay reaches its end still holding it. -3. **A hole in the log** — Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check). +- **An unclaimed event that repeats nothing**: A duplicate of a kind the log already records for that entity is read past rather than failing the run, so a second `step_completed` or `wait_completed` is not this error (see [Duplicate Events](/docs/how-it-works/event-sourcing#duplicate-events)). What fails is an unclaimed event with no earlier counterpart to defer to: a `step_started` behind a `step_completed` on a log that never recorded a `step_started`, for instance. No consumer remains for the step, and there is no earlier event of that kind the replay could be reading instead. +- **Orphaned events**: A `step_completed` or `wait_completed` event whose `correlationId` doesn't match any step or sleep in the workflow code, so the replay reaches its end still holding it. +- **A hole in the log**: Events are numbered by their position in the run's log, and those positions are dense, so a position below the log's highest that holds no event means the log the replay loaded is incomplete. The runtime cannot tell a position no write ever occupied from one whose event it failed to read, so it refuses to replay rather than produce a result that may be silently wrong. See [`WORKFLOW_SLOT_GAP_CHECK`](/docs/configuration/runtime-tuning#workflow_slot_gap_check). -## What To Do +## What to do -This error indicates a bug in the Workflow SDK or Workflow server — not in your workflow code. Your workflow code does not need to change. Follow these steps to resolve the issue: +This error indicates a bug in the Workflow SDK or Workflow server, not in your workflow code. Your workflow code does not need to change. Follow these steps to resolve the issue: ### 1. Upgrade to the latest `workflow` package @@ -51,14 +51,14 @@ If this error is displayed, automatic replay recovery has already been exhausted ### 3. Report the issue -If the error persists after upgrading, please [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate and fix the underlying bug. Include the following details to help us diagnose the problem: +If the error persists after upgrading, [open an issue on GitHub](https://github.com/vercel/workflow/issues/new) so we can investigate and fix the underlying bug. Include the following details to help us diagnose the problem: - The version of the `workflow` package you are using - The run ID(s) of the affected workflow run(s) - The error message (including `eventType`, `correlationId`, and `eventId`) - Any details about the event log or the workflow that triggered the error -## This Error Cannot Be Caught +## This error cannot be caught Unlike other workflow errors, a corrupted event log error is **not catchable** inside your workflow function. Because the event log itself is invalid, the runtime cannot safely continue executing any user code. The entire run fails immediately and is marked as `failed`. diff --git a/docs/content/docs/v5/errors/deployment-mismatch.mdx b/docs/content/docs/v5/errors/deployment-mismatch.mdx index bbeced87ae..fcaaf320b2 100644 --- a/docs/content/docs/v5/errors/deployment-mismatch.mdx +++ b/docs/content/docs/v5/errors/deployment-mismatch.mdx @@ -15,30 +15,30 @@ Every run is pinned to a single deployment when it starts. When a queued workflo This is an SDK/runtime signal, not an error thrown by your workflow code, and it is not catchable inside a workflow function. -## Error Message +## Error message -``` +```text Workflow run "wrun_..." is pinned to deployment "dpl_A", but was received by deployment "dpl_B". The runtime re-routed the message to "dpl_A" 3 times and it kept arriving elsewhere, so the run was stopped to protect against code-skew errors. Verify that the run's deployment is still available and that queue callbacks are routed to it. ``` -When the queue definitively reports that the run's deployment cannot be reached — it was deleted, or aged out of its retention window — no re-route is possible and the message omits the re-routing clause. Transient or unknown publishing failures leave the current delivery unacknowledged so the queue can redeliver it; they do not fail the run or consume this recovery budget. +When the queue definitively reports that the run's deployment cannot be reached (it was deleted, or aged out of its retention window), no re-route is possible and the message omits the re-routing clause. Transient or unknown publishing failures leave the current delivery unacknowledged so the queue can redeliver it; they do not fail the run or consume this recovery budget. -## Why A Run Is Pinned +## Why a run is pinned A run's deployment is chosen once, at [`start()`](/docs/api-reference/workflow-api/start): -- By default it is the deployment that called `start()` — see [Versioning](/docs/foundations/versioning) for why runs are pinned this way. +- By default it is the deployment that called `start()`. See [Versioning](/docs/foundations/versioning) for why runs are pinned this way. - With `start(workflow, args, { deploymentId })` it is the id you pass, so a run can deliberately target a deployment other than the one that created it. - With `deploymentId: "latest"` it is the most recent deployment for the current environment, resolved at start time. Whichever it is, that `deploymentId` is recorded on the run, and every subsequent workflow replay and step execution must happen on that deployment. Continuing on a different one is unsafe: 1. **Code skew.** The workflow and step bundles on the receiving deployment may not match the code that produced the run's recorded history, so replay could diverge or produce incorrect results. -2. **Encryption.** Step inputs and other event-log payloads are encrypted with a per-run key derived from the pinned deployment's key material. A different deployment derives the wrong key and cannot decrypt them — previously the source of a confusing [runtime-decryption-failed](/docs/errors/runtime-decryption-failed) that exhausted retries with no clear cause. +2. **Encryption.** Step inputs and other event-log payloads are encrypted with a per-run key derived from the pinned deployment's key material. A different deployment derives the wrong key and cannot decrypt them, previously the source of a confusing [runtime-decryption-failed](/docs/errors/runtime-decryption-failed) that exhausted retries with no clear cause. -So the runtime checks the pinned deployment before it executes anything, and `DEPLOYMENT_MISMATCH` names the result — instead of the mismatch surfacing later as an unrelated decryption failure. +So the runtime checks the pinned deployment before it executes anything, and `DEPLOYMENT_MISMATCH` names the result, instead of the mismatch surfacing later as an unrelated decryption failure. -## Automatic Recovery +## Automatic recovery A deployment that receives a run it does not own first tries to fix the delivery rather than fail the run: @@ -46,19 +46,19 @@ A deployment that receives a run it does not own first tries to fix the delivery 2. Delivery is delayed with a short exponential backoff (1s, 2s, 4s). 3. If the run keeps arriving at the wrong deployment, the run is failed with `DEPLOYMENT_MISMATCH` after `WORKFLOW_DEPLOYMENT_MISMATCH_MAX_RETRIES` attempts (default `3`). Set it to `0` to fail on the first misrouted delivery instead. -Nothing is executed on the wrong deployment during recovery: no workflow code, no step body, no `step_started`, and no hook resume. Whatever the delivery was carrying travels with it, so a pending step keeps its identity and a hook resume keeps its payload — they run on the deployment that can actually decrypt them. +Nothing is executed on the wrong deployment during recovery: no workflow code, no step body, no `step_started`, and no hook resume. Whatever the delivery was carrying travels with it, so a pending step keeps its identity and a hook resume keeps its payload: they run on the deployment that can actually decrypt them. Recovery attempts do not create events on the run, so a run that self-heals looks completely normal. They are reported on the invocation's trace span (`workflow.deployment.pinned_id`, `workflow.deployment_mismatch.retry_count`, `workflow.deployment_mismatch.recovered`) and as a runtime warning in your function logs. -## What To Do +## What to do - **Re-run from the current deployment.** Trigger the workflow again from your latest deployment (or use the **Re-run** button in the Workflow Dashboard). The new run is pinned to the current deployment. -- **Keep a run's deployment available** for the lifetime of that run. A run whose deployment has been deleted or has aged out cannot be resumed and must be re-run — recovery cannot help, so these fail on the first misrouted delivery. This applies to runs started with an explicit `deploymentId` too: pinning a run to an older deployment keeps it dependent on that deployment for its whole lifetime. -- **Report it** if the pinned deployment was still available. Include both deployment ids and the run id from the error message, plus the trace span attributes above — a run that failed this way despite a reachable target is a routing fault worth investigating rather than something to work around. +- **Keep a run's deployment available** for the lifetime of that run. A run whose deployment has been deleted or has aged out cannot be resumed and must be re-run: recovery cannot help, so these fail on the first misrouted delivery. This applies to runs started with an explicit `deploymentId` too: pinning a run to an older deployment keeps it dependent on that deployment for its whole lifetime. +- **Report it** if the pinned deployment was still available. Include both deployment IDs and the run ID from the error message, plus the trace span attributes above. A run that failed this way despite a reachable target is a routing fault worth investigating rather than something to work around. -## This Error Cannot Be Caught +## This error cannot be caught -Like other runtime signals, `DEPLOYMENT_MISMATCH` is **not catchable** inside your workflow function — the run is failed before any workflow or step code executes on the receiving deployment. Check the run status from outside instead: +Like other runtime signals, `DEPLOYMENT_MISMATCH` is **not catchable** inside your workflow function: the run is failed before any workflow or step code executes on the receiving deployment. Check the run status from outside instead: ```typescript lineNumbers import { getRun } from "workflow/api"; diff --git a/docs/content/docs/v5/errors/fetch-in-workflow.mdx b/docs/content/docs/v5/errors/fetch-in-workflow.mdx index 140d92f4ed..41f98455bc 100644 --- a/docs/content/docs/v5/errors/fetch-in-workflow.mdx +++ b/docs/content/docs/v5/errors/fetch-in-workflow.mdx @@ -10,24 +10,24 @@ related: --- This error occurs when you try to use `fetch()` directly in a workflow function, or when a library (like the AI SDK) tries to call `fetch()` under the hood. -## Error Message +## Error message -``` +```text Global "fetch" is unavailable in workflow functions. Use the "fetch" step function from "workflow" to make HTTP requests. ``` -## Why This Happens +## Why this happens Workflow functions run in a sandboxed environment without direct access to `fetch()`. Many libraries make HTTP requests under the hood. For example, the AI SDK's `generateText()` function calls `fetch()` to make HTTP requests to AI providers. When these libraries run inside a workflow function, they fail because the global `fetch` is not available. -## Quick Fix +## Quick fix Import the `fetch` step function from the `workflow` package and assign it to `globalThis.fetch` inside your workflow function. This version of `fetch` is a step function that wraps the standard `fetch` API, automatically handling serialization and providing retry capabilities. This will also make `fetch()` available to all functions and libraries in the current workflow function. @@ -72,9 +72,9 @@ export async function chatWorkflow(prompt: string) { } ``` -## Common Scenarios +## Common scenarios -### AI SDK Integration +### AI SDK integration This is the most common scenario - using AI SDK functions that make HTTP requests: @@ -98,7 +98,7 @@ export async function aiWorkflow(userMessage: string) { } ``` -### Direct API Calls +### Direct API calls You can also use the fetch step function directly for your own HTTP requests: diff --git a/docs/content/docs/v5/errors/hook-conflict.mdx b/docs/content/docs/v5/errors/hook-conflict.mdx index 8cd51a9ce1..3d99877aa2 100644 --- a/docs/content/docs/v5/errors/hook-conflict.mdx +++ b/docs/content/docs/v5/errors/hook-conflict.mdx @@ -16,13 +16,13 @@ related: This error occurs when you try to create a hook with a token that is already in use by another active workflow run. Hook tokens must be unique across all running workflows in your project. -## Error Message +## Error message -``` +```text Hook token "" is already in use by another workflow ``` -## Why This Happens +## Why this happens Hooks use tokens to identify incoming webhook payloads. When you create a hook with `createHook({ token: "my-token" })`, the Workflow runtime reserves that token for your workflow run. If another workflow run is already using that token, a conflict occurs. @@ -31,9 +31,9 @@ This typically happens when: 1. **Two workflows start simultaneously** with the same hardcoded token 2. **A previous workflow run is still waiting** for a hook when a new run tries to use the same token -## Common Causes +## Common causes -### Hardcoded Token Values +### Hardcoded token values {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -61,7 +61,7 @@ export async function processPayment(orderId: string) { } ``` -### Omitting the Token (Auto-generated) +### Omitting the token (auto-generated) The safest approach is to let the Workflow runtime generate a unique token automatically: @@ -77,7 +77,7 @@ export async function processPayment() { } ``` -## Handling Hook Conflicts +## Handling hook conflicts When a hook conflict occurs, awaiting the hook will throw a `HookConflictError`. The error exposes the token that conflicted and, for current worlds, the run ID that currently owns it. `conflictingRunId` remains optional for compatibility with older persisted events and world implementations, so guard it before delegating: @@ -114,7 +114,7 @@ export async function processPayment(orderId: string) { This pattern is useful when you want to detect duplicate processing inside the workflow. Runtime APIs such as `resumeHook()` and `getRun()` must be called outside workflow functions, for example from an API route or in a step. -### Delegate to the Active Run +### Delegate to the active Run In idempotency flows, a conflict means another active run already owns the hook token. You can return the duplicate-processing payload from the workflow, resume the active hook to deliver the payload to the existing run, then use `getRun(result.runId)` to wait for, stream, or cancel the active run: @@ -156,17 +156,17 @@ export async function POST(request: Request) { If the caller needs live output instead of the final result, return `activeRun.getReadable()` from the same branch. If the duplicate request should replace the active work, call `await activeRun.cancel()` after inspecting the run. -## When Hook Tokens Are Released +## When hook tokens are released Hook tokens are automatically released when: - The workflow run **completes** (successfully or with an error) -- The workflow run is **cancelled** +- The workflow run is **canceled** - The hook is explicitly **disposed** After a workflow completes, its hook tokens become available for reuse by other workflows. -## Best Practices +## Best practices 1. **Use auto-generated tokens** when possible - they are guaranteed to be unique 2. **Include unique identifiers** if you need custom tokens (order ID, user ID, etc.) diff --git a/docs/content/docs/v5/errors/index.mdx b/docs/content/docs/v5/errors/index.mdx index ebc703c681..bcd285f0c6 100644 --- a/docs/content/docs/v5/errors/index.mdx +++ b/docs/content/docs/v5/errors/index.mdx @@ -11,7 +11,7 @@ Fix common mistakes when creating and executing workflows in the **Workflow SDK* -## Learn More +## Learn more * [API Reference](/docs/api-reference) - Complete API documentation * [Foundations](/docs/foundations) - Architecture and core concepts diff --git a/docs/content/docs/v5/errors/node-js-module-in-workflow.mdx b/docs/content/docs/v5/errors/node-js-module-in-workflow.mdx index 670adab787..82cf4aa8e4 100644 --- a/docs/content/docs/v5/errors/node-js-module-in-workflow.mdx +++ b/docs/content/docs/v5/errors/node-js-module-in-workflow.mdx @@ -15,19 +15,19 @@ related: This error occurs when you try to import or use Node.js core modules (like `fs`, `http`, `crypto`, `path`, etc.) directly inside a workflow function. -## Error Message +## Error message -``` +```text Cannot use Node.js module "fs" in workflow functions. Move this module to a step function. ``` -## Why This Happens +## Why this happens Workflow functions run in a sandboxed environment without full Node.js runtime access. This restriction is important for maintaining **determinism** - the ability to replay workflows exactly and resume from where they left off after suspensions or failures. Node.js modules have side effects and non-deterministic behavior that could break workflow replay guarantees. -## Quick Fix +## Quick fix Move any code using Node.js modules to a step function. Step functions have full Node.js runtime access. @@ -68,7 +68,7 @@ async function read(filePath: string) { } ``` -## Common Node.js Modules +## Common Node.js modules These common Node.js core modules cannot be used in workflow functions: diff --git a/docs/content/docs/v5/errors/replay-divergence.mdx b/docs/content/docs/v5/errors/replay-divergence.mdx index 5b24d86bd6..c292ec879f 100644 --- a/docs/content/docs/v5/errors/replay-divergence.mdx +++ b/docs/content/docs/v5/errors/replay-divergence.mdx @@ -14,7 +14,7 @@ A replay divergence occurs when one invocation of a workflow cannot consume the This is an SDK/runtime signal, not an error thrown by your workflow code. It is not catchable inside a workflow function. -## Automatic Recovery +## Automatic recovery A single divergent replay does not prove that persisted history is corrupted. For example, asynchronous delivery ordering may cause one invocation to follow the wrong side of a race while another replay can follow the recorded history correctly. @@ -22,6 +22,6 @@ The runtime automatically queues another replay when an invocation reports `REPL If recovery replays continue to diverge after the recovery budget is exhausted, the runtime marks the run as failed with `CORRUPTED_EVENT_LOG` and records the latest divergent event for diagnosis. -## What To Do +## What to do Most replay divergence signals recover without action. If a run ultimately fails with `CORRUPTED_EVENT_LOG`, update to the latest `workflow` package and report the run ID and error details if the failure persists. diff --git a/docs/content/docs/v5/errors/runtime-decryption-failed.mdx b/docs/content/docs/v5/errors/runtime-decryption-failed.mdx index c8a7536251..91d1a6a2f9 100644 --- a/docs/content/docs/v5/errors/runtime-decryption-failed.mdx +++ b/docs/content/docs/v5/errors/runtime-decryption-failed.mdx @@ -11,33 +11,33 @@ related: This error occurs when the Workflow SDK's built-in AES-GCM encryption layer fails while encrypting or decrypting a workflow payload. The SDK encrypts step inputs, step outputs, hook payloads, and other event-log data with a per-run AES-256 key whenever encryption is configured for the deployment. -This is an **internal SDK failure** — your workflow code never invokes the encryption primitives directly. When this surfaces, it means the ciphertext, nonce, or auth tag the SDK tried to verify is not the bytes that were originally produced. The run is failed with the `RUNTIME_ERROR` classification. +This is an **internal SDK failure**: your workflow code never invokes the encryption primitives directly. When this surfaces, the ciphertext, nonce, or authentication tag the SDK tried to verify does not match the bytes that were originally produced. The run fails with the `RUNTIME_ERROR` classification. -## Error Message +## Error message -``` +```text AES-256-GCM decryption failed: The operation failed for an operation-specific reason ``` -The underlying cause is a native Web Crypto [`OperationError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#operationerror) — most commonly raised by `AESCipherJob.onDone` in Node's `node:internal/crypto/util` module when the GCM authentication tag does not verify. +The underlying cause is a native Web Crypto [`OperationError`](https://developer.mozilla.org/en-US/docs/Web/API/DOMException#operationerror), most commonly raised by `AESCipherJob.onDone` in Node's `node:internal/crypto/util` module when the GCM authentication tag does not verify. The thrown `RuntimeDecryptionError` carries a small `context` object with diagnostic fields to help triangulate the source: -- `operation` — `'encrypt'` or `'decrypt'` -- `byteLength` — total byte length of the payload at the failure site -- `formatPrefix` — the first 4 bytes of the input (`'encr'` for a well-formed encrypted envelope, otherwise a hex dump) +- `operation`: `'encrypt'` or `'decrypt'` +- `byteLength`: total byte length of the payload at the failure site +- `formatPrefix`: the first 4 bytes of the input (`'encr'` for a well-formed encrypted envelope, otherwise a hex dump) -## Why This Happens +## Why this happens Common causes, in rough order of likelihood: 1. **Ciphertext mutation or truncation in transit.** The encrypted payload reached the SDK with bytes that differ from what storage holds. Possible sources include a truncated HTTP response from a workflow-server ref endpoint, an edge-cache miss returning a partial 200, or a proxy drop during streaming. A truncated body whose first 4 bytes happen to still spell `encr` produces the exact "auth tag mismatch" symptom. -2. **Key resolution mismatch.** The key used to decrypt is not the key that was used to encrypt — e.g. the run's `deploymentId` was not threaded through key resolution and the SDK fell back to the wrong deployment's key material. +2. **Key resolution mismatch.** The key used to decrypt is not the key that was used to encrypt, e.g. the run's `deploymentId` was not threaded through key resolution and the SDK fell back to the wrong deployment's key material. 3. **Malformed encrypted envelope.** The envelope is too short to contain the GCM nonce (12 bytes) and auth tag (16 bytes), so decryption is rejected before it begins. -## What To Do +## What to do -This error indicates an SDK or infrastructure problem — not a bug in your workflow code. Your workflow code does not need to change. +This error indicates an SDK or infrastructure problem, not a bug in your workflow code. Your workflow code does not need to change. ### 1. Upgrade to the latest `workflow` package @@ -60,7 +60,7 @@ If the error persists after upgrading, please [open an issue on GitHub](https:// - The full error message, including the `context` fields (`operation`, `byteLength`, `formatPrefix`) - Whether the affected workflows make heavy use of large step inputs/outputs (which may indicate the failure is on the lazy-loaded ref read path) -## This Error Cannot Be Caught +## This error cannot be caught Like other `WorkflowRuntimeError` subclasses, a runtime decryption failure is **not catchable** inside your workflow function. The runtime cannot safely continue executing user code when an event-log payload can't be verified, so the entire run fails immediately and is marked as `failed`. diff --git a/docs/content/docs/v5/errors/serialization-failed.mdx b/docs/content/docs/v5/errors/serialization-failed.mdx index 6e892b4ddd..c57b7e391b 100644 --- a/docs/content/docs/v5/errors/serialization-failed.mdx +++ b/docs/content/docs/v5/errors/serialization-failed.mdx @@ -15,9 +15,9 @@ related: This error occurs when you try to pass non-serializable data between execution boundaries in your workflow. All data passed between workflow functions, step functions, and the workflow runtime must be serializable to persist in the event log. -## Error Message +## Error message -``` +```text Failed to serialize workflow arguments. Ensure you're passing serializable types (plain objects, arrays, primitives, Date, RegExp, Map, Set). ``` @@ -29,13 +29,13 @@ This error can appear when: - Serializing step arguments - Serializing step return values -## Where the Error Surfaces +## Where the error surfaces Where you observe the failure depends on which boundary it crosses: -- **Workflow arguments** — `start()` throws synchronously in your application code. -- **Step arguments and step return values** — the *step* fails with the `SerializationError`, exactly like a step whose body threw a fatal error: no retries (the failure is deterministic), and a `try/catch` around the step call in your workflow code observes it. The step's recorded input shows `[input unavailable: step argument serialization failed]` when the arguments were the unserializable part. -- **Workflow return values** — the workflow body has already returned, so nothing can catch it; the run fails. +- **Workflow arguments**: `start()` throws synchronously in your application code. +- **Step arguments and step return values**: the *step* fails with the `SerializationError`, exactly like a step whose body threw a fatal error: no retries (the failure is deterministic), and a `try/catch` around the step call in your workflow code observes it. The step's recorded input shows `[input unavailable: step argument serialization failed]` when the arguments were the unserializable part. +- **Workflow return values**: the workflow body has already returned, so nothing can catch it; the run fails. ```typescript lineNumbers async function stepWithBadArguments(value: unknown) { @@ -55,9 +55,9 @@ export async function processWorkflow(someValue: unknown) { } ``` -Uncaught, the error propagates out of the workflow body and the run fails immediately with the error code `USER_ERROR` — it does not retry. +Uncaught, the error propagates out of the workflow body and the run fails immediately with the error code `USER_ERROR`; it does not retry. -## Why This Happens +## Why this happens Workflows persist their state using an event log. Every value that crosses execution boundaries must be: @@ -66,9 +66,9 @@ Workflows persist their state using an event log. Every value that crosses execu Functions, class instances, symbols, and other non-serializable types cannot be properly reconstructed after serialization, which would break workflow replay. -## Common Causes +## Common causes -### Passing Functions +### Passing functions {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -100,7 +100,7 @@ async function processStep(config: { shouldLog: boolean }) { } ``` -### Class Instances +### Class instances ```typescript lineNumbers class User { @@ -139,11 +139,11 @@ async function greetStep(userData: { name: string }) { } ``` -## Supported Serializable Types +## Supported serializable types Workflow SDK supports these types across execution boundaries: -### Standard JSON Types +### Standard JSON types - `string`, `number`, `boolean`, `null` - Arrays of serializable values @@ -151,10 +151,10 @@ Workflow SDK supports these types across execution boundaries: To learn more about supported types, see the [Serialization](/docs/foundations/serialization) section. -## Debugging Serialization Issues +## Debugging serialization issues To identify what's causing serialization to fail: -1. **Check the error stack trace** - it often shows which property failed -2. **Simplify your data** - temporarily pass smaller objects to isolate the issue -3. **Ensure you are using supported data types** - see the [Serialization](/docs/foundations/serialization) section for more details +1. **Check the error stack trace**: It often shows which property failed. +2. **Simplify your data**: Temporarily pass smaller objects to isolate the issue. +3. **Use supported data types**: See [Serialization](/docs/foundations/serialization) for details. diff --git a/docs/content/docs/v5/errors/start-invalid-workflow-function.mdx b/docs/content/docs/v5/errors/start-invalid-workflow-function.mdx index a1ed19823a..c1543deda6 100644 --- a/docs/content/docs/v5/errors/start-invalid-workflow-function.mdx +++ b/docs/content/docs/v5/errors/start-invalid-workflow-function.mdx @@ -17,17 +17,17 @@ related: This error occurs when `start()` receives a function that does not have Workflow SDK's generated workflow metadata. In practice, that usually means the function is missing `"use workflow"` or the file was never transformed by your framework integration. -## Error Message +## Error message -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` -## Why This Happens +## Why this happens -`start()` expects an imported workflow function, not just any async function. During compilation, Workflow SDK transforms files that contain `"use workflow"` and attaches generated metadata such as the workflow ID. If that transform never runs, or if you pass a wrapper function instead of the transformed export, `start()` cannot identify what to enqueue and throws this error. +`start()` expects an imported workflow function rather than any async function. During compilation, Workflow SDK transforms files that contain `"use workflow"` and attaches generated metadata such as the workflow ID. If that transform never runs, or if you pass a wrapper function instead of the transformed export, `start()` cannot identify what to enqueue and throws this error. -## Common Causes +## Common causes ### Missing `"use workflow"` diff --git a/docs/content/docs/v5/errors/step-executed-multiple-times.mdx b/docs/content/docs/v5/errors/step-executed-multiple-times.mdx index a7159cbaf0..76eff10f26 100644 --- a/docs/content/docs/v5/errors/step-executed-multiple-times.mdx +++ b/docs/content/docs/v5/errors/step-executed-multiple-times.mdx @@ -12,12 +12,12 @@ related: There may be cases where you see multiple `step_started` events for the same step in a workflow run. This happens if the function invocation executing the step crashes unexpectedly, and the step can not report the error. The step will be re-tried according to your retry policy in this case, but no error will be visible in the [Observability UI](/docs/observability). -## Common Causes +## Common causes - **Function timeouts**: if your step code runs longer than the configured maximum function duration, it will be killed. Compare the gap between the `step_started` events to your configured function duration to be sure. - **Out of memory (OOM)**: if your step code loads enough data into memory, especially if the step is invoked concurrently, the function invocation might run out of memory. You can see your function's peak memory use by going to the [Observability Query page](https://vercel.com/docs/observability) and showing the **Function Invocation Peak Memory** metric, then filtering down the **Route** to `/.well-known/workflow` endpoints. - **Network issues**: persistent firewall, network stability, and related issues might prevent your function from reporting results or errors. This should be temporary. -## Getting Help +## Getting help If you consistently see multiple `step_started` events and have ruled out function timeouts, OOMs, and firewall issues, please [contact support](https://vercel.com/help). diff --git a/docs/content/docs/v5/errors/step-not-registered.mdx b/docs/content/docs/v5/errors/step-not-registered.mdx index 9d96cea98c..e27abbfd57 100644 --- a/docs/content/docs/v5/errors/step-not-registered.mdx +++ b/docs/content/docs/v5/errors/step-not-registered.mdx @@ -12,21 +12,21 @@ related: This error occurs when the Workflow runtime tries to execute a step function that is not registered in the current deployment. When this happens, the step fails (like a `FatalError`) and control is passed back to the workflow function, which can optionally handle the failure. -## Error Message +## Error message -``` +```text Step "" is not registered in the current deployment. This usually indicates a build or bundling issue that caused the step to not be included in the deployment. ``` -## Why This Happens +## Why this happens Workflow runs are pegged to a specific deployment, so this error is not caused by newer deployments overriding the running code. Instead, it means the step function was not included in the deployment's workflow bundle at build time. This is an **infrastructure error**, not a user code error. -## Common Causes +## Common causes ### Build tooling issue @@ -40,7 +40,7 @@ Something went wrong during the build process that caused the step function to n The step function was deleted or its `"use step"` directive was removed, but the workflow still references it. Ensure all steps referenced by your workflow are present in the codebase. -## How to Resolve +## How to resolve 1. **Check your build logs:** Look for errors or warnings related to workflow bundling. Ensure the step file contains a valid `"use step"` directive and is properly exported. diff --git a/docs/content/docs/v5/errors/timeout-in-workflow.mdx b/docs/content/docs/v5/errors/timeout-in-workflow.mdx index 78ead0b220..5505f5abad 100644 --- a/docs/content/docs/v5/errors/timeout-in-workflow.mdx +++ b/docs/content/docs/v5/errors/timeout-in-workflow.mdx @@ -15,19 +15,19 @@ related: This error occurs when you try to use `setTimeout()`, `setInterval()`, or related timing functions directly inside a workflow function. -## Error Message +## Error message -``` +```text Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions. Use the "sleep" function from "workflow" for time-based delays. ``` -## Why This Happens +## Why this happens Workflow functions run in a sandboxed environment where timing functions like `setTimeout()` and `setInterval()` are not available. These functions rely on asynchronous scheduling that would break the **deterministic replay** guarantees that workflows depend on. When a workflow suspends and later resumes, it replays from the event log. If timing functions were allowed, the replay would produce different results than the original execution. -## Quick Fix +## Quick fix Use the `sleep` function from the `workflow` package for time-based delays. Unlike `setTimeout()`, `sleep` is tracked in the event log and replays correctly. @@ -59,7 +59,7 @@ export async function delayedWorkflow() { } ``` -## Unavailable Functions +## Unavailable functions These timing functions cannot be used in workflow functions: @@ -70,9 +70,9 @@ These timing functions cannot be used in workflow functions: - `clearInterval()` - `clearImmediate()` -## Common Scenarios +## Common scenarios -### Polling with Delays +### Polling with delays If you need to poll an external service with delays between requests: @@ -102,7 +102,7 @@ async function checkStatus() { } ``` -### Scheduled Delays +### Scheduled delays For workflows that need to wait for a specific duration: diff --git a/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx b/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx index 473fc41c70..78a1f9c016 100644 --- a/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx +++ b/docs/content/docs/v5/errors/webhook-invalid-respond-with-value.mdx @@ -11,13 +11,13 @@ related: This error occurs when you provide an invalid value for the `respondWith` option when creating a webhook. The `respondWith` option must be either `"manual"` or a `Response` object. -## Error Message +## Error message -``` +```text Invalid `respondWith` value: [value] ``` -## Why This Happens +## Why this happens When creating a webhook with `createWebhook()`, you can specify how the webhook should respond to incoming HTTP requests using the `respondWith` option. This option only accepts specific values: @@ -25,9 +25,9 @@ When creating a webhook with `createWebhook()`, you can specify how the webhook 2. A `Response` object - A pre-defined response to send immediately 3. `undefined` (default) - Returns a `202 Accepted` response -## Common Causes +## Common causes -### Using an Invalid String Value +### Using an invalid string value ```typescript lineNumbers // Error - invalid string value @@ -60,7 +60,7 @@ export async function webhookWorkflow() { } ``` -### Using a Non-Response Object +### Using a non-Response object ```typescript lineNumbers // Error - plain object instead of Response @@ -88,9 +88,9 @@ export async function webhookWorkflow() { } ``` -## Valid Usage Examples +## Valid usage examples -### Default Behavior (202 Response) +### Default behavior (202 response) ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -101,7 +101,7 @@ const request = await webhook; // No need to send a response ``` -### Manual Response +### Manual response ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -125,7 +125,7 @@ await request.respondWith( ); ``` -### Pre-defined Response +### Pre-defined response ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -139,7 +139,7 @@ const request = await webhook; // Response already sent ``` -## Learn More +## Learn more - [createWebhook() API Reference](/docs/api-reference/workflow/create-webhook) - [resumeWebhook() API Reference](/docs/api-reference/workflow-api/resume-webhook) diff --git a/docs/content/docs/v5/errors/webhook-response-not-sent.mdx b/docs/content/docs/v5/errors/webhook-response-not-sent.mdx index 4b59ec6d40..b33292d16e 100644 --- a/docs/content/docs/v5/errors/webhook-response-not-sent.mdx +++ b/docs/content/docs/v5/errors/webhook-response-not-sent.mdx @@ -15,21 +15,21 @@ related: This error occurs when a webhook is configured with `respondWith: "manual"` but the workflow does not send a response using `request.respondWith()` before the webhook execution completes. -## Error Message +## Error message -``` +```text Workflow run did not send a response ``` -## Why This Happens +## Why this happens When you create a webhook with `respondWith: "manual"`, you are responsible for calling `request.respondWith()` to send the HTTP response back to the caller. If the workflow execution completes without sending a response, this error will be thrown. The webhook infrastructure waits for a response to be sent, and if none is provided, it cannot complete the HTTP request properly. -## Common Causes +## Common causes -### Forgetting to Call `request.respondWith()` +### Forgetting to call `request.respondWith()` ```typescript lineNumbers // Error - no response sent @@ -74,7 +74,7 @@ export async function webhookWorkflow() { } ``` -### Conditional Response Logic +### Conditional response logic ```typescript lineNumbers // Error - response only sent in some branches @@ -119,7 +119,7 @@ export async function webhookWorkflow() { } ``` -### Exception Before Response +### Exception before response ```typescript lineNumbers // Error - exception thrown before response @@ -168,7 +168,7 @@ export async function webhookWorkflow() { } ``` -## Alternative: Use Default Response Mode +## Alternative: Use default response mode If you don't need custom response control, consider using the default response mode which automatically returns a `202 Accepted` response: @@ -189,7 +189,7 @@ export async function webhookWorkflow() { } ``` -## Learn More +## Learn more - [createWebhook() API Reference](/docs/api-reference/workflow/create-webhook) - [resumeWebhook() API Reference](/docs/api-reference/workflow-api/resume-webhook) diff --git a/docs/content/docs/v5/errors/workflow-not-registered.mdx b/docs/content/docs/v5/errors/workflow-not-registered.mdx index 3a8ffd0622..9d5e0e900d 100644 --- a/docs/content/docs/v5/errors/workflow-not-registered.mdx +++ b/docs/content/docs/v5/errors/workflow-not-registered.mdx @@ -12,19 +12,19 @@ related: This error occurs when the Workflow runtime tries to execute a workflow function that is not registered in the current deployment. When this happens, the run fails with a `RUNTIME_ERROR` error code. -## Error Message +## Error message -``` +```text Workflow "" is not registered in the current deployment. This usually means a run was started against a deployment that does not have this workflow, or there was a build/bundling issue. ``` -## Why This Happens +## Why this happens This error means the deployment that received the workflow execution request does not have the specified workflow function in its bundle. This is an **infrastructure error**, not a user code error. -## Common Causes +## Common causes ### Run started against a deployment without the workflow @@ -57,7 +57,7 @@ Something went wrong during the build process that caused the workflow function - The workflow function is not exported from the workflow file - An esbuild or SWC plugin error silently excluded the workflow -## How to Resolve +## How to resolve 1. **If the workflow was renamed or moved:** Deploy with the workflow restored to its original name and location, then retry the run. Alternatively, start a new run using the updated workflow name against the current deployment. diff --git a/docs/content/docs/v5/foundations/cancellation.mdx b/docs/content/docs/v5/foundations/cancellation.mdx index 6e67cedbe4..73f85039f2 100644 --- a/docs/content/docs/v5/foundations/cancellation.mdx +++ b/docs/content/docs/v5/foundations/cancellation.mdx @@ -14,7 +14,7 @@ Workflow DevKit supports two cancellation mechanisms: **AbortSignal** for fine-g ## AbortSignal -`AbortController` and `AbortSignal` work across workflow and step boundaries. Create an `AbortController` with `new AbortController()` in a workflow function, pass its signal to steps, and call `abort()` — using the standard [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API you already know. +`AbortController` and `AbortSignal` work across workflow and step boundaries. Create an `AbortController` with `new AbortController()` in a workflow function, pass its signal to steps, and call `abort()` using the standard [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) API. ```typescript lineNumbers import { sleep } from "workflow"; @@ -48,17 +48,17 @@ async function longRunningStep(signal: AbortSignal) { } ``` -No special imports, no wrapper functions — just the standard `AbortController` API. +Use the standard `AbortController` API without special imports or wrapper functions. -Cancellation is **cooperative**. Aborting a signal doesn't forcefully kill a step — it's up to the step's code to check `signal.aborted` or pass the signal to APIs like `fetch` that respect it. If a step ignores the signal, it runs to completion. +Cancellation is **cooperative**. Aborting a signal doesn't forcefully kill a step. The step's code must check `signal.aborted` or pass the signal to APIs like `fetch` that respect it. If a step ignores the signal, it runs to completion. To learn how `AbortController` works durably across workflow suspensions, replays, and step boundaries, see [How Cancellation Works](/docs/how-it-works/cancellation). -### Timeout with Cancellation +### Timeout with cancellation Race a step against a timeout, and cancel the step if the timeout wins: @@ -90,7 +90,7 @@ async function fetchUrl(url: string, signal: AbortSignal) { } ``` -### Cancelling Parallel Work +### Cancelling parallel work When racing multiple steps, cancel the losers: @@ -116,7 +116,7 @@ async function fetchUrl(url: string, signal: AbortSignal) { } ``` -### Passing Signal Through a Pipeline +### Passing signal through a pipeline Pass the same signal to a chain of steps. Aborting cancels whichever step is currently running: @@ -175,7 +175,7 @@ async function uploadData(data: ArrayBuffer, signal: AbortSignal) { } ``` -### Step-Initiated Abort +### Step-initiated abort A step can receive the full `AbortController` and call `abort()` to cancel parallel work. This is useful for watchdog/monitor patterns where one step observes an external condition and cancels other in-flight steps: @@ -220,7 +220,7 @@ async function monitorQuota(userId: string, controller: AbortController) { } ``` -### User-Triggered Cancellation with Hooks +### User-triggered cancellation with hooks Combine hooks with abort controllers to let users cancel in-flight work from an external API: @@ -266,23 +266,23 @@ export async function POST(request: Request) { } ``` -### How Steps Handle Abort +### How steps handle abort When an `AbortSignal` is aborted, the behavior depends on how the step uses it: -| Usage | Behavior on Abort | +| Usage | Behavior on abort | |-------|-------------------| -| `fetch(url, { signal })` | Request is cancelled, throws `AbortError` | +| `fetch(url, { signal })` | Request is canceled, throws `AbortError` | | `signal.throwIfAborted()` | Throws the abort reason | | `signal.aborted` check | Returns `true`, step can exit gracefully | | `signal.addEventListener('abort', fn)` | Callback fires, step can clean up | | Ignored | Step runs to completion (abort is cooperative) | -### Abort Errors Skip Retries +### Abort errors skip retries -When a step throws due to an abort (e.g., `fetch` throws `AbortError`, or `signal.throwIfAborted()` throws), the error is automatically wrapped in a `FatalError`. This means the step **skips retries** and the error bubbles up to the workflow immediately. +When a step throws due to an abort (for example, `fetch` throws `AbortError`, or `signal.throwIfAborted()` throws), the runtime wraps the error in a `FatalError`. The step **skips retries**, and the error immediately bubbles up to the workflow. -This is the correct behavior because an abort is an intentional cancellation — retrying the step would just result in another abort. You don't need to manually wrap abort errors in `FatalError`. +An abort is an intentional cancellation, so retrying the step would result in another abort. You don't need to manually wrap abort errors in `FatalError`. ```typescript lineNumbers import { sleep } from "workflow"; @@ -299,7 +299,7 @@ export async function workflow() { if (result === null) controller.abort(); return result; } catch (err) { - // AbortError arrives as FatalError — no retries attempted // [!code highlight] + // AbortError arrives as FatalError, with no retries attempted // [!code highlight] return { status: "cancelled" }; } } @@ -312,7 +312,7 @@ async function cancellableStep(signal: AbortSignal) { } ``` -### Passing AbortSignal as Workflow Input +### Passing AbortSignal as workflow input You can pass an `AbortSignal` from external code into a workflow via `start()`: @@ -329,15 +329,15 @@ export async function POST(request: Request) { } ``` -When the signal is serialized at the `start()` boundary, an event listener is attached to the external signal that writes the cancellation packet to the backing stream. This means the external `abort()` propagates into the workflow — but only while the originating process is still alive (same constraint as passing a `ReadableStream` as input). +When the signal is serialized at the `start()` boundary, an event listener attaches to the external signal and writes the cancellation packet to the backing stream. The external `abort()` propagates into the workflow only while the originating process is still alive (the same constraint as passing a `ReadableStream` as input). For reliable external cancellation that works regardless of process lifetime, prefer the [User-Triggered Cancellation with Hooks](#user-triggered-cancellation-with-hooks) pattern. Hooks are durable and don't depend on the caller's process staying alive. -## Run Cancellation +## Run cancellation -Run cancellation stops an entire workflow at the next suspension point. Unlike `AbortSignal`, it is not cooperative — the workflow does not continue executing after cancellation. +Run cancellation stops an entire workflow at the next suspension point. Unlike `AbortSignal`, it is not cooperative. The workflow does not continue executing after cancellation. ```typescript title="app/api/cancel-run/route.ts" lineNumbers import { getRun } from "workflow/api"; @@ -353,10 +353,10 @@ export async function POST(request: Request) { ``` -Calling `run.cancel()` is the same action as clicking the **Cancel** button on a run in the observability UI — both produce identical `run_cancelled` events in the event log. +Calling `run.cancel()` is the same action as clicking the **Cancel** button on a run in the observability UI. Both produce identical `run_cancelled` events in the event log. -When a run is cancelled: +When a run is canceled: - The workflow stops at its next suspension point (step call, hook await, or sleep) - A `run_cancelled` event is recorded in the [event log](/docs/how-it-works/event-sourcing) - All associated hooks are disposed and their tokens released @@ -366,19 +366,19 @@ When a run is cancelled: Run cancellation does **not** automatically abort any outstanding `AbortSignal`s. Steps that are currently executing will run to completion. If you need in-flight cancellation of specific operations, use `AbortSignal`. -## AbortSignal vs. Run Cancellation +## AbortSignal vs. run cancellation | | AbortSignal | Run Cancellation | |---|---|---| | **Scope** | Individual operations within a step | Entire workflow run | | **Triggered by** | Your code (`controller.abort()`) | External API (`run.cancel()`) | -| **Cooperative** | Yes — steps must check the signal | No — workflow stops at the next suspension point | +| **Cooperative** | Yes. Steps must check the signal | No. The workflow stops at the next suspension point | | **Granularity** | Can target specific steps or operations | All-or-nothing | | **In-flight steps** | Aborted immediately if using the signal | Run to completion | Use `AbortSignal` when you need fine-grained, in-flight cancellation of specific operations. Use run cancellation when you want to stop the entire workflow. -## Best Practices +## Best practices **Use `throwIfAborted()` before expensive work.** This throws the signal's abort reason if the signal is already aborted, preventing wasted compute: @@ -448,12 +448,12 @@ export async function workflow() { } ``` -This is safe even if both steps have already completed — aborting a finished operation is a no-op. +This is safe even if both steps have already completed because aborting a finished operation is a no-op. -## Related Documentation +## Related documentation -- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream backing, serialization internals -- [Serialization](/docs/foundations/serialization) — Understanding serializable types -- [Cookbook](/cookbook) — Timeout, race, and other reliability patterns -- [Hooks](/docs/foundations/hooks) — Pausing workflows for external events -- [Errors and Retries](/docs/foundations/errors-and-retries) — Handling step failures +- [How Cancellation Works](/docs/how-it-works/cancellation): Hook and stream backing, serialization internals +- [Serialization](/docs/foundations/serialization): Understanding serializable types +- [Cookbook](/cookbook): Timeout, race, and other reliability patterns +- [Hooks](/docs/foundations/hooks): Pausing workflows for external events +- [Errors and Retries](/docs/foundations/errors-and-retries): Handling step failures diff --git a/docs/content/docs/v5/foundations/errors-and-retries.mdx b/docs/content/docs/v5/foundations/errors-and-retries.mdx index c079b53d0a..4ea7a6b4c6 100644 --- a/docs/content/docs/v5/foundations/errors-and-retries.mdx +++ b/docs/content/docs/v5/foundations/errors-and-retries.mdx @@ -1,6 +1,6 @@ --- title: Errors & Retrying -description: Customize retry behavior with FatalError and RetryableError for robust error handling. +description: Customize retry behavior with FatalError and RetryableError. type: conceptual summary: Control how steps handle failures and customize retry behavior. prerequisites: @@ -12,7 +12,7 @@ related: By default, errors thrown inside steps are retried. Additionally, Workflow SDK provides two new types of errors you can use to customize retries. -## Default Retrying +## Default retrying By default, steps retry up to 3 times on arbitrary errors. You can customize the number of retries by adding a `maxRetries` property to the step function. @@ -42,9 +42,9 @@ Steps get enqueued immediately after a failure. Read on to see how this can be c more information. -## Intentional Errors +## Intentional errors -When your step needs to intentionally throw an error and skip retrying, simply throw a [`FatalError`](/docs/api-reference/workflow/fatal-error). +When your step needs to intentionally throw an error and skip retrying, throw a [`FatalError`](/docs/api-reference/workflow/fatal-error). ```typescript lineNumbers import { FatalError } from "workflow"; @@ -67,7 +67,7 @@ async function callApi(endpoint: string) { } ``` -## Customize Retry Behavior +## Customize retry behavior When you need to customize the delay on a retry, use [`RetryableError`](/docs/api-reference/workflow/retryable-error) and set the `retryAfter` property. @@ -97,7 +97,7 @@ async function callApi(endpoint: string) { } ``` -## Advanced Example +## Advanced example This final example combines everything we've learned, along with [`getStepMetadata`](/docs/api-reference/workflow/get-step-metadata). @@ -139,7 +139,7 @@ callApi.maxRetries = 5; // Retry up to 5 times on failure (6 total attempts) step can run up to 4 times total (1 initial attempt + 3 retries). -## Serialization Failures +## Serialization failures A step whose arguments or return value cannot be [serialized](/docs/foundations/serialization) fails like a step whose body threw a `FatalError`: the failure is deterministic, so it skips the retry loop, and a `try/catch` around the step call observes the `SerializationError`: @@ -162,9 +162,9 @@ export async function myWorkflow(input: unknown) { } ``` -Uncaught, the run fails immediately with the `USER_ERROR` code — without retrying. See [serialization-failed](/docs/errors/serialization-failed) for common causes and fixes. +Uncaught, the run fails immediately with the `USER_ERROR` code, without retrying. See [serialization-failed](/docs/errors/serialization-failed) for common causes and fixes. -## Error Codes +## Error codes When a workflow run fails, the error includes an `errorCode` that classifies the failure, alongside the original thrown value (preserved as `cause`): @@ -194,16 +194,16 @@ try { | `MAX_EVENTS_EXCEEDED` | The run reached the World's per-run event ceiling (25,000 on the Local and Vercel Worlds). Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows); see [Limits](/docs/configuration/runtime-tuning#limits) | | `MAX_DELIVERIES_EXCEEDED` | The run exceeded the maximum number of queue deliveries | | `REPLAY_TIMEOUT` | A workflow replay exceeded the maximum allowed duration | -| `REPLAY_DIVERGENCE` | A replay could not consume the event log deterministically — usually non-deterministic workflow code | +| `REPLAY_DIVERGENCE` | A replay could not consume the event log deterministically, usually because of non-deterministic workflow code. | | `CORRUPTED_EVENT_LOG` | The event log contains orphaned or mismatched events and cannot be replayed. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) | | `WORLD_CONTRACT_ERROR` | A World response violated the SDK contract; points at a World implementation bug | | `RUNTIME_ERROR` | An internal runtime error. If you see this, please [file an issue](https://github.com/vercel/workflow/issues) | - The error code is also available on the run entity via the CLI (`npx workflow inspect runs `) in the `error.code` field, and as an OTEL span attribute (`workflow.error.code`) for observability. + The error code is also available on the run entity through the Workflow CLI (`npx workflow inspect runs `) in the `error.code` field, and as an OpenTelemetry span attribute (`workflow.error.code`) for observability. -## Rolling Back Failed Steps +## Rolling back failed steps When a workflow fails partway through, it can leave the system in an inconsistent state. A common pattern to address this is "rollbacks": for each successful step, record a corresponding rollback action that can undo it. diff --git a/docs/content/docs/v5/foundations/hooks.mdx b/docs/content/docs/v5/foundations/hooks.mdx index 531eaa29ef..61e4d1e54f 100644 --- a/docs/content/docs/v5/foundations/hooks.mdx +++ b/docs/content/docs/v5/foundations/hooks.mdx @@ -11,9 +11,9 @@ related: - /docs/ai/human-in-the-loop --- -Hooks provide a powerful mechanism for pausing workflow execution and resuming it later with external data. They enable workflows to wait for external events, user interactions (also known as "human in the loop"), or HTTP requests. This guide will teach you the core concepts, starting with the low-level Hook primitive and building up to the higher-level Webhook abstraction. +Hooks pause workflow execution and resume it later with external data. Workflows can wait for external events, user interactions (also known as "human in the loop"), or HTTP requests. -## Understanding Hooks +## Understanding hooks At their core, **Hooks** are a low-level primitive that allows you to pause a workflow and resume it later with arbitrary [serializable data](/docs/foundations/serialization). Think of them as suspension points in your workflow where you're waiting for external input. @@ -23,9 +23,9 @@ When you create a hook, it generates a unique token that external systems can us - Receiving data from an external system or service - Implementing event-driven workflows that react to multiple events over time -### Creating Your First Hook +### Creating your first hook -Let's start with a simple example. Here's a workflow that creates a hook and waits for external data: +This workflow creates a hook and waits for external data: ```typescript lineNumbers import { createHook } from "workflow"; @@ -59,7 +59,7 @@ We recommend using the `using` keyword which implements the [TC39 Explicit Resou See the full API reference for [`createHook()`](/docs/api-reference/workflow/create-hook) for all available options. -### Resuming a Hook +### Resuming a hook To send data to a waiting workflow, use [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) from an API route, server action, or any other external context: @@ -85,7 +85,7 @@ The key points: - You need the hook's `token` to resume it - The workflow will resume execution right where it left off -### Checking for Token Conflicts +### Checking for token conflicts Sometimes you need to know that a hook token has been claimed, but you do not want to wait for external data yet. Await `hook.getConflict()` for that: @@ -112,9 +112,9 @@ export async function orderWorkflow(orderId: string) { } ``` -Calling `createHook()` on its own does not register the hook — registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()` — see [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies. +Calling `createHook()` on its own does not register the hook; registration is only committed when the workflow suspends. Awaiting `hook.getConflict()` suspends the workflow to commit the hook registration, then resolves with `null` once the hook is registered and ready to receive payloads, or with a `Run` handle for the run that owns the token (see [`HookConflictError`](/docs/errors/hook-conflict)). For `hook_conflict` events persisted by older worlds that did not record the owning run's ID, `getConflict()` rejects with `HookConflictError` instead of resolving with an incomplete handle. The conflicting run's accessors are durable steps, so the workflow can inspect `await conflict.status`, wait on `await conflict.returnValue`, or cancel the owner with `await conflict.cancel()`. See [Run idempotency](/docs/foundations/idempotency#run-idempotency) for these strategies. -### Custom Tokens for Deterministic Hooks +### Custom tokens for deterministic hooks By default, hooks generate their own token. However, you often want to use a **custom token** that external systems can reconstruct. This is especially useful for long-running workflows where the same workflow instance should handle multiple events. @@ -168,9 +168,9 @@ export async function POST(request: Request) { } ``` -### Receiving Multiple Events +### Receiving multiple events -Hooks are _reusable_ - they implement `AsyncIterable`, which means you can use `for await...of` to receive multiple events over time: +Hooks are _reusable_. They implement `AsyncIterable`, which means you can use `for await...of` to receive multiple events over time: ```typescript lineNumbers import { createHook } from "workflow"; @@ -198,7 +198,7 @@ export async function dataCollectionWorkflow() { Each time you call `resumeHook()` with the same token, the loop receives another value. -### Disposing Hooks Early +### Disposing hooks early When a workflow ends, hooks are automatically disposed. However, you may want to release a hook token early so another workflow can use it while your workflow continues running. Use a block scope with `using` to control when disposal happens: @@ -240,9 +240,9 @@ hook.dispose(); // Manually release the token After disposal, the hook will no longer receive events and the async iterator will stop yielding values. -## Understanding Webhooks +## Understanding webhooks -While hooks are powerful, they require you to manually handle HTTP requests and route them to workflows. **Webhooks** solve this by providing a higher-level abstraction built on top of hooks that: +Hooks require you to manually handle HTTP requests and route them to workflows. **Webhooks** provide a higher-level abstraction built on top of hooks that: 1. Automatically serializes the entire HTTP [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) object 2. Provides an automatically addressable `url` property pointing to the generated webhook endpoint @@ -251,16 +251,16 @@ While hooks are powerful, they require you to manually handle HTTP requests and When using Workflow SDK, webhooks are automatically wired up at `/.well-known/workflow/v1/webhook/:token` without any additional setup. -`createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. This is convenient for prototypes and a simple developer experience because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. +`createWebhook()` exposes a public route at `/.well-known/workflow/v1/webhook/:token`, and the token in that URL is the only authorization performed for incoming requests. This is convenient for prototypes because you can share the webhook URL (endpoint) without creating another route, but if you need stronger security, prefer [`createHook()`](/docs/api-reference/workflow/create-hook) behind your own route and authorize the request before calling [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) to avoid unauthenticated workflow resumptions. See the full API reference for [`createWebhook()`](/docs/api-reference/workflow/create-webhook) for all available options. -### Creating Your First Webhook +### Creating your first webhook -Here's a simple webhook that receives HTTP requests. Like hooks, webhooks support the `using` keyword for automatic cleanup: +Here's a webhook that receives HTTP requests. Like hooks, webhooks support the `using` keyword for automatic cleanup: ```typescript lineNumbers import { createWebhook } from "workflow"; @@ -284,13 +284,13 @@ export async function webhookWorkflow() { } ``` -The webhook will automatically respond with a `202 Accepted` status by default. External systems can simply make an HTTP request to the `webhook.url` to resume your workflow. +The webhook will automatically respond with a `202 Accepted` status by default. External systems can make an HTTP request to the `webhook.url` to resume your workflow. -### Sending Custom Responses +### Sending custom responses Webhooks provide two ways to send custom HTTP responses: **static responses** and **dynamic responses**. -#### Static Responses +#### Static responses Use the `respondWith` option to provide a static response that will be sent automatically for every request: @@ -319,7 +319,7 @@ async function processData(data: any) { } ``` -#### Dynamic Responses (Manual Mode) +#### Dynamic responses (manual mode) For dynamic responses based on the request content, set `respondWith: "manual"` and call the `respondWith()` method on the request: @@ -365,7 +365,7 @@ export async function webhookWithDynamicResponse() { When using `respondWith: "manual"`, the `respondWith()` method **must** be called from within a step function due to serialization requirements. This requirement may be removed in the future. -### Handling Multiple Webhook Requests +### Handling multiple webhook requests Like hooks, webhooks support iteration: @@ -405,7 +405,7 @@ export async function eventCollectorWorkflow() { } ``` -## Hooks vs. Webhooks: When to Use Each +## Hooks vs. webhooks: when to use each | Feature | Hooks | Webhooks | |---------|-------|----------| @@ -415,19 +415,19 @@ export async function eventCollectorWorkflow() { | **Use Case** | Custom integrations, type-safe payloads | HTTP webhooks, standard REST APIs | | **Resuming** | [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) | Automatic via HTTP, or [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) | -**Use Hooks when:** +**Use hooks when:** - You need full control over the payload structure - You're integrating with custom event sources - You want strong TypeScript typing with [`defineHook()`](/docs/api-reference/workflow/define-hook) -**Use Webhooks when:** +**Use webhooks when:** - You're receiving HTTP requests from external services - You need to send HTTP responses back to the caller - You want automatic URL routing without writing API handlers -## Advanced Patterns +## Advanced patterns -### Type-Safe Hooks with `defineHook()` +### Type-safe hooks with `defineHook()` The [`defineHook()`](/docs/api-reference/workflow/define-hook) helper provides type safety and runtime validation between creating and resuming hooks using [Standard Schema v1](https://standardschema.dev). Use any compliant validator like Zod or Valibot: @@ -476,25 +476,25 @@ export async function POST(request: Request) { This pattern is especially valuable in larger applications where the workflow and API code are in separate files, providing both compile-time type safety and runtime validation. -## Best Practices +## Best practices -### Token Design +### Token design Custom tokens are available for `createHook()` with server-side `resumeHook()` only. Webhooks (`createWebhook()`) always generate their own unique tokens. A generated token is not trivial to guess, but it is not a strong security contract either, so anyone who obtains the URL can invoke an unintended webhook resumption. To prevent unauthenticated run resumptions entirely, prefer a **hook** over the **webhook** convenience and implement your own authentication on the route that calls `resumeHook()`. When using custom tokens with `createHook()`: -- **Make them deterministic**: Base them on data the external system can reconstruct (like channel IDs, user IDs, etc.) -- **Use namespacing**: Prefix tokens to avoid conflicts (e.g., `slack:${channelId}`, `github:${repoId}`) -- **Include routing information**: Ensure the token contains enough information to identify the correct workflow instance +- **Make them deterministic**: Base them on data the external system can reconstruct, such as channel IDs or user IDs. +- **Use namespacing**: Prefix tokens to avoid conflicts, such as `slack:${channelId}` or `github:${repoId}`. +- **Include routing information**: Ensure the token contains enough information to identify the correct workflow instance. -### Response Handling in Webhooks +### Response handling in webhooks -- Use **static responses** (`respondWith: Response`) for simple acknowledgments +- Use **static responses** (`respondWith: Response`) for acknowledgments - Use **manual mode** (`respondWith: "manual"`) when responses depend on request processing - Remember that `respondWith()` must be called from within a step function -### Iterating Over Events +### Iterating over events Both hooks and webhooks support iteration, making them perfect for long-running event loops: @@ -513,7 +513,7 @@ for await (const event of hook) { This pattern allows a single workflow instance to handle multiple events over time, maintaining state between events. -## Related Documentation +## Related documentation - [Serialization](/docs/foundations/serialization) - Understanding what data can be passed through hooks - [`createHook()` API Reference](/docs/api-reference/workflow/create-hook) diff --git a/docs/content/docs/v5/foundations/idempotency.mdx b/docs/content/docs/v5/foundations/idempotency.mdx index ed51c86faa..a4c6af82c2 100644 --- a/docs/content/docs/v5/foundations/idempotency.mdx +++ b/docs/content/docs/v5/foundations/idempotency.mdx @@ -15,9 +15,9 @@ Idempotency is a property of an operation that ensures repeated attempts have th In Workflow, idempotency shows up in two related places: step idempotency makes external calls safe when a step retries, and run idempotency coordinates duplicate requests that try to start the same workflow. -## Step Idempotency +## Step idempotency -In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once just by seeing if it succeeds. +In distributed systems (calling external APIs), it is not always possible to ensure an operation has only been performed once by seeing if it succeeds. Consider a payment API that charges the user $10, but due to network failures, the confirmation response is lost. When the step retries (because the previous attempt was considered a failure), it will charge the user again. To prevent this, many external APIs support idempotency keys. An idempotency key is a unique identifier for an operation that can be used to deduplicate requests. @@ -61,7 +61,7 @@ Because [hooks](/docs/foundations/hooks) already ensure globally unique active t Use a hook token as the idempotency key for an active workflow run. Hook tokens are globally unique while they are active: if another run tries to create a hook with the same token, the runtime records a conflict, `hook.getConflict()` resolves with a `Run` handle for the run that owns the token, and the hook rejects with [`HookConflictError`](/docs/errors/hook-conflict) when the workflow awaits or iterates its payload. -The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook — awaiting `getConflict()` suspends the workflow to commit the registration. +The token should come from your domain, such as an order ID, invoice ID, import ID, or request ID. Create the hook near the beginning of the workflow and check `await hook.getConflict()` before doing duplicate-sensitive work that depends on owning the active token. Calling `createHook()` alone does not register the hook; awaiting `getConflict()` suspends the workflow to commit the registration. ```typescript lineNumbers import { createHook } from "workflow"; @@ -96,7 +96,7 @@ export async function processOrder(orderId: string): Promise { } ``` -The runtime creates the hook atomically. At most one hook can own `order:${orderId}`, so duplicate workflow runs converge on one owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner — see [conflict-handling strategies](#conflict-handling-strategies) below. +The runtime creates the hook atomically. At most one hook can own `order:${orderId}`, so duplicate workflow runs converge on one owner. A duplicate run observes `getConflict()` resolving with the owner's `Run` and returns before it reaches `chargeOrder()`. The conflicting run's accessors (`status`, `returnValue`, `cancel()`, …) are durable steps, so the duplicate run can do more than report the owner. See [conflict-handling strategies](#conflict-handling-strategies) below. Outside the workflow, try to resume the hook first. If the hook is not registered yet, start the workflow and retry the resume until the new run creates the hook: @@ -146,14 +146,14 @@ export async function POST(request: Request) { ``` -This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work — and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`. +This avoids creating a new run only after the first run has registered its hook. Because `start()` returns before the run body executes and calls `createHook()`, two concurrent requests can both observe "no hook yet" and each call `start()`. The race is resolved inside the workflow body, where the losing run observes `getConflict()` resolving with the active owner and returns without doing duplicate-sensitive work, and the route detects it by comparing the resumed hook's `runId` against the run it just started, without waiting for either run to finish. A native API for atomically starting a run and registering a hook is in the works. Until then, model recovery inside the workflow by checking `hook.getConflict()`. This coordinates active runs by default: the token becomes available when its workflow ends. Set `experimental_minRetention` to keep it unavailable to late duplicates. After the workflow ends, the Hook can still be found with `getHookByToken()` until retention ends, but it cannot be resumed. See [`createHook()` minimum retention](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) for examples and supported values. ### Conflict-handling strategies -Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy — typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting `Run` itself, and the policy is ordinary code — including policies that inspect state before deciding, which static configuration can't express. +Some workflow systems resolve duplicate IDs with a fixed, pre-declared policy, typically a static choice between rejecting the new execution, deferring to the existing one, or terminating it. Workflow has no policy enum. `hook.getConflict()` hands the duplicate run the conflicting `Run` itself, and the policy is ordinary code, including policies that inspect state before deciding, which static configuration can't express. The example above implements **reject the duplicate**: return the owner's `runId` and let the caller decide. Other common strategies: @@ -260,7 +260,7 @@ export async function processOrderNewestWins(orderId: string) { const conflict = await request.getConflict(); if (!conflict) { - // Token claimed — this run is now the owner. + // Token claimed: this run is now the owner. const { confirmed } = await request; if (confirmed) { await chargeOrder(orderId); @@ -271,12 +271,12 @@ export async function processOrderNewestWins(orderId: string) { await conflict.cancel(); // [!code highlight] } - throw new Error(`Could not claim ${token} after cancelling the owner`); + throw new Error(`Could not claim ${token} after canceling the owner`); } ``` -This pattern does not work with `experimental_minRetention`: cancelling the old run does not make its token available early. +This pattern does not work with `experimental_minRetention`: canceling the old run does not make its token available early. If duplicate requests should only reuse the active run without sending data, use [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) as an advisory pre-check before calling `start()`. The workflow should still check `hook.getConflict()`, because the lookup and `start()` are not atomic. diff --git a/docs/content/docs/v5/foundations/serialization.mdx b/docs/content/docs/v5/foundations/serialization.mdx index 1e3326a466..705f6ecc44 100644 --- a/docs/content/docs/v5/foundations/serialization.mdx +++ b/docs/content/docs/v5/foundations/serialization.mdx @@ -15,11 +15,11 @@ All function arguments and return values passed between workflow and step functi The serialization system ensures that all data persists correctly across workflow suspensions and resumptions, enabling durable execution. -## Supported Serializable Types +## Supported serializable types The following types can be serialized and passed through workflow functions: -**Standard JSON Types:** +**Standard JSON types:** - `string` - `number` @@ -28,7 +28,7 @@ The following types can be serialized and passed through workflow functions: - Arrays of serializable values - Objects with string keys and serializable values -**Extended Types:** +**Extended types:** - `undefined` - `bigint` @@ -58,7 +58,7 @@ These types have special handling and are explained in detail in the sections be - `AbortController` - `AbortSignal` -## Pass-by-Value Semantics +## Pass-by-value semantics **Parameters are passed by value, not by reference.** Steps receive deserialized copies of data. Mutations inside a step won't affect the original in the workflow. @@ -81,7 +81,7 @@ async function updateUserStep(user: { id: string; name: string; email: string }) } ``` -**Correct - return the modified data:** +**Correct, return the modified data:** ```typescript title="workflows/correct-mutation.ts" lineNumbers export async function updateUserWorkflow(userId: string) { @@ -100,7 +100,7 @@ async function updateUserStep(user: { id: string; name: string; email: string }) } ``` -**Custom Classes:** +**Custom classes:** - Class instances that implement [`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`](#custom-class-serialization) @@ -110,7 +110,7 @@ async function updateUserStep(user: { id: string; name: string; email: string }) For complete information about using streams in workflows, including patterns for AI streaming, file processing, and progress updates, see the [Streaming Guide](/docs/foundations/streaming). -## Request & Response +## Request & response The Web API [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) APIs are supported by the serialization system, and can be passed around between workflow and step functions similarly to other data types. @@ -140,7 +140,7 @@ export async function handleWebhookWorkflow() { } ``` -### Using `fetch` in Workflows +### Using `fetch` in workflows Because `Request` and `Response` are serializable, Workflow SDK provides a `fetch` function that can be used directly in workflow functions: @@ -158,7 +158,7 @@ export async function apiWorkflow() { } ``` -The implementation is straightforward - `fetch` from workflow is a step function that wraps the standard `fetch`: +The `fetch` implementation from `workflow` is a step function that wraps the standard `fetch`: ```typescript title="Implementation" lineNumbers export async function fetch(...args: Parameters) { @@ -202,11 +202,11 @@ async function fetchData(signal: AbortSignal) { For usage patterns including timeouts, parallel cancellation, user-triggered cancellation, and run cancellation, see the [Cancellation Guide](/docs/foundations/cancellation). For details on the hook and stream backing that makes this work, see [How Cancellation Works](/docs/how-it-works/cancellation). -## Custom Class Serialization +## Custom class serialization By default, custom class instances cannot be serialized because the serialization system doesn't know how to reconstruct them. You can make your classes serializable by implementing two static methods using special symbols from the `@workflow/serde` package. -### Basic Example +### Basic example {/* @expect-error:2351 */} @@ -256,13 +256,13 @@ async function doublePoint(point: Point) { } ``` -### How It Works +### How it works 1. **`WORKFLOW_SERIALIZE`**: A static method that receives a class instance and returns serializable data (primitives, plain objects, arrays, etc.) 2. **`WORKFLOW_DESERIALIZE`**: A static method that receives the serialized data and returns a new class instance -3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time — no manual registration step is required +3. **Automatic Registration**: The SWC compiler plugin automatically detects classes that implement these symbols and registers them for serialization. Each class receives a deterministic `classId` derived from its file path and class name, and is registered into the global `Symbol.for("workflow-class-registry")` registry at build time. No manual registration step is required ### Requirements @@ -279,12 +279,12 @@ The `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE` methods run inside the workf - No non-deterministic operations (like `Math.random()` or `Date.now()`) - No external network calls -Keep these methods simple and focused on data transformation only. +Keep these methods focused on data transformation only. -### Instance Methods as Steps +### Instance methods as steps -In practice, many classes have methods that need Node.js APIs, perform network calls, or interact with databases — operations that are not allowed in the `"use workflow"` execution context. You can make these methods workflow-compatible by adding `"use step"` to them. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step — with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary. +In practice, many classes have methods that need Node.js APIs, perform network calls, or interact with databases, operations that are not allowed in the `"use workflow"` execution context. You can make these methods workflow-compatible by adding `"use step"` to them. The SWC compiler will strip the method bodies from the workflow bundle and replace them with proxy functions that invoke the method as a step, with full Node.js runtime access. The `this` context (the class instance) is automatically serialized and deserialized across the workflow/step boundary. This requires the class to implement `WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`, so that the instance can be passed to the step execution context. @@ -301,7 +301,7 @@ class Order { public createdAt: Date ) {} - // Custom serialization — data must be serializable types + // Custom serialization: data must be serializable types static [WORKFLOW_SERIALIZE](instance: Order) { // [!code highlight] return { // [!code highlight] id: instance.id, // [!code highlight] @@ -329,7 +329,7 @@ class Order { } // Instance methods with "use step" run as step functions - // with full Node.js access — `this` is automatically serialized + // with full Node.js access; `this` is automatically serialized async save(): Promise { "use step"; // [!code highlight] await db.orders.insert({ // [!code highlight] @@ -355,7 +355,7 @@ class Order { } ``` -The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step — with automatic caching, retry semantics, and full Node.js runtime access. Methods _without_ `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions: +The class can then be used naturally inside a workflow function. Instance methods marked with `"use step"` are each executed as a step, with automatic caching, retry semantics, and full Node.js runtime access. Methods _without_ `"use step"` run directly in the workflow context, so they must follow the same constraints as workflow functions: {/* @expect-error:2693 */} @@ -369,7 +369,7 @@ export async function processOrderWorkflow( const order = new Order(orderId, items, new Date()); // [!code highlight] - // Runs in the workflow context — no "use step" needed + // Runs in the workflow context; no "use step" needed const itemCount = order.total(); // [!code highlight] // Each "use step" instance method call runs as a separate step @@ -380,7 +380,7 @@ export async function processOrderWorkflow( } ``` -Note that [pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow: +[Pass-by-value semantics](#pass-by-value-semantics) also apply to the `this` context of `"use step"` instance methods. Modifying instance properties inside a step method will not affect the original instance in the workflow. If you need to update instance state, return `this` from the step method and re-assign the variable in the workflow: {/* @expect-error:2351 */} @@ -408,4 +408,3 @@ export async function processOrderWorkflow() { order = await order.addItem("Widget", 3); // [!code highlight] } ``` - diff --git a/docs/content/docs/v5/foundations/starting-workflows.mdx b/docs/content/docs/v5/foundations/starting-workflows.mdx index 63db2b813a..f1b851e38c 100644 --- a/docs/content/docs/v5/foundations/starting-workflows.mdx +++ b/docs/content/docs/v5/foundations/starting-workflows.mdx @@ -1,5 +1,5 @@ --- -title: Starting Workflows +title: Starting workflows description: Trigger workflow execution with the start() function and track progress with Run objects. type: guide summary: Trigger workflows and track their execution using the start() function. @@ -9,11 +9,11 @@ related: - /docs/api-reference/workflow-api/start --- -Once you've defined your workflow functions, you need to trigger them to begin execution. This is done using the `start()` function from `workflow/api`, which enqueues a new workflow run and returns a `Run` object that you can use to track its progress. +After you define a workflow function, use the `start()` function from `workflow/api` to trigger it. The function enqueues a new workflow run and returns a `Run` object for tracking its progress. -## The `start()` Function +## The `start()` function -The [`start()`](/docs/api-reference/workflow-api/start) function is used to programmatically trigger workflow executions from runtime contexts like API routes, Server Actions, or any server-side code. In v5, you can also call `start()` from inside a workflow function when you want to spawn a child run or continue work in a new run. +The [`start()`](/docs/api-reference/workflow-api/start) function programmatically triggers workflow executions from runtime contexts such as API routes, Server Actions, or other server-side code. In v5, you can also call `start()` inside a workflow function to spawn a child run or continue work in a new run. ```typescript lineNumbers import { start } from "workflow/api"; @@ -32,21 +32,21 @@ export async function POST(request: Request) { } ``` -**Key Points:** +**Key points:** -- `start()` returns immediately after enqueuing the workflow - it doesn't wait for completion +- `start()` returns immediately after enqueuing the workflow. It doesn't wait for completion - The first argument is your workflow function - The second argument is an array of arguments to pass to the workflow (optional if the workflow takes no arguments) - All arguments must be [serializable](/docs/foundations/serialization) -- On Worlds with a regional dimension, the optional `region` option pins the new run's storage, queuing, and streams to a specific region — see [Multi-region on the Vercel World](/worlds/vercel#multi-region) +- On Worlds with a regional dimension, the optional `region` option pins the new run's storage, queuing, and streams to a specific region. See [Multi-region on the Vercel World](/worlds/vercel#multi-region) -**Learn more**: [`start()` API Reference](/docs/api-reference/workflow-api/start) +**Learn more**: [`start()` API reference](/docs/api-reference/workflow-api/start) For parent-child workflow patterns, see [Workflow Composition](/cookbook/common-patterns/workflow-composition). For long-lived workflows that intentionally hand off to newer deployments with `deploymentId: "latest"`, see [Versioning](/docs/foundations/versioning). -## The `Run` Object +## The `Run` object When you call `start()`, it returns a [`Run`](/docs/api-reference/workflow-api/start#returns) object that provides access to the workflow's status and results. @@ -66,22 +66,22 @@ const status = await run.status; // "running" | "completed" | "failed" const result = await run.returnValue; ``` -**Key Properties:** +**Key properties:** -- `runId` - Unique identifier for this workflow run -- `status` - Current status of the workflow (async) -- `returnValue` - The value returned by the workflow function (async, blocks until completion) -- `readable` - ReadableStream for streaming updates from the workflow +- `runId`: Unique identifier for this workflow run +- `status`: Current status of the workflow (async) +- `returnValue`: The value returned by the workflow function (async, blocks until completion) +- `readable`: `ReadableStream` for streaming updates from the workflow -Most `Run` properties are async getters that return promises. You need to `await` them to get their values. For a complete list of properties and methods, see the API reference below. +Most `Run` properties are async getters that return promises. `await` them to get their values. For a complete list of properties and methods, see the API reference below. -**Learn more**: [`Run` API Reference](/docs/api-reference/workflow-api/start#returns) +**Learn more**: [`Run` API reference](/docs/api-reference/workflow-api/start#returns) -## Common Patterns +## Common patterns -### Starting Workflows from Workflow Functions +### Starting workflows from workflow functions You can also call `start()` directly inside workflow functions to spawn child workflows. For choosing between this and awaiting a workflow function directly, see [Workflow Composition](/cookbook/common-patterns/workflow-composition). @@ -94,25 +94,25 @@ export async function parentWorkflow(inputValue: number) { const childRun = await start(childWorkflow, [inputValue]); // [!code highlight] - // childRun is a full Run object — use it like normal + // childRun is a full Run object. Use it like normal. const childResult = await childRun.returnValue; return { childRunId: childRun.runId, childResult }; } ``` -When `start()` is called inside a workflow function, it automatically executes through an internal step to maintain deterministic replay. The returned `Run` object works just like it does outside workflows — properties like `.runId`, `.status`, `.returnValue`, and methods like `.cancel()` are all available. Each property access or method call executes as a separate step under the hood. +When you call `start()` inside a workflow function, it automatically executes through an internal step to maintain deterministic replay. The returned `Run` object works as it does outside workflows. Properties such as `.runId`, `.status`, and `.returnValue`, and methods such as `.cancel()`, are all available. Each property access or method call executes as a separate step. Inside workflow functions, each `Run` property access (e.g., `run.status`, `run.returnValue`) triggers a workflow step. This means each access is recorded in the event log and replayed deterministically. -Awaiting `returnValue` polls the child run every second, and the polling step holds its worker slot open for as long as the child takes to finish. Worker-based Worlds must be sized to cover the peak number of these polls in flight. If the child workflow is long-running, spawn it without awaiting `returnValue` and have it resume a [hook](/docs/foundations/hooks) when it completes — see the [`startAndWait()` pattern](/cookbook/advanced/child-workflows). +Awaiting `returnValue` polls the child run every second, and the polling step holds its worker slot open until the child finishes. Size worker-based Worlds to cover the peak number of these polls in flight. If the child workflow is long-running, spawn it without awaiting `returnValue` and have it resume a [hook](/docs/foundations/hooks) when it completes. See the [`startAndWait()` pattern](/cookbook/advanced/child-workflows). -### Fire and Forget +### Fire and forget -The most common pattern is to start a workflow and immediately return, letting it execute in the background: +Start a workflow and immediately return to let it execute in the background: ```typescript lineNumbers import { start } from "workflow/api"; @@ -130,7 +130,7 @@ export async function POST(request: Request) { } ``` -### Wait for Completion +### Wait for completion If you need to wait for the workflow to complete before responding: @@ -149,12 +149,12 @@ export async function POST(request: Request) { ``` -Be cautious when waiting for `returnValue` - if your workflow takes a long time, your API route may timeout. +Waiting for `returnValue` can cause your API route to time out if the workflow takes a long time. -### Stream Updates to Client +### Stream updates to client -Stream real-time updates from your workflow as it executes, without waiting for completion: +Stream updates from your workflow as it executes without waiting for completion: ```typescript lineNumbers import { start } from "workflow/api"; @@ -212,12 +212,12 @@ async function streamContentToClient( ``` -Streams are particularly useful for AI workflows where you want to show progress to users in real-time, or for long-running processes that produce intermediate results. +Use streams to show users real-time progress from AI workflows or intermediate results from long-running processes. -**Learn more**: [Streaming in Workflows](/docs/foundations/serialization#streaming) +**Learn more**: [Streaming in workflows](/docs/foundations/serialization#streaming) -### Check Status Later +### Check status later You can retrieve a workflow run later using its `runId` with [`getRun()`](/docs/api-reference/workflow-api/get-run): @@ -243,9 +243,9 @@ export async function GET(request: Request) { } ``` -### Recursive and Repeating Workflows +### Recursive and repeating workflows -A workflow can start a new instance of itself. This is useful when a single long-running workflow would accumulate too many events — large event logs become slower to replay, more expensive to store, and harder to inspect in the UI. By breaking work into smaller runs that chain together, each run stays lean. +A workflow can start a new instance of itself. This pattern prevents a single long-running workflow from accumulating too many events. Large event logs are slower to replay, more expensive to store, and harder to inspect in the UI. Breaking work into smaller runs that chain together keeps each run lean. ```typescript lineNumbers import { start } from "workflow/api"; @@ -265,7 +265,7 @@ export async function processQueue(cursor?: string) { } ``` -This pattern also enables **repeating cron-like workflows**. A workflow can complete its work, sleep, and then schedule a new instance of itself — creating an indefinite chain without any single run growing too large: +This pattern also enables **repeating cron-like workflows**. A workflow can complete its work, sleep, and then schedule a new instance of itself. This creates an indefinite chain without allowing any single run to grow too large: ```typescript lineNumbers import { sleep } from "workflow"; @@ -287,10 +287,8 @@ export async function syncDashboard() { By default a chained run starts on the same deployment as its parent. For workflows that chain over long periods, pass [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) so the next run picks up new code. [Versioning](/docs/foundations/versioning#self-upgrading-workflows) covers this pattern in full, including how the serialized state acts as the migration boundary between versions. -## Next Steps - -Now that you understand how to start workflows and track their execution: +## Next steps - Browse the [Cookbook](/cookbook) for copy-paste recipes covering composition, scheduling, timeouts, and more -- Explore [Errors & Retrying](/docs/foundations/errors-and-retries) to handle failures gracefully -- Check the [`start()` API Reference](/docs/api-reference/workflow-api/start) for complete details +- Explore [Errors and retrying](/docs/foundations/errors-and-retries) to handle failures +- Check the [`start()` API reference](/docs/api-reference/workflow-api/start) for complete details diff --git a/docs/content/docs/v5/foundations/streaming.mdx b/docs/content/docs/v5/foundations/streaming.mdx index 046bab40e9..7e2477a437 100644 --- a/docs/content/docs/v5/foundations/streaming.mdx +++ b/docs/content/docs/v5/foundations/streaming.mdx @@ -1,8 +1,8 @@ --- title: Streaming -description: Stream data in real-time to clients for progress updates and incremental content delivery. +description: Stream data in real time to clients for progress updates and incremental content delivery. type: conceptual -summary: Stream real-time data to clients without waiting for workflow completion. +summary: Stream data to clients in real time without waiting for workflow completion. prerequisites: - /docs/foundations/workflows-and-steps related: @@ -10,9 +10,9 @@ related: - /docs/ai/resumable-streams --- -Workflows can stream data in real-time to clients without waiting for the entire workflow to complete. This enables progress updates, AI-generated content, log messages, and other incremental data to be delivered as workflows execute. +Workflows can stream data to clients in real time without waiting for the entire workflow to complete. Clients can receive progress updates, AI-generated content, log messages, and other incremental data as workflows execute. -## Getting Started with `getWritable()` +## Getting started with `getWritable()` Every workflow run has a default writable stream that steps can write to using [`getWritable()`](/docs/api-reference/workflow/get-writable). Data written to this stream becomes immediately available to clients consuming the workflow's output. @@ -38,7 +38,7 @@ export async function simpleStreamingWorkflow() { } ``` -### Consuming the Stream +### Consuming the stream Use the `Run` object's `readable` property to consume the stream from your API route: @@ -58,7 +58,7 @@ export async function POST() { When a client makes a request to this endpoint, they'll receive each message as it's written, without waiting for the workflow to complete. -### Avoiding Function Timeouts After Client Disconnects +### Avoiding function timeouts after client disconnects On Vercel, `run.readable` and `run.getReadable()` reconnect to Workflow's stream storage while the workflow is still running. By default, a client disconnect does not terminate the Vercel Function serving the stream. If a user closes the page or stops the request, the function can therefore keep reconnecting until it reaches its maximum duration and fails with `FUNCTION_INVOCATION_TIMEOUT`. @@ -80,9 +80,9 @@ Replace the function path with the path or glob for your streaming route. When t Cancellation applies to every function matching the configured path or glob, even if the route does not listen to `request.signal`. Any other work in that invocation which is not wrapped in [`waitUntil`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#waituntil) or [`after`](https://nextjs.org/docs/app/api-reference/functions/after) can be lost. Only enable it for routes that are safe to terminate when their client disconnects. -This setting prevents abandoned stream readers from consuming the rest of a function invocation. It does not extend the function's maximum duration: an actively connected streaming response can still reach the configured limit, at which point the client should reconnect to the durable stream. +This setting prevents abandoned stream readers from consuming the rest of a function invocation. It does not extend the function's maximum duration. An actively connected streaming response can still reach the configured limit, at which point the client should reconnect to the durable stream. -### Resuming Streams from a Specific Point +### Resuming streams from a specific point Use `run.getReadable({ startIndex })` to resume a stream from a specific position. This is useful for reconnecting after timeouts or network interruptions: @@ -109,7 +109,7 @@ export async function GET( } ``` -This allows clients to reconnect and continue receiving data from where they left off, rather than restarting from the beginning. +Clients can reconnect and continue receiving data from where they left off instead of restarting from the beginning. `startIndex` also supports **negative values** to read relative to the end of the stream. For example, `startIndex: -5` starts 5 chunks before the current end. This is useful when you want to show the most recent output without reading the entire stream history. @@ -127,31 +127,32 @@ If the absolute value exceeds the total number of chunks, reading starts from th Because streams are live and continue receiving chunks, negative `startIndex` values resolve to different absolute positions on each call. Accurate pagination over a live stream requires cursor-based access, which is not yet supported. Keep this in mind when building clients that paginate over stream data. -## Streams as Data Types +## Streams as data types -[`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) and [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) are standard Web Streams API types that Workflow SDK makes serializable. These are not custom types - they follow the web standard - but Workflow SDK adds the ability to pass them between functions while maintaining their streaming capabilities. +[`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) and [`WritableStream`](https://developer.mozilla.org/en-US/docs/Web/API/WritableStream) are standard Web Streams API types that Workflow SDK makes serializable. Workflow SDK lets you pass these standard types between functions while maintaining their streaming capabilities. Unlike regular values that are fully serialized to the [event log](/docs/how-it-works/event-sourcing), streams maintain their streaming capabilities when passed between functions. **Key properties:** + - Stream references can be passed between workflow and step functions - Stream data flows directly without being stored in the event log - Streams preserve their state across workflow suspension points -**How Streams Persist Across Workflow Suspensions** +**How streams persist across workflow suspensions** -Streams in Workflow SDK are backed by persistent, resumable storage provided by the "world" implementation. This is what enables streams to maintain their state even when workflows suspend and resume: +Persistent, resumable storage from the World implementation lets Workflow SDK streams maintain their state when workflows suspend and resume: - **Vercel deployments**: Streams are backed by a performant Redis-based stream - **Local development**: Stream chunks are stored in the filesystem -### Passing Streams as Arguments +### Passing streams as arguments -Since streams are serializable data types, you don't need to use the special [`getWritable()`](/docs/api-reference/workflow/get-writable). You can even wire your own streams through workflows, passing them as arguments from outside into steps. +Because streams are serializable data types, you don't need to use [`getWritable()`](/docs/api-reference/workflow/get-writable). You can pass your own streams as arguments from outside a workflow into steps. -Here's an example of passing a request body stream through a workflow to a step that processes it: +The following example passes a request body stream through a workflow to a step that processes it: ```typescript title="app/api/upload/route.ts" lineNumbers import { start } from "workflow/api"; @@ -191,15 +192,15 @@ async function processInputStream(input: ReadableStream) { } ``` -## Important Limitation +## Important limitation -**Streams Cannot Be Used Directly in Workflow Context** +**Streams cannot be used directly in workflow context** You cannot read from or write to streams directly within a workflow function. All stream operations must happen in step functions. -Workflow functions must be deterministic to support replay. Since streams bypass the [event log](/docs/how-it-works/event-sourcing) for performance, reading stream data in a workflow would break determinism - each replay could see different data. By requiring all stream operations to happen in steps, the framework ensures consistent behavior. +Workflow functions must be deterministic to support replay. Streams bypass the [event log](/docs/how-it-works/event-sourcing) for performance, so reading stream data in a workflow would break determinism because each replay could see different data. Requiring all stream operations to happen in steps ensures consistent behavior. For more on determinism and replay, see [Workflows and Steps](/docs/foundations/workflows-and-steps). @@ -238,7 +239,7 @@ async function writeToStream(data: string) { } ``` -## Namespaced Streams +## Namespaced streams Use `getWritable({ namespace: 'name' })` to create multiple independent streams for different types of data. This is useful when you want to separate logs, metrics, data outputs, or other distinct channels. @@ -288,7 +289,7 @@ export async function multiStreamWorkflow() { } ``` -### Consuming Namespaced Streams +### Consuming namespaced streams Use `run.getReadable({ namespace: 'name' })` to access specific streams: @@ -313,9 +314,9 @@ export async function POST(request: Request) { } ``` -## Common Patterns +## Common patterns -### Progress Updates for Long-Running Tasks +### Progress updates for long-running tasks Send incremental progress updates to keep users informed during lengthy workflows: @@ -369,7 +370,7 @@ export async function batchProcessingWorkflow(items: string[]) { } ``` -### Streaming AI Responses with `WorkflowAgent` +### Streaming AI responses with `WorkflowAgent` Stream AI-generated content using AI SDK's [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) from `@ai-sdk/workflow`. The agent writes `ModelCallStreamPart` chunks to the workflow stream, and route handlers convert them to UI message chunks with `createModelCallToUIChunkTransform()` before returning the response: @@ -430,7 +431,7 @@ export async function POST(request: Request) { For the full agent API and migration notes, see the [`WorkflowAgent` documentation](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent). -### Streaming Between Steps +### Streaming between steps One step produces a stream and another step consumes it: @@ -471,7 +472,7 @@ async function consumeData(readable: ReadableStream) { } ``` -### Processing Large Files Without Memory Overhead +### Processing large files without memory overhead Process large files by streaming chunks through transformation steps: @@ -512,11 +513,11 @@ async function uploadResult(stream: ReadableStream) { } ``` -## Best Practices +## Best practices **Batching and first-chunk latency:** -Writes are flushed immediately by default — the leading chunk of an idle stream dispatches as soon as it is written, and chunks arriving while a flush is in flight coalesce into the next batch. If you write bursts of many tiny chunks and prefer fewer round trips over first-chunk latency, set a group-commit window with the World's `streamFlushIntervalMs` option or the `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable (see [Worlds configuration](/docs/configuration/worlds#streamflushintervalms)). +Writes are flushed immediately by default. The leading chunk of an idle stream dispatches as soon as it is written, and chunks arriving while a flush is in flight coalesce into the next batch. If you write bursts of many tiny chunks and prefer fewer round trips over first-chunk latency, set a group-commit window with the World's `streamFlushIntervalMs` option or the `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` environment variable. See [Worlds configuration](/docs/configuration/worlds#streamflushintervalms). **Release locks properly:** @@ -534,7 +535,7 @@ Stream locks acquired in a step only apply within that step, not across other st -If a lock is not released, the invocation that ran the step cannot terminate. Even though the step returns and the workflow continues, the underlying request will remain active until it times out—wasting compute resources unnecessarily. +If a lock is not released, the invocation that ran the step cannot terminate. Even though the step returns and the workflow continues, the underlying request remains active until it times out and wastes compute resources. **Close streams when done:** @@ -560,7 +561,7 @@ const writer = writable.getWriter(); await writer.write({ /* typed data */ }); ``` -## Stream Failures +## Stream failures When a step returns a stream, the step is considered successful once it returns, even if the stream later encounters an error. The workflow won't automatically retry the step. The consumer of the stream must handle errors gracefully. For more on retry behavior, see [Errors and Retries](/docs/foundations/errors-and-retries). @@ -603,17 +604,17 @@ export async function streamErrorWorkflow() { ``` -Stream errors don't trigger automatic retries for the producer step. Design your stream consumers to handle errors appropriately. Since the stream is already in an errored state, retrying the consumer won't help - use `FatalError` to fail the workflow immediately. +Stream errors don't trigger automatic retries for the producer step. Design your stream consumers to handle errors. Because the stream is already in an errored state, retrying the consumer won't help. Use `FatalError` to fail the workflow immediately. -## Related Documentation - -- [`getWritable()` API Reference](/docs/api-reference/workflow/get-writable) - Get the workflow's writable stream -- [`sleep()` API Reference](/docs/api-reference/workflow/sleep) - Pause workflow execution for a duration -- [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows and access the `Run` object -- [`getRun()` API Reference](/docs/api-reference/workflow-api/get-run) - Retrieve runs and their streams later -- [world.streams](/docs/api-reference/workflow-runtime/world/streams) - Low-level stream read/write/close via World SDK -- [WorkflowAgent](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent) - AI agents with durable, resumable streaming support -- [Errors and Retries](/docs/foundations/errors-and-retries) - Understanding error handling and retry behavior -- [Serialization](/docs/foundations/serialization) - Understanding what data types can be passed in workflows -- [Workflows and Steps](/docs/foundations/workflows-and-steps) - Core concepts of workflow execution +## Related documentation + +- [`getWritable()` API reference](/docs/api-reference/workflow/get-writable): Get the workflow's writable stream +- [`sleep()` API reference](/docs/api-reference/workflow/sleep): Pause workflow execution for a duration +- [`start()` API reference](/docs/api-reference/workflow-api/start): Start workflows and access the `Run` object +- [`getRun()` API reference](/docs/api-reference/workflow-api/get-run): Retrieve runs and their streams later +- [`world.streams`](/docs/api-reference/workflow-runtime/world/streams): Use low-level stream read, write, and close operations through the World SDK +- [`WorkflowAgent`](https://ai-sdk.dev/v7/docs/agents/workflow-agent#workflowagent): Build AI agents with durable, resumable streaming support +- [Errors and retries](/docs/foundations/errors-and-retries): Understand error handling and retry behavior +- [Serialization](/docs/foundations/serialization): Understand which data types you can pass in workflows +- [Workflows and steps](/docs/foundations/workflows-and-steps): Learn the core concepts of workflow execution diff --git a/docs/content/docs/v5/foundations/versioning.mdx b/docs/content/docs/v5/foundations/versioning.mdx index 58903096a5..04743a3df0 100644 --- a/docs/content/docs/v5/foundations/versioning.mdx +++ b/docs/content/docs/v5/foundations/versioning.mdx @@ -75,7 +75,7 @@ Sometimes you deploy because the old code had a bug. The safest fix is usually e 3. Cancel the old runs if they are still running. 4. Rerun them on the latest deployment with the same inputs. -This keeps the version boundary visible. The old run ends as cancelled or failed, and the replacement run starts fresh on the fixed deployment. This is a good fit for one-off, ad-hoc upgrades where you explicitly opt in to moving affected runs onto a new version. +This keeps the version boundary visible. The old run ends as canceled or failed, and the replacement run starts fresh on the fixed deployment. This is a good fit for one-off, ad-hoc upgrades where you explicitly opt in to moving affected runs onto a new version. ```bash # Inspect affected runs and copy the exact workflowName value. @@ -121,9 +121,9 @@ export async function POST(request: Request) { ## Self upgrading workflows -Some workflows are expected to run for a very long time. Scheduled loops, recurring jobs, agents, and chat sessions often should not stay on one deployment forever. +Some workflows are expected to run for long periods. Scheduled loops, recurring jobs, agents, and chat sessions often should not stay on one deployment forever. -Model those as a sequence of runs. Each run does a bounded piece of work, then starts the next run on the latest deployment and exits. This is similar to `continueAsNew` in other durable execution systems, but in Workflow SDK it is just [explicit recursion through `start()`](/cookbook/common-patterns/workflow-composition). +Model those as a sequence of runs. Each run does a bounded piece of work, then starts the next run on the latest deployment and exits. This is similar to `continueAsNew` in other durable execution systems, but Workflow SDK uses [explicit recursion through `start()`](/cookbook/common-patterns/workflow-composition). ```typescript title="workflows/daily-digest.ts" lineNumbers import { sleep } from "workflow"; diff --git a/docs/content/docs/v5/foundations/workflows-and-steps.mdx b/docs/content/docs/v5/foundations/workflows-and-steps.mdx index 2e43f71b41..3c6da11918 100644 --- a/docs/content/docs/v5/foundations/workflows-and-steps.mdx +++ b/docs/content/docs/v5/foundations/workflows-and-steps.mdx @@ -14,18 +14,18 @@ import { File, Folder, Files } from "fumadocs-ui/components/files"; Workflows (a.k.a. *durable functions*) are a programming model for building long-running, stateful application logic that can maintain its execution state across restarts, failures, or user events. Unlike traditional serverless functions that lose all state when they terminate, workflows persist their progress and can resume exactly where they left off. -Moreover, workflows let you easily model complex multi-step processes in simple, elegant code. To do this, we introduce two fundamental entities: +Workflows let you model complex multi-step processes in code. To do this, we introduce two fundamental entities: 1. **Workflow Functions**: Functions that orchestrate/organize steps 2. **Step Functions**: Functions that carry out the actual work -## Workflow Functions +## Workflow functions *Directive: `"use workflow"`* Workflow functions define the entrypoint of a workflow and organize how step functions are called. This type of function does not have access to the Node.js runtime, and usable `npm` packages are limited. -Although this may seem limiting initially, this feature is important in order to suspend and accurately resume execution of workflows. +Although this may seem limiting initially, this feature is required to suspend and accurately resume workflow execution. It helps to think of the workflow function less like a full JavaScript runtime and more like "stitching together" various steps using conditionals, loops, try/catch handlers, `Promise.all`, and other language primitives. @@ -51,7 +51,7 @@ Determinism in the workflow is required to resume the workflow from a suspension The sandboxed environment that workflows run in already ensures determinism. For instance, `Math.random` and `Date` constructors are fixed in workflow runs, so you are safe to use them, and the framework ensures that the values don't change across replays. -## Step Functions +## Step functions *Directive: `"use step"`* @@ -115,10 +115,10 @@ export async function POST() { ``` -Keep in mind that calling a step function outside of a workflow function will not have retry semantics, nor will it be observable. Additionally, certain workflow-specific functions like [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) will throw an error when used inside a step that's called outside a workflow. +Calling a step function outside a workflow function provides neither retry semantics nor observability. Additionally, certain workflow-specific functions like [`getStepMetadata()`](/docs/api-reference/workflow/get-step-metadata) will throw an error when used inside a step that's called outside a workflow. -### Suspension and Resumption +### Suspension and resumption Workflow functions have the ability to automatically suspend while they wait on asynchronous work. While suspended, the workflow's state is stored via the [event log](/docs/how-it-works/event-sourcing) and no compute resources are used until the workflow resumes execution. @@ -150,9 +150,9 @@ export async function documentReviewProcess(userId: string) { } ``` -## Writing Workflows +## Writing workflows -### Basic Structure +### Basic structure The simplest workflow consists of a workflow function and one or more step functions. diff --git a/docs/content/docs/v5/getting-started/astro.mdx b/docs/content/docs/v5/getting-started/astro.mdx index 3c4167d0fa..3033e9a380 100644 --- a/docs/content/docs/v5/getting-started/astro.mdx +++ b/docs/content/docs/v5/getting-started/astro.mdx @@ -13,16 +13,16 @@ related: text="In this Astro app, run `npm i workflow`. In `astro.config.mjs`, import `workflow` from `workflow/astro` and add `integrations: [workflow()]`. Add the TypeScript plugin `{ "name": "workflow" }` to `tsconfig.json` if TypeScript is used. Create `src/workflows/user-signup.ts` exporting `handleUserSignup(email)` with `"use workflow"`, `sleep` from `workflow`, and `"use step"` helpers. Add `src/pages/api/signup.ts` exporting `POST: APIRoute` that reads `{ email }`, calls `start(handleUserSignup, [email])` from `workflow/api`, returns `Response.json`, and sets `prerender = false`. Run `npm run dev`, call `curl -X POST --json '{"email":"hello@example.com"}' http://localhost:4321/api/signup`, and inspect with `npx workflow inspect runs`." /> -This guide will walk through setting up your first workflow in an Astro app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +Set up your first durable workflow in an Astro app and learn the core Workflow SDK concepts. --- -## Create Your Astro Project +## Create your Astro project -Start by creating a new Astro project. This command will create a new directory named `my-workflow-app` and setup a minimal Astro project inside it. +Create an Astro project in a new directory named `my-workflow-app`: ```bash npm create astro@latest my-workflow-app -- --template minimal --install --yes @@ -59,16 +59,16 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | - ### Setup IntelliSense for TypeScript (Optional) + ### Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -91,7 +91,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -112,14 +112,14 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="src/workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -172,7 +172,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `src/pages/api/signup.ts` with the following code: @@ -204,7 +204,7 @@ Workflows can be triggered from API routes or any server-side code. -## Run in Development +## Run in development To start your development server, run the following command in your terminal in the Vite root directory: @@ -231,9 +231,9 @@ npx workflow inspect runs --- -## Deploying to Production +## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. @@ -251,7 +251,7 @@ Additionally, check the [Deploying](/docs/deploying) section to learn how your w If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -262,7 +262,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/express.mdx b/docs/content/docs/v5/getting-started/express.mdx index 63a139ed5c..9c61ab3a66 100644 --- a/docs/content/docs/v5/getting-started/express.mdx +++ b/docs/content/docs/v5/getting-started/express.mdx @@ -20,7 +20,7 @@ This guide will walk through setting up your first workflow in an Express app. A -## Create Your Express Project +## Create your Express project Start by creating a new Express project. @@ -117,7 +117,7 @@ To use the Nitro builder, update your `package.json` to include the following sc -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -137,14 +137,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -198,7 +198,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create both the Express app and a new API route handler at `src/index.ts` with the following code: @@ -272,7 +272,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -283,7 +283,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/fastify.mdx b/docs/content/docs/v5/getting-started/fastify.mdx index 4a36a742d7..2eef5bdfd8 100644 --- a/docs/content/docs/v5/getting-started/fastify.mdx +++ b/docs/content/docs/v5/getting-started/fastify.mdx @@ -20,7 +20,7 @@ This guide will walk through setting up your first workflow in a Fastify app. Al -## Create Your Fastify Project +## Create your Fastify project Start by creating a new Fastify project. @@ -116,7 +116,7 @@ To use the Nitro builder, update your `package.json` to include the following sc -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -136,13 +136,13 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions: +Define the missing functions: ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -186,7 +186,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create both the Fastify app and a new API route handler at `src/index.ts` with the following code: @@ -259,7 +259,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -270,7 +270,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/hono.mdx b/docs/content/docs/v5/getting-started/hono.mdx index 46d2349655..a5087ad57a 100644 --- a/docs/content/docs/v5/getting-started/hono.mdx +++ b/docs/content/docs/v5/getting-started/hono.mdx @@ -16,7 +16,7 @@ related: -## Create Your Hono Project +## Create your Hono project Start by creating a new Hono project. This command will create a new directory named `my-workflow-app` and set up a Hono project inside it. @@ -100,7 +100,7 @@ To use the Nitro builder, update your `package.json` to include the following sc -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -122,14 +122,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -183,7 +183,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create a new API route handler at `src/index.ts` with the following code: @@ -254,7 +254,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -265,7 +265,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/nestjs.mdx b/docs/content/docs/v5/getting-started/nestjs.mdx index a3eaddd937..ad0525c08d 100644 --- a/docs/content/docs/v5/getting-started/nestjs.mdx +++ b/docs/content/docs/v5/getting-started/nestjs.mdx @@ -13,11 +13,11 @@ related: text="In this NestJS app, run `npm i workflow @workflow/nest` and `npm i -D @swc/cli @swc/core`. Configure `nest-cli.json` with `compilerOptions.builder: "swc"` and `deleteOutDir: true`. Run `npx @workflow/nest init`, add `.swcrc` to `.gitignore`, and set package scripts `prebuild: "npx @workflow/nest init --force"` and `start:dev: "npx @workflow/nest init --force && nest start --watch"`. Import `WorkflowModule.forRoot()` from `@workflow/nest` in `src/app.module.ts` (use `{ moduleType: "commonjs", distDir: "dist" }` if compiling CommonJS). Create `src/workflows/user-signup.ts` with `"use workflow"`, `sleep`, and `"use step"` helpers. Add a `POST /signup` controller method that reads `email`, calls `start(handleUserSignup, [email])` from `workflow/api`, and returns JSON. Run `npm run start:dev`, call `curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/signup`, and inspect with `npx workflow web` or `npx workflow inspect runs`." /> -This guide will walk through setting up your first workflow in a NestJS app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +Set up your first durable workflow in a NestJS app and learn the core Workflow SDK concepts. NestJS integration is experimental. Deployment to Vercel is supported via the -`workflow-nest build --vercel` command — see [Deploy to Vercel](#deploy-to-vercel) below. +`workflow-nest build --vercel` command. See [Deploy to Vercel](#deploy-to-vercel) below. --- @@ -25,7 +25,7 @@ NestJS integration is experimental. Deployment to Vercel is supported via the -## Create Your NestJS Project +## Create your NestJS project Start by creating a new NestJS project using the NestJS CLI. @@ -46,7 +46,7 @@ cd my-workflow-app npm i workflow @workflow/nest ``` -### Choose Your Module Format +### Choose your module format NestJS projects using `@workflow/nest` can compile as either ESM or CommonJS. Choose the setup that matches your SWC output instead of assuming ESM is required. @@ -112,7 +112,7 @@ Ensure your `nest-cli.json` has SWC as the builder: } ``` -### Initialize SWC Configuration +### Initialize SWC configuration Run the init command to generate the SWC configuration: @@ -143,10 +143,10 @@ Add scripts to regenerate the SWC configuration before builds: - Setup IntelliSense for TypeScript (Optional) + Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -208,7 +208,7 @@ The `WorkflowModule` handles workflow bundle building and provides HTTP routing -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow in the `src/workflows` directory: @@ -241,14 +241,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="src/workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -302,7 +302,7 @@ Taking a look at this code: -## Create Your Controller +## Create your controller To invoke your new workflow, update your controller with a new endpoint: @@ -426,7 +426,7 @@ execute instead of staying `pending`. --- -## Configuration Options +## Configuration options The `WorkflowModule.forRoot()` method accepts optional configuration: @@ -456,7 +456,7 @@ WorkflowModule.forRoot({ // development, false in production). // Accepts the same values as esbuild's sourcemap option: true, false, // 'inline', 'linked', 'external', 'both'. Set to false for smaller - // function bundles (useful for staying under the Vercel 250MB function + // function bundles (useful for staying under the Vercel 250 MB function // size limit) at the cost of stack traces pointing at generated code. // Can also be set via the WORKFLOW_SOURCEMAP environment variable. sourcemap: 'inline', @@ -469,7 +469,7 @@ WorkflowModule.forRoot({ If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -479,7 +479,8 @@ Check both of these first: 2. Your NestJS app imports and registers the `WorkflowModule`. See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps + +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/next.mdx b/docs/content/docs/v5/getting-started/next.mdx index d1308d08b1..06e9c42775 100644 --- a/docs/content/docs/v5/getting-started/next.mdx +++ b/docs/content/docs/v5/getting-started/next.mdx @@ -17,7 +17,7 @@ related: -## Create Your Next.js Project +## Create your Next.js project Start by creating a new Next.js project. This command will create a new directory named `my-workflow-app` and set up a Next.js project inside it. @@ -89,7 +89,7 @@ If your Next.js app has a [proxy handler](https://nextjs.org/docs/app/api-refere (formerly known as "middleware"), you'll need to update the matcher pattern to exclude Workflow's internal paths to prevent the proxy handler from running on them. -If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This is especially easy to miss in Next.js 16, where `proxy.ts` replaced `middleware.ts`. +If you see `[local world] Queue operation failed` with `Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer`, your proxy matcher is still intercepting Workflow's internal `POST /.well-known/workflow/v1/flow` request. This issue can be hard to spot in Next.js 16, where `proxy.ts` replaced `middleware.ts`. Add `.well-known/workflow/*` to your matcher exclusion list: @@ -121,7 +121,7 @@ This ensures that internal Workflow paths are not intercepted by your middleware -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -144,14 +144,14 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -204,7 +204,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll need to add your workflow to a `POST` API Route Handler, `app/api/signup/route.ts`, with the following code: @@ -276,7 +276,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error when upgrading to Next.js 16.1 or later: -``` +```text Build error occurred Error: Cannot find module 'next/dist/lib/server-external-packages.json' ``` @@ -315,7 +315,7 @@ Without this configuration, you may experience intermittent issues where workflo If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -326,7 +326,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/nitro.mdx b/docs/content/docs/v5/getting-started/nitro.mdx index 5848b203c4..f0660e31e1 100644 --- a/docs/content/docs/v5/getting-started/nitro.mdx +++ b/docs/content/docs/v5/getting-started/nitro.mdx @@ -1,6 +1,6 @@ --- title: Nitro -description: This guide will walk through setting up your first workflow in a Nitro v3 project. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +description: Set up your first durable workflow in a Nitro v3 project. type: guide summary: Set up Workflow SDK in a Nitro app. prerequisites: @@ -16,9 +16,9 @@ related: -## Create Your Nitro Project +## Create your Nitro project -Start by creating a new [Nitro v3](https://v3.nitro.build/) project. This command will create a new directory named `nitro-app` and setup a Nitro project inside it. +Create a [Nitro v3](https://v3.nitro.build/) project in a new directory named `nitro-app`: ```bash npx create-nitro-app @@ -38,7 +38,7 @@ npm i workflow ### Configure Nitro -Add `workflow/nitro` module to your `nitro.config.ts` This enables usage of the `"use workflow"` and `"use step"` directives. +Add the `workflow/nitro` module to your `nitro.config.ts`. This enables the `"use workflow"` and `"use step"` directives. ```typescript title="nitro.config.ts" lineNumbers import { defineConfig } from "nitro"; @@ -70,15 +70,15 @@ export default defineConfig({ | --- | --- | --- | --- | | `dirs` | `string[]` | — | Directories to scan for workflows and steps. By default, `workflows/` is scanned from the project root and all layer source directories. | | `runtime` | `string` | `'nodejs22.x'` | Node.js runtime version for Vercel Functions (e.g. `'nodejs22.x'`, `'nodejs24.x'`). | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | - Setup IntelliSense for TypeScript (Optional) + Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -102,7 +102,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -124,14 +124,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -185,7 +185,7 @@ Taking a look at this code: -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll create a new API route handler at `server/api/signup.post.ts` with the following code: @@ -204,7 +204,7 @@ export default defineEventHandler(async ({ req }) => { }); ``` -This Route Handler creates a `POST` request endpoint at `/api/signup` that will trigger your workflow. +This route handler creates a `POST` request endpoint at `/api/signup` that triggers your workflow. Workflows can be triggered from API routes or any server-side @@ -248,7 +248,7 @@ npx workflow inspect runs ## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. @@ -260,7 +260,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -271,7 +271,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/nuxt.mdx b/docs/content/docs/v5/getting-started/nuxt.mdx index 97da5fd9ae..1150c878e2 100644 --- a/docs/content/docs/v5/getting-started/nuxt.mdx +++ b/docs/content/docs/v5/getting-started/nuxt.mdx @@ -16,7 +16,7 @@ related: -## Create Your Nuxt Project +## Create your Nuxt project Start by creating a new Nuxt project. This command will create a new directory named `nuxt-app` and setup a Nuxt project inside it. @@ -79,7 +79,7 @@ export default defineNuxtConfig({ -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -101,14 +101,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: - We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. - The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="server/workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow"; @@ -162,7 +162,7 @@ Taking a look at this code: -## Create Your API Route +## Create your API route To invoke your new workflow, we'll create a new API route handler at `server/api/signup.post.ts` with the following code: @@ -239,7 +239,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -250,7 +250,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/python.mdx b/docs/content/docs/v5/getting-started/python.mdx index b381831a53..901f3c3464 100644 --- a/docs/content/docs/v5/getting-started/python.mdx +++ b/docs/content/docs/v5/getting-started/python.mdx @@ -20,7 +20,7 @@ The Python SDK is currently in **beta**. APIs and behavior may change. For the l You can build durable workflows in Python using the [`vercel` Python SDK](https://pypi.org/project/vercel/). Your workflow code can pause, resume, and maintain state, just like the JavaScript and TypeScript Workflow SDK. -## Getting Started +## Getting started Add the `vercel` package and workflow entrypoint to `pyproject.toml`: @@ -160,11 +160,11 @@ async def resume(approval: Approval): When a hook receives data, the workflow resumes automatically. You don't need polling, message queues, or manual state management. -## Learn More +## Learn more For comprehensive documentation, examples, and the latest updates, visit the [official Vercel Workflow Python documentation](https://vercel.com/docs/workflows/python). -## Next Steps +## Next steps - Learn more about the [Foundations](/docs/foundations). - Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/sveltekit.mdx b/docs/content/docs/v5/getting-started/sveltekit.mdx index 892f94eac0..84ac707bfd 100644 --- a/docs/content/docs/v5/getting-started/sveltekit.mdx +++ b/docs/content/docs/v5/getting-started/sveltekit.mdx @@ -1,6 +1,6 @@ --- title: SvelteKit -description: This guide will walk through setting up your first workflow in a SvelteKit app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +description: Set up your first durable workflow in a SvelteKit app. type: guide summary: Set up Workflow SDK in a SvelteKit app. prerequisites: @@ -16,9 +16,9 @@ related: -## Create Your SvelteKit Project +## Create your SvelteKit project -Start by creating a new SvelteKit project. This command will create a new directory named `my-workflow-app` with a minimal setup and setup a SvelteKit project inside it. +Create a minimal SvelteKit project in a new directory named `my-workflow-app`: ```bash npx sv create my-workflow-app --template=minimal --types=ts --no-add-ons @@ -54,16 +54,16 @@ export default defineConfig({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles — helps stay under the Vercel 250MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | +| `sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` (dev) / `false` (prod) | Controls source maps on generated workflow bundles. Accepts the same values as esbuild's `sourcemap` option. Defaults to `'inline'` in development and `false` in production (smaller function bundles, which helps stay under the Vercel 250 MB function size limit). Set it explicitly, or use the `WORKFLOW_SOURCEMAP` environment variable, to override in either environment. | - ### Setup IntelliSense for TypeScript (Optional) + ### Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -86,7 +86,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -109,14 +109,14 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -169,7 +169,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `src/routes/api/signup/+server.ts` with the following code: @@ -232,7 +232,7 @@ npx workflow inspect runs ## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. @@ -244,7 +244,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -255,7 +255,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/tanstack-start.mdx b/docs/content/docs/v5/getting-started/tanstack-start.mdx index 4a45f609eb..b62f2061de 100644 --- a/docs/content/docs/v5/getting-started/tanstack-start.mdx +++ b/docs/content/docs/v5/getting-started/tanstack-start.mdx @@ -13,14 +13,14 @@ related: text="In this TanStack Start app, run `npm i workflow`. In `vite.config.ts`, import `workflow` from `workflow/vite` and add `workflow()` first in the existing `plugins` array before `tanstackStart()`, `nitro()`, or other plugins. Add `{ "name": "workflow" }` to `compilerOptions.plugins` in `tsconfig.json` if TypeScript is used. Create `src/workflows/user-signup.ts` with `handleUserSignup(email)`, `"use workflow"`, `sleep`, and `"use step"` helpers. Add `src/routes/api/signup.ts` using `createFileRoute("/api/signup")`, a POST server handler, `start` from `workflow/api`, and `json` from `@tanstack/react-start`. Run `npm run dev`, call `curl -X POST --json '{"email":"hello@example.com"}' http://localhost:3000/api/signup`, and inspect with `npx workflow web` or `npx workflow inspect runs`." /> -This guide will walk through setting up your first workflow in a TanStack Start app. Along the way, you'll learn more about the concepts that are fundamental to using the Workflow SDK in your own projects. +Set up your first durable workflow in a TanStack Start app and learn the core Workflow SDK concepts. --- -## Create Your TanStack Start Project +## Create your TanStack Start project Start by creating a new TanStack Start project: @@ -42,7 +42,7 @@ npm i workflow ### Configure TanStack Start -TanStack Start runs on Vite, so the Workflow SDK is wired in via the same `workflow/vite` plugin. Add `workflow()` to the existing `plugins` array in your Vite config — list it first so the `"use workflow"` and `"use step"` transforms run before any other plugin processes the file. +TanStack Start runs on Vite, so the Workflow SDK is wired in via the same `workflow/vite` plugin. Add `workflow()` to the existing `plugins` array in your Vite config. List it first so the `"use workflow"` and `"use step"` transforms run before any other plugin processes the file. ```typescript title="vite.config.ts" lineNumbers import { defineConfig } from "vite"; @@ -60,11 +60,11 @@ export default defineConfig({ - ### Setup IntelliSense for TypeScript (Optional) + ### Set up IntelliSense for TypeScript (optional) -To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json`: +To enable helpful hints in your IDE, set up the workflow plugin in `tsconfig.json`: ```json title="tsconfig.json" lineNumbers { @@ -87,7 +87,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -107,14 +107,14 @@ export async function handleUserSignup(email: string) { } ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next, but first review this code: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="src/workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -167,7 +167,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, add a server handler at `src/routes/api/signup.ts`: @@ -232,13 +232,13 @@ npx workflow inspect runs ## Deploying to production -Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and needs no special configuration. +Workflow SDK apps currently work best when deployed to [Vercel](https://vercel.com/home) and need no special configuration. Check the [Deploying](/docs/deploying) section to learn how your workflows can be deployed elsewhere. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/getting-started/vite.mdx b/docs/content/docs/v5/getting-started/vite.mdx index 96aa6404f3..2df7533f79 100644 --- a/docs/content/docs/v5/getting-started/vite.mdx +++ b/docs/content/docs/v5/getting-started/vite.mdx @@ -20,7 +20,7 @@ This guide will walk through setting up your first workflow in a Vite app. Along -## Create Your Vite Project +## Create your Vite project Start by creating a new Vite project. This command will create a new directory named `my-workflow-app` with a minimal setup and setup a Vite project inside it. @@ -91,7 +91,7 @@ To enable helpful hints in your IDE, setup the workflow plugin in `tsconfig.json -## Create Your First Workflow +## Create your first workflow Create a new file for our first workflow: @@ -112,14 +112,14 @@ export async function handleUserSignup(email: string) { ``` -We'll fill in those functions next, but let's take a look at this code: +We'll fill in those functions next. The current code does the following: * We define a **workflow** function with the directive `"use workflow"`. Think of the workflow function as the _orchestrator_ of individual **steps**. * The Workflow SDK's `sleep` function allows us to suspend execution of the workflow without using up any resources. A sleep can be a few seconds, hours, days, or even months long. -## Create Your Workflow Steps +## Create your workflow steps -Let's now define those missing functions. +Define the missing functions. ```typescript title="workflows/user-signup.ts" lineNumbers import { FatalError } from "workflow" @@ -172,7 +172,7 @@ We'll dive deeper into workflows, steps, and other ways to suspend or handle eve -## Create Your Route Handler +## Create your route handler To invoke your new workflow, we'll have to add your workflow to a `POST` API route handler, `api/signup.post.ts` with the following code: @@ -244,7 +244,7 @@ Check the [Deploying](/docs/deploying) section to learn how your workflows can b If you see this error: -``` +```text 'start' received an invalid workflow function. Ensure the Workflow SDK is configured correctly and the function includes a 'use workflow' directive. ``` @@ -255,7 +255,7 @@ Check both of these first: See [start-invalid-workflow-function](/docs/errors/start-invalid-workflow-function) for full examples and fixes. -## Next Steps +## Next steps * Learn more about the [Foundations](/docs/foundations). * Check [Errors](/docs/errors) if you encounter issues. diff --git a/docs/content/docs/v5/how-it-works/cancellation.mdx b/docs/content/docs/v5/how-it-works/cancellation.mdx index fa41fcd330..d7ebc1a640 100644 --- a/docs/content/docs/v5/how-it-works/cancellation.mdx +++ b/docs/content/docs/v5/how-it-works/cancellation.mdx @@ -18,30 +18,30 @@ This guide explains how cancellation works internally. Understanding these detai When you write `new AbortController()` in a workflow function, Workflow DevKit creates a durable controller backed by two existing primitives: a [hook](/docs/foundations/hooks) and a [stream](/docs/foundations/streaming). This page explains why both are needed and how they work together. -## The Problem +## The problem -`AbortController` and `AbortSignal` are inherently stateful — an abort happens once and is permanent. In a durable workflow, this state must: +`AbortController` and `AbortSignal` are inherently stateful: an abort happens once and is permanent. In a durable workflow, this state must: -1. **Survive replay** — If `abort()` was called, `signal.aborted` must return `true` on every subsequent replay of the workflow. -2. **Propagate in real-time** — A running step on a different compute instance must receive the abort immediately, not on the next replay. +1. **Survive replay**: If `abort()` was called, `signal.aborted` must return `true` on every subsequent replay of the workflow. +2. **Propagate in real-time**: A running step on a different compute instance must receive the abort immediately, not on the next replay. No single primitive solves both. Hooks provide durable event log state but can't reach into a running step. Streams provide real-time cross-process communication but aren't part of the event log. The solution is to use both. -## Dual Backing: Hook + Stream +## Dual backing: hook + stream Every `AbortController` in the workflow context is backed by: -### Hook (Durable State) +### Hook (durable state) -When `new AbortController()` is called in a workflow, an internal hook is created — similar to calling `createHook()`. This hook is registered in the workflow's invocations queue and produces events in the [event log](/docs/how-it-works/event-sourcing): +When `new AbortController()` is called in a workflow, an internal hook is created, similar to calling `createHook()`. This hook is registered in the workflow's invocations queue and produces events in the [event log](/docs/how-it-works/event-sourcing): - **On creation**: A `hook_created` event records that the controller exists - **On abort**: The hook is resumed (producing a `hook_received` event), recording the abort permanently - **On replay**: The event consumer processes the `hook_received` event and updates `signal.aborted` to `true` at the same point in the replay as the original abort -This gives the workflow deterministic access to the abort state — `controller.signal.aborted` always returns the correct value, even after cold starts. +This gives the workflow deterministic access to the abort state: `controller.signal.aborted` always returns the correct value, even after cold starts. -### Stream (Real-Time Propagation) +### Stream (real-time propagation) When `controller.signal` is serialized as a step argument, a stream name is included in the serialized form. Inside the step, the deserialized `AbortSignal` listens on this stream: @@ -50,7 +50,7 @@ When `controller.signal` is serialized as a step argument, a stream name is incl This gives steps real-time cancellation without waiting for the workflow to replay. -### Why Both? +### Why both? | Mechanism | Solves | Doesn't Solve | |---|---|---| @@ -60,18 +60,18 @@ This gives steps real-time cancellation without waiting for the workflow to repl ## Lifecycle -### 1. Controller Created in Workflow +### 1. Controller created in workflow -``` +```text new AbortController() │ ├─→ Internal hook created (registered in invocations queue) └─→ Stream name generated (deterministic ULID) ``` -### 2. Signal Passed to Step +### 2. Signal passed to step -``` +```text stepFunction(controller.signal) │ ├─→ Signal serialized as { streamName, hookToken, aborted } @@ -80,9 +80,9 @@ stepFunction(controller.signal) └─→ Background reader listens on stream for abort packet ``` -### 3. abort() Called in Workflow +### 3. abort() called in workflow -``` +```text controller.abort() │ ├─→ signal.aborted set to true (synchronous, local state) @@ -92,13 +92,13 @@ controller.abort() ├─→ Suspension handler creates hook_received event ├─→ Suspension handler writes cancellation packet to stream │ │ - │ └─→ Step receives packet → local signal fires → fetch cancelled + │ └─→ Step receives packet → local signal fires → fetch canceled └─→ Workflow re-enqueued for replay ``` -### 4. Workflow Replays After Abort +### 4. Workflow replays after abort -``` +```text Replay starts → events loaded │ ├─→ new AbortController() → hook created → event consumer subscribes @@ -107,34 +107,34 @@ Replay starts → events loaded └─→ Workflow code sees signal.aborted === true at the correct point in replay ``` -On replay, the events consumer re-applies the abort by calling `_setAborted` when it encounters the `hook_received` event in the log — at the same point in execution where the original `abort()` happened. This is what makes the abort deterministic across replays. +On replay, the events consumer re-applies the abort by calling `_setAborted` when it encounters the `hook_received` event in the log, at the same point in execution where the original `abort()` happened. This is what makes the abort deterministic across replays. -## Where the Hook Is Created +## Where the hook is created The backing hook is set up whenever an `AbortController` or `AbortSignal` enters the workflow context: -**`new AbortController()` in a workflow function** — The workflow VM provides a durable `AbortController` implementation (similar to how it provides deterministic `Date` and serializable `Request`/`Response`). The hook is created in the constructor using the orchestrator context injected via VM globals. +**`new AbortController()` in a workflow function**: The workflow VM provides a durable `AbortController` implementation (similar to how it provides deterministic `Date` and serializable `Request`/`Response`). The hook is created in the constructor using the orchestrator context injected via VM globals. -**Returned from a step** — A step can create a plain `new AbortController()` and return it. The step-side serializer generates a stream name and hook token (using a random ULID) and includes them in the serialized payload. When the return value is deserialized into the workflow via `hydrateStepReturnValue`, the workflow reviver reads the token from the payload and sets up the hook with that token. Since the serialized payload is stored in the event log (as part of the `step_completed` event), the same token is used on every replay — no deterministic generation needed in the workflow. +**Returned from a step**: A step can create a plain `new AbortController()` and return it. The step-side serializer generates a stream name and hook token (using a random ULID) and includes them in the serialized payload. When the return value is deserialized into the workflow via `hydrateStepReturnValue`, the workflow reviver reads the token from the payload and sets up the hook with that token. Since the serialized payload is stored in the event log (as part of the `step_completed` event), the same token is used on every replay, so no deterministic generation is needed in the workflow. -**Passed as workflow input** — Conceptually the same as "returned from a step". The **external reducer** handles it at serialization time: +**Passed as workflow input**: Conceptually the same as "returned from a step". The **external reducer** handles it at serialization time: 1. Generates a stream name and hook token (random ULID) 2. Attaches an `abort` event listener on the source signal: when the external code calls `controller.abort()`, the listener writes the cancellation packet to the stream 3. Pushes the listener's async work into `ops` (awaited via `waitUntil`) 4. Serializes the reference as `{ streamName, hookToken, aborted }` -The serialized payload (including the generated token) is stored in the event log as part of the workflow's input. When the workflow deserializes the input, the reviver reads the token from the payload and creates the hook — identical to the "returned from a step" case. On replay, the same token is read from the event log, so the hook matches the same events. +The serialized payload (including the generated token) is stored in the event log as part of the workflow's input. When the workflow deserializes the input, the reviver reads the token from the payload and creates the hook, identical to the "returned from a step" case. On replay, the same token is read from the event log, so the hook matches the same events. If the external code calls `abort()` while the process is still alive (within the `waitUntil` window), the stream packet arrives in the workflow, and the workflow can resume the hook to record it in the event log. -Since the external `AbortController` is a plain JavaScript object (not the workflow VM's durable version), the stream write depends on the originating process still being alive. This is the same constraint that applies to passing a `ReadableStream` as a workflow argument — the stream pipe runs via `waitUntil` and requires the process to remain active until the data is written. +Since the external `AbortController` is a plain JavaScript object (not the workflow VM's durable version), the stream write depends on the originating process still being alive. This is the same constraint that applies to passing a `ReadableStream` as a workflow argument: the stream pipe runs via `waitUntil` and requires the process to remain active until the data is written. -## Serialization & Deserialization +## Serialization & deserialization -### Serialized Form +### Serialized form An `AbortController` or `AbortSignal` is serialized as: @@ -148,23 +148,23 @@ An `AbortController` or `AbortSignal` is serialized as: } ``` -The `streamName` and `hookToken` are generated once at serialization time (in the step or external context) and stored in the event log as part of the serialized payload. On replay, the workflow reviver reads them from the payload — it never generates them itself. This is the same pattern used by `ReadableStream` and `WritableStream` serialization. +The `streamName` and `hookToken` are generated once at serialization time (in the step or external context) and stored in the event log as part of the serialized payload. On replay, the workflow reviver reads them from the payload; it never generates them itself. This is the same pattern used by `ReadableStream` and `WritableStream` serialization. -### Reducers (Serialization) +### Reducers (serialization) **In step context** (`getStepReducers`): When a step returns an `AbortController`, the reducer captures the stream name. If `abort()` was called in the step, `aborted: true` is recorded. -**In workflow context** (`getWorkflowReducers`): The reducer captures the stream name and hook token. These are handles — no I/O happens during serialization in the workflow. +**In workflow context** (`getWorkflowReducers`): The reducer captures the stream name and hook token. These are handles; no I/O happens during serialization in the workflow. **In external context** (`getExternalReducers`): When an `AbortController` is passed as a workflow argument from outside, the reducer creates the backing stream and serializes the reference. -### Revivers (Deserialization) +### Revivers (deserialization) **Into step context** (`getStepRevivers`): Creates a real `AbortController`. If `aborted: true`, calls `abort()` immediately. Otherwise, pushes a stream reader into the step's `ops` array that listens for the cancellation packet and calls `abort()` when received. **Into workflow context** (`getWorkflowRevivers`): Creates the durable AbortController with hook backing. Subscribes to the events consumer for the hook's correlation ID. If the event log contains a `hook_received` event, `signal.aborted` is `true`. -### abort() in a Step +### abort() in a step When `abort()` is called on a deserialized `AbortController` inside a step: @@ -172,18 +172,18 @@ When `abort()` is called on a deserialized `AbortController` inside a step: 2. The stream write (cancellation packet) is pushed into `ctx.ops` 3. The hook resume (`resumeHook`) is pushed into `ctx.ops` -The step's `ops` array is awaited via `waitUntil(Promise.all(ops))` after the step function returns — the same mechanism used by [`getWritable()`](/docs/api-reference/workflow/get-writable). This keeps `abort()` synchronous from the caller's perspective while ensuring the async work completes. +The step's `ops` array is awaited via `waitUntil(Promise.all(ops))` after the step function returns, the same mechanism used by [`getWritable()`](/docs/api-reference/workflow/get-writable). This keeps `abort()` synchronous from the caller's perspective while ensuring the async work completes. -### Abort Errors Are Wrapped in FatalError +### Abort errors are wrapped in FatalError -When a step throws due to an abort — whether from `fetch` throwing `AbortError`, `signal.throwIfAborted()`, or any other abort-induced error — the step executor wraps the error in `FatalError` before recording it in the event log. This ensures: +When a step throws due to an abort (whether from `fetch` throwing `AbortError`, `signal.throwIfAborted()`, or any other abort-induced error), the step executor wraps the error in `FatalError` before recording it in the event log. This ensures: -- **No retries**: An abort is intentional cancellation, not a transient failure. Retrying would just abort again. +- **No retries**: An abort is intentional cancellation, not a transient failure. Retrying would abort again. - **Immediate propagation**: The error bubbles up to the workflow as a `FatalError`, which the workflow can catch with `FatalError.is(err)`. The wrapping happens in `runtime/step-executor.ts` during error hydration. When the step's thrown error is an `AbortError` (checked via `err.name === 'AbortError'`), it is treated as fatal regardless of the step's `maxRetries` configuration. -### abort() in the Workflow +### abort() in the workflow When `abort()` is called in the workflow context: @@ -194,7 +194,7 @@ When `abort()` is called in the workflow context: - Creates a `hook_received` event in the event log - Writes the cancellation packet to the stream (for real-time step propagation) - Re-enqueues the workflow for replay -4. On replay, the event consumer processes the `hook_received` event, updating `signal.aborted` to `true` at the deterministically correct point +5. On replay, the event consumer processes the `hook_received` event, updating `signal.aborted` to `true` at the deterministically correct point `signal.aborted` is updated synchronously so that the workflow can immediately check the state and serialization captures `aborted: true` when passing the signal to steps. On replay, the event consumer also processes the `hook_received` event, ensuring the state is consistent. @@ -203,55 +203,55 @@ For abort specifically, this ensures that: - The abort's `hook_received` event is created in the event log - The cancellation stream packet is written to propagate to running steps -## Race Conditions +## Race conditions -### Abort Before Hook Exists +### Abort before hook exists When an `AbortSignal` is passed as a workflow argument via `start()`, the external reducer attaches a listener at serialization time. If the external code calls `abort()` before the workflow has started and created the internal hook, the stream packet is written but the hook doesn't exist yet. This is resolved through eventual consistency: -1. The stream packet is durable — it persists in storage +1. The stream packet is durable; it persists in storage 2. When the workflow runs and passes the signal to a step, the step's reviver reads from the stream starting at index 0 3. The step sees the existing packet, aborts locally, and resumes the hook (via `ops`) 4. On the next workflow replay, the hook event is in the log and `signal.aborted` is `true` -**Important:** There is a window where the workflow's `signal.aborted` returns `false` even though the external code has already called `abort()`. This lasts until a step processes the stream packet and resumes the hook. This is analogous to hooks — `resumeHook()` doesn't take effect until the workflow replays. +**Important:** There is a window where the workflow's `signal.aborted` returns `false` even though the external code has already called `abort()`. This lasts until a step processes the stream packet and resumes the hook. This is analogous to hooks: `resumeHook()` doesn't take effect until the workflow replays. -### Abort at Serialization Time +### Abort at serialization time To prevent a micro-window where `abort()` is called between checking `signal.aborted` and attaching the listener, the external reducer uses this order: 1. Attach the `abort` event listener first -2. Then check `signal.aborted` — if already `true`, the listener won't fire, so handle immediately +2. Then check `signal.aborted`; if already `true`, the listener won't fire, so handle immediately This ensures no abort events are missed regardless of timing. -## Stream/Hook Consistency +## Stream/hook consistency Since abort involves two operations (stream write + hook resume), partial failure is possible: -### Stream Succeeds, Hook Fails +### Stream succeeds, hook fails - Steps see the abort and throw `AbortError` (stream worked) - Workflow doesn't see `signal.aborted === true` on the next replay (hook not resumed) - The workflow sees the step failure as an error, which it can handle with try/catch -- **Recovery:** The step-side `resumeHook` call is best-effort — if it throws, the failure is swallowed. Convergence comes from the next replay: when the step's reviver re-reads the stream, it sees the abort packet and calls `resumeHook` again. There's no in-process retry loop; the dual-mechanism design relies on either the stream or the hook eventually landing. +- **Recovery:** The step-side `resumeHook` call is best-effort: if it throws, the failure is swallowed. Convergence comes from the next replay: when the step's reviver re-reads the stream, it sees the abort packet and calls `resumeHook` again. There's no in-process retry loop; the dual-mechanism design relies on either the stream or the hook eventually landing. -### Hook Succeeds, Stream Fails +### Hook succeeds, stream fails - Workflow sees `signal.aborted === true` on replay (hook worked) -- Steps don't receive real-time cancellation (stream failed) — they run to completion +- Steps don't receive real-time cancellation (stream failed), so they run to completion - On the next suspension, the workflow knows the abort happened and can stop calling more steps -- **Recovery:** Natural convergence — no active harm, just missed real-time cancellation for in-flight steps. +- **Recovery:** Natural convergence. No active harm, only missed real-time cancellation for in-flight steps. -### Both Fail +### Both fail -- Abort is lost — no propagation -- No crash or corruption — the system continues as if abort was never called +- Abort is lost; no propagation +- No crash or corruption; the system continues as if abort was never called - **Recovery:** The caller can retry the abort. If using a hook for external cancellation, the hook's retry semantics apply. -The dual mechanism provides natural resilience — if either one succeeds, the system converges on the correct state. +The dual mechanism provides natural resilience: if either one succeeds, the system converges on the correct state. ## `AbortSignal.timeout()` in Workflow VM @@ -264,7 +264,7 @@ The dual mechanism provides natural resilience — if either one succeeds, the s A `Request`'s `.signal` is forwarded by the `Request` reducer in two cases: 1. **The signal is already aborted.** The serialized payload preserves `aborted: true` and the abort `reason`, so the deserialized step sees the cancellation that happened before the boundary. -2. **The signal is workflow-managed** (i.e., it has the `ABORT_STREAM_NAME` symbol — produced by a workflow-context `AbortController`). Its hook + stream backing carries through, and the deserialized step listens on the stream as usual. +2. **The signal is workflow-managed** (i.e., it has the `ABORT_STREAM_NAME` symbol, produced by a workflow-context `AbortController`). Its hook + stream backing carries through, and the deserialized step listens on the stream as usual. Plain non-aborted native signals are intentionally dropped, including the auto-generated signal that `new Request(url)` synthesizes when no `signal` is passed. Forwarding every Request signal would mint stream infrastructure for the throwaway auto-signals on every Request, even ones the caller never intended to use for cancellation. @@ -278,10 +278,10 @@ await fetchStep(req); // signal carries through controller.abort(); // step-side fetch sees the abort ``` -## Related Documentation +## Related documentation -- [Cancellation](/docs/foundations/cancellation) — Usage patterns and API -- [Event Sourcing](/docs/how-it-works/event-sourcing) — How the event log works -- [Hooks](/docs/foundations/hooks) — The hook primitive -- [Streaming](/docs/foundations/streaming) — The stream primitive -- [Serialization](/docs/foundations/serialization) — Serializable types +- [Cancellation](/docs/foundations/cancellation): Usage patterns and API +- [Event Sourcing](/docs/how-it-works/event-sourcing): How the event log works +- [Hooks](/docs/foundations/hooks): The hook primitive +- [Streaming](/docs/foundations/streaming): The stream primitive +- [Serialization](/docs/foundations/serialization): Serializable types diff --git a/docs/content/docs/v5/how-it-works/code-transform.mdx b/docs/content/docs/v5/how-it-works/code-transform.mdx index 32e9315861..0428d15ce5 100644 --- a/docs/content/docs/v5/how-it-works/code-transform.mdx +++ b/docs/content/docs/v5/how-it-works/code-transform.mdx @@ -1,5 +1,5 @@ --- -title: How the Directives Work +title: How the directives work description: Deep dive into the internals of how Workflow SDK directives transform your code. type: conceptual summary: Learn how the compiler transforms directive-annotated code into three execution modes. @@ -10,12 +10,12 @@ related: --- -This is an advanced guide that dives into internals of the Workflow SDK directive and is not required reading to use workflows. To simply use the Workflow SDK, check out the [getting started](/docs/getting-started) guides for your framework. +This advanced guide covers the internals of Workflow SDK directives. To start using Workflow SDK, see the [getting started](/docs/getting-started) guide for your framework. -Workflows use special directives to mark code for transformation by the Workflow SDK compiler. This page explains how `"use workflow"` and `"use step"` directives work, what transformations are applied, and why they're necessary for durable execution. +Workflows use special directives to mark code for transformation by the Workflow SDK compiler. The `"use workflow"` and `"use step"` directives apply the transformations required for durable execution. -## Directives Overview +## Directives overview Workflows use two directives to mark functions for special handling: @@ -43,7 +43,7 @@ async function createUser(email: string) { These directives trigger the `@workflow/swc-plugin` compiler to transform your code in different ways depending on the execution context. -## The Three Transformation Modes +## The three transformation modes The compiler operates in three distinct modes, transforming the same source code differently for each execution context: @@ -60,7 +60,7 @@ flowchart LR D --> I["Build manifest
(discovery)"] ``` -### Comparison Table +### Comparison table | Mode | Used In | Purpose | Runtime role | Required? | |----------|------------|--------------------------------|--------------|-----------| @@ -72,7 +72,7 @@ flowchart LR Earlier releases had a separate **client mode** for application code. In 5.0 it merged into step mode, which produces the same app-code behavior (workflow functions throw on direct calls and carry `workflowId` for `start()`) while also registering step functions. Build integrations that passed `mode: "client"` now pass `mode: "step"`.
-## Detailed Transformation Examples +## Detailed transformation examples @@ -119,12 +119,12 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; / - Step function bodies are kept completely intact (no transformation) - Each step function is registered with the runtime via an inline IIFE (no imports needed) - Step functions run with full Node.js/Deno/Bun access -- Workflow function bodies are **replaced** with an error throw, and a `workflowId` property is attached — workflow functions must be launched with [`start()`](/docs/api-reference/workflow-api/start), never called directly, and the ID is what `start()` uses to identify the workflow +- Workflow function bodies are **replaced** with an error throw, and a `workflowId` property is attached. Launch workflow functions with [`start()`](/docs/api-reference/workflow-api/start) instead of calling them directly. The ID identifies the workflow to `start()` - A dead-code-elimination pass removes code reachable only from the replaced workflow bodies -**Why no step transformation?** Step functions execute in your main runtime with full access to Node.js APIs, file system, databases, etc. They don't need any special handling—they just run normally. +**Why no step transformation?** Step functions execute in your main runtime with full access to Node.js APIs, the file system, databases, and other resources. They run normally without special handling. -**ID Format:** Step IDs follow the pattern `step//{filepath}//{functionName}`, where the filepath is relative to your project root. +**ID format:** Step IDs follow the pattern `step//{filepath}//{functionName}`, where the file path is relative to your project root. @@ -164,7 +164,7 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; / **What happens:** - Step function bodies are **replaced** with calls to `globalThis[Symbol.for("WORKFLOW_USE_STEP")]` -- Workflow function bodies remain **intact**—they execute deterministically during replay +- Workflow function bodies remain **intact**, so they execute deterministically during replay - The workflow function gets a `workflowId` property for runtime identification - The `"use workflow"` directive is removed @@ -172,9 +172,9 @@ handleUserSignup.workflowId = "workflow//workflows/user.js//handleUserSignup"; / 1. Checks if the step has already been executed (in the event log) 2. If yes: Returns the cached result -3. If no: Suspends the replay and executes the step — usually inline in the same invocation, with the run only handed back to the queue when the invocation's inline budget is exhausted or its timeout approaches +3. If no: Suspends the replay and executes the step, usually inline in the same invocation. The runtime returns the run to the queue only when the invocation's inline budget is exhausted or its timeout approaches -**ID Format:** Workflow IDs follow the pattern `workflow//{filepath}//{functionName}`. The `workflowId` property is attached to the function to allow [`start()`](/docs/api-reference/workflow-api/start) to work at runtime. +**ID format:** Workflow IDs follow the pattern `workflow//{filepath}//{functionName}`. The `workflowId` property is attached to the function so [`start()`](/docs/api-reference/workflow-api/start) works at runtime. @@ -206,11 +206,11 @@ export async function handleUserSignup(email: string) { **What happens:** -- The code is **not modified** — detect mode only walks the AST +- The code is **not modified**. Detect mode only walks the AST - Discovered workflows, steps, and custom-serialization classes are emitted as a JSON manifest comment - The build uses this to decide which files feed the step and workflow bundles -**Why a separate mode?** The build system first runs a fast regexp pre-scan to find candidate files containing directive-like strings, then runs detect mode on those candidates to validate at the AST level. False positives — for example, a directive-like string inside a template literal — are eliminated because the plugin only recognizes genuine directive statements. +**Why a separate mode?** The build system first runs a fast regular expression pre-scan to find candidate files containing directive-like strings. It then runs detect mode on those candidates to validate them at the abstract syntax tree (AST) level. The plugin eliminates false positives, such as a directive-like string inside a template literal, because it only recognizes genuine directive statements. **Working without the app-code loader:** Frameworks apply the step-mode transform to application code by default, which is what gives `start(handleUserSignup)` its automatic IDs and type safety. If your setup can't run the loader, you can instead construct workflow IDs manually using the pattern `workflow//{filepath}//{functionName}`, look them up in the build manifest, and pass them to `start()` as strings. @@ -219,7 +219,7 @@ export async function handleUserSignup(email: string) { -## Generated Files +## Generated files When you build your application, the Workflow SDK generates a combined flow handler, an internal step registration bundle, and a webhook handler. Exact filenames vary by framework. @@ -232,7 +232,7 @@ Contains all workflow functions transformed in **workflow mode**. This file is i All workflow code is bundled together and embedded as a string inside `flow.js`. When a workflow needs to execute, this bundled code is run inside a **Node.js VM** (virtual machine) to ensure: - **Determinism**: The same inputs always produce the same outputs -- **Side-effect prevention**: Direct access to Node.js APIs, file system, network, etc. is blocked +- **Side-effect prevention**: Direct access to Node.js APIs, the file system, the network, and other resources is blocked - **Sandboxed execution**: Workflow orchestration logic is isolated from the main runtime **Build-time validation:** @@ -268,7 +268,7 @@ Contains all step functions transformed in **step mode**. The combined flow hand This module must not be exposed as an HTTP endpoint. - **Changed in 5.0:** in 4.x the step bundle was served as its own HTTP route at `POST /.well-known/workflow/v1/step`, with step messages delivered on a separate `__wkf_step_*` queue topic. v5 merged both into the combined flow handler — the step bundle became a registration module imported by `flow.js`, and step messages arrive on the shared workflow queue. See the v4 version of this page, reachable from the version picker, for the old layout. + **Changed in 5.0:** In 4.x, the step bundle was served as its own HTTP route at `POST /.well-known/workflow/v1/step`, with step messages delivered on a separate `__wkf_step_*` queue topic. v5 merged both into the combined flow handler. The step bundle became a registration module imported by `flow.js`, and step messages arrive on the shared workflow queue. Use the version picker to see the old layout on the v4 version of this page. ### `webhook.js` @@ -281,24 +281,24 @@ Contains webhook handling logic for delivering external data to running workflow - Validates tokens and routes data to the correct workflow run - Resumes workflow execution after webhook delivery -**Note:** The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to leverage App Router's dynamic routing, while other frameworks generate a single `webhook.js` or `webhook.mjs` handler. +The webhook file structure varies by framework. Next.js generates `webhook/[token]/route.js` to use App Router's dynamic routing, while other frameworks generate a single `webhook.js` or `webhook.mjs` handler. -## Why Three Modes? +## Why three modes? The multi-mode transformation enables the Workflow SDK's durable execution model: -1. **Step Mode** (required) - Bundles executable step functions that can access the full runtime, and doubles as the app-code transform that prevents direct workflow execution and enables type-safe `start()` references -2. **Workflow Mode** (required) - Creates orchestration logic that can replay from event logs -3. **Detect Mode** (build-internal) - Discovers directive-marked functions for the build without touching the code +1. **Step mode** (required): Bundles executable step functions that can access the full runtime and serves as the application-code transform that prevents direct workflow execution and enables type-safe `start()` references +2. **Workflow mode** (required): Creates orchestration logic that can replay from event logs +3. **Detect mode** (build-internal): Discovers directive-marked functions for the build without changing the code This separation allows: - **Deterministic replay**: Workflows can be safely replayed from event logs without re-executing side effects - **Sandboxed orchestration**: Workflow logic runs in a controlled VM without direct runtime access - **Stateless execution**: Your compute can scale to zero and resume from any point in the workflow -- **Type safety**: TypeScript works seamlessly with workflow references passed to `start()` +- **Type safety**: TypeScript supports workflow references passed to `start()` -## Determinism and Replay +## Determinism and replay A key aspect of the transformation is maintaining **deterministic replay** for workflow functions. @@ -308,7 +308,7 @@ A key aspect of the transformation is maintaining **deterministic replay** for w - No direct side effects (no API calls, no database writes, no file I/O) - Can use seeded random/time APIs provided by the VM (`Math.random()`, `Date.now()`, etc.) -Because workflow functions are deterministic and have no side effects, they can be safely re-run multiple times to calculate what the next step should be. This is why workflow function bodies remain intact in workflow mode—they're pure orchestration logic. +Because workflow functions are deterministic and have no side effects, they can be safely rerun multiple times to calculate the next step. Workflow function bodies remain intact in workflow mode because they're pure orchestration logic. **Step functions can be non-deterministic:** @@ -318,7 +318,7 @@ Because workflow functions are deterministic and have no side effects, they can Learn more about [Workflows and Steps](/docs/foundations/workflows-and-steps). -## ID Generation +## ID generation The compiler generates stable IDs for workflows and steps based on file paths and function names: @@ -340,15 +340,15 @@ The compiler generates stable IDs for workflows and steps based on file paths an Although IDs can change when files are moved or functions are renamed, Workflow SDK functions assume [atomic versioning](/docs/foundations/versioning) in the World. This means changing IDs won't break old workflows from running, but will prevent runs from being upgraded and will cause your workflow/step names to change in observability across deployments. -## Framework Integration +## Framework integration -These transformations are framework-agnostic—they output standard JavaScript that works anywhere. +These transformations are framework-agnostic. They output standard JavaScript that works anywhere. **For users**: Your framework handles all transformations automatically. See the [Getting Started](/docs/getting-started) guide for your framework. **For framework authors**: Learn how to integrate these transformations into your framework in [Building Framework Integrations](/docs/how-it-works/framework-integrations). -## Debugging Transformed Code +## Debugging transformed code If you need to debug transformation issues, you can inspect the generated files: diff --git a/docs/content/docs/v5/how-it-works/encryption.mdx b/docs/content/docs/v5/how-it-works/encryption.mdx index dc165c27bc..5ea8e59fce 100644 --- a/docs/content/docs/v5/how-it-works/encryption.mdx +++ b/docs/content/docs/v5/how-it-works/encryption.mdx @@ -11,58 +11,58 @@ related: --- -This guide explains how Workflow SDK encrypts user data in the event log. Understanding these details is not required to use workflows — encryption is automatic and requires no code changes. For getting started, see the [getting started](/docs/getting-started) guides for your framework. +Workflow SDK automatically encrypts user data in the event log without requiring code changes. To start using workflows, see the [getting started](/docs/getting-started) guide for your framework. -Workflow SDK supports automatic end-to-end encryption of all user data before it is written to the event log. When a `World` implementation provides encryption support, it is safe to pass sensitive data — such as API keys, tokens, or user credentials — as workflow inputs, step arguments, and return values. The storage backend only ever sees ciphertext. +Workflow SDK supports automatic end-to-end encryption of all user data before writing it to the event log. When a `World` implementation provides encryption support, you can pass sensitive data, such as API keys, tokens, or user credentials, as workflow inputs, step arguments, and return values. The storage backend only sees ciphertext. -Encryption support varies by `World` implementation. See the [Worlds](/worlds) page to check which worlds support this feature. `World` implementations opt into encryption by providing a `getEncryptionKeyForRun()` method — the core runtime will use it automatically when present. +Encryption support varies by `World` implementation. See the [Worlds](/worlds) page to check which Worlds support this feature. `World` implementations opt into encryption by providing a `getEncryptionKeyForRun()` method. The core runtime uses it automatically when present. -## What Is Encrypted +## What is encrypted All user data flowing through the event log is encrypted: -- **Workflow inputs** — arguments passed when starting a workflow -- **Workflow return values** — the final output of a workflow -- **Step inputs** — arguments passed to step functions -- **Step return values** — the result returned by step functions -- **Hook metadata** — data attached when creating a hook -- **Hook payloads** — data received by hooks and webhooks -- **Stream data** — each frame in a `ReadableStream` or `WritableStream` +- **Workflow inputs**: Arguments passed when starting a workflow +- **Workflow return values**: The final output of a workflow +- **Step inputs**: Arguments passed to step functions +- **Step return values**: The result returned by step functions +- **Hook metadata**: Data attached when creating a hook +- **Hook payloads**: Data received by hooks and webhooks +- **Stream data**: Each frame in a `ReadableStream` or `WritableStream` Metadata such as workflow names, step names, entity IDs, timestamps, and lifecycle states are **not** encrypted. This allows the observability tools to display run structure and timelines without requiring decryption. -## How It Works +## How it works ### Compression -Payloads are compressed before they are encrypted. A format prefix on the stored value records the compression codec (gzip, with zstd support in the format), and the inner payload keeps its own serialization format prefix after decompression. Repetitive payloads compress heavily — AI token streams average around 80% smaller — which means less stored data and less to move over the network. Like encryption itself, this is automatic and requires no code changes. +Payloads are compressed before encryption. A format prefix on the stored value records the compression codec (gzip, with zstd support in the format), and the inner payload keeps its own serialization format prefix after decompression. Repetitive payloads compress heavily. AI token streams average around 80% smaller, reducing storage and network transfer. Like encryption, compression is automatic and requires no code changes. -### Key Management +### Key management Each workflow run is encrypted with its own unique key, provided by the `World` implementation via `getEncryptionKeyForRun()`. How the key is generated and stored is up to the `World`. For example, the [Vercel World](/worlds/vercel) provides unique keys per run and execution environment, ensuring that a given run can only decrypt data from that run itself. -### Encryption Algorithm +### Encryption algorithm Data is encrypted using **AES-256-GCM** via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API): - A random 12-byte nonce is generated for each encryption operation -- The GCM authentication tag provides integrity verification — any tampering with the ciphertext is detected +- The GCM authentication tag provides integrity verification and detects any ciphertext tampering - The same plaintext produces different ciphertext each time due to the random nonce -## Decrypting Data +## Decrypting data When viewing workflow runs through the observability tools, encrypted fields display as locked placeholders until you explicitly choose to decrypt them. ### Permissions -Decryption access is controlled by the `World` implementation. On Vercel, decryption follows the same permissions model as project environment variables — if you don't have permission to view environment variable values for a project, you won't be able to decrypt workflow data either. Each decryption request is recorded in your [Vercel audit log](https://vercel.com/docs/audit-log), giving your team full visibility into when and by whom workflow data was accessed. +The `World` implementation controls decryption access. On Vercel, decryption follows the same permissions model as project environment variables. If you don't have permission to view environment variable values for a project, you can't decrypt its workflow data. Vercel records each decryption request in your [Vercel audit log](https://vercel.com/docs/audit-log), so your team can see when someone accessed workflow data and who accessed it. -### Web Dashboard +### Web dashboard -Click the **Decrypt** button in the run detail panel to decrypt all data fields. Decryption happens entirely in the browser via the Web Crypto API — the observability server retrieves the encryption key but never sees your plaintext data. +Click the **Decrypt** button in the run detail panel to decrypt all data fields. Decryption happens entirely in the browser through the Web Crypto API. The observability server retrieves the encryption key but never sees your plaintext data. ### CLI @@ -84,7 +84,7 @@ npx workflow inspect stream --run --decrypt Without `--decrypt`, encrypted fields display as `🔒 Encrypted` placeholders. -## Custom World Implementations +## Custom World implementations The core runtime encrypts data automatically when the `World` implementation provides a `getEncryptionKeyForRun()` method. The core runtime can call this method in two forms: @@ -102,7 +102,7 @@ Use `getEncryptionKeyForRun(run)` when the run entity already exists. Use `getEn To add encryption support to a custom `World`: 1. Implement `getEncryptionKeyForRun()` on your `World` class, handling both call shapes -2. Return the raw 32-byte key as a `Uint8Array` — the core runtime uses it for AES-256-GCM operations +2. Return the raw 32-byte key as a `Uint8Array`. The core runtime uses it for AES-256-GCM operations 3. Ensure the same key is returned for the same run ID across invocations (for decryption during replay) ```typescript diff --git a/docs/content/docs/v5/how-it-works/event-sourcing.mdx b/docs/content/docs/v5/how-it-works/event-sourcing.mdx index 0a7b870ce9..cb6091010d 100644 --- a/docs/content/docs/v5/how-it-works/event-sourcing.mdx +++ b/docs/content/docs/v5/how-it-works/event-sourcing.mdx @@ -1,5 +1,5 @@ --- -title: Event Sourcing +title: Event sourcing description: Learn how Workflow SDK uses event sourcing internally for debugging and observability. type: conceptual summary: Understand the event log that powers workflow replay and debugging. @@ -10,14 +10,12 @@ related: --- -This guide explores how the Workflow SDK uses event sourcing internally. Understanding these concepts is helpful for debugging and building observability tools, but is not required to use workflows. For getting started with workflows, see the [getting started](/docs/getting-started) guides for your framework. +Workflow SDK uses event sourcing internally for debugging and observability tools. To start using workflows, see the [getting started](/docs/getting-started) guide for your framework. The Workflow SDK uses event sourcing to track all state changes in workflow executions. Every mutation creates an event that is persisted to the event log, and entity state is derived by replaying these events. -This page explains the event sourcing model and entity lifecycles. - -## Event Sourcing Overview +## Event sourcing overview Event sourcing is a persistence pattern where state changes are stored as a sequence of events rather than by updating records in place. The current state of any entity is reconstructed by replaying its events from the beginning. @@ -35,15 +33,15 @@ In the Workflow SDK, the following entity types are managed through events: - **Hooks**: Suspension points that can receive external data (materialized in storage) - **Waits**: Sleep or delay operations (materialized in storage) -## Entity Lifecycles +## Entity lifecycles -Each entity type follows a specific lifecycle defined by the events that can affect it. Events transition entities between states, and certain states are terminal—once reached, no further transitions are possible. +Each entity type follows a specific lifecycle defined by the events that can affect it. Events transition entities between states. Once an entity reaches a terminal state, no further transitions are possible. In the diagrams below, purple nodes indicate terminal states that cannot be transitioned out of. -### Run Lifecycle +### Run lifecycle A run represents a single execution of a workflow function. Runs begin in `pending` state when created, transition to `running` when execution starts, and end in one of three terminal states. @@ -67,9 +65,9 @@ flowchart TD - `running`: Actively executing workflow code - `completed`: Finished successfully with an output value - `failed`: Terminated due to an unrecoverable error -- `cancelled`: Explicitly cancelled by the user or system +- `cancelled`: Explicitly canceled by the user or system -### Step Lifecycle +### Step lifecycle A step represents a single invocation of a step function. Steps can retry on failure, either transitioning back to `pending` via `step_retrying` or being re-executed directly with another `step_started` event. @@ -94,7 +92,7 @@ flowchart TD - `cancelled`: Reserved for future use (not currently emitted) -The `step_retrying` event is optional. Steps can retry without it - the retry mechanism works regardless of whether this event is emitted. You may see back-to-back `step_started` events in logs when a step retries after a timeout or when the error is not explicitly captured, and also when concurrent replays each commit one (see [Duplicate Events](#duplicate-events)). See [Errors and Retries](/docs/foundations/errors-and-retries) for more on how retries work. +The `step_retrying` event is optional. Steps can retry without it, regardless of whether the event is emitted. You may see consecutive `step_started` events when a step retries after a timeout, when the error isn't explicitly captured, or when concurrent replays each commit one. See [Duplicate events](#duplicate-events) and [Errors and retries](/docs/foundations/errors-and-retries) for more information. When present, the `step_retrying` event moves a step back to `pending` state and records the error that caused the retry. This provides two benefits: @@ -102,7 +100,7 @@ When present, the `step_retrying` event moves a step back to `pending` state and - **Cleaner observability**: The event log explicitly shows retry transitions rather than consecutive `step_started` events - **Error history**: The error that triggered the retry is preserved for debugging -### Hook Lifecycle +### Hook lifecycle A hook represents a suspension point that can receive external data, created by [`createHook()`](/docs/api-reference/workflow/create-hook). Hooks enable workflows to pause and wait for external events, user interactions, or HTTP requests. Webhooks (created with [`createWebhook()`](/docs/api-reference/workflow/create-webhook)) are a higher-level abstraction built on hooks that adds automatic HTTP request/response handling. @@ -125,15 +123,15 @@ flowchart TD - `disposed`: No longer accepting payloads - `conflicted`: Hook creation failed because the token is already in use by another workflow -Unlike other entities, hooks don't have a `status` field—the states above are conceptual. When a `hook_disposed` event is created, the hook record is removed rather than updated. +Unlike other entities, hooks don't have a `status` field. The states above are conceptual. When a `hook_disposed` event is created, the hook record is removed rather than updated. -While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token reserved by another run — either by an active hook or by `experimental_minRetention` after its run ended — a `hook_conflict` event is recorded instead of `hook_created`. Current worlds include the token and the run ID that owns it, though older persisted events or world implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details. +While a hook is active, its token is reserved and cannot be used by other workflows. If a workflow attempts to create a hook with a token reserved by another run, either by an active hook or by `experimental_minRetention` after its run ended, a `hook_conflict` event is recorded instead of `hook_created`. Current Worlds include the token and the run ID that owns it, though older persisted events or World implementations may only include the token. This causes `hook.getConflict()` to resolve with the conflicting run and the hook's payload promise to reject with a `HookConflictError`, which you can detect with `HookConflictError.is(error)`. See the [hook-conflict error](/docs/errors/hook-conflict) documentation for more details. -When a workflow ends, its Hooks can no longer be resumed. They are normally removed and their tokens become available again. With `experimental_minRetention`, a Hook remains readable and its token remains unavailable until retention ends. A `hook_disposed` event removes the Hook and makes its token available immediately. +When a workflow ends, its hooks can no longer be resumed. They are normally removed, and their tokens become available again. With `experimental_minRetention`, a hook remains readable, and its token remains unavailable until retention ends. A `hook_disposed` event removes the hook and makes its token available immediately. -See [Hooks & Webhooks](/docs/foundations/hooks) for more on how hooks and webhooks work. +See [Hooks and webhooks](/docs/foundations/hooks) for more on how hooks and webhooks work. -### Wait Lifecycle +### Wait lifecycle A wait represents a sleep operation created by [`sleep()`](/docs/api-reference/workflow/sleep). Waits track when a delay period has elapsed. @@ -151,10 +149,10 @@ flowchart TD - `completed`: Delay period has elapsed, workflow can resume -Like Runs, Steps, and Hooks, waits are materialized as entities in storage. When a `wait_created` event is processed, a wait entity is created with status `waiting`. When a `wait_completed` event is processed, the wait entity is atomically transitioned to `completed` — this guarantees that a wait can only be completed exactly once, even if multiple concurrent invocations attempt to complete it simultaneously. +Like runs, steps, and hooks, waits are materialized as entities in storage. Processing a `wait_created` event creates a wait entity with the `waiting` status. Processing a `wait_completed` event atomically transitions the wait entity to `completed`. This process guarantees that a wait can only be completed once, even if multiple concurrent invocations attempt to complete it simultaneously. -## Event Types Reference +## Event types reference Events are categorized by the entity type they affect. Each event contains metadata including a timestamp and a `correlationId` that links the event to a specific entity: @@ -163,7 +161,7 @@ Events are categorized by the entity type they affect. Each event contains metad - Wait events use the `waitId` as the correlation ID - Run events do not require a correlation ID since the `runId` itself identifies the entity -### Run Events +### Run events | Event | Description | |-------|-------------| @@ -173,7 +171,7 @@ Events are categorized by the entity type they affect. Each event contains metad | `run_failed` | Transitions the run to `failed` state with error details and optional error code. | | `run_cancelled` | Transitions the run to `cancelled` state. Can be triggered from `pending` or `running` states. | -### Step Events +### Step events | Event | Description | |-------|-------------| @@ -183,7 +181,7 @@ Events are categorized by the entity type they affect. Each event contains metad | `step_failed` | Transitions the step to `failed` state with error details. The step will not be retried. | | `step_retrying` | (Optional) Transitions the step back to `pending` state for retry. Contains the error that caused the retry and optional delay before the next attempt. When not emitted, retries appear as consecutive `step_started` events. | -### Hook Events +### Hook events | Event | Description | |-------|-------------| @@ -192,20 +190,20 @@ Events are categorized by the entity type they affect. Each event contains metad | `hook_received` | Records that a payload was delivered to the hook. The hook remains `active` and can receive more payloads. | | `hook_disposed` | Deletes the hook from storage (conceptually transitioning to `disposed` state). The token is released for reuse by future workflows. | -### Wait Events +### Wait events | Event | Description | |-------|-------------| | `wait_created` | Creates a new wait in `waiting` state. Contains the timestamp when the wait should complete. | | `wait_completed` | Transitions the wait to `completed` state when the delay period has elapsed. | -### System Events +### System events | Event | Description | |-------|-------------| -| `noop` | Seals an abandoned log position (specVersion 7 and above). Written only by the backend, never by workflow code — the create endpoints reject it. See [Sealed positions](#sealed-positions-noop-events). | +| `noop` | Seals an abandoned log position (`specVersion` 7 and above). Only the backend writes this event; the create endpoints reject it. See [Sealed positions](#sealed-positions-noop-events). | -## Terminal States +## Terminal states Terminal states represent the end of an entity's lifecycle. Once an entity reaches a terminal state, no further events can transition it to another state. @@ -213,7 +211,7 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach - `completed`: Workflow finished successfully - `failed`: Workflow encountered an unrecoverable error -- `cancelled`: Workflow was explicitly cancelled +- `cancelled`: Workflow was explicitly canceled **Step terminal states:** @@ -231,15 +229,15 @@ Terminal states represent the end of an entity's lifecycle. Once an entity reach Attempting to create an event that would transition an entity out of a terminal state will result in an error. This prevents inconsistent state and ensures the integrity of the event log. -That guard sits on the write path. A duplicate the write path does permit — a second `step_created` for a step that is not yet terminal, for example — is handled during replay instead, described next. +That guard sits on the write path. Replay handles duplicates that the write path permits, such as a second `step_created` for a step that isn't terminal. -## Duplicate Events +## Duplicate events -Concurrent invocations replaying the same run share one event log. An invocation working from a stale prefix — one that predates another invocation's write — can commit its own `step_created`, `step_started`, or `wait_created` for an entity the log already records one of. These writes pass the terminal-state guard above, so the write path commits them even when a backend validates transitions atomically with the insert. +Concurrent invocations replaying the same run share one event log. An invocation working from a stale prefix that predates another invocation's write can commit its own `step_created`, `step_started`, or `wait_created` for an entity that already has one in the log. These writes pass the terminal-state guard, so the write path commits them even when a backend validates transitions atomically with the insert. Those duplicates are committed but inert. The outcome was decided by the first event of its kind at a lower position in the log, and every replay reads that same event at that same position, so a later copy cannot change what the workflow observes. -To keep an inert copy from failing an otherwise healthy run, the runtime groups event types into **classes** and tracks, per entity, which classes the current replay has already consumed. When an event is offered to every registered consumer and none wants it, and its class is already recorded for that entity, the replay steps over it instead of reporting a [replay divergence](/docs/errors/replay-divergence) — which, once the recovery budget is exhausted, ends the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). +To prevent an inert copy from failing an otherwise healthy run, the runtime groups event types into **classes**. For each entity, it tracks which classes the current replay has consumed. If no registered consumer accepts an event and its class is already recorded for that entity, replay skips it instead of reporting a [replay divergence](/docs/errors/replay-divergence). After exhausting the recovery budget, a replay divergence ends the run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). | Class | Event types | |-------|-------------| @@ -253,7 +251,7 @@ To keep an inert copy from failing an otherwise healthy run, the runtime groups | `hook_created` | `hook_created` | | `hook_disposed` | `hook_disposed` | -Types that share a class are the mutually exclusive outcomes of one decision: a step either completes or fails, and the first outcome recorded is the one that counts. Classes are independent of one another, so passing over one does not suppress another. A step whose result is already in the log has still recorded exactly one `step_created`, which is what makes a second one ignorable on its own terms. +Types that share a class are the mutually exclusive outcomes of one decision. A step either completes or fails, and the first recorded outcome counts. Classes are independent, so skipping one doesn't suppress another. A step whose result is already in the log has still recorded exactly one `step_created`, making a second one independently ignorable. The two hook classes cover the same shape of duplicate, and replay reaches them less often because the write path resolves most hook duplicates before they reach the log: a run re-creating a hook it already owns converges on the existing `hook_created` rather than appending a second one, and a second `hook_disposed` for the same hook is refused as an idempotent no-op. A log that holds either anyway is read past like any other repeat. @@ -265,25 +263,25 @@ The remaining event types belong to no class and are never skipped: - `run_created` precedes every replay and is always consumed. - `run_completed`, `run_failed`, and `run_cancelled` never reach the check. The runtime exits before replaying the workflow body once the log holds one of them, so no consumer ever takes one and no class is ever recorded for them. -Both kinds of skip are logged at `debug`, so neither reaches the console unless you run with `DEBUG=workflow:runtime:*`. A duplicate is a permanent feature of the log: every later replay re-reads it and lands on the same check, so anything printed unconditionally would print once per replay for the life of the run, and there is nothing to act on either way. A repeat that decides a class differently — a `step_failed` behind a `step_completed`, or the reverse — gets its own message, because unlike a re-commit of the same outcome there is no reading in which both writers were right. +Both kinds of skip are logged at `debug`, so neither reaches the console unless you run with `DEBUG=workflow:runtime:*`. A duplicate is a permanent part of the log. Every later replay reads it and reaches the same check, so an unconditional message would print once per replay for the life of the run without requiring action. A repeat that decides a class differently, such as a `step_failed` after a `step_completed` or the reverse, gets its own message. Unlike a recommit of the same outcome, both writers cannot be correct. -The observability UI greys out the events it can identify this way, with the reason on hover. Its set is narrower than the runtime's: it reads the log without consumer state, and a consumer for an entity that is still open legitimately claims a repeat — each retry of a step writes another `step_started`. So it marks a repeat only once no consumer can remain for it: past a terminal event for the same entity, or a second `run_started`, of which the log records one per run. On a partial view of the log — one page of a paginated list, or search results — it marks nothing, since which copy came first is a property of the whole log. +The observability UI grays out events it can identify this way and shows the reason on hover. Its set is narrower than the runtime's because it reads the log without consumer state. A consumer for an entity that is still open can legitimately claim a repeat because each step retry writes another `step_started`. The UI marks a repeat only when no consumer can remain for it: after a terminal event for the same entity or at a second `run_started`, of which the log records one per run. The UI marks nothing on a partial log view, such as one page of a paginated list or search results, because identifying the first copy requires the entire log. -## Sealed Positions (noop events) +## Sealed positions (noop events) -Runs at specVersion 7 and above live in a *sealed log*: the backend hands each write its position from a per-run sequencer **before** the write commits, so concurrent writers hold distinct positions and never race one another for a slot. This is how new runs are created by default; [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) puts a deployment back on the previous scheme. Every runtime *reads* a sealed log regardless, and a run's version is fixed at creation, so changing the setting never affects a run already in flight. The trade is that a writer can claim a position and then die — a crashed process, a cancelled transaction — leaving a hole that no writer will ever fill, and a hole reads exactly like an event the reader failed to load. +Runs at `specVersion` 7 and above use a *sealed log*. The backend gives each write its position from a per-run sequencer **before** the write commits, so concurrent writers hold distinct positions and never race for a slot. New runs use this behavior by default. [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) returns a deployment to the previous scheme. Every runtime reads a sealed log regardless, and a run's version is fixed at creation, so changing the setting never affects an in-flight run. However, a writer can claim a position and then stop because of a crashed process or canceled transaction. This leaves a hole that no writer will fill, and a hole looks like an event the reader failed to load. -The backend restores the dense log at read time by **sealing** such positions: once a hole is provably abandoned (bounded by the commit time of later positions — positions are handed out in order, so a committed later position proves how long the hole has been open), the backend writes a `noop` event into it. A `noop` occupies its position — length-based completeness checks, cursors, and pagination all count it — and means nothing: +The backend restores the dense log at read time by **sealing** these positions. Once a hole is provably abandoned, bounded by the commit time of later positions, the backend writes a `noop` event into it. Positions are assigned in order, so a committed later position proves how long the hole has been open. A `noop` occupies its position, and length-based completeness checks, cursors, and pagination all count it. It has no other effect: - It is **never offered to any consumer** during replay. The walk steps over it in the same synchronous pass that delivers the events around it, so its presence cannot perturb delivery order, promise scheduling, or which branch of a `Promise.all` resumes first. -- It **never advances the deterministic clock**. A `noop`'s `createdAt` is the *sealer's* wall clock — it can even postdate events at higher positions — and letting it feed the replay clock would make a log containing a seal replay differently from one whose hole was filled by its original writer. Same rule, and same mechanism, as skipped duplicates above. -- Its `correlationId` is `noop_` followed by the sealed position's zero-padded digits — deterministic, so any two sealers racing for the same hole mint the identical event, and recognizable at a glance in the log. +- It **never advances the deterministic clock**. A `noop`'s `createdAt` is the *sealer's* wall clock and can postdate events at higher positions. Allowing it to feed the replay clock would make a log containing a seal replay differently from one whose original writer filled the hole. This behavior uses the same rule and mechanism as skipped duplicates. +- Its `correlationId` is `noop_` followed by the sealed position's zero-padded digits. This deterministic format ensures that two sealers racing for the same hole create an identical event that is recognizable in the log. -A sealed position races its original writer at the same uniqueness fence as every other write, and losing that race is the good outcome: the real event landed first, and readers get it instead. A live writer that gets sealed over simply re-derives a fresh position and commits there — the same recovery as losing any other write race — so sealing can cost a retry, never a wrong log. +A sealed position races its original writer at the same uniqueness fence as every other write. Losing that race means the real event landed first, so readers receive it instead. A live writer that gets sealed over derives a new position and commits there, using the same recovery as any other lost write race. Sealing can require a retry but cannot produce an incorrect log. `noop` is not user-creatable: it does not exist in the create schemas, and backends reject it on every create endpoint. Only a backend's own read path writes one. -## Event Correlation +## Event correlation Events use a `correlationId` to link related events together. For step, hook, and wait events, the correlation ID identifies the specific entity instance: @@ -299,7 +297,7 @@ This correlation enables: - Building timelines of entity lifecycle transitions - Debugging by tracing the complete history of any entity -### Request ID Correlation +### Request ID correlation Some `World` implementations also attach a `requestId` to events for platform-log correlation. This is different from `correlationId`: @@ -331,7 +329,7 @@ All entities in the Workflow SDK use a consistent ID format: a 4-character prefi **Why this format?** -- **Prefixes enable introspection**: Given any ID, you can immediately identify what type of entity it refers to. This makes debugging, logging, and cross-referencing entities across the system straightforward. +- **Prefixes enable introspection**: Given any ID, you can immediately identify what type of entity it refers to. This helps you debug, log, and cross-reference entities across the system. - **Fixed-width bodies enable ordering**: Unlike UUIDs, these bodies sort lexicographically in creation order, so the event log is stored and retrieved in the correct order by sorting IDs alone. Slot numbers get that from counting at a fixed width, which makes string order the same as numeric order. ULIDs get it from the timestamp in their first 48 bits, which also makes a ULID's creation time recoverable from the ID itself. diff --git a/docs/content/docs/v5/how-it-works/framework-integrations.mdx b/docs/content/docs/v5/how-it-works/framework-integrations.mdx index 8f3e119be0..7fe7348abb 100644 --- a/docs/content/docs/v5/how-it-works/framework-integrations.mdx +++ b/docs/content/docs/v5/how-it-works/framework-integrations.mdx @@ -10,7 +10,7 @@ related: --- - **For users:** If you just want to use Workflow SDK with an existing framework, see [Getting Started](/docs/getting-started). This page is for framework authors. + **For users:** If you want to use Workflow SDK with an existing framework, see [Getting Started](/docs/getting-started). This page is for framework authors. This guide uses Bun as a concrete example, but the same build and routing model applies to other JavaScript frameworks and runtimes. @@ -48,7 +48,7 @@ Steps do not have their own HTTP route. A queued step invocation contains `stepI ### 1. Generate the bundles -The standalone CLI scans `workflows/` and creates the combined flow handler, an internal step registration module, and the webhook handler. +The standalone Workflow CLI scans `workflows/` and creates the combined flow handler, an internal step registration module, and the webhook handler. ```json title="package.json" { @@ -60,9 +60,9 @@ The standalone CLI scans `workflows/` and creates the combined flow handler, an The default output is: -- `/.well-known/workflow/v1/flow.mjs` — the combined workflow and step queue consumer -- `/.well-known/workflow/v1/__step_registrations.mjs` — an internal module imported by `flow.mjs`; do not route to it -- `/.well-known/workflow/v1/webhook.mjs` — webhook delivery +- `/.well-known/workflow/v1/flow.mjs`: the combined workflow and step queue consumer +- `/.well-known/workflow/v1/__step_registrations.mjs`: an internal module imported by `flow.mjs`; do not route to it +- `/.well-known/workflow/v1/webhook.mjs`: webhook delivery Production integrations should extend `BaseBuilder` from `@workflow/builders` so they can participate in the framework's build, watch, and routing lifecycle. @@ -149,10 +149,10 @@ console.log(`Server listening on http://localhost:${server.port}`); The handler consumes every workflow queue message. Depending on the payload and event log, it can: -- start or replay workflow orchestration in the sandboxed VM; -- execute a queued step in the host runtime; -- continue replay inline after a step completes; -- resume a run after a hook, webhook, sleep, retry, or recovery event. +- Start or replay workflow orchestration in the sandboxed VM. +- Execute a queued step in the host runtime. +- Continue replay inline after a step completes. +- Resume a run after a hook, webhook, sleep, retry, or recovery event. ### Webhook endpoint diff --git a/docs/content/docs/v5/how-it-works/understanding-directives.mdx b/docs/content/docs/v5/how-it-works/understanding-directives.mdx index 5595e977c0..dcdf84bb7a 100644 --- a/docs/content/docs/v5/how-it-works/understanding-directives.mdx +++ b/docs/content/docs/v5/how-it-works/understanding-directives.mdx @@ -21,7 +21,7 @@ This page explores how directives enable this execution model and the design pri To understand how directives work, let's first understand what workflows and steps are in the Workflow SDK. -## Workflows and Steps Primer +## Workflows and steps primer The Workflow SDK has two types of functions: @@ -58,7 +58,7 @@ export async function onboardUser(userId: string) { **The key insight:** Workflows resume from suspension by replaying their code using cached step results from the [event log](/docs/how-it-works/event-sourcing). When a step like `await fetchUserData(userId)` is called: - **If already executed:** Returns the cached result immediately from the event log -- **If not yet executed:** Suspends the workflow and executes the step — usually inline in the same invocation, falling back to the queue when the invocation runs out of inline budget or nears its timeout — then resumes with the result +- **If not yet executed:** Suspends the workflow, executes the step (usually inline in the same invocation, or through the queue when the invocation runs out of inline budget or nears its timeout), then resumes with the result This replay mechanism requires deterministic code. If `Math.random()` weren't seeded, the first execution might return `0.7` (sending the email) but replay might return `0.3` (skipping it), thus breaking resumption. The Workflow SDK sandbox provides seeded `Math.random()` and `Date` to ensure consistent behavior across replays. @@ -66,9 +66,9 @@ This replay mechanism requires deterministic code. If `Math.random()` weren't se For a deeper dive into workflows and steps, see [Workflows and Steps](/docs/foundations/workflows-and-steps). -## The Core Challenge +## The core challenge -This execution model enables powerful durability features - workflows can suspend for days, survive restarts, and resume from any point. However, it also requires a semantic boundary in the code that tells **the compiler, runtime, and developer** that execution semantics have changed. +This execution model provides durability: workflows can suspend for days, survive restarts, and resume from any point. However, it also requires a semantic boundary in the code that tells **the compiler, runtime, and developer** that execution semantics have changed. The challenge: how do we mark this boundary in a way that: @@ -77,7 +77,7 @@ The challenge: how do we mark this boundary in a way that: 3. Allows static analysis of workflow structure 4. Feels natural to JavaScript developers -Let's look at where directives have been used before, and the alternatives we considered: +Directives have prior uses, and we considered several alternatives: ## Prior art on directives @@ -104,11 +104,11 @@ The `"use workflow"` directive is also used by the Language Server Plugin shippe But we didn't get here immediately. This took some discovery to arrive at: -## Alternatives We Explored +## Alternatives we explored -Before settling on directives, we prototyped several other approaches. Each had significant limitations that made them unsuitable for production use. +Before settling on directives, we prototyped several other approaches. Each had limitations that made them unsuitable for production use. -### Runtime-Only "Suspense" API +### Runtime-only "Suspense" API Our first proof of concept used a wrapper-based API without a build step: @@ -140,7 +140,7 @@ export const myWorkflow = workflow(async () => { }); ``` -This was verbose and easy to forget. Moreover, if a developer forgot to wrap something innocent like using `Date.now()`, it led to unstable runtime behavior. +This was verbose and developers could forget it. If a developer forgot to wrap something like `Date.now()`, it led to unstable runtime behavior. For example: @@ -200,7 +200,7 @@ export const myWorkflow = workflow(async () => { }); ``` -### Generator-Based API +### Generator-based API We explored using generators for explicit suspension points, inspired by libraries like Effect.ts: @@ -220,7 +220,7 @@ We're big fans of [Effect.ts](https://effect.website/) and the power of generato **1. Syntax felt more like a DSL than JavaScript** -Generators require a custom mental model that differs significantly from familiar async/await patterns. The `yield*` syntax and generator delegation were unfamiliar to many developers: +Generators require a custom mental model that differs from familiar async/await patterns. The `yield*` syntax and generator delegation were unfamiliar to many developers: {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -267,7 +267,7 @@ export const myWorkflow = workflow(function*() { The generator syntax addressed suspension but didn't solve the fundamental sandboxing problem. -### File System-Based Conventions +### File system-based conventions We explored using file system conventions to identify workflows and steps, similar to how modern frameworks handle routing (Next.js, Hono, Nitro, SvelteKit): @@ -282,7 +282,7 @@ We explored using file system conventions to identify workflows and steps, simil -With this approach, any function in the `workflows/` directory would be transformed as a workflow, and any function in `steps/` would be a step. No directives needed, just file locations. +With this approach, any function in the `workflows/` directory would be transformed as a workflow, and any function in `steps/` would be a step. File locations would replace directives. **Why this could work:** @@ -308,7 +308,7 @@ The directive approach solved all these issues: it works in any project structur ### Decorators -We considered decorators, but they presented significant challenges both technical and ergonomic. +We considered decorators, but they presented technical and ergonomic challenges. **Decorators are non-yet-standard and class-focused** @@ -349,7 +349,7 @@ While decorators can be handled at compile-time with build tool support, they pr See the [Macro Wrapper](#macro-wrapper-approach) section below for a deeper dive into why this approach breaks down with concrete examples. -### Macro Wrapper Approach +### Macro wrapper approach We also explored compile-time macro approaches - using a compiler to transform wrapper functions or decorators into directive-based code: @@ -385,7 +385,7 @@ export const processOrder = async (orderId: string) => { }; ``` -The benefit is that macros could enforce types and provide "Go To Definition" or other LSP features out of the box. +The benefit is that macros could enforce types and provide "Go To Definition" or other LSP features without additional configuration. However, **the core problem remains: Workflows aren't runtime values** @@ -431,7 +431,7 @@ To detect that `processOrder` is actually a workflow, the compiler would need wh This level of cross-function analysis is impractical for build tools - it would require analyzing every function call chain in your entire codebase and all dependencies. The compiler can only reliably detect direct `useWorkflow` calls, not calls hidden behind abstractions. -## How Directives Solve These Problems +## How directives solve these problems Directives address all the issues we encountered with previous approaches: @@ -527,7 +527,7 @@ export async function processOrder(orderId: string) { The `"use step"` directive maintains consistency. While steps run in the full Node.js runtime and *could* work without a directive, they need some way to signal to the workflow runtime that they're steps. -We could have used a function wrapper just for steps: +We could have used a function wrapper for steps: {/* @skip-typecheck: incomplete code sample */} ```typescript lineNumbers @@ -584,7 +584,7 @@ By requiring explicit `"use step"` directives, developers have fine-grained cont To understand how directives are transformed at compile time, see [How the Code Transform Works](/docs/how-it-works/code-transform). -## What Directives Enable +## What directives enable Because `"use workflow"` defines a compile-time semantic boundary, we can provide: @@ -603,7 +603,7 @@ Because `"use workflow"` defines a compile-time semantic boundary, we can provid -## Directives as a JavaScript Pattern +## Directives as a JavaScript pattern Directives in JavaScript have always been contracts between the developer and the execution environment. `"use strict"` made this pattern familiar - it's a string literal that changes how code is interpreted. @@ -611,7 +611,7 @@ While JavaScript doesn't yet have first-class support for custom directives (lik As TC39 members, we at Vercel are actively working with the standards body and broader ecosystem to explore formal specifications for pragma-like syntax or macro annotations that can express execution semantics. -## Closing Thoughts +## Closing thoughts Directives aren't about syntax preference, they're about expressing semantic boundaries. `"use workflow"` tells the compiler, developer, and runtime that this code is deterministic, resumable, and sandboxed. diff --git a/docs/content/docs/v5/internal/index.mdx b/docs/content/docs/v5/internal/index.mdx index a05c3ae4f4..18f77cb23c 100644 --- a/docs/content/docs/v5/internal/index.mdx +++ b/docs/content/docs/v5/internal/index.mdx @@ -8,14 +8,14 @@ type: overview This page is only visible on preview deployments and local development. It does not appear in production. -## Preview Package +## Preview package -## Draft Changelogs +## Draft changelogs -Changelog entries staged here for review before publishing to the Vercel website. +Review these changelog entries before publishing them to the Vercel website. -- [Local web UI in Nitro dev](/docs/internal/nitro-web-ui) — unreleased (ships in 5.0.0) -- [Native Nitro v3 bundling for workflows](/docs/internal/nitro-native-build) — May 22, 2026 -- [Serializable AbortController and AbortSignal](/docs/internal/serializable-abort-controller) — March 12, 2026 +- [Local web UI in Nitro dev](/docs/internal/nitro-web-ui): Unreleased (ships in 5.0.0) +- [Native Nitro v3 bundling for workflows](/docs/internal/nitro-native-build): May 22, 2026 +- [Serializable AbortController and AbortSignal](/docs/internal/serializable-abort-controller): March 12, 2026 diff --git a/docs/content/docs/v5/internal/nitro-native-build.mdx b/docs/content/docs/v5/internal/nitro-native-build.mdx index 13f5722a53..30403f064f 100644 --- a/docs/content/docs/v5/internal/nitro-native-build.mdx +++ b/docs/content/docs/v5/internal/nitro-native-build.mdx @@ -34,5 +34,5 @@ export async function cacheResult(key: string, value: string) { ## Learn more -- [Nitro](/docs/getting-started/nitro) — Set up Workflow SDK in a Nitro v3 app -- [Deploying](/docs/deploying) — How workflow bundles are deployed +- [Nitro](/docs/getting-started/nitro): Set up Workflow SDK in a Nitro v3 app +- [Deploying](/docs/deploying): How workflow bundles are deployed diff --git a/docs/content/docs/v5/internal/nitro-web-ui.mdx b/docs/content/docs/v5/internal/nitro-web-ui.mdx index 5ef0fbd89f..36e3127c9b 100644 --- a/docs/content/docs/v5/internal/nitro-web-ui.mdx +++ b/docs/content/docs/v5/internal/nitro-web-ui.mdx @@ -6,19 +6,19 @@ type: overview # Local web UI in Nitro dev -{/* TODO: unreleased — changeset .changeset/nitro-dashboard-route.md is pending; ships in @workflow/nitro 5.0.0. Update this date on publish. */} +{/* TODO: unreleased. Changeset .changeset/nitro-dashboard-route.md is pending; ships in @workflow/nitro 5.0.0. Update this date on publish. */} June 2, 2026 The Workflow SDK web UI is now built into the Nitro dev server. During development, open `/_workflow` in your browser to inspect, monitor, and debug your workflow runs. ## What's new -- **Built-in `/_workflow` route in development.** The route starts the local web UI and redirects to it — no separate command or process required. +- **Built-in `/_workflow` route in development.** The route starts the local web UI and redirects to it without requiring a separate command or process. - **Inspect runs in place.** Inspect, monitor, and debug your workflow runs directly from the dev server you're already running. ![Workflow SDK web UI on the /_workflow route](/local-web-ui.png) ## Learn more -- [Observability](/docs/observability) — Inspect runs with the web UI and CLI -- [Nitro](/docs/getting-started/nitro) — Set up Workflow SDK in a Nitro v3 app +- [Observability](/docs/observability): Inspect runs with the web UI and CLI +- [Nitro](/docs/getting-started/nitro): Set up Workflow SDK in a Nitro v3 app diff --git a/docs/content/docs/v5/internal/serializable-abort-controller.mdx b/docs/content/docs/v5/internal/serializable-abort-controller.mdx index b8e8765138..11fec8abee 100644 --- a/docs/content/docs/v5/internal/serializable-abort-controller.mdx +++ b/docs/content/docs/v5/internal/serializable-abort-controller.mdx @@ -8,7 +8,7 @@ type: overview March 12, 2026 -`AbortController` and `AbortSignal` now work natively in workflow functions. Create a controller, pass its signal to steps, and call `abort()` — no special imports or wrapper functions needed. +`AbortController` and `AbortSignal` now work natively in workflow functions. Create a controller, pass its signal to steps, and call `abort()`; no special imports or wrapper functions needed. ## What's new @@ -17,8 +17,8 @@ type: overview - **Cooperative cancellation.** Steps receive the abort in real time and can respond by checking `signal.aborted`, calling `signal.throwIfAborted()`, or passing the signal to APIs like `fetch`. - **Abort errors skip retries.** When a step throws due to an abort (e.g., `fetch` throws `AbortError`), the error is automatically wrapped in `FatalError` so it skips retries and bubbles up immediately. - **`AbortSignal.timeout()` blocked in workflow VM.** Because it relies on real-time timers that break deterministic replay, `AbortSignal.timeout()` throws a helpful error pointing to the `sleep()` + `AbortController` pattern instead. -- **`Request.signal` preserved when it carries abort state.** A `Request`'s `.signal` is serialized when it's already aborted (so the cancellation that happened pre-serialization is preserved) or when it's a workflow-managed signal (so its hook + stream backing carries through). Plain non-aborted native signals — including the auto-generated signal on `new Request(url)` — are dropped to avoid minting stream infrastructure for every `Request`. To get cross-boundary cancellation through a `Request`, build it with the signal from a workflow-context `AbortController`. -- **Pending queue items drain on completion.** If you call `abort()` (or `dispose` a hook, or kick off a `void sleep('1d')`, or fire a `void someStep()`) without a suspension point between that call and the workflow's return, the runtime now treats end-of-run as a final suspension and commits all pending operations before the run is marked terminal. This matches normal JS semantics — `setTimeout` etc. continue running after the surrounding function returns. The most important case: `controller.abort()` called as the last statement of a workflow now actually propagates to in-flight steps on other compute instances. +- **`Request.signal` preserved when it carries abort state.** A `Request`'s `.signal` is serialized when it's already aborted (so the cancellation that happened pre-serialization is preserved) or when it's a workflow-managed signal (so its hook + stream backing carries through). Plain non-aborted native signals (including the auto-generated signal on `new Request(url)`) are dropped to avoid minting stream infrastructure for every `Request`. To get cross-boundary cancellation through a `Request`, build it with the signal from a workflow-context `AbortController`. +- **Pending queue items drain on completion.** If you call `abort()` (or `dispose` a hook, or kick off a `void sleep('1d')`, or fire a `void someStep()`) without a suspension point between that call and the workflow's return, the runtime now treats end-of-run as a final suspension and commits all pending operations before the run is marked terminal. This matches normal JS semantics: `setTimeout` etc. continue running after the surrounding function returns. The most important case: `controller.abort()` called as the last statement of a workflow now actually propagates to in-flight steps on other compute instances. ## Timeout with cancellation @@ -106,7 +106,7 @@ export async function userCancellableWorkflow(jobId: string) { ## Step-initiated abort -A step can receive the full `AbortController` and call `abort()` to cancel parallel work — useful for watchdog patterns like quota monitoring: +A step can receive the full `AbortController` and call `abort()` to cancel parallel work, useful for watchdog patterns like quota monitoring: ```typescript declare function processData(url: string, signal: AbortSignal): Promise<{ processed: boolean }>; // @setup @@ -143,6 +143,6 @@ async function monitorQuota(userId: string, controller: AbortController) { ## Learn more -- [Cancellation](/docs/foundations/cancellation) — Full guide with all usage patterns -- [How Cancellation Works](/docs/how-it-works/cancellation) — Hook and stream internals -- [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow) — Why `AbortSignal.timeout()` is blocked and what to use instead +- [Cancellation](/docs/foundations/cancellation): Full guide with all usage patterns +- [How Cancellation Works](/docs/how-it-works/cancellation): Hook and stream internals +- [AbortSignal.timeout() in Workflow](/docs/errors/abort-signal-timeout-in-workflow): Why `AbortSignal.timeout()` is blocked and what to use instead diff --git a/docs/content/docs/v5/observability/attributes.mdx b/docs/content/docs/v5/observability/attributes.mdx index a74d7419eb..5896785b66 100644 --- a/docs/content/docs/v5/observability/attributes.mdx +++ b/docs/content/docs/v5/observability/attributes.mdx @@ -11,7 +11,7 @@ related: - /docs/api-reference/workflow-errors/workflow-world-error --- -[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes are displayed in observability CLI/UI, and can be used to search and filter runs through the [Analytics API](/docs/api-reference/workflow-runtime/world/analytics). +[`setAttributes`](/docs/api-reference/workflow/set-attributes) attaches plaintext string metadata to the current workflow run. These attributes appear in the Workflow CLI and web UI, and you can use them to search and filter runs through the [Analytics API](/docs/api-reference/workflow-runtime/world/analytics). You can also seed any attributes directly when starting a run: @@ -76,13 +76,13 @@ Each `setAttributes` call appears on the trace timeline as a diamond marker at t ![Trace timeline with attr_set diamond markers on the run row](/screenshots/attributes/trace-timeline.png) -Expanding an `attr_set` event — in the run sidebar or the Events tab — shows the changed keys, removed keys, and whether the write came from the workflow body or a step (with the attempt number): +Expanding an `attr_set` event (in the run sidebar or the Events tab) shows the changed keys, removed keys, and whether the write came from the workflow body or a step (with the attempt number): ![Expanded attr_set events showing changes and the writer](/screenshots/attributes/run-details-attr-set-events.png) ## Searching and filtering by attributes -The [Analytics API](/docs/api-reference/workflow-runtime/world/analytics) can discover which attribute keys exist and filter run listings by them. The `analytics` namespace is optional on `World` — feature-detect it before use; it is absent on local, Postgres, and other custom Worlds: +The [Analytics API](/docs/api-reference/workflow-runtime/world/analytics) can discover which attribute keys exist and filter run listings by them. The `analytics` namespace is optional on `World`, so feature-detect it before use; it is absent on local, Postgres, and other custom Worlds: ```typescript lineNumbers import { getWorld } from "workflow/runtime"; diff --git a/docs/content/docs/v5/observability/index.mdx b/docs/content/docs/v5/observability/index.mdx index 207581b848..87ef36bfac 100644 --- a/docs/content/docs/v5/observability/index.mdx +++ b/docs/content/docs/v5/observability/index.mdx @@ -1,8 +1,8 @@ --- title: Observability -description: Inspect, monitor, and debug workflows through the CLI and Web UI with powerful observability tools. +description: Inspect, monitor, and debug workflows through the CLI and web UI. type: overview -summary: Inspect and debug workflow runs using the CLI and Web UI. +summary: Inspect and debug workflow runs using the CLI and web UI. prerequisites: - /docs/foundations related: @@ -10,9 +10,9 @@ related: - /docs/how-it-works/encryption --- -Workflow SDK provides powerful tools to inspect, monitor, and debug your workflows through the CLI and Web UI. These tools allow you to inspect workflow runs, steps, webhooks, [events](/docs/how-it-works/event-sourcing), and stream output. +Workflow SDK provides a Workflow CLI and web UI to inspect, monitor, and debug workflows. You can inspect workflow runs, steps, webhooks, [events](/docs/how-it-works/event-sourcing), and stream output. -## Quick Start +## Quick start ```bash npx workflow @@ -33,24 +33,24 @@ npx workflow inspect runs ## Web UI Workflow SDK ships with a local web UI for inspecting your workflows. The CLI -will locally serve the Web UI when using the `--web` flag. +serves the web UI locally when you use the `--web` flag. ```bash -# Launch Web UI for visual exploration +# Launch the web UI for visual exploration npx workflow inspect runs --web ``` ![Workflow SDK Web UI](/o11y-ui.png) On [Nitro](/docs/getting-started/nitro), the dev server has the web UI built -in: open `/_workflow` while `nitro dev` is running — no separate command +in: open `/_workflow` while `nitro dev` is running. No separate command is required. needed. In the runs table, select one or more runs and choose **Cancel** to cancel the batch in a single request. Runs that fail with a retryable error stay selected so you can retry them. To share a link to a specific run without opening a browser, use the `--url` flag. It prints the dashboard deep link to stdout and exits (no browser, no -local server) — useful for scripts, PR comments, or automation. Add `--json` to +local server), which is useful for scripts, PR comments, or automation. Add `--json` to get `{ "url": "..." }`. ```bash @@ -72,9 +72,9 @@ If you're deploying workflows to a production environment, but want to inspect t Backends might require additional configuration. If you're missing environment variables, the World package should provide instructions on how to configure it. -### Vercel Backend +### Vercel backend -To inspect workflows running on Vercel, ensure you're logged in to the Vercel CLI and have linked your project. See [Vercel CLI authentication and project linking docs](https://vercel.com/docs/cli/project-linking) for more information. Then, simply specify the backend as `vercel`. +To inspect workflows running on Vercel, ensure you're logged in to the Vercel CLI and have linked your project. See [Vercel CLI authentication and project linking docs](https://vercel.com/docs/cli/project-linking) for more information. Then, specify the backend as `vercel`. ```bash # Inspect workflows running on Vercel @@ -83,6 +83,6 @@ npx workflow inspect runs --backend vercel When deployed to Vercel, workflow data is [encrypted end-to-end](/docs/how-it-works/encryption). Encrypted fields display as locked placeholders until you choose to decrypt them using the **Decrypt** button in the web UI or the `--decrypt` flag in the CLI. -## More Observability Features +## More observability features diff --git a/docs/content/docs/v5/observability/tracing.mdx b/docs/content/docs/v5/observability/tracing.mdx index 4b424e2b15..99deb76682 100644 --- a/docs/content/docs/v5/observability/tracing.mdx +++ b/docs/content/docs/v5/observability/tracing.mdx @@ -11,7 +11,7 @@ related: - /docs/how-it-works/event-sourcing --- -The Workflow SDK is instrumented with [OpenTelemetry](https://opentelemetry.io) out of the box. It emits spans for workflow starts, every workflow and step invocation, and the HTTP calls it makes to the workflow backend — and it propagates trace context across queue deliveries so a run remains traceable end to end. +The Workflow SDK includes [OpenTelemetry](https://opentelemetry.io) instrumentation. It emits spans for workflow starts, every workflow and step invocation, and the HTTP calls it makes to the workflow backend, and it propagates trace context across queue deliveries so a run remains traceable end to end. The SDK only depends on the OpenTelemetry **API**, never on an SDK or exporter. If your application does not register an OpenTelemetry SDK, all tracing code is a silent no-op with no overhead and no behavior change. @@ -30,7 +30,7 @@ export function register() { No workflow-specific configuration is required. As soon as a tracer provider and propagator are registered, the SDK's spans, context propagation, and span links activate automatically. -`@opentelemetry/api` is an **optional peer dependency**. An OpenTelemetry SDK such as `@vercel/otel` normally pulls it in transitively, but installing it directly (`npm i @opentelemetry/api`) guarantees it is present in your build — particularly for bundled or serverless targets where the SDK's tracing is inlined at build time. If it can't be resolved, tracing is a silent no-op. +`@opentelemetry/api` is an **optional peer dependency**. An OpenTelemetry SDK such as `@vercel/otel` normally pulls it in transitively, but installing it directly (`npm i @opentelemetry/api`) guarantees it is present in your build, particularly for bundled or serverless targets where the SDK's tracing is inlined at build time. If it can't be resolved, tracing is a silent no-op. ## Spans @@ -38,7 +38,7 @@ No workflow-specific configuration is required. As soon as a tracer provider and | Span name | Kind | Emitted when | | --- | --- | --- | | `workflow.start ` | internal | `start()` is called in your application code | -| `workflow.execute ` | consumer (root) | a queue delivery invokes the workflow — replay, orchestration, and inline steps run under it | +| `workflow.execute ` | consumer (root) | a queue delivery invokes the workflow; replay, orchestration, and inline steps run under it | | `step.execute ` | internal (inline) / consumer + root (queue-delivered) | a step function executes | | `http ` | client | the SDK calls the workflow backend (event reads/writes) | | `workflow.stream.write` | client | a stream chunk (or the stream close) is flushed to the backend | @@ -56,11 +56,11 @@ Stream spans are emitted by the SDK's world backend on the client that writes or | Attribute | Description | | --- | --- | -| `workflow.run.id` | The run ID (`wrun_...`). Present on every workflow and step span — the primary key for finding all spans of a run. | +| `workflow.run.id` | The run ID (`wrun_...`). Present on every workflow and step span; the primary key for finding all spans of a run. | | `workflow.name` | The workflow function name. | | `workflow.trace.mode` | The active trace mode (`linked` or `continuous`). | | `workflow.trace.propagated` | Whether the invocation received trace context from the queue message. | -| `workflow.queue.overhead_ms` | Time between the message being enqueued and the handler starting — queue dwell plus any cold start. | +| `workflow.queue.overhead_ms` | Time between the message being enqueued and the handler starting: queue dwell plus any cold start. | | `workflow.stream.name` | The stream name, on stream write/read spans. | | `workflow.stream.operation` | The stream operation: `write`, `write_multi`, `close`, `read`, or `flush`. | | `workflow.stream.write.chunk_rtt` | Time between emissions of a chunk to the wire, and receiving the `ack` message for that chunk. Also stamped on `workflow.stream.flush` (the batch's write RPC duration, network included). | @@ -74,8 +74,8 @@ A single workflow run can span hours or days across many separate function invoc Instead, the SDK creates **one bounded trace per invocation**. Each `workflow.execute` (or background `step.execute`) span starts a new trace root and attaches two **span links**: -- a link to the **enqueue site** — the span that queued the message which triggered this invocation, and -- a link to the **run origin** — the trace in which `start()` was originally called. +- a link to the **enqueue site**, the span that queued the message which triggered this invocation, and +- a link to the **run origin**, the trace in which `start()` was originally called. A span link is OpenTelemetry's relationship for "causally related, but in a different trace." It is the standard pattern for asynchronous messaging, where producing and consuming a message can be separated by arbitrary time. @@ -96,7 +96,7 @@ flowchart LR Each invocation links back to the trace that enqueued it and to the run origin. -To see a whole run, query by attribute rather than by trace ID — for example `workflow.run.id = wrun_...` in your tracing backend — or follow the span links between invocation traces. +To see a whole run, query by attribute rather than by trace ID (for example `workflow.run.id = wrun_...` in your tracing backend), or follow the span links between invocation traces. ## Trace modes @@ -108,7 +108,7 @@ The `WORKFLOW_TRACE_MODE` environment variable controls the shape: | `continuous` | The run-origin context becomes the **parent** of every invocation, so the entire run shares one trace ID. | -This is a behavior change from v4, which always used `continuous`-style tracing. If you have dashboards or queries that assume one trace ID per run, either update them to use `workflow.run.id` and span links, or set `WORKFLOW_TRACE_MODE=continuous` to restore the previous shape. Note that in `linked` mode each invocation root makes its own sampling decision, and the number of root spans increases to one per invocation. +This is a behavior change from v4, which always used `continuous`-style tracing. If you have dashboards or queries that assume one trace ID per run, either update them to use `workflow.run.id` and span links, or set `WORKFLOW_TRACE_MODE=continuous` to restore the previous shape. In `linked` mode, each invocation root makes its own sampling decision, and the number of root spans increases to one per invocation. ## Context propagation diff --git a/docs/content/docs/v5/testing/index.mdx b/docs/content/docs/v5/testing/index.mdx index 36a69fda11..66829e68be 100644 --- a/docs/content/docs/v5/testing/index.mdx +++ b/docs/content/docs/v5/testing/index.mdx @@ -3,18 +3,18 @@ title: Testing description: Unit test individual steps and integration test entire workflows using Vitest. --- -Testing is a critical part of building reliable workflows. Because steps are just functions annotated with directives, they can be unit tested like any other JavaScript function. Workflow SDK also provides a Vitest plugin that runs full workflows in-process — no running server required. +Test steps like any other JavaScript function, or use the Workflow SDK Vitest plugin to run complete workflows in-process without a server. This guide covers two approaches: -1. **Unit testing** - Test individual steps as plain functions, without the workflow runtime. -2. **Integration testing** - Test entire workflows in-process using the `workflow()` Vitest plugin. Required when you want to test workflow specific code paths, like those using [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), retries, etc. +1. **Unit testing**: Test individual steps as plain functions without the workflow runtime. +2. **Integration testing**: Test entire workflows in-process using the `workflow()` Vitest plugin. Use integration tests for workflow-specific code paths that use [hooks](/docs/foundations/hooks), webhooks, [`sleep()`](/docs/api-reference/workflow/sleep), or retries. -## Unit Testing Steps +## Unit testing steps -Without the workflow compiler, the `"use step"` directive is a no-op. Your step functions run as regular JavaScript functions, making them straightforward to unit test with no special configuration. +Without the workflow compiler, the `"use step"` directive is a no-op. Your step functions run as regular JavaScript functions, so you can unit test them without special configuration. -### Example Steps +### Example steps Given a workflow file with step functions like this: @@ -49,7 +49,7 @@ export async function sendOnboardingEmail(user: { id: string; email: string }) { } ``` -### Writing Unit Tests for Steps +### Writing unit tests for steps You can import and test step functions directly with Vitest. No special configuration or workflow plugin is needed: @@ -77,18 +77,18 @@ describe("sendWelcomeEmail step", () => { This approach is ideal for verifying the business logic inside individual steps in isolation. -Unit testing works well for individual steps. A simple workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features. +Unit testing works well for individual steps. A workflow that only calls steps can also be unit tested this way, since `"use workflow"` is similarly a no-op without the compiler. However, any workflow that uses runtime features like [`sleep()`](/docs/api-reference/workflow/sleep), [hooks](/docs/foundations/hooks), or [webhooks](/docs/foundations/hooks#understanding-webhooks) cannot be unit tested directly because those APIs require the workflow runtime. Use [integration testing](#integration-testing-with-the-vitest-plugin) for testing entire workflows, especially those that depend on workflow-only features. -## Integration Testing with the Vitest Plugin +## Integration testing with the Vitest plugin -For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically — it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required. +For workflows that rely on runtime features like [hooks](/docs/foundations/hooks), [webhooks](/docs/foundations/hooks#understanding-webhooks), [`sleep()`](/docs/api-reference/workflow/sleep), or error retries, you need to test against a real workflow setup. The `@workflow/vitest` plugin handles everything automatically: it compiles your workflow directives, builds the runtime bundles, and executes workflows entirely in-process. No server required. `vi.mock()` and related calls do _not_ work inside workflow functions, only step functions. Your workflow functions cannot import third party code that needs to be mocked. Mocking works for npm packages imported in step functions. If something needs to be mocked, it likely belongs inside a step function either way. -### Vitest Configuration +### Vitest configuration Create a separate Vitest config for integration tests that includes the `workflow()` plugin: @@ -109,13 +109,13 @@ That's it. The plugin automatically: 1. Transforms `"use workflow"` and `"use step"` directives via SWC 2. Builds workflow and step bundles before tests run -3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker — all workflow data is cleared automatically between test files for full isolation +3. Sets up an in-process workflow runtime using a fresh [Local World](/worlds/local) instance in each test worker; all workflow data is cleared automatically between test files for full isolation Use a separate Vitest configuration and a distinct file naming convention (e.g. `*.integration.test.ts`) to keep unit tests and integration tests separate. Unit tests run with a standard Vitest config without the workflow plugin, while integration tests use the config above. -### Writing Integration Tests +### Writing integration tests Use [`start()`](/docs/api-reference/workflow-api/start) to trigger a workflow and [`run.returnValue`](/docs/api-reference/workflow-api/start#returns) to get the result. `returnValue` is a promise that blocks until the workflow completes (or throws if it fails): @@ -144,9 +144,9 @@ describe("calculateWorkflow", () => { }); ``` -### Testing Hooks and Waits +### Testing hooks and waits -The real power of integration testing comes when testing workflow-only features. Hooks and waits can be resumed programmatically using the [`workflow/api`](/docs/api-reference/workflow-api) functions, making it straightforward to simulate external events in your tests. +Integration testing is most useful for workflow-only features. You can resume hooks and waits programmatically using the [`workflow/api`](/docs/api-reference/workflow-api) functions to simulate external events in your tests. Given a workflow that waits for approval via a hook, then sleeps before publishing: @@ -228,7 +228,7 @@ describe("approvalWorkflow", () => { reviewer: "bob", }); - // No wakeUp() needed here — the rejected path has no sleep + // No wakeUp() needed here; the rejected path has no sleep const result = await run.returnValue; expect(result).toEqual({ status: "rejected", @@ -243,12 +243,12 @@ describe("approvalWorkflow", () => { -`waitForSleep()` returns the first **pending** sleep — one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it. +`waitForSleep()` returns the first **pending** sleep, one that has a `wait_created` event but no corresponding `wait_completed` event. If your workflow has multiple parallel sleeps, `waitForSleep()` returns whichever is found first. After waking one, call `waitForSleep()` again to get the next pending one. For sequential sleeps, `waitForSleep()` naturally returns each one as the workflow reaches it. -### Testing Webhooks +### Testing webhooks -Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload — no HTTP server needed: +Webhooks are hooks that receive HTTP `Request` objects. In tests, resume them using [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook) with a `Request` payload, with no HTTP server needed: ```typescript title="workflows/ingest.ts" lineNumbers import { createWebhook } from "workflow"; @@ -303,7 +303,7 @@ describe("ingestWorkflow", () => { }); ``` -### Manual Setup +### Manual setup If you need more control over the test lifecycle, the plugin also exports the individual setup functions: @@ -354,11 +354,11 @@ afterAll(async () => { For advanced setups that require a running server (e.g. testing against your actual framework's HTTP layer), see [Server-based integration testing](/docs/testing/server-based). -## Debugging Test Runs +## Debugging test runs -When integration tests fail, the [Workflow SDK CLI and Web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you would use in development. +When integration tests fail, the [Workflow SDK CLI and web UI](/docs/observability) can help you inspect what happened. Because integration tests persist workflow state locally, you can use the same observability tools you use in development. -Launch the Web UI to visually explore your test workflow runs: +Launch the web UI to explore your test workflow runs: ```bash npx workflow web @@ -374,7 +374,7 @@ npx workflow inspect runs npx workflow inspect run ``` -The Web UI shows each step, its inputs and outputs, retry attempts, hook state, and timing. This is especially useful for diagnosing issues with hooks that were not resumed, steps that failed unexpectedly, or workflows that timed out. +The web UI shows each step, its inputs and outputs, retry attempts, hook state, and timing. Use it to diagnose hooks that were not resumed, steps that failed unexpectedly, or workflows that timed out. ![Workflow SDK Web UI](/o11y-ui.png) @@ -382,28 +382,28 @@ The Web UI shows each step, its inputs and outputs, retry attempts, hook state, See the [Observability](/docs/observability) docs for the full set of CLI commands and Web UI features. -## Best Practices +## Best practices -### Separate Unit and Integration Tests +### Separate unit and integration tests Keep two test configurations: -- **Unit tests** - Standard Vitest config, no workflow plugin. Fast, no infrastructure required. -- **Integration tests** - Vitest config with `workflow()` plugin. Tests the full workflow lifecycle including hooks, sleeps, and retries. +- **Unit tests**: Standard Vitest config with no workflow plugin. These tests require no infrastructure. +- **Integration tests**: Vitest config with the `workflow()` plugin. These tests cover the full workflow lifecycle, including hooks, sleeps, and retries. -### Use Custom Hook Tokens for Deterministic Testing +### Use custom hook tokens for deterministic testing -When testing workflows with hooks, use [custom tokens](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) based on predictable values (like document IDs or test identifiers). This makes it easy to resume the correct hook in your test code. +When testing workflows with hooks, use [custom tokens](/docs/foundations/hooks#custom-tokens-for-deterministic-hooks) based on predictable values (like document IDs or test identifiers). This lets you resume the correct hook in your test code. -### Set Appropriate Timeouts +### Set appropriate timeouts Workflows may take longer to execute than typical unit tests, especially when they involve multiple steps or retries. Set a generous `testTimeout` in your integration test config. -### Test Error and Retry Scenarios +### Test error and retry scenarios Integration tests are the right place to verify that your workflows handle errors correctly, including retryable errors, fatal errors, and timeout scenarios. -## Further Reading +## Further reading - [Hooks & Webhooks](/docs/foundations/hooks) - Pausing and resuming workflows with external data - [`start()` API Reference](/docs/api-reference/workflow-api/start) - Start workflows programmatically diff --git a/docs/content/docs/v5/testing/server-based.mdx b/docs/content/docs/v5/testing/server-based.mdx index 2fa3369462..a0c4beccd5 100644 --- a/docs/content/docs/v5/testing/server-based.mdx +++ b/docs/content/docs/v5/testing/server-based.mdx @@ -9,9 +9,9 @@ The [Vitest plugin](/docs/testing#integration-testing-with-the-vitest-plugin) ru - Reproducing behavior that only occurs in a specific framework's runtime (e.g. Next.js, Nitro) - Testing webhook endpoints that receive real HTTP requests -This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point — customize the server setup to match your own deployment environment. +This guide shows how to set up integration tests that spawn a dev server as a sidecar process. The example below uses [Nitro](https://v3.nitro.build), but the same pattern works with any supported server framework. It is meant as a starting point; customize the server setup to match your own deployment environment. -## Vitest Configuration +## Vitest configuration Create a Vitest config with the `workflow()` Vite plugin for code transforms and a `globalSetup` script that manages the server lifecycle: @@ -36,7 +36,7 @@ export default defineConfig({ Note the import path: `workflow/vite` (not `@workflow/vitest`). The Vite plugin handles code transforms but does not set up in-process execution. The server handles workflow execution instead. -## Global Setup Script +## Global setup script The `globalSetup` script starts a dev server before tests run and tears it down afterwards. This example uses [Nitro](https://v3.nitro.build), but you can use any server framework that supports the workflow runtime. @@ -154,9 +154,9 @@ The setup script sets `WORKFLOW_LOCAL_BASE_URL` so the workflow runtime sends fl You can use any server framework that supports the workflow runtime. The example above uses [Nitro](https://v3.nitro.build), but you could also use [Next.js](https://nextjs.org), [Hono](https://hono.dev), or any other supported server. -## Writing Tests +## Writing tests -Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs — [`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run) — to control workflow execution: +Tests are written the same way as [in-process integration tests](/docs/testing#writing-integration-tests). You can use the same programmatic APIs ([`start()`](/docs/api-reference/workflow-api/start), [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook), [`resumeWebhook()`](/docs/api-reference/workflow-api/resume-webhook), and [`getRun().wakeUp()`](/docs/api-reference/workflow-api/get-run)) to control workflow execution: ```typescript title="workflows/calculate.server.test.ts" lineNumbers import { describe, it, expect } from "vitest"; @@ -199,10 +199,10 @@ describe("approvalWorkflow", () => { ``` -In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly — you may need to add short delays or polling to ensure the workflow has reached the desired state before resuming. +In server-based tests, the `waitForSleep()` and `waitForHook()` helpers from `@workflow/vitest` are not available since there is no in-process world. Instead, use the programmatic APIs directly. You may need to add short delays or polling to ensure the workflow has reached the desired state before resuming. -## Running Tests +## Running tests Add a script to your `package.json`: @@ -215,12 +215,12 @@ Add a script to your `package.json`: } ``` -## When to Use This Approach +## When to use this approach | Scenario | Recommended approach | | --- | --- | | Testing workflow logic, steps, hooks, retries | [In-process plugin](/docs/testing) | | Testing HTTP middleware or authentication | Server-based | | Testing webhook endpoints with real HTTP | Server-based | -| CI/CD pipeline testing | [In-process plugin](/docs/testing) | +| Continuous integration and continuous delivery (CI/CD) pipeline testing | [In-process plugin](/docs/testing) | | Reproducing framework-specific behavior | Server-based | diff --git a/docs/content/docs/v5/whats-new.mdx b/docs/content/docs/v5/whats-new.mdx index 617dfb203b..adbe3c1e45 100644 --- a/docs/content/docs/v5/whats-new.mdx +++ b/docs/content/docs/v5/whats-new.mdx @@ -9,7 +9,7 @@ related: - /docs/foundations/cancellation --- -We recommend upgrading to v5 to get all of the performance, cost, and feature improvements listed below. It's as simple as installing the migration skill and telling your agent to migrate your app from Workflow SDK v4 to v5. +We recommend upgrading to v5 to get all of the performance, cost, and feature improvements listed below. Install the migration skill, then tell your agent to migrate your app from Workflow SDK v4 to v5. ```bash npm install workflow@latest @@ -29,9 +29,9 @@ npx skills add https://github.com/vercel/workflow --skill migrating-workflow-v4- The largest change in v5 has no API surface: the runtime does far less work per unit of progress. The time between calling `start()` and your first step body executing is now less than half of what it was in v4. This is made possible by many smaller optimizations: -**A workflow invocation now does as much as it can in a single pass.** In 4.x, progress was largely deferred to the queue: an invocation would execute a step, hand back to the queue, and let a fresh invocation pick up the next one. v5 creates and executes steps inline — several per suspension, in parallel — and only uses the queue when it really has to: a wait, a hook, or the function is approaching its timeout. +**A workflow invocation now does as much as it can in a single pass.** In 4.x, progress was largely deferred to the queue: an invocation would execute a step, hand back to the queue, and let a fresh invocation pick up the next one. v5 creates and executes several steps inline per suspension, in parallel, and only uses the queue for a wait, a hook, or when the function approaches its timeout. -**The runtime avoids waiting on the persistence layer where it can determine that is safe for your workload.** Many API calls are now simply skipped when not needed, like requesting the event-log on a run's first invocation. Step creation is folded into step execution rather than being its own round trip. The inline loop consumes the event-log delta from the previous step's write instead of re-listing events. Each optimization is gated on specific runtime conditions, and can be turned off individually — see [Runtime tuning](/docs/configuration/runtime-tuning). +**The runtime avoids waiting on the persistence layer where it can determine that is safe for your workload.** The runtime skips many API calls when they aren't needed, such as requesting the event log on a run's first invocation. Step creation is folded into step execution rather than being its own round trip. The inline loop consumes the event-log delta from the previous step's write instead of re-listing events. Each optimization is gated on specific runtime conditions and can be turned off individually. See [Runtime tuning](/docs/configuration/runtime-tuning). **The workflow VM is kept alive across inline steps.** Within one invocation, a step-only suspension keeps the live VM and hydrated state, so the next iteration appends only the newly written events instead of rebuilding the sandbox and replaying the whole log. Step inputs made of plain data or standard built-ins keep this fast path; see [`WORKFLOW_RETAINED_VM`](/docs/configuration/runtime-tuning#workflow_retained_vm). @@ -99,7 +99,7 @@ See [Attributes](/docs/observability/attributes). ### Richer serialization -Everything that crosses a workflow/step boundary is serialized, and v5 widens what survives the trip with its identity intact. Errors — including your own classes and built-ins like `TypeError` — keep their class and `cause` chain through `WorkflowRunFailedError.cause`: +Everything that crosses a workflow/step boundary is serialized, and v5 widens what survives the trip with its identity intact. Errors, including your own classes and built-ins like `TypeError`, keep their class and `cause` chain through `WorkflowRunFailedError.cause`: ```typescript lineNumbers import { WorkflowRunFailedError } from "workflow/errors"; @@ -124,11 +124,11 @@ See [Serialization](/docs/foundations/serialization). ### A redesigned trace viewer -The trace viewer has been rebuilt from the ground up, with an easily visible timeline, a minimap, pan, zoom, debug functionality, a new JSON viewer, keyboard navigation, and more. +The trace viewer has been rebuilt with a visible timeline, a minimap, pan, zoom, debug functionality, a new JSON viewer, keyboard navigation, and more. Vercel Observability uses this trace viewer for all runs, v4 included, but with v5, you get the same new design for self-hosted UI and local debugging. See [Observability](/docs/observability). -The local tooling around it grew as well: on [Nitro](/docs/getting-started/nitro) the dev server has the web UI built in at `/_workflow`, `workflow inspect runs` accepts `--since`/`--until` listing windows, run lookups by name search past the backend's default 24-hour window, and Worlds can surface their own run fields in `inspect` output — the Vercel World shows each run's region. On any other framework, `createWorkflowWebHandler()` from `@workflow/web/handler` serves the same UI as one `Request` to `Response` handler under a base path of your choosing. +The local tooling around it grew as well: on [Nitro](/docs/getting-started/nitro) the dev server has the web UI built in at `/_workflow`, `workflow inspect runs` accepts `--since`/`--until` listing windows, run lookups by name search past the backend's default 24-hour window, and Worlds can surface their own run fields in `inspect` output. For example, the Vercel World shows each run's region. On any other framework, `createWorkflowWebHandler()` from `@workflow/web/handler` serves the same UI as one `Request` to `Response` handler under a base path of your choosing. Each run also carries more of the infrastructure it ran on. A step attempt records the compute instance that executed it, surfaced as **Compute Instance ID** in the run sidebar and as a `faas.instance` span attribute on flow and step spans, so a run that behaves oddly can be correlated with one warm instance. The sidebar also shows a copyable **Request ID** for looking the invocation up in your platform's logs. @@ -164,7 +164,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and | Default trace mode is `linked` | Update dashboards that assume one trace per run, or set `WORKFLOW_TRACE_MODE=continuous`. | | The event-creation precondition guard is gone | `WORKFLOW_PRECONDITION_GUARD` no longer exists, and no World in the SDK rejects a write for a stale snapshot. Remove the variable if you set it. A replay that is behind now learns what it missed from the write it makes next instead of from a rejection, and [`PreconditionFailedError`](/docs/api-reference/workflow-errors/precondition-failed-error) remains only for a custom World that would still rather refuse. | | Event IDs are slot numbers, not ULIDs | An event ID is now its 1-based position in the run's log (`evnt_00000000000000000000000042`). It is unique only within a run, so pair it with the `runId` as a key, and it carries no timestamp: decoding one yields the Unix epoch rather than a creation time, so read `createdAt` off the event instead. Other entity IDs are unchanged. See [Event IDs](/docs/how-it-works/event-sourcing#event-ids). | -| A per-run event limit is enforced | The World supplies the ceiling — 25,000 events on the Local and Vercel Worlds — and a run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows). As a fallback, the ceiling can be tuned — see [Limits](/docs/configuration/runtime-tuning#limits). | +| A per-run event limit is enforced | The World supplies the ceiling, which is 25,000 events on the Local and Vercel Worlds. A run that reaches it fails with `MAX_EVENTS_EXCEEDED`. Split unbounded loops into [child workflows](/cookbook/advanced/child-workflows). As a fallback, you can tune the ceiling. See [Limits](/docs/configuration/runtime-tuning#limits). | | Stream writes flush the first chunk immediately | The leading-edge flush window defaults to `0` instead of 10ms. Restore a window with `streamFlushIntervalMs` or `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`. | | The workflow sandbox is stricter about nondeterminism | `WeakRef`, `FinalizationRegistry`, `Atomics.waitAsync`, and async `WebAssembly` compilation are no longer available inside workflow functions, and `crypto.subtle.digest` computes synchronously (same results, deterministic timing). Move code that needs them into a step. | | `Date()` without `new` returns a string inside workflow functions | This matches the language spec, and 4.x returned a `Date` object. Use `new Date()` where you need the object. Subclassing `Date` now works, so libraries like `TZDate` keep their identity across the sandbox boundary. | @@ -176,7 +176,7 @@ Runs created on 4.x keep executing on the deployment that created them, so upgra ## If you maintain a World -The World interface — the storage, queue, streaming, and analytics contract that a Workflow SDK deployment runs against — also changed in v5, and those changes are not visible from application code. If you implement `World` yourself, or maintain a build integration that compiles workflow files, upgrade it alongside the SDK: see [Upgrading a World to v5](/worlds/upgrading-to-v5) for the full interface delta and the contract changes that affect existing implementations. There is a separate skill for that job, since none of it applies to application code: +The World interface, which defines the storage, queue, streaming, and analytics contract that a Workflow SDK deployment runs against, also changed in v5. Those changes are not visible from application code. If you implement `World` yourself or maintain a build integration that compiles workflow files, upgrade it alongside the SDK. See [Upgrading a World to v5](/worlds/upgrading-to-v5) for the full interface delta and the contract changes that affect existing implementations. There is a separate skill for that job because none of it applies to application code: ```bash npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5 diff --git a/docs/content/worlds/v4/building-a-world.mdx b/docs/content/worlds/v4/building-a-world.mdx index bd0b1a047d..ceee349678 100644 --- a/docs/content/worlds/v4/building-a-world.mdx +++ b/docs/content/worlds/v4/building-a-world.mdx @@ -12,23 +12,23 @@ related: - /worlds/vercel --- -A **World** is the abstraction that allows workflows to run on any infrastructure. It handles workflow storage, step execution queuing, and data streaming. This guide explains the World interface and how to implement your own. +A **World** is the abstraction that allows workflows to run on any infrastructure. It handles workflow storage, step execution queuing, and data streaming. You can implement the World interface to connect workflows to your own infrastructure. - Before building a custom World, check the [Worlds Ecosystem](/worlds) page — there may already be a community implementation for your infrastructure. + Before building a custom World, check the [Worlds Ecosystem](/worlds) page. There may already be a community implementation for your infrastructure. - **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of how to implement the World interface with a database backend and graphile-worker for queuing. + **Reference implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of how to implement the World interface with a database backend and graphile-worker for queuing. ## What is a World? A World connects workflows to the infrastructure that powers them. The World interface abstracts three core responsibilities: -1. **Storage** — Persisting workflow runs, steps, hooks, and the event log -2. **Queue** — Enqueuing and processing workflow and step invocations -3. **Streamer** — Managing real-time data streams between workflows and clients +- **Storage**: Persists workflow runs, steps, hooks, and the event log. +- **Queue**: Enqueues and processes workflow and step invocations. +- **Streamer**: Manages real-time data streams between workflows and clients. {/* @skip-typecheck - interface definition, not runnable code */} ```typescript @@ -42,13 +42,13 @@ interface World extends Storage, Queue, Streamer { The optional `start()` method initializes background tasks (for example, queue polling). The optional `close()` method releases resources like connection pools and listeners. The optional `getEncryptionKeyForRun()` method returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled. -## The Event Log Model +## The event log model -Workflow storage is built on an **append-only event log**. All state changes happen through events — you never modify runs, steps, or hooks directly. Instead, you create events that update the materialized state. +Workflow storage is built on an **append-only event log**. All state changes happen through events: you never modify runs, steps, or hooks directly. Instead, you create events that update the materialized state. Events fall into three categories: run lifecycle events, step lifecycle events, and hook lifecycle events. See the [Event Sourcing](/docs/how-it-works/event-sourcing) documentation for a complete list of event types and their semantics. -## Storage Interface +## Storage interface The Storage interface provides read access to materialized entities and write access through events: @@ -84,20 +84,20 @@ interface Storage { } ``` -### Key Implementation Details +### Key implementation details -**Event Creation:** When `events.create()` is called, your implementation must: -1. Persist the event to the event log -2. Atomically update the affected entity (run, step, or hook) -3. Return both the created event and the updated entity +**Event creation:** When `events.create()` is called, your implementation must: +1. Persist the event to the event log. +2. Atomically update the affected entity (run, step, or hook). +3. Return both the created event and the updated entity. -**Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`. +**Run creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`. -**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead and include the active hook owner's run ID as `eventData.conflictingRunId`. +**Hook tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an existing token, return a `hook_conflict` event instead and include the active hook owner's run ID as `eventData.conflictingRunId`. -**Automatic Hook Disposal:** When a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`), automatically dispose of all associated hooks to release tokens for reuse. +**Automatic hook disposal:** When a workflow reaches a terminal state (`completed`, `failed`, or `cancelled`), automatically dispose of all associated hooks to release tokens for reuse. -## Queue Interface +## Queue interface The Queue interface handles asynchronous execution of workflows and steps: @@ -119,17 +119,17 @@ interface Queue { } ``` -### Queue Names +### Queue names Queue names follow a specific pattern: -- `__wkf_workflow_` — For workflow invocations -- `__wkf_step_` — For step invocations +- `__wkf_workflow_`: For workflow invocations +- `__wkf_step_`: For step invocations -### Message Payloads +### Message payloads Two types of messages flow through queues: -**Workflow Invocations:** +**Workflow invocations:** {/* @skip-typecheck - interface definition, not runnable code */} ```typescript interface WorkflowInvokePayload { @@ -139,7 +139,7 @@ interface WorkflowInvokePayload { } ``` -**Step Invocations:** +**Step invocations:** {/* @skip-typecheck - interface definition, not runnable code */} ```typescript interface StepInvokePayload { @@ -152,14 +152,14 @@ interface StepInvokePayload { } ``` -### Implementation Considerations +### Implementation considerations -- Messages must be delivered at-least-once -- Support configurable retry policies -- Track attempt counts for observability -- Implement idempotency using the `idempotencyKey` option when provided +- Messages must be delivered at-least-once. +- Support configurable retry policies. +- Track attempt counts for observability. +- Implement idempotency using the `idempotencyKey` option when provided. -## Streamer Interface +## Streamer interface The Streamer interface enables real-time data streaming: @@ -215,34 +215,33 @@ interface Streamer { Streams are identified by a combination of `runId` and `name`. Each workflow run can have multiple named streams. `writeMulti()` is an optional optimization for batching multiple writes. -`getChunks` returns a paginated snapshot of currently available chunks (unlike `get` which returns a live `ReadableStream` that waits for new chunks). `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete — useful for resolving negative `startIndex` values into absolute positions. +`getChunks` returns a paginated snapshot of currently available chunks (unlike `get` which returns a live `ReadableStream` that waits for new chunks). `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete, which is useful for resolving negative `startIndex` values into absolute positions. -## Reference Implementations +## Reference implementations Study these implementations for guidance: -- **[Local World](https://github.com/vercel/workflow/tree/main/packages/world-local)** — Filesystem-based, great for understanding the basics -- **[Postgres World](https://github.com/vercel/workflow/tree/main/packages/world-postgres)** — Database-backed with graphile-worker for queuing +- **[Local World](https://github.com/vercel/workflow/tree/main/packages/world-local)**: Filesystem-based reference for understanding the fundamentals +- **[Postgres World](https://github.com/vercel/workflow/tree/main/packages/world-postgres)**: Database-backed with graphile-worker for queuing -## Testing Your World +## Testing your World -Workflow SDK includes an E2E test suite that validates World implementations. Once your World is published to npm: +The Workflow SDK includes an end-to-end (E2E) test suite that validates World implementations. Once your World is published to npm: -1. Add your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json) -2. Open a PR to the Workflow repository -3. CI will automatically run the E2E test suite against your implementation +1. Add your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json). +2. Open a pull request (PR) to the Workflow repository. +3. Continuous integration (CI) will automatically run the E2E test suite against your implementation. Your world will then appear on the [Worlds Ecosystem](/worlds) page with its compatibility status and performance benchmarks. -## Publishing Your World +## Publishing your World -1. **Package your World** — Export a default World instance from your package -2. **Publish to npm** — Publish your package to npm -3. **Add to the manifest** — Submit a PR adding your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json) -4. **Document configuration** — Clearly document any required environment variables +1. **Package your World**: Export a default World instance from your package. +2. **Publish to npm**: Publish your package to npm. +3. **Add to the manifest**: Submit a PR adding your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json). +4. **Document configuration**: Clearly document any required environment variables. -```json -// worlds-manifest.json entry +```json title="worlds-manifest.json" { "package": "your-world-package", "repository": "https://github.com/you/your-world", diff --git a/docs/content/worlds/v4/local.mdx b/docs/content/worlds/v4/local.mdx index 7c3e5d39ea..6e180a46e3 100644 --- a/docs/content/worlds/v4/local.mdx +++ b/docs/content/worlds/v4/local.mdx @@ -32,7 +32,7 @@ npx workflow web Learn more in the [Observability](/docs/observability) documentation. -## Testing & Compatibility +## Testing & compatibility diff --git a/docs/content/worlds/v4/postgres.mdx b/docs/content/worlds/v4/postgres.mdx index 6a39495a02..c7bbca6cdc 100644 --- a/docs/content/worlds/v4/postgres.mdx +++ b/docs/content/worlds/v4/postgres.mdx @@ -12,7 +12,7 @@ related: The Postgres World is a production-ready backend for self-hosted deployments. It uses PostgreSQL for durable storage and [graphile-worker](https://github.com/graphile/worker) for reliable job processing. -Use the Postgres World when you need to deploy workflows on your own infrastructure outside of Vercel - such as a Docker container, Kubernetes cluster, or any cloud that supports long-running servers. +Use the Postgres World to deploy workflows on your own infrastructure outside Vercel, such as a Docker container, Kubernetes cluster, or any cloud that supports long-running servers. ## Installation @@ -77,9 +77,9 @@ To subscribe to the graphile-worker queue, your workflow app needs to start the This step is specific to worlds that run a background worker, such as Postgres -World. Worlds whose queue delivers work over HTTP, including the Vercel world, +World. Worlds whose queue delivers work over HTTP, including the Vercel World, have no worker to subscribe, so `start()` does nothing there while the import -still costs you: pulling in `workflow/runtime` from a server-startup hook puts +still adds overhead. Pulling in `workflow/runtime` from a server-startup hook puts the whole runtime into the cold-start path before the first request is served. @@ -164,7 +164,7 @@ The Postgres World requires a long-lived worker process that polls the database ## Observability -Use the `workflow` CLI to inspect workflows stored in PostgreSQL: +Use the Workflow CLI to inspect workflows stored in PostgreSQL: ```bash # Set your database URL @@ -181,13 +181,13 @@ If `WORKFLOW_POSTGRES_URL` is not set, the CLI defaults to `postgres://world:wor Learn more in the [Observability](/docs/observability) documentation. -## Testing & Compatibility +## Testing & compatibility ## Configuration -All configuration options can be set via environment variables or programmatically via `createWorld()`. +You can set all configuration options through environment variables or programmatically through `createWorld()`. ### `WORKFLOW_POSTGRES_URL` (required) @@ -203,11 +203,11 @@ Prefix for graphile-worker queue job names. Useful when sharing a database betwe Number of concurrent workers polling for jobs. Default: `50`. -This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates — if you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` e2e test (fib(6), ~24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting. +This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates. If you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` e2e test (fib(6), ~24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting. ### `WORKFLOW_POSTGRES_MAX_POOL_SIZE` -Maximum size of the internal `pg.Pool` used when `createWorld()` constructs the pool. Default: `10` +Maximum size of the internal `pg.Pool` used when `createWorld()` constructs the pool. Default: `10`. For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` to `10` or `queueConcurrency + 2`, whichever is larger. @@ -226,13 +226,13 @@ const world = createWorld({ }); ``` -## How It Works +## How it works The Postgres World uses PostgreSQL as a durable backend: -- **Storage** - Workflow runs, events, steps, and hooks are stored in PostgreSQL tables -- **Job Queue** - [graphile-worker](https://github.com/graphile/worker) handles reliable job processing with retries -- **Streaming** - PostgreSQL NOTIFY/LISTEN enables real-time event distribution +- **Storage**: Workflow runs, events, steps, and hooks are stored in PostgreSQL tables. +- **Job queue**: [graphile-worker](https://github.com/graphile/worker) handles reliable job processing with retries. +- **Streaming**: PostgreSQL NOTIFY/LISTEN enables real-time event distribution. This architecture ensures workflows survive application restarts with all state reliably persisted. For implementation details, see the [source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres). @@ -243,13 +243,13 @@ Deploy your application to any cloud that supports long-running servers: - Docker containers - Kubernetes clusters - Virtual machines -- Platform-as-a-Service providers (Railway, Render, Fly.io, etc.) +- Platform-as-a-service (PaaS) providers, such as Railway, Render, and Fly.io Ensure your deployment has: -1. Network access to your PostgreSQL database -2. Environment variables configured correctly -3. The `start()` function called on server initialization +- Network access to your PostgreSQL database +- Environment variables configured correctly +- The `start()` function called on server initialization The Postgres World is not compatible with Vercel deployments. On Vercel, workflows automatically use the [Vercel World](/worlds/vercel) with zero configuration. @@ -257,8 +257,8 @@ The Postgres World is not compatible with Vercel deployments. On Vercel, workflo ## Limitations -- **Requires long-running process** - Must call `start()` on server initialization; not compatible with serverless platforms -- **PostgreSQL infrastructure** - Requires a PostgreSQL database (self-hosted or managed) -- **Not compatible with Vercel** - Use the [Vercel World](/worlds/vercel) for Vercel deployments +- **Requires long-running process**: Must call `start()` on server initialization; not compatible with serverless platforms +- **PostgreSQL infrastructure**: Requires a PostgreSQL database (self-hosted or managed) +- **Not compatible with Vercel**: Use the [Vercel World](/worlds/vercel) for Vercel deployments For local development, use the [Local World](/worlds/local) which requires no external services. diff --git a/docs/content/worlds/v4/vercel.mdx b/docs/content/worlds/v4/vercel.mdx index 749c225df5..4a3852df64 100644 --- a/docs/content/worlds/v4/vercel.mdx +++ b/docs/content/worlds/v4/vercel.mdx @@ -1,8 +1,8 @@ --- title: Vercel World -description: Fully-managed world for Vercel deployments with automatic storage, queuing, and authentication. +description: Fully managed World for Vercel deployments with automatic storage, queuing, and authentication. type: integration -summary: Deploy workflows to Vercel with fully-managed storage, queuing, and authentication. +summary: Deploy workflows to Vercel with fully managed storage, queuing, and authentication. prerequisites: - /docs/deploying related: @@ -11,9 +11,9 @@ related: - /worlds/postgres --- -The Vercel World is a fully-managed workflow backend for applications deployed on Vercel. It provides scalable storage, distributed queuing, and automatic authentication with zero configuration. +The Vercel World is a fully managed workflow backend for applications deployed on Vercel. It provides scalable storage, distributed queuing, and automatic authentication without configuration. -When you deploy to Vercel, workflows automatically use the Vercel World - no setup required. +When you deploy to Vercel, workflows automatically use the Vercel World without requiring setup. ## Usage @@ -23,7 +23,7 @@ Deploy your application to Vercel: vercel deploy ``` -That's it. Vercel automatically: +Vercel automatically: - Selects the Vercel World backend - Configures authentication using OIDC tokens @@ -36,9 +36,9 @@ That's it. Vercel automatically: For complete details on pricing, usage limits, and included allotments on Vercel, see the official Vercel documentation: -- **[Vercel Workflow](https://vercel.com/docs/workflows)** — Pricing details, concepts, and observability for Workflow on Vercel -- **[Vercel limits](https://vercel.com/docs/limits)** — Platform-wide limits including Workflow-specific constraints -- **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)** — Free tier included usage for Workflow and other resources +- **[Vercel Workflow](https://vercel.com/docs/workflows)**: Pricing details, concepts, and observability for Workflow on Vercel +- **[Vercel limits](https://vercel.com/docs/limits)**: Platform-wide limits, including Workflow-specific constraints +- **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)**: Free-tier included usage for Workflow and other resources For self-hosted deployments, use the [Postgres World](/worlds/postgres). For local development, use the [Local World](/worlds/local). @@ -46,16 +46,16 @@ For self-hosted deployments, use the [Postgres World](/worlds/postgres). For loc **Multi-region support is available starting with `workflow` version - 5.0.0.** On 5.x, workflow runs are pinned to the region that creates them — + 5.0.0.** On 5.x, workflow runs are pinned to the region that creates them, and storage, queuing, and streams are served region-locally instead of routing through `iad1`. See [Multi-region on the v5 version of this page](/v5/worlds/vercel#multi-region). The limitations below apply to the 4.x release line, which will not support multi-region. -- **Single-region deployment** - On the 4.x release line, the backend infrastructure is used only in `iad1`. Applications in other regions will route workflow requests to `iad1`, which may result in higher latency. For best performance, deploy your Vercel apps using Workflow to `iad1` — or upgrade to `workflow` 5.0.0+ for multi-region support. +- **Single-region deployment**: On the 4.x release line, the backend infrastructure is used only in `iad1`. Applications in other regions route workflow requests to `iad1`, which may result in higher latency. For best performance, deploy Vercel apps using Workflow to `iad1`, or upgrade to `workflow` 5.0.0 or later for multi-region support. -- **Data residency** - On the 4.x release line, independently of the deployment location of your application, the data for your workflows will be stored in the `iad1` region. +- **Data residency**: On the 4.x release line, workflow data is stored in the `iad1` region regardless of your application's deployment location. ## Observability @@ -92,7 +92,7 @@ npx workflow inspect runs \ Learn more in the [Observability](/docs/observability) documentation. -## Testing & Compatibility +## Testing & compatibility @@ -188,15 +188,15 @@ Practically, this means: - Handlers receive only a message ID that must be retrieved from Vercel's backend, making it impossible to craft custom payloads - This configuration is managed entirely by the Workflow SDK build step. You should not need to write this yourself. If you are writing a custom integration, see [Framework Integrations — Security](/docs/how-it-works/framework-integrations#security) for more details. + The Workflow SDK build step manages this configuration. If you are writing a custom integration, see [Framework integrations: Security](/docs/how-it-works/framework-integrations#security) for more details. -## How It Works +## How it works The Vercel World uses Vercel's infrastructure for workflow execution: -- **Storage** - Workflow data is stored in Vercel's cloud with automatic replication and [end-to-end encryption](/docs/how-it-works/encryption) -- **Queuing** - Steps are distributed across serverless functions via [Vercel Queues](https://vercel.com/docs/queues) with automatic retries and [consumer function security](#consumer-function-security) -- **Authentication** - OIDC tokens provide secure, automatic authentication +- **Storage**: Workflow data is stored in Vercel's cloud with automatic replication and [end-to-end encryption](/docs/how-it-works/encryption) +- **Queuing**: Steps are distributed across Vercel Functions through [Vercel Queues](https://vercel.com/docs/queues) with automatic retries and [consumer function security](#consumer-function-security) +- **Authentication**: OIDC tokens provide secure, automatic authentication For more details, see the [Vercel Workflow documentation](https://vercel.com/docs/workflows). diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index 854e4e921e..c30b1237ee 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -16,24 +16,24 @@ related: A **World** is the abstraction that allows workflows to run on any infrastructure. It handles workflow storage, step execution queuing, and data streaming. This guide explains the World interface and how to implement your own. - Before building a custom World, check the [Worlds Ecosystem](/worlds) page — there may already be a community implementation for your infrastructure. + Before building a custom World, check the [Worlds ecosystem](/worlds) page. There may already be a community implementation for your infrastructure. - **Reference Implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of how to implement the World interface with a database backend and graphile-worker for queuing. + **Reference implementation:** The [Postgres World source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres) is a production-ready example of implementing the World interface with a database backend and Graphile Worker for queuing. - Already have a World on the 4.x spec? See [Upgrading a World to v5](/worlds/upgrading-to-v5) for the interface delta and the contract changes, rather than reading this guide top to bottom. + If you have a World on the 4.x spec, see [Upgrading a World to v5](/worlds/upgrading-to-v5) for the interface and contract changes. ## What is a World? A World connects workflows to the infrastructure that powers them. The World interface abstracts three core responsibilities: -1. **Storage** — Persisting workflow runs, steps, hooks, and the event log -2. **Queue** — Enqueuing and processing workflow and step invocations -3. **Streamer** — Managing real-time data streams between workflows and clients +1. **Storage**: Persists workflow runs, steps, hooks, and the event log +2. **Queue**: Enqueues and processes workflow and step invocations +3. **Streamer**: Manages real-time data streams between workflows and clients {/* @skip-typecheck - interface definition, not runnable code */} ```typescript @@ -60,18 +60,18 @@ interface World extends Storage, Queue, Streamer { `specVersion` is required. See [Declaring the spec version](#declaring-the-spec-version). -The optional `capabilities` object advertises additional behavior, and every capability **fails closed**: a missing member means "unsupported", and the runtime keeps its conservative behavior. Set `hookRetention.active` to `true` only when the World implements Hook token retention. Set `maxConcurrency` only when the World's queue supports `maxConcurrency`-limited consumption (used by `WORKFLOW_SEQUENTIAL_REPLAYS=1`). +The optional `capabilities` object advertises additional behavior, and every capability **fails closed**. A missing member means "unsupported," and the runtime keeps its conservative behavior. Set `hookRetention.active` to `true` only when the World implements hook token retention. Set `maxConcurrency` only when the World's queue supports `maxConcurrency`-limited consumption, which `WORKFLOW_SEQUENTIAL_REPLAYS=1` uses. -Note what is deliberately *not* in there. [Slot-numbered event IDs](#event-id-allocation) are a requirement of this contract rather than a capability, so there is no flag to set and no fallback path if you skip them. +[Slot-numbered event IDs](#event-id-allocation) are a contract requirement rather than a capability, so there is no flag or fallback path. The remaining optional members: -- `analytics` provides metadata-only listings of runs, steps, events, hooks, waits, and attributes for observability surfaces — `workflow inspect` and the local web UI read from it, including attribute search. See [Analytics Interface](#analytics-interface-optional). +- `analytics` provides metadata-only listings of runs, steps, events, hooks, waits, and attributes for observability surfaces. `workflow inspect` and the local web UI read from it, including for attribute search. See [Analytics interface](#analytics-interface-optional). - `start()` initializes background tasks (for example, queue polling); `close()` releases resources like connection pools and listeners. - `getEncryptionKeyForRun()` returns the AES-256 key used to encrypt data for a run; if it is not implemented, encryption is disabled. -- `createRunId()` mints the ID for a new run. Implementations may embed world-specific metadata as long as the result stays a valid ULID — this is how [multi-region placement](/worlds/vercel#multi-region) works: `@workflow/world-vercel` reads `options.region` (the `start()` options bag) and embeds a region tag. When omitted, the runtime generates a standard monotonic ULID. +- `createRunId()` mints the ID for a new run. Implementations may embed World-specific metadata as long as the result remains a valid ULID. This is how [multi-region placement](/worlds/vercel#multi-region) works. `@workflow/world-vercel` reads `options.region` from the `start()` options and embeds a region tag. When omitted, the runtime generates a standard monotonic ULID. - `describeRun()` returns world-specific display fields for a run (for example, a region decoded from its ID) that tooling like `workflow inspect` renders as extra columns. It must be cheap, tolerate missing fields, and never throw. -- `processExitTriggersQueueRedelivery` tells the runtime how to handle an exhausted replay budget: `true` means it exits and relies on queue redelivery; `false` (or absent) means it writes `run_failed` best-effort and returns. +- `processExitTriggersQueueRedelivery` tells the runtime how to handle an exhausted replay budget. When `true`, it exits and relies on queue redelivery. When `false` or absent, it attempts to write `run_failed` and returns. ### Declaring the spec version @@ -91,15 +91,15 @@ export function createWorld(): World { The runtime checks this before it creates or replays anything and throws if the version is outside the range it supports, naming both the range and what your World declared. A version below the range means your World allocates event IDs the runtime cannot read positions out of; above it means your World speaks a protocol this runtime has not learned. -Using the constant is what keeps that check passing across upgrades: it moves with the `@workflow/world` version your package resolves, so a spec bump raises your declaration and the runtime's requirement together. A hard-coded number leaves your World a version behind the next bump, and the runtime rejects it. Keep `@workflow/world` in the same release channel as the `workflow` version your users install. +Using the constant keeps that check passing across upgrades. It moves with the `@workflow/world` version your package resolves, so a spec bump raises your declaration and the runtime's requirement together. A hard-coded number leaves your World a version behind the next bump, and the runtime rejects it. Keep `@workflow/world` in the same release channel as the `workflow` version your users install. -## The Event Log Model +## The event log model -Workflow storage is built on an **append-only event log**. All state changes happen through events — you never modify runs, steps, or hooks directly. Instead, you create events that update the materialized state. +Workflow storage uses an **append-only event log**. All state changes happen through events. Instead of modifying runs, steps, or hooks directly, you create events that update the materialized state. Events fall into three categories: run lifecycle events, step lifecycle events, and hook lifecycle events. See the [Event Sourcing](/docs/how-it-works/event-sourcing) documentation for a complete list of event types and their semantics. -## Storage Interface +## Storage interface The Storage interface provides read access to materialized entities and write access through events: @@ -110,7 +110,7 @@ interface Storage { get(id: string, params?: GetWorkflowRunParams): Promise; list(params?: ListWorkflowRunsParams): Promise>; // Optional: backs setAttributes(). Omitting it means run attributes are - // unsupported on this World — the SDK helper no-ops with a warning. + // unsupported on this World. The SDK helper no-ops with a warning. experimentalSetAttributes?(runId: string, changes: AttributeChange[], options?: { allowReservedAttributes?: boolean }): Promise; // Optional: long poll for a terminal status (see below) @@ -141,40 +141,41 @@ interface Storage { } ``` -### Key Implementation Details +### Key implementation details + +**Event creation:** When `events.create()` is called, your implementation must: -**Event Creation:** When `events.create()` is called, your implementation must: 1. Persist the event to the event log 2. Atomically update the affected entity (run, step, or hook) 3. Return both the created event and the updated entity -**Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`. +**Run creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`. -**Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an active Hook or a token still reserved after its run ended, return a `hook_conflict` event and include the owner's run ID as `eventData.conflictingRunId`. +**Hook tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an active hook or a token still reserved after its run ended, return a `hook_conflict` event and include the owner's run ID as `eventData.conflictingRunId`. -Keep the owning Run available for at least as long as its token remains unavailable, because conflicts return that Run. +Keep the owning run available for at least as long as its token remains unavailable because conflicts return that run. -**Automatic Hook Cleanup:** When a run ends, remove its live Hooks. Make each token available unless its `tokenRetentionUntil` is still in the future. A `hook_disposed` event always makes the token available immediately. +**Automatic hook cleanup:** When a run ends, remove its live hooks. Make each token available unless its `tokenRetentionUntil` is still in the future. A `hook_disposed` event always makes the token available immediately. -### Optional: Waiting for a Terminal Run Status +### Optional: waiting for a terminal run status -`await run.returnValue` has to find out when a run finished. Without help it re-reads the run every second, so a run that finishes just after a read is reported up to a second late. Implement `runs.waitForTerminalStatus(id, { timeoutMs, signal, resolveData })` and the runtime asks once and is answered the moment the run ends. +`await run.returnValue` must determine when a run finishes. Without help, it rereads the run every second, so it reports a run that finishes just after a read up to 1s late. Implement `runs.waitForTerminalStatus(id, { timeoutMs, signal, resolveData })` so the runtime asks once and receives an answer when the run ends. The contract is deliberately forgiving, because "wait" means something different in every store: - Resolve as soon as the run's status is terminal, returning the same entity `get` returns. -- Resolve no later than roughly `timeoutMs` with the latest snapshot, whatever its status. **A timeout is a normal return, never an error** — a run that is still running is a legitimate answer, and the runtime simply asks again. -- `timeoutMs` is an upper bound, not a lower one: returning a non-terminal snapshot early is allowed, and the runtime paces its own retries. -- Fail exactly like `get` — a missing run throws `WorkflowRunNotFoundError`. +- Resolve no later than roughly `timeoutMs` with the latest snapshot, whatever its status. **A timeout is a normal return, never an error.** A run that is still running is a valid response, and the runtime asks again. +- `timeoutMs` is an upper bound, not a lower one. You may return a nonterminal snapshot early, and the runtime paces its own retries. +- Fail exactly like `get`. A missing run throws `WorkflowRunNotFoundError`. -How you wait is up to your store. The reference worlds use, respectively, a server-side long poll (`world-vercel` holds `GET /v2/runs/:runId/status` open), `LISTEN`/`NOTIFY` (`world-postgres`), and an in-process emitter over the run files (`world-local`). Whatever the mechanism, treat the notification as a *signal only* and re-read the run before answering, and back the wait with a periodic re-read so a lost notification costs latency rather than hanging until the budget expires. +Choose a waiting mechanism that fits your store. The reference Worlds use a server-side long poll (`world-vercel` holds `GET /v2/runs/:runId/status` open), `LISTEN`/`NOTIFY` (`world-postgres`), and an in-process emitter over the run files (`world-local`), respectively. Treat the notification only as a *signal*, and reread the run before responding. Back the wait with a periodic reread so a lost notification increases latency instead of causing a hang until the budget expires. -Omitting the method is a supported choice — a store with no change notification, or a deterministic simulator like `world-sim` where a real wait would stall a virtual clock, simply leaves it off and the runtime keeps interval-polling `get`. Nothing else degrades; there is no capability to declare. +Omitting the method is supported. A store with no change notification, or a deterministic simulator such as `world-sim` where a real wait would stall a virtual clock, can leave it out. The runtime continues polling `get` at intervals, and no other behavior degrades. There is no capability to declare. -### Event ID Allocation +### Event ID allocation - **Required.** Your World assigns every event ID, and every ID must be a position in its run's log. The runtime reads a position out of every ID it loads and fails the run when it cannot, so a World whose IDs are anything else — a ULID, a UUID, a database sequence shared across runs — cannot replay a single workflow. There is no capability flag and no fallback path. + **Required.** Your World assigns every event ID, and every ID must be a position in its run's log. The runtime reads a position from every ID it loads and fails the run when it cannot. A World whose IDs use another format, such as a ULID, UUID, or database sequence shared across runs, cannot replay a workflow. There is no capability flag or fallback path. An ID is `evnt_` followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so the run's first event is `evnt_00000000000000000000000001`. Use `slotToEventId()` from `@workflow/world` to format one. @@ -184,36 +185,36 @@ Two properties have to hold, and both are about what a reader can conclude from - **Uniqueness.** Two writers racing to append must not both take a position. Settle it where the store settles it, with a unique constraint on `(runId, eventId)` or a conditional write, rather than reading the maximum and adding one in your own process. - **Density.** Positions run from 1 with no holes, which is what lets a reader tell a complete log from a truncated one by its length alone. A writer that loses a race must re-derive its position from the store and take the next free one. Incrementing a local number after a loss leaves a permanent hole, and the runtime treats a hole as a log it cannot safely replay across. -Allocate the position **at the commit**, in the same operation that appends the event. That is what makes a reader's log a *prefix* of the run's log rather than a prefix with a hole in it: nothing can land behind a position a reader has already passed. Handing a position out earlier — in a request handler, say — and committing later breaks the property every replay depends on, and is the one case where you may need [a stale-write rejection](#optional-rejecting-a-stale-write) to compensate. +Allocate the position **at the commit** in the same operation that appends the event. This makes a reader's log a *prefix* of the run's log rather than a prefix with a hole. Nothing can land behind a position that a reader has passed. Assigning a position earlier, such as in a request handler, and committing later breaks the property that every replay depends on. This is the one case where you may need [a stale-write rejection](#optional-rejecting-a-stale-write) to compensate. #### Optional: pre-assigned positions and `noop` sealing -Spec version 7 legitimizes one alternative to allocate-at-commit, for Worlds whose store makes commit-time allocation a contention bottleneck: hand positions out from a per-run atomic counter **before** the commit, and restore density at read time. Pre-assignment means concurrent writers hold distinct positions and never race for one — but a writer that claims a position and dies leaves a permanent hole. A World that allocates this way MUST therefore **seal** provably abandoned positions by writing a `noop` event into them (racing the original writer at the same uniqueness fence — losing that race means the real event landed, which is success), and MUST NOT return a page with an interior hole: return the dense prefix below the hole and let the caller's next page pick up past it once the position resolves to an event or a seal. +Spec version 7 supports one alternative to allocate-at-commit for Worlds whose stores make commit-time allocation a contention bottleneck. Assign positions from a per-run atomic counter **before** the commit, and restore density at read time. With preassignment, concurrent writers hold distinct positions and never race for one. However, a writer that claims a position and stops leaves a permanent hole. A World that allocates this way MUST **seal** provably abandoned positions by writing a `noop` event into them. The seal races the original writer at the same uniqueness fence, and losing that race means the real event landed successfully. The World MUST NOT return a page with an interior hole. Return the dense prefix below the hole, and let the caller's next page continue past it after the position resolves to an event or a seal. -The runtime's side of the contract: it skips `noop` events during replay — never delivered to a consumer, never advancing the deterministic clock — so a sealed log replays identically to one whose holes were filled by their writers. `noop` is not user-creatable and is never sent to `events.create()`; only your own read path may write one. Worlds that allocate at the commit (a synchronous counter, a unique-constraint append) keep perfect density by construction and never need any of this — `world-local` and `world-postgres` never seal, and a World that allocates at the commit is spec-7 compliant with no work. +The runtime skips `noop` events during replay. It never delivers them to a consumer or uses them to advance the deterministic clock, so a sealed log replays identically to one whose writers filled the holes. `noop` isn't user-creatable and is never sent to `events.create()`. Only your read path may write one. Worlds that allocate at the commit, through a synchronous counter or unique-constraint append, maintain perfect density and don't need sealing. `world-local` and `world-postgres` never seal, and a World that allocates at the commit is spec 7 compliant without additional work. -Note the version a World stamps comes from `mintedSpecVersion()`: 7 by default, and the slot-identity version when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) switches it off. Declare `mintedSpecVersion()` rather than a literal so your World moves with the fleet, and note that a run created at spec 7 may be read by a runtime other than the one that created it — the reader has to understand `noop` before anything stamps 7 in that environment. +The version a World stamps comes from `mintedSpecVersion()`: 7 by default or the slot-identity version when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) disables it. Declare `mintedSpecVersion()` instead of a literal so your World moves with the fleet. A runtime other than the one that created a spec 7 run may read it, so readers must understand `noop` before anything stamps 7 in that environment. `events.create()` params carry `eventCount`: how many events the writer held in the log it replayed from, which is the position it expects to land on minus one. Attempt `eventCount + 1`. When that position is taken, **do not reject the write**. Advance to the next free position, commit there, and return the events occupying the positions you skipped over on the success response, in `events` with a matching `cursor` and `hasMore`. The writer merges them into its own log and replays once, rather than paying a second round trip to discover it was behind. A caller with a stale count is the normal case for a fan-out, and rejecting it would serialize writes the runtime issues in parallel. -### Optional: Rejecting a Stale Write +### Optional: rejecting a stale write A replay writes events derived from the event log it loaded, so a write made from an event log that no longer matches the store can commit events no correct replay would produce. Rejecting one means answering `events.create()` with a `PreconditionFailedError` when the run's log already holds more events than the caller's `eventCount` says it had loaded. -No World in this repository does. Reporting is the better mechanism, and the reason it is *sufficient* rather than merely cheaper is worth stating, because it is what removed the need to reject at all: a reader's log is a prefix of the run's log rather than a prefix with a hole in it, replay is deterministic on a prefix, and the writer's next write brings back what it missed. A shorter log therefore means a run that has not caught up, never a run that will decide differently. +No World in this repository does. Reporting is the better mechanism because it removes the need to reject, not only because it costs less. A reader's log is a prefix of the run's log rather than a prefix with a hole in it, replay is deterministic on a prefix, and the writer's next write brings back what it missed. A shorter log therefore means a run that has not caught up, never a run that will decide differently. -That leaves rejecting worth implementing in one case — your store allocates positions somewhere other than the commit, so it cannot report the skipped span reliably and refusing a stale write is safer than accepting one out of order. +Implement rejection when your store allocates positions outside the commit and cannot reliably report the skipped span. Refusing a stale write is then safer than accepting one out of order. Two rules make this safe: -- **Only reject on evidence.** If your record of a run's events is incomplete, expired, or cannot answer the question, accept the creation. A rejection must always mean a real discrepancy, because the runtime responds to one by discarding a replay. -- **Accept a creation that carries no `eventCount`.** It came from a caller with no loaded log to be stale (a queued step body, an out-of-band writer), not from a caller claiming the log is empty. +- **Only reject on evidence.** If your record of a run's events is incomplete, expired, or cannot answer the question, accept the creation. A rejection must always indicate a real discrepancy because the runtime responds by discarding a replay. +- **Accept a creation that carries no `eventCount`.** It came from a caller with no loaded log that could be stale, such as a queued step body or out-of-band writer, rather than a caller claiming the log is empty. -A rejection may optionally carry the events the caller was missing, as `{ events, cursor }` on the error's `details`. Only include them when you can prove the set is complete — that those events fully account for the discrepancy and are not truncated — and that every one of them belongs to the run being written. The runtime merges them straight into the replay's event log, so anything else there is worse than no delta at all. Otherwise omit them, and the runtime performs a full reload instead. +A rejection may include the events the caller was missing as `{ events, cursor }` on the error's `details`. Include them only when you can prove that the set fully accounts for the discrepancy, isn't truncated, and contains only events from the run being written. The runtime merges them directly into the replay's event log, so incorrect data is worse than no delta. Otherwise, omit them, and the runtime performs a full reload. -The runtime handles a rejection wherever it can arrive: it restarts the replay in-process from a corrected log, and re-invokes with a fresh replay once that budget is spent. It never retries the rejected write as-is, because a replay working from a corrected log derives different events. Nothing has to be declared for that to work — there is no capability to set. +The runtime handles a rejection wherever it arrives. It restarts the replay in-process from a corrected log and invokes a fresh replay after spending that budget. It never retries the rejected write as-is because a replay working from a corrected log derives different events. This behavior requires no declaration or capability. -## Queue Interface +## Queue interface The Queue interface handles asynchronous workflow execution, including queued step work: @@ -235,12 +236,12 @@ interface Queue { } ``` -### Queue Names +### Queue names Queue names follow a specific pattern: -- `__wkf_workflow_` — For workflow orchestration and queued step invocations +- `__wkf_workflow_`: Used for workflow orchestration and queued step invocations -### Message Payloads +### Message payloads Workflow orchestration and step execution use the same payload. `stepId` and `stepName` identify a queued step invocation: @@ -258,16 +259,16 @@ interface WorkflowInvokePayload { The SDK also sends an internal `HealthCheckPayload` through the same workflow queue. -### Implementation Considerations +### Implementation considerations - Messages must be delivered at-least-once - Support configurable retry policies - Track attempt counts for observability - Implement idempotency using the `idempotencyKey` option when provided -- Honor the `delaySeconds` option — waits (`sleep()`) are delivered as ordinary delayed continuations on the workflow queue, so a World that ignores `delaySeconds` redelivers immediately and busy-loops every sleeping run +- Honor the `delaySeconds` option. Waits (`sleep()`) are delivered as ordinary delayed continuations on the workflow queue, so a World that ignores `delaySeconds` redelivers immediately and causes every sleeping run to enter a busy loop - Optionally honor the `region` option, a routing hint naming the region a message should be dispatched in; Worlds without a regional dimension ignore it -## Streamer Interface +## Streamer interface The Streamer interface enables real-time data streaming: @@ -323,13 +324,13 @@ interface Streamer { Streams are identified by a combination of `runId` and `name`. Each workflow run can have multiple named streams. `writeMulti()` is an optional optimization for batching multiple writes. -`getChunks` returns a paginated snapshot of currently available chunks (unlike `get` which returns a live `ReadableStream` that waits for new chunks). `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete — useful for resolving negative `startIndex` values into absolute positions. +`getChunks` returns a paginated snapshot of currently available chunks, unlike `get`, which returns a live `ReadableStream` that waits for new chunks. `getInfo` returns the tail index (last chunk index, 0-based, or `-1` when empty) and whether the stream is complete. Use this information to resolve negative `startIndex` values into absolute positions. -## Analytics Interface (Optional) +## Analytics interface (optional) The optional `analytics` namespace provides **metadata-only** access to runs and their related records. It is intended for observability and discovery surfaces such as dashboards, `workflow inspect`, and the local web UI. Implementations can optimize these queries independently of payload storage. -Tooling feature-detects this namespace. When `world.analytics` is available, tooling prefers it for listings and attribute search; otherwise, it uses the Storage APIs. Among the first-party Worlds, only the [Vercel World](/worlds/vercel) currently implements it. The Local and Postgres Worlds leave it `undefined`. +Tooling detects whether this namespace is available. When `world.analytics` is available, tooling prefers it for listings and attribute search. Otherwise, it uses the Storage APIs. Among the first-party Worlds, only the [Vercel World](/worlds/vercel) currently implements it. The Local and Postgres Worlds leave it `undefined`. {/* @skip-typecheck - interface definition, not runnable code */} ```typescript @@ -372,14 +373,14 @@ If you implement this namespace, observe the following requirements: See the [Analytics API reference](/docs/api-reference/workflow-runtime/world/analytics) for per-method parameters, row shapes, and `pageInfo` semantics. -## Reference Implementations +## Reference implementations Study these implementations for guidance: -- **[Local World](https://github.com/vercel/workflow/tree/main/packages/world-local)** — Filesystem-based, great for understanding the basics -- **[Postgres World](https://github.com/vercel/workflow/tree/main/packages/world-postgres)** — Database-backed with graphile-worker for queuing +- **[Local World](https://github.com/vercel/workflow/tree/main/packages/world-local)**: A file-system-based implementation for understanding the basics +- **[Postgres World](https://github.com/vercel/workflow/tree/main/packages/world-postgres)**: A database-backed implementation with Graphile Worker for queuing -## Testing Your World +## Testing your World Workflow SDK includes an E2E test suite that validates World implementations. Once your World is published to npm: @@ -387,14 +388,14 @@ Workflow SDK includes an E2E test suite that validates World implementations. On 2. Open a PR to the Workflow repository 3. CI will automatically run the E2E test suite against your implementation -Your world will then appear on the [Worlds Ecosystem](/worlds) page with its compatibility status and performance benchmarks. +Your World will then appear on the [Worlds ecosystem](/worlds) page with its compatibility status and performance benchmarks. -## Publishing Your World +## Publishing your World -1. **Package your World** — Export a default World instance from your package -2. **Publish to npm** — Publish your package to npm -3. **Add to the manifest** — Submit a PR adding your world to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json) -4. **Document configuration** — Clearly document any required environment variables +1. **Package your World**: Export a default World instance from your package +2. **Publish to npm**: Publish your package to npm +3. **Add to the manifest**: Submit a PR adding your World to [`worlds-manifest.json`](https://github.com/vercel/workflow/blob/main/worlds-manifest.json) +4. **Document configuration**: Document any required environment variables ```json // worlds-manifest.json entry diff --git a/docs/content/worlds/v5/local.mdx b/docs/content/worlds/v5/local.mdx index 9ea44300e6..41f6a7c2a0 100644 --- a/docs/content/worlds/v5/local.mdx +++ b/docs/content/worlds/v5/local.mdx @@ -2,7 +2,7 @@ title: Local World description: Zero-config world bundled with Workflow for local development. No external services required. type: integration -summary: Set up the Local World for zero-config workflow development on your machine. +summary: Set up the Local World for zero-configuration workflow development on your machine. prerequisites: - /docs/deploying related: @@ -10,9 +10,9 @@ related: - /worlds/vercel --- -The Local World is bundled with `workflow` and used automatically during local development. No installation or configuration required. +The Local World is bundled with `workflow` and used automatically during local development. It requires no installation or configuration. -To explicitly use the local world in any environment, set the environment variable: +To explicitly use the Local World in any environment, set this environment variable: ```bash WORKFLOW_TARGET_WORLD=local @@ -20,7 +20,7 @@ WORKFLOW_TARGET_WORLD=local ## Observability -The `workflow` CLI uses the local world by default. Running these commands inside your workflow project will show your local development workflows: +The Workflow CLI uses the Local World by default. Run these commands inside your workflow project to view your local development workflows: ```bash # List recent workflow runs @@ -32,13 +32,13 @@ npx workflow web Learn more in the [Observability](/docs/observability) documentation. -## Testing & Compatibility +## Testing & compatibility ## Configuration -The local world works with zero configuration, but you can customize behavior through environment variables or programmatically via `createWorld()`. +The Local World requires no configuration, but you can customize its behavior through environment variables or programmatically through `createWorld()`. ### `WORKFLOW_LOCAL_DATA_DIR` @@ -66,7 +66,7 @@ Maximum number of seconds a local queue message can stay hidden before the handl How often a wait for a terminal run status re-reads the run file, in milliseconds. Default: `100`. -`await run.returnValue` asks the World to wait for the run to finish. When the run and the caller share a process — the usual dev-server case — an in-process signal resolves the wait as soon as the run ends, and this interval never comes into play. It is the backstop that covers everything the signal cannot see, chiefly a second process awaiting a run over the same data directory, so it is set far below the SDK's own polling interval. +`await run.returnValue` asks the World to wait for the run to finish. When the run and the caller share a process (the usual development server case), an in-process signal resolves the wait as soon as the run ends, and this interval never comes into play. The interval covers activity the signal cannot detect, primarily a second process awaiting a run over the same data directory, so it is set far below Workflow's own polling interval. ### `WORKFLOW_LOCAL_HEADERS_TIMEOUT_MS` @@ -82,7 +82,7 @@ Whether queue deliveries go out through Node's built-in `node:http` and `node:ht ### `WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS` -Whether pending and running runs found in the data directory are re-enqueued when the world starts. Set to `0` or `false` to skip recovery and leave stale runs untouched. Default: `true` +Whether pending and running runs found in the data directory are re-enqueued when the World starts. Set to `0` or `false` to skip recovery and leave stale runs untouched. Default: `true`. ### `WORKFLOW_LOCAL_HOOK_RETENTION_LIMIT_DAYS` @@ -90,7 +90,7 @@ Maximum [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#k ### `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` -Group-commit window, in milliseconds, for the leading chunk of an idle stream. Default: `0` (dispatch immediately). A positive value holds the first chunk up to that long to collect a group — trading first-chunk latency for fewer requests. Chunks arriving while a request is in flight always coalesce into the next group regardless. +Group-commit window, in milliseconds, for the leading chunk of an idle stream. Default: `0` (dispatch immediately). A positive value holds the first chunk up to that long to collect a group, trading first-chunk latency for fewer requests. Chunks arriving while a request is in flight always coalesce into the next group regardless. ### Programmatic configuration @@ -117,11 +117,11 @@ WORKFLOW_TARGET_WORLD="./my-world.ts" ## Limitations -The local world is designed for development, not production: +The Local World is designed for development, not production: -- **In-memory queue** - Workflow messages, including queued step invocations, do not persist across server restarts -- **Filesystem storage** - Data is stored in local JSON files -- **Single instance** - Cannot handle distributed deployments -- **No authentication** - Suitable only for local development +- **In-memory queue**: Workflow messages, including queued step invocations, do not persist across server restarts. +- **Filesystem storage**: Data is stored in local JSON files. +- **Single instance**: The Local World cannot handle distributed deployments. +- **No authentication**: The Local World is suitable only for local development. For production deployments, use the [Vercel World](/worlds/vercel) or [Postgres World](/worlds/postgres). diff --git a/docs/content/worlds/v5/postgres.mdx b/docs/content/worlds/v5/postgres.mdx index 7aedfe8d63..2d6160163e 100644 --- a/docs/content/worlds/v5/postgres.mdx +++ b/docs/content/worlds/v5/postgres.mdx @@ -12,7 +12,7 @@ related: The Postgres World is a production-ready backend for self-hosted deployments. It uses PostgreSQL for durable storage and [graphile-worker](https://github.com/graphile/worker) for reliable job processing. -Use the Postgres World when you need to deploy workflows on your own infrastructure outside of Vercel - such as a Docker container, Kubernetes cluster, or any cloud that supports long-running servers. +Use the Postgres World to deploy workflows on your own infrastructure outside Vercel, such as a Docker container, Kubernetes cluster, or any cloud that supports long-running servers. ## Installation @@ -84,9 +84,9 @@ To subscribe to the graphile-worker queue, your workflow app needs to start the This step is specific to worlds that run a background worker, such as Postgres -World. Worlds whose queue delivers work over HTTP, including the Vercel world, +World. Worlds whose queue delivers work over HTTP, including the Vercel World, have no worker to subscribe, so `start()` does nothing there while the import -still costs you: pulling in `workflow/runtime` from a server-startup hook puts +still adds overhead. Pulling in `workflow/runtime` from a server-startup hook puts the whole runtime into the cold-start path before the first request is served. @@ -171,7 +171,7 @@ The Postgres World requires a long-lived worker process that polls the database ## Observability -Use the `workflow` CLI to inspect workflows stored in PostgreSQL: +Use the Workflow CLI to inspect workflows stored in PostgreSQL: ```bash # Set your database URL @@ -188,13 +188,13 @@ If `WORKFLOW_POSTGRES_URL` is not set, the CLI defaults to `postgres://world:wor Learn more in the [Observability](/docs/observability) documentation. -## Testing & Compatibility +## Testing & compatibility ## Configuration -All configuration options can be set via environment variables or programmatically via `createWorld()`. +You can set all configuration options through environment variables or programmatically through `createWorld()`. ### `WORKFLOW_POSTGRES_URL` @@ -212,7 +212,7 @@ Prefix for graphile-worker queue job names. Useful when sharing a database betwe Number of concurrent workers polling for jobs. Default: `50`. -This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates — if you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` e2e test (fib(6), ~24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting. +This value also bounds how many parent→child workflow polls can be in flight simultaneously. Every `await childRun.returnValue` inside a workflow holds a worker slot until the child run terminates. If you expect recursive or highly-fanned-out parent/child workflows, raise this ceiling above the peak number of concurrent polls. With the default of 50, the included `fibonacciWorkflow` end-to-end test (`fib(6)`, about 24 concurrent polls at peak) passes; deeper recursion or larger fanouts need a correspondingly larger setting. ### `WORKFLOW_POSTGRES_MAX_POOL_SIZE` @@ -242,7 +242,7 @@ For example, `custom` changes the queue topic prefix from `__wkf_workflow_` to ` ### `WORKFLOW_STREAM_FLUSH_INTERVAL_MS` -Group-commit window, in milliseconds, for the leading chunk of an idle stream. Default: `0` (dispatch immediately). A positive value holds the first chunk up to that long to collect a group — trading first-chunk latency for fewer requests. Chunks arriving while a request is in flight always coalesce into the next group regardless. +Group-commit window, in milliseconds, for the leading chunk of an idle stream. Default: `0` (dispatch immediately). A positive value holds the first chunk up to that long to collect a group, trading first-chunk latency for fewer requests. Chunks arriving while a request is in flight always coalesce into the next group regardless. ### Programmatic configuration @@ -280,13 +280,13 @@ Graphile Worker responds automatically when the application is asked to shut dow Closing the world stops new queue claims and waits for active jobs. After Graphile Worker's grace period, a pending workflow HTTP request is aborted. Graphile Worker unlocks that same row through its normal failure handling. The already-claimed delivery consumes a Graphile attempt and is retried only if its attempt budget remains; a one-attempt or final-attempt job is not retried. The shutdown handler does not insert a successor row. Because a client abort does not prove the server handler stopped, workflow and step handlers must tolerate at-least-once execution. -## How It Works +## How it works The Postgres World uses PostgreSQL as a durable backend: -- **Storage** - Workflow runs, events, steps, and hooks are stored in PostgreSQL tables -- **Job Queue** - [graphile-worker](https://github.com/graphile/worker) handles reliable job processing with retries -- **Streaming** - PostgreSQL NOTIFY/LISTEN enables real-time event distribution +- **Storage**: Workflow runs, events, steps, and hooks are stored in PostgreSQL tables. +- **Job queue**: [graphile-worker](https://github.com/graphile/worker) handles reliable job processing with retries. +- **Streaming**: PostgreSQL NOTIFY/LISTEN enables real-time event distribution. This architecture ensures workflows survive application restarts with all state reliably persisted. For implementation details, see the [source code](https://github.com/vercel/workflow/tree/main/packages/world-postgres). @@ -297,13 +297,13 @@ Deploy your application to any cloud that supports long-running servers: - Docker containers - Kubernetes clusters - Virtual machines -- Platform-as-a-Service providers (Railway, Render, Fly.io, etc.) +- Platform-as-a-service (PaaS) providers, such as Railway, Render, and Fly.io Ensure your deployment has: -1. Network access to your PostgreSQL database -2. Environment variables configured correctly -3. The `start()` function called on server initialization +- Network access to your PostgreSQL database +- Environment variables configured correctly +- The `start()` function called on server initialization The Postgres World is not compatible with Vercel deployments. On Vercel, workflows automatically use the [Vercel World](/worlds/vercel) with zero configuration. @@ -311,8 +311,8 @@ The Postgres World is not compatible with Vercel deployments. On Vercel, workflo ## Limitations -- **Requires long-running process** - Must call `start()` on server initialization; not compatible with serverless platforms -- **PostgreSQL infrastructure** - Requires a PostgreSQL database (self-hosted or managed) -- **Not compatible with Vercel** - Use the [Vercel World](/worlds/vercel) for Vercel deployments +- **Requires long-running process**: Must call `start()` on server initialization; not compatible with serverless platforms +- **PostgreSQL infrastructure**: Requires a PostgreSQL database (self-hosted or managed) +- **Not compatible with Vercel**: Use the [Vercel World](/worlds/vercel) for Vercel deployments For local development, use the [Local World](/worlds/local) which requires no external services. diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx index 3ce2d8b0e8..2ddadc5cab 100644 --- a/docs/content/worlds/v5/upgrading-to-v5.mdx +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -30,7 +30,7 @@ This is a different skill from `migrating-workflow-v4-to-v5`, which upgrades the If you would rather work from the diff directly: - + We're working on bringing back World compatibility tests and reporting on the [Worlds page](/worlds), to make it easier to see which Workflow versions each World is compatible with. @@ -52,7 +52,7 @@ export function createWorld(): World { In v4 the runtime required that number to equal its own current version exactly. In v5 it checks the declaration against a range, `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]`, before it creates or replays anything, and refuses a World outside it with an error naming both the range and what your World declared. -The two bounds are the same version today, so exactly one is accepted. That is a consequence of [event ID allocation](#event-id-allocation) being a requirement rather than an option: a World declaring anything lower allocates IDs the runtime cannot read positions out of, and admitting it would only move the failure from startup into the middle of a run. The check is written as a range because the constants answer different questions and come apart while a version bump is staged — the ceiling rises when the runtime learns to read the next version, the floor when that version becomes the one Worlds stamp. +The two bounds are the same version today, so exactly one is accepted. That is a consequence of [event ID allocation](#event-id-allocation) being a requirement rather than an option: a World declaring anything lower allocates IDs the runtime cannot read positions out of, and admitting it would only move the failure from startup into the middle of a run. The check is written as a range because the constants answer different questions and come apart while a version bump is staged. The ceiling rises when the runtime learns to read the next version, and the floor rises when that version becomes the one Worlds stamp. Using the constant is what keeps the check passing across upgrades. It moves with the `@workflow/world` version your package resolves, so a bump raises your declaration and the runtime's floor together, while a hard-coded number leaves your World a version behind the next bump and gets it rejected by the runtime it ships alongside. This is worth re-checking if you followed earlier guidance: `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` names the version that introduced slot-numbered IDs and is equal to `SPEC_VERSION_CURRENT` today, but declaring it pins you to a literal by another name. `@workflow/world-vercel` declared it and now declares the current version instead. Keep `@workflow/world` in the same release channel as the `workflow` version your users install. @@ -66,8 +66,8 @@ These break an existing v4 implementation. Each one is a signature or module-sha | --- | --- | | `getWorld()` and `createWorld()` are async | `await getWorld()`. This already worked in 4.x, so it is safe to write before upgrading. See [`getWorld`](/docs/api-reference/workflow-runtime/get-world). | | Stream methods moved to `world.streams.*`, with `runId` first | `writeToStream(name, runId, chunk)` becomes `streams.write(runId, name, chunk)`; likewise `writeToStreamMulti` → `streams.writeMulti`, `closeStream` → `streams.close`, `readFromStream` → `streams.get`, `getStreamChunks` → `streams.getChunks`, `listStreamsByRunId` → `streams.list`. | -| `world.steps.get()` requires `runId` | The first argument is no longer `string \| undefined` — pass the run ID that owns the step. | -| `events.listByCorrelationId()` requires `runId` | A correlation ID identifies a step, hook or wait within its run, not across runs, so the lookup is scoped to one run — pass the run that owns the correlation ID. Same for `analytics.events.listByCorrelationId()`. A World that paginates by event ID needs the scope in its cursor comparison too, since two runs can hold the same correlation ID. | +| `world.steps.get()` requires `runId` | The first argument is no longer `string \| undefined`. Pass the run ID that owns the step. | +| `events.listByCorrelationId()` requires `runId` | A correlation ID identifies a step, hook, or wait within its run, not across runs, so the lookup is scoped to one run. Pass the run that owns the correlation ID. The same applies to `analytics.events.listByCorrelationId()`. A World that paginates by event ID also needs the scope in its cursor comparison because two runs can hold the same correlation ID. | | `createLocalWorld()` and `createVercelWorld()` removed | Export a `createWorld()` factory from your package instead, matching the first-party Worlds. The arguments are unchanged. | | Worlds are injected into host bundles at build time | Selection is static rather than resolved dynamically at runtime. Verify your World still resolves after the upgrade, and that its module graph survives bundling. | | `@workflow/world-local` stream chunks moved | Chunks live at `streams/chunks//`. Files written in the old flat layout are not read back, so local development state from 4.x can be deleted. Only relevant if your World inherited that layout. | @@ -82,7 +82,7 @@ These do not change any signature, so an implementation ported by types alone wi **Capabilities fail closed.** The optional `capabilities` object advertises behavior the runtime otherwise assumes is absent. An unadvertised capability costs performance, never correctness, so a partial World stays correct while it catches up. The reverse is not true: advertising something you do not enforce removes a guard the runtime was relying on. Only set a flag once the behavior is implemented. -**A stale replay no longer has to be refused.** v5 shipped with a `preconditionGuard` capability for a World that rejected an event creation whose snapshot was behind the log. It is gone, and nothing replaced it: allocating positions at the commit means a reader's log is a prefix rather than a prefix with a hole, replay is deterministic on a prefix, and a write reports the events it was pushed past — so a stale replay costs a merge instead of a rejection. If you implemented the guard, you can delete it. `PreconditionFailedError` and the runtime's handling of it remain for a World that allocates positions away from the commit (see [Event ID allocation](#event-id-allocation)); no World in the SDK throws it. +**A stale replay no longer has to be refused.** v5 shipped with a `preconditionGuard` capability for a World that rejected an event creation whose snapshot was behind the log. It is gone, and nothing replaced it: allocating positions at the commit means a reader's log is a prefix rather than a prefix with a hole, replay is deterministic on a prefix, and a write reports the events it was pushed past. As a result, a stale replay costs a merge instead of a rejection. If you implemented the guard, you can delete it. `PreconditionFailedError` and the runtime's handling of it remain for a World that allocates positions away from the commit (see [Event ID allocation](#event-id-allocation)); no World in the SDK throws it. **Event creation can return a delta.** `events.create()` may return events alongside the one it created, in `events` with a matching `cursor` and `hasMore`. The runtime uses this to skip a follow-up `events.list` round trip on `run_started`, on step-terminal writes that carried a `sinceCursor`, and on `hook_received` writes that carried `preloadEvents`. All three are advisory: a World that returns only the created event stays correct and pays one more round trip. @@ -92,7 +92,7 @@ This is the largest change for a World implementation, and it is required. In v4 an event ID was a ULID your World minted however it liked. In v5 an event ID is its **slot**: `evnt_` followed by the event's 1-based position in that run's log, zero-padded to 26 characters, so a run's first event is `evnt_00000000000000000000000001`. Format one with `slotToEventId()` from `@workflow/world`. -There is no capability to declare and no fallback path. The runtime reads a position out of every ID it loads and fails the run when it cannot, so a World whose IDs are not positions cannot replay a single workflow — it will pass a type check, start runs, and fail on the first replay with `Event id is not slot-numbered`. +There is no capability to declare and no fallback path. The runtime reads a position out of every ID it loads and fails the run when it cannot. A World whose IDs are not positions will pass a type check and start runs, but it cannot replay a single workflow. The first replay fails with `Event id is not slot-numbered`. The scheme exists for what a reader can conclude from a log it just fetched: positions are dense, so a truncated log is distinguishable from a complete one by its length alone. The runtime relies on that, and it fails a run with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log) rather than replay across a hole, so three rules bind an implementation: @@ -106,7 +106,7 @@ The scheme exists for what a reader can conclude from a log it just fetched: pos One consequence is specific to an upgrade, and it is the thing to plan around. - **Runs already in your store cannot be replayed by the new code.** A ULID-numbered run is not readable as positions, and the runtime refuses it rather than guessing, so there is no mixed-scheme mode and no per-run fallback. Drain those runs on your 4.x build before deploying a v5 World, or accept that the ones still in flight will fail. On a platform where a run executes on the deployment that created it — Vercel, for instance — this resolves itself: those runs finish on the build that started them and never meet the new code. Anywhere a single deployment serves every run, sequencing matters. + **Runs already in your store cannot be replayed by the new code.** A ULID-numbered run is not readable as positions, and the runtime refuses it rather than guessing, so there is no mixed-scheme mode and no per-run fallback. Drain those runs on your 4.x build before deploying a v5 World, or accept that the ones still in flight will fail. On a platform such as Vercel, where a run executes on the deployment that created it, this resolves itself: those runs finish on the build that started them and never meet the new code. Anywhere a single deployment serves every run, sequencing matters. ## New optional surface @@ -154,6 +154,6 @@ The cases worth covering explicitly: - A stream written and read back, including a stream closed before the reader attaches. - A run created under an older spec version, if your World has any, read back by the new code. -`@workflow/world-testing` is the shared suite the first-party Worlds run, and it now covers event ID allocation directly: `numbers events by position` fails a World whose IDs do not decode to slots, whose run is not dense from 1, or whose IDs are not in canonical form — a World padding to a different width sorts its own log wrongly past ten events. Run it against your World before the end-to-end cases above; it turns the failure that would otherwise appear on a first replay into one line of test output. +`@workflow/world-testing` is the shared suite the first-party Worlds run, and it now covers event ID allocation directly: `numbers events by position` fails a World whose IDs do not decode to slots, whose run is not dense from 1, or whose IDs are not in canonical form. A World padding to a different width sorts its own log incorrectly past ten events. Run it against your World before the end-to-end cases above; it turns the failure that would otherwise appear on a first replay into one line of test output. The first-party implementations in `packages/world-local` and `packages/world-postgres` are the reference for everything else, and their test suites are the closest thing to full conformance while the compatibility tests are being rebuilt. diff --git a/docs/content/worlds/v5/vercel.mdx b/docs/content/worlds/v5/vercel.mdx index c92b862f15..0f6336b60e 100644 --- a/docs/content/worlds/v5/vercel.mdx +++ b/docs/content/worlds/v5/vercel.mdx @@ -1,8 +1,8 @@ --- title: Vercel World -description: Fully-managed world for Vercel deployments with automatic storage, queuing, and authentication. +description: Fully managed World for Vercel deployments with automatic storage, queuing, and authentication. type: integration -summary: Deploy workflows to Vercel with fully-managed storage, queuing, and authentication. +summary: Deploy workflows to Vercel with fully managed storage, queuing, and authentication. prerequisites: - /docs/deploying related: @@ -11,9 +11,9 @@ related: - /worlds/postgres --- -The Vercel World is a fully-managed workflow backend for applications deployed on Vercel. It provides scalable storage, distributed queuing, and automatic authentication with zero configuration. +The Vercel World is a fully managed workflow backend for applications deployed on Vercel. It provides scalable storage, distributed queuing, and automatic authentication without configuration. -When you deploy to Vercel, workflows automatically use the Vercel World - no setup required. +When you deploy to Vercel, workflows automatically use the Vercel World without requiring setup. ## Usage @@ -23,7 +23,7 @@ Deploy your application to Vercel: vercel deploy ``` -That's it. Vercel automatically: +Vercel automatically: - Selects the Vercel World backend - Configures authentication using OIDC tokens @@ -36,19 +36,19 @@ That's it. Vercel automatically: For complete details on pricing, usage limits, and included allotments on Vercel, see the official Vercel documentation: -- **[Vercel Workflow](https://vercel.com/docs/workflows)** — Pricing details, concepts, and observability for Workflow on Vercel -- **[Vercel limits](https://vercel.com/docs/limits)** — Platform-wide limits including Workflow-specific constraints -- **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)** — Free tier included usage for Workflow and other resources +- **[Vercel Workflow](https://vercel.com/docs/workflows)**: Pricing details, concepts, and observability for Workflow on Vercel +- **[Vercel limits](https://vercel.com/docs/limits)**: Platform-wide limits, including Workflow-specific constraints +- **[Vercel Hobby plan](https://vercel.com/docs/plans/hobby)**: Free-tier included usage for Workflow and other resources For self-hosted deployments, use the [Postgres World](/worlds/postgres). For local development, use the [Local World](/worlds/local). ## Multi-region -The Vercel World runs in every [Vercel Function region](https://vercel.com/docs/regions). Each workflow run is pinned to a single region at creation time: its stored state, queue dispatch, and streams are all served from that region — no cross-region round trips on the hot path. When your application is deployed in the run's region (the automatic case below), step execution is region-local too. +The Vercel World runs in every [Vercel Function region](https://vercel.com/docs/regions). Each workflow run is pinned to a single region at creation time. Its stored state, queue dispatch, and streams are all served from that region without cross-region round trips on the hot path. When your application is deployed in the run's region, as described below, step execution is also region-local. Multi-region requires `workflow` version **5.0.0** or later. The 4.x - release line does not support region pinning — runs created by 4.x always + release line does not support region pinning. Runs created by 4.x always live in `iad1`. @@ -57,7 +57,7 @@ The Vercel World runs in every [Vercel Function region](https://vercel.com/docs/ No configuration is needed. A run is pinned to the region of the function that creates it: - Deploy your app to a single region (via [`regions`](https://vercel.com/docs/project-configuration/vercel-json#regions) in `vercel.json` or the project settings), and every run lives there. -- Deploy to multiple regions for a globally distributed audience, and each run is pinned to the region that served the user who triggered it — workflow data and streaming stay close to that user. +- Deploy to multiple regions for a globally distributed audience, and each run is pinned to the region that served the user who triggered it. Workflow data and streaming stay close to that user. ### Explicit region selection @@ -72,10 +72,10 @@ const run = await start(myWorkflow, [input], { region: "sfo1" }); The `region` option controls where the run's **data is stored** and where - its **queue messages are dispatched from** — it does not deploy your code + its **queue messages are dispatched from**. It does not deploy your code there. Your workflow and step functions execute in the regions your application is deployed to. For execution to actually happen in the - specified region, your app must be deployed there — via + specified region, your app must be deployed there through [`regions`](https://vercel.com/docs/project-configuration/vercel-json#regions) in `vercel.json` or the Function Regions setting in your project settings. If it isn't, the run's data lives in the requested region but its steps @@ -84,20 +84,20 @@ const run = await start(myWorkflow, [input], { region: "sfo1" }); ### Good to know -- Reads, hook resumes, and stream consumers can come from anywhere — the platform routes them to the run's region automatically. -- Runs created by 4.x SDKs (and any runs that existed before you upgraded) live in `iad1` and are unaffected by an upgrade; there is no migration. -- **Hook tokens are currently stored in `iad1`** for every run, regardless of the run's region: the token-to-run mapping that powers [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) and [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) lives there so tokens — which carry no region information — can always be resolved. Hook *payloads* are not affected: a received payload is recorded on the run's event log, which lives in the run's region like all other run data. This token placement may become a project-level setting in the future. +- Reads, hook resumes, and stream consumers can come from anywhere. The platform routes them to the run's region automatically. +- Runs created by 4.x SDKs, including runs that existed before you upgraded, live in `iad1` and are unaffected by an upgrade. There is no migration. +- **Hook tokens are currently stored in `iad1`** for every run, regardless of the run's region. The token-to-run mapping that powers [`getHookByToken()`](/docs/api-reference/workflow-api/get-hook-by-token) and [`resumeHook()`](/docs/api-reference/workflow-api/resume-hook) lives there so tokens without region information can always be resolved. Hook *payloads* are not affected. A received payload is recorded on the run's event log, which lives in the run's region like all other run data. This token placement may become a project-level setting in the future. ## Limitations -- **No run migration** - A run's region is fixed at creation. Existing runs cannot be moved to a different region. -- **Hook minimum retention** - [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) cannot exceed 30 days. +- **No run migration**: A run's region is fixed at creation. Existing runs cannot be moved to a different region. +- **Hook minimum retention**: [`experimental_minRetention`](/docs/api-reference/workflow/create-hook#keep-a-token-unavailable-after-the-run-ends) cannot exceed 30 days. ## Observability Workflow observability is built into the Vercel dashboard on your project page. It respects your existing authentication and project permission settings. -The Vercel World implements the optional [`world.analytics`](/docs/api-reference/workflow-runtime/world/analytics) interface, backed by the same observability data pipeline as the dashboard. Two behaviors are specific to this implementation: listings scan significantly faster when bounded with a `startTime`/`endTime` window, and the queryable window is capped by your plan's observability lookback — requesting an older window fails with `observability-upgrade-required`, and responses carry `pageInfo` (current and maximum lookback) so tools can size date ranges. +The Vercel World implements the optional [`world.analytics`](/docs/api-reference/workflow-runtime/world/analytics) interface, backed by the same observability data pipeline as the dashboard. Two behaviors are specific to this implementation. Listings scan faster when bounded with a `startTime`/`endTime` window, and your plan's observability lookback caps the queryable window. Requesting an older window fails with `observability-upgrade-required`, and responses include `pageInfo` with the current and maximum lookback so tools can size date ranges. The `workflow` CLI commands open a browser window deeplinked to the Vercel dashboard: @@ -130,7 +130,7 @@ npx workflow inspect runs \ Learn more in the [Observability](/docs/observability) documentation. -## Testing & Compatibility +## Testing & compatibility @@ -150,7 +150,7 @@ The Vercel environment to target. Options: `production`, `preview`. Default: `pr ### `WORKFLOW_VERCEL_AUTH_TOKEN` -Vercel API authentication token (secret — keep it in your environment, not in code). Falls back to `VERCEL_TOKEN`, then to your Vercel CLI login. +Vercel API authentication token. Keep this secret in your environment, not in code. Falls back to `VERCEL_TOKEN`, then to your Vercel CLI login. ### `WORKFLOW_VERCEL_PROJECT` @@ -194,7 +194,7 @@ Vercel Queues can delay messages for up to 7 days, capped by the message TTL. Be Per-request timeout, in milliseconds, for Vercel World HTTP calls to workflow-server. Default: `60000`. Clamped to `10000`-`120000`; a value outside that range is pulled into it and warns once. -Below the floor the deadline starts cancelling requests that would have succeeded, since a cold route or a large event page can take seconds, and a cancelled request is redriven through the queue rather than making progress. The ceiling matches the longest a backend route holds a response. Requests that legitimately outlast it (stream reads and writes, event batches) opt out of the deadline entirely rather than relying on this value. +Below the floor the deadline starts canceling requests that would have succeeded, since a cold route or a large event page can take seconds, and a canceled request is redriven through the queue rather than making progress. The ceiling matches the longest a backend route holds a response. Requests that legitimately outlast it (stream reads and writes, event batches) opt out of the deadline entirely rather than relying on this value. ### `WORKFLOW_MAX_CHUNKS_PER_REQUEST` @@ -206,7 +206,7 @@ Experimental. Set `WORKFLOW_EVENTS_TRANSPORT=ws` to ship workflow run events to The setting is ignored when the World is configured with `projectConfig` and therefore routes through the `api-workflow` proxy: that endpoint is an HTTP-only REST gateway and does not forward a WebSocket upgrade, so events stay on HTTP and a warning is logged once per process. -Tracing is unaffected by the choice. Each event write emits an `http POST` client span whichever transport carries it, against the same `url.full` — on the WebSocket path that span is synthesized around the frame, since no HTTP request is made. Attributes tell the two apart: +Tracing is unaffected by the choice. Each event write emits an `http POST` client span against the same `url.full`, regardless of which transport carries it. On the WebSocket path, the span is synthesized around the frame because no HTTP request is made. Attributes distinguish the transports: | Attribute | HTTP | WebSocket | | --- | --- | --- | @@ -279,15 +279,15 @@ Practically, this means: - Handlers receive only a message ID that must be retrieved from Vercel's backend, making it impossible to craft custom payloads - This configuration is managed entirely by the Workflow SDK build step. You should not need to write this yourself. If you are writing a custom integration, see [Framework Integrations — Security](/docs/how-it-works/framework-integrations#security) for more details. + The Workflow SDK build step manages this configuration. If you are writing a custom integration, see [Framework integrations: Security](/docs/how-it-works/framework-integrations#security) for more details. -## How It Works +## How it works The Vercel World uses Vercel's infrastructure for workflow execution: -- **Storage** - Workflow data is stored in Vercel's cloud with automatic replication and [end-to-end encryption](/docs/how-it-works/encryption) -- **Queuing** - Steps are distributed across serverless functions via [Vercel Queues](https://vercel.com/docs/queues) with automatic retries and [consumer function security](#consumer-function-security) -- **Authentication** - OIDC tokens provide secure, automatic authentication +- **Storage**: Workflow data is stored in Vercel's cloud with automatic replication and [end-to-end encryption](/docs/how-it-works/encryption) +- **Queuing**: Steps are distributed across Vercel Functions through [Vercel Queues](https://vercel.com/docs/queues) with automatic retries and [consumer function security](#consumer-function-security) +- **Authentication**: OIDC tokens provide secure, automatic authentication For more details, see the [Vercel Workflow documentation](https://vercel.com/docs/workflows). diff --git a/packages/ai/README.md b/packages/ai/README.md index eaab11e7df..69a568931e 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1,6 +1,6 @@ # @workflow/ai -[Workflow SDK](https://workflow-sdk.dev) compatible helper library for the [AI SDK](https://ai-sdk.dev/). +A helper library that makes [Workflow SDK](https://workflow-sdk.dev) compatible with the [AI SDK](https://ai-sdk.dev/). ## Installation @@ -8,7 +8,7 @@ npm install @workflow/ai ``` -## AI SDK Compatibility +## AI SDK compatibility This package supports both **AI SDK v5** and **AI SDK v6**. The `ai` package is a peer dependency, so you control which version to use: @@ -20,16 +20,16 @@ npm install ai npm install ai@5 ``` -### Version Differences +### Version differences | Feature | AI SDK v5 | AI SDK v6 | |---------|-----------|-----------| | Model interface | `LanguageModelV2` | `LanguageModelV3` | | Provider package | `@ai-sdk/provider@2.x` | `@ai-sdk/provider@3.x` | -Both versions work seamlessly with `@workflow/ai` - the package handles the differences internally through a compatibility layer. +Both versions work with `@workflow/ai`. The package handles their differences through an internal compatibility layer. -### Provider Packages +### Provider packages If you use the provider wrappers (e.g., `@workflow/ai/anthropic`, `@workflow/ai/openai`), install the corresponding provider packages: diff --git a/packages/ai/src/agent/do-stream-step.ts b/packages/ai/src/agent/do-stream-step.ts index 3aba64775d..521d3ce849 100644 --- a/packages/ai/src/agent/do-stream-step.ts +++ b/packages/ai/src/agent/do-stream-step.ts @@ -61,7 +61,7 @@ export interface RawReasoningPart { /** * File chunk captured during streaming. `data` is the raw value emitted by the - * model — a base64 string or a Uint8Array. The type is derived from the V3 file + * model: a base64 string or a Uint8Array. The type is derived from the V3 file * stream part so it tracks the SDK: if a future provider version widens it (e.g. * a URL), `buildStepResult`'s base64 decode path stops type-checking and forces * us to handle the new shape rather than silently corrupting it. @@ -84,7 +84,7 @@ export interface RawResponseMetadata { * Minimal aggregates needed to reconstruct a `StepResult` outside the step * boundary. By returning only these fields (instead of a fully-populated * StepResult), we avoid serializing the redundant copies the AI SDK keeps - * in StepResult — `toolCalls`/`dynamicToolCalls`/`staticToolCalls`, + * in StepResult: `toolCalls`/`dynamicToolCalls`/`staticToolCalls`, * `content`, `reasoningText`, the always-empty `*ToolResults` arrays, the * dual base64+uint8Array file encoding, and `request.body` (a JSON dump of * the input prompt). The caller reconstructs the full StepResult from @@ -379,7 +379,7 @@ export async function doStreamStep( chunk.providerMetadata as SharedV3ProviderOptions; } } else { - // Delta without a preceding start — still collect it + // Delta without a preceding start, so still collect it reasoningById.set(chunk.id, { text: chunk.delta, providerMetadata: chunk.providerMetadata as diff --git a/packages/ai/src/agent/durable-agent.ts b/packages/ai/src/agent/durable-agent.ts index 5b9dc33f69..383a2c9e15 100644 --- a/packages/ai/src/agent/durable-agent.ts +++ b/packages/ai/src/agent/durable-agent.ts @@ -575,7 +575,7 @@ export interface DurableAgentStreamOptions< /** * Whether to include raw chunks from the provider in the stream. * When enabled, you will receive raw chunks with type 'raw' that contain the unprocessed data from the provider. - * This allows access to cutting-edge provider features not yet wrapped by the AI SDK. + * This allows access to provider features not yet wrapped by the AI SDK. * Defaults to false. */ includeRawChunks?: boolean; @@ -763,7 +763,7 @@ export interface DurableAgentStreamResult< * * DurableAgent enables you to create AI-powered agents that can maintain state * across workflow steps, call tools, and gracefully handle interruptions and resumptions. - * It integrates seamlessly with the AI SDK and the Workflow SDK for + * It integrates with the AI SDK and the Workflow SDK for * production-grade reliability. * * @example @@ -1082,7 +1082,7 @@ export class DurableAgent { // Further split non-provider tool calls into executable (has execute function) // and client-side (no execute function, needs external resolution) - // Note: missing tools (!tool) are left to executeTool which will throw — + // Note: missing tools (!tool) are left to executeTool which will throw; // only tools that exist but lack execute are treated as client-side. const executableToolCalls = nonProviderToolCalls.filter((tc) => { const tool = (effectiveTools as ToolSet)[tc.toolName]; @@ -1789,7 +1789,7 @@ async function executeTool( attributes: { 'ai.toolCall.name': toolCall.toolName, 'ai.toolCall.id': toolCall.toolCallId, - // Gate input recording on recordOutputs (AI SDK convention — tool args + // Gate input recording on recordOutputs (AI SDK convention: tool args // are considered "output" of the model, not user input) ...(telemetry?.recordOutputs !== false && { 'ai.toolCall.args': toolCall.input, diff --git a/packages/ai/src/agent/telemetry.ts b/packages/ai/src/agent/telemetry.ts index 3f702729e8..5e4b4d1cd7 100644 --- a/packages/ai/src/agent/telemetry.ts +++ b/packages/ai/src/agent/telemetry.ts @@ -38,7 +38,7 @@ interface OtelApi { SpanStatusCode: { ERROR: number }; } -// Lazy-loaded OTel API — self-initializes on first use (item 5) +// Lazy-loaded OTel API: self-initializes on first use (item 5) let otelApi: OtelApi | null = null; let otelLoadAttempted = false; @@ -46,7 +46,7 @@ async function ensureOtelApi(): Promise { if (otelLoadAttempted) return otelApi; otelLoadAttempted = true; try { - // Dynamic import — @opentelemetry/api is an optional peer dependency. + // Dynamic import, since @opentelemetry/api is an optional peer dependency. // Use Function() to hide the import from bundlers that would fail at // compile time when the package is absent. otelApi = await (Function( @@ -171,7 +171,7 @@ export async function recordSpan(options: { attributes?: Attributes; fn: (span?: Span) => PromiseLike | T; }): Promise { - // Self-initialise on first call (item 5) + // Self-initialize on first call (item 5) if (!otelLoadAttempted) { await ensureOtelApi(); } diff --git a/packages/ai/src/agent/types.ts b/packages/ai/src/agent/types.ts index c2232d8b6c..b688681b07 100644 --- a/packages/ai/src/agent/types.ts +++ b/packages/ai/src/agent/types.ts @@ -6,6 +6,6 @@ import type { LanguageModelV3 } from '@ai-sdk/provider'; /** * Language model type for AI SDK V3. * - * This is a simple alias for LanguageModelV3 from @ai-sdk/provider. + * This aliases LanguageModelV3 from @ai-sdk/provider. */ export type CompatibleLanguageModel = LanguageModelV3; diff --git a/packages/ai/src/normalize-ui-message-stream.ts b/packages/ai/src/normalize-ui-message-stream.ts index f96772e5fa..a650e788f6 100644 --- a/packages/ai/src/normalize-ui-message-stream.ts +++ b/packages/ai/src/normalize-ui-message-stream.ts @@ -75,7 +75,7 @@ function* repairPart( * - The same stream is read across reconnects, and a stream-producing step can * run more than once (retry/redelivery, or the concurrent-worker duplication * tracked in vercel/workflow#2331 and #2039). Either can interleave or - * duplicate chunks on the shared stream — e.g. a `finish-step` landing in the + * duplicate chunks on the shared stream, e.g. a `finish-step` landing in the * middle of another execution's text part. * * Since the content is still flowing and only the framing is damaged, repairing diff --git a/packages/ai/src/providers/mock.ts b/packages/ai/src/providers/mock.ts index 0eed83217c..ecd9807a31 100644 --- a/packages/ai/src/providers/mock.ts +++ b/packages/ai/src/providers/mock.ts @@ -13,7 +13,7 @@ export type MockResponseDescriptor = /** * Mock model that returns a fixed text response. * Same 'use step' pattern as real providers (anthropic, openai, etc.). - * Only captures `text` (string) — fully serializable across step boundary. + * Only captures `text` (string), which is fully serializable across step boundary. */ export function mockTextModel(text: string) { return async () => { @@ -56,7 +56,7 @@ export function mockTextModel(text: string) { /** * Mock model that plays through a sequence of responses. * Determines which response to return by counting assistant messages in the prompt. - * Only captures `responses` (array of plain objects) — fully serializable. + * Only captures `responses` (array of plain objects), which is fully serializable. */ export function mockSequenceModel(responses: MockResponseDescriptor[]) { return async () => { diff --git a/packages/ai/src/stream-iterator.ts b/packages/ai/src/stream-iterator.ts index 7e6975ee86..983c6b3e6e 100644 --- a/packages/ai/src/stream-iterator.ts +++ b/packages/ai/src/stream-iterator.ts @@ -6,7 +6,7 @@ const isBrowser = typeof window !== 'undefined'; * pull→enqueue loop (common when replaying buffered data on reconnect) * starves the event loop and blocks paint until the stream ends. * - * Only applies in browser environments — server-side consumers skip + * Only applies in browser environments: server-side consumers skip * the yield since there is no paint to unblock. */ const yieldToMacrotask = (): Promise | void => diff --git a/packages/ai/src/workflow-chat-transport.ts b/packages/ai/src/workflow-chat-transport.ts index 6a06ff6c04..ad035ef5e0 100644 --- a/packages/ai/src/workflow-chat-transport.ts +++ b/packages/ai/src/workflow-chat-transport.ts @@ -20,13 +20,13 @@ import { iteratorToStream, streamToIterator } from './stream-iterator.js'; * `tool-input-delta` (and the matching `*-end`) when the start chunk for that * id was never observed, and on tool output/approval chunks when no tool part * exists for the call id. A negative `startIndex` on a flat chunk stream can - * easily land mid-part, so without this guard the client crashes on resume. + * can land mid-part, so without this guard the client crashes on resume. * * A tool part is established by `tool-input-start` OR by a self-contained * `tool-input-available`/`tool-input-error` chunk (the AI SDK creates the * part from those directly), so all three mark the call id as seen. * - * This is a best-effort safety net — it preserves only the parts that the + * This is a best-effort safety net: it preserves only the parts that the * resumed window includes a `*-start` for. Server-side rewinding to a step * boundary is the proper fix when you want the full message preserved. */ @@ -437,7 +437,7 @@ export class WorkflowChatTransport // the middle of a `*-start` / `*-delta` / `*-end` sequence, which crashes // the AI SDK UI stream processor. The orphan filter drops chunks whose // start chunk was emitted before the resume window. Only activated for - // negative resumes — non-negative startIndex is the caller's explicit + // negative resumes, since non-negative startIndex is the caller's explicit // choice and we trust them. See: https://github.com/vercel/workflow/issues/1835 const orphanFilter = useExplicitStartIndex && explicitStartIndex < 0 @@ -476,7 +476,7 @@ export class WorkflowChatTransport // Resolve: e.g. tailIndex=499, startIndex=-20 → 500 + (-20) = 480 chunkIndex = Math.max(0, tailIndex + 1 + explicitStartIndex); } else { - // Header missing or unparseable — fall back to replaying from the + // Header missing or unparseable, so fall back to replaying from the // beginning so retries don't resume from a wrong position. console.warn( '[WorkflowChatTransport] Negative initialStartIndex is configured ' + diff --git a/packages/builders/README.md b/packages/builders/README.md index 62b2a11a41..c77a822ab9 100644 --- a/packages/builders/README.md +++ b/packages/builders/README.md @@ -10,7 +10,7 @@ This package contains the core build logic for transforming workflow source file - `@workflow/next` - For Next.js integration - `@workflow/nitro` - For Nitro/Nuxt integration -## Key Components +## Key components - **BaseBuilder**: Abstract base class providing common build logic - **Build plugins**: esbuild plugins for workflow transformations diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 9877abffc3..df00419c8c 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -165,8 +165,8 @@ async function withRealpaths(entries: string[]): Promise { * virtual-entry imports. * * If the file resolves to a real package specifier (`workflow/internal/builtins`, - * `@internal/agent/server`, etc.), we return the bare specifier — version - * stripped — because esbuild's package resolution will collapse all + * `@internal/agent/server`, etc.), we return the bare specifier (version + * stripped) because esbuild's package resolution will collapse all * importers of that specifier to the same physical module regardless of * which on-disk copy (src vs dist) any one importer wrote. * @@ -513,7 +513,7 @@ export abstract class BaseBuilder { // If workflow constructs live in sub-paths (e.g. `my-pkg/workflows`), // they won't be detected here. The @workflow/serde dep check above // partially covers serde cases. This is acceptable as a best-effort - // heuristic — the primary fix is auto-removal in withWorkflow(). + // heuristic; the primary fix is auto-removal in withWorkflow(). let hasUseStep = false; let hasUseWorkflow = false; let hasSerde = hasWorkflowSerdeDep; @@ -576,7 +576,7 @@ export abstract class BaseBuilder { // discovers classes like `Run` that live inside SDK packages. Without this, // files like `run.js` are only discovered when user code imports them. // This is resolved here (rather than in callers) so that the original - // `inputs` array reference is preserved for WeakMap caching — callers + // `inputs` array reference is preserved for WeakMap caching: callers // like createWorkflowsBundle and createStepsBundle can share the same // cache entry when they pass the same inputFiles array. const resolvedWorkflowRuntime = await enhancedResolve( @@ -1042,7 +1042,7 @@ export const __steps_registered = true; // Only use relative source paths for workspace symlinks (files // outside node_modules in a packages/*/src/ directory). For tarball- // installed packages (files inside node_modules/), fall through to - // getImportPath which returns package specifiers — this allows the + // getImportPath which returns package specifiers; this allows the // SWC plugin's externalizeNonSteps to work correctly. const isWorkspaceSourceBackedPackageFile = normalizedWorkspaceFile.includes('/packages/') && @@ -1101,7 +1101,7 @@ export const __steps_registered = true; // import lines that resolve to the same physical module. Pre-seed the // set with the built-in steps import so a workspace step file at // `packages/workflow/src/internal/builtins.ts` doesn't emit a second, - // relative-path competing import — esbuild would otherwise transform + // relative-path competing import; esbuild would otherwise transform // both copies and the swc plugin would generate duplicate step IDs. const emittedImportIdentities = new Set([builtInSteps]); const buildImports = (files: string[]): string => @@ -1369,7 +1369,7 @@ export const __steps_registered = true; .join('\n'); // The SWC plugin in workflow mode emits `globalThis.__private_workflows.set(workflowId, fn)` - // calls directly, so we just need to import the files (Map is initialized via banner) + // calls directly, so we only need to import the files (Map is initialized via banner) const workflowImports = buildImports(workflowFiles); // Include serde-only files for class registration side effects @@ -1408,7 +1408,7 @@ export const __steps_registered = true; treeShaking: true, keepNames: true, minify: false, - // Initialize the workflow registry at the very top of the bundle + // Initialize the workflow registry at the beginning of the bundle // This must be in banner (not the virtual entry) because esbuild's bundling // can reorder code, and the .set() calls need the Map to exist first banner: { @@ -1725,7 +1725,7 @@ ${createWorkflowRouteHandlersCode(`workflowEntrypoint(workflowCode${workflowEntr sourceStepRegistrationImports, tsconfigPath, discoveredEntries: effectiveDiscoveredEntries, - // Skip the createRequire banner here — when bundleFinalOutput is true + // Skip the createRequire banner here: when bundleFinalOutput is true // the outer esbuild pass will inline this bundle and add its own // banner. Emitting it twice declares __createRequire twice. skipEsmRequireBanner: bundleFinalOutput, @@ -2056,7 +2056,7 @@ export const HEAD = handler; export const OPTIONS = handler;`; if (!bundle) { - // For Next.js, just write the unbundled file + // For Next.js, write the unbundled file await writeFileIfChanged(outfile, routeContent); return; } diff --git a/packages/builders/src/constants.ts b/packages/builders/src/constants.ts index a884bc7332..cc93b3f9eb 100644 --- a/packages/builders/src/constants.ts +++ b/packages/builders/src/constants.ts @@ -101,7 +101,7 @@ export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger(); /** * Returns the queue trigger configuration for workflow (flow) routes. * - * Builds on `createWorkflowQueueTrigger()` — the namespace comes from + * Builds on `createWorkflowQueueTrigger()`: the namespace comes from * `options` or `WORKFLOW_QUEUE_NAMESPACE`, resolved at call time. When * `WORKFLOW_SEQUENTIAL_REPLAYS` is enabled, sets `maxConcurrency: 1` so the * queue processes at most one flow invocation per concrete topic at a time. @@ -111,7 +111,7 @@ export const WORKFLOW_QUEUE_TRIGGER = createWorkflowQueueTrigger(); * trigger rather than using a separate route. * * Integrations that write their own flow trigger config instead of calling - * this must mirror the conditional `maxConcurrency: 1` themselves — the + * this must mirror the conditional `maxConcurrency: 1` themselves, since the * runtime half (per-run topics) activates from the env var alone, and without * the trigger half those topics are not serialized. * diff --git a/packages/builders/src/fast-discovery.ts b/packages/builders/src/fast-discovery.ts index f4eecfc520..03a90ee276 100644 --- a/packages/builders/src/fast-discovery.ts +++ b/packages/builders/src/fast-discovery.ts @@ -65,7 +65,7 @@ interface FastDiscoverEntriesOptions { /** * Whether workflow discovery descends into `node_modules`. When `false`, * imports from application code that resolve into `node_modules` are not - * followed, so no dependency file is read, scanned, or registered — third + * followed, so no dependency file is read, scanned, or registered: third * party workflow/step/serde code is neither transformed nor bundled. Imports * *within* `node_modules` are still followed, so the SDK's own seeded runtime * serde entry keeps discovering its transitive classes. Defaults to `true`. diff --git a/packages/builders/src/module-specifier.ts b/packages/builders/src/module-specifier.ts index 57d88867b0..82483deebe 100644 --- a/packages/builders/src/module-specifier.ts +++ b/packages/builders/src/module-specifier.ts @@ -411,7 +411,7 @@ export function resolveModuleSpecifier( * `@workflow/ai/agent@5.0.0-beta.5` as the same logical module). * * Colocated with `resolveModuleSpecifier` so the construction and parsing - * stay in sync — see the `${pkg.name}${subpath}@${pkg.version}` and + * stay in sync; see the `${pkg.name}${subpath}@${pkg.version}` and * `${pkg.name}@${pkg.version}` paths above. */ export function stripPackageVersion(specifier: string): string { diff --git a/packages/builders/src/node-module-esbuild-plugin.ts b/packages/builders/src/node-module-esbuild-plugin.ts index 0382ab7a29..fedb09ddad 100644 --- a/packages/builders/src/node-module-esbuild-plugin.ts +++ b/packages/builders/src/node-module-esbuild-plugin.ts @@ -308,8 +308,8 @@ export function createNodeModuleErrorPlugin(): esbuild.Plugin { // Enable the esbuild metafile so we can inspect which Node.js / Bun // built-in imports actually survive tree-shaking. We need this to // suppress false positives where a shared module has a workflow-safe - // export alongside a step-only export that references Node.js builtins - // — esbuild's tree-shaker can drop the step-only branch, but `onResolve` + // export alongside a step-only export that references Node.js builtins. + // esbuild's tree-shaker can drop the step-only branch, but `onResolve` // fires before tree-shaking, so we defer the final error decision to // `onEnd` (see the metafile-based filter below). build.initialOptions.metafile = true; diff --git a/packages/builders/src/optional-otel-api.ts b/packages/builders/src/optional-otel-api.ts index 79f9d70b95..2ac7d2e2a1 100644 --- a/packages/builders/src/optional-otel-api.ts +++ b/packages/builders/src/optional-otel-api.ts @@ -4,7 +4,7 @@ * isn't installed. Rollup/Vite (e.g. SvelteKit's build) treat an unresolvable * static `import('@opentelemetry/api')` as a fatal error when the peer is * absent, so the framework integrations mark this specifier **external only - * when it can't be resolved** (they do NOT alias it to an empty stub — that + * when it can't be resolved** (they do NOT alias it to an empty stub, which * would permanently disable tracing). When the peer IS installed it resolves * and bundles normally, which matters for self-contained outputs (Nitro's * `.output/server`, esbuild) that ship no node_modules and would otherwise diff --git a/packages/builders/src/optional-typescript.ts b/packages/builders/src/optional-typescript.ts index b8261b2ea8..2c7508cef6 100644 --- a/packages/builders/src/optional-typescript.ts +++ b/packages/builders/src/optional-typescript.ts @@ -1,7 +1,7 @@ // Stub aliased in place of the `typescript` package in framework server // bundles. It is only reachable through cosmiconfig's TS-config loader // (via world packages -> graphile-worker), where `require('typescript')` -// is lazy and never fires at runtime — but bundling converts it into an +// is lazy and never fires at runtime, but bundling converts it into an // eager top-level evaluation, pulling the entire compiler into the server // output and executing it at boot. const typescript = {}; diff --git a/packages/builders/src/optional-ws-native.ts b/packages/builders/src/optional-ws-native.ts index 031f43356e..74f70a8b38 100644 --- a/packages/builders/src/optional-ws-native.ts +++ b/packages/builders/src/optional-ws-native.ts @@ -7,7 +7,7 @@ * Marking them external keeps that fallback reachable. Two bundlers take it away * without changing the build's outcome, so the damage only shows up at runtime, * on the first frame big enough to reach the native masker (`ws` uses pure JS - * below 48 bytes — small-payload smoke tests pass; CBOR event frames don't): + * below 48 bytes, so small-payload smoke tests pass; CBOR event frames don't): * * - **webpack** bundles the JS wrapper without its native `.node` binding. * - **Vite** resolves the absent peer to its own `optional-peer-dep` stub, so diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index 9b18bc3bba..569f85e0ba 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -50,7 +50,7 @@ export interface SwcPluginOptions { * ESM loader (e.g. vitest), which cannot resolve .ts extensions. * * Leave disabled (default) when a downstream bundler (webpack, Vite, etc.) - * handles resolution — those tools resolve .ts natively and rewriting + * handles resolution: those tools resolve .ts natively and rewriting * breaks them because the .js file doesn't exist on disk. */ rewriteTsExtensions?: boolean; @@ -237,7 +237,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { } // When only sideEffectEntries is set (no entriesToBundle), we only - // need to override sideEffects for top-level bare imports — typically + // need to override sideEffects for top-level bare imports, typically // from the virtual entry. Skip resolution for transitive imports // (dynamic imports, requires, etc.) to avoid unnecessary overhead. if (!options.entriesToBundle && args.kind !== 'import-statement') { @@ -423,7 +423,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { }; } - // No entriesToBundle — only override sideEffects when needed. + // No entriesToBundle, so only override sideEffects when needed. // We must return the resolved `path` alongside `sideEffects` because // returning only `{ sideEffects: true }` without a path causes esbuild // to fall through to its own resolver, which re-reads the package.json @@ -488,7 +488,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { ).replace(/\\/g, '/'); // Handle files discovered outside the working directory - // These come back as ../path/to/file, but we want just path/to/file + // These come back as ../path/to/file, but we want path/to/file if (relativeFilepath.startsWith('../')) { const aliasedRelativePath = await resolveWorkflowAliasRelativePath(args.path, workingDir); @@ -508,7 +508,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { relativeFilepath.includes(':') || relativeFilepath.startsWith('/') ) { - // This should never happen, but if it does, use just the filename as last resort + // This should never happen, but if it does, use the filename as a last resort console.error( `[ERROR] relativeFilepath is still absolute: ${relativeFilepath}` ); diff --git a/packages/builders/src/types.ts b/packages/builders/src/types.ts index e127cc4ef6..de8c423cc3 100644 --- a/packages/builders/src/types.ts +++ b/packages/builders/src/types.ts @@ -44,7 +44,7 @@ interface BaseWorkflowConfig { // Optionally generate a client library for workflow execution. The preferred // method of using workflow is to use a loader within a framework (like - // NextJS) that resolves client bindings on the fly. + // Next.js) that resolves client bindings on the fly. clientBundlePath?: string; externalPackages?: string[]; @@ -100,8 +100,8 @@ interface BaseWorkflowConfig { * Default is `'inline'` for the step bundle and intermediate workflow * bundle (gives readable stack traces for step errors and workflow VM * errors). Setting `false` omits source maps entirely, which produces - * smaller bundles — useful for staying under the Vercel 250MB function - * limit — at the cost of stack traces that reference generated code. + * smaller bundles (useful for staying under the Vercel 250MB function + * limit) at the cost of stack traces that reference generated code. * * `'external'` and `'linked'` write a separate `.map` file; use these * when you want to ship source maps to observability tooling but keep @@ -118,7 +118,7 @@ interface BaseWorkflowConfig { * `workflow`/`@workflow/*` dependency are discovered and compiled into the * app's bundles. * - * Set to `false` to opt out — imports from your application code that resolve + * Set to `false` to opt out: imports from your application code that resolve * into `node_modules` are not followed, so the build never reads, scans, or * descends into dependency file graphs. This skips the cost of scanning * `node_modules` and stops third-party workflow/step/serde code from being diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index b8d38cce5d..f861830164 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -39,7 +39,7 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { await this.createVcConfig(workflowsFuncDir, { handler: 'index.mjs', // Skip the source-map-support runtime shim when sourcemaps are - // disabled — it's a meaningful chunk of the function bundle and + // disabled: it's a meaningful chunk of the function bundle and // serves no purpose without maps. shouldAddSourcemapSupport: this.sourcemapsEnabled, maxDuration: 'max', diff --git a/packages/cli/src/lib/bulk-cancel.ts b/packages/cli/src/lib/bulk-cancel.ts index 4e4cb49b9d..31869e5b8e 100644 --- a/packages/cli/src/lib/bulk-cancel.ts +++ b/packages/cli/src/lib/bulk-cancel.ts @@ -28,8 +28,8 @@ export const CANCELLABLE_STATUSES = [ /** * Guidance printed when more runs match the filters than were fetched. Bulk - * cancel is deliberately single-batch and user-paced — no cursors or async - * jobs — so the user re-runs to cancel the next batch. + * cancel is deliberately single-batch and user-paced (no cursors or async + * jobs), so the user re-runs to cancel the next batch. */ export const HAS_MORE_GUIDANCE = 'More runs match these filters. Re-run this command to cancel the next batch,\n' + @@ -164,7 +164,7 @@ export async function performBulkCancel( } // The analytics backend defaults its listing to a recent window // (trailing 24h on the Vercel backend), but bulk cancel must match - // across the plan's whole observability window — a run can sleep or + // across the plan's whole observability window: a run can sleep or // wait on a hook for days without emitting recent events. Probe for // the plan window bounds first, then match across them. const probe = await analytics.runs.list({ diff --git a/packages/cli/src/lib/config/workflow-config.ts b/packages/cli/src/lib/config/workflow-config.ts index 6fb2d32698..03a273b989 100644 --- a/packages/cli/src/lib/config/workflow-config.ts +++ b/packages/cli/src/lib/config/workflow-config.ts @@ -31,7 +31,7 @@ export const getWorkflowConfig = ( webhookBundlePath: './.well-known/workflow/v1/webhook.mjs', workflowManifestPath: workflowManifest, - // WIP: generate a client library to easily execute workflows/steps + // WIP: generate a client library to execute workflows/steps // clientBundlePath: './lib/generated/workflows.js', }; return config; diff --git a/packages/cli/src/lib/inspect/auth.ts b/packages/cli/src/lib/inspect/auth.ts index 7757139fa9..c58cdfacad 100644 --- a/packages/cli/src/lib/inspect/auth.ts +++ b/packages/cli/src/lib/inspect/auth.ts @@ -47,7 +47,7 @@ export async function getAuthToken(): Promise { return credentials.token; } - // Token is expired — attempt refresh + // Token is expired, so attempt refresh if (!credentials.refreshToken) { logger.debug('Auth token expired and no refresh token available'); return null; diff --git a/packages/cli/src/lib/inspect/env.ts b/packages/cli/src/lib/inspect/env.ts index 7630ec990d..251fccda90 100644 --- a/packages/cli/src/lib/inspect/env.ts +++ b/packages/cli/src/lib/inspect/env.ts @@ -18,7 +18,7 @@ import { * Used by the CLI to configure environment variables that are read by * various subsystems (e.g., WORKFLOW_TARGET_WORLD, WORKFLOW_LOCAL_DATA_DIR). * Note: WORKFLOW_VERCEL_* env vars are read back via getEnvVars() and passed - * to @workflow/world-vercel's createWorld() explicitly — they are NOT read by runtime createWorld(). + * to @workflow/world-vercel's createWorld() explicitly; they are NOT read by runtime createWorld(). */ export const writeEnvVars = (envVars: Record) => { Object.entries(envVars).forEach(([key, value]) => { @@ -147,7 +147,7 @@ export const inferLocalWorldEnvVars = async () => { } } - // It's okay if manifest is not found - the web UI will just show empty workflows + // If the manifest is not found, the web UI will show no workflows. if (!envVars.WORKFLOW_MANIFEST_PATH) { logger.debug( 'No workflow manifest found. Workflows tab will be empty.' @@ -236,7 +236,7 @@ export const inferVercelEnvVars = async (): Promise => { } // Fetch team information from Vercel API to get the team slug - // TODO: Sadly, in order to redirect to Vercel dashboard correctly, we need to + // TODO: To redirect to the Vercel dashboard correctly, we need to // resolve the team name, which is a whole API request. The alternative would be to // change front to allow passing in the team slug directly, or add some generic redirect. if (envVars.WORKFLOW_VERCEL_TEAM && envVars.WORKFLOW_VERCEL_AUTH_TOKEN) { diff --git a/packages/cli/src/lib/inspect/hydration.ts b/packages/cli/src/lib/inspect/hydration.ts index eb043aa7d2..6b72523516 100644 --- a/packages/cli/src/lib/inspect/hydration.ts +++ b/packages/cli/src/lib/inspect/hydration.ts @@ -24,7 +24,7 @@ import chalk from 'chalk'; /** * A function that resolves an encryption key for a run, or null to skip - * decryption. Accepts a runId — the resolver is responsible for looking + * decryption. Accepts a runId; the resolver is responsible for looking * up the WorkflowRun internally (with caching) if the World needs it. */ export type EncryptionKeyResolver = @@ -167,7 +167,7 @@ const ERROR_REVIVER_KEYS = [ * non-enumerable `toJSON` method. The runtime revivers return real `Error` * instances (good for `util.inspect`, `instanceof`, `toString`, etc.), but * `Error.prototype`'s `name` / `message` / `stack` / `cause` are - * non-enumerable and would be dropped by `JSON.stringify` — which is how + * non-enumerable and would be dropped by `JSON.stringify`, which is how * the CLI emits its `--json` output. Adding `toJSON` (which `JSON.stringify` * calls but `util.inspect` ignores) gives us the best of both worlds: * round-tripped errors render cleanly in both modes without the @@ -216,7 +216,7 @@ function wrapErrorReviverWithToJSON( * `RetryableError`, the built-in `Error` subclasses) would silently * disappear from CLI output: devalue throws "Unknown type X" for * unrecognized reduced types, and `hydrateResourceIO` swallows that error - * and surfaces the raw `Uint8Array` payload to consumers — which then + * and surfaces the raw `Uint8Array` payload to consumers, which then * shows up as `step.error` / `run.error` byte dumps instead of usable * `{ message, stack, … }` objects. * @@ -243,7 +243,7 @@ export function getCLIRevivers(): Revivers { // O11y-specific revivers (streams, step functions → display objects). ...observabilityRevivers, // Node `Request` / `Response` revivers that don't rely on running an - // actual fetch handler — used to render request/response IO inline. + // actual fetch handler, used to render request/response IO inline. Request: (value) => { // biome-ignore lint/complexity/useArrowFunction: arrow functions have no .prototype const ctor = { Request: function () {} }.Request!; @@ -372,7 +372,7 @@ async function maybeDecryptFields< throw err; } - // Decryption failed (bad key, corrupted ciphertext, etc.) — fall back + // Decryption failed (bad key, corrupted ciphertext, etc.), so fall back // to showing encrypted placeholders instead of crashing the CLI. const { logger } = await import('../config/log.js'); logger.warn(`Decryption failed for resource ${runId}: ${message}`); diff --git a/packages/cli/src/lib/inspect/output.ts b/packages/cli/src/lib/inspect/output.ts index 3785b9ca16..4447d0d2bd 100644 --- a/packages/cli/src/lib/inspect/output.ts +++ b/packages/cli/src/lib/inspect/output.ts @@ -34,7 +34,7 @@ import { resolveTimeWindow } from './time-window.js'; /** * Create an EncryptionKeyResolver from a World instance. - * Returns null if decrypt is false — encrypted data will show as a placeholder. + * Returns null if decrypt is false; encrypted data will show as a placeholder. * * The resolver fetches the full WorkflowRun (cached per runId) so that the * World can inspect deployment-specific fields for key resolution. @@ -463,7 +463,7 @@ const showJson = (data: unknown) => { * Defensively evaluate `World.describeRun` for one run row. * * The interface contract says implementations are pure and must not - * throw — but it is an external extension point, so the CLI does not + * throw, but it is an external extension point, so the CLI does not * trust that: a throwing implementation contributes no fields instead * of crashing the command. Keys that already exist on the run row are * dropped so a world can never overwrite canonical fields (`status`, @@ -476,7 +476,7 @@ const safeWorldFields = async ( let fields: Record | null; try { // The hook may be sync or async; a rejection is treated the same as - // a throw — no fields. + // a throw: no fields. fields = await describeRun(row); } catch { return {}; @@ -571,7 +571,7 @@ export const hasExpiredData = (run: WorkflowRun): boolean => { /** * Checks if any data field in a hydrated resource has been replaced with an - * expired placeholder. Works for runs, steps, hooks, and events — unlike + * expired placeholder. Works for runs, steps, hooks, and events, unlike * `hasExpiredData` which only checks the run-level `expiredAt` field. */ const hasExpiredFields = (resource: Record): boolean => { @@ -630,7 +630,7 @@ const inlineFormatIO = (io: T, topLevel: boolean = true): string => { /** * List views surface metadata only. When the active backend exposes the * optional `world.analytics` read namespace we read list pages from it - * (metadata-only — no payload resolution); otherwise we fall back to the + * (metadata-only, no payload resolution); otherwise we fall back to the * runtime storage APIs with `resolveData: 'none'`. * * `--withData` forces the runtime path so payloads can be resolved into the @@ -749,7 +749,7 @@ export const listRuns = async (world: World, opts: InspectCLIOptions = {}) => { }; }; - // For JSON output, just fetch once and return + // For JSON output, fetch once and return if (opts.json) { try { const page = await fetchRunsPage(opts.cursor); @@ -792,7 +792,7 @@ export const getRecentRun = async ( try { const runs = await world.runs.list({ pagination: { limit: 1, sortOrder: opts.sort || 'desc' }, - resolveData: 'none', // Don't need data for just getting the ID + resolveData: 'none', // Don't need data when getting only the ID }); runs.data = await Promise.all( runs.data.map((r) => hydrateResourceIO(r, resolveKey)) @@ -820,7 +820,7 @@ export const showRun = async ( const run = await world.runs.get(runId, { resolveData: 'all' }); const hydrated = await hydrateResourceIO(run, resolveKey); // World-specific display fields (World.describeRun), evaluated - // defensively — see safeWorldFields. `null` field values are kept so + // defensively; see safeWorldFields. `null` field values are kept so // structured output distinguishes "undeterminable" from "hook absent". const worldFields = world.describeRun ? await safeWorldFields( @@ -930,7 +930,7 @@ export const listSteps = async ( }; }; - // For JSON output, just fetch once and return + // For JSON output, fetch once and return if (opts.json) { try { const page = await fetchStepsPage(opts.cursor); @@ -1040,7 +1040,7 @@ export const showStream = async ( const run = await world.runs.get(opts.runId!); const rawKey = await world.getEncryptionKeyForRun?.(run); // Full capability, so sealed ('encp') stream frames written by other - // runs are readable too — not just the run's own symmetric frames. + // runs are readable too, not just the run's own symmetric frames. return rawKey ? await deriveRunPayloadKeys(rawKey) : undefined; })(); } else if (opts.decrypt && !opts.runId) { @@ -1198,7 +1198,7 @@ export const listEvents = async ( }; }; - // For JSON output, just fetch once and return + // For JSON output, fetch once and return if (opts.json) { try { const page = await fetchEventsPage(opts.cursor); @@ -1276,7 +1276,7 @@ export const listHooks = async (world: World, opts: InspectCLIOptions = {}) => { }; }; - // For JSON output, just fetch once and return + // For JSON output, fetch once and return if (opts.json) { try { const page = await fetchHooksPage(opts.cursor); diff --git a/packages/cli/src/lib/inspect/run.ts b/packages/cli/src/lib/inspect/run.ts index 4799511499..6899b8c5e9 100644 --- a/packages/cli/src/lib/inspect/run.ts +++ b/packages/cli/src/lib/inspect/run.ts @@ -98,7 +98,7 @@ export const startRun = async ( specVersion = hc.specVersion; } } catch { - // Health check failed — use run's specVersion as fallback + // Health check failed, so use run's specVersion as fallback } const newRun = await start({ workflowId }, jsonArgs, { diff --git a/packages/cli/src/lib/inspect/vercel-link.ts b/packages/cli/src/lib/inspect/vercel-link.ts index 61de998061..da4a8d7944 100644 --- a/packages/cli/src/lib/inspect/vercel-link.ts +++ b/packages/cli/src/lib/inspect/vercel-link.ts @@ -23,12 +23,12 @@ interface RepoProjectConfig { id: string; name: string; directory: string; - /** Per-project orgId — added in vercel/vercel#14967. Prefer this over root-level orgId. */ + /** Per-project orgId, added in vercel/vercel#14967. Prefer this over root-level orgId. */ orgId?: string; } interface RepoProjectsConfig { - /** Legacy root-level orgId — older Vercel CLI versions put orgId here. */ + /** Legacy root-level orgId: older Vercel CLI versions put orgId here. */ orgId?: string; remoteName: string; projects: RepoProjectConfig[]; diff --git a/packages/cli/src/lib/inspect/web.ts b/packages/cli/src/lib/inspect/web.ts index a646073a54..d692790086 100644 --- a/packages/cli/src/lib/inspect/web.ts +++ b/packages/cli/src/lib/inspect/web.ts @@ -22,7 +22,8 @@ interface DashboardRegistryEntry { * Find embedded dashboards (e.g. a framework integration serving `/_workflow`) * that are currently live for this project. Reads the best-effort registry and * health-checks each entry, since stale entries are expected (a SIGKILL'd dev - * server can't clean up). Returns [] on any error — coordination is optional. + * server can't clean up). Returns [] on any error, since coordination is + * optional. */ async function findLiveEmbeddedDashboards(): Promise { let entries: DashboardRegistryEntry[]; @@ -53,7 +54,7 @@ async function findLiveEmbeddedDashboards(): Promise { // Any non-5xx response means something is serving that route. if (res.status < 500) live.push(entry); } catch { - // unreachable — treat as dead + // unreachable, so treat as dead } } return live; @@ -317,7 +318,7 @@ export function buildDeepLinkUrl( } /** - * Resolve and print a shareable deep-link URL to stdout, then return — without + * Resolve and print a shareable deep-link URL to stdout, then return, without * opening a browser or starting the local web server. Intended for scripting * and agents that need the link rather than a rendered dashboard. * diff --git a/packages/core/scripts/README.md b/packages/core/scripts/README.md index e42ae594ec..da48c9bdd9 100644 --- a/packages/core/scripts/README.md +++ b/packages/core/scripts/README.md @@ -1,10 +1,10 @@ # Compression benchmarks -Reproducible benchmarks for the gzip payload compression feature -(specVersion 5, PR adding the `gzip` serialization format prefix). Two -dimensions are measured: **storage size** (bytes saved) and **CPU cost** -(time added to serialize/deserialize). All workloads are shared and -deterministic — see `lib/workloads.mjs`. +These benchmarks measure the gzip payload compression feature +(specVersion 5, PR adding the `gzip` serialization format prefix). The benchmarks +measure two dimensions: **storage size** (bytes saved) and **CPU cost** +(time added to serialize/deserialize). Both benchmarks use the shared, +deterministic workloads in `lib/workloads.mjs`. Build `@workflow/core` first so the scripts can import the compiled serialization layer: @@ -20,11 +20,12 @@ cd packages/core node scripts/benchmark-compression-size.mjs ``` -Prints the exact bytes the serialization layer hands to the World storage -backends (S3/DynamoDB refs for vercel, `bytea` columns for postgres, JSON +The script prints the exact bytes the serialization layer hands to the World storage +backends (S3/DynamoDB refs for Vercel, `bytea` columns for Postgres, JSON files for local), compression off vs on, per workload, plus a simulated 10-step AI-agent event-log total. Backends that base64-encode binary -(DynamoDB inline refs, world-local JSON) see ~33% larger absolute savings +(DynamoDB inline refs, world-local JSON) see approximately 33% larger absolute +savings than the raw numbers. ## 2. CPU cost @@ -38,10 +39,10 @@ Three sections: 1. **Per-payload serialize + deserialize cost** through the real shipping path (`step.serialize` / `step.deserialize`, which use the Web `CompressionStream('gzip')`), off vs on, with throughput. -2. **Stress** — total serialization CPU to write + replay-read thousands - of event payloads, modelling a long workflow. -3. **Algorithm comparison** (`node:zlib` sync) — gzip levels 1/6/9, - brotli, deflate-raw — informational, to compare candidate codecs for a +2. **Stress**: Total serialization CPU to write and replay-read thousands + of event payloads, modeling a long workflow. +3. **Algorithm comparison** (`node:zlib` sync): Gzip levels 1/6/9, + Brotli, and deflate-raw. This comparison evaluates candidate codecs for a future format prefix (e.g. a `zsd1` zstd codec). Not the shipping path. Compression is a **world-independent CPU cost** added to the @@ -51,22 +52,22 @@ relative impact is largest there; Vercel (network + AES encryption + S3) has the slowest baseline so the relative impact is smallest. The absolute microbenchmark numbers hold for every backend. -## 3. End-to-end runtime (local + vercel) +## 3. End-to-end runtime (local + Vercel) The end-to-end benchmark runner (`packages/core/e2e/benchmark.test.ts`) drives the scenario workflows in `workbench/example/workflows/97_bench.ts` through a real World and records -core latency metrics — TTFS (time to first step), STSO (step-to-step -overhead), WO (workflow overhead), and SL (stream latency) — reported as -`avg`/`p50`/`p90`/`p99` and written to `bench-results--.json`. +core latency metrics, including TTFS (time to first step), STSO (step-to-step +overhead), WO (workflow overhead), and SL (stream latency). The runner reports +`avg`/`p50`/`p90`/`p99` and writes them to `bench-results--.json`. It requires `DEPLOYMENT_URL` (the running app) and `APP_NAME` (used in the -output filename). Iteration counts are tunable via `BENCH_*` env vars (see +output filename). You can tune iteration counts via `BENCH_*` env vars (see the file header). ```bash -# Local world (nextjs-turbopack dev server on :3000) +# Local World (nextjs-turbopack dev server on :3000) cd workbench/nextjs-turbopack && WORKFLOW_PUBLIC_MANIFEST=1 pnpm dev & -# from repo root +# From repo root DEPLOYMENT_URL=http://localhost:3000 APP_NAME=nextjs-turbopack pnpm bench ``` @@ -76,19 +77,19 @@ output JSON: once normally (compression on, specVersion 5) and once with bench runner (compression off, everything else identical): ```bash -# compression OFF baseline +# Compression-off baseline WORKFLOW_DISABLE_COMPRESSION=1 pnpm dev & # in the workbench -# from repo root +# From repo root WORKFLOW_DISABLE_COMPRESSION=1 \ DEPLOYMENT_URL=http://localhost:3000 APP_NAME=nextjs-turbopack pnpm bench mv bench-results-nextjs-turbopack-local.json bench-results-...-off.json ``` -For **Vercel**, the same runner targets a deployment when the Vercel env -vars from `CLAUDE.md` are set (`WORKFLOW_VERCEL_ENV`, `VERCEL_DEPLOYMENT_ID`, +For **Vercel**, the same runner targets a deployment when you set the Vercel env +vars from `CLAUDE.md` (`WORKFLOW_VERCEL_ENV`, `VERCEL_DEPLOYMENT_ID`, `WORKFLOW_VERCEL_AUTH_TOKEN`, `WORKFLOW_VERCEL_PROJECT`, `VERCEL_OIDC_TOKEN`, -etc.); the backend is then detected as `vercel` and it writes -`bench-results--vercel.json`. The `WORKFLOW_DISABLE_COMPRESSION=1` kill -switch must be set on the deployment (an env var on the Vercel project) for +etc.). The runner detects the backend as `vercel` and writes +`bench-results--vercel.json`. Set the `WORKFLOW_DISABLE_COMPRESSION=1` kill +switch on the deployment (an env var on the Vercel project) for the off baseline, since compression runs server-side in the step/workflow handlers there. diff --git a/packages/core/src/capabilities.ts b/packages/core/src/capabilities.ts index f670a7ebff..f991e07dc9 100644 --- a/packages/core/src/capabilities.ts +++ b/packages/core/src/capabilities.ts @@ -17,7 +17,7 @@ * * ## Adding a new non-format capability * - * Some capabilities aren't serialization format prefixes — e.g. + * Some capabilities aren't serialization format prefixes: e.g. * byte-stream wire framing is an envelope around chunks rather than * a content format. For those, add a boolean field to `RunCapabilities` * and an entry in `CAPABILITY_VERSION_TABLE` below. @@ -30,7 +30,7 @@ * - `framedByteStreams` (wire-level chunk framing for byte streams): added in `5.0.0-beta.15` * - `gzip` (gzip payload compression): added in `5.0.0-beta.18` * - `zstd` (zstd payload compression, preferred codec): added in `5.0.0-beta.18` - * alongside gzip — they co-ship, so any run that can read one can read both. + * alongside gzip; they co-ship, so any run that can read one can read both. * - `encp` (X25519 sealed-box encryption for cross-run writes): added in * `5.0.0-beta.37`. Note that producers do **not** gate `encp` on this table: * they gate on the presence of `encryptionPublicKey` on the target run, @@ -65,7 +65,7 @@ export interface RunCapabilities { * Whether the target run can decode wire-framed byte streams. When true, * byte streams (`type: 'bytes'` ReadableStreams passed across boundaries) * are wrapped in a length-prefixed frame envelope on the wire so the - * reader can identify chunk boundaries — which enables auto-reconnect + * reader can identify chunk boundaries, which enables auto-reconnect * on transient stream errors. When false, byte streams are written as * raw bytes (the legacy format) for compatibility with older runs. */ @@ -87,7 +87,7 @@ const FORMAT_VERSION_TABLE: ReadonlyArray<{ // bump to the next beta. A too-low cutoff makes new producers write // compressed payloads to consumers that cannot decompress them; too-high // merely delays the optimization (safe). gzip and zstd ship together, so - // they share a min version — a run that can read one can read both. + // they share a min version: a run that can read one can read both. { format: SerializationFormat.GZIP, minVersion: '5.0.0-beta.18' }, { format: SerializationFormat.ZSTD, minVersion: '5.0.0-beta.18' }, // TODO(release): verify this matches the actual version that ships sealed-box @@ -137,7 +137,7 @@ const BASELINE_FORMATS: ReadonlySet = new Set([ * its `@workflow/core` version string (from `executionContext.workflowCoreVersion`). * * When the version is `undefined`, not a string, or not a valid semver string - * (e.g. very old runs that predate the field, or corrupted metadata), + * (e.g. older runs that predate the field, or corrupted metadata), * we assume the most conservative capabilities (baseline formats only, * non-format capabilities all `false`). */ diff --git a/packages/core/src/capture-stack.ts b/packages/core/src/capture-stack.ts index a17fb73d1a..5cac90b3c0 100644 --- a/packages/core/src/capture-stack.ts +++ b/packages/core/src/capture-stack.ts @@ -5,7 +5,7 @@ * code frame at our `throw` site inside `@workflow/core`, which is useless * to the user. * - * No-op on engines that don't expose `Error.captureStackTrace` — the stack + * No-op on engines that don't expose `Error.captureStackTrace`: the stack * degrades gracefully to the default behavior. * * Kept in its own tiny module so callers that can't participate in the diff --git a/packages/core/src/class-serialization.ts b/packages/core/src/class-serialization.ts index b8b7edd6d7..aac4d21ebc 100644 --- a/packages/core/src/class-serialization.ts +++ b/packages/core/src/class-serialization.ts @@ -52,31 +52,31 @@ export function registerSerializationClass(classId: string, cls: Function) { } /** - * Stable, well-known registry id for the SDK's `Run` class. + * Stable, well-known registry ID for the SDK's `Run` class. * - * The SWC plugin auto-registers `Run` under a *path-derived* id (e.g. + * The SWC plugin auto-registers `Run` under a *path-derived* ID (e.g. * `class//./node_modules/@workflow/core/dist/runtime/run//Run`), which * varies with the app's dependency layout and bundler. Host-side code * that needs to construct `Run` instances inside the workflow VM (e.g. * the hook event consumer resolving `hook.getConflict()`) cannot know - * that id statically, so the workflow-mode `create-hook` module also - * aliases the bundle's `Run` under this stable id at evaluation time. + * that ID statically, so the workflow-mode `create-hook` module also + * aliases the bundle's `Run` under this stable ID at evaluation time. * - * The `workflow` pseudo-path cannot collide with plugin-derived ids, + * The `workflow` pseudo-path cannot collide with plugin-derived IDs, * which always use real relative module paths (`./…` / `../…`). */ export const RUN_CLASS_ID = 'class//workflow//Run'; /** - * Register an additional registry id for a class without touching its + * Register an additional registry ID for a class without touching its * `classId` property. * * Unlike {@link registerSerializationClass}, this is safe to call for a * class the SWC plugin has already registered: the plugin's inlined IIFE * defines `classId` as non-configurable, so a second `defineProperty` - * would throw. Aliasing only adds a registry entry — the class keeps + * would throw. Aliasing only adds a registry entry: the class keeps * serializing under its primary (path-derived) id, while lookups succeed - * under both. + * under both IDs. * * Registration is per-global by construction: evaluated inside the * workflow VM it registers the VM's compiled class on the VM's registry; diff --git a/packages/core/src/classify-error.ts b/packages/core/src/classify-error.ts index 1e606d6b4f..08af3686f5 100644 --- a/packages/core/src/classify-error.ts +++ b/packages/core/src/classify-error.ts @@ -22,7 +22,7 @@ const WORLD_CONTRACT_ERROR_CODES = new Set([ /** * `WorkflowWorldError.code` values that mark a transient transport failure * (set by world-vercel's HTTP client): `TRANSPORT` covers an exhausted - * RetryAgent (`UND_ERR_REQ_RETRY` — e.g. the firewall in front of + * RetryAgent (`UND_ERR_REQ_RETRY`, e.g. the firewall in front of * workflow-server shedding load with 429/503), a dropped socket, or a * connect/DNS failure; `TIMEOUT` covers a request that exceeded the client * timeout. Both are infrastructure failures a fresh invocation can recover @@ -34,7 +34,7 @@ const RETRYABLE_WORLD_ERROR_CODES = new Set(['TRANSPORT', 'TIMEOUT']); /** * Set of error names that should classify as generic `RUNTIME_ERROR`. Each * `*.is()` static does a name-based duck check, so subclassing alone is - * not enough — we have to enumerate every concrete subclass we want to + * not enough, so we have to enumerate every concrete subclass we want to * recognize. Keep in sync with the `WorkflowRuntimeError` class hierarchy * in `@workflow/errors`. */ @@ -126,9 +126,9 @@ export function classifyRunError(err: unknown): RunErrorCode { return RUN_ERROR_CODES.DEPLOYMENT_MISMATCH; } - // World-layer faults — both a malformed response (contract violation) and a + // World-layer faults, both a malformed response (contract violation) and a // transient infrastructure failure (throttle / 5xx / transport / timeout, - // e.g. a firewall challenge) — are the backend's fault, not the user's. + // e.g. a firewall challenge), are the backend's fault, not the user's. // Bucket them under WORLD_CONTRACT_ERROR rather than USER_ERROR so dashboards // attribute an outage correctly. Note the retryable variants are normally // redelivered via the queue (see `isRetryableWorldError`) and only reach this diff --git a/packages/core/src/context-violation-error.ts b/packages/core/src/context-violation-error.ts index 1efd50528a..5b7e5bfbfc 100644 --- a/packages/core/src/context-violation-error.ts +++ b/packages/core/src/context-violation-error.ts @@ -14,7 +14,7 @@ const INSPECT_CUSTOM = Symbol.for('nodejs.util.inspect.custom'); * ANSI-framed string (for terminal display via `util.inspect` / `toString`). * * Keeping the pieces structured means we never have to strip ANSI back out - * once it's in the message — we just don't put it there in the first place. + * once it's in the message because we don't put it there in the first place. */ export interface FramedContent { /** Headline. `{ code: 'foo()' }` segments render as backticked inline code. */ @@ -83,7 +83,7 @@ export function renderPretty(c: FramedContent): string { * * - `.message` is **plain text** (no ANSI escape bytes). Structured logs, * log drains, CBOR-serialized event data, and anything else that reads - * `err.message` / `err.stack` as a string gets clean output — no mojibake + * `err.message` / `err.stack` as a string gets clean output: no mojibake * in JSON, no `\x1B[...m` noise in Vercel logs. * * - The ANSI-framed version is rendered **lazily** via `toString()` and @@ -92,12 +92,12 @@ export function renderPretty(c: FramedContent): string { * attached to a structured log field, the consumer sees plain text. * * - `fatal = true` marks these as non-retryable. Calling `createHook()` - * from a step function will never succeed no matter how many retries — - * burning attempts just produces duplicated log output. The runtime's + * from a step function will never succeed no matter how many retries. + * Burning attempts produces duplicated log output. The runtime's * `FatalError.is(err)` gate recognizes any error with `fatal: true`. */ export abstract class ContextViolationError extends Error { - /** Non-retryable — see class doc. */ + /** Non-retryable, see class doc. */ readonly fatal = true; readonly #content: FramedContent; diff --git a/packages/core/src/create-hook.ts b/packages/core/src/create-hook.ts index dce83c4b2d..358101c704 100644 --- a/packages/core/src/create-hook.ts +++ b/packages/core/src/create-hook.ts @@ -35,7 +35,7 @@ export interface Hook extends AsyncIterable, Thenable { * Returns the {@link Run} already using this token, or `null` when this Hook * registers successfully. * - * Calling `createHook()` alone does not register the hook — registration + * Calling `createHook()` alone does not register the hook: registration * only happens when the workflow suspends. Awaiting `getConflict()` * suspends the workflow to commit the hook registration without waiting for * payload data. @@ -118,7 +118,7 @@ export interface HookOptions { * * Deterministic tokens are intended for use with `createHook()` and * server-side `resumeHook()` only. For webhooks (`createWebhook()`), an - * explicit token is not accepted — one is always generated for you. + * explicit token is not accepted, one is always generated for you. * * A generated token is not trivial to guess but is not a security * contract, so authenticate webhook requests themselves rather than diff --git a/packages/core/src/define-hook.ts b/packages/core/src/define-hook.ts index bd4442f653..e5344fdd13 100644 --- a/packages/core/src/define-hook.ts +++ b/packages/core/src/define-hook.ts @@ -76,7 +76,7 @@ export function defineHook({ function create(_options?: HookOptions): Hook { // NOTE: `create` is referenced by name (not `this.create`) so the stack // strip still works if the caller destructured the hook (`const { create } - // = defineHook(); create()`) — in that case `this` is undefined. + // = defineHook(); create()`), since in that case `this` is undefined. throwNotInWorkflowContext( 'defineHook().create()', 'https://workflow-sdk.dev/docs/api-reference/workflow/define-hook', diff --git a/packages/core/src/describe-error.ts b/packages/core/src/describe-error.ts index 384f9a3c7e..b20d2ce568 100644 --- a/packages/core/src/describe-error.ts +++ b/packages/core/src/describe-error.ts @@ -18,7 +18,7 @@ import { * * - `user`: the error came from customer code (a step or workflow function * threw, or a value they passed across a boundary wasn't serializable). - * - `sdk`: the SDK produced the error itself — an internal invariant broke, + * - `sdk`: the SDK produced the error itself: an internal invariant broke, * or a runtime guard rejected the call. These should be rare; when they * happen we want to frame the terminal output as "this is us, not you." */ @@ -42,7 +42,7 @@ export interface ErrorDescription { * * - `errorCode` is typed as `string` rather than `RunErrorCode` because * the value comes from stored JSON/CBOR and may predate the current - * enum — callers should not narrow on it blindly. Values that don't + * enum, so callers should not narrow on it blindly. Values that don't * match a known `RUN_ERROR_CODES` entry fall through to USER_ERROR. * - `errorName` is the thrown `Error#name`. It is not universally * persisted today; callers that have access to it (either via an @@ -91,7 +91,7 @@ const DEPLOYMENT_MISMATCH_HINT = "The run was delivered to a deployment other than the deployment it is pinned to, and was stopped to protect against code-skew errors after the runtime failed to re-route it there. Verify that the run's deployment is still available and that queue callbacks route to it."; function normalizeErrorCode(code: string | undefined): RunErrorCode { - // Values read back from persisted events are `string | undefined` — we + // Values read back from persisted events are `string | undefined`, so we // only trust codes that match a known entry in `RUN_ERROR_CODES`. const known = Object.values(RUN_ERROR_CODES) as readonly string[]; if (code && known.includes(code)) { @@ -102,9 +102,9 @@ function normalizeErrorCode(code: string | undefined): RunErrorCode { /** * Data-driven variant of {@link describeError} that works from persisted - * event fields instead of a live `Error` instance. Intended for CLI/web - * renderers that read failure events and no longer have the original - * thrown object. + * event fields instead of a live `Error` instance. Intended for command-line + * interface (CLI) and web renderers that read failure events and no longer + * have the original thrown object. */ export function describeRunError( signal: PersistedErrorSignal @@ -164,7 +164,7 @@ export function describeRunError( } /** - * Describe an error for user-facing presentation. Purely informational — + * Describe an error for user-facing presentation. Purely informational: it * does not change any persisted event data or error classification used by * the runtime. * @@ -176,7 +176,7 @@ export function describeRunError( * - Context-violation errors (`NotInWorkflowContextError`, etc.) likewise * describe a user mistake. * - `WorkflowRuntimeError` (and subclasses like `StepNotRegisteredError`) - * indicates an internal SDK invariant broke — surface that as `sdk`. + * indicates an internal SDK invariant broke, so surface that as `sdk`. * * @param err The error value thrown by the workflow / step. * @param errorCode Optional precomputed error code. Callers that already @@ -214,7 +214,7 @@ export function describeError( }; } - // Check DEPLOYMENT_MISMATCH before the generic WorkflowRuntimeError branch — + // Check DEPLOYMENT_MISMATCH before the generic WorkflowRuntimeError branch: // WorkflowDeploymentMismatchError subclasses it, but has its own code + hint. if (effectiveCode === RUN_ERROR_CODES.DEPLOYMENT_MISMATCH) { return { diff --git a/packages/core/src/encryption.ts b/packages/core/src/encryption.ts index 7e0675f9ce..4214bf8d75 100644 --- a/packages/core/src/encryption.ts +++ b/packages/core/src/encryption.ts @@ -14,7 +14,7 @@ import { RuntimeDecryptionError, WorkflowRuntimeError } from '@workflow/errors'; * calls on every encrypt/decrypt invocation. * * Wire format: `[nonce (12 bytes)][ciphertext + auth tag]` - * The `encr` format prefix is NOT part of this module — it's added/stripped + * The `encr` format prefix is NOT part of this module: it's added/stripped * by the serialization layer in `maybeEncrypt`/`maybeDecrypt`. */ @@ -39,7 +39,7 @@ const KEY_LENGTH = 32; // bytes (AES-256) * * Pass `usages: ['encrypt']` (or `['decrypt']`) for cross-run scenarios * where the caller should not be able to perform the inverse operation - * with the key — for example a child workflow writing into a parent + * with the key. For example, a child workflow writing into a parent * run's forwarded WritableStream only needs to encrypt, never decrypt. * * @param raw - Raw 32-byte AES-256 key (from World.getEncryptionKeyForRun) @@ -98,7 +98,7 @@ export async function encrypt( ); } catch (cause) { // Re-wrap any Web Crypto failure (DOMException etc.) as a - // RuntimeDecryptionError. Failures here are rare — they happen e.g. + // RuntimeDecryptionError. Failures here are rare: they happen e.g. // when a CryptoKey was imported with `usages: ['decrypt']` only. throw new RuntimeDecryptionError( `AES-256-GCM encryption failed: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -120,17 +120,17 @@ export async function encrypt( /** * Decrypt data using AES-256-GCM. * - * Any failure inside the Web Crypto layer — most commonly an + * Any failure inside the Web Crypto layer (most commonly an * `OperationError: The operation failed for an operation-specific reason` * raised by `AESCipherJob.onDone` when the GCM authentication tag does - * not verify — is rewrapped as {@link RuntimeDecryptionError}. The + * not verify) is rewrapped as {@link RuntimeDecryptionError}. The * wrapped error carries the original DOMException as `cause`, plus a * small diagnostic context (`operation`, input `byteLength`) to help * disambiguate ciphertext corruption from key mismatch from truncated * transport reads. * * Note: `data` is the raw AES payload (`[nonce][ciphertext + tag]`), not a - * format-prefixed envelope — callers strip the `encr` marker via + * format-prefixed envelope: callers strip the `encr` marker via * `decodeFormatPrefix()` before reaching this function. The outer * envelope's format prefix is therefore attached by the serialization * layer (`serialization/encryption.ts`), which is the layer that has it. @@ -176,7 +176,7 @@ export async function decrypt( } catch (cause) { // The most common shape we see in the wild is a DOMException with // `name: 'OperationError'` and message "The operation failed for - // an operation-specific reason" — this is what Web Crypto throws + // an operation-specific reason", which is what Web Crypto throws // when the GCM auth tag does not verify. Re-throw as // RuntimeDecryptionError, attaching diagnostic context (byte length) // that the bare DOMException lacks. diff --git a/packages/core/src/events-consumer.ts b/packages/core/src/events-consumer.ts index b8d11622c2..512631087c 100644 --- a/packages/core/src/events-consumer.ts +++ b/packages/core/src/events-consumer.ts @@ -63,7 +63,7 @@ const getDeferredCheckDelayMs = (): number => * waits, and tolerating a stray one is the cheaper direction to be wrong in. * * An allowlist rather than the complement of the ordered set, so a type this - * file has not been taught about keeps the strict old behaviour. + * file has not been taught about keeps the strict old behavior. * * `hook_disposed` is deliberately absent despite being about a hook: it is * written when the workflow's own `using` scope exits, so it is replay-origin. @@ -154,7 +154,7 @@ export interface EventsConsumerOptions { * Callback invoked when an event is skipped because it repeats an event * class the walk already consumed for the same entity. `firstEventType` is * the type that recorded the class, which is the one the workflow observed. - * Diagnostics only: skipping is a normal outcome, not an error — though a + * Diagnostics only: skipping is a normal outcome, not an error, though a * `firstEventType` differing from `event.eventType` says the two writers * decided the entity's outcome differently, which is worth more than an * info log. @@ -174,7 +174,7 @@ export interface EventsConsumerOptions { * claimed yet is an event it has not reached yet. * * Required rather than defaulting to always-idle: always-idle is exactly the - * pre-gate behaviour, so a defaulted option would let a construction site opt + * pre-gate behavior, so a defaulted option would let a construction site opt * a whole replay path back out without saying so. Tests that drive a consumer * with no orchestrator context pass `() => true` to keep the pre-existing * timing, and say so at the call site. @@ -239,8 +239,8 @@ export class EventsConsumer { /** * The oldest event the walk stepped over that no consumer has claimed yet, * if any. Parking is a bet that a consumer will be registered later, so at - * any point where no consumer ever will be again — the replay finishing is - * the definitive one — this answers which event the bet lost on. + * any point where no consumer ever will be again (the replay finishing is + * the definitive one), this answers which event the bet lost on. */ get strandedEvent(): Event | undefined { return this.parked[0]?.event; @@ -326,7 +326,7 @@ export class EventsConsumer { // delivery `resolve()`; none of the callbacks here call `subscribe()` // synchronously. So within one pass `this.callbacks` is mutated only by // this loop (the `Finished` splice), and the next event's consumer is - // either already present (advance now) or not yet registered — in which + // either already present (advance now) or not yet registered, in which // case no callback consumes the event and we fall through to the // cross-VM-safe deferred unconsumed-event check below, exactly as before. while (true) { @@ -369,7 +369,7 @@ export class EventsConsumer { this.scheduleUnconsumedCheck(currentEvent, true); return; } - // A real event was consumed — advance to the next in the same pass. + // A real event was consumed, so advance to the next in the same pass. } }; @@ -534,7 +534,7 @@ export class EventsConsumer { * ids are minted from a monotonic ULID per body position, so nothing later in * the body registers a second consumer under this id. Waiting would cost * `getDeferredCheckDelayMs()` per straggler per replay for information that - * cannot arrive — 0.75% of production runs carry at least one straggler, and + * cannot arrive: 0.75% of production runs carry at least one straggler, and * the p99 among those carries 155. * * The invariant to preserve if hook identity ever becomes caller-supplied @@ -553,9 +553,9 @@ export class EventsConsumer { * Steps the walk over a sealed-log `noop` (specVersion >= 7): the World's * backend wrote it to occupy a slot whose writer allocated the position and * died, so the log's density arithmetic holds. It is invisible to the - * workflow: no consumer is offered it, no event class is recorded, and — - * exactly as with {@link skipDuplicateEvent} — the deterministic clock does - * not advance, so a log that happens to contain one produces the same + * workflow: no consumer is offered it, no event class is recorded, and the + * deterministic clock does not advance, exactly as with + * {@link skipDuplicateEvent}, so a log that happens to contain one produces the same * timestamps as a log that does not. (Its `createdAt` is the seal time, * which can even postdate later slots' events; letting it touch the clock * would leak the sealer's wall clock into replay.) @@ -594,7 +594,7 @@ export class EventsConsumer { private handleEndOfLog() { // Everything still parked is waiting for a consumer some later replay will - // register, which is the whole point of parking — except once the log + // register, which is the whole point of parking, except once the log // already holds the run's terminal event, because then there is no later // replay and no consumer will ever come. if (this.parked.length === 0) { @@ -624,7 +624,7 @@ export class EventsConsumer { // Schedule a deferred check. We chain onto the promiseQueue so that any // pending async work (e.g., deserialization/decryption that triggers // resolve() → user code → subscribe()) completes first. If the event - // is still unconsumed after the queue drains, it's truly orphaned — or, + // is still unconsumed after the queue drains, it's truly orphaned, or, // when its type carries no ordering claim, parked for a later consumer. const checkVersion = ++this.unconsumedCheckVersion; this.pendingUnconsumedCheck = this.getPromiseQueue() @@ -674,7 +674,7 @@ export class EventsConsumer { * pass that offered it, before this check is ever scheduled, and a class * recorded while the check was in flight can only have been recorded by a * consumption inside {@link consume}, whose next pass re-offers this event - * and steps over it there — leaving the identity guard above to drop the + * and steps over it there, leaving the identity guard above to drop the * in-flight check. */ private resolveUnconsumedEvent(currentEvent: Event, mayPark: boolean) { @@ -713,7 +713,7 @@ export class EventsConsumer { * loses it, and no measurement of a delivery outrunning 100ms exists either * way. So read this as retiring the bet rather than as repairing an observed * failure of that number: the delay is a user-settable env override, which - * leaves the old behaviour one configuration away from losing on any backend. + * leaves the old behavior one configuration away from losing on any backend. * * Termination is `hasParkedCommittedDelivery`'s: it counts only deliveries * that resolve on their own, so nothing here can gate its own retirement. A diff --git a/packages/core/src/flushable-stream.ts b/packages/core/src/flushable-stream.ts index 3722e8d449..6c270cf3a7 100644 --- a/packages/core/src/flushable-stream.ts +++ b/packages/core/src/flushable-stream.ts @@ -20,7 +20,7 @@ type DrainBarrier = () => Promise; * This is deliberately distinct from the per-request batch caps below: this * bounds how much is *buffered*, those bound how much goes out in one * `writeMulti`. Raising this must never let a single request exceed a wire - * limit — batch sizing enforces that independently. + * limit, since batch sizing enforces that independently. */ export const MAX_INFLIGHT_CHUNKS = 1000; @@ -46,7 +46,7 @@ export const getMaxChunksPerBatch = (): number => /** * Wire limit: maximum cumulative bytes in a single coalesced `writeMulti`. - * Chunk *count* alone is not enough — 1,000 small chunks are ~100KB but 1,000 + * Chunk *count* alone is not enough: 1,000 small chunks are ~100KB but 1,000 * file-sized chunks can be hundreds of MB, which platform request-body limits * reject long before the count cap matters. A batch is split once adding the * next chunk would exceed this (a single chunk larger than the cap still goes @@ -61,7 +61,7 @@ export const getMaxBytesPerBatch = (): number => }); /** - * Buffer bound (bytes) for the server writable's group-commit buffer — the + * Buffer bound (bytes) for the server writable's group-commit buffer, the * byte-denominated counterpart of {@link MAX_INFLIGHT_CHUNKS}. `write()` * blocks once this much data is buffered-but-not-durable, so a fast producer * of large chunks can't grow client memory without bound. Default 8 MiB @@ -88,7 +88,7 @@ export const getMaxBufferedBytes = (): number => * after each step body returns, so a coarser interval (the previous 100ms) * adds visible per-step latency to streaming workflows. With a uniformly * distributed offset between step return and the next tick, the expected - * wait is half the interval — so 10ms means ~5ms average wait per step + * wait is half the interval, so 10ms means ~5ms average wait per step * instead of ~50ms. The per-tick work is `writable.locked` plus a * `getWriter()`/`releaseLock()` probe, both microsecond-scale; 10× more * ticks during a stream's lifetime is not measurable in practice. @@ -213,8 +213,8 @@ function isReadableUnlockedNotClosed(readable: ReadableStream): boolean { * * Lock release means the producer is done *writing*; with a group-commit * sink, accepted chunks may still be client-buffered or in a request that is - * in flight. Awaiting the barrier here keeps the completion's meaning — - * "everything written so far is durable" — identical to the pre-batching + * in flight. Awaiting the barrier here keeps the completion's meaning + * ("everything written so far is durable") identical to the pre-batching * behavior where each write() was individually durable. */ function resolveAfterDrain(state: FlushableStreamState): void { @@ -324,7 +324,7 @@ export function flushablePipe( state: FlushableStreamState ): Promise { // Batching lives in the sink (`WorkflowServerWritableStream` group-commits - // its buffer), so this pipe is a plain per-chunk pump regardless of path — + // its buffer), so this pipe is a plain per-chunk pump regardless of path: // its only responsibilities are lock-release completion and durability // tracking. Group-commit sinks ack write() on buffer entry; adopt their // durability barrier so the lock-release completion still means @@ -363,8 +363,8 @@ async function flushablePipePerChunk( return; } - // Read from source - don't count as pending op since we're just waiting for data - // The important ops are writes to the sink (server) + // Read from the source. Don't count this as a pending operation because + // reads wait for data. The important operations are writes to the sink. const readResult = await Promise.race([ reader.read(), writer.closed.then(() => { @@ -405,7 +405,7 @@ async function flushablePipePerChunk( // that accepted prefix before settling the failure: once the state // rejects, the step may persist its failure and the invocation finish, // and anything still client-side would be lost. The original pipe error - // stays primary — a drain failure is already sticky on the sink. + // stays primary, since a drain failure is already sticky on the sink. if (state.drainBarrier) { await state.drainBarrier().catch(() => {}); } @@ -422,7 +422,7 @@ async function flushablePipePerChunk( } finally { // Cancel the upstream reader so the source knows to stop generating data. // Uses cancelReason (set in the catch block) so the source receives context - // about why it was cancelled. On normal completion cancelReason is undefined, + // about why it was canceled. On normal completion cancelReason is undefined, // which is a harmless no-op on an already-done reader. reader.cancel(cancelReason).catch(() => {}); reader.releaseLock(); diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index d54e365a5c..8b3e0d92ba 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -16,7 +16,7 @@ export interface StepInvocationQueueItem { * during replay: the queue message ID stamped by the invocation running * the step inline, or undefined when the latest start was unstamped (a * retry attempt driven by a queued step message, or an older runtime). - * Latest start wins — an unstamped bare start clears a previous stamp; an + * Latest start wins: an unstamped bare start clears a previous stamp; an * owner-recovery re-stamped start restores it. */ ownerMessageId?: string; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d40e09cdb1..7458271e84 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ /** - * Just the core utilities that are meant to be imported by user + * Core utilities intended for import by user * steps/workflows. This allows the bundler to tree-shake and limit what goes * into the final user bundles. Logic for running/handling steps/workflows * should live in runtime. Eventually these might be separate packages diff --git a/packages/core/src/log-format.ts b/packages/core/src/log-format.ts index d63e847e3f..5e95a3a7d0 100644 --- a/packages/core/src/log-format.ts +++ b/packages/core/src/log-format.ts @@ -17,7 +17,7 @@ import { * step step_… · add (./workflows/x) * hint: Move the call to a step function. * FatalError: … - * at … (trimmed stack — internals collapsed) + * at … (trimmed stack, internals collapsed) * * Without this composition, callers passing `${framing}\n${stack}` as the * message and structured fields as the metadata object got `util.inspect`'s @@ -26,7 +26,7 @@ import { * * The same metadata is also emitted as structured OTel span events from * the logger itself, so backends that want JSON-shaped data still get it. - * web/web-shared do not consume stderr at all — they read CBOR/JSON event + * web/web-shared do not consume stderr at all: they read CBOR/JSON event * payloads from the World event log. */ export function composeLogLine( @@ -164,7 +164,7 @@ function renderStructuredFields( * * 1. Drop framework-internal frames (`node_modules/.pnpm/`, `node:internal/`, * Turbopack-bundled `node_modules__pnpm_*` / `_next_dist_*` chunks). - * 2. Cap the surviving frames at `MAX_VISIBLE_FRAMES` — past that, even + * 2. Cap the surviving frames at `MAX_VISIBLE_FRAMES`: past that, even * "user-ish" frames are usually deep async wrapping that doesn't help * pinpoint the throw. The user can drop into the inspect CLI for the * full stack on demand. @@ -230,7 +230,7 @@ function isFrameworkFrame(line: string): boolean { // Turbopack/Next bundle the same framework code into chunks like // `node_modules__pnpm_._.js` and `<...>_next_dist_._.js`, // and emits Next.js loader runtime as `0dx6_next_dist_._.js`. - // These are the frames that show up after Turbopack DCE — same intent + // These are the frames that show up after Turbopack DCE, same intent // as the raw `node_modules/.pnpm/` filter above. if (trimmed.includes('node_modules__pnpm_')) return true; if (trimmed.includes('_next_dist_')) return true; diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index a7ca414ef8..715ae257aa 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -36,7 +36,7 @@ type LoggerOptions = { /** * Lightweight `DEBUG=` pattern matcher. Replaces the `debug` package, which - * was previously a static dependency of this module — that import path + * was previously a static dependency of this module: that import path * pulled `debug/src/node` and its dynamic `require('tty')` into the * generated Next.js webpack flow route, breaking the V2 combined-bundle * build with `Dynamic require of "tty" is not supported`. Keeping this @@ -94,8 +94,8 @@ function createLogger(namespace: string, options: LoggerOptions = {}): Logger { // single string so the runtime's `console.error` / `util.inspect` // doesn't quote-escape multi-line stacks or paragraph hints inside // a JSON-y object dump. The framing line stays at the top with the - // structured fields right under it; the stack body — with framework - // internal frames collapsed — sits at the bottom. See log-format.ts. + // structured fields right under it; the stack body (with framework + // internal frames collapsed) sits at the bottom. See log-format.ts. if (level === 'error' || level === 'warn') { const out = level === 'error' ? console.error : console.warn; out(composeLogLine('[workflow-sdk]', message, merged)); diff --git a/packages/core/src/private.ts b/packages/core/src/private.ts index 2c78ff1685..2cd9b28bc8 100644 --- a/packages/core/src/private.ts +++ b/packages/core/src/private.ts @@ -138,7 +138,7 @@ export interface WorkflowOrchestratorContext { /** * Increments when a suspension is accepted and on every retained-session * resume. STEP suspension signals capture it when scheduled and no-op if - * it moved (see step.ts) — this drops same-boundary sibling signals and + * it moved (see step.ts), which drops same-boundary sibling signals and * timers queued at boundary N that would fire after the session resumed * into boundary N+1. Sleep/hook/attribute signals are intentionally * unguarded: their presence makes the boundary unretainable, so a late @@ -179,12 +179,12 @@ export interface WorkflowOrchestratorContext { * Counter of in-flight async data delivery operations (step result * hydration, hook payload hydration, abort signal hydration). Suspensions * must wait for this to reach 0 before firing, to avoid preempting data - * delivery — e.g. dehydrating a step's arguments while an abort that should + * delivery, e.g. dehydrating a step's arguments while an abort that should * be reflected in those arguments is still hydrating its reason. */ pendingDeliveries: number; /** - * Ordered registry of in-flight "branch-deciding" deliveries — the + * Ordered registry of in-flight "branch-deciding" deliveries: the * resolutions a workflow typically `Promise.race`s on, or awaits from * independent concurrent branches: hook payloads (`hook_received`), wait * completions (`wait_completed`), and step results (`step_completed` / @@ -197,7 +197,7 @@ export interface WorkflowOrchestratorContext { * costing extra hops; a `wait_completed` resolves with fewer, and a reused * sleep can resolve in an entirely earlier loop iteration; a step result is * gated on hydration whose cost varies between replays of the SAME - * invocation — the first replay pays the full decrypt/decompress/revive, + * invocation: the first replay pays the full decrypt/decompress/revive, * while later replays sharing the invocation's `ReplayPayloadCache` * memo-hit small primitive results and resolve in one or two hops. Either * way, the resolution that the committed event log ordered first can lose a @@ -242,7 +242,7 @@ interface DeliveryBarrierEntry { * that already had a waiting consumer when it was consumed. * * False for a BUFFERED hook payload no consumer has claimed yet: it is - * delivered by `claim()`, i.e. whenever the workflow next reads the hook — + * delivered by `claim()`, i.e. whenever the workflow next reads the hook, * which may be causally *after* a later-in-log delivery. `arm()` flips it * once a consumer takes the payload. */ @@ -261,20 +261,20 @@ interface DeliveryBarrierEntry { * blocks on a peer it does not need to: * * - a hook defers behind earlier HOOKS, WAITS and STEPS; - * - a wait defers behind earlier HOOKS and STEPS — not earlier waits, since a - * wait never needs to queue behind another wait; + * - a wait defers behind earlier HOOKS and STEPS (not earlier waits, since a + * wait never needs to queue behind another wait); * - a step defers behind earlier WAITS, HOOKS and STEPS. * * The step-behind-step edge is not redundant with the serial `promiseQueue`. * The queue fixes the order in which step results are HYDRATED, but a step no - * longer resolves inside its queue slot — it captures the outcome there and + * longer resolves inside its queue slot: it captures the outcome there and * resolves from a detached continuation once its barrier clears. Two steps * agree on that continuation's ordering only while they defer behind the same * set, which holds when they are consumed in the same drain window but not * across windows: a step consumed later can miss a wait/hook barrier that an * earlier step is still parked on, because the barrier retired in between. The * earlier step is then waiting out the macrotask yield below while the later - * one resolves on microtasks, and overtakes it — see + * one resolves on microtasks, and overtakes it. See * `delivery-barrier-coverage.test.ts`. Deferring behind earlier steps * closes that window structurally: the earlier step's barrier is still * registered precisely because it has not delivered yet. @@ -314,14 +314,14 @@ function gatesOn( return false; } // A step skips an UNARMED earlier entry (an unclaimed buffered hook - // payload) — see the asymmetry described on `awaitEarlierDeliveries`. The + // payload). See the asymmetry described on `awaitEarlierDeliveries`. The // skip is direct, never transitive: armed entries are still gated on, even // when they are themselves parked behind such a payload. return !(kind === 'step' && !other.armed); } /** - * Whether `entry` will resolve on its own — it is armed, and every earlier + * Whether `entry` will resolve on its own: it is armed, and every earlier * delivery it actually gates on ({@link gatesOn}) will likewise resolve on its * own. * @@ -337,7 +337,7 @@ function gatesOn( * * Recursion terminates because every edge points to a strictly smaller index. * `memo` keeps the walk linear in registry size, and the registry is not small - * by construction — `EventsConsumer` drains consecutively consumable events + * by construction: `EventsConsumer` drains consecutively consumable events * synchronously while barriers only retire on microtask-driven deliveries, so * a fan-out of `Promise.race([hook, sleep])` branches accumulates one barrier * per branch per kind. The memo MUST be per-call: `armed` mutates between @@ -345,7 +345,7 @@ function gatesOn( * * The memo is an optimization, not a correctness requirement. It once was one: * `awaitEarlierDeliveries` used to run this walk for every earlier entry of a - * step delivery, with no early exit, which unmemoized is T(n) = Σ T(j) — + * step delivery, with no early exit, which unmemoized is T(n) = Σ T(j), * measured at 4.3e8 recursive calls (84s) for 40 alternating armed hook/wait * barriers. That call site is gone; a step now tests `armed` directly. The one * surviving caller, {@link hasParkedCommittedDelivery}, cannot reach that @@ -397,8 +397,8 @@ function computeResolvesOnItsOwn( * earlier in the log than `eventIndex` and that a delivery of `kind` defers * behind (see {@link DEFER_BEHIND}), so that this resolution is handed to the * workflow only after all relevant earlier-in-log deliveries have been. This - * is what keeps a `Promise.race` — or the ULID a follow-up `useStep` draws on - * a concurrent branch — deterministic and aligned with the committed event + * is what keeps a `Promise.race` (or the ULID a follow-up `useStep` draws on + * a concurrent branch) deterministic and aligned with the committed event * log, independent of microtask-hop counts, hydration time, or race-argument * order. When this delivery does have to wait, it also yields a macrotask * afterwards so the earlier delivery's consumer can run to its own next @@ -411,10 +411,10 @@ function computeResolvesOnItsOwn( * One asymmetry: a STEP result skips any earlier delivery that is UNARMED, * i.e. a buffered hook payload no consumer has claimed. Such a payload is * delivered only when the workflow next reads the hook, and reaching that read - * very commonly requires the step result itself (`await stepX()` before the + * often requires the step result itself (`await stepX()` before the * read). Gating the step on it would stall the workflow until the barrier's * idle safety net fires, which then releases every delivery queued behind that - * payload at once — losing exactly the race this ordering exists to protect. + * payload at once, losing exactly the race this ordering exists to protect. * Waits and hooks keep gating on unclaimed payloads: for them, waiting for the * claim IS the ordering guarantee (a `wait_completed` must not preempt a * payload the log ordered first). @@ -425,7 +425,7 @@ function computeResolvesOnItsOwn( * there is: a workflow that creates a hook it does not read on this branch, * races `step` against `sleep`, and has the log say the sleep won. The step * would then overtake the wait, both branches would swap the correlation ids - * they draw next, and replay would diverge — see + * they draw next, and replay would diverge. See * `step-delivery-ordering.test.ts`. Waiting instead is safe because the * payload's own idle safety net retires it and the whole chain then delivers * in log order; {@link hasParkedCommittedDelivery} deliberately reports such a @@ -436,26 +436,26 @@ function computeResolvesOnItsOwn( * structural: safety-net retirements go through one per-context dispenser that * only ever retires the lowest-index entry at delivery idle, and every * retirement that wakes a chain flips {@link hasParkedCommittedDelivery} back - * to true, re-blocking the dispenser until the chain has drained — see + * to true, re-blocking the dispenser until the chain has drained. See * {@link ensureBarrierSafetyNet}. (This used to rest on the FIFO of one idle * poll per barrier, which held for a single parked segment but decayed to - * timing noise with several — the release order, and therefore the ULIDs + * timing noise with several: the release order, and therefore the ULIDs * drawn by the woken branches, then depended on how much log the replay had * loaded. storm-log-replay.test.ts replays a production log corrupted exactly * that way.) */ /** * Whether correlation-id draw order is pinned to event-log order. Default ON: - * only the literal string `WORKFLOW_LOG_ORDER_DRAWS=0` opts out — `=false`, + * only the literal string `WORKFLOW_LOG_ORDER_DRAWS=0` opts out. `=false`, * `=off`, and every other value keep it enabled. Read per call so tests can * flip it. * * Off, a delivery that had to defer yields ONE macrotask after its - * predecessors resolve — enough for short consumers, but a woken branch whose - * path to its next draw crosses more hops (a `Promise.race` resolution, a + * predecessors resolve. This is enough for short consumers, but a woken branch + * whose path to its next draw crosses more hops (a `Promise.race` resolution, a * user-level semaphore, an async-iterator read) can still be overtaken by a - * later-in-log delivery's shorter cascade, so the run's draw order — and - * therefore its correlation ids — depends on how much log this replay loaded. + * later-in-log delivery's shorter cascade, so the run's draw order, including + * its correlation ids, depends on how much log this replay loaded. * On, the yield becomes a fixpoint: the delivery resolves only once every * earlier cascade has quiesced, making the draw sequence a pure function of * the dense log, stable under prefix extension, and concurrent writers' @@ -469,7 +469,7 @@ function isLogOrderDrawsEnabled(): boolean { * One quiescence turn: lets the entire pending microtask queue drain, then * yields to the event loop once. `setImmediate` (check phase) is used where * available because Node clamps `setTimeout(0)` to ~1ms while `setImmediate` - * costs ~20µs — and every branch-deciding delivery pays this turn at least + * costs ~20µs. Every branch-deciding delivery pays this turn at least * once, so on a sequential replay the clamp is the whole cost. Timers still * run between consecutive turns (the loop re-enters the event loop each * iteration, passing through the timers phase), so chains parked on the @@ -489,8 +489,8 @@ function quiescenceTurn(delayMs: number): Promise { * * Termination: each extra iteration requires a new ULID draw or a hydration * started in the previous turn. `mintCount` counts EVERY draw from the run's - * sequence — correlation ids and the serialization-driven draws (stream ids - * minted through the `STABLE_ULID` global while dehydrating) — which is + * sequence, including correlation ids and serialization-driven draws (stream + * ids minted through the `STABLE_ULID` global while dehydrating). This is * conservative in the safe direction: serialization draws only extend the * wait, and both body progress and the serialization work one cascade can * schedule are finite between deliveries, so the fixpoint is reached. The @@ -526,8 +526,8 @@ async function quiesceEarlierCascades( } // Quiet is not enough on its own: several delivery chains can be sitting // in this loop at once, and letting the first quiet observation resolve - // would break the tie by timer arrival — the arrival-order dependence this - // mode removes. A lower-index ARMED barrier is a delivery committed to + // would break the tie by timer arrival. This mode removes that arrival-order + // dependence. A lower-index ARMED barrier is a delivery committed to // happening that has not happened yet, so this one keeps waiting. Unarmed // entries (buffered payloads nobody has claimed) do not block, exactly as // in `gatesOn`: their handover is claim-driven, which is body-position @@ -537,8 +537,8 @@ async function quiesceEarlierCascades( // e.g., excludes wait→wait): under log-order draws EVERY branch-deciding // delivery must resolve in log order, kinds included. The width is safe // against the dispenser deadlock that `resolvesOnItsOwn` guards, because - // the one edge the wider relation adds — waiting on an armed entry that - // gatesOn does not model — always bottoms out at the same unarmed payload: + // the one edge the wider relation adds (waiting on an armed entry that + // gatesOn does not model) always bottoms out at the same unarmed payload: // a lower armed WAIT is non-self-resolving only when it is (transitively) // parked behind an unclaimed buffered payload, and a wait gates on every // lower hook and step directly, so the spinner here also gates on that @@ -583,7 +583,7 @@ export async function awaitEarlierDeliveries( // delivery's barrier deregisters when its resolve() runs, but the branch // it woke may still be hops away from its next draw. A later delivery // consumed after that deregistration sees an empty gate set, and without - // this it would resolve mid-cascade and overtake the draw — the exact + // this it would resolve mid-cascade and overtake the draw. This is the exact // arrival-order dependence this mode exists to remove. Costs one quiet // macrotask turn when nothing is in flight. await quiesceEarlierCascades(ctx, eventIndex); @@ -592,7 +592,7 @@ export async function awaitEarlierDeliveries( if (earlier.length > 0) { // An earlier delivery being "delivered" only means its `resolve()` ran. // The branch it woke may need an arbitrary number of further microtask - // hops before it reaches its next `useStep` call and draws a ULID — a + // hops before it reaches its next `useStep` call and draws a ULID: a // `for await` over a hook, for instance, resumes the generator, settles // the promise from `next()`, and only then runs the loop body. Resolving // this delivery on a microtask would let it overtake that branch and @@ -638,12 +638,12 @@ export interface DeliveryBarrier { * suspending and never observes it), the barrier auto-resolves at idle. * * INVARIANT required of every call site: a barrier that is ever `armed` must - * be paired with a delivery chain that runs unconditionally — attached when + * be paired with a delivery chain that runs unconditionally, attached when * the event is consumed (waits, step results, waiting-consumer hook payloads, * aborts), or by the `claim()` whose invocation is what arms it (buffered * hook payloads). The idle check ({@link scheduleWhenIdle}) refuses to * observe idle while an armed, self-resolving barrier is undelivered, and the - * safety net below is itself idle-gated — so an armed barrier with no + * safety net below is itself idle-gated, so an armed barrier with no * unconditional chain would livelock every idle check in the run, including * its own retirement. */ @@ -686,7 +686,7 @@ export function registerDeliveryBarrier( // it is retired at idle so a later delivery gated on it cannot deadlock and // the registry cannot leak an entry per abandoned delivery. Retirement goes // through the context's single ordered dispenser rather than a per-barrier - // idle poll — see {@link ensureBarrierSafetyNet} for why the ORDER of these + // idle poll. See {@link ensureBarrierSafetyNet} for why the ORDER of these // retirements is load-bearing. ensureBarrierSafetyNet(ctx); @@ -713,13 +713,13 @@ const activeBarrierSafetyNets = new WeakSet(); * * Why one ordered dispenser and not a poll per barrier (which is what this * replaced): the order of safety-net retirements decides the delivery order of - * every chain parked behind an unclaimed buffered hook payload — a hook the + * every chain parked behind an unclaimed buffered hook payload: a hook the * workflow never reads (a fire-and-forget `createHook`) parks every later * armed wait/hook behind a barrier that only this net can retire. Per-barrier * polls fire in whatever order their re-arm cycles land, and each re-arm * attaches to a `promiseQueue` that grows between checks, so with several * parked segments the release order decays to timing noise. Draws (`useStep` - * correlation ids) then depend on which segment happened to release first — + * correlation ids) then depend on which segment happened to release first: * concretely, on how MUCH log the replay loaded, since that decides what is in * the registry. That is the mechanism behind slot-mode CORRUPTED_EVENT_LOG on * storm-shaped runs (see storm-log-replay.test.ts, built from a production @@ -729,12 +729,12 @@ const activeBarrierSafetyNets = new WeakSet(); * Retiring lowest-first is not merely tidy, it is the only order that cannot * invert the log: every gate points from a higher index to a strictly lower * one, so at delivery idle the lowest undelivered entry gates on nothing - * still registered — it is the head of every parked chain (in practice, the + * still registered: it is the head of every parked chain (in practice, the * unclaimed payload itself). Releasing it lets the chain above deliver * through the ordinary barrier order; anything the release wakes flips * {@link hasParkedCommittedDelivery} back to true, which re-blocks this * dispenser until the chain has fully drained. A higher entry must never be - * retired while a lower one is registered — that is exactly the inversion + * retired while a lower one is registered, which is exactly the inversion * described on {@link awaitEarlierDeliveries}. * * The dispenser goes dormant when the registry empties and is re-armed by the @@ -771,16 +771,16 @@ function ensureBarrierSafetyNet(ctx: WorkflowOrchestratorContext): void { return; } // Idle with entries left: nothing remaining delivers on its own, so - // release parked chains from the head — lowest index first, one at a + // release parked chains from the head: lowest index first, one at a // time, re-reading idle between retirements. A retirement that wakes a // chain flips {@link hasParkedCommittedDelivery} synchronously (it is // computed from the registry this loop just mutated), which stops the // sweep so the chain delivers before anything above it is released. A // retirement that wakes nothing (a stale payload no delivery gates on) // keeps the sweep going, so a backlog of those drains in ONE idle - // observation — pacing them one per timer tick would hold consumed-but- - // undelivered events hostage long enough to trip the events consumer's - // unconsumed-event deadline and fail healthy replays. + // observation, since pacing them one per timer tick would hold + // consumed-but-undelivered events hostage long enough to trip the events + // consumer's unconsumed-event deadline and fail healthy replays. while (barriers.size > 0 && canRetireAbandonedBarriers(ctx)) { let lowestIndex: number | undefined; let lowestEntry: DeliveryBarrierEntry | undefined; @@ -811,19 +811,19 @@ function canRetireAbandonedBarriers(ctx: WorkflowOrchestratorContext): boolean { /** * Whether some registered branch-deciding delivery is going to reach the * workflow without any further help (it is armed and not transitively parked - * behind an unclaimed buffered payload — see {@link resolvesOnItsOwn}) but + * behind an unclaimed buffered payload, see {@link resolvesOnItsOwn}) but * has not been handed over yet. * * This is the delivery state `pendingDeliveries` cannot see. That counter * covers the hydration window inside a serial `promiseQueue` slot and is * released there, while the delivery's `resolve()` runs later, from a - * detached continuation behind {@link awaitEarlierDeliveries} — including its + * detached continuation behind {@link awaitEarlierDeliveries}, including its * macrotask yield whenever the delivery had to defer. Replaying a batch of N * parallel step results consumed in one drain window leaves N-1 of them * parked on that yield with `pendingDeliveries` already at 0. An idle check * armed during the same window (a pending `sleep()` arms one on every replay) * could then observe "idle" mid-deferral and raise a `WorkflowSuspension` - * BEFORE the workflow's own continuations ran — a suspension carrying none of + * BEFORE the workflow's own continuations ran: a suspension carrying none of * the follow-up work the batch was about to create, which the runtime * dutifully schedules as nothing, leaving the run dormant until an unrelated * timer fires (vercel/workflow#3183). @@ -832,7 +832,7 @@ function canRetireAbandonedBarriers(ctx: WorkflowOrchestratorContext): boolean { * accuracy but for termination: an unclaimed buffered hook payload is retired * BY the idle safety net in {@link registerDeliveryBarrier}, so counting it * here would gate its own retirement. That reasoning extends to whatever is - * parked behind such a payload — a wait, and a step gating on that wait — for + * parked behind such a payload (a wait, and a step gating on that wait) for * the same reason: the whole chain moves only once the net fires, and it * cannot fire while the chain is counted. Self-resolving deliveries always * deliver from their own chains (see the INVARIANT on @@ -846,7 +846,7 @@ export function hasParkedCommittedDelivery( if (!barriers || barriers.size === 0) { return false; } - // Shared across this call only — see `resolvesOnItsOwn`. + // Shared across this call only. See `resolvesOnItsOwn`. const selfResolving = new Map(); for (const [index, entry] of barriers) { if (resolvesOnItsOwn(barriers, index, entry, selfResolving)) { @@ -862,7 +862,7 @@ export function hasParkedCommittedDelivery( * "In flight" is two distinct windows, each with its own guard: * `pendingDeliveries > 0` covers hydration inside the serial queue slots, and * {@link hasParkedCommittedDelivery} covers the detached gap between a slot - * releasing that counter and the delivery's `resolve()` actually running — + * releasing that counter and the delivery's `resolve()` actually running, * deliberately outside `pendingDeliveries` (see step.ts), and invisible to it. * * Anything that decides a replay is over, or that a replay went wrong, has to @@ -875,7 +875,7 @@ export function hasParkedCommittedDelivery( * entry is parked behind an unclaimed buffered payload. Those entries only * move when the safety-net dispenser retires them (lowest-first, see * {@link ensureBarrierSafetyNet}), and the deliveries they release are real - * workflow reactions — a suspension raised before they run would be computed + * workflow reactions: a suspension raised before they run would be computed * from a VM that has not seen them, scheduling none of their follow-up work * and leaving the run dormant (the vercel/workflow#3183 shape). The dispenser * itself is gated on {@link canRetireAbandonedBarriers}, the weaker predicate diff --git a/packages/core/src/replay-payload-cache.ts b/packages/core/src/replay-payload-cache.ts index 31144a6add..a66dde8670 100644 --- a/packages/core/src/replay-payload-cache.ts +++ b/packages/core/src/replay-payload-cache.ts @@ -70,8 +70,8 @@ export class ReplayPayloadCache { // This cache is scoped to one invocation. Incremental loads and write // response deltas only ever append, so the scanned length locates the // events added since the previous replay. A reload that can insert events - // BELOW that length — a stale-snapshot restart replacing the log with a - // corrected one — must call `resetScan()` first, or the inserted events are + // BELOW that length (a stale-snapshot restart replacing the log with a + // corrected one) must call `resetScan()` first, or the inserted events are // never scanned. Prepared entries stay valid across that: they are keyed by // event id, not by position. for ( @@ -115,7 +115,7 @@ export class ReplayPayloadCache { * Required before a replay whose event log was reloaded rather than extended: * a corrected log inserts the events the previous load was missing, which * shifts every later position, so a positional resume would skip exactly the - * events the reload was for. Already-prepared payloads are kept — they are + * events the reload was for. Already-prepared payloads are kept: they are * keyed by event id, so re-scanning re-observes them for free. */ resetScan(): void { diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index 5fa19d63ea..5962cc4473 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -212,7 +212,7 @@ function clampMaxEvents(serverValue: number | undefined): number | undefined { * `start()` performs two writes that must land in the same tenant: the * `run_created` event, attributed to whatever environment the *caller* * authenticates as, and the queue message, pinned to a *deployment*. A - * misconfigured caller can split them — writing the run to one environment + * misconfigured caller can split them, writing the run to one environment * while addressing the message to a deployment in another. The consumer then * finds no run under its own tenant and the backend's resilient start * (`run_started` creates the run when `run_created` was never seen) mints a @@ -222,7 +222,7 @@ function clampMaxEvents(serverValue: number | undefined): number | undefined { * * Nothing external is needed to catch this: the creator's environment rides the * message in `runInput.environment` and this process already knows its own. So - * compare them and stop BEFORE `run_started` — the write that would create the + * compare them and stop BEFORE `run_started`, the write that would create the * fork. Refusing after it would be too late. * * Returns `true` when the caller must abandon the delivery. Skipped whenever @@ -277,14 +277,14 @@ function refuseCrossEnvironmentDelivery({ * Log when a `start()`-enqueued message pinned to one deployment is delivered * to a different one. * - * A first-delivery message carries `runInput.deploymentId` — the deployment - * `start()` addressed it to — so comparing that against this handler's own + * A first-delivery message carries `runInput.deploymentId` (the deployment + * `start()` addressed it to), so comparing that against this handler's own * deployment detects mis-delivery directly. This is a DIAGNOSTIC, not a gate: * it warns and lets the invocation proceed, deliberately. * * Why this one only warns while its environment sibling above refuses: a * differing deployment id is not by itself evidence of the fork we care about. - * The environment pair is exact — two named environments that disagree — and it + * The environment pair is exact (two named environments that disagree) and it * is the dimension the run's tenant is keyed on. Deployment ids disagree for * benign reasons too: `world-local` derives its id from the installed package * version (`dpl_local@`), so upgrading the SDK mid-run changes it with @@ -292,7 +292,7 @@ function refuseCrossEnvironmentDelivery({ * refusing before `run_started` leaves no server-side record to explain why. * Anyone tempted to promote this to a hard failure has to handle that first. * - * Skipped unless BOTH ids are known — `getDeploymentId()` throws in worlds that + * Skipped unless BOTH ids are known: `getDeploymentId()` throws in worlds that * require a deployment and have none, and a re-enqueued message carries no * `runInput` at all. */ @@ -315,7 +315,7 @@ async function warnOnDeploymentPinningMismatch({ currentDeploymentId = await world.getDeploymentId(); } catch { // Worlds that require a deployment id throw when there isn't one. That is - // not a mismatch — there is simply nothing to compare against. + // not a mismatch: there is nothing to compare against. return; } if (!currentDeploymentId || currentDeploymentId === pinnedDeploymentId) { @@ -350,7 +350,7 @@ function getWorkflowSetupErrorCode(err: unknown): RunErrorCode | null { } /** - * Whether a step execution rejected because the step entity does not exist — + * Whether a step execution rejected because the step entity does not exist, * the signature of a resilient step dispatch message whose delivery beat (or * outlived a transient failure of) the producer's parallel `step_created` * write. Every World surfaces it as a `WorkflowWorldError` naming the missing @@ -360,9 +360,9 @@ function getWorkflowSetupErrorCode(err: unknown): RunErrorCode | null { * across those shapes; the status check narrows the remote case without * excluding the local ones (which carry no status). * - * Used only when the message carries `stepInput` — a bare dispatch without a - * payload has nothing to recover from, and the error keeps propagating for - * queue-driven recovery exactly as before. + * Used only when the message carries `stepInput`, since a bare dispatch + * without a payload has nothing to recover from, and the error keeps + * propagating for queue-driven recovery exactly as before. */ function isStepMissingError(err: unknown): boolean { if (!WorkflowWorldError.is(err)) return false; @@ -480,7 +480,7 @@ function rootRunIdFrom( /** * Whether the run has a hook and/or wait that an out-of-band writer could * append an event for between an inline step's `step_completed` write and - * the next replay — namely an open hook (a `hook_created` not yet + * the next replay, namely an open hook (a `hook_created` not yet * `hook_disposed`, which a webhook receiver can resolve with * `hook_received`) or an open wait (a `wait_created` not yet * `wait_completed`, which the wait timer can resolve with @@ -544,7 +544,7 @@ function appendEventLog(log: LoadedEventLog, appended: LoadedEventLog): void { /** * The whole retention predicate: keep the session only for a pure step - * boundary (every suspension item is a step — any other item type, present + * boundary (every suspension item is a step: any other item type, present * or future, is unretainable by default) whose new step inputs serialized * without executing workflow code, with no out-of-band continuation source: * attributes require replay; hooks and waits can wake another invocation. @@ -554,7 +554,7 @@ function appendEventLog(log: LoadedEventLog, appended: LoadedEventLog): void { * and consulted last, after every cheap check has passed. * * INVARIANT this predicate leans on: every suspension signaler that does NOT - * carry the step-consumer generation guard (sleep, hook, attribute — see + * carry the step-consumer generation guard (sleep, hook, attribute, see * `suspensionGeneration` in private.ts) must be unretainable here, either via * a non-step queue item or the open hook/wait scan. A new signaler that * satisfies neither would let a stale signal be accepted as a fresh @@ -656,7 +656,7 @@ export function workflowEntrypoint( const handlerEnteredAtMs = Date.now(); // Check if this is a health check message // NOTE: Health check messages are intentionally unauthenticated for monitoring purposes. - // They only write a simple status response to a stream and do not expose sensitive data. + // They only write a status response to a stream and do not expose sensitive data. // The stream name includes a unique correlationId that must be known by the caller. const healthCheck = parseHealthCheckPayload(message_); if (healthCheck) { @@ -684,7 +684,7 @@ export function workflowEntrypoint( // --- Hook-resume TTR telemetry (runtime/resume-latency.ts) --- // Threaded through this invocation and CONSUMED by the first durable - // step that follows the resumption — cleared at that point so a later + // step that follows the resumption, cleared at that point so a later // step, a retry, or a redelivery never re-reports the same resume. // // Two delivery shapes carry timing: @@ -710,7 +710,7 @@ export function workflowEntrypoint( } // `start()` always attaches a trace carrier, but // serializeTraceCarrier() returns `{}` when no OTEL SDK is registered - // or no span is active — treat an empty carrier the same as an + // or no span is active, so treat an empty carrier the same as an // absent one so linked mode falls back to a fresh origin instead of // forwarding a useless `{}` forever. const traceContext = isUsableTraceCarrier(incomingTraceCarrier) @@ -722,7 +722,7 @@ export function workflowEntrypoint( // --- Max delivery check --- // Enforce max delivery limit before any infrastructure calls. // This prevents runaway workflows from consuming infinite queue deliveries. - // Scoped logger for this run — attaches runId/workflowName to every + // Scoped logger for this run: attaches runId/workflowName to every // log line and child loggers below, so callers don't repeat it. const runLogger = runtimeLogger.forRun(runId, workflowName); @@ -786,18 +786,18 @@ export function workflowEntrypoint( // --- Trace correlation mode --- // 'linked' (default): the workflow.execute span below stays a CHILD - // of the local delivery (flow-route) context, so one invocation — - // route handler, workflow replay, inline steps, event writes — is a + // of the local delivery (flow-route) context, so one invocation + // (route handler, workflow replay, inline steps, event writes) is a // single bounded trace. The run-origin context travels as a span // LINK (not a parent), and re-enqueues forward the original carrier // unchanged, so a (potentially hours-long) run is never stitched // into one giant trace across invocations. - // 'continuous': legacy behavior — the restored run-origin context + // 'continuous': legacy behavior, where the restored run-origin context // becomes the parent of this invocation's spans. const traceMode = getWorkflowTraceMode(); - // Trace carrier to attach to messages this invocation enqueues — - // see getNextTraceCarrier for the linked/continuous semantics. + // Trace carrier to attach to messages this invocation enqueues. + // See getNextTraceCarrier for the linked/continuous semantics. const nextTraceCarrier = (): Promise> => getNextTraceCarrier(traceMode, traceContext); @@ -814,7 +814,7 @@ export function workflowEntrypoint( const replayBudget = new ReplayBudget(); // In linked mode the run-origin context is NOT restored as the - // active (parent) context — passing `undefined` makes + // active (parent) context: passing `undefined` makes // withTraceContext a passthrough, so the workflow.execute span below // stays a child of the local delivery (flow-route) context and the // run-origin travels as a span link instead. @@ -831,12 +831,12 @@ export function workflowEntrypoint( getWorld() ); // Both checks below look at `runInput`, so both are no-ops on a - // re-enqueued message (which carries none) — they only ever run on + // re-enqueued message (which carries none): they only ever run on // a first delivery, the one that could create the run. // // Returning acks the message. That is deliberate: the mismatch is // baked into this message, so every redelivery would reach the - // same verdict, and throwing would just hot-loop the handler until + // same verdict, and throwing would hot-loop the handler until // MAX_QUEUE_DELIVERIES with the same error each time. It matches // how the run_started path already discards deliveries whose // verdict cannot change (EntityConflictError, RunExpiredError). @@ -850,7 +850,7 @@ export function workflowEntrypoint( ) { return; } - // Diagnostic only — see the helper for why this warns instead of + // Diagnostic only. See the helper for why this warns instead of // refusing the invocation. await warnOnDeploymentPinningMismatch({ world, @@ -915,7 +915,7 @@ export function workflowEntrypoint( * a World is free to serve both: the cursor says where to * start listing from, the slot says which events this writer * had already decided without. Worlds that treat the delta as - * the better answer simply ignore the slot. + * the better answer ignore the slot. * * Taken from whatever is loaded, including the stale * `loadAfter` state. Understating is safe: the World reports @@ -978,8 +978,8 @@ export function workflowEntrypoint( * back into place. Sharing an implementation would mean one * of them doing the other's work. * - * Declining is always safe — an unabsorbed delta is a delta - * the next `events.list` returns — so the guards are free to + * Declining is always safe (an unabsorbed delta is a delta + * the next `events.list` returns), so the guards are free to * be strict: * * - `hasMore` means the World truncated the page. Taking it @@ -1023,7 +1023,7 @@ export function workflowEntrypoint( let maxEventsLimit: number | undefined; let workflowStartedAt = -1; - // Latency telemetry (TTFS) state — see runtime/step-latency.ts. + // Latency telemetry (TTFS) state. See runtime/step-latency.ts. // Whether this invocation's FIRST event snapshot contained // nothing beyond run_created/run_started: anything else was // written by an earlier invocation, whose contribution to @@ -1032,11 +1032,11 @@ export function workflowEntrypoint( // Set once, on the first iteration's loaded snapshot. let invocationStartedClean: boolean | undefined; // Epoch ms the `run_started` response was received/parsed - // by the SDK — anchors RSFS (run_started → first step's + // by the SDK: anchors RSFS (run_started → first step's // start POST). Set once, in the run_started setup below. // Under turbo, run_started is backgrounded rather than // awaited, so this is stamped at the point the run is - // synthesized locally instead of the real response — see + // synthesized locally instead of the real response. See // StepLatencyTracking.rsfsAnchorMs. let runStartedReceivedAtMs: number | undefined; // Wall-clock ms spent committing hook_created events before @@ -1044,18 +1044,18 @@ export function workflowEntrypoint( // and subtracted from TTFS. Accumulators here survive an // in-process replay restart (a stale-snapshot rejection, or // the attribute-event restart below), so a restarted - // invocation over-counts by the abandoned pass's hook time — + // invocation over-counts by the abandoned pass's hook time, // the same slight over-count either restart has always had. let preStepBlockingMs = 0; // Snapshot of the accumulator as of the suspension that // wrote the run's first attr_set (whose hook phase ran // before its attr writes). When a pre-step setAttributes // ends the TTFS measurement at the attr write, only hook - // time from BEFORE that point may be subtracted — later + // time from BEFORE that point may be subtracted, since later // hook writes fall outside the measured window. let preStepBlockingBeforeAttrMs: number | undefined; - // Turbo mode fast-paths the very first delivery of the very + // Turbo mode fast-paths the first delivery of the first // first invocation, where it is provably safe to: background // `run_started`, skip the initial event-log load (nothing has // been written yet), and force optimistic inline start (no @@ -1063,12 +1063,12 @@ export function workflowEntrypoint( // `runInput` is only present on the start()-enqueued message, // and `attempt === 1` (1-based) means this is the first // delivery; `incomingStepId` would mark a background-step - // invocation and `replayDivergence` a recovery replay — both + // invocation and `replayDivergence` a recovery replay, both // ineligible. The single-handler guarantee that makes forced // optimistic start safe ends once a hook or wait is created // (they introduce resume invocations), so turbo exits at that // point (see `forceOptimisticStart`). Workflow attribute - // writes introduce no such invocation source — they resolve + // writes introduce no such invocation source: they resolve // via an in-process replay and don't end turbo. // NOTE: `metadata.attempt === 1` is also load-bearing for // inline step ownership: owned-recovery steps (a step @@ -1076,7 +1076,7 @@ export function workflowEntrypoint( // exist on attempt ≥ 2, so turbo and owned recovery are // mutually exclusive. The turbo `reinvoke()` paths (hook // conflict, throttle backoff) ack this message and continue - // under a NEW message id — safe only because no + // under a NEW message id, safe only because no // non-terminal step can be inline-owned by the acked id // when turbo is on. If turbo ever engages on redeliveries, // those paths must first check for owned pending steps and @@ -1097,7 +1097,7 @@ export function workflowEntrypoint( let runReadyBarrier: Promise | undefined; // Order a terminal run write (run_completed / run_failed) after - // the backgrounded run_started in turbo mode — a no-step + // the backgrounded run_started in turbo mode, since a no-step // workflow can otherwise reach run_completed before the run // exists. Best-effort: a barrier rejection is swallowed for // ordering only; if run_started truly failed the terminal write @@ -1108,7 +1108,7 @@ export function workflowEntrypoint( try { await runReadyBarrier; } catch { - // intentional: ordering barrier only — see above. + // intentional: ordering barrier only, see above. } } }; @@ -1118,7 +1118,7 @@ export function workflowEntrypoint( // CURRENT delivery's message. In turbo that is a trap: the // current message carries `runInput`, and on async queues // (e.g. graphile-worker) a reschedule comes back as delivery - // attempt 1 — so turbo re-engages, skips the event-log load + // attempt 1, so turbo re-engages, skips the event-log load // again, replays against an empty log, never observes the // hook event this invocation just wrote, and re-suspends // forever (the run wedges). Under turbo we instead enqueue an @@ -1133,7 +1133,7 @@ export function workflowEntrypoint( // body is in flight. Crash recovery depends on the owning // message NOT being acked while such a step is non-terminal // (ack = handler return or reinvoke, which acks + enqueues - // under a NEW message id — the redelivery of the old id is + // under a NEW message id: the redelivery of the old id is // what re-executes an orphaned owned step). All executeStep // calls are awaited before any ack path runs, so this set // is empty at every ack by construction; the check exists @@ -1155,7 +1155,7 @@ export function workflowEntrypoint( }; // A plain orchestrator-replay message. Omits `runInput` (it - // re-engages turbo on the next delivery and wedges the run — + // re-engages turbo on the next delivery and wedges the run, // see `reinvoke` below) and `replayDivergence` / // `serverErrorRetryCount` (this delivery chain's budgets). const replayMessage = @@ -1170,7 +1170,7 @@ export function workflowEntrypoint( /** * Extra fields to carry on the new message. Only reaches the * next invocation on the turbo path: without turbo no - * message is enqueued at all — the handler returns a + * message is enqueued at all: the handler returns a * visibility timeout and the queue redelivers the CURRENT * message, whose body (and therefore any counter on it) * cannot be changed. @@ -1195,8 +1195,8 @@ export function workflowEntrypoint( * Event ids the discarded replay held, kept until the next * load resolves so a restart can report what its reload * actually found. Without this a 412 only ever tells you that - * a restart happened, which conflates two very different - * situations: a log that grew (expected — the fence did its + * a restart happened, which conflates two distinct + * situations: a log that grew (expected, the fence did its * job) and a log that came back identical, which means client * and backend disagree about the count for the same set of * events and the restart will re-derive the same snapshot and @@ -1258,12 +1258,12 @@ export function workflowEntrypoint( * * A 412 means the log this replay derived its events from was * missing an event the backend had already recorded. The - * rejected write cannot simply be retried: correlation ids + * rejected write cannot be retried: correlation ids * are positional ordinals of one seeded sequence, so a replay * over the corrected log mints a different id for the same * logical event, and re-posting this one would persist an * event no correct replay ever produces. The whole replay has - * to be re-derived — which the loop does by discarding its + * to be re-derived, which the loop does by discarding its * cached log, since `runWorkflow` then builds a fresh VM, * seed and correlation-id sequence from the reloaded events. */ @@ -1373,7 +1373,7 @@ export function workflowEntrypoint( * (see `reinvoke`). A redelivery-based hop keeps the same body * and so the same count, but it also keeps advancing * `metadata.attempt`, which the max-delivery check at the top - * of the handler already bounds — and the delay below is what + * of the handler already bounds, and the delay below is what * makes that budget span real time rather than being burned in * a tight loop. */ @@ -1414,7 +1414,7 @@ export function workflowEntrypoint( // Delayed, unlike the other reinvoke() callers: the // in-process restarts already reloaded the log several times // without catching up, so the writers this replay is racing - // are still active. Retrying instantly just burns the + // are still active. Retrying instantly burns the // per-run budget at full speed. return { reinvoked: true, @@ -1468,17 +1468,17 @@ export function workflowEntrypoint( // If incoming message has a stepId, this is a background step // execution. Execute the step, then check if all parallel steps // from the batch are done. If so, replay inline (saving a queue - // roundtrip). If not, return — the last handler to complete + // roundtrip). If not, return: the last handler to complete // will pick up the replay. if (incomingStepId && incomingStepName) { try { // Resilient step dispatch: the producer parallelized the // `step_created` write with this queue publish, so the // step entity may not exist yet when this delivery - // executes — the delivery beat the write, or the write + // executes: the delivery beat the write, or the write // failed transiently and this message carries the only // copy of the input. Idempotently re-ensure the event - // from the message's `stepInput` — keyed by the step's + // from the message's `stepInput`, keyed by the step's // correlation id, so the producer's write and this // re-ensure converge on exactly one event. // @@ -1493,11 +1493,11 @@ export function workflowEntrypoint( // (attempt resets to 1), so an attempt-gated recovery // is unreachable on the retry chain and the step // would stall until the ORIGINAL message's - // ~300s visibility-timeout redelivery — measured + // ~300s visibility-timeout redelivery, measured // exactly so in the durabench parallel sweeps before // this path existed. // - EAGERLY on a genuine redelivery (attempt > 1), - // in parallel with the run fetch below — a + // in parallel with the run fetch below, since a // redelivered dispatch already had its create race // resolved either way, so this saves the failed // start round-trip at no wall-time cost. First @@ -1536,7 +1536,7 @@ export function workflowEntrypoint( viaStepDispatch: true, } ); - // This delivery materialized the step — the + // This delivery materialized the step, the // completion of the producer's recovery path. span?.setAttributes( Attribute.StepResilientDispatchMaterialized(true) @@ -1556,11 +1556,11 @@ export function workflowEntrypoint( // Nothing left to execute: the run went terminal // (matches the run-status check below), or a // guard-enforcing backend revoked this dispatch - // (410 `step-dispatch-revoked` — the producer's + // (410 `step-dispatch-revoked`, the producer's // write was 412-rejected and the replay restarted // with a corrected schedule). if (RunExpiredError.is(err)) return 'gone'; - // Transient — rethrow so the queue redelivers and a + // Transient: rethrow so the queue redelivers and a // later attempt converges instead of executing (and // acking) a step that may not exist. throw err; @@ -1589,7 +1589,7 @@ export function workflowEntrypoint( ); return; } - // Covers every queued step execution — first dispatch and + // Covers every queued step execution, first dispatch and // redeliveries/retries alike. if ( (await guardDeployment(bgRun, async () => ({ @@ -1610,8 +1610,8 @@ export function workflowEntrypoint( // Retry ceiling for a backgrounded step. `metadata.attempt` // (the queue delivery count) is a cheap upper bound, but it - // over-counts: a ThrottleError / TooEarlyError — or any - // redelivery that never ran the body — still advances it, so + // over-counts: a ThrottleError / TooEarlyError (or any + // redelivery that never ran the body) still advances it, so // trusting it directly could fail a step as "exceeded max // retries" before the body ever ran (a user-visible // regression under transient backend pressure). Use it only @@ -1619,12 +1619,12 @@ export function workflowEntrypoint( // step cannot be exhausted, so proceed without touching the // log. Only once it crosses the ceiling do we load the full // event log and derive the authoritative attempt from the - // recorded `step_started` count — scoped to the lifecycle + // recorded `step_started` count, scoped to the lifecycle // attempt total (bare starts plus the largest single // owner's starts): throttle/too-early redeliveries write // no start at all, racing invocations' one-off stamped // duplicates don't accumulate (counting them falsely - // exhausted healthy steps — see countStepStartedEvents), + // exhausted healthy steps, see countStepStartedEvents), // and attempts burned under a prior inline-ownership // phase still count, so a step that times out under // owned recovery and then transitions to queued/bare @@ -1652,7 +1652,7 @@ export function workflowEntrypoint( const bgResumeTracking = resumeTracking; resumeTracking = undefined; - // Pause the replay budget while the step body runs — + // Pause the replay budget while the step body runs: // step duration is bounded by the platform's function // maxDuration, not by the replay timeout. See the // ReplayBudget docs for the contract. @@ -1661,7 +1661,7 @@ export function workflowEntrypoint( try { // Single-flight: a delayed backstop (or retry) message // can arrive while another execution of this same step - // is mid-body in this process — most importantly on + // is mid-body in this process, most importantly on // worlds with no invocation kill bound (world-local), // where the ownership lease is not a death proof. The // loser awaits the winner's settlement, then acks @@ -1702,7 +1702,7 @@ export function workflowEntrypoint( // failure of) the producer's parallel // step_created write. Materialize the event // from the payload and retry ONCE, within this - // delivery — see ensureStepFromMessage for why + // delivery. See ensureStepFromMessage for why // this cannot wait for a redelivery. A second // failure propagates as before. if (!stepInput || !isStepMissingError(err)) { @@ -1726,7 +1726,7 @@ export function workflowEntrypoint( } // If step had pending ops (stream writes), break and let - // waitUntil flush them — can't continue inline. + // waitUntil flush them, so can't continue inline. if ( stepResult.type === 'completed' && stepResult.hasPendingOps @@ -1798,7 +1798,7 @@ export function workflowEntrypoint( ); } else { // Other steps still in progress. Return without - // queuing — the last handler to complete will see + // queuing: the last handler to complete will see // all steps done and replay inline. runtimeLogger.debug( 'Background step done but other steps pending, returning', @@ -1808,7 +1808,7 @@ export function workflowEntrypoint( } } - // All steps done — fall through to the main replay loop. + // All steps done: fall through to the main replay loop. // Set up shared state so the loop can continue. runtimeLogger.debug( 'All parallel steps done, replaying inline after background step', @@ -1872,7 +1872,7 @@ export function workflowEntrypoint( // pinned deployment (`hookInput.deploymentId`), so a // misrouted delivery is detectable with a cheap ambient // deployment-id comparison BEFORE the fast path's - // hook_received write — the correctly-routed common case + // hook_received write, so the correctly-routed common case // pays no run fetch and no latency. Only a detected // mismatch fetches the authoritative run and hands it to // the existing guard (which owns re-route/fail policy); @@ -1881,8 +1881,8 @@ export function workflowEntrypoint( // When the pinned or ambient id is unavailable (older // producer message, a world without deployment affinity, // or a getDeploymentId failure), behavior is unchanged: - // the authoritative guard after run setup — which remains - // the protection before replay and step execution — still + // the authoritative guard after run setup (which remains + // the protection before replay and step execution) still // covers the delivery; the fast path's write is idempotent // per (runId, resumeId), so a pre-guard write from an // older message stays convergent. @@ -1915,8 +1915,8 @@ export function workflowEntrypoint( async () => ({ ...(await replayMessage()), hookInput, - // Forwarded UNMODIFIED — in particular without - // this delivery's entry time — so the misrouted + // Forwarded UNMODIFIED (in particular without + // this delivery's entry time) so the misrouted // hop is attributed to `queue_delivery` and T2 // ends up being the final consumer's entry. ...(hookResumeTiming ? { hookResumeTiming } : {}), @@ -1935,7 +1935,7 @@ export function workflowEntrypoint( // re-ensure above run_started and asks the World to return // the current replay log with the write (preloadEvents). A // supporting World answers with the reconstructed run, the - // complete replay log, and the run's event ceiling — + // complete replay log, and the run's event ceiling: // everything the generic setup below would spend a // run_started POST and an events.list on. Any other result // (older server, a World that ignores the param, or a @@ -1955,7 +1955,7 @@ export function workflowEntrypoint( ) { const hookResumeInput = hookInput; // Date the materialized event to when the resume actually - // occurred — same derivation as the re-ensure below (the + // occurred, same derivation as the re-ensure below (the // resumeId is a ULID minted by resumeHook() at resume // time). let occurredAt: Date | undefined; @@ -1990,7 +1990,7 @@ export function workflowEntrypoint( ); hookEnsured = true; // Note: unlike the re-ensure below, this hoisted write - // does NOT set HookResilientResumeMaterialized — it + // does NOT set HookResilientResumeMaterialized: it // runs on every fast-path resume, including the common // case where the producer's direct write already landed // and this call merely converged on it, so it carries @@ -2000,15 +2000,16 @@ export function workflowEntrypoint( // The preload is usable as replay input only when it is // demonstrably the complete picture: a reconstructed // run with a start time, a non-empty COMPLETE log - // (hasMore false — this path has no cursor-continuation - // machinery, so a bounded page must not be trusted), + // (hasMore false, since this path has no + // cursor-continuation machinery, so a bounded page must + // not be trusted), // the server's event ceiling (this response plays // run_started's role, so a missing ceiling would leave // event-limit enforcement disabled for the run), // both run lifecycle events, the canonical event this // write converged on, and the hook_received matching // THIS resume (so we never replay against a log that is - // missing the very event that triggered this delivery). + // missing the event that triggered this delivery). const usableReplayPreload = result.run !== undefined && result.run.startedAt !== undefined && @@ -2038,7 +2039,7 @@ export function workflowEntrypoint( // The reconstructed run always reads 'running', but a // terminal event committed concurrently rides in the // log itself. The node replay loop would catch it, but - // QuickJS dispatches before that check — so consume + // QuickJS dispatches before that check, so consume // the delivery here, before any engine runs. Same // outcome as the run_started path's non-running // status check. @@ -2077,7 +2078,7 @@ export function workflowEntrypoint( } workflowRun = result.run; maxEventsLimit = clampMaxEvents(result.maxEvents); - // Anchors RSFS — see the declaration above. This + // Anchors RSFS, see the declaration above. This // response plays run_started's role on this path. runStartedReceivedAtMs = Date.now(); if (resumeTracking) { @@ -2103,8 +2104,8 @@ export function workflowEntrypoint( // failed validation): take the generic run_started // setup below. Its preload is loaded after this write // committed, so the canonical hook_received is part - // of whatever log that setup reads — no splice - // needed. + // of whatever log that setup reads, so no splice + // is needed. span?.setAttributes( Attribute.HookResumeSetupSource( 'hook_received_fallback' @@ -2116,7 +2117,7 @@ export function workflowEntrypoint( // - HookNotFound / RunExpired: the run went terminal; // nothing left to resume, consume the message. // - anything else (EntityConflict, a truncated preload - // stream, transport failures): transient — rethrow so + // stream, transport failures): transient. Rethrow so // the queue redelivers and the idempotent // (runId, resumeId) claim converges on retry. if ( @@ -2138,7 +2139,7 @@ export function workflowEntrypoint( // step path (inline replay after all parallel steps done) // or by the lazy hook fast path above. if (!workflowRun) { - // Always call run_started directly — this both transitions + // Always call run_started directly: this both transitions // the run to 'running' AND returns the run entity, saving // a separate runs.get round-trip. // Contract: events.create('run_started') must be idempotent @@ -2173,7 +2174,7 @@ export function workflowEntrypoint( // Turbo: background `run_started` and synthesize the run // entity locally so replay can begin without waiting for // the round-trip. Safe here because this is the first - // delivery of the first invocation — start() created the + // delivery of the first invocation: start() created the // run moments ago and no events have been written yet. The // barrier is consumed by every downstream write (suspension // handler, optimistic step_started, terminal run writes) so @@ -2187,17 +2188,18 @@ export function workflowEntrypoint( // never read its preloaded events, so skip the // run_started event-log preload. That trims the // run_started request the chained first step_started - // waits on — shortening time-to-second-step — and the + // waits on (shortening time-to-second-step) and the // wasted list+resolve it would otherwise compute. { requestId, skipPreload: true } ); runReadyBarrier = startedPromise; - // Turbo backgrounds run_started, so the non-turbo assignment - // below never runs — thread the per-run event ceiling off the - // backgrounded response here instead. The guard re-checks - // maxEventsLimit every loop iteration, so a value that lands - // shortly after start still enforces well before a runaway - // log approaches the ceiling. + // Turbo backgrounds run_started, so the non-turbo + // assignment below never runs. Thread the per-run event + // ceiling off the backgrounded response here instead. + // The guard re-checks maxEventsLimit every loop + // iteration, so a value that lands shortly after start + // still enforces well before a runaway log approaches + // the ceiling. void startedPromise .then((r) => { const limit = clampMaxEvents(r.maxEvents); @@ -2235,7 +2237,7 @@ export function workflowEntrypoint( // "structural until a read API is introduced"), so the // empty preloaded log can't diverge on a read. If a read // API is ever added it MUST read from this snapshot, not - // by replaying run_created/attr_set events — otherwise + // by replaying run_created/attr_set events, otherwise // turbo's empty initial log would surface seed attributes // as `{}` on the first delivery only. attributes: runInput.attributes ?? {}, @@ -2263,7 +2265,7 @@ export function workflowEntrypoint( }); workflowRun = result.run; maxEventsLimit = clampMaxEvents(result.maxEvents); - // Anchors RSFS — see the declaration above. + // Anchors RSFS, see the declaration above. runStartedReceivedAtMs = Date.now(); // Covers both the plain sequential resume and the // lazy fast path's fallback (whose hoisted write @@ -2306,13 +2308,13 @@ export function workflowEntrypoint( return; } } catch (err) { - // Run was concurrently completed/failed/cancelled + // Run was concurrently completed/failed/canceled if ( EntityConflictError.is(err) || RunExpiredError.is(err) ) { // EntityConflictError: run was concurrently - // completed/failed/cancelled during setup. + // completed/failed/canceled during setup. // RunExpiredError: run already in terminal state. // In both cases, skip processing this message. runtimeLogger.info( @@ -2348,7 +2350,7 @@ export function workflowEntrypoint( // loaded and setup was a plain event load. Only the // background-step fall-through reaches here with a run // preloaded today, and it consumes the tracking before this - // point — so this is the honest default rather than a live + // point, so this is the honest default rather than a live // path, and keeps the dimension total if one is ever added. if ( resumeTracking && @@ -2357,8 +2359,8 @@ export function workflowEntrypoint( resumeTracking.setupSource = 'event_load'; } - // Covers every flow replay — initial start, step completions, - // hook resumptions, wait completions — and stops before any + // Covers every flow replay (initial start, step completions, + // hook resumptions, wait completions) and stops before any // workflow code or inline step executes when the run is not // pinned here (see `guardDeploymentAffinity`). This remains // the AUTHORITATIVE protection before replay and step @@ -2368,12 +2370,12 @@ export function workflowEntrypoint( // Lazy hook resumes are additionally pre-checked above the // fast path: a new message's `hookInput.deploymentId` is // compared against the ambient deployment id for free, and - // only a detected mismatch fetches the authoritative run — + // only a detected mismatch fetches the authoritative run, // so a misrouted modern resume re-routes with zero event // writes. Older messages (no `hookInput.deploymentId`) reach // the fast path's idempotent hook_received write before this // guard; that write converges per (runId, resumeId) on the - // pinned deployment, so it is safe, just not free. Either + // pinned deployment, so it is safe but incurs the write. Either // way the re-routed message must carry `hookInput`, or a // resume whose producer write had not yet landed would be // lost. @@ -2396,14 +2398,14 @@ export function workflowEntrypoint( // Lazy hook resume: the producer (resumeHook fast path) // parallelized the `hook_received` write with this queue // publish, so the event may not be persisted yet. Idempotently - // ensure it before replay — keyed by `resumeId` so a + // ensure it before replay, keyed by `resumeId` so a // concurrent producer write converges on exactly one event // (the server resolves a matching claim as success, not an // error). `hookInput` never rides a turbo first-delivery // (that path carries `runInput`, not `hookInput`), so this // only runs on the normal load-and-replay path. Skipped // entirely when the fast path above already ensured the - // event (`hookEnsured`) — successfully or via its fallback, + // event (`hookEnsured`), successfully or via its fallback, // whose run_started preload was loaded after the write and // therefore already contains the canonical event. if (hookInput && !hookEnsured) { @@ -2419,8 +2421,8 @@ export function workflowEntrypoint( // // Intentionally unreachable for atomic lazy resumes // (resumeId + digest): those set `hookEnsured` in the - // hoisted fast path above — on success AND on its - // fallback — so this block (and the skip check) now only + // hoisted fast path above (on success AND on its + // fallback), so this block (and the skip check) now only // serves hookInput shapes without the full idempotency // pair. const alreadyPreloaded = @@ -2432,7 +2434,7 @@ export function workflowEntrypoint( e.resumeId === hookInput.resumeId ); if (alreadyPreloaded) { - // Event already visible in the preloaded log — nothing to + // Event already visible in the preloaded log: nothing to // ensure or splice. } else { // Date the materialized event to when the resume actually @@ -2440,7 +2442,7 @@ export function workflowEntrypoint( // is the ULID minted by `resumeHook()` at resume time, so // its embedded timestamp is the honest `occurredAt` and // keeps latency attribution off the queue round trip. A - // non-ULID resumeId (legacy / test) simply leaves it + // non-ULID resumeId (legacy / test) leaves it // undefined, so the World falls back to `createdAt`. let occurredAt: Date | undefined; try { @@ -2477,8 +2479,8 @@ export function workflowEntrypoint( span?.setAttributes( Attribute.HookResilientResumeMaterialized(true) ); - // The canonical event — whether this call committed it or - // converged on the producer's concurrent write — so we can + // The canonical event (whether this call committed it or + // converged on the producer's concurrent write), so we can // splice it into the preloaded log instead of discarding // the preload (see below). // @@ -2516,7 +2518,7 @@ export function workflowEntrypoint( // already ended this resume's eligibility (the run went // terminal). There is nothing left to resume, so consume // the message and stop. Continuing to replay would be - // wasted work — and, worse, would ack a delivery that may + // wasted work and, worse, would ack a delivery that may // carry the only copy of the payload. if ( HookNotFoundError.is(err) || @@ -2526,7 +2528,7 @@ export function workflowEntrypoint( } // - EntityConflict (and any other unexpected error): the // resumeId constraint exists but the matching event is - // not yet observable — the producer's parallel write is + // not yet observable: the producer's parallel write is // still in flight, or a redrive raced the claim. This is // transient: rethrow so the queue redelivers and a later // attempt converges on the committed event instead of @@ -2642,7 +2644,7 @@ export function workflowEntrypoint( // escape the entrypoint (MaxEventsExceededError, a // WASM OOM at the memory ceiling, a bundle-eval // failure) must reach this loop's catch so they are - // classified and recorded as run_failed — outside the + // classified and recorded as run_failed: outside the // try they would nack the message and burn all queue // redeliveries before dying as // MAX_DELIVERIES_EXCEEDED. Transient world errors @@ -2656,7 +2658,7 @@ export function workflowEntrypoint( // Under turbo, run_started is backgrounded. The // QuickJS entrypoint fetches the event log and // writes events directly, so wait for the run to be - // durably started first — it does not thread the + // durably started first: it does not thread the // turbo runReadyBarrier the way handleSuspension // does. await awaitRunReady(); @@ -2670,8 +2672,8 @@ export function workflowEntrypoint( // Lazy import: the QuickJS entrypoint's import chain // embeds the base64 WASM binary + extensions // (~1.3 MB decoded at module scope). Loading it here - // keeps that out of node-engine deployments entirely - // — only the opt-in path pays, on first dispatch. + // keeps that out of node-engine deployments entirely, + // so only the opt-in path pays, on first dispatch. const { runWorkflowWithQuickJS } = await import( './runtime/quickjs-entrypoint.js' ); @@ -2707,7 +2709,7 @@ export function workflowEntrypoint( // current message carries `runInput` and a // reschedule would re-engage turbo on redelivery // (replaying against a stale preloaded log and - // wedging the run — see the reinvoke() docs + // wedging the run, see the reinvoke() docs // above). reinvoke enqueues an explicit // continuation without `runInput` in that case. return await reinvoke(quickjsResult.timeoutSeconds); @@ -2996,7 +2998,7 @@ export function workflowEntrypoint( if (workflowResult.type === 'suspended') { // Park the live session; the suspension catch below - // makes the one retention decision — keep it for the + // makes the one retention decision: keep it for the // next iteration or discard it for a fresh replay. retainedSession = workflowResult.session; throw workflowResult.suspension; @@ -3052,7 +3054,7 @@ export function workflowEntrypoint( if (WorkflowSuspension.is(err)) { replayRecoveryReporter.activate(); // Synchronous workflow-execution duration for THIS - // suspension only — anchors the `finalSchedulingReplay` + // suspension only: anchors the `finalSchedulingReplay` // telemetry field below (see // StepLatencyTracking.replayMs). This is the FINAL // replay pass, the one that reached and scheduled the @@ -3062,7 +3064,7 @@ export function workflowEntrypoint( // redelivery omits earlier invocations' replay work // entirely. This value is NOT accumulated across // those earlier passes, so it must not be read as - // "the replay portion of rsfs" — rsfs covers the + // "the replay portion of rsfs": rsfs covers the // whole run_started-to-first-step window; // finalSchedulingReplay covers only this last pass. // Captured here, before `handleSuspension`'s awaited @@ -3081,7 +3083,7 @@ export function workflowEntrypoint( // bimodal once retention is active. const suspendedAtMs = Date.now(); const replayDurationMs = suspendedAtMs - replayStart; - // TTR T4 — the next durable step has been encountered. + // TTR T4: the next durable step has been encountered. // Gated on the suspension actually scheduling steps: // a suspension that only creates hooks/waits has not // reached a step, and a later pass may. Taken from @@ -3115,7 +3117,7 @@ export function workflowEntrypoint( // backend holding an event this replay never saw rejects // the write (412) instead of accepting a divergent one. // The rejection is handled here, by restarting the - // replay — never by re-posting the same event. + // replay, never by re-posting the same event. const suspensionStart = Date.now(); assert( eventLog.type === 'ready', @@ -3149,8 +3151,8 @@ export function workflowEntrypoint( }, // Inline pre-claims: lets the batched fan-out // fold the lazy-inline steps' step_created + - // step_started pairs — stamped with this - // message's ownership — into the one commit, so + // step_started pairs (stamped with this + // message's ownership) into the one commit, so // the bodies below start straight off it. See // SuspensionHandlerResult.inlineClaims. ownerMessageId: metadata.messageId, @@ -3159,7 +3161,7 @@ export function workflowEntrypoint( // step-message publishes ride // `deferredBatchWork`, which this invocation // joins before it can ack (below, next to the - // dispatch join) — so the durability contract + // dispatch join), so the durability contract // is unchanged while the bodies start earlier. allowDeferredBatchWork: true, }); @@ -3169,7 +3171,7 @@ export function workflowEntrypoint( // Once the in-process budget is spent, fall back to an // explicit immediate re-invocation (a rethrow relies // on redelivery of a message the turbo path already - // acked — the run would stall for the queue's ~300s + // acked, so the run would stall for the queue's ~300s // default visibility timeout). if (PreconditionFailedError.is(suspensionError)) { if ( @@ -3196,7 +3198,7 @@ export function workflowEntrypoint( throw suspensionError; } // Non-retryable failure while committing the - // suspension's events — e.g. an attribute write + // suspension's events, e.g. an attribute write // the World rejected as invalid (the cumulative // per-run cap can only be checked World-side). // Redelivery would replay the workflow into the @@ -3276,13 +3278,13 @@ export function workflowEntrypoint( // Open hooks/waits in the log as loaded for this // replay. This suspension's own hook/wait writes are - // NOT in it — they never reach retention anyway, + // NOT in it: they never reach retention anyway, // because a suspension containing a non-step item // fails canRetainWorkflowSession's type check before // the scan is consulted. Computed // lazily, at most once, and shared between the // retention decision here and the delta/turbo gates - // below — the attr-detour and hook-conflict paths + // below, since the attr-detour and hook-conflict paths // return/continue before the gates and usually // short-circuit before ever scanning the log. const openHookWait = once(() => { @@ -3333,7 +3335,7 @@ export function workflowEntrypoint( // log (now holding the just-committed attr_set) and // replays, resolving the setAttributes promise. Skip // step processing for this pass so that replay decides - // races first — in Promise.race([setAttributes(), + // races first: in Promise.race([setAttributes(), // step()]), the durable attribute event must be able // to win without executing the losing step. The replay // happens in-process rather than via a queue @@ -3352,7 +3354,7 @@ export function workflowEntrypoint( // + step_failed (see finalizeUnserializableStep). No // step-execution message is dispatched for them, so // when such a step is the only pending work nothing - // would ever re-invoke the run — replay in-process + // would ever re-invoke the run, so replay in-process // over the reloaded log instead. The replay rejects // the step's promise with the SerializationError, // which a try/catch around the step call observes; @@ -3371,7 +3373,7 @@ export function workflowEntrypoint( // below: this invocation must not proceed (and // eventually ack) before every create and publish // it launched is durable. A rejection propagates - // like theirs — transient world errors rethrow to + // like theirs: transient world errors rethrow to // the queue for redelivery. await suspensionResult.deferredBatchWork; // Inline steps whose pair-folded step_started this @@ -3381,7 +3383,7 @@ export function workflowEntrypoint( // pending steps, and owned recovery (the claims // carry this message's ownerMessageId) re-executes // them there. Its "previous delivery crashed - // mid-body" log is a misnomer on this path — + // mid-body" log is a misnomer on this path: // nothing crashed, the bodies were never started. // // The failed dehydration may have executed @@ -3400,7 +3402,7 @@ export function workflowEntrypoint( // Inline execution is gated on ownership. The // suspension handler deferred the step_created write for // up to `getMaxInlineSteps()` steps (`lazyInlineSteps`) - // so we can run them inline — in parallel — via lazy + // so we can run them inline (in parallel) via lazy // `step_started` events that create each step on the fly, // saving one world round-trip per inline step. Ownership // is still atomic and exactly-one per step: the world's @@ -3417,7 +3419,7 @@ export function workflowEntrypoint( // inline: an inline `await executeStep(...)` blocks this // handler for the full step duration, so the awaiter's // continuation (which only advances on the next replay) - // would be serialized behind the step — defeating work + // would be serialized behind the step, defeating work // the workflow expressed as parallel (e.g. // `hook.getConflict().then(() => stepB())` racing `await // stepA()`). In that case `lazyInlineSteps` is empty and @@ -3446,7 +3448,7 @@ export function workflowEntrypoint( // redelivery of the owning message re-executes // the step it crashed on, via a payload-less // step_started re-stamped with its - // ownerMessageId — not a bare start). + // ownerMessageId, not a bare start). // - Inline-owned, owner !== this message → // ensure a DELAYED backstop wake exists // (delaySeconds = ownership lease remaining) @@ -3467,7 +3469,7 @@ export function workflowEntrypoint( // idempotencyKey is scoped to the ownership // EPOCH (latest step_started timestamp), NOT // just the correlation ID, and must never be - // the step message's own key — see + // the step message's own key. See // backstopIdempotencyKey for both invariants // (fixed keys either absorb the retry handoff // or dedupe the refreshed-lease re-arm against @@ -3495,7 +3497,7 @@ export function workflowEntrypoint( // maximum queue delay (long waits chain across // multiple hops) and its idempotency key dedupes // re-observations of the same pending wait across - // suspension passes — see + // suspension passes. See // runtime/wait-continuation.ts for the full // delay/key selection rationale. const traceCarrier = await nextTraceCarrier(); @@ -3508,7 +3510,7 @@ export function workflowEntrypoint( // TTR hand-off. The measurement may only go to an // execution that will actually ATTEMPT the next // durable step, and the loop below is what decides - // which those are — so the decision is made against + // which those are, so the decision is made against // its classification, never ahead of it. // // This invocation keeps the tracking whenever it will @@ -3521,9 +3523,9 @@ export function workflowEntrypoint( // enqueues as a step message. // // A step converted into a delayed backstop wake is - // NOT an attempt — the wake is a plain run + // NOT an attempt (the wake is a plain run // continuation, and whichever invocation eventually - // runs the step is a different delivery — so it must + // runs the step is a different delivery), so it must // not take the sample. If every pending step becomes // a backstop, nothing here attempts the step and the // resumption goes unreported, which is the honest @@ -3532,8 +3534,8 @@ export function workflowEntrypoint( // // Gated on there being a measurement to place at all: // `&&` short-circuits, so a delivery carrying no - // tracking — every non-resume delivery, and every - // resume past the step that consumed it — never pays + // tracking (every non-resume delivery, and every + // resume past the step that consumed it) never pays // for the pending-step scan. That matters on wide // fan-outs, where `pendingSteps` runs to hundreds. let handOffResumeTiming = @@ -3580,7 +3582,7 @@ export function workflowEntrypoint( // ownership lease is live; immediate step enqueue // otherwise (lease expired ⇒ remaining 0 ⇒ same as // today, which is also the degraded mode for - // worlds with unstable message IDs — the owner + // worlds with unstable message IDs, where the owner // check above never matches there). const backstopDelaySeconds = ownershipActive ? stepLeaseRemainingSeconds(step, dispatchNowMs) @@ -3615,7 +3617,7 @@ export function workflowEntrypoint( } // This step IS being attempted, by the invocation // that picks the message up. Consume the tracking - // here — the first such step and no other. + // here, for the first such step and no other. const stepResumeTiming = handOffResumeTiming ? resumeTimingForMessage(resumeTracking) : undefined; @@ -3643,8 +3645,8 @@ export function workflowEntrypoint( // handlers, crash-recovery re-dispatch, the // suspension handler's resilient publish) // without absorbing a dispatch of a different - // step under a reassigned correlation id — - // see stepDispatchIdempotencyKey. + // step under a reassigned correlation id. + // See stepDispatchIdempotencyKey. idempotencyKey: stepDispatchIdempotencyKey( step.correlationId, step.stepName @@ -3674,7 +3676,7 @@ export function workflowEntrypoint( // run CONCURRENTLY: the suspension commit already made // every dispatched step durable (and, when the fold // engaged, settled the inline pairs' claims), which is - // the only ordering both sides need — so neither waits + // the only ordering both sides need, so neither waits // for the other. The joins below (before step results // are read, and on the no-inline early returns) keep // the failure contract: a dispatch rejection still @@ -3689,10 +3691,10 @@ export function workflowEntrypoint( // The set of steps THIS invocation executes: the // deferred lazy-inline batch plus any owned-recovery // steps (this message's redelivery re-executing a - // step it crashed on — no lazyStepInput; the input + // step it crashed on: no lazyStepInput; the input // hydrates from the step entity like the background // path, and the payload-less step_started re-stamps - // ownership — unlike the background path's start it + // ownership. Unlike the background path's start it // is NOT bare: it carries this message's // ownerMessageId, which is also what the // owned-recovery retry ceiling counts). @@ -3732,7 +3734,7 @@ export function workflowEntrypoint( // attributes so production traces show when crash // recovery ran or a wake was converted into a // backstop, and a warn (always printed, unlike - // debug/info) for owned recovery — it means a prior + // debug/info) for owned recovery, since it means a prior // delivery of this message died mid-step-body. if ( backstopWakesArmed > 0 || @@ -3763,12 +3765,12 @@ export function workflowEntrypoint( ); } - // Nothing to execute inline — everything has been + // Nothing to execute inline: everything has been // queued (or no work needs scheduling). Exit and let // the queue drive subsequent replays. if (inlineExecutions.length === 0) { // Nothing runs concurrently with the dispatches on - // this path — join them (and the fold's deferred + // this path, so join them (and the fold's deferred // chunk commits/publishes) here so a failure fails // the delivery exactly as it always has. await Promise.all([ @@ -3791,9 +3793,9 @@ export function workflowEntrypoint( // below; resolve the memoized scan once here. const openHookWaitState = openHookWait.value; - // Inline-delta fast path gate. We request the delta — - // and on the next iteration consume it in place of the - // events.list — only when ALL hold: + // Inline-delta fast path gate. We request the delta + // (and on the next iteration consume it in place of the + // events.list) only when ALL hold: // // - We have a real prior cursor to diff against (a // World may return none on the initial load). @@ -3802,7 +3804,7 @@ export function workflowEntrypoint( // waits (`err.{step,wait}Count`), that one step is // the lone pending step (`pendingSteps.length === 1`) // and the lone inline step - // (`lazyInlineSteps.length === 1` — no parallel + // (`lazyInlineSteps.length === 1`: no parallel // siblings queued to background handlers, and no other // inline step writing its own events out of band). // - No pending wait timer from THIS suspension, and no @@ -3859,7 +3861,7 @@ export function workflowEntrypoint( // Stale-sensitive batch: a hook is open in the run (or // was created by this suspension, so its hook_received - // can land any moment) — an out-of-band event can make + // can land any moment), so an out-of-band event can make // the view this batch was scheduled from stale, which // is when several invocations race for one step's // claim. Optimistic start begins the body before the @@ -3880,11 +3882,11 @@ export function workflowEntrypoint( suspensionResult.hasHookEvents; // Turbo mode forces optimistic inline start for this - // batch — but only while the run is still "clean" (a pure + // batch, but only while the run is still "clean" (a pure // step suspension). The moment a hook or wait is // created, later resume/parallel invocations become // possible, so the single-handler guarantee that makes - // forced optimistic start safe no longer holds — turbo + // forced optimistic start safe no longer holds: turbo // exits and the steps take the normal (env-gated) // await-then-run path. The hook-conflict case already // returned early above, the attr case continued into a @@ -3901,12 +3903,12 @@ export function workflowEntrypoint( // step suspensions). Once any hook or wait is open in the // cumulative log, resume/parallel invocations are possible // for the rest of the run, so turbo must latch off - // permanently — checked here via `openHookAndWaitState` + // permanently, checked here via `openHookAndWaitState` // over the cumulative event log. // // NOTE: `WORKFLOW_SEQUENTIAL_REPLAYS=1` (per-run flow // topics consumed with `maxConcurrency: 1`) would in - // principle waive this latch — serialized orchestrator + // principle waive this latch: serialized orchestrator // invocations restore the single-handler guarantee for // the whole delivery. The waiver is intentionally NOT // taken: the env var is a runtime-process setting that @@ -3928,9 +3930,9 @@ export function workflowEntrypoint( !openHookWaitState.openWait; // Execute the inline steps in parallel. The replay - // budget is paused for the whole batch — step duration is + // budget is paused for the whole batch (step duration is // bounded by the platform's function maxDuration, not the - // replay timeout — so the budget check at the top of the + // replay timeout) so the budget check at the top of the // next loop iteration doesn't charge the step bodies. // Latency telemetry: decide whether this batch's first // step qualifies for TTFS/STSO measurement. Only the @@ -3964,7 +3966,7 @@ export function workflowEntrypoint( // lazy claim is the first durable write of a hot-path // step (its step_created is deferred), so without a // snapshot it would name no position at all and a stale - // replay could claim — and commit — a step scheduled off + // replay could claim (and commit) a step scheduled off // a view that misses an event it never loaded. // // Taken here rather than inside the executor because @@ -3976,7 +3978,7 @@ export function workflowEntrypoint( ); // The batched fan-out's own events are not in the // loaded log yet (the next iteration reloads), but - // this invocation wrote them — fold the batch's + // this invocation wrote them, so fold the batch's // ceiling in, or every inline terminal write would // name a pre-batch position and be answered with a // skipped-slot report echoing the events this @@ -3993,13 +3995,13 @@ export function workflowEntrypoint( // TTR: consumed by this batch. Every step is handed // the SAME tracking object and its one-shot // `reported` latch picks the single step that - // actually reaches user code — so the sample + // actually reaches user code, so the sample // survives the batch's first step losing its atomic // create-claim to a concurrent invocation while a // sibling runs, without a parallel batch reporting // once per sibling. Cleared here so a later loop - // iteration's steps — and any in-process replay - // restart — cannot report the same resumption again. + // iteration's steps (and any in-process replay + // restart) cannot report the same resumption again. const batchResumeTracking = resumeTracking; resumeTracking = undefined; @@ -4034,13 +4036,13 @@ export function workflowEntrypoint( // metadata.messageId. Starts written by racing // invocations (stale/wake replays, a step // message dispatched off a lost create-claim) - // carry other IDs — or none — and must not + // carry other IDs (or none) and must not // count, or the ceiling falsely exhausts a // healthy step (see countStepStartedEvents). // A lazy step is brand-new by construction (it // enters the batch only when it has no // step_created yet), so it has zero prior - // starts and is always attempt 1 — skip the + // starts and is always attempt 1, so skip the // log scan entirely. Only an owned-recovery // re-run can have prior starts, and that path // is uncommon, so reserve the O(n) scan for it @@ -4086,8 +4088,8 @@ export function workflowEntrypoint( forceOptimisticStart, // Guard-enforced batches with an open hook // await the claim before running the body, so - // a 412-fenced step never executes user code — - // see suppressOptimisticStart above. + // a 412-fenced step never executes user code. + // See suppressOptimisticStart above. suppressOptimisticStart, runReadyBarrier, slotSnapshot: inlineClaimSnapshot, @@ -4109,14 +4111,14 @@ export function workflowEntrypoint( }); }; // Invariant bookkeeping: this invocation owns - // these bodies until they settle — see + // these bodies until they settle. See // assertNoInFlightOwnedSteps. inFlightOwnedSteps.add(s.correlationId); // Lazy and pre-claimed steps are brand-new // (their create-claim is the exactly-once gate), // but an owned-recovery step already exists and // its delayed backstop message may fire mid-body - // in this same process — route those through + // in this same process, so route those through // the in-process single-flight. const executed = s.lazyStepInput === undefined && @@ -4136,7 +4138,7 @@ export function workflowEntrypoint( // and the `Promise.all` that reads them, so a body that // rejects while a publish or a trailing chunk commit is // still in flight would have no handler attached at the - // microtask checkpoint — an `unhandledRejection`, fatal + // microtask checkpoint: an `unhandledRejection`, fatal // under Node's default `--unhandled-rejections=throw`. // A 412 stale-claim rejection races exactly that window. // Attach now; every rejection is still observed by the @@ -4147,7 +4149,7 @@ export function workflowEntrypoint( try { // Join the dispatch publishes launched above and // the fold's deferred batch work (trailing chunk - // commits + per-chunk step-message publishes) — + // commits + per-chunk step-message publishes): // the bodies are already running in parallel with // both, and this invocation must not ack before // every create and publish is durable. A failure @@ -4173,9 +4175,9 @@ export function workflowEntrypoint( // claim: the loaded view this batch was scheduled // from is missing an event the backend already has, // so the claim was fenced by the guard and no step - // events were written. Abandon the batch — any + // events were written. Abandon the batch (any // optimistic body result is discarded by executeStep's - // reconciliation — and restart the replay so it + // reconciliation) and restart the replay so it // observes the missing event. Wait for the sibling // executions to settle first so no owned body is in // flight when the restart (or the ack path) runs. @@ -4185,7 +4187,7 @@ export function workflowEntrypoint( ); // A sibling whose claim was accepted wrote step // events of its own, possibly after the World built - // this 412's delta — so that delta can no longer be + // this 412's delta, so that delta can no longer be // assumed to complete the log, and the restart has // to reload it in full. `skipped` (the step already // existed), `gone` and `throttled` (claim rejected) @@ -4222,14 +4224,14 @@ export function workflowEntrypoint( } // Aggregate the batch results. `retry` steps (which - // already exist — their `step_started` succeeded) are + // already exist, since their `step_started` succeeded) are // re-queued per-step as background steps with their own // delay; `throttled` steps (rejected on the create-claim, // so never created) instead defer redelivery of this // orchestrator message so they re-run inline with input // on replay; completed/failed steps already wrote their // terminal events. We only loop back to replay when every - // inline step reached a terminal state — otherwise the + // inline step reached a terminal state, otherwise the // still-pending steps will be re-run by their queued retry // messages and the background-step path replays once // all steps are done. @@ -4242,7 +4244,7 @@ export function workflowEntrypoint( // orchestrator message rather than being re-queued as a // background step. Crucially, a `throttled` result means // the lazy `step_started` was rejected on the atomic - // create-claim — so the step was NEVER created (no + // create-claim, so the step was NEVER created (no // `step_created`, no step entity). Re-queuing it as a // background step would send a bare `step_started` (no // input), which the world rejects with `Step "" not @@ -4289,7 +4291,7 @@ export function workflowEntrypoint( // Terminal steps (completed/failed/skipped/gone) are // observed from their events and not re-run. Because // the replay drives all remaining work, we must NOT - // also re-queue `toRetry` here — that would + // also re-queue `toRetry` here, since that would // double-dispatch those steps. // // This returns BEFORE the `anyPendingOps` branch @@ -4334,7 +4336,7 @@ export function workflowEntrypoint( // sees the still-`retrying` step as pending // and re-dispatches it *immediately* and // *with* a key. Since this delayed retry had - // no key, the two messages wouldn't dedupe — + // no key, the two messages wouldn't dedupe: // the step would run twice, the configured // retry backoff would be ignored (plain // `Error` retries persist no `retryAfter`, so @@ -4374,13 +4376,13 @@ export function workflowEntrypoint( if (toRetry.length > 0) { // Some inline steps will be re-run via their queued // retry messages; the background-step path replays - // once all steps are terminal. Don't loop here — the + // once all steps are terminal. Don't loop here: the // retrying steps have no terminal event to observe yet. return; } // All inline steps reached a terminal state - // (completed/failed/skipped/gone) — loop back to replay + // (completed/failed/skipped/gone), so loop back to replay // (the workflow observes the terminal events on replay). // // Reuse any inline delta. If it is partial, continue @@ -4398,19 +4400,19 @@ export function workflowEntrypoint( eventLog = nextEventLogLoad(eventLog); } else { // Stale-snapshot rejection of a guarded write made - // directly by the replay loop — the result-bearing + // directly by the replay loop: the result-bearing // `run_completed`, or the `wait_completed` of the wait // pass. Both reach this one catch and the rejection // does not say which, hence the neutral label. // Neither may be re-posted in place: the correlation id // and (for run_completed) the result itself came from // this replay, and a corrected log may produce - // different ones. Don't fail the run — restart the + // different ones. Don't fail the run: restart the // replay in this invocation, and only once that budget // is spent schedule an explicit re-invocation. // Rethrowing instead would rely on redelivery of the // CURRENT message, which the turbo path has already - // acked — empirically the run then stalls for the + // acked. Empirically the run then stalls for the // queue's ~300s default visibility timeout before // completing. let terminalError = err; @@ -4431,12 +4433,13 @@ export function workflowEntrypoint( } // Transient infrastructure failures talking to the - // world (workflow-server) — an exhausted RetryAgent - // (UND_ERR_REQ_RETRY from a sustained 429/503 storm), - // a dropped socket, a connect/DNS failure, or a client - // timeout — must NOT fail the run. Rethrow so the queue - // redelivers and a fresh invocation retries the replay - // once the backend recovers. The @vercel/queue handler + // world (workflow-server), such as an exhausted + // RetryAgent (UND_ERR_REQ_RETRY from a sustained + // 429/503 storm), a dropped socket, a connect/DNS + // failure, or a client timeout, must NOT fail the + // run. Rethrow so the queue redelivers and a fresh + // invocation retries the replay once the backend + // recovers. The @vercel/queue handler // applies a fast (1s→60s) backoff by delivery count, // avoiding the ~5min default visibility-timeout redrive // (and never killing the process via run_failed). @@ -4544,7 +4547,7 @@ export function workflowEntrypoint( // value so that the serialized error preserves it // for consumers. `types.isNativeError()` is used // instead of `err instanceof Error` because the - // workflow runs in a separate VM realm — its Error + // workflow runs in a separate VM realm: its Error // class is distinct from the host's, so `instanceof // Error` is `false` for VM-thrown errors. The V8 // type tag works across realms. diff --git a/packages/core/src/runtime/compute-instance.ts b/packages/core/src/runtime/compute-instance.ts index 9c453d333f..0c40f16e44 100644 --- a/packages/core/src/runtime/compute-instance.ts +++ b/packages/core/src/runtime/compute-instance.ts @@ -3,11 +3,12 @@ import { ulid } from 'ulid'; /** * Identifier for the compute instance (microVM) this module was loaded into. * - * Vercel exposes no native per-instance id under Fluid compute (`AWS_LAMBDA_*` + * Vercel exposes no native per-instance ID under Fluid compute (`AWS_LAMBDA_*` * is blocked), so we synthesize one at module load: a prefixed ULID * (`cinst_`, per the `wrun_`/`step_` convention) whose timestamp is the * instance's birth time. Stable for the instance's life and shared by every - * invocation it handles — including the concurrent ones Fluid packs onto it; - * cold starts mint fresh ids. Emitted as the OTEL `faas.instance` attribute. + * invocation it handles, including the concurrent ones Fluid packs onto it; + * cold starts mint fresh IDs. Emitted as the OpenTelemetry `faas.instance` + * attribute. */ export const COMPUTE_INSTANCE_ID = `cinst_${ulid()}`; diff --git a/packages/core/src/runtime/constants.ts b/packages/core/src/runtime/constants.ts index 26ea76bfd0..2f7bb669d0 100644 --- a/packages/core/src/runtime/constants.ts +++ b/packages/core/src/runtime/constants.ts @@ -10,15 +10,15 @@ import { runtimeLogger } from '../logger.js'; // `retry-after` the handler returns (see world-vercel // `getHandlerErrorRetryAfterSeconds`) fed through VQS `calculateBackoffDelay`. // VQS uses our value for the first 32 attempts (clamped to [5s, 900s]) then -// applies its own exponential growth — every hop hard-capped at the SQS limit +// applies its own exponential growth, every hop hard-capped at the SQS limit // of 900s. With the backoff ramping toward that 900s ceiling (reached by // ~delivery 11), 48 attempts span roughly 9–10 hours of wall-clock (~35,000s), // comfortably under the 24-hour message-visibility limit so the failure path // runs before the message expires. (A flatter, low-capped backoff exhausts the // budget in only a few hours, failing otherwise-healthy runs during a transient // backend outage; conversely, spanning the full 24h window would require a -// substantially higher cap here, not a higher per-hop ceiling — VQS clamps -// every hop at 900s.) +// substantially higher cap here, not a higher per-hop ceiling, since VQS +// clamps every hop at 900s.) export const MAX_QUEUE_DELIVERIES = 48; /** @@ -40,7 +40,7 @@ export function getMaxQueueDeliveries(): number { /** * Default maximum time allowed for the *replay* portion of a single workflow * handler invocation (in ms). This budget only covers deterministic-replay - * and workflow-VM execution between step boundaries — inline step bodies + * and workflow-VM execution between step boundaries. Inline step bodies * (`"use step"` functions invoked via `executeStep`) do NOT count against * it. Step bodies are bounded separately by the platform's function * `maxDuration` (e.g. 800s on Vercel Pro Fluid) and `NO_INLINE_REPLAY_AFTER_MS`. @@ -51,7 +51,7 @@ export function getMaxQueueDeliveries(): number { * `RUN_ERROR_CODES.REPLAY_TIMEOUT`. * * Note that on Vercel Hobby (standard functions), the platform `maxDuration` - * is 60s — well below this budget, so the platform SIGTERM will fire first + * is 60s, well below this budget, so the platform SIGTERM will fire first * and the queue will re-deliver until the visibility window expires. With * Fluid Compute on Hobby the per-function ceiling rises to 300s, still * under the default budget. @@ -90,7 +90,7 @@ function warnOnce( * * Reads `process.env.WORKFLOW_REPLAY_TIMEOUT_MS` lazily so tests and * deployments can override per invocation. Invalid / out-of-range values - * fall back to a safe value (no throw — the env var is an escape hatch, + * fall back to a safe value (no throw: the env var is an escape hatch, * not a hard requirement) and emit a one-time warning so misconfiguration * is observable. */ @@ -126,7 +126,7 @@ export function getReplayTimeoutMs(): number { } /** - * Reset the warn-once cache. Test-only — exported so unit tests can + * Reset the warn-once cache. Test-only: exported so unit tests can * exercise the warn path repeatedly without sharing state. * * @internal @@ -156,8 +156,8 @@ export function getReplayTimeoutMaxRetries(): number { /** * Default maximum number of steps the owned-inline path runs inline (in * parallel) per suspension. The rest are queued to background handlers. Each - * inline step is created lazily — its `step_created` is folded into the - * `step_started` that `executeStep` sends — so inlining N steps saves N queue + * inline step is created lazily (its `step_created` is folded into the + * `step_started` that `executeStep` sends), so inlining N steps saves N queue * round-trips for a `Promise.all`-style fan-out. `1` reproduces the * single-inline-step behavior exactly (useful kill-switch). * @@ -183,7 +183,7 @@ const warnedMaxInlineStepsValues = new Set(); * * Reads `process.env.WORKFLOW_MAX_INLINE_STEPS` lazily so tests and * deployments can override per invocation. Invalid / out-of-range values fall - * back to a safe value (no throw — the env var is an escape hatch) and emit a + * back to a safe value (no throw: the env var is an escape hatch) and emit a * one-time warning so misconfiguration is observable. */ export function getMaxInlineSteps(): number { @@ -243,7 +243,7 @@ export const MAX_RESILIENT_STEP_INPUT_BYTES = 128 * 1024; * **Off by default.** Enable via `WORKFLOW_RESILIENT_STEP_DISPATCH=1`. * * The queue publish races the create's verdict, and a create can come back - * refused — as a duplicate this replay should stop pursuing, or as a stale + * refused: as a duplicate this replay should stop pursuing, or as a stale * write on a World that refuses rather than reports. Either way the message * carrying the payload is already out, so the consumer can materialize a step * whose create was refused, and nothing orders the verdict before the @@ -261,11 +261,11 @@ export function isResilientStepDispatchEnabled(): boolean { * instead of one write per event. Only engages when the World implements the * optional `events.createBatch` AND the run is on slot identity * (specVersion >= 6) AND the suspension carries no attribute/hook writes and - * no resilient step dispatch — everything else keeps the single-event path + * no resilient step dispatch. Everything else keeps the single-event path * byte-for-byte. * * Reads `process.env.WORKFLOW_BATCH_TRANSITIONS` lazily. Default **ON**; - * disabled only by an explicit `'0'` / `'false'` (case-insensitive) — the + * disabled only by an explicit `'0'` / `'false'` (case-insensitive), the * operator escape hatch that restores the exact prior one-write-per-event * path, mirroring `WORKFLOW_TURBO`'s kill-switch shape. */ @@ -280,10 +280,10 @@ export function isBatchTransitionsEnabled(): boolean { * Mirrors the server's transaction budgets with a comfortable margin: each * fan-out event costs 2 transaction items server-side (entity + event row) * against the 100-item DynamoDB cap, and inline payloads count against a - * 768 KB byte budget — 32 events stays well under both, and a fan-out larger - * than this simply commits in successive batches (split batches lose + * 768 KB byte budget, so 32 events stays well under both, and a fan-out larger + * than this commits in successive batches (split batches lose * cross-batch atomicity, which is exactly today's per-event-write crash - * surface — every batch still converges on retry via per-event 409s). + * surface, and every batch still converges on retry via per-event 409s). */ export const MAX_BATCH_FANOUT_EVENTS = 32; @@ -297,7 +297,7 @@ const warnedMaxEventsValues = new Set(); * * Reads `process.env.WORKFLOW_MAX_EVENTS_OVERRIDE` lazily so tests and * deployments can override per invocation. Invalid values fall back to unset - * (no throw — the env var is an escape hatch) and emit a one-time warning. + * (no throw: the env var is an escape hatch) and emit a one-time warning. */ export function getMaxEventsOverride(): number | undefined { const raw = process.env.WORKFLOW_MAX_EVENTS_OVERRIDE; @@ -323,7 +323,7 @@ export function getMaxEventsOverride(): number | undefined { * `step_started` only before the terminal write. * * This can run a step body more than once when handlers race for the same - * step's create-claim — both run the body before one wins. That is unsafe for + * step's create-claim: both run the body before one wins. That is unsafe for * steps with non-idempotent side effects; in particular, two concurrent runs * of a step that writes to the workflow stream (e.g. an AI agent streaming * tokens) can interleave and corrupt the stream data. So the optimization is @@ -358,7 +358,7 @@ export function isOptimisticInlineStartExplicitlyDisabled(): boolean { * the first invocation* of a run (detected by the entrypoint via `runInput` * presence + `metadata.attempt === 1`): it backgrounds the `run_started` event * creation, skips the initial event-log load (nothing has been written yet), - * and forces optimistic inline step start for that invocation — independent of + * and forces optimistic inline step start for that invocation, independent of * `WORKFLOW_OPTIMISTIC_INLINE_START`. * * Forcing optimistic start is safe here because the first delivery has no @@ -381,7 +381,7 @@ export function isTurboEnabled(): boolean { * enabled (default ON). When on, the engine hydrates a VM with the * workflow bundle once per function instance, snapshots it, and starts * every invocation by restoring the snapshot instead of re-evaluating - * the bundle — skipping the dominant share of VM startup (measured + * the bundle, skipping the dominant share of VM startup (measured * ~77ms → ~3ms to first suspension for a 1.3MB bundle). Bundles whose * module scope consumes randomness, reads the clock, or replaces a * serialization intrinsic are detected at hydrate time and @@ -416,12 +416,12 @@ export function isVmRetentionEnabled(): boolean { * Whether inline step ownership is enabled (default ON). When on, the lazy * `step_started` that creates an inline step records the owning queue * message ID, and wake replays that observe an actively-owned step enqueue a - * *delayed backstop* message instead of immediately requeueing it — fixing + * *delayed backstop* message instead of immediately requeueing it, fixing * duplicate inline step execution when a hook/wait wakes a run mid-step * (vercel/workflow#2780). * * `WORKFLOW_INLINE_OWNERSHIP=0` (or `false`) is the kill switch: dispatch - * reverts to the unconditional immediate requeue. Stamping is unaffected — + * reverts to the unconditional immediate requeue. Stamping is unaffected: * the recorded ownerMessageId is inert data when the switch is off. */ export function isInlineOwnershipEnabled(): boolean { @@ -438,16 +438,16 @@ export function isInlineOwnershipEnabled(): boolean { * immediately; past it, they enqueue immediately (today's behavior). * * Why a fixed 860 and not a value derived from the function's `maxDuration`: - * neither runtime nor build time can see the resolved value — builders emit + * neither runtime nor build time can see the resolved value: builders emit * `maxDuration: 'max'`, which the platform resolves per-plan at deploy, and * no env var or request-context deadline API exposes the result. The bound * comes from a platform rule instead: durations above 800s require explicit * per-function numeric config, so a builder-emitted `'max'` resolves to at - * most 800s — 860s therefore dominates any workflow route's invocation + * most 800s, and 860s therefore dominates any workflow route's invocation * lifetime plus scheduling slack. Revisit when that ceiling moves (the * 30-minute duration beta becoming reachable via `'max'` would invalidate * the bound). Worlds without an invocation kill bound (world-local, - * self-hosted) get no death proof from any constant — there the in-process + * self-hosted) get no death proof from any constant. There the in-process * single-flight layer (step-single-flight.ts) is what makes a backstop * firing mid-step harmless. * @@ -461,14 +461,14 @@ export const INLINE_OWNERSHIP_LEASE_SECONDS = 860; /** * Upper bound for the lease env override. 900s is the queue's maximum - * per-message delay (SQS cap) — a longer lease would need delay chaining + * per-message delay (SQS cap). A longer lease would need delay chaining * like long waits use; clamp instead so one delayed message always suffices. */ export const MAX_INLINE_OWNERSHIP_LEASE_SECONDS = 900; /** * Effective inline-ownership lease. Override via - * `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS` — e.g. raise it on self-hosted + * `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS`, e.g. raise it on self-hosted * multi-instance worlds with long-running steps to widen the window in which * a live owner is protected from a concurrent backstop execution. */ @@ -520,9 +520,9 @@ export function getPreconditionMaxInProcessRestarts(): number { // The in-process budget above is per-invocation, and a re-invocation that // enqueues a fresh message also restarts the queue's delivery count, so without // a counter carried on the message a permanently fenced run has no run-level -// bound at all. It can stay fenced without any permanent fault — a full reload +// bound at all. It can stay fenced without any permanent fault (a full reload // is not atomic across pages, so a busy run can acquire a new hole on every -// reload — so the chain is counted and the run fails once this many +// reload), so the chain is counted and the run fails once this many // re-invocations have been spent on stale-snapshot rejections. export const PRECONDITION_MAX_REINVOCATIONS = 5; diff --git a/packages/core/src/runtime/count-step-started-events.ts b/packages/core/src/runtime/count-step-started-events.ts index 68ee1f6ef2..3576e1811e 100644 --- a/packages/core/src/runtime/count-step-started-events.ts +++ b/packages/core/src/runtime/count-step-started-events.ts @@ -3,16 +3,16 @@ import type { Event } from '@workflow/world'; /** * Scope for {@link countStepStartedEvents}: * - `{ type: 'ownedBy', messageId }` counts only starts whose - * `eventData.ownerMessageId` matches — i.e. attempts performed by that + * `eventData.ownerMessageId` matches, i.e. attempts performed by that * owning queue message (inline lazy starts and owned-recovery re-stamps). * - `{ type: 'totalAttempts' }` estimates the step's genuine attempt total - * across its whole lifecycle: unstamped (bare) starts — which only real - * queue-dispatched background deliveries write — plus the starts of the + * across its whole lifecycle: unstamped (bare) starts, which only real + * queue-dispatched background deliveries write, plus the starts of the * single most-started owner. A genuine lifecycle has at most one inline * owner phase (ownership is claimed atomically at step creation and * lapses permanently at `step_retrying` or on any bare start), so taking * the max over owners counts that phase's real attempts while invocations - * racing on the same batch — which stamp their own one-off message IDs — + * racing on the same batch, which stamp their own one-off message IDs, * contribute at most their single largest count instead of accumulating. */ export type StepStartScope = @@ -36,7 +36,7 @@ const ownerOf = (e: Event): string | undefined => * Concurrent invocations racing on the same pending batch (stale replays, * wake replays, a step message dispatched by a replay that lost the * create-claim race, ...) can each write a `step_started` for the same - * logical attempt — worlds without an atomic start guard (world-local) let + * logical attempt. Worlds without an atomic start guard (world-local) let * all of them through. Counting those duplicates as "attempts" made the * maxRetries ceiling fire on healthy runs and fail them with a false * "exceeded max retries" (see workflow#3069). @@ -44,11 +44,11 @@ const ownerOf = (e: Event): string | undefined => * Callers therefore scope the count to the starts their ceiling is actually * about via `scope`: * - the inline owned-recovery ceiling counts only THIS message's starts - * (`ownedBy`) — each real (re)delivery of the owning message stamps its + * (`ownedBy`): each real (re)delivery of the owning message stamps its * `ownerMessageId` on the start it writes, while racing invocations stamp * their own IDs (or none), so they no longer inflate the count; * - the background-step ceiling counts the lifecycle attempt total - * (`totalAttempts`: bare starts plus the largest single owner's starts) — + * (`totalAttempts`: bare starts plus the largest single owner's starts), * so a step that exhausted part of its budget under inline ownership * before transitioning to queued/bare retries still trips the combined * ceiling, while racers' one-off stamped duplicates don't accumulate, and diff --git a/packages/core/src/runtime/deployment-guard.ts b/packages/core/src/runtime/deployment-guard.ts index 99f6c059df..f607858344 100644 --- a/packages/core/src/runtime/deployment-guard.ts +++ b/packages/core/src/runtime/deployment-guard.ts @@ -31,7 +31,7 @@ export type DeploymentAffinityOutcome = | 'failed'; /** - * `spanAttributes` is present only on a misrouted delivery — mismatches are + * `spanAttributes` is present only on a misrouted delivery: mismatches are * rare, so the presence of `workflow.deployment.pinned_id` on a span *is* the * signal that one happened, and `recovered` separates the ones a re-route fixed * from the ones that kept misrouting. @@ -65,7 +65,7 @@ function result( /** * Arguments handed to a call site's re-enqueue closure. The guard owns the - * policy — counting, backoff, logging, telemetry, escalation — and the call + * policy (counting, backoff, logging, telemetry, escalation) and the call * site owns only the shape of the message it needs to put back on the queue. */ export interface ReenqueueArgs { @@ -86,9 +86,9 @@ export interface ReenqueueArgs { * Guards deployment affinity: a run may only execute on the deployment it is * pinned to. * - * A run's `deploymentId` is fixed when it starts — the deployment that called + * A run's `deploymentId` is fixed when it starts: the deployment that called * `start()`, or whatever `start({ deploymentId })` resolved to (an explicit id - * or `'latest'`) — and no replay or step execution may happen anywhere else: + * or `'latest'`). No replay or step execution may happen anywhere else: * the bundles here may not match the run's persisted history, and any step * dispatched from here derives the per-run encryption key from the wrong * deployment's master key, which surfaces as a `RuntimeDecryptionError` the @@ -102,8 +102,8 @@ export interface ReenqueueArgs { * spent, mirroring how a replay divergence gets bounded recovery replays before * being recorded as a corrupted event log. * - * Callers must pass a run entity they already have in hand — every call site - * loads the run for other reasons — so the guard costs no extra round trip. + * Callers must pass a run entity they already have in hand (every call site + * loads the run for other reasons), so the guard costs no extra round trip. * (`world.getDeploymentId()` reads the ambient deployment id, e.g. * `VERCEL_DEPLOYMENT_ID`, and does not call the backend either.) * @@ -144,7 +144,7 @@ export async function guardDeploymentAffinity({ * Ordering barrier awaited once a mismatch is confirmed, before either * stopping action. Under turbo the `run_started` write is backgrounded, and * both outcomes hand the run off (to a `run_failed` here, or to the pinned - * deployment's own `run_started`) — so that write must have landed first. + * deployment's own `run_started`), so that write must have landed first. */ beforeStop?: () => Promise; }): Promise { @@ -214,7 +214,7 @@ export async function guardDeploymentAffinity({ ); } catch (failError) { // Run already reached a terminal state (a concurrent writer failed it, or - // it was cancelled/expired) — still stop. Anything else is a transient + // it was canceled/expired), so still stop. Anything else is a transient // persistence failure: rethrow so the queue redelivers and we try again. if ( !EntityConflictError.is(failError) && @@ -228,7 +228,7 @@ export async function guardDeploymentAffinity({ if (reenqueue && retryCount < maxRetries) { const attempt = retryCount + 1; - // Exponential, capped: 1s, 2s, 4s — cheap insurance against a hot + // Exponential, capped: 1s, 2s, 4s. Cheap insurance against a hot // re-enqueue loop, and time for a mid-flight routing change to settle. const delaySeconds = Math.min(2 ** retryCount, MAX_REROUTE_DELAY_SECONDS); try { @@ -251,7 +251,7 @@ export async function guardDeploymentAffinity({ throw reenqueueError; } - // The World explicitly classified the target as unavailable — deleted, + // The World explicitly classified the target as unavailable: deleted, // aged out of its retention window, or otherwise undiscoverable. Burning // the rest of the budget on a deployment that provably cannot be reached // only delays the failure, so fail now. diff --git a/packages/core/src/runtime/get-port-lazy.ts b/packages/core/src/runtime/get-port-lazy.ts index dbba7651f9..8b337b1fcc 100644 --- a/packages/core/src/runtime/get-port-lazy.ts +++ b/packages/core/src/runtime/get-port-lazy.ts @@ -14,13 +14,13 @@ let _getPort: (() => Promise) | undefined; // Per-process cache of the resolved port. The workflow server listens on a // stable port for the lifetime of the process, but `getPort()` rediscovers it -// on every call by querying the OS for the process's listening sockets — on +// on every call by querying the OS for the process's listening sockets. On // macOS that shells out to `lsof` (~60ms), which the runtime pays on EVERY // workflow replay or step invocation. Since the port does not change within a // process, resolve it once and reuse it. `_inFlight` // dedupes concurrent first calls so discovery never runs more than once. // -// The first concrete port is pinned for the lifetime of the process — there is +// The first concrete port is pinned for the lifetime of the process: there is // no per-call re-resolution. This is safe because the runtime only runs inside // the already-listening dev-server process, and `getPort()` -> `getAllPorts()` // returns a deterministic order, so repeated calls would resolve the same port @@ -33,7 +33,7 @@ export async function getPortLazy(): Promise { if (_cachedPort !== undefined) { return _cachedPort; } - // A discovery is already running — share it rather than starting a second. + // A discovery is already running, so share it rather than starting a second. if (_inFlight) { return _inFlight; } @@ -61,7 +61,7 @@ export async function getPortLazy(): Promise { _inFlight = resolver() .then((port) => { // Only cache a concrete port. A transient `undefined` (e.g. the server is - // not listening yet on the very first replay) must not poison the cache — + // not listening yet on the first replay) must not poison the cache: // leaving it unset lets the next call retry discovery. if (typeof port === 'number') { _cachedPort = port; diff --git a/packages/core/src/runtime/get-world-lazy.ts b/packages/core/src/runtime/get-world-lazy.ts index 2f48ac8eb2..b28c9a5a8c 100644 --- a/packages/core/src/runtime/get-world-lazy.ts +++ b/packages/core/src/runtime/get-world-lazy.ts @@ -9,9 +9,9 @@ * * Resolution order, in priority: * - * 1. `globalThis[WorldCacheKey]` — populated by a successful prior + * 1. `globalThis[WorldCacheKey]`: populated by a successful prior * `getWorld()` call. This is the steady-state hot path. - * 2. `globalThis[GetWorldFnKey]` — populated by the module-load side + * 2. `globalThis[GetWorldFnKey]`: populated by the module-load side * effect at the bottom of `./world.ts`. Fires on every server bundle * that reaches this file via `workflow` or `workflow/api` (which import * `./world-init.ts` for its side effect; see that file for the full diff --git a/packages/core/src/runtime/helpers.ts b/packages/core/src/runtime/helpers.ts index 03c08f88c3..24c17fd430 100644 --- a/packages/core/src/runtime/helpers.ts +++ b/packages/core/src/runtime/helpers.ts @@ -89,7 +89,7 @@ export interface HealthCheckResult { /** * `@workflow/core` version of the responding deployment, used for * capability detection (see `getRunCapabilities`). Omitted when the - * responding deployment did not provide the field as a string — + * responding deployment did not provide the field as a string: * for example, an older `@workflow/core` that predates this field, * or a non-JSON plain-text health response. */ @@ -104,7 +104,7 @@ export interface HealthCheckResult { */ encryptionPublicKey?: string; /** - * The responding deployment's `HOOK_RESUME_INPUT_VERSION` — the protocol + * The responding deployment's `HOOK_RESUME_INPUT_VERSION`: the protocol * version at which the *consumer* (queue-message target) re-ensures the * `hook_received` event from `hookInput` on replay. A cross-deployment * `start()` stamps the *target's* value (not the caller's) into the new @@ -162,7 +162,7 @@ export async function handleHealthCheckMessage( // // Only a *public* key may travel this way: the probe response stream is // deliberately unauthenticated, so anything secret would be exposed. - // Best-effort — a failure here must not fail the health check itself, which + // Best-effort: a failure here must not fail the health check itself, which // callers also rely on for plain capability detection. let encryptionPublicKey: string | undefined; if (healthCheck.runId) { @@ -190,7 +190,7 @@ export async function handleHealthCheckMessage( specVersion: worldSpecVersion ?? SPEC_VERSION_CURRENT, workflowCoreVersion, // We are executing inside the target deployment, so this constant reflects - // the *consumer's* hook-resume protocol version — exactly what a + // the *consumer's* hook-resume protocol version, exactly what a // cross-deployment caller needs to gate its parallel resume path on. hookResumeInputVersion: HOOK_RESUME_INPUT_VERSION, ...(encryptionPublicKey ? { encryptionPublicKey } : {}), @@ -219,7 +219,7 @@ export interface HealthCheckOptions { * Queue namespace of the target deployment (e.g. `'eve'` for topics like * `__eve_wkf_workflow_*`). Falls back to `WORKFLOW_QUEUE_NAMESPACE` in the * calling process. Cross-context callers (e.g. the observability - * dashboard) must pass the target deployment's namespace explicitly — + * dashboard) must pass the target deployment's namespace explicitly: * the env fallback resolves in the caller's process, and a message * published to a mismatched topic has no consumer, so the check would * always time out. @@ -254,7 +254,7 @@ const HEALTH_CHECK_READ_TIMEOUT = 500; * health-check poll loop: some worlds hold that request open until the * stream has data (e.g. workflow-server holds unwritten streams open for * ~2 minutes), which would otherwise blow through the configured health - * check timeout — the `while` condition is only re-checked between + * check timeout, since the `while` condition is only re-checked between * iterations. */ function withDeadline(promise: Promise, ms: number): Promise { @@ -492,7 +492,7 @@ function recordRequestedEventCursor( * re-sorted. Every append source is already in canonical order relative to the * tail (a cursor-delimited page, or a write-response delta), so receipt order is * the order to keep, and re-sorting here would only cost a pass over the log. - * Nothing downstream may assume the tail is the newest event — see + * Nothing downstream may assume the tail is the newest event. See * {@link maxEventSlot}. */ export function appendUniqueEvents( @@ -521,7 +521,7 @@ export function appendUniqueEvents( * * `preloadedEvents` is loaded `sortOrder: 'asc'` and is never re-sorted * client-side, so a `hook_received` spliced in by the lazy-resume consumer must - * land in `eventId` order — a plain `push` would place a late-committing + * land in `eventId` order: a plain `push` would place a late-committing * earlier event after events that sort before it, corrupting replay. * * Lexicographic string order is the log's order: a slot id is a fixed-width @@ -537,7 +537,7 @@ export function insertEventByEventId(target: Event[], event: Event): void { while (i > 0) { const existing = target[i - 1]; if (existing.eventId === event.eventId) { - // Already present — keep the splice idempotent. + // Already present: keep the splice idempotent. return; } if (existing.eventId < event.eventId) { @@ -615,7 +615,7 @@ export async function loadWorkflowRunEvents( const world = await getWorldLazy(); const loadStart = Date.now(); while (hasMore) { - // TODO: we're currently loading all the data with resolveRef behaviour. We need to update this + // TODO: we're currently loading all the data with resolveRef behavior. We need to update this // to lazyload the data from the world instead so that we can optimize and make the event log loading // much faster and memory efficient const pageStart = Date.now(); @@ -663,9 +663,9 @@ export async function loadWorkflowRunEvents( ); // Preserve the last non-null cursor across pages. A World may // legitimately return `{ data: [], cursor: null, hasMore: false }` - // on a trailing empty page — for example when the previous page's + // on a trailing empty page, for example when the previous page's // underlying DB query hit the limit exactly and returned a - // `LastEvaluatedKey` "just in case". Overwriting with that null + // precautionary `LastEvaluatedKey`. Overwriting with that null // would lose the position past the last real event we loaded and // force the runtime into the "no cursor after initial load" full- // reload fallback on every subsequent replay iteration. @@ -744,8 +744,8 @@ export function isSlotGapCheckEnabled(): boolean { * bump-and-report write hands back the slots it skipped ({@link * mergeReportedEvents}), an inline delta extends the tail (`absorbCreateDelta` * in `runtime.ts`), and a listed page appends ({@link appendUniqueEvents}). - * They look alarming — the replay is reading an array while something else - * writes to it — and they are safe for one reason worth stating plainly, since + * They look alarming (the replay is reading an array while something else + * writes to it) and they are safe for one reason worth stating plainly, since * every correctness argument in this file leans on it. * * **An event in the log is a fact, and a longer log cannot retract one.** The @@ -757,8 +757,8 @@ export function isSlotGapCheckEnabled(): boolean { * * Merging is therefore monotone: it can only add facts this replay has yet to * reconcile to, never remove one it already has. That is why absorbing is - * always optional and never wrong to decline — an unabsorbed event is one the - * next read returns — and why the guards below can afford to be strict. + * always optional and never wrong to decline (an unabsorbed event is one the + * next read returns), and why the guards below can afford to be strict. */ /** @@ -809,7 +809,7 @@ export interface SkippedSlotReport { * would raise the log's highest position past a position whose event is * missing. Later writes read that maximum to say what they have seen, so each * would claim a position it never saw, and the World only reports the span a - * write skips — it would never send the missing one. Dropping costs one more + * write skips: it would never send the missing one. Dropping costs one more * round of the same events on the next write and keeps the log a prefix of the * truth, which the note above {@link mergeReportedEvents} explains is always an * available answer. @@ -946,7 +946,7 @@ export function findEventSlotGap( * * A hole can be transient. The World allocates a slot inside the insert that * occupies it, so two concurrent writers can collide, one retry past the other, - * and the higher slot commit first — leaving a window in which the lower one is + * and the higher slot commit first, leaving a window in which the lower one is * genuinely absent from a strongly-consistent read and fills in a moment later. * The window is one commit wide, so a short backoff clears it; anything that * survives all three re-reads is a position no write will ever occupy. @@ -959,7 +959,7 @@ const SLOT_GAP_RECHECK_BASE_DELAY_MS = 25; * out, and return the settled log alongside the hole that survived. * * Reads are strongly consistent, so a hole is not an artifact of *when* the log - * was read — but it can be an artifact of a write that had not committed yet + * was read, but it can be an artifact of a write that had not committed yet * (see {@link SLOT_GAP_RECHECK_ATTEMPTS}). Distinguishing the two costs a * re-read, which is only ever paid by a replay that already found a hole. * @@ -994,7 +994,7 @@ export async function settleEventSlotGap( * One integer says it because the World keeps its positions dense: a writer * that names slot N is claiming to hold every event from 1 to N and nothing * above. The World answers by numbering the write above whatever the log has - * actually reached and handing back the events on the slots in between — the + * actually reached and handing back the events on the slots in between: the * ones this writer decided without. * * Density is the World's invariant, not a claim about this particular read. A @@ -1032,14 +1032,14 @@ export function slotSnapshotParams( * The events a rejecting World attached to a `PreconditionFailedError`, when it * returned the ones the client's snapshot was missing inline. * - * Returns `null` for anything else — no details, a World that did not implement + * Returns `null` for anything else: no details, a World that did not implement * this, or a payload that does not narrow cleanly. Callers fall back to * reloading the event log, which is always correct; this is untrusted-shaped * data on a failure path, so nothing here is repaired. * * `runId` is the caller's run. Every event must belong to it: the delta is * merged straight into the replay's log, and one foreign event there is a - * corrupt log rather than a corrected one — the replay would consume a + * corrupt log rather than a corrected one: the replay would consume a * correlation id for an event that does not exist on this run. */ export function preconditionEventDelta( @@ -1129,7 +1129,7 @@ export function withHealthCheck( } /** FNV-1a 32-bit hash of a string, as 8 hex chars. Tiny, deterministic, and - * dependency-free — used only to scope idempotency keys, not for security. */ + * dependency-free. Used only to scope idempotency keys, not for security. */ function fnv1a32Hex(value: string): string { let hash = 0x811c9dc5; for (let i = 0; i < value.length; i++) { @@ -1141,19 +1141,19 @@ function fnv1a32Hex(value: string): string { /** * Idempotency key for a step's background-dispatch queue message, scoped to - * the step's IDENTITY — correlation id plus (hashed) step name — rather than + * the step's IDENTITY, correlation id plus (hashed) step name, rather than * the bare correlation id. * * The scoping matters for resilient step dispatch under the precondition * guard: a guard-rejected `step_created` leaves its (revoked) step message in * flight, and the corrected replay may re-derive the same correlation id for * a DIFFERENT step. Under a bare-correlationId key the corrected replay's - * dispatch would silently dedupe against the revoked in-flight message — + * dispatch would silently dedupe against the revoked in-flight message, * which then resolves `skipped` against the re-created entity (the server's - * stepName fence rejects its bare start) — and the legitimate step would + * stepName fence rejects its bare start), and the legitimate step would * never be executed. Scoping by step name keeps every dedup property that * matters (crash recovery re-dispatch, concurrent handlers, the delayed - * retry sharing the suspension re-dispatch's key — all name the same step) + * retry sharing the suspension re-dispatch's key: all name the same step) * while letting the corrected schedule's dispatch through. * * Every producer of a step-dispatch (or step-retry) message must use this @@ -1224,15 +1224,15 @@ export function getQueueOverhead(message: { requestedAt?: Date }) { * If the world doesn't support encryption or the run has no key configured, * the cached value is `undefined`. * - * The resolved value is deliberately the *full* capability — the symmetric AES - * key plus the run's X25519 keypair — not just a `CryptoKey`. A run reading + * The resolved value is deliberately the *full* capability (the symmetric AES + * key plus the run's X25519 keypair), not just a `CryptoKey`. A run reading * its own event log can encounter sealed (`encp`) payloads that another run * wrote to it (a cross-deployment hook resumption, say), and opening those * needs the keypair. Resolving only the symmetric key would leave those * payloads unopenable and wedge the run. * * Used by step / workflow handlers to defer the (potentially expensive) - * key fetch until the first code path that actually needs it — typically + * key fetch until the first code path that actually needs it: typically * input hydration on the success path, or error dehydration on a failure * path. Both paths can race-call the accessor without triggering duplicate * fetches. diff --git a/packages/core/src/runtime/quickjs-entrypoint.ts b/packages/core/src/runtime/quickjs-entrypoint.ts index 74d0300cb6..a26c164dc3 100644 --- a/packages/core/src/runtime/quickjs-entrypoint.ts +++ b/packages/core/src/runtime/quickjs-entrypoint.ts @@ -1,9 +1,9 @@ /** - * QuickJS VM integration with the Workflow DevKit. + * QuickJS WebAssembly (WASM) VM integration with the Workflow DevKit. * * This module provides the entry point for running workflows in the - * QuickJS WASM VM engine instead of the `node:vm` engine. Both engines - * implement the same event-replay execution model — every invocation: + * QuickJS VM engine instead of the `node:vm` engine. Both engines + * implement the same event-replay execution model, where every invocation: * * 1. Loads the full event log for the run * 2. Runs the workflow function from the top in a fresh QuickJS VM, @@ -77,14 +77,14 @@ import { unserializableStepInputPlaceholder } from './unserializable-step.js'; import { getWaitContinuationDispatch } from './wait-continuation.js'; import { getWorld } from './world.js'; -/** Tiny ms timer using performance.now() — already monotonic on Node. */ +/** Tiny ms timer using performance.now(), already monotonic on Node. */ function tick(): number { return performance.now(); } /** * Returns true when the supplied preloaded events indicate this is the - * first workflow handler invocation for the run — i.e. the log contains + * first workflow handler invocation for the run, i.e. the log contains * nothing beyond `run_created` / `run_started`. In that case the * preloaded events ARE the complete event log and the `events.list` * round-trips can be skipped entirely. @@ -130,7 +130,7 @@ async function queueStepMessage(params: { * Publish purpose, used to bucket the idempotency key. Worlds retire * used keys (VQS retention TTL, world-postgres completed-keys cache), * so a key shared across purposes silently swallows the second - * publish — see wait-continuation.ts for the same hazard on wait + * publish. See wait-continuation.ts for the same hazard on wait * keys. `dispatch` is the plain background handoff (overflow / crash * recovery) and uses the step-identity-scoped dispatch key * (stepDispatchIdempotencyKey) so it stays mutually @@ -178,7 +178,7 @@ async function queueStepMessage(params: { }, { // The 'dispatch' key is step-identity-scoped (correlationId + hashed - // step name) — shared with the node engine's dispatch of the same step + // step name), shared with the node engine's dispatch of the same step // so the two stay mutually exclusive, without a revoked resilient // message absorbing a reassigned correlation id's legitimate dispatch. // See stepDispatchIdempotencyKey. @@ -203,7 +203,7 @@ async function queueStepMessage(params: { * step_created (+ optional queueing), hook_created / hook_received (aborts), * attr_set, hook_disposed, and wait_created events. * - * Steps are created but (usually) not queued here — queueing (or inline + * Steps are created but (usually) not queued here: queueing (or inline * execution) is the caller's decision. Used both for suspension * processing (the inline loop) and for the terminal drain (flushing * leftover side effects when the workflow completed or failed, mirroring @@ -212,7 +212,7 @@ async function queueStepMessage(params: { * The one exception is resilient step dispatch: for step cids named in * `queueStepCids` (the caller's overflow steps) that pass the eligibility * gates, the step_created write is parallelized with the step's queue - * publish — the message carries the serialized input (`stepInput`) so the + * publish: the message carries the serialized input (`stepInput`) so the * consumer can idempotently re-ensure the event if the direct write failed * transiently. Steps queued this way are reported in `queuedStepCids`; the * caller queues the rest itself. @@ -243,7 +243,7 @@ async function dispatchPendingOps(params: { * Run-origin trace carrier accessor from runtime.ts. In the default * `linked` trace mode this returns the carrier of the run's ORIGIN * context (workflow.start), so every invocation links back to the - * start in a star — capturing the current context here instead would + * start in a star. Capturing the current context here instead would * chain invocations to each other and fragment the run view on async * queues. */ @@ -252,18 +252,18 @@ async function dispatchPendingOps(params: { * When true (the inline loop), a step carrying `serializationError` is * finalized as step_created (placeholder input) + step_failed so the * live-VM feed rejects the step's promise and workflow code can catch - * it — mirroring the node:vm engine's finalizeUnserializableStep. When + * it, mirroring the node:vm engine's finalizeUnserializableStep. When * false (the terminal drain), such steps are skipped entirely: the run * is already completing/failing, no replay follows the drain to observe * the failure, and a completed run carrying a failed step would read as - * a bug from the dashboard — matching the node:vm drain's behavior. + * a bug from the dashboard, matching the node:vm drain's behavior. */ finalizeUnserializableSteps?: boolean; wfdiag: (checkpoint: string, fields: Record) => void; }): Promise<{ createdAttributeEvent: boolean; createdGetConflictHook: boolean; - /** Step cids already published via resilient dispatch — see above. */ + /** Step cids already published via resilient dispatch. See above. */ queuedStepCids: Set; /** * Step cids finalized as failed because their input refused to @@ -290,7 +290,7 @@ async function dispatchPendingOps(params: { // skips them in its own queueing pass. const queuedStepCids = new Set(); // Step cids finalized as step_created + step_failed because their input - // refused to serialize — see the `finalizeUnserializableSteps` param. + // refused to serialize. See the `finalizeUnserializableSteps` param. const failedSerializationStepCids = new Set(); // Resilient step dispatch eligibility, shared by every step op below (the // per-step input-size check is applied inside the op): feature enabled and @@ -299,7 +299,7 @@ async function dispatchPendingOps(params: { // Unlike the node:vm suspension handler's gate (see // SuspensionHandlerParams.stepDispatch), there is NO precondition-guard // gate here: this engine's step_created writes are unguarded (no snapshot - // is attached), so a guard-enforcing World can never 412-reject them — + // is attached), so a guard-enforcing World can never 412-reject them: // the consumer's re-ensure therefore cannot materialize a step the guard // rejected. If this engine ever adopts guarded suspension writes, the // capability gate from the node:vm handler must be added here too. @@ -315,7 +315,7 @@ async function dispatchPendingOps(params: { let createdGetConflictHook = false; // Set when a new attr_set event is written this invocation. The // workflow must be re-invoked to consume it (resolving the pending - // setAttributes() promise), so the entrypoint requeues immediately — + // setAttributes() promise), so the entrypoint requeues immediately, // same pattern as an elapsed wait. let createdAttributeEvent = false; const opsPromises: Promise[] = []; @@ -336,7 +336,7 @@ async function dispatchPendingOps(params: { // `hook.metadata` is the format-prefixed devalue bytes // produced by `globalThis[Symbol.for('workflow-serialize')] // (options.metadata)` inside the VM. Encrypt on the host - // side before writing — matches the node:vm engine's + // side before writing, which matches the node:vm engine's // `dehydrateStepArguments` flow. // // No pre-check via hooks.list: with deterministic correlationIds @@ -386,7 +386,7 @@ async function dispatchPendingOps(params: { ); } } catch (err) { - // Already created by a concurrent invocation — fall through + // Already created by a concurrent invocation, so fall through // to abort processing below (if any) instead of bailing. if (!EntityConflictError.is(err)) throw err; } @@ -429,7 +429,7 @@ async function dispatchPendingOps(params: { await world.streams.write(runId, streamName, abortPayload); await world.streams.close(runId, streamName); } catch { - // Best-effort — the hook event provides the durable + // Best-effort: the hook event provides the durable // fallback. runtimeLogger.debug( 'QuickJS runtime: failed to write abort stream packet', @@ -471,7 +471,7 @@ async function dispatchPendingOps(params: { // code order within each group, mirroring the node:vm suspension // handler (hookItemsByToken): a dispose() of an earlier hook must // release the token before a later same-token hook's creation is - // validated by the world — parallel dispatch would otherwise record a + // validated by the world: parallel dispatch would otherwise record a // spurious hook_conflict against the run's own disposed hook (e.g. a // dispose→recreate loop reusing one token). Different tokens have no // claim interaction, so token groups run in parallel with each other @@ -488,7 +488,7 @@ async function dispatchPendingOps(params: { ) { key = (op as PendingHook).token; } else if (op.type === 'hook_dispose' && !op.hasCreatedEvent) { - // Per-op fallback group when the token is unknown — no ordering + // Per-op fallback group when the token is unknown: no ordering // guarantees, matching the previous parallel behavior. key = (op as PendingHookDispose).token ?? `__cid:${op.correlationId}`; } @@ -525,13 +525,13 @@ async function dispatchPendingOps(params: { (async () => { // The step's input refused to serialize while dumping the VM's // pending ops (see PendingStep.serializationError). Finalize it - // as step_created (placeholder input — the world requires the + // as step_created (placeholder input, since the world requires the // step entity before a terminal event) + step_failed carrying // the SerializationError, so the live-VM feed rejects the // step's promise and workflow code can catch it. Never queue an // execution message for it. Mirrors the node:vm engine's // finalizeUnserializableStep. In the terminal drain - // (finalizeUnserializableSteps unset), skip entirely — see the + // (finalizeUnserializableSteps unset), skip entirely: see the // param docs. if (step.serializationError) { if (!params.finalizeUnserializableSteps) { @@ -608,7 +608,7 @@ async function dispatchPendingOps(params: { // by `globalThis[Symbol.for('workflow-serialize')]({args, // closureVars, thisVal})` inside the VM. The VM has no // access to the CryptoKey, so encryption is applied here - // on the host side — matching what + // on the host side, matching what // `dehydrateStepArguments` does in the node:vm engine. const encryptedInput = await encryptSerializedData( step.input, @@ -616,7 +616,7 @@ async function dispatchPendingOps(params: { ); // Resilient step dispatch: fire the step_created write and the - // step's queue publish in parallel — the message carries the + // step's queue publish in parallel: the message carries the // same serialized input (`stepInput`) so the consumer can // idempotently re-ensure the event if the direct write failed // transiently. Mirrors the node:vm suspension handler and the @@ -664,15 +664,15 @@ async function dispatchPendingOps(params: { if (createResult.status === 'rejected') { const err = createResult.reason; if (EntityConflictError.is(err)) { - // Concurrent invocation wrote it first — the message is + // Concurrent invocation wrote it first: the message is // already out; its duplicate publish dedupes on the // shared step-identity-scoped idempotency key. return; } if (isRetryableWorldError(err)) { // Resilient: the write failed transiently (429 / 5xx / - // transport) but the step message — carrying the same - // serialized input — was published, so the consumer + // transport) but the step message (carrying the same + // serialized input) was published, so the consumer // idempotently re-ensures the step_created before // executing. runtimeLogger.warn( @@ -712,7 +712,7 @@ async function dispatchPendingOps(params: { throw err; } - // NOTE: step queueing is otherwise the caller's decision — the + // NOTE: step queueing is otherwise the caller's decision: the // inline loop executes fresh steps in the live VM and only // queues the overflow / retry / backstop cases (see // queueStepMessage). @@ -738,7 +738,7 @@ async function dispatchPendingOps(params: { createdAttributeEvent = true; } catch (err) { if (EntityConflictError.is(err)) { - // Event already exists (concurrent invocation) — the + // Event already exists (concurrent invocation), but the // replay still needs to consume it, so requeue. createdAttributeEvent = true; return; @@ -786,12 +786,12 @@ async function dispatchPendingOps(params: { * This replaces the `node:vm` replay path (runWorkflow + EventsConsumer) * with a QuickJS VM invocation that performs the same full event replay. * - * KNOWN GAP — slot snapshot: unlike the node:vm path, no event write in + * KNOWN GAP (slot snapshot): unlike the node:vm path, no event write in * this file carries {@link CreateEventParams.eventCount}, so a World never * learns which events the writer had not seen and never reports them back. * The engine currently relies on per-(runId, correlationId) event * uniqueness (EntityConflictError dedup) alone. This is a deliberate - * simplification while the engine is experimental — wiring the snapshot is + * simplification while the engine is experimental: wiring the snapshot is * tracked follow-up work; anyone adding new write paths here should not * assume parity with the node engine on this axis. */ @@ -802,8 +802,8 @@ export async function runWorkflowWithQuickJS(params: { /** * Events returned inline by `events.create('run_started', ...)` or by * the lazy hook fast path's `hook_received` preload. When they indicate - * a first invocation — or when `preloadedEventsComplete` attests they - * are the complete log — they are used as the event log instead of + * a first invocation, or when `preloadedEventsComplete` attests they + * are the complete log, they are used as the event log instead of * fetching via `events.list`, matching the node:vm engine's fast path. */ preloadedEvents?: Event[]; @@ -854,8 +854,8 @@ export async function runWorkflowWithQuickJS(params: { /** * Queue namespace resolved at route registration (runtime.ts). Must be * threaded into every message publish: the builders bake the namespace - * into generated routes, so consumers listen on `___wkf_workflow_*` - * — a publish without it lands on `__wkf_workflow_*` and is never + * into generated routes, so consumers listen on `___wkf_workflow_*`. + * A publish without it lands on `__wkf_workflow_*` and is never * picked up. */ namespace?: string; @@ -931,7 +931,7 @@ export async function runWorkflowWithQuickJS(params: { // (e.g. "workflow//./workflows/1_simple//simple") const workflowId = workflowName; - // Resolve the encryption key up front — needed to decrypt event + // Resolve the encryption key up front: needed to decrypt event // payloads inside the VM and to encrypt event payloads written below. // Resolve the FULL capability (symmetric AES key + X25519 keypair), not // just `importKey(rawKey)`: a run reading its own event log can encounter @@ -939,7 +939,7 @@ export async function runWorkflowWithQuickJS(params: { // wrote to it (sealing is presence-gated on the run's published // encryptionPublicKey, which the shared start() path stamps regardless of // engine). A bare symmetric key cannot open those and would wedge the run - // right after hook_received — the node:vm engine resolves the same full + // right after hook_received. The node:vm engine resolves the same full // capability via memoizeEncryptionKey. const rawKey = await world.getEncryptionKeyForRun?.(workflowRun); const encryptionKey = rawKey ? await deriveRunPayloadKeys(rawKey) : undefined; @@ -974,7 +974,7 @@ export async function runWorkflowWithQuickJS(params: { eventsFetchedPages++; allEvents.push(...response.data); // Update the cursor to the last successfully fetched page's cursor. - // Only update when we got results — the final empty-page response + // Only update when we got results: the final empty-page response // returns cursor=null which we must NOT use (it would reset the cursor). if (response.cursor) { cursor = response.cursor; @@ -986,7 +986,7 @@ export async function runWorkflowWithQuickJS(params: { } // Event-limit guard: fail a runaway run once its log reaches the - // server-supplied ceiling — same enforcement point as the node:vm + // server-supplied ceiling, the same enforcement point as the node:vm // engine's replay loop. if (maxEventsLimit !== undefined && events.length >= maxEventsLimit) { throw new MaxEventsExceededError(events.length, maxEventsLimit); @@ -1043,7 +1043,7 @@ export async function runWorkflowWithQuickJS(params: { } // Resolve the workflow server port so `getWorkflowMetadata().url` inside - // the VM matches what the step-side handler reports. Skipped on Vercel — + // the VM matches what the step-side handler reports. Skipped on Vercel: // the VM reads VERCEL_URL directly in that environment. const isVercel = process.env.VERCEL_URL !== undefined; const port = isVercel ? undefined : await getPortLazy(); @@ -1108,7 +1108,7 @@ export async function runWorkflowWithQuickJS(params: { // hook_received for aborts) and complete elapsed waits. // 2. Feed all newly recorded events (attr_set, hook_created, elapsed // wait_completed, terminals written by concurrent invocations, ...) - // into the LIVE VM via session.continueWithEvents — resuming + // into the LIVE VM via session.continueWithEvents, resuming // execution exactly where it left off, no fresh-VM re-replay. // Cheap progress is fed BEFORE running step bodies so promise // chains that are not gated on steps (hook.getConflict(), @@ -1117,7 +1117,7 @@ export async function runWorkflowWithQuickJS(params: { // 3. Once no cheap progress remains, execute up to // getMaxInlineSteps() steps created by THIS invocation inline (no // queue round-trip), in parallel, with the replay budget paused - // during step bodies — mirroring the node:vm engine's inline + // during step bodies, mirroring the node:vm engine's inline // replay loop. Overflow and retry/throttled steps are queued for // background execution. A delayed wait-continuation message is // enqueued for the soonest pending wait first, so racing timers @@ -1135,14 +1135,14 @@ export async function runWorkflowWithQuickJS(params: { const executedStepIds = new Set(); // Steps for which THIS invocation already sent a queue message. const queuedStepIds = new Set(); - // Aborts THIS invocation already recorded (hook_received written) — - // guards against re-recording when the VM-side flag has not been + // Aborts THIS invocation already recorded (hook_received written). + // Guards against re-recording when the VM-side flag has not been // cleared yet within the same iteration. const recordedAbortIds = new Set(); // Waits for which THIS invocation already completed/scheduled work. const completedWaitIds2 = new Set(); // Inline-ownership state per step correlationId, derived from every - // event this invocation observes (initial log + every feed) — the + // event this invocation observes (initial log + every feed): the // quickjs analog of the replay-derived ownership on the node engine's // StepInvocationQueueItem (see step-ownership.ts). Latest-wins: // events arrive in log order, so a later step_started overwrites the @@ -1192,7 +1192,7 @@ export async function runWorkflowWithQuickJS(params: { let runGone = false; // Set when this invocation wrote an event the workflow must consume to // make progress (attr_set, getConflict-awaited hook_created) and the - // loop has not yet read it back — eventually-consistent listings can + // loop has not yet read it back, since eventually-consistent listings can // return 0 new events right after a write. If it is still set when the // loop exits suspended, the entrypoint requeues immediately instead of // exiting awaiting_external with the unblocking event already written @@ -1233,7 +1233,7 @@ export async function runWorkflowWithQuickJS(params: { // each continueWithEvents, so a single invocation can otherwise grow // the log arbitrarily far past the operator's limit (the node engine // re-checks per replay for the same reason). `seenEventIds` counts - // every event this invocation has observed — initial log + all + // every event this invocation has observed: initial log + all // feeds. if (maxEventsLimit !== undefined && seenEventIds.size >= maxEventsLimit) { throw new MaxEventsExceededError(seenEventIds.size, maxEventsLimit); @@ -1242,7 +1242,7 @@ export async function runWorkflowWithQuickJS(params: { // Select this turn's inline candidates BEFORE dispatch: fresh steps // (no step_created yet) that this invocation hasn't already handled. - // Their step_created is deliberately NOT written by dispatch — the + // Their step_created is deliberately NOT written by dispatch: the // inline claim below is a lazy step_started carrying the input, // which the world applies as an atomic create-claim. A concurrent // invocation racing on the same fresh step loses that claim with @@ -1258,7 +1258,7 @@ export async function runWorkflowWithQuickJS(params: { ); // Steps whose input refused to serialize (see // PendingStep.serializationError) never execute: they must not be - // inline-claimed (a lazy step_started would need the very input that + // inline-claimed (a lazy step_started would need the input that // failed) nor queued. Dispatch below finalizes them as step_created // + step_failed instead; only healthy steps compete for inline // slots and overflow. @@ -1287,13 +1287,13 @@ export async function runWorkflowWithQuickJS(params: { // Steps beyond the inline cap are handed to the queue in the same // turn their step_created is written. Where eligible, the dispatch // below parallelizes each overflow step's step_created write with - // its queue publish (resilient step dispatch — the message carries + // its queue publish (resilient step dispatch: the message carries // `stepInput` so the consumer can re-ensure the event); the rest // are queued right after, in parallel. This must all happen BEFORE - // the event feed below: the feed always observes those very + // the event feed below: the feed always observes those // step_created writes as unseen events and `continue`s, so a // handoff placed after it is unreachable on the only iteration - // that still classifies these steps as fresh — next turn they carry + // that still classifies these steps as fresh: next turn they carry // hasCreatedEvent and would never be queued at all (the wedge behind // promiseRaceStressTestWorkflow hanging in the quickjs CI legs). The // step-identity-scoped idempotency key makes repeats harmless. @@ -1321,8 +1321,8 @@ export async function runWorkflowWithQuickJS(params: { // written but no execution message anywhere: if the feed below // doesn't surface them (eventually-consistent listing) and the loop // exits, nothing would ever re-invoke the run to observe the - // failure. Raise the requeue signal — same mechanism as inline - // terminals — and mark the steps handled so later turns don't + // failure. Raise the requeue signal (same mechanism as inline + // terminals) and mark the steps handled so later turns don't // re-finalize or backstop-queue them. if (dispatched.failedSerializationStepCids.size > 0) { pendingRequeueSignal = true; @@ -1385,7 +1385,7 @@ export async function runWorkflowWithQuickJS(params: { { const newEvents = await fetchUnseenEvents(); if (newEvents.length > 0) { - // The listing caught up with this invocation's writes — any + // The listing caught up with this invocation's writes, so any // attr_set / getConflict hook_created has been (or is being) // consumed by the live VM, so no external requeue is needed. pendingRequeueSignal = false; @@ -1404,13 +1404,13 @@ export async function runWorkflowWithQuickJS(params: { } } - // 3. No cheap progress left — execute steps inline. + // 3. No cheap progress left, so execute steps inline. const stepOps = pendingOperations.filter( (op): op is PendingStep => op.type === 'step' ); // Steps created by an EARLIER invocation (or an earlier turn) that // are still pending, with no work owned by THIS invocation. Mirror - // the node engine's ownership decision table (step-ownership.ts) — + // the node engine's ownership decision table (step-ownership.ts), // NOT a deliveryAttempt gate: worlds advance the attempt counter on // routine redeliveries (world-local counts every handled response), // so attempt > 1 is the common case and would fire backstops at @@ -1486,7 +1486,7 @@ export async function runWorkflowWithQuickJS(params: { } if (inlineCandidates.length === 0) { - // No in-process progress possible — the run awaits an external + // No in-process progress possible: the run awaits an external // stimulus (hook payload, queued step, wait timer). break; } @@ -1494,7 +1494,7 @@ export async function runWorkflowWithQuickJS(params: { // Racing timers must fire on time while step bodies block this // invocation: enqueue a delayed continuation for the soonest // pending wait (a separate invocation writes its wait_completed at - // the right log position — same mechanism as the node:vm engine's + // the right log position, the same mechanism as the node:vm engine's // wait-continuation dispatch). let soonestWait: { correlationId: string; seconds: number } | undefined; for (const op of pendingOperations) { @@ -1502,7 +1502,7 @@ export async function runWorkflowWithQuickJS(params: { const wait = op as PendingWait; if (scheduledWaitContinuations.has(wait.correlationId)) continue; // Waits whose wait_completed THIS invocation already wrote (the - // elapsed-wait pass above) are done — the event just hasn't fed + // elapsed-wait pass above) are done: the event just hasn't fed // back into the VM yet. No continuation needed. if (completedWaitIds2.has(wait.correlationId)) continue; const resumeMs = new Date(wait.resumeAt).getTime() - Date.now(); @@ -1512,14 +1512,14 @@ export async function runWorkflowWithQuickJS(params: { // whose deadline falls between this iteration's elapsed-wait // pass (which saw it as still pending and wrote nothing) and // this sweep would otherwise get NEITHER a wait_completed NOR a - // continuation — and the inline batch below then blocks this + // continuation, and the inline batch below then blocks this // invocation for the full step duration with no wake armed // anywhere. For `Promise.race(step, sleep)` that silently hands // the race to the step: the sleep's wait_completed is never // written and the run completes with the wrong winner. The // window between the two checks spans this iteration's dispatch // + feed round-trips, so on network-backed worlds (world-vercel) - // a short sleep lands in it routinely — observed as a ~50% + // a short sleep lands in it routinely, observed as a ~50% // sleepWinsRaceWorkflow failure rate in the Vercel e2e legs, // while world-local's sub-ms round-trips masked it locally. The // continuation invocation's pre-VM elapsed check writes the @@ -1551,11 +1551,11 @@ export async function runWorkflowWithQuickJS(params: { } // Execute the inline batch in parallel. The replay budget is - // paused while step bodies run — step duration is bounded by the + // paused while step bodies run: step duration is bounded by the // platform function duration, not the replay timeout. NOTE (by // design): with the budget parked per batch, the only bound on how // many inline steps one invocation can chain is the platform's - // function timeout — the SDK deliberately imposes no cap of its + // function timeout: the SDK deliberately imposes no cap of its // own, matching the node:vm engine, where a long sequential // workflow likewise runs step-by-step until the platform reclaims // the invocation and a redelivery resumes from the log. @@ -1580,7 +1580,7 @@ export async function runWorkflowWithQuickJS(params: { runSpecVersion: workflowRun.specVersion, // Lazy inline claim: step_created is deferred (dispatch // skipped it) and this step_started carries the input, - // so the world creates the step atomically — + // so the world creates the step atomically: // exactly-one-owner. A concurrent claimant gets // EntityConflictError → { type: 'skipped' } and never // runs the body. Mirrors the node engine's inline path. @@ -1592,7 +1592,7 @@ export async function runWorkflowWithQuickJS(params: { // flight in this invocation and arm a delayed backstop // instead of immediately requeueing the step. ownerMessageId, - // A lazy step is brand-new by construction — first + // A lazy step is brand-new by construction: first // attempt. authoritativeAttempt: 1, }))() @@ -1609,7 +1609,7 @@ export async function runWorkflowWithQuickJS(params: { const outcome = outcomes[i]; executedStepIds.add(step.correlationId); if (outcome.type === 'retry' || outcome.type === 'throttled') { - // Hand the step to the queue with the requested backoff — + // Hand the step to the queue with the requested backoff: // background delivery drives the retry from here. queuedStepIds.add(step.correlationId); await queueStepMessage({ @@ -1621,7 +1621,7 @@ export async function runWorkflowWithQuickJS(params: { namespace, nextTraceCarrier, // Suffixed key: this step was inline-claimed, so no dispatch - // publish exists under the dispatch key — but suffixing + // publish exists under the dispatch key, but suffixing // keeps the retry enqueueable even if a world retired a // historical key for this step (see the purpose docs above). purpose: 'retry:1', @@ -1649,8 +1649,8 @@ export async function runWorkflowWithQuickJS(params: { // park the run 'running' with all its steps complete. Raise the // requeue signal so the suspended exit schedules a fresh immediate // invocation whose fresh read picks the terminals up. Outcomes that - // wrote no terminal ('skipped' — a concurrent claimant owns the - // body; 'gone', retry/throttled — a queue message exists) don't + // wrote no terminal ('skipped': a concurrent claimant owns the + // body; 'gone', retry/throttled: a queue message exists) don't // need it, but signaling on them too only costs a no-op invocation // in an already-rare lag window. const newEvents = await fetchUnseenEvents(); @@ -1691,7 +1691,7 @@ export async function runWorkflowWithQuickJS(params: { // Flush leftover pending side effects (abort recordings, system-hook // disposals, fire-and-forget attribute/hook events) BEFORE writing - // run_completed — mirrors the node:vm engine's drainPendingQueueItems. + // run_completed. Mirrors the node:vm engine's drainPendingQueueItems. // Drain failures are swallowed: the workflow's own outcome is the // source of truth. if (result.completed.drainOperations?.length) { @@ -1772,7 +1772,7 @@ export async function runWorkflowWithQuickJS(params: { }); if (runGone) { - // The run no longer exists (expired / deleted) — nothing to drive. + // The run no longer exists (expired / deleted), so nothing to drive. wfdiag('exit_suspended', { action: 'run_gone' }); return; } @@ -1781,13 +1781,13 @@ export async function runWorkflowWithQuickJS(params: { // visibility-redelivery of the current message. Redelivering the // CURRENT message is a trap: a hook-resume delivery carries // `hookInput`, and its redelivery re-runs the lazy-resume re-ensure - // in the handler prologue — if the workflow disposed that hook + // in the handler prologue: if the workflow disposed that hook // during this invocation (dispose → sleep), a world that rejects // the re-ensure would ack the message as "nothing left to resume" // and the continuation it carried is silently lost. A fresh message // carries only `runId`, so its delivery always reaches replay (and // under turbo a reschedule would re-engage turbo against a stale - // preloaded log — see the reinvoke() docs in runtime.ts). + // preloaded log: see the reinvoke() docs in runtime.ts). const requeueImmediately = async (): Promise => { await queueMessage( world, @@ -1802,7 +1802,7 @@ export async function runWorkflowWithQuickJS(params: { if (budget.isExhausted()) { // The loop stopped on the replay budget with progress still - // possible — continue in a fresh invocation. + // possible, so continue in a fresh invocation. wfdiag('exit_suspended', { action: 'budget_exhausted_requeue' }); await requeueImmediately(); return; @@ -1843,7 +1843,7 @@ export async function runWorkflowWithQuickJS(params: { // terminals) but the eventually-consistent listing never returned // them before the loop exited. Without a requeue the run would // park awaiting_external with its unblocking events already - // durably written and no future invocation coming — requeue + // durably written and no future invocation coming, so requeue // immediately so a fresh read picks them up. In the common case // the loop's own feed observes the writes and clears this flag, so // this only fires when the read actually lagged. @@ -1856,7 +1856,7 @@ export async function runWorkflowWithQuickJS(params: { // Delayed continuation for the soonest pending wait the loop has // not already scheduled. The dispatch helper handles delay // clamping (long waits chain across hops) and idempotency-key - // dedup of re-observations of the same pending wait — see + // dedup of re-observations of the same pending wait. See // runtime/wait-continuation.ts. wfdiag('exit_suspended', { action: 'schedule_wait_timeout', @@ -1885,11 +1885,11 @@ export async function runWorkflowWithQuickJS(params: { pendingOpsCount: pendingOperations.length, }); } else if (result.failed) { - // Workflow failed — remap stack trace using inline source maps. + // Workflow failed, so remap stack trace using inline source maps. // Frames carry the run's workflowId as their filename on the fresh // path, but the workflow-independent BASELINE_BUNDLE_FILENAME on the // snapshot path (the name is baked into the shared baseline's - // compiled code at hydrate) — remap against both. remapErrorStack + // compiled code at hydrate), so remap against both. remapErrorStack // early-exits on a cheap includes() when a filename has no frames. let errorStack = result.failed.stack; if (errorStack) { @@ -1909,7 +1909,7 @@ export async function runWorkflowWithQuickJS(params: { // // The VM serializes errors as `{ name, message, stack }`, so we // reconstruct a host-side Error of the correct class based on the - // VM-side `name` — specific WorkflowRuntimeError subclasses need + // VM-side `name`: specific WorkflowRuntimeError subclasses need // to be preserved so classifyRunError() tags them as RUNTIME_ERROR. const reconstructed: Error = result.failed.name === 'WorkflowNotRegisteredError' @@ -1932,7 +1932,7 @@ export async function runWorkflowWithQuickJS(params: { ...Attribute.QuickJSOutcome('failed'), }); - // Flush leftover pending side effects before writing run_failed — + // Flush leftover pending side effects before writing run_failed, // same drain semantics as the completed branch. if (result.failed.drainOperations?.length) { try { @@ -1963,7 +1963,7 @@ export async function runWorkflowWithQuickJS(params: { // cause chain, plain object, primitive, etc.) using the VM's // workflow-serialize. Pass those bytes through directly so // type identity, cause chains, and non-Error throws survive. - // We just need to apply encryption if configured (the VM's + // Apply encryption if configured (the VM's // serializer doesn't have access to the encryption key). // * Legacy fallback: reconstruct an Error from the host-visible // {name, message, stack} fields and run it through @@ -1972,7 +1972,7 @@ export async function runWorkflowWithQuickJS(params: { let dehydratedError: Uint8Array; if (result.failed.valueBytes) { // Hydrate the VM-side bytes, remap the error stack with the - // host-side source map (the VM can't do this — it lacks both the + // host-side source map (the VM can't do this: it lacks both the // source map and `remapErrorStack`), and re-dehydrate. This // preserves the original value's type identity / cause chain // while fixing up frames to point at the user's source files. @@ -1990,7 +1990,7 @@ export async function runWorkflowWithQuickJS(params: { ) { const parsedName = parseWorkflowName(workflowName); const filename = parsedName?.moduleSpecifier || workflowName; - // Both filename spaces — see the failed-branch comment above. + // Both filename spaces. See the failed-branch comment above. (hydrated as { stack?: string }).stack = remapErrorStack( remapErrorStack( (hydrated as { stack: string }).stack, @@ -2010,7 +2010,7 @@ export async function runWorkflowWithQuickJS(params: { if (typeof nodeStack === 'string') { const parsedName = parseWorkflowName(workflowName); const filename = parsedName?.moduleSpecifier || workflowName; - // Both filename spaces — see the failed-branch comment above. + // Both filename spaces. See the failed-branch comment above. (node as { stack?: string }).stack = remapErrorStack( remapErrorStack(nodeStack, filename, workflowCode), BASELINE_BUNDLE_FILENAME, @@ -2026,7 +2026,7 @@ export async function runWorkflowWithQuickJS(params: { ); } catch (rehydrateErr) { // If hydration / re-dehydration fails for any reason, fall - // back to passing through the original VM bytes (just apply + // back to passing through the original VM bytes (applying // encryption if configured). Better to lose source-mapped // frames than to lose the error entirely. runtimeLogger.warn( diff --git a/packages/core/src/runtime/quickjs-runtime.ts b/packages/core/src/runtime/quickjs-runtime.ts index bc1cf5f80e..18e04a93f5 100644 --- a/packages/core/src/runtime/quickjs-runtime.ts +++ b/packages/core/src/runtime/quickjs-runtime.ts @@ -1,11 +1,11 @@ /** - * QuickJS WASM workflow VM. + * QuickJS WebAssembly (WASM) workflow VM. * * An alternative engine for the event-replay execution model: the workflow * code runs inside a QuickJS WASM VM (via quickjs-wasi) instead of a * `node:vm` context. Every invocation creates a fresh VM, re-executes the * workflow function from the top, and replays the recorded event log to - * resolve awaited primitives — the same replay semantics as the `node:vm` + * resolve awaited primitives: the same replay semantics as the `node:vm` * engine. * * The workflow primitives (useStep, sleep, createHook) are implemented as @@ -14,11 +14,11 @@ * resolve/reject promises. * * The VM bootstrap is deliberately split into two phases: - * 1. Static initialization (`initWorkflowVM`) — run-independent setup: + * 1. Static initialization (`initWorkflowVM`): run-independent setup: * VM creation and the workflow primitives. (Serialization lives on - * the host — see quickjs-serde.ts — so no serde code is evaluated + * the host (see quickjs-serde.ts) so no serde code is evaluated * in the VM.) - * 2. Per-run initialization (inline in `runQuickJSWorkflow`) — seeded + * 2. Per-run initialization (inline in `runQuickJSWorkflow`): seeded * PRNG/ULID host functions, workflow bundle evaluation, run metadata, * workflow input, and start. * Keeping the phases separate is groundwork for VM-memory snapshotting: @@ -72,7 +72,7 @@ import { runIdCreatedAt } from './run-id-time.js'; * Prepare persisted payload bytes for consumption inside the VM: decrypt * (when an encryption key is configured) and decompress (specVersion >= 5 * payloads may be gzip/zstd-compressed). The VM only understands plain - * format-prefixed 'devl' bytes — it has neither the key material nor zlib. + * format-prefixed 'devl' bytes: it has neither the key material nor zlib. * The key is the run's full DecryptionKey capability (symmetric AES key + * X25519 keypair) so sealed `encp` hook payloads from cross-deployment * resumeHook() calls open here too, not just symmetric `encr` ones. @@ -95,7 +95,7 @@ export interface PendingStep { stepId: string; /** * Format-prefixed devalue-serialized step input (args + closureVars). - * Absent when {@link serializationError} is set — the input is precisely + * Absent when {@link serializationError} is set: the input is precisely * what refused to serialize. */ input?: Uint8Array; @@ -184,7 +184,7 @@ export type PendingOperation = | PendingHookDispose; export interface QuickJSRuntimeResult { - /** The workflow completed — result is format-prefixed devalue bytes */ + /** The workflow completed: result is format-prefixed devalue bytes */ completed?: { result: Uint8Array; /** @@ -205,7 +205,7 @@ export interface QuickJSRuntimeResult { message: string; stack?: string; name?: string; - /** See completed.drainOperations — same semantics on failure. */ + /** See completed.drainOperations: same semantics on failure. */ drainOperations?: PendingOperation[]; /** * Format-prefixed devalue bytes of the original thrown value @@ -240,8 +240,8 @@ export interface QuickJSRuntimeOptions { /** * The local port the workflow server is listening on, used to populate * `workflowMetadata.url`. Resolved at call time on the host side so the - * VM doesn't have to probe the filesystem. Ignored on Vercel — VERCEL_URL - * takes precedence there. + * VM doesn't have to probe the filesystem. Ignored on Vercel, where + * VERCEL_URL takes precedence. */ port?: number; /** @@ -1008,7 +1008,7 @@ globalThis[Symbol.for("WORKFLOW_GET_STREAM_ID")] = function(namespace) { // ---- Runtime ---- /** - * Phase 1 — static (run-independent) VM initialization. + * Phase 1: static (run-independent) VM initialization. * * Creates a QuickJS VM and loads everything that does not depend on a * specific workflow run: the workflow-primitive bootstrap (useStep / @@ -1017,7 +1017,7 @@ globalThis[Symbol.for("WORKFLOW_GET_STREAM_ID")] = function(namespace) { * right after this returns. * * `getNowMs` backs the VM's WASI clock (`Date.now()` / `new Date()` - * inside the VM). The callback itself is static — the per-run state it + * inside the VM). The callback itself is static: the per-run state it * reads lives on the host and is advanced as events are consumed, * matching the node:vm engine's deterministic replay clock. * @@ -1044,7 +1044,7 @@ type CompiledExtension = Omit & { * Process-wide cache of the compiled `WebAssembly.Module`s for the main * QuickJS runtime and its native extensions. `WebAssembly.compile` of the * ~600 KB runtime binary is the most expensive part of VM creation and is - * pure (no per-VM state — instantiation binds the per-VM memory), so it + * pure (no per-VM state: instantiation binds the per-VM memory), so it * only needs to happen once per process. The promise is cached (not the * result) so concurrent first invocations share a single compilation. */ @@ -1082,7 +1082,7 @@ function getCompiledAssets() { } /** - * WASI clock override reading the given accessor — shared between fresh + * WASI clock override reading the given accessor, shared between fresh * boots (initWorkflowVM) and baseline-snapshot restores, so the two * paths cannot drift on rounding/encoding. * @@ -1134,14 +1134,14 @@ async function initWorkflowVM( // run-seeded PRNG and the run's deterministic clock. A restored heap // carries whatever module scope computed at HYDRATE time, so the // optimization is only sound when module scope consumed neither -// randomness nor time. Both are detected during hydrate — the placeholder -// host fns count draws, the hydrate clock counts reads — and a bundle +// randomness nor time. Both are detected during hydrate (the placeholder +// host fns count draws, the hydrate clock counts reads) and a bundle // that used either is marked ineligible: every invocation falls back to // fresh evaluation, preserving exact node:vm-parity semantics. When the // gate passes, restore is byte-equivalent to fresh eval (verified by the // parity tests): the per-run host fns are re-registered by name on the // restored VM (quickjs-wasi restore semantics) before the workflow body -// runs, so the seeded draw sequence — and every correlationId — is +// runs, so the seeded draw sequence (and every correlationId) is // identical. // // The cache is per function instance and keyed on the bundle string @@ -1153,7 +1153,7 @@ async function initWorkflowVM( * Eval filename used when hydrating the baseline VM. The baseline is * shared by EVERY workflow in the bundle, so the filename baked into * its compiled code (and therefore into snapshot-path stack frames) - * must be workflow-independent — hydrating under the first caller's + * must be workflow-independent: hydrating under the first caller's * workflowId would break `remapErrorStack`'s filename matching for * every other workflow in the bundle. Remap call sites match this * constant IN ADDITION to the run's module specifier (which covers @@ -1170,7 +1170,7 @@ type BaselineEntry = * bundle evaluated (see captureSerdeRoot). The box lives in the * snapshot's memory image at this offset; every restored VM * re-adopts it so serde initialization executes no guest code - * after user code has run — capture-before-user-code semantics, + * after user code has run: capture-before-user-code semantics, * identical to the fresh path. */ serdeRootPtr: number; @@ -1195,7 +1195,7 @@ export async function __peekBaselineEntryForTests( /** * Hydrate a VM with the workflow bundle and snapshot it, gating on * module-scope nondeterminism (see the section comment above). Returns an - * `ineligible` entry instead of throwing on eval failure — the fresh path + * `ineligible` entry instead of throwing on eval failure: the fresh path * re-evaluates and produces the real, source-mapped error. */ async function prepareBaselineSnapshot( @@ -1249,7 +1249,7 @@ async function prepareBaselineSnapshot( vm.setProp(vm.global, '__generateUlid', ulidFn); } - // Serde capture root — created BEFORE the bundle evaluates, exactly + // Serde capture root: created BEFORE the bundle evaluates, exactly // like the fresh path's capture. Its box pointer rides the // BaselineEntry and each restored VM re-adopts it, so serde // initialization never executes guest code after user code has run. @@ -1259,14 +1259,14 @@ async function prepareBaselineSnapshot( // serde uses the pristine pre-eval captures on both paths, and no // post-eval probe exists whose side effects could bake into the // snapshot. The handle is deliberately NOT disposed before the - // snapshot — the box must stay live in the memory image (the + // snapshot: the box must stay live in the memory image (the // baseline VM's dispose below tears down the whole instance without // freeing individual boxes). const serdeRoot = captureSerdeRoot(vm); clockReads = 0; // only count reads made by the bundle itself try { - // Workflow-independent filename — see BASELINE_BUNDLE_FILENAME. + // Workflow-independent filename. See BASELINE_BUNDLE_FILENAME. vm.evalCode(workflowCode, BASELINE_BUNDLE_FILENAME).dispose(); } catch { // Let the fresh path re-evaluate and surface the real error with @@ -1306,7 +1306,7 @@ async function prepareBaselineSnapshot( /** * Cached baseline entry for a bundle, preparing it on first access. * Concurrent first invocations share one hydrate via the cached promise; - * a hydrate that REJECTS (infrastructure failure, not bundle eval — that + * a hydrate that REJECTS (infrastructure failure, not bundle eval, which * returns `ineligible`) is evicted so a later invocation can retry. */ function getBaselineEntry( @@ -1330,7 +1330,7 @@ function getBaselineEntry( * A live QuickJS workflow invocation. When the initial `result` is * `suspended`, the VM is kept alive so the caller can feed newly recorded * events (e.g. terminal events of inline-executed steps) into the SAME VM - * via `continueWithEvents` — resuming execution exactly where it left off + * via `continueWithEvents`, resuming execution exactly where it left off * without a fresh-VM re-replay. Terminal results dispose the VM * automatically; `dispose()` must be called when abandoning a suspended * session (idempotent). @@ -1367,7 +1367,7 @@ export async function startQuickJSWorkflow( const startedAt = workflowRun.startedAt ? +workflowRun.startedAt : Date.now(); - // Deterministic PRNG seed — identical for EVERY invocation of the same + // Deterministic PRNG seed, identical for EVERY invocation of the same // run. Full event replay requires this: each invocation re-executes the // workflow from the top and must regenerate the exact same correlationId // sequence so that pending operations re-created by replay match the @@ -1391,7 +1391,7 @@ export async function startQuickJSWorkflow( ].join(':'); const rng = seedrandom(seed); - // Seeded nanoid generator — uses the same nanoid package and seeded PRNG + // Seeded nanoid generator: uses the same nanoid package and seeded PRNG // as the node:vm engine for consistent token generation. const generateNanoid = nanoid.customRandom(nanoid.urlAlphabet, 21, (size) => new Uint8Array(size).map(() => 256 * rng()) @@ -1429,7 +1429,7 @@ export async function startQuickJSWorkflow( } catch (err) { // A rejection here is an infrastructure failure during hydrate // (e.g. vm.snapshot() under memory pressure, QuickJS.create or - // getCompiledAssets() failing) — NOT bundle eval, which returns + // getCompiledAssets() failing), NOT bundle eval, which returns // an ineligible entry. getBaselineEntry has already evicted the // cached promise so a later invocation can retry. This invocation // must fall back to fresh evaluation (which would have succeeded) @@ -1456,10 +1456,10 @@ export async function startQuickJSWorkflow( // Host-side serde: captures the VM's intrinsics (bootstrap included) // before any user code runs. All serialization now happens on the host - // through handles — no serializer code is evaluated inside the VM. - // Fresh path: capture now (no user code has run — the bundle evaluates + // through handles: no serializer code is evaluated inside the VM. + // Fresh path: capture now (no user code has run, as the bundle evaluates // later in the per-run phase). Snapshot path: re-adopt the capture root - // the baseline hydrate created BEFORE the bundle evaluated — the box + // the baseline hydrate created BEFORE the bundle evaluated, since the box // lives in the restored memory image at the recorded offset. Both give // the serde pristine capture-before-user-code intrinsics; neither // executes guest code here. @@ -1471,7 +1471,7 @@ export async function startQuickJSWorkflow( // Any throw between here and the terminal paths (which dispose the VM // inside checkWorkflowState / extractError before RETURNING) would leak // a live QuickJS instance and its WASM linear memory for the lifetime - // of the compute instance — which is reused. Dispose on the way out of + // of the compute instance, which is reused. Dispose on the way out of // an exceptional exit and rethrow. try { return await runWorkflowInVM(); @@ -1479,7 +1479,7 @@ export async function startQuickJSWorkflow( try { vm.dispose(); } catch { - // Already disposed by a terminal path — ignore. + // Already disposed by a terminal path, so ignore. } throw err; } @@ -1507,11 +1507,11 @@ export async function startQuickJSWorkflow( // Host-side deterministic ULID generator for correlationIds. Uses the // same `ulid` package and monotonic factory as before, drawing from - // the SAME seeded PRNG instance as the VM's Math.random — so the + // the SAME seeded PRNG instance as the VM's Math.random, so the // interleaved draw sequence (and therefore every correlationId) is // byte-identical to what the previous in-VM ULID factory produced for // the same run. The time prefix is derived from the runId's embedded - // ULID (stable across invocations by construction — unlike + // ULID (stable across invocations by construction, unlike // `startedAt`, which differs between turbo's synthesized run object // and the durably stored run), so two concurrent invocations of the // same run produce IDENTICAL correlationIds and the world's @@ -1527,17 +1527,17 @@ export async function startQuickJSWorkflow( vm.setProp(vm.global, '__generateUlid', ulidFn); } - // `process.env` — parity with the node:vm engine, which exposes a frozen + // `process.env`: parity with the node:vm engine, which exposes a frozen // copy of the host env (vm/index.ts). Injected per run so the snapshot of // the env is taken at invocation time, same as node. Handle-based (no // guest source evaluated): on the baseline-snapshot path this runs // after user code, and a guest-source injection would execute through - // potentially patched globals (JSON.parse, Object.freeze) — visible + // potentially patched globals (JSON.parse, Object.freeze), visible // to module-scope wrappers only on the restore path, diverging // replays. serde.installProcessEnv(process.env); - // Execute the workflow bundle — use the workflowId as the eval filename + // Execute the workflow bundle: use the workflowId as the eval filename // so QuickJS stack traces reference the workflow name, enabling source map // remapping by remapErrorStack (which matches frames by filename). // Evaluated in the per-run phase (after Math.random seeding) so that @@ -1545,7 +1545,7 @@ export async function startQuickJSWorkflow( // node:vm engine's replay determinism. Skipped on the // baseline-snapshot path: the restored heap already carries the // evaluated bundle, and the baseline gate guarantees module scope - // consumed no PRNG draws or clock reads — so skipping the eval is + // consumed no PRNG draws or clock reads, so skipping the eval is // observationally identical to re-running it (the run-seeded host // fns registered above rebind the SAME names the restored heap's // function objects dispatch through). @@ -1562,7 +1562,7 @@ export async function startQuickJSWorkflow( // Extract workflow arguments. Prefer the run_created event; fall back // to the queue message's runInput if the event log is incomplete // (eventually-consistent read after start()). Failing to find input - // for a first invocation is fatal — running the workflow function + // for a first invocation is fatal: running the workflow function // with no args would silently turn typed arguments into `undefined` // and, for recursive workflows, produce exponential fan-out. const runCreatedEvent = events.find((e) => e.eventType === 'run_created'); @@ -1592,13 +1592,13 @@ export async function startQuickJSWorkflow( // The event log is non-empty (we got run_started or similar) but // no run_created event was found and no queue-provided runInput is // available. This is the race condition observed during the fib - // incident — silently dropping arguments would turn `n` into + // incident: silently dropping arguments would turn `n` into // `undefined` and, for recursive workflows, cause exponential // fan-out. Fail loud: the throw escapes the entrypoint into the // replay loop's catch in runtime.ts (the QuickJS dispatch runs // inside that loop's try), which records run_failed. A visible // terminal failure is - // preferred over silently executing with undefined arguments — the + // preferred over silently executing with undefined arguments. The // queue-provided runInput fallback above makes this path rare. // Empty `events` is allowed because tests that bootstrap a workflow // with no arguments rely on the old permissive behavior. @@ -1700,7 +1700,7 @@ export async function startQuickJSWorkflow( } while (batch > 0); } while (madeProgress && --maxIterations > 0); if (madeProgress && maxIterations === 0) { - // The drain loop hit its bound while still making progress — + // The drain loop hit its bound while still making progress: // proceeding as if it converged would present as a mysterious // suspension or replay divergence. Make the giving-up visible so // a wedge is attributable to this bound rather than a mystery. @@ -1765,7 +1765,7 @@ function makeLiveSession( 'QuickJS workflow session is not alive — continueWithEvents is only valid while suspended' ); } - // Fresh execution burst — the interrupt budget bounds VM compute, + // Fresh execution burst: the interrupt budget bounds VM compute, // not wall time spent waiting on inline steps between bursts. interruptBudget.start = Date.now(); @@ -1799,7 +1799,7 @@ function makeLiveSession( try { vm.dispose(); } catch { - // Already disposed — ignore. + // Already disposed, so ignore. } } }, @@ -1822,7 +1822,7 @@ async function processEvents( // observed it. Step over it BEFORE the clock line below, not at the // switch: its `createdAt` is the sealer's wall clock and can postdate // every real event around it, so advancing to it would leak the sealer's - // schedule into replay — and because the clock is monotonic, every later + // schedule into replay. Because the clock is monotonic, every later // Date.now() in the run with it. That would make a log whose hole was // sealed replay differently from the same log whose hole its own writer // filled, and differently from this log on the node:vm engine, which @@ -1832,7 +1832,7 @@ async function processEvents( // Advance the VM's deterministic clock to this event's creation time // BEFORE resolving anything, so workflow code unblocked by this event - // observes Date.now() at (or after — the clock is monotonic) the time + // observes Date.now() at (or after, since the clock is monotonic) the time // the event was recorded. Mirrors the node:vm engine's // `onConsumedEvent → updateTimestamp(+event.createdAt)`. advanceClock(+event.createdAt); @@ -1858,7 +1858,7 @@ async function processEvents( const rawOutput = eventData?.result ?? eventData?.output; if (hasResolver) { if (rawOutput instanceof Uint8Array) { - // Decrypt if encrypted — the VM only understands 'devl' format + // Decrypt if encrypted: the VM only understands 'devl' format runtimeLogger.debug('QuickJS runtime: step result raw', { correlationId: cid, rawPrefix: new TextDecoder().decode(rawOutput.subarray(0, 4)), @@ -1906,7 +1906,7 @@ async function processEvents( } while (b > 0); } } else { - // No resolver yet — buffer the prepared outcome so the promise + // No resolver yet, so buffer the prepared outcome so the promise // settles the moment the VM constructs it (see __terminalBuffer // in the bootstrap). Without this, the live-continuation path // (which scans each delta exactly once) drops the terminal and @@ -1994,7 +1994,7 @@ async function processEvents( } while (b > 0); } } else { - // No resolver yet — buffer the prepared rejection (see the + // No resolver yet, so buffer the prepared rejection (see the // step_completed branch above for the rationale). const errorData = eventData?.error; if (errorData instanceof Uint8Array) { @@ -2046,7 +2046,7 @@ async function processEvents( } while (b > 0); } } else { - // No resolver yet — buffer (see step_completed above). + // No resolver yet, so buffer (see step_completed above). vm.evalCode( `globalThis.__terminalBuffer[${cidJs}] = { kind: "resolve_undefined" };` ).dispose(); @@ -2065,7 +2065,7 @@ async function processEvents( vm.evalCode(`!!globalThis.__resolvers[${cidJs}]`) ); if (!hasResolver) { - // No resolver yet — buffer (see step_completed above). + // No resolver yet, so buffer (see step_completed above). vm.evalCode( `globalThis.__terminalBuffer[${cidJs}] = { kind: "resolve_undefined" };` ).dispose(); @@ -2113,7 +2113,7 @@ async function processEvents( // EventsConsumer in workflow/hook.ts): two hook_received rows for // ONE resume attempt share a client-minted `resumeId` (a duplicate // can be committed when the materialization fallback races a - // delayed direct write — hook_received has no storage uniqueness + // delayed direct write, since hook_received has no storage uniqueness // constraint). Deliver only the first-in-log occurrence. The seen // set lives in the VM heap so it is deterministic per replay and // survives event re-scans within the invocation. Events without a @@ -2121,8 +2121,8 @@ async function processEvents( { // Top-level event.resumeId is the canonical location (the backend // hoists it to a first-class column); the nested - // eventData.resumeId form is a deprecated legacy fallback — - // mirrors the node engine's dedup in workflow/hook.ts. + // eventData.resumeId form is a deprecated legacy fallback. + // Mirrors the node engine's dedup in workflow/hook.ts. const resumeId = (event as { resumeId?: unknown }).resumeId ?? (eventData as { resumeId?: unknown } | undefined)?.resumeId; @@ -2182,7 +2182,7 @@ async function processEvents( `globalThis.__abortSignals[${cidJs}]._setAborted(undefined);` ).dispose(); } - // The abort is durably recorded — clear the pending op's + // The abort is durably recorded, so clear the pending op's // abortRequested marker so the host doesn't re-record it (the // workflow's own abort() call can set the flag before this // event is processed when it happens later in replay order, @@ -2226,7 +2226,7 @@ async function processEvents( }); if (hasResolver) { if (rawPayload instanceof Uint8Array) { - // Decrypt if encrypted — the VM only understands 'devl' format + // Decrypt if encrypted: the VM only understands 'devl' format const decryptedPayload = await prepareBytesForVM( rawPayload, encryptionKey @@ -2264,7 +2264,7 @@ async function processEvents( } while (b > 0); } } else { - // No resolver yet — buffer the payload in the VM heap. When + // No resolver yet, so buffer the payload in the VM heap. When // createHookPromise() is called later, it will drain this buffer // first (matching the node:vm engine's payloadsQueue behavior). const eventIdJs = event.eventId @@ -2277,7 +2277,7 @@ async function processEvents( ? `(globalThis.__hookPayloadBuffer.__processedEventIds = globalThis.__hookPayloadBuffer.__processedEventIds || {})[${eventIdJs}] = true;` : ''); if (rawPayload instanceof Uint8Array) { - // Decrypt if encrypted — the VM only understands 'devl' format + // Decrypt if encrypted: the VM only understands 'devl' format const decryptedPayload = await prepareBytesForVM( rawPayload, encryptionKey @@ -2315,7 +2315,7 @@ async function processEvents( // with HookConflictError; getConflict() awaiters resolve with a // Run handle for the conflicting run (revived through the VM's // class registry so its methods are durable step proxies) or - // reject with the error when no handle can be constructed — + // reject with the error when no handle can be constructed, // mirroring the node:vm engine's hook.ts hook_conflict handling. const conflictToken = (eventData?.token as string) ?? 'unknown'; const conflictingRunId = eventData?.conflictingRunId as @@ -2411,7 +2411,7 @@ async function processEvents( } case 'hook_disposed': { // Disambiguate from the `hook` pending op with the same - // correlationId — we want to mark the `hook_dispose` entry. + // correlationId: we want to mark the `hook_dispose` entry. markCreated(vm, cidJs, 'hook_dispose'); break; } @@ -2424,7 +2424,7 @@ function markCreated(vm: QuickJS, cidJs: string, opType?: string): void { // `cidJs` is the JSON.stringify-quoted correlation id (see processEvents). // `hook` and `hook_dispose` pending ops share the same correlationId, // so when processing `hook_disposed` events we must disambiguate by - // type — otherwise `.find()` returns the original `hook` op and the + // type: otherwise `.find()` returns the original `hook` op and the // `hook_dispose` op is never marked, causing the entrypoint to keep // retrying a hook_disposed for an already-deleted entity. const predicate = opType @@ -2522,7 +2522,7 @@ function dumpPendingOps( // Byte-cache eviction: entries for settled ops can never be read again // (neither collection filter matches a settled op), so dropping them // bounds the cache by the LIVE pending set instead of growing - // monotonically for the VM's lifetime — which matters for the inline + // monotonically for the VM's lifetime, which matters for the inline // loop's long-lived sessions and snapshot-restored VMs. if (byteCache && dumped.settled.length > 0) { for (const cid of dumped.settled) { @@ -2550,7 +2550,7 @@ function dumpPendingOps( // from the outside, where no workflow code can observe it (and // with a bare DevalueError instead of the framed message the // node:vm engine produces). Reframe it exactly like - // `dehydrateStepArguments` does and surface it on the op — the + // `dehydrateStepArguments` does and surface it on the op: the // entrypoint finalizes the step as step_created + step_failed so // the failure rejects into the workflow, catchable. Other raw // fields (hook metadata, abort payloads) keep the throwing @@ -2583,7 +2583,7 @@ function collectDrainOperations( ): PendingOperation[] { // Share the per-VM byte cache with the suspension path: an op that was // serialized during a suspension pass must reuse those exact bytes at - // terminal drain — re-serializing can invoke getters again and produce + // terminal drain, since re-serializing can invoke getters again and produce // a DIFFERENT byte sequence for what the event log treats as one value. return dumpPendingOps( vm, @@ -2622,7 +2622,7 @@ function checkWorkflowState( serde: QuickJSSerde, opts: { keepAliveOnSuspend?: boolean } = {} ): QuickJSRuntimeResult { - // Check completed — __workflowResult holds the RAW return value (with a + // Check completed: __workflowResult holds the RAW return value (with a // separate done flag so `undefined` results are distinguishable); the // host serializes it through a handle. { @@ -2666,7 +2666,7 @@ function checkWorkflowState( valueBytes = serde.serialize(rawValue); } catch (serializeErr) { // A thrown value the codec cannot serialize must not mask the - // workflow failure itself — fall back to the display fields. + // workflow failure itself, so fall back to the display fields. runtimeLogger.warn( 'QuickJS runtime: failed to serialize thrown workflow error', { @@ -2703,7 +2703,7 @@ function checkWorkflowState( } } - // Check suspended — the workflow is suspended if there are active resolvers + // Check suspended: the workflow is suspended if there are active resolvers // OR pending operations that haven't been created yet (e.g. hooks created // upfront but not yet awaited) { @@ -2767,7 +2767,7 @@ function extractError( * Mutable interrupt budget for a VM. QuickJS polls the interrupt handler * during JS execution; when it returns true, execution aborts. The budget * bounds a single host->VM execution burst (bundle eval + event - * processing), not total VM lifetime — the inline-step loop keeps a VM + * processing), not total VM lifetime: the inline-step loop keeps a VM * alive across step executions that can legitimately take minutes, so the * host resets the budget before each re-entry (see resetBudget calls). * diff --git a/packages/core/src/runtime/quickjs-serde.ts b/packages/core/src/runtime/quickjs-serde.ts index 159871a4b7..86e4e7a679 100644 --- a/packages/core/src/runtime/quickjs-serde.ts +++ b/packages/core/src/runtime/quickjs-serde.ts @@ -6,8 +6,8 @@ * host code operating on `JSValueHandle`s, using devalue 5.9's pluggable * operations (quickjs-wasi's host-side introspection primitives underneath). * The serde bundle previously evaluated inside the VM is gone: guest values - * are read and built through handles, so no serializer code lives in — or - * can be tampered with from — the guest realm. + * are read and built through handles, so no serializer code lives in (or + * can be tampered with from) the guest realm. * * Side-effect discipline mirrors the node:vm engine's hardened codec * (serialization/hardened.ts): @@ -17,7 +17,7 @@ * `Symbol.toStringTag`; * - extraction goes through intrinsics captured at boot (before any user * code runs) invoked with explicit receivers, or through own-property - * descriptor reads — patched prototypes and inherited accessors never + * descriptor reads, so patched prototypes and inherited accessors never * run; * - the only guest code serialization can execute is the same code the * previous in-VM codec executed by contract: a class's static @@ -30,7 +30,7 @@ * versa). * * Hybrid value space: reducers return host shapes (plain objects, strings, - * numbers) whose leaves may be guest handles — exactly how the node:vm + * numbers) whose leaves may be guest handles, exactly how the node:vm * codec mixes host shapes with sandbox-realm leaves. Every stringify * operation therefore dispatches on `JSValueHandle` and falls back to * devalue's default host operations for host values. Parse operations @@ -143,8 +143,8 @@ const BRANDED_SAMPLES = `({ * * Globals installed by the extensions/bootstrap (Headers, Request, * Response, ReadableStream, WritableStream, URL, URLSearchParams, - * DOMException, __WorkflowAbortSignal) are captured defensively — absent - * ones yield `undefined` and their reducers simply never match, exactly + * DOMException, __WorkflowAbortSignal) are captured defensively: absent + * ones yield `undefined` and their reducers never match, exactly * like the old in-VM reducers' `globalThis.X` probes. */ const CAPTURE_INTRINSICS = `(() => { @@ -268,7 +268,7 @@ type SymbolName = (typeof SYMBOL_NAMES)[number]; * created BEFORE the workflow bundle evaluates (capture-before-user-code, * same as the fresh path), its handle's box lives in the snapshot's * linear memory, and every restored VM re-adopts it BY POINTER - * (`adoptSerdeRoot`) — so serde initialization executes NO guest code + * (`adoptSerdeRoot`), so serde initialization executes NO guest code * after user code has run. Anything less lets a module-scope wrapper * around e.g. `Object.getOwnPropertyDescriptor` observe (and be mutated * by) a post-eval capture, diverging fresh and restored replays. @@ -288,7 +288,7 @@ ${SYMBOL_NAMES.map( * bootstrap and BEFORE any user code. The returned handle owns the * container; on the baseline-snapshot path its raw box pointer * (`handle.ptr`) is recorded next to the snapshot and re-adopted per - * restored VM via {@link adoptSerdeRoot} — do NOT dispose the handle + * restored VM via {@link adoptSerdeRoot}. Do NOT dispose the handle * before the snapshot is taken (the box must stay live in the memory * image). */ @@ -300,7 +300,7 @@ export function captureSerdeRoot(vm: QuickJS): JSValueHandle { * Re-create the capture-root handle in a VM restored from a snapshot * taken while the exported root was alive, via quickjs-wasi's * snapshot-portable handle tokens (`importHandle` duplicates the - * underlying value — the returned handle is independently owned and the + * underlying value: the returned handle is independently owned and the * serde disposes it per restored VM). No guest code executes. */ export function adoptSerdeRoot(vm: QuickJS, token: number): JSValueHandle { @@ -310,7 +310,7 @@ export function adoptSerdeRoot(vm: QuickJS, token: number): JSValueHandle { /** * Export the capture root as a snapshot-portable token to record next to * the baseline snapshot. The root handle must stay undisposed until the - * snapshot is taken (its box — and the reference it holds — must be part + * snapshot is taken (its box, and the reference it holds, must be part * of the memory image). */ export function exportSerdeRoot(vm: QuickJS, root: JSValueHandle): number { @@ -325,7 +325,7 @@ export interface QuickJSSerde { /** * The reducer names this codec applies, in registration order. Exposed * so tests can assert exhaustiveness against the shared value-space - * codec (codec-devalue-vm) — a reducer added there but not here would + * codec (codec-devalue-vm): a reducer added there but not here would * otherwise silently round-trip values as plain objects. */ reducerKeys: readonly string[]; @@ -333,7 +333,7 @@ export interface QuickJSSerde { reviverKeys: readonly string[]; /** * Install `globalThis.process = { env: Object.freeze({...}) }` in the - * VM through handles and boot-captured intrinsics only — no guest + * VM through handles and boot-captured intrinsics only: no guest * source is evaluated, so a module-scope wrapper around JSON.parse / * Object.freeze cannot observe the injection. This matters on the * baseline-snapshot path, where env injection happens after user code @@ -355,7 +355,7 @@ export function createQuickJSSerde( * Pre-captured serde root (baseline-snapshot path): the container * `captureSerdeRoot` created BEFORE user code, re-adopted from the * restored memory image via `adoptSerdeRoot`. When omitted, the root - * is captured now — callers must guarantee no user code has run yet. + * is captured now; callers must guarantee no user code has run yet. * Either way, initialization below performs only plain-data property * reads and C-level classId reads on the container: NO guest code * executes here, so nothing user-patchable can observe or perturb it. @@ -513,7 +513,7 @@ export function createQuickJSSerde( * Guest own-property check through the handle's introspection method. * MUST NOT be replaced with `Object.hasOwn`, which would interrogate the * host JSValueHandle wrapper object (always false) instead of the guest - * value — biome's noPrototypeBuiltins auto-fix does exactly that, which + * value; biome's noPrototypeBuiltins auto-fix does exactly that, which * is why this is centralized here with the suppression. */ const guestHasOwn = (handle: JSValueHandle, key: string): boolean => @@ -522,7 +522,7 @@ export function createQuickJSSerde( /** * Own data-property read. Returns undefined for absent properties AND for - * accessor properties — an inherited or own getter is never invoked + * accessor properties: an inherited or own getter is never invoked * (matching the hardened node:vm codec's descriptor-based reads). */ const own = ( @@ -592,7 +592,7 @@ export function createQuickJSSerde( }; /** - * Whether `handle` has `prototypeHandle` anywhere on its prototype chain — + * Whether `handle` has `prototypeHandle` anywhere on its prototype chain: * the trap-free analogue of `instanceof` (which would fire * `Symbol.hasInstance`). */ @@ -725,8 +725,8 @@ export function createQuickJSSerde( }; /** - * Extract a guest bigint via the captured `BigInt.prototype.toString` — - * `handle.toBigInt()` truncates to 64 bits. + * Extract a guest bigint via the captured `BigInt.prototype.toString`, + * since `handle.toBigInt()` truncates to 64 bits. */ const guestBigInt = (handle: JSValueHandle): bigint => BigInt(call(i.bigIntToString, handle).consume((h) => h.toString())); @@ -2116,7 +2116,7 @@ export function createQuickJSSerde( serialize(value: JSValueHandle): Uint8Array { // Handle scope: reducers and the hybrid operations mint one handle // per visited value node (descriptor reads, dup()s, intrinsic call - // results) and nothing disposes them individually — without the + // results) and nothing disposes them individually. Without the // scope each serialize leaks ~one handle per node for the VM's // lifetime, which compounds across an inline-loop session's whole // batch. Requires quickjs-wasi >= 3.3.1: earlier versions also @@ -2129,7 +2129,7 @@ export function createQuickJSSerde( // // `identities` must be cleared per pass ONCE handles are bulk-freed: // it keys object identity on the raw guest pointer, and freeing - // handles lets QuickJS reuse pointers — a stale entry from an + // handles lets QuickJS reuse pointers, so a stale entry from an // earlier pass could then alias a different object and corrupt the // dedup/cycle detection. Identity only needs stability within one // stringify pass. @@ -2159,7 +2159,7 @@ export function createQuickJSSerde( const payload = decoder.decode(data.subarray(FORMAT_PREFIX_LENGTH)); // Handle scope, mirroring serialize(): revivers mint intermediate // handles (children already attached to their parents, intrinsic - // call results) that are safe to free once the graph is built — + // call results) that are safe to free once the graph is built: // guest values are refcounted, so parents keep their children // alive. Only the root escapes to the caller. return vm.withScope((scope) => diff --git a/packages/core/src/runtime/replay-budget.ts b/packages/core/src/runtime/replay-budget.ts index 53f70f3b1f..28457eb39c 100644 --- a/packages/core/src/runtime/replay-budget.ts +++ b/packages/core/src/runtime/replay-budget.ts @@ -12,7 +12,7 @@ import { getWorld } from './world.js'; * handler run: deterministic event-log replay, workflow-VM execution * between step boundaries, suspension handling, queue round-trips, etc. * Inline step bodies (`"use step"` functions invoked via `executeStep`) - * are intentionally excluded — they are bounded by the platform's + * are intentionally excluded since they are bounded by the platform's * function `maxDuration` and the `NO_INLINE_REPLAY_AFTER_MS` early-return * guard. * @@ -38,7 +38,7 @@ import { getWorld } from './world.js'; * This protects against double-counting in future refactors that nest * step execution or take an early-return path between a `pause()` and * the matching `resume()`. - * - `isExhausted()` is checked at loop boundaries by the caller — the + * - `isExhausted()` is checked at loop boundaries by the caller; the * budget itself does not arm any timers. This means an in-flight * pathological `runWorkflow` call (e.g. a huge event-log replay) can * overshoot the budget by up to one iteration's worth of work before @@ -75,7 +75,7 @@ export class ReplayBudget { } /** - * Stop counting elapsed time toward the budget. Idempotent — safe to + * Stop counting elapsed time toward the budget. Idempotent: safe to * call multiple times in a row; subsequent calls are no-ops until * `resume()` reopens an interval. */ @@ -86,7 +86,7 @@ export class ReplayBudget { } /** - * Resume counting elapsed time toward the budget. Idempotent — safe to + * Resume counting elapsed time toward the budget. Idempotent: safe to * call multiple times in a row; subsequent calls re-anchor the * interval start to `now()`, which is fine because no time accrues * between back-to-back `resume()` calls. diff --git a/packages/core/src/runtime/replay-recovery-reporter.ts b/packages/core/src/runtime/replay-recovery-reporter.ts index 55268f64c7..98ef046592 100644 --- a/packages/core/src/runtime/replay-recovery-reporter.ts +++ b/packages/core/src/runtime/replay-recovery-reporter.ts @@ -19,7 +19,7 @@ export class ReplayRecoveryReporter { * it, so `withEventCreate` always passes writes through untouched. Lets every * call site hold a non-optional reporter instead of branching on one. * - * Structurally inert rather than a zero-count reporter on purpose — the + * Structurally inert rather than a zero-count reporter on purpose: the * server rejects a count below 1 as out-of-range and emits a * `telemetry_rejected` metric, so a reporter that *can* report zero is a * live footgun. diff --git a/packages/core/src/runtime/resume-hook.ts b/packages/core/src/runtime/resume-hook.ts index 6cf0a68fec..1deb367fd7 100644 --- a/packages/core/src/runtime/resume-hook.ts +++ b/packages/core/src/runtime/resume-hook.ts @@ -49,7 +49,7 @@ const generateResumeId = monotonicFactory(); * message at ~256 KiB, and the message also carries the runId, hookId, token, * resumeId, digest, and trace carrier alongside CBOR framing overhead. Staying * well under that ceiling keeps the queue publish from rejecting an oversized - * message — which, on the parallel path, would persist `hook_received` but never + * message, which, on the parallel path, would persist `hook_received` but never * re-trigger the run. Above this size we fall back to the sequential path, whose * queue message carries only the run ID (the payload lives in the event log). */ @@ -61,7 +61,7 @@ const MAX_INLINE_RESUME_PAYLOAD_BYTES = 128 * 1024; * the queue `hookInput`, so both writers of the same `resumeId` record an * identical digest on the server's `(runId, resumeId)` constraint. Hashing the * already-serialized bytes (not the raw value) keeps producer and consumer in - * lockstep — the consumer forwards this string without recomputing. + * lockstep: the consumer forwards this string without recomputing. */ async function computeResumePayloadDigest(bytes: Uint8Array): Promise { const digest = await crypto.subtle.digest('SHA-256', bytes); @@ -110,8 +110,8 @@ function resumeContextFromRun(run: WorkflowRun): HookResumeContext { /** * Resolve resume context for a hook. Uses the stored `resumeContext` when - * present (fast path — no run read); otherwise fetches the run and synthesizes - * it. Does NOT resolve the encryption key — callers do that separately. Only + * present (fast path, no run read); otherwise fetches the run and synthesizes + * it. Does NOT resolve the encryption key; callers do that separately. Only * the fallback path can gate key work behind a local terminal-run check (it * has the fetched run); the fast path's stored context carries no status, so * seal/serialization work may run before the receiving side rejects @@ -131,7 +131,7 @@ async function resolveHookResumeInfo(hook: Hook): Promise { /** * Resolve the run's symmetric key for a payload WRITE, as a bare `CryptoKey` - * (`importKey`) — the `encr` write fallback used when the run published no + * (`importKey`): the `encr` write fallback used when the run published no * public key to seal to. Writing needs only the AES key, not the read-side * keypair. On the fast path this needs only `runId` + `deploymentId` (no run * entity); on the fallback path the already fetched run is reused. @@ -159,7 +159,7 @@ async function getHookByTokenWithKey(token: string): Promise<{ // Only a hook that actually carries metadata needs the run's key resolved // here: hydrating that metadata is a READ, so derive the full RunPayloadKeys // (which opens sealed `encp` metadata, not just symmetric `encr`). The common - // default webhook — createWebhook() with no `respondWith` — stores no + // default webhook (createWebhook() with no `respondWith`) stores no // metadata, so it skips this entirely: no ~350ms `run-key` API round trip, // and, crucially, no resolved key handed to `resumeHook`, leaving it free to // seal the payload to the run's published public key instead. Metadata- @@ -205,8 +205,8 @@ export async function getHookByToken(token: string): Promise { * * On the parallel fast path, `resumeHook()` writes the `hook_received` event and * dispatches the workflow queue message concurrently. When the direct event - * write fails *transiently* — a 429/5xx, a transport error, or an expected - * `(runId, resumeId)` conflict with its own re-ensuring consumer — but the queue + * write fails *transiently* (a 429/5xx, a transport error, or an expected + * `(runId, resumeId)` conflict with its own re-ensuring consumer) but the queue * dispatch succeeds, the resume is still guaranteed: the queue consumer * idempotently materializes the `hook_received` event from the payload carried * on the message before replay. In that recovered case the returned hook carries @@ -253,8 +253,8 @@ export async function resumeHook( encryptionKeyOverride?: PayloadKey ): Promise { // Public entry point. It never attests hook freshness, so a Hook object - // supplied here — which may carry a `resumeCapabilities` cached before a - // server rollback or kill switch — is ignored by the dynamic-dedup gate and + // supplied here (which may carry a `resumeCapabilities` cached before a + // server rollback or kill switch) is ignored by the dynamic-dedup gate and // fails closed to the sequential path. Only `resumeWebhook`, which fetches // the hook by token in-line during the same resume, reaches the internal // implementation with the fresh attestation set. Keeping the freshness flag @@ -262,7 +262,7 @@ export async function resumeHook( // `true` and reactivating dynamic dedup against a rolled-back backend. // // T0 of the hook-resume TTR window is taken HERE, at the public entry point, - // rather than inside the implementation — see the parameter's doc comment. + // rather than inside the implementation; see the parameter's doc comment. return resumeHookImpl( tokenOrHook, payload, @@ -284,8 +284,8 @@ export async function resumeHook( * @param resumeRequestedAtMs - T0 of the hook-resume TTR window (see * runtime/resume-latency.ts), stamped by the PUBLIC entry point the caller * used. It is a parameter rather than a local because `resumeWebhook` does - * real work before it gets here — the by-token lookup, the run-key - * resolution that hydrates hook metadata, and the `respondWith` setup — and + * real work before it gets here (the by-token lookup, the run-key + * resolution that hydrates hook metadata, and the `respondWith` setup) and * stamping locally would silently exclude all of it, so the two entry points * would report the same metric over different windows. */ @@ -325,7 +325,7 @@ async function resumeHookImpl( // fallback path (which fetched the run). On the fast path the terminal // check happens server-side: `hook_received` against an ended run is // rejected, which the catch around `world.events.create` below re-keys - // to HookNotFoundError — same public contract, no run pre-fetch. + // to HookNotFoundError: same public contract, no run pre-fetch. if (info.run && isTerminalWorkflowRunStatus(info.run.status)) { throw new HookNotFoundError(hook.token); } @@ -343,7 +343,7 @@ async function resumeHookImpl( // // Preferred path: seal to the run's published X25519 public key, which // the stored `resumeContext` carries inline. On the fast path this is - // the whole win — no run read AND no `getEncryptionKeyForRun`, whose + // the whole win: no run read AND no `getEncryptionKeyForRun`, whose // ~350ms `run-key` API round trip dominates cross-deployment hook // resumption latency. (On the fallback path the key is synthesized // from the fetched run, which also carries it.) @@ -357,7 +357,7 @@ async function resumeHookImpl( // is itself the gate. A run only carries one if the runtime that // created it could also open a sealed payload, and runs are pinned to // their creating deployment, so presence is a more reliable attestation - // than a version compare — and it stays correct even when package + // than a version compare, and it stays correct even when package // versions drift. let payloadKey: PayloadKey | undefined; const runPublicKey = encryptionKeyOverride @@ -453,12 +453,12 @@ async function resumeHookImpl( // so changing it generally requires redeploying the workflow // deployment. (The backend can independently drop new resumes to the // sequential path fleet-wide by ceasing to attest dedup support on - // the by-token lookup — see the backend-dedup condition below.) + // the by-token lookup; see the backend-dedup condition below.) // - backend dedup: the live backend must enforce the // `(runId, resumeId)` constraint, or the two writers would commit two // `hook_received` events. Fail closed. Attested by EITHER a fresh, // response-only `hook.resumeCapabilities.hookResumeDedupVersion` from - // the by-token lookup (world-vercel — recomputed every read, so a + // the by-token lookup (world-vercel: recomputed every read, so a // server rollback or kill switch drops to sequential immediately) OR // the static `world.capabilities.hookResumeDedup` (world-local, whose // adapter and backend ship together). The response-only capability is @@ -479,8 +479,8 @@ async function resumeHookImpl( // - raw bytes: the dehydrated payload must be a `Uint8Array` (the // content digest that keys the dedup constraint is over these bytes). // - size: a payload above the queue's message ceiling would fail the - // publish — which, on the parallel path, would persist the event but - // never re-trigger the run — so oversized payloads stay sequential + // publish (which, on the parallel path, would persist the event but + // never re-trigger the run), so oversized payloads stay sequential // (their queue message carries only the run ID). const parallelResumeDisabled = process.env.WORKFLOW_DISABLE_LAZY_HOOK_RESUME === '1'; @@ -540,7 +540,7 @@ async function resumeHookImpl( // treated as "hook gone" for historical / conflict-shaped-rejection // compatibility: there is no queue message in flight, so a conflict has // no re-ensuring consumer to converge on. The PARALLEL path handles 409 - // differently (see below) — it published the queue message before the + // differently (see below): it published the queue message before the // conflict, so the consumer will converge the event. const isHookGoneError = (err: unknown): boolean => HookNotFoundError.is(err) || @@ -569,7 +569,7 @@ async function resumeHookImpl( // T1 of the TTR window. Stamped immediately before the publish so // `producer_prep` covers exactly the work above it (hook lookup, - // key resolution, serialization, and — on this path — the awaited + // key resolution, serialization, and, on this path, the awaited // `hook_received` write, which is genuinely serial here). const queuePublishRequestedAtMs = Date.now(); await world.queue( @@ -599,7 +599,7 @@ async function resumeHookImpl( span?.setAttributes({ 'workflow.hook.resume_id': resumeId }); // Wrapped in a thunk purely so T1 of the TTR window is stamped at the - // exact instant the publish is requested — after the racing + // exact instant the publish is requested, after the racing // `hook_received` write has been kicked off, which is where the // additive `producer_prep` phase must end. The two calls still start // in the same turn and race exactly as before. @@ -644,7 +644,7 @@ async function resumeHookImpl( publishInvocation(), ]); - // Queue failure is always fatal — the run was not re-triggered, so no + // Queue failure is always fatal: the run was not re-triggered, so no // consumer will re-ensure the event. if (queueResult.status === 'rejected') { throw queueResult.reason; @@ -666,7 +666,7 @@ async function resumeHookImpl( if (EntityConflictError.is(err) || isRetryableWorldError(err)) { // Resilient. Two shapes reach here, both non-terminal: // - EntityConflict (409): this write raced its own re-ensuring - // consumer (or a redrive) on the shared `resumeId` — the run is + // consumer (or a redrive) on the shared `resumeId`; the run is // NOT gone, and the consumer converges on the one committed // event. Unlike the sequential path, a 409 here is expected // concurrency, not a vanished hook. @@ -759,8 +759,8 @@ export async function resumeWebhook( token: string, request: Request ): Promise { - // T0 of the hook-resume TTR window. Everything below — the by-token lookup, - // the run-key resolution it may trigger, and the `respondWith` setup — is + // T0 of the hook-resume TTR window. Everything below (the by-token lookup, + // the run-key resolution it may trigger, and the `respondWith` setup) is // real producer-side latency on this path, so the window has to open here // and not inside `resumeHookImpl`; otherwise webhook resumes would report a // systematically shorter total than `resumeHook` ones into the same metric. @@ -804,7 +804,7 @@ export async function resumeWebhook( // `hook` was just fetched via `getHookByTokenWithKey` (a fresh by-token // lookup) above, so its response-only `resumeCapabilities` reflects the live - // backend — call the internal implementation with the fresh attestation so + // backend. Call the internal implementation with the fresh attestation so // the parallel fast path stays available without a second GET. (The public // `resumeHook` never sets this, so a caller cannot forge it.) await resumeHookImpl(hook, request, encryptionKey, true, resumeRequestedAtMs); diff --git a/packages/core/src/runtime/resume-latency.ts b/packages/core/src/runtime/resume-latency.ts index 31ccecfa1b..2d9c87e67e 100644 --- a/packages/core/src/runtime/resume-latency.ts +++ b/packages/core/src/runtime/resume-latency.ts @@ -8,31 +8,31 @@ import * as Attribute from '../telemetry/semantic-conventions.js'; * * ```text * T0 resumeHook() entered - * producer_prep — hook lookup, serialization, encryption + * producer_prep : hook lookup, serialization, encryption * T1 queue publish requested - * queue_delivery — network + VQS delivery (incl. any affinity re-route) + * queue_delivery : network + VQS delivery (incl. any affinity re-route) * T2 final consumer's queue handler entered - * resume_setup — affinity check, hook_received re-ensure, replay preload + * resume_setup : affinity check, hook_received re-ensure, replay preload * T3 replay begins - * replay — VM/session creation and workflow replay + * replay : VM/session creation and workflow replay * T4 next durable step encountered - * step_dispatch — suspension handling, inline batch or queue dispatch + * step_dispatch : suspension handling, inline batch or queue dispatch * T5 step_started request begins - * step_claim — the claim round trip + * step_claim : the claim round trip * T6 step_started response returned - * step_prepare — key resolution, argument hydration, context setup + * step_prepare : key resolution, argument hydration, context setup * T7 immediately before stepFn.apply() * ``` * * The producer's direct `hook_received` POST races the queue publish on the - * parallel fast path, so it deliberately has no phase of its own — the two + * parallel fast path, so it deliberately has no phase of its own: the two * overlap, and representing both as additive phases would double-count. It * remains visible as a contextual span (`hook.resume`). * * T0/T1 are stamped on the producer's machine and T2..T7 on the consumer's, so * the measurement is subject to cross-machine clock skew. Rather than clamp * (which would break the sum-equals-total property this decomposition exists - * for), a non-monotonic boundary set drops the whole sample — see + * for), a non-monotonic boundary set drops the whole sample; see * {@link computeResumeTtrAttributes}. */ @@ -45,12 +45,12 @@ export type ResumeStrategy = 'parallel' | 'sequential'; /** * How the consuming invocation initialized its replay state: * - * - `hook_preload` — the hoisted `hook_received` write returned a usable + * - `hook_preload`: the hoisted `hook_received` write returned a usable * replay preload, so neither `run_started` nor the initial `events.list` ran. - * - `run_started` — the generic `run_started` setup ran (including the fast + * - `run_started`: the generic `run_started` setup ran (including the fast * path's fallback, where the hoisted write succeeded but returned no usable * preload). - * - `event_load` — neither setup ran because the run arrived already loaded, + * - `event_load`: neither setup ran because the run arrived already loaded, * so setup was a plain event load. No current path produces it (the one * preloaded-run path, the background-step fall-through, consumes the * tracking on its own step first); it is the honest default rather than a @@ -69,7 +69,7 @@ export type ResumeStepExecution = 'inline' | 'dispatched'; * * The runtime holds at most one of these per invocation and CONSUMES it when * it hands it to the execution that will attempt the next durable step, so a - * later step in the same invocation — or a retry of the same step — never + * later step in the same invocation (or a retry of the same step) never * re-reports the same resumption. Within one inline batch the object is * shared by every step and the {@link ResumeTtrTracking.reported} latch picks * the single reporter. @@ -78,19 +78,19 @@ export interface ResumeTtrTracking { trigger: ResumeTrigger; /** Absent only if an older producer omitted it from the queue message. */ strategy?: ResumeStrategy; - /** T0 — entry into `resumeHook()`. */ + /** T0: entry into `resumeHook()`. */ resumeRequestedAtMs: number; - /** T1 — immediately before the queue publish was requested. */ + /** T1: immediately before the queue publish was requested. */ queuePublishRequestedAtMs: number; /** - * T2 — entry into the FINAL consumer's queue handler. A delivery that + * T2: entry into the FINAL consumer's queue handler. A delivery that * re-routes for deployment affinity never stamps this, so the re-routed hop * stays inside `queue_delivery` where it belongs. */ consumerStartedAtMs: number; - /** T3 — this invocation's first replay pass. */ + /** T3: this invocation's first replay pass. */ replayStartedAtMs?: number; - /** T4 — replay first encountered a durable step after the resume. */ + /** T4: replay first encountered a durable step after the resume. */ nextStepEncounteredAtMs?: number; setupSource?: ResumeSetupSource; stepExecution: ResumeStepExecution; @@ -98,7 +98,7 @@ export interface ResumeTtrTracking { * One-shot latch, set by the step executor once this resumption has been * reported. Every step of an inline batch is handed the SAME tracking * object, so the first one to reach user code takes the measurement and the - * rest see this and skip — one resumption, one sample, without pinning the + * rest see this and skip: one resumption, one sample, without pinning the * sample to a step that may lose its create-claim and never run. * * Deliberately not part of {@link HookResumeTiming}: it is invocation-local @@ -109,8 +109,8 @@ export interface ResumeTtrTracking { /** * Rebuild tracking from a queue message's timing object. Returns undefined for - * a message that carries none (an older producer, or any non-hook delivery) — - * the caller then simply reports no TTR. + * a message that carries none (an older producer, or any non-hook delivery); + * the caller then reports no TTR. */ export function resumeTrackingFromMessage( timing: HookResumeTiming | undefined, @@ -185,7 +185,7 @@ interface ResumeBoundaries { /** T3 */ replayStartedAtMs: number; /** T4 */ nextStepEncounteredAtMs: number; /** T5 */ stepClaimStartedAtMs: number; - /** T6 — absent on the optimistic-start path; see below. */ + /** T6: absent on the optimistic-start path; see below. */ stepClaimCompletedAtMs: number | undefined; /** T7 */ stepCodeStartedAtMs: number; } @@ -222,7 +222,7 @@ function validateBoundaries( /** * Compute the TTR span attributes for a step that is the first durable step - * following a hook resume. Returns undefined — emitting nothing at all — when + * following a hook resume. Returns undefined (emitting nothing at all) when * any of these hold: * * - the invocation carries no resume tracking (not a hook resume, or an older @@ -241,11 +241,11 @@ export function computeResumeTtrAttributes(params: { tracking: ResumeTtrTracking | undefined; /** The attempt number of the execution about to run. */ attempt: number; - /** T5 — `Date.now()` immediately before the `step_started` request. */ + /** T5: `Date.now()` immediately before the `step_started` request. */ stepClaimStartedAtMs: number | undefined; - /** T6 — `Date.now()` once the `step_started` response returned. */ + /** T6: `Date.now()` once the `step_started` response returned. */ stepClaimCompletedAtMs: number | undefined; - /** T7 — `Date.now()` immediately before `stepFn.apply()`. */ + /** T7: `Date.now()` immediately before `stepFn.apply()`. */ stepCodeStartedAtMs: number; }): Record | undefined { const { tracking } = params; diff --git a/packages/core/src/runtime/run-id-time.ts b/packages/core/src/runtime/run-id-time.ts index 64f3829f24..3d09229221 100644 --- a/packages/core/src/runtime/run-id-time.ts +++ b/packages/core/src/runtime/run-id-time.ts @@ -3,9 +3,9 @@ import { decodeTime } from 'ulid'; /** * Run IDs are minted client-side in `start()` as `wrun_` (via - * `World.createRunId()` when the world provides one, else a plain ULID — see + * `World.createRunId()` when the world provides one, else a plain ULID; see * `runtime/helpers.ts`). A ULID encodes its creation time in its first 48 bits, - * so the run's creation timestamp is recoverable from the run ID alone — + * so the run's creation timestamp is recoverable from the run ID alone, * without any server round-trip or run-snapshot load. This is the earliest * replay-stable timestamp a delivery has (the run ID arrives in the queue * payload), which lets the workflow VM be seeded and clock-initialized before @@ -20,8 +20,8 @@ const RUN_ID_PREFIX = 'wrun_'; * `@workflow/world-vercel`'s region-tagged run IDs mark an ID as carrying * metadata by setting the most-significant bit of the ULID's 48-bit * timestamp, which would otherwise skew the decoded time past the year 6400. - * The tag-bit handling is delegated to the scheme's own codec — - * `decode()` returns the ULID with the tag bit cleared — so if the tagged + * The tag-bit handling is delegated to the scheme's own codec + * (`decode()` returns the ULID with the tag bit cleared), so if the tagged * layout ever evolves (the scheme carries a 5-bit version field for exactly * that), this anchor keeps tracking the codec instead of silently diverging. * For untagged input `decode()` is a passthrough. diff --git a/packages/core/src/runtime/run.ts b/packages/core/src/runtime/run.ts index b962d7a235..a3395fbd56 100644 --- a/packages/core/src/runtime/run.ts +++ b/packages/core/src/runtime/run.ts @@ -48,7 +48,7 @@ export function getReturnValuePollIntervalMs(): number { /** * How long a single `runs.waitForTerminalStatus` call may block while waiting * for a run to finish. The wait is re-issued until the run is terminal, so - * this is a per-call budget rather than a limit on total wait time — its only + * this is a per-call budget rather than a limit on total wait time: its only * job is to bound one request so a stalled connection cannot hold the awaiting * side forever. * @@ -101,7 +101,7 @@ export function getReturnValueMaxLongPolls(): number { * Whether `await run.returnValue` may use the World's long poll * (`runs.waitForTerminalStatus`) instead of interval-polling `runs.get`. * - * Default **ON** wherever the World implements the method — a World that does + * Default **ON** wherever the World implements the method; a World that does * not is already on the interval path with nothing to switch off. Reads * `process.env.WORKFLOW_RETURN_VALUE_LONG_POLL` lazily; an explicit `'0'` / * `'false'` is the kill switch, restoring the fixed-interval poll exactly as @@ -122,7 +122,7 @@ export type WorkflowReadableStream = ReadableStream & { /** * Returns the tail index (index of the last known chunk, 0-based) of the * underlying workflow stream. Useful for resolving a negative `startIndex` - * into an absolute position — for example, when building reconnection + * into an absolute position, for example, when building reconnection * endpoints that need to inform the client where the stream starts. * * Returns `-1` when no chunks have been written yet. @@ -145,8 +145,8 @@ export interface WorkflowReadableStreamOptions { */ startIndex?: number; /** - * Any asynchronous operations that need to be performed before the execution - * environment is paused / terminated + * Any asynchronous operations to complete before pausing or terminating the + * execution environment * (i.e. using [`waitUntil()`](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) or similar). */ ops?: Promise[]; @@ -300,7 +300,7 @@ export class Run { /** * The return value of the workflow run. - * Polls the workflow return value until it is completed. + * Polls the workflow return value until the workflow run completes. */ get returnValue(): Promise { 'use step'; @@ -439,7 +439,7 @@ export class Run { } /** - * Polls the workflow return value until it is completed. + * Polls the workflow return value until the workflow run completes. * @internal * @returns The workflow return value. */ @@ -450,7 +450,7 @@ export class Run { // not exist yet. Retry on WorkflowRunNotFoundError up to 3 times // (1s + 3s + 6s = 10s total) to give the queue time to deliver // and the runtime to create the run via run_started. - // When resilientStart is false, 404 is a real error — fail fast. + // When resilientStart is false, 404 is a real error: fail fast. let notFoundRetries = 0; const NOT_FOUND_MAX_RETRIES = this.#resilientStart ? 3 : 0; const NOT_FOUND_DELAYS = [1_000, 3_000, 6_000]; @@ -458,16 +458,16 @@ export class Run { // Prefer the World's long poll: one read that the backend holds open // until the run finishes, instead of asking again every second and // paying up to a full interval of quantization on a run that already - // ended. Worlds that cannot wait simply do not implement it (see + // ended. Worlds that cannot wait do not implement it (see // `Storage['runs'].waitForTerminalStatus`) and this stays the exact - // fixed-interval poll it has always been — as does an operator who + // fixed-interval poll it has always been, as does an operator who // throws the `WORKFLOW_RETURN_VALUE_LONG_POLL=0` kill switch. const waitForTerminalStatus = isReturnValueLongPollEnabled() ? world.runs.waitForTerminalStatus?.bind(world.runs) : undefined; // Long polls spent so far. Once the cap is reached the loop keeps waiting - // but on the interval path, which is the strategy we know is correct — see + // but on the interval path, which is the strategy we know is correct; see // `RETURN_VALUE_MAX_LONG_POLLS`. Never a reason to stop awaiting. let longPolls = 0; const maxLongPolls = getReturnValueMaxLongPolls(); @@ -476,7 +476,7 @@ export class Run { // workflow uses to await a child workflow's `returnValue`), it blocks // a queue worker slot for as long as the child run takes to finish. // Worker-based worlds like `world-postgres` must be sized to cover the - // peak number of such polls in flight — see the `queueConcurrency` + // peak number of such polls in flight; see the `queueConcurrency` // default on the Postgres world and the notes in the eager-processing // changelog for details. while (true) { @@ -509,14 +509,14 @@ export class Run { return await this.#resolveTerminalReturnValue(run); } - // Run not completed yet — sleep and poll again. + // Run not completed yet: sleep and poll again. throw new WorkflowRunNotCompletedError(this.runId, runMetadata.status); } catch (error) { if (WorkflowRunNotCompletedError.is(error)) { // Space consecutive non-terminal observations at least one poll // interval apart. On the plain-poll path that is the familiar fixed // sleep; on the long-poll path the wait has usually already - // outlasted the interval and this is a no-op — but it also means a + // outlasted the interval and this is a no-op, but it also means a // World whose wait returns early (a backend with no long poll, a // clamped budget) degrades to interval polling instead of spinning. const remainingIntervalMs = diff --git a/packages/core/src/runtime/runs.ts b/packages/core/src/runtime/runs.ts index df49de3840..ef5cef85d5 100644 --- a/packages/core/src/runtime/runs.ts +++ b/packages/core/src/runtime/runs.ts @@ -20,9 +20,9 @@ export interface RecreateRunOptions { * Queue namespace of the target deployment (e.g. `'eve'` for topics like * `__eve_wkf_workflow_*`). Falls back to `WORKFLOW_QUEUE_NAMESPACE` in * the calling process. Cross-context callers (e.g. the observability - * dashboard) must pass the target deployment's namespace explicitly — - * a run enqueued to a topic the target deployment has no consumer for - * is never picked up. + * dashboard) must pass the target deployment's namespace explicitly, + * since a run enqueued to a topic the target deployment has no consumer + * for is never picked up. */ namespace?: string; } @@ -171,7 +171,7 @@ const CANCEL_RUNS_FALLBACK_CONCURRENCY = 20; * `cancelRun()` calls with at most {@link CANCEL_RUNS_FALLBACK_CONCURRENCY} * in flight at a time. Fallback successes are reported as `cancelled`; * failures are reported as `failed` with `code: 'internal_error'` and - * `retryable: true` — deliberately without per-world error classification. + * `retryable: true`, deliberately without per-world error classification. * * @throws if `runIds` is empty, contains duplicates, or exceeds * {@link BULK_CANCEL_MAX_RUN_IDS}. diff --git a/packages/core/src/runtime/start.ts b/packages/core/src/runtime/start.ts index 9ee1dac6e6..693ca6adfa 100644 --- a/packages/core/src/runtime/start.ts +++ b/packages/core/src/runtime/start.ts @@ -45,7 +45,7 @@ import { assertWorldSupportsRuntimeProtocol } from './world-compatibility.js'; * dehydrating workflow arguments. Kept tight on purpose: the probe is * an optimization (it lets the caller emit the framed byte-stream wire * format when the target supports it), and the fallback on timeout is - * the legacy raw format which always works. Long delays here would just + * the legacy raw format which always works. Long delays here would * make `start({ deploymentId: ... })` slower for users whose target * deployments don't recognize the health check at all. */ @@ -83,7 +83,7 @@ function resolveLineageAttributes(): Record | undefined { let hasWarnedLatestNoOp = false; /** - * Reset the `deploymentId: 'latest'` no-op warn-once guard. Test-only — + * Reset the `deploymentId: 'latest'` no-op warn-once guard. Test-only, * exported so unit tests can exercise the warn path across `start()` calls. * * @internal @@ -110,7 +110,7 @@ export interface StartOptionsBase { * run ID and routes the initial workflow message to the matching * regional queue. When omitted, the world falls back to its own * default (for `world-vercel`: the `VERCEL_REGION` environment - * variable, then the server-side default region `iad1` — a concrete, + * variable, then the server-side default region `iad1`; a concrete, * routable region is always chosen). * * Worlds without a regional dimension ignore this field. @@ -180,7 +180,7 @@ export interface StartOptionsWithDeploymentId extends StartOptionsBase { * This is only meaningful in worlds with atomic, immutable deployments * (currently Vercel). In other worlds (local dev, Postgres) there is no * notion of multiple deployments to resolve between, so `'latest'` has no - * effect — a warning is logged and the run targets the current deployment. + * effect: a warning is logged and the run targets the current deployment. * * **Note:** When `deploymentId` is provided, the argument and return types become `unknown` * since there is no guarantee the types will be consistent across deployments. @@ -295,12 +295,12 @@ export async function start( // resolveLatestDeploymentId(). Worlds without that concept (local dev, // self-hosted Postgres) have nothing to resolve between, so rather than // fail a run that works fine on Vercel, we warn and fall back to the - // current deployment — making 'latest' an effective no-op there. + // current deployment, making 'latest' an effective no-op there. if (deploymentId === 'latest') { if (world.resolveLatestDeploymentId) { deploymentId = await world.resolveLatestDeploymentId(); } else { - // Warn once per process — see hasWarnedLatestNoOp above. + // Warn once per process; see hasWarnedLatestNoOp above. if (!hasWarnedLatestNoOp) { hasWarnedLatestNoOp = true; runtimeLogger.warn( @@ -320,7 +320,7 @@ export async function start( // deployment starts (explicit deploymentId or 'latest' that resolves // to a different deployment) we probe the target via healthCheck to // learn its workflow-core version, then derive the capability. The - // probe has a tight timeout — on miss/failure we fall back to the + // probe has a tight timeout: on miss/failure we fall back to the // legacy raw byte format, which is universally readable. // // Worlds that don't expose the `streams` API (e.g. minimal test @@ -357,14 +357,15 @@ export async function start( } else if (typeof world.streams?.get !== 'function') { framedByteStreams = false; targetSupportsCompression = false; - // No probe channel to the target — cannot attest the consumer honors - // `hookInput`, so leave the marker off (fail closed to sequential). + // No probe channel to the target, so we cannot attest the consumer + // honors `hookInput`; leave the marker off (fail closed to + // sequential). targetHookResumeInputVersion = undefined; } else { // Ask for this run's public key while we're here. The probe already // blocks `start()` on every cross-deployment call, and the responder // executes inside the target deployment where the key material is - // local — so the key comes back for free on a response we are + // local, so the key comes back for free on a response we are // already awaiting, and we can skip the key-lookup API request // entirely. Best-effort: on timeout or an older target, no key comes // back and we fall through to the regular lookup below. @@ -382,7 +383,7 @@ export async function start( ); // The responder runs inside the target deployment, so its // `hookResumeInputVersion` reflects the consumer. Undefined on an - // older target or a probe timeout — leaving the marker off. + // older target or a probe timeout, leaving the marker off. targetHookResumeInputVersion = probe?.hookResumeInputVersion; } @@ -404,7 +405,7 @@ export async function start( ); } // `normalizeAttributeChanges` treats `undefined` as "remove this - // key", which is meaningless at creation time — reject it up front + // key", which is meaningless at creation time. Reject it up front // so JS callers get a clear error instead of a downstream schema // failure (the types already forbid non-string values). for (const [key, value] of Object.entries(opts.attributes)) { @@ -468,7 +469,7 @@ export async function start( // // Preferred: the capability probe already told us this run's public // key, so seal to it. That skips `getEncryptionKeyForRun`, which for a - // cross-deployment start is a `run-key` API request — the last one left + // cross-deployment start is a `run-key` API request, the last one left // on this path. It is also a privilege reduction: the caller ends up // able to write the arguments but not read them back, whereas fetching // the symmetric key grants full read access to a run it merely @@ -533,19 +534,19 @@ export async function start( // // The two writes below go to different places by different routes: // `events.create` is attributed to THIS client's tenant, while the queue - // message is pinned to a deploymentId. When those disagree — a - // production-credentialed client pinning a preview deployment — the + // message is pinned to a deploymentId. When those disagree (a + // production-credentialed client pinning a preview deployment) the // preview consumer can't find the run in its own tenant, falls back to // resilient start, and re-creates it: one client-minted run id, two // environments, the production copy pending forever and the preview copy // executing. Worlds with a single tenant return undefined and the field - // is simply absent. + // is absent. const creatorEnvironment = world.getEnvironment?.(); // If WORKFLOW_VM is set on the client starting the run, stamp the // engine choice into the run's executionContext so the run keeps // executing on the engine it started on (the same deployment can - // serve both VM engines). Unknown values throw — see + // serve both VM engines). Unknown values throw; see // getWorkflowVmFromEnv(). const workflowVm = getWorkflowVmFromEnv(); @@ -559,7 +560,7 @@ export async function start( // resumeContext by the server) to decide whether the parallel fast // path is safe. For a cross-deployment start the consumer is the // target deployment, so we stamp the *target's* value carried back on - // the health-check probe — never the caller's. Omitted when we could + // the health-check probe, never the caller's. Omitted when we could // not attest the target (older target, timeout, or no probe channel), // which fails the resume gate closed to the sequential path. ...(targetHookResumeInputVersion !== undefined @@ -625,7 +626,7 @@ export async function start( ), ]); - // Queue failure is always fatal — the run was not enqueued + // Queue failure is always fatal: the run was not enqueued if (queueResult.status === 'rejected') { throw queueResult.reason; } @@ -641,7 +642,7 @@ export async function start( // In this case, we can safely return. } else if (isRetryableWorldError(err)) { // 429 (ThrottleError), 5xx, and transient transport failures - // (TRANSPORT/TIMEOUT) are retryable — the run was accepted via the + // (TRANSPORT/TIMEOUT) are retryable: the run was accepted via the // queue and creation will be re-tried by the runtime when it calls // run_started. resilientStart = true; diff --git a/packages/core/src/runtime/step-executor.ts b/packages/core/src/runtime/step-executor.ts index 7652bd5dc4..a4136268b8 100644 --- a/packages/core/src/runtime/step-executor.ts +++ b/packages/core/src/runtime/step-executor.ts @@ -132,7 +132,7 @@ export interface StepExecutorParams { * Pre-claimed inline start: the suspension handler committed (or lost) this * step's `step_created` + `step_started` pair inside its batched fan-out * write, so the start this executor would otherwise send has already been - * decided. `owned: false` returns `{ type: 'skipped' }` before any write — + * decided. `owned: false` returns `{ type: 'skipped' }` before any write, * the same outcome as losing the lazy claim's atomic create. `owned: true` * skips both start paths (no start write at all) and runs the body against * the claimed step. Mutually exclusive with `lazyStepInput`: the input @@ -142,11 +142,11 @@ export interface StepExecutorParams { /** * Inline step ownership: the queue message ID of the invocation this * executeStep call runs in (from the queue handler's meta). When set, the - * `step_started` this call sends is stamped with it — on the lazy paths + * `step_started` this call sends is stamped with it: on the lazy paths * (where `lazyStepInput` is present) and on the owned-recovery * payload-less start (where it is not; the re-stamp keeps a recovered * step readable as owned by this message, since ownership derives from - * the LATEST start — so a recovery start is never bare). Wake + * the LATEST start, so a recovery start is never bare). Wake * replays that observe an actively-owned step suppress the immediate * requeue and enqueue a delayed backstop instead. Omitted on the * background-step path, whose bare start intentionally clears ownership @@ -173,7 +173,7 @@ export interface StepExecutorParams { * It matters most on the lazy inline path, where the `step_started` claim is * the step's FIRST durable write (its `step_created` is deferred): without a * seed the claim would name no position at all, and a replay working from a - * stale view could claim — and then commit — a step scheduled without + * stale view could claim (and then commit) a step scheduled without * observing an event it never loaded. * * A World that fences rejects a stale claim with `PreconditionFailedError` @@ -191,7 +191,7 @@ export interface StepExecutorParams { * stale-sensitive (an open hook means an out-of-band event can make the * scheduling view stale): the guard's 412 fence can reject a stale claim's * durable writes, but it cannot un-run a body that optimistic start began - * before the claim settled — awaiting the claim extends the fence to user + * before the claim settled; awaiting the claim extends the fence to user * code. Wins over `forceOptimisticStart` and the env flag. */ suppressOptimisticStart?: boolean; @@ -207,8 +207,8 @@ export interface StepExecutorParams { * Turbo mode only: a promise that resolves once the backgrounded * `run_started` has landed. When set, the lazy/optimistic `step_started` is * chained on it so the step is never created before its run exists. The body - * still runs immediately against locally-synthesized state — only the network - * write waits — so the `run_started` round-trip overlaps the body. `undefined` + * still runs immediately against locally-synthesized state (only the network + * write waits), so the `run_started` round-trip overlaps the body. `undefined` * outside turbo, where `run_started` was already awaited up front. */ runReadyBarrier?: Promise; @@ -217,7 +217,7 @@ export interface StepExecutorParams { * by the orchestrator. When set, this executor computes the final values * against the wall clock taken immediately before user code runs and * attaches them to the step's terminal event. Set only for the first step of - * an inline batch, and only on first-attempt executions that qualify — see + * an inline batch, and only on first-attempt executions that qualify; see * runtime/step-latency.ts. */ latencyTracking?: StepLatencyTracking; @@ -227,12 +227,12 @@ export interface StepExecutorParams { * executor closes the measurement against the `step_started` claim and the * wall clock taken immediately before user code, and attaches the total plus * its phase breakdown to this step's `step.execute` span. Set by the runtime - * for exactly one step per resumption — see runtime/resume-latency.ts. + * for exactly one step per resumption; see runtime/resume-latency.ts. */ resumeTracking?: ResumeTtrTracking; /** * Authoritative attempt number for this execution, used to bound retries - * against `maxRetries` BEFORE the body runs. In order to also catch + * against `maxRetries` BEFORE the body runs. To also catch * step timeouts (which we can't have catch handlers for), we determine * the attempt count based as follows: * - Inline (combined handler): the number of `step_started` events already @@ -264,13 +264,13 @@ export type PreclaimedInlineStart = */ step: StartedStep; /** - * `Date.now()` taken right before the batch POST that carried the pair - * — the claim's "start POST sent" instant, anchoring RSFS exactly like + * `Date.now()` taken right before the batch POST that carried the pair: + * the claim's "start POST sent" instant, anchoring RSFS exactly like * the lazy claim's own POST would. */ batchPostSentAtMs?: number; /** - * `Date.now()` taken right after that batch POST returned — the + * `Date.now()` taken right after that batch POST returned: the * claim's completion instant (T6 of the hook-resume TTR window). */ claimCompletedAtMs?: number; @@ -324,7 +324,7 @@ export type StepExecutionResult = * Executes a single step: creates step_started event, hydrates input, * runs the step function, creates step_completed/step_failed/step_retrying events. * - * Does NOT queue workflow continuation messages — the caller decides what to do next. + * Does NOT queue workflow continuation messages; the caller decides what to do next. * Used by the combined workflow handler for step execution. */ export async function executeStep( @@ -370,7 +370,7 @@ export async function executeStep( if (knownSlot === undefined) { // The caller scheduled this step without naming a position, so there is // no snapshot to advance and the writes below send none. Not the same as - // a run without positions: every run has them, this executor just was not + // a run without positions: every run has them, this executor was not // told which one it started from. return; } @@ -432,7 +432,7 @@ export async function executeStep( // A pre-claimed start that LOST the batched pair's atomic create-claim: a // concurrent writer owns this step. Same outcome as losing the lazy - // claim (EntityConflictError → skipped), decided before ANY write — the + // claim (EntityConflictError → skipped), decided before ANY write, the // unregistered-step fallback below included, since a step this handler // does not own is not its to fail. if (params.preclaimedStart && params.preclaimedStart.owned === false) { @@ -445,7 +445,7 @@ export async function executeStep( ...Attribute.StepSkipped(true), // `running`, not `completed`: the pair's 409 says the step already // EXISTS, and the writer that won the create-claim is executing it. - // The other skip site below is a genuine terminal-state conflict — + // The other skip site below is a genuine terminal-state conflict; // tagging both `completed` would make the attribute read 100% // `completed` and lose the only distinction worth querying it for. ...Attribute.StepSkipReason('running'), @@ -461,7 +461,7 @@ export async function executeStep( const stepFn = getStepFunction(stepName); if (!stepFn || typeof stepFn !== 'function') { - // Step function not registered — fail the step immediately (not the run). + // Step function not registered: fail the step immediately (not the run). // Create a step_failed event so // the workflow can handle it gracefully via try/catch in user code. const errorMessage = `Step "${stepName}" is not registered in the current deployment. This usually indicates a build or bundling issue that caused the step to not be included in the deployment.`; @@ -473,7 +473,7 @@ export async function executeStep( // On the lazy inline path the suspension handler deferred this step's // `step_created`, expecting executeStep to materialize the step via a // lazy `step_started` carrying its input. We never get that far for an - // unregistered step, so the step entity does not exist yet — writing + // unregistered step, so the step entity does not exist yet, and writing // `step_failed` straight away would hit the world's "step must exist" // ordering guard and wedge the run. Send the lazy `step_started` first // (it creates the step + synthetic `step_created` atomically and keeps @@ -484,7 +484,7 @@ export async function executeStep( if (params.lazyStepInput !== undefined) { try { // Turbo: this lazy `step_started` must not precede the backgrounded - // `run_started`. Order it after the run-ready barrier (best-effort — + // `run_started`. Order it after the run-ready barrier (best-effort: // a barrier rejection means the run doesn't exist, and the create // below surfaces the real error). No-op outside turbo. if (params.runReadyBarrier) { @@ -500,7 +500,7 @@ export async function executeStep( workflowName, input: params.lazyStepInput, // Stamped for consistency even though this step terminal-fails - // immediately below — the log should never show an unowned + // immediately below; the log should never show an unowned // lazy start. ...(params.ownerMessageId !== undefined ? { ownerMessageId: params.ownerMessageId } @@ -595,7 +595,7 @@ export async function executeStep( } catch (err) { if (EntityConflictError.is(err)) { // Step already reached a terminal state (a concurrent handler or an - // earlier delivery failed/completed it) — nothing to do. + // earlier delivery failed/completed it), so nothing to do. runtimeLogger.info( 'Tried failing step for exceeded retries, but step has already finished.', { @@ -669,12 +669,12 @@ export async function executeStep( // Optimistic inline start: when we hold the step input locally (lazy inline // path) and the optimization is enabled, fire `step_started` WITHOUT // awaiting and run the body against locally-synthesized state. A lazy step - // is always brand-new ⇒ attempt 1, no prior error, started now — so we + // is always brand-new ⇒ attempt 1, no prior error, started now, so we // don't need the server round-trip to begin. We reconcile the in-flight // `step_started` before any terminal write (`reconcileOptimisticStart`): if // it lost the atomic create-claim (409) or the run is gone/throttled, we // discard the body result. Running the body before confirming ownership can - // execute a step more than once when handlers race — inline step bodies + // execute a step more than once when handlers race, so inline step bodies // must be idempotent; disable via WORKFLOW_OPTIMISTIC_INLINE_START=0. // // Turbo mode passes `forceOptimisticStart` to enable this regardless of the @@ -686,11 +686,11 @@ export async function executeStep( const optimisticStart = // A pre-claimed start already settled its claim in the suspension // batch; there is nothing to fire optimistically (lazyStepInput is - // also absent on that path — this term is documentation). + // also absent on that path; this term is documentation). params.preclaimedStart === undefined && params.lazyStepInput !== undefined && // Stale-sensitive guarded batches await the claim so the 412 fence - // covers the body, not just durable writes — see + // covers the body, not just durable writes; see // StepExecutorParams.suppressOptimisticStart. params.suppressOptimisticStart !== true && (isOptimisticInlineStartEnabled() || @@ -703,11 +703,11 @@ export async function executeStep( // this one included. const startEventParams = stepStartedEventParams; // `Date.now()` taken immediately before the `step_started` create is - // issued (either path below) — anchors RSFS's end point. See + // issued (either path below); anchors RSFS's end point. See // StepLatencyEventData.rsfs and the call sites below. let stepStartPostSentAtMs: number | undefined; // `Date.now()` taken once the `step_started` response has returned and the - // claim succeeded — T6 of the hook-resume TTR window. Only the await path + // claim succeeded: T6 of the hook-resume TTR window. Only the await path // can set it: optimistic inline start deliberately does not wait for the // claim before running the body, so at T7 it has no completion instant and // the TTR breakdown reports no `step_claim_ms` (see @@ -724,7 +724,7 @@ export async function executeStep( // output. Returns undefined when we own the step and may write its terminal // event. A non-translatable rejection is rethrown (so a transient // step_started failure propagates to the queue handler for redelivery, - // exactly as on the await path). Idempotent — safe to call more than once. + // exactly as on the await path). Idempotent: safe to call more than once. const reconcileOptimisticStart = async (): Promise< StepExecutionResult | undefined > => { @@ -740,7 +740,7 @@ export async function executeStep( // Pre-claimed inline start: the suspension handler's batched fan-out // already committed this step's `step_created` + `step_started` pair, // so this execution owns a started attempt-1 step without sending a - // start of its own — the body begins straight off the batch commit and + // start of its own: the body begins straight off the batch commit and // the terminal write below has no in-flight claim to reconcile. The // batch timestamps stand in for the claim's: the POST instant anchors // RSFS, the response instant is TTR's claim completion (T6). @@ -755,7 +755,7 @@ export async function executeStep( // barrier is undefined and this is a plain create. const startedPromise = (params.runReadyBarrier ?? Promise.resolve()).then( () => { - // Taken right before the create fires, not before the barrier — + // Taken right before the create fires, not before the barrier: // RSFS measures the run_started-to-POST stretch, and the barrier // wait IS part of that stretch under turbo. stepStartPostSentAtMs = Date.now(); @@ -768,13 +768,13 @@ export async function executeStep( stepName, workflowName, input: params.lazyStepInput, - // Inline-ownership stamp — see StepExecutorParams.ownerMessageId. + // Inline-ownership stamp; see StepExecutorParams.ownerMessageId. ...(params.ownerMessageId !== undefined ? { ownerMessageId: params.ownerMessageId } : {}), }, }, - // Guard the claim — see StepExecutorParams.slotSnapshot. A + // Guard the claim; see StepExecutorParams.slotSnapshot. A // stale (412) rejection surfaces via reconcileOptimisticStart as a // non-translatable error: the body result is discarded and the // rejection propagates to the caller. @@ -809,7 +809,7 @@ export async function executeStep( try { // Inline-ownership stamp: present on the lazy paths AND on the // owned-recovery payload-less start (a redelivery of the owning - // message re-executing its step must re-stamp — ownership derives + // message re-executing its step must re-stamp, since ownership derives // from the latest start, so an unstamped recovery start would read // as "unowned" to a later wake). Only the background-step path // passes no ownerMessageId: its start is the bare one, clearing @@ -834,7 +834,7 @@ export async function executeStep( } : { stepName, ...ownershipStamp }, }, - // Guard the claim — see StepExecutorParams.slotSnapshot. A + // Guard the claim; see StepExecutorParams.slotSnapshot. A // stale (412) rejection is intentionally NOT translated by // startErrorToResult below, so it propagates to the caller for a // fresh replay. @@ -864,7 +864,7 @@ export async function executeStep( let result: unknown; // Check max retries AFTER step_started (attempt was just incremented). - // Only enforce when the step has a previous error — this distinguishes + // Only enforce when the step has a previous error; this distinguishes // actual retries (failed → retry) from concurrent inline starts // execution loop can cause multiple handlers to step_started the same // step simultaneously, inflating the attempt counter without any failure). @@ -890,7 +890,7 @@ export async function executeStep( await getEncryptionKey() ); } catch { - // Ignore — best-effort cause attachment. + // Ignore: best-effort cause attachment. } } try { @@ -946,15 +946,15 @@ export async function executeStep( // Backfill RSFS onto the already-computed telemetry once the optimistic // turbo start has settled. On that path the step-start POST fires inside - // the run-ready barrier's `.then`, so `stepStartPostSentAtMs` — and - // therefore RSFS — is usually still unset when `latencyEventData` is + // the run-ready barrier's `.then`, so `stepStartPostSentAtMs` (and + // therefore RSFS) is usually still unset when `latencyEventData` is // first computed just before user code (the barrier is still in flight // for any non-trivial `run_started` round-trip, which is exactly the // slow-run_started case RSFS exists to measure). By the time // `reconcileOptimisticStart()` has awaited the barrier the POST timestamp // is known, so we patch RSFS in before the terminal event is written. // Without this, slow-run_started samples are dropped, biasing RSFS - // percentiles low (missing-not-at-random). Recomputes only RSFS — + // percentiles low (missing-not-at-random). Recomputes only RSFS; // TTFS/STSO stay anchored to `executionStartTime` as computed above. const backfillOptimisticRsfs = (): void => { if (!latencyEventData || latencyEventData.rsfs !== undefined) return; @@ -1008,7 +1008,7 @@ export async function executeStep( // A crash or transient failure between them leaves this step pending // with the placeholder stored as its input, and normal crash recovery // then dispatches it here. NEVER run user code with placeholder - // arguments — complete the intended failure instead. The + // arguments; complete the intended failure instead. The // SerializationError is fatal (`fatal: true`), so the catch below // writes step_failed without retries, exactly what the interrupted // finalization was about to do. @@ -1032,7 +1032,7 @@ export async function executeStep( // Wrap only stepFn.apply() (user step code) so cleanup below runs on // BOTH success and failure. A user-code throw is captured here and // re-raised after cancelAbortReaders, so it still flows to the outer - // catch (step_failed/step_retrying) — but the abort-stream reader is + // catch (step_failed/step_retrying), but the abort-stream reader is // torn down first. Without this, a throwing/retrying signal-bearing // step would leak a real-time abort reader per attempt. let userCodeError: unknown; @@ -1075,7 +1075,7 @@ export async function executeStep( * Close the hook-resume TTR measurement (see runtime/resume-latency.ts). * * Called from INSIDE `contextStorage.run`, immediately before - * `stepFn.apply()` — deliberately not alongside the step-latency + * `stepFn.apply()`, deliberately not alongside the step-latency * telemetry above. `executionStartTime` is taken before the inner * `step.execute` span and the step context are established, and * `step_prepare_ms` is documented to include exactly that setup, so @@ -1140,7 +1140,7 @@ export async function executeStep( : undefined, }, () => { - // The last instant before user code — T7 of the resume window. + // The last instant before user code: T7 of the resume window. reportResumeTtr(); return stepFn.apply(thisVal, args); } @@ -1162,7 +1162,7 @@ export async function executeStep( // arguments (a serialized AbortSignal opens a real-time abort reader for // the step's duration). Without this the reader's `read()` promise never // settles, so the `ops` flush below always loses the 500ms race and the - // step reports `hasPendingOps` — forcing the inline loop to queue a + // step reports `hasPendingOps`, forcing the inline loop to queue a // continuation and paying a full round-trip per signal-bearing step. // Runs unconditionally (success or failure) so a throwing step doesn't // leak the reader. @@ -1241,7 +1241,7 @@ export async function executeStep( } // Optimistic start: the body ran before `step_started` was confirmed. - // Reconcile it now — if we lost the create-claim (or the run is + // Reconcile it now: if we lost the create-claim (or the run is // gone/throttled) discard this result and don't write step_completed. // Reconcile before draining preCompletionOps: a discarded result means // the winning handler owns the outcome (and re-fires any abort @@ -1249,7 +1249,7 @@ export async function executeStep( if (optimisticStart) { const reconcile = await reconcileOptimisticStart(); if (reconcile) return reconcile; - // Barrier resolved — the step-start POST timestamp is now known, so + // Barrier resolved: the step-start POST timestamp is now known, so // RSFS can be attached to the step_completed event below. backfillOptimisticRsfs(); } @@ -1262,7 +1262,7 @@ export async function executeStep( // StepContext.preCompletionOps). // // Tradeoff: correctness requires the hook be durable before completion, - // so — unlike the background `ops` flush above — this cannot be capped + // so, unlike the background `ops` flush above, this cannot be capped // with a resolve-on-timeout race. A slow resume therefore adds its // latency to a step that aborts a controller, and a true hang holds // completion until the platform/queue execution timeout fires; the queue @@ -1272,7 +1272,7 @@ export async function executeStep( } } catch (err: unknown) { // Optimistic start: the body threw before `step_started` was confirmed. - // Reconcile first — if we lost the create-claim (or the run is + // Reconcile first: if we lost the create-claim (or the run is // gone/throttled) the body error is moot; discard it and don't write a // terminal event (the winning handler owns the outcome). Reconcile // before draining preCompletionOps for the same reason as the success @@ -1280,12 +1280,12 @@ export async function executeStep( if (optimisticStart) { const reconcile = await reconcileOptimisticStart(); if (reconcile) return reconcile; - // Barrier resolved — attach RSFS to the step_failed event(s) below. + // Barrier resolved: attach RSFS to the step_failed event(s) below. backfillOptimisticRsfs(); } // Order any must-be-durable ops (e.g. a step-initiated abort's - // hook_received event) ahead of step_failed too — a step that aborts and + // hook_received event) ahead of step_failed too: a step that aborts and // then throws must still have the abort recorded before the failure // continuation observes it. Same latency tradeoff and no-reject contract // as the success path above. See StepContext.preCompletionOps. diff --git a/packages/core/src/runtime/step-latency.ts b/packages/core/src/runtime/step-latency.ts index c30cc2fe4f..acc218afff 100644 --- a/packages/core/src/runtime/step-latency.ts +++ b/packages/core/src/runtime/step-latency.ts @@ -8,11 +8,11 @@ import type { Event } from '@workflow/world'; * beginning to execute. STSO (step-to-step overhead) measures the previous * step's terminal event → the next step's body beginning to execute. RSFS * (run-started-to-first-step) measures the `run_started` response landing → - * the first step's start POST being issued — a sub-window of TTFS that + * the first step's start POST being issued, a sub-window of TTFS that * isolates replay overhead from the run-creation queue hop; `finalSchedulingReplay` * is the synchronous workflow-function-execution duration of only the FINAL * replay pass within that window (the pass that reached and scheduled the - * first step) — it is NOT accumulated across earlier pre-first-step passes + * first step); it is NOT accumulated across earlier pre-first-step passes * (see {@link StepLatencyTracking.replayMs}), so it must not be read as "the * replay portion of RSFS". All are attached to the step's terminal * event so a backend can emit latency metrics from the event write alone, @@ -43,7 +43,7 @@ export interface StepLatencyTracking { * workflow-body `setAttributes` before the first step * resolves through an extra replay (see the `hasAttributeEvents` branch in * runtime.ts), so everything from this write until the step body runs is - * the duration of the setAttributes call — which is subtracted by ending + * the duration of the setAttributes call, which is subtracted by ending * the measurement at the point where the step would otherwise have been * scheduled. */ @@ -60,7 +60,7 @@ export interface StepLatencyTracking { eventCount?: number; /** * Epoch ms the `run_started` response was received/parsed by the SDK. - * Present only when the step qualifies for RSFS — the same eligibility as + * Present only when the step qualifies for RSFS: the same eligibility as * TTFS (see {@link computeStepLatencyTracking}), plus a recoverable * anchor. In turbo mode, `run_started` is backgrounded rather than * awaited, so this is stamped at the point the runtime synthesizes the @@ -78,12 +78,12 @@ export interface StepLatencyTracking { * event commits, the step's own start POST). Present only alongside * `rsfsAnchorMs`. * - * This is the FINAL replay pass only — the invocation that reached and + * This is the FINAL replay pass only: the invocation that reached and * scheduled the first step. Valid RSFS paths can replay more than once * before the first step (e.g. a workflow-body `setAttributes()` detour * replays twice), and a redelivery omits earlier invocations' replay work * entirely; this value is not accumulated across those earlier passes. - * Do not read it as "the replay portion of RSFS" — RSFS + * Do not read it as "the replay portion of RSFS": RSFS * ({@link rsfsAnchorMs}) covers the whole run_started-to-first-step * window, this covers only the last pass. */ @@ -94,8 +94,8 @@ export interface StepLatencyTracking { /** * Most negative raw duration still attributed to cross-machine clock skew - * (and clamped to 0). Anything more negative means a corrupt anchor — e.g. a - * mis-decoded run-ID timestamp — and the sample is dropped instead: a + * (and clamped to 0). Anything more negative means a corrupt anchor (e.g. a + * mis-decoded run-ID timestamp) and the sample is dropped instead: a * systematically corrupt anchor would otherwise report an exact-zero latency * on every sample, silently dragging entire percentile distributions to 0 * rather than surfacing as missing data. @@ -112,7 +112,7 @@ export interface StepLatencyEventData { rsfs?: number; /** * Client-measured wall-clock ms of the FINAL replay pass that scheduled - * the first step (see {@link StepLatencyTracking.replayMs}) — not + * the first step (see {@link StepLatencyTracking.replayMs}); not * accumulated across earlier pre-first-step passes, so it must not be * read as "the replay portion of `rsfs`". */ @@ -136,7 +136,7 @@ export interface StepLatencyEventData { * {@link StepLatencyTracking.preStepBlockingMs}. * - `attr_set` (workflow-body `setAttributes`): resolves through * an extra replay before steps run. Subtracted by ending the measurement at - * the first attr write's timestamp instead — see + * the first attr write's timestamp instead; see * {@link StepLatencyTracking.preStepAttrStartMs}. */ const TTFS_DISQUALIFYING_EVENT_TYPES: ReadonlySet = new Set( @@ -176,13 +176,13 @@ export function computeStepLatencyTracking(params: { runCreatedAtMs: number | undefined; /** * Epoch ms the `run_started` response was received/parsed by the SDK (or, - * under turbo, the instant the run was synthesized locally — see + * under turbo, the instant the run was synthesized locally; see * {@link StepLatencyTracking.rsfsAnchorMs}). Absent disqualifies RSFS. */ runStartedReceivedAtMs: number | undefined; /** * Wall-clock ms this suspension's `runWorkflow` call spent executing - * synchronously before throwing — the FINAL replay pass only, not + * synchronously before throwing: the FINAL replay pass only, not * accumulated across earlier passes. See * {@link StepLatencyTracking.replayMs}. */ @@ -193,7 +193,7 @@ export function computeStepLatencyTracking(params: { * The accumulator's value as of the suspension that wrote the run's first * attr_set (its hook phase runs before its attr writes). When the * measurement ends at the attr write, only hook time from before that - * point may be subtracted — later hook writes fall outside the measured + * point may be subtracted; later hook writes fall outside the measured * window. Undefined when no attr suspension happened in this invocation * (e.g. the attr_set was loaded from a redelivery's snapshot, where no * same-invocation hook time precedes it). @@ -202,14 +202,14 @@ export function computeStepLatencyTracking(params: { /** * Whether the suspension that scheduled this batch also created waits. * Those `wait_created` writes are not in `events` yet (they were committed - * by this very suspension pass), so they must be reported separately. A + * by this suspension pass), so they must be reported separately. A * wait disqualifies both measurements. */ suspensionHasWaits: boolean; /** * Whether the suspension that scheduled this batch also created hooks - * (also not in `events` yet). Hooks keep TTFS eligible — their measured - * write time is subtracted via `preStepBlockingMs` — but disqualify STSO, + * (also not in `events` yet). Hooks keep TTFS eligible (their measured + * write time is subtracted via `preStepBlockingMs`) but disqualify STSO, * which is only meaningful for a pure back-to-back step gap. */ suspensionCreatedHooks: boolean; @@ -239,7 +239,7 @@ export function computeStepLatencyTracking(params: { } } - // STSO: the two steps ran back-to-back — the newest known event is the + // STSO: the two steps ran back-to-back, so the newest known event is the // previous step's terminal event, with nothing (hook_received, waits, // attr_set, ...) in between and this suspension scheduling nothing but // steps. @@ -308,7 +308,7 @@ export function computeStepLatencyTracking(params: { * Compute the latency telemetry to attach to the step's terminal event. * Called by `executeStep` with the wall-clock timestamp taken immediately * before user step code runs. Returns undefined when there is nothing to - * report (no tracking, or a retry attempt — retries measure neither TTFS nor + * report (no tracking, or a retry attempt; retries measure neither TTFS nor * STSO). */ export function computeStepLatencyEventData(params: { @@ -345,11 +345,11 @@ export function computeStepLatencyEventData(params: { // doc comment). // // A pre-step setAttributes detour ends the measurement at the first attr - // write instead of the step's code start — the remainder is the duration + // write instead of the step's code start: the remainder is the duration // of the setAttributes call (resolved via an extra replay), which is // subtracted rather than disqualifying the sample. In that case // `tracking.preStepBlockingMs` already holds only the hook time from - // BEFORE the attr write — hook writes after the window closed are excluded + // BEFORE the attr write; hook writes after the window closed are excluded // by computeStepLatencyTracking (see preStepBlockingBeforeAttrMs). const ttfsEndMs = tracking.preStepAttrStartMs ?? params.stepCodeStartedAtMs; const rawTtfs = @@ -368,12 +368,12 @@ export function computeStepLatencyEventData(params: { rawStso !== undefined && rawStso >= -MAX_CLOCK_SKEW_MS ? Math.max(0, rawStso) : undefined; - // RSFS ends at the actual start-POST instant, not at ttfsEndMs — unlike + // RSFS ends at the actual start-POST instant, not at ttfsEndMs: unlike // TTFS it is not subject to the pre-step attr-write shortcut, so a // pre-step setAttributes detour (rare) makes RSFS include the detour // while TTFS excludes it. `finalSchedulingReplay` is a direct passthrough - // of `tracking.replayMs` — the FINAL replay pass only (see - // StepLatencyTracking.replayMs) — so no further subtraction applies, but + // of `tracking.replayMs`, the FINAL replay pass only (see + // StepLatencyTracking.replayMs), so no further subtraction applies, but // it also means it is NOT accumulated across any earlier pre-first-step // passes (e.g. a setAttributes detour) and must not be read as "the // replay portion of rsfs"; rsfs covers the whole window. diff --git a/packages/core/src/runtime/step-ownership.ts b/packages/core/src/runtime/step-ownership.ts index 021511a175..5380c79f1b 100644 --- a/packages/core/src/runtime/step-ownership.ts +++ b/packages/core/src/runtime/step-ownership.ts @@ -28,13 +28,13 @@ export function isStepOwnershipActive(step: StepInvocationQueueItem): boolean { /** * Seconds left on an owned step's liveness lease, anchored at its latest * `step_started`. 0 means the lease has expired (or the start timestamp is - * missing — the degraded mode for worlds whose events lack usable + * missing, the degraded mode for worlds whose events lack usable * timestamps), in which case dispatch falls back to the immediate enqueue. * * The result is clamped to the configured lease: `lastStartedAt` is the * server-stamped event `createdAt` while `nowMs` is the local clock, so a * client running behind the server would otherwise compute a remainder - * LONGER than the lease itself — and with the lease tuned to the 900s cap, + * LONGER than the lease itself, and with the lease tuned to the 900s cap, * a `delaySeconds` above the queue's per-message maximum, which SQS-backed * worlds reject outright (the wake replay's enqueue would throw and ride * the redelivery loop). The clamp makes skew strictly harmless; remaining @@ -53,14 +53,14 @@ export function stepLeaseRemainingSeconds( /** * Idempotency key for the delayed backstop wake of an inline-owned step. * - * The key is scoped to the current OWNERSHIP EPOCH — the timestamp of the - * latest `step_started` — not just the correlation ID. Within one epoch, + * The key is scoped to the current OWNERSHIP EPOCH (the timestamp of the + * latest `step_started`), not just the correlation ID. Within one epoch, * every wake replay derives the same key, so fan-out stays capped at one * pending backstop per step. But when owner recovery re-stamps the step * (queue redelivery of the owning message → new `step_started` → new * `lastStartedAt`), the key CHANGES. This is load-bearing for liveness: * queues dedupe an idempotency key for the lifetime of the original - * message — including while a delivery of it is in flight — so a backstop + * message (including while a delivery of it is in flight), so a backstop * that fires during a refreshed lease and tries to re-arm under a fixed key * would dedupe against ITSELF and be dropped, leaving no escape hatch if * the recovered owner later dies without further redeliveries. The epoch @@ -75,7 +75,7 @@ export function stepLeaseRemainingSeconds( * The key must also never be the step message's own `idempotencyKey` * (the bare correlation ID): the owner's retry handoff enqueues the step * under that key with a short backoff, and a pending backstop sharing it - * would absorb the retry — turning a 1s backoff into a full-lease stall. + * would absorb the retry, turning a 1s backoff into a full-lease stall. */ export function backstopIdempotencyKey(step: StepInvocationQueueItem): string { return `${step.correlationId}:backstop:${step.lastStartedAt}`; @@ -97,7 +97,7 @@ export function hasPendingStepOwnedByMessage( ): boolean { // Latest-wins scan: events are in log order, so later entries overwrite. // A step_retrying lapses ownership permanently (matching the sawRetrying - // semantics of the replay consumer in step.ts) — from that point the step + // semantics of the replay consumer in step.ts): from that point the step // is queue-owned, whatever starts follow. const latestOwner = new Map(); const sawRetrying = new Set(); diff --git a/packages/core/src/runtime/step-single-flight.ts b/packages/core/src/runtime/step-single-flight.ts index e0fc801eb8..e96582b2bf 100644 --- a/packages/core/src/runtime/step-single-flight.ts +++ b/packages/core/src/runtime/step-single-flight.ts @@ -10,7 +10,7 @@ import type { StepExecutionResult } from './step-executor.js'; * *death proof* only on platforms with a bounded invocation lifetime. On * worlds without an invocation kill bound (world-local's single process, * self-hosted deployments) a delayed backstop can fire while the owning - * execution is still mid-body — in the same process. This map absorbs that + * execution is still mid-body, in the same process. This map absorbs that * race: the loser awaits the winner's settlement and then acks WITHOUT * executing. On Vercel Fluid compute it also absorbs same-instance races * between an owner redelivery and a backstop. @@ -19,12 +19,12 @@ import type { StepExecutionResult } from './step-executor.js'; * after an early ack would consume the loser's queue message while the * winner's outcome is still unknown, potentially orphaning the step with no * message left to drive it. Awaiting settlement first keeps the at-least-once - * envelope intact — if the loser's own invocation hits its deadline while + * envelope intact: if the loser's own invocation hits its deadline while * waiting, its message redelivers and re-checks, degrading gracefully to * polling. * * Cross-instance duplicates (two separate processes racing the same step) - * are out of scope here — that is what the ownership lease bounds on + * are out of scope here; that is what the ownership lease bounds on * platforms where it is a death proof, and the documented residual risk on * multi-instance self-hosted worlds (mitigate by raising * `WORKFLOW_INLINE_OWNERSHIP_LEASE_SECONDS`). @@ -36,7 +36,7 @@ const inFlightSteps = new Map>(); * already in flight in this process. The winner's result is returned to the * winner; a loser awaits the winner's settlement (success OR failure) and * then returns `{ type: 'skipped' }` so its caller acks without running the - * body. A winner failure is not propagated to the loser — the winner's own + * body. A winner failure is not propagated to the loser: the winner's own * queue message redelivers and drives the retry, so exactly one message * keeps owning the outcome. */ @@ -49,7 +49,7 @@ export async function runStepSingleFlight( const existing = inFlightSteps.get(key); if (existing) { // warn (always printed, unlike debug/info): the single-flight is - // absorbing what would have been a duplicate execution — typically a + // absorbing what would have been a duplicate execution, typically a // delayed backstop or retry message landing in the same process while // the owner is still mid-body. Rare by design; a burst of these means // leases are expiring under live executions (raise @@ -62,7 +62,7 @@ export async function runStepSingleFlight( await existing; } catch { // The winner failed (typically a transient world error). Its own queue - // message redelivers and retries; this loser still just skips. + // message redelivers and retries; this loser still skips. } return { type: 'skipped' }; } diff --git a/packages/core/src/runtime/suspension-handler.ts b/packages/core/src/runtime/suspension-handler.ts index d76ec20f00..f56625fbb5 100644 --- a/packages/core/src/runtime/suspension-handler.ts +++ b/packages/core/src/runtime/suspension-handler.ts @@ -71,7 +71,7 @@ export interface SuspensionHandlerParams { /** * The runtime's loaded event log. Every event creation this suspension makes * names the position it was derived from, so a backend that has recorded - * events the replay did not see can report them back on the write — or, if + * events the replay did not see can report them back on the write, or, if * it would rather refuse than report, reject it with a 412. A rejection is * not retried here: the event's correlation id was minted by *this* replay's * seeded sequence, so re-committing it against a corrected log would persist @@ -93,14 +93,14 @@ export interface SuspensionHandlerParams { replayRecoveryReporter?: ReplayRecoveryReporter; /** * Resilient step dispatch: when provided (and the per-step eligibility gates - * pass — see the step ops below), each newly created non-inline step's + * pass, see the step ops below), each newly created non-inline step's * `step_created` write is parallelized with its step-execution queue * publish, and the queue message carries the serialized step input * (`stepInput`) so the consumer can idempotently re-ensure the event if the * direct write failed transiently. Steps queued this way are reported in * {@link SuspensionHandlerResult.queuedStepCorrelationIds} so the caller * skips them in its own dispatch pass. Omitted by callers that must not - * queue (terminal drain, tests) — creates then behave exactly as before. + * queue (terminal drain, tests); creates then behave exactly as before. */ stepDispatch?: { /** The unified workflow queue this run's step messages are published to. */ @@ -115,9 +115,9 @@ export interface SuspensionHandlerParams { * Inline step ownership: the queue message ID of the invocation this * suspension runs in (the queue handler's meta). When present AND the * batched fan-out engages, the lazy-inline steps' deferred writes are - * folded into the batch as `step_created` + `step_started` pairs — the + * folded into the batch as `step_created` + `step_started` pairs (the * started row stamped with this ID, exactly like the lazy claim it - * replaces — pre-claiming the steps the caller is about to run inline. See + * replaces), pre-claiming the steps the caller is about to run inline. See * {@link SuspensionHandlerResult.inlineClaims}. Callers that never * inline-execute (terminal drain) omit it, keeping their lazy steps on the * plain deferred path. @@ -127,8 +127,8 @@ export interface SuspensionHandlerParams { * Lets the batched fan-out return before every chunk has committed: only * the chunk carrying the pre-claimed inline pairs gates the handler's * return (its claims are what the caller starts bodies from), while the - * trailing chunks' commits — and every chunk's in-flush step-message - * publishes — ride {@link SuspensionHandlerResult.deferredBatchWork}. A + * trailing chunks' commits (and every chunk's in-flush step-message + * publishes) ride {@link SuspensionHandlerResult.deferredBatchWork}. A * caller that opts in MUST await that promise before acking its delivery: * the durability contract ("every create durable before ack") moves from * the handler's return to that join, and nothing else re-drives a lost @@ -149,17 +149,17 @@ export interface SuspensionHandlerResult { * Correlation IDs for which this suspension call actually wrote the * step_created event (as opposed to catching EntityConflictError because * a concurrent handler wrote it first). Only the handler that wrote the - * step_created event should queue / inline-execute the step — this + * step_created event should queue / inline-execute the step; this * guarantees a single owner per step, even when multiple handlers race * into the same batch boundary. */ createdStepCorrelationIds: Set; /** * Correlation IDs of steps whose arguments failed to serialize. Each was - * finalized here as `step_created` (with a placeholder input — the real + * finalized here as `step_created` (with a placeholder input; the real * input is precisely what refused to serialize) followed by `step_failed` * carrying the SerializationError, so the next replay rejects the step's - * promise and a try/catch around the step call observes the error — + * promise and a try/catch around the step call observes the error, * exactly like a step-body failure. No step-execution message is * dispatched for these, so the caller MUST force an in-process replay: * when the failed step was the only pending work, nothing else will ever @@ -170,7 +170,7 @@ export interface SuspensionHandlerResult { * Correlation IDs of steps this suspension call already published * step-execution queue messages for, via resilient step dispatch (the * `step_created` write parallelized with a `stepInput`-carrying queue - * publish). The caller MUST NOT dispatch these again — the message is + * publish). The caller MUST NOT dispatch these again: the message is * already out (a duplicate would be deduped by its idempotency key, but * costs a wasted round-trip). Empty when {@link SuspensionHandlerParams.stepDispatch} * was not provided or no step was eligible. @@ -205,12 +205,12 @@ export interface SuspensionHandlerResult { * Pre-claimed inline starts, by correlation id: the per-step verdicts of * the `step_created` + `step_started` pairs the batched fan-out committed * for the lazy-inline steps. A step with an entry here is passed to - * `executeStep` as `preclaimedStart` INSTEAD of `lazyStepInput` — its + * `executeStep` as `preclaimedStart` INSTEAD of `lazyStepInput`: its * input already rode the pair, and the claim is settled: `owned: true` * carries the started attempt-1 entity (input re-attached) so the body * runs straight off the batch commit with no start write of its own; * `owned: false` lost the pair's atomic create-claim to a concurrent - * writer, and executeStep returns `skipped` without running the body — + * writer, and executeStep returns `skipped` without running the body, * the same outcome as losing the lazy claim. Empty whenever the fold did * not engage (batching off, no `ownerMessageId`, or the lone-inline case, * which keeps the optimistic lazy path and its claim/body overlap). @@ -218,20 +218,20 @@ export interface SuspensionHandlerResult { * Crash window: the pair commits before the caller runs the body, so a * crash between them leaves a started step stamped with this message's * ID. Redelivery of the same message re-executes it via the owned-recovery - * path — the exact machinery the lazy claim's crash window already uses. + * path, the exact machinery the lazy claim's crash window already uses. */ inlineClaims: Map; /** * The highest slot the batched fan-out committed, when it ran. The batch's * own events are not in the caller's loaded log (the next reload picks * them up), so the caller folds this ceiling into the slot snapshot it - * hands the inline executions — otherwise every inline terminal write + * hands the inline executions; otherwise every inline terminal write * would name a pre-batch position and be answered with a skipped-slot * report echoing the events this suspension just wrote. Under * {@link SuspensionHandlerParams.allowDeferredBatchWork} this covers the * chunks that had committed by the handler's return (always the pair * chunk); a trailing chunk that commits later is echoed back on the - * terminal writes like any foreign event — reports the executor reads for + * terminal writes like any foreign event: reports the executor reads for * position and discards. * * So the echo is only fully suppressed for a SINGLE-chunk fold. On a @@ -239,7 +239,7 @@ export interface SuspensionHandlerResult { * chunks are still in flight, and an inline terminal write issued in that * window still names a position below them and still draws a report for * their events. Bounded (trailing chunks only, large fan-outs only) and - * self-correcting on the next reload — recorded so a report seen there + * self-correcting on the next reload, and recorded so a report seen there * reads as expected rather than as a bug. */ batchCommittedSlotCeiling?: number; @@ -249,7 +249,7 @@ export interface SuspensionHandlerResult { * trailing work exists: the commits of every chunk except the pair chunk, * plus every chunk's step-message publishes (each chained on ITS OWN * chunk's commit, so publish-after-create holds per step). The caller - * MUST await it before acking — a rejection here is a failed suspension + * MUST await it before acking: a rejection here is a failed suspension * write and fails the delivery exactly as it would have at the handler's * return. Steps whose messages this work publishes are already in * {@link queuedStepCorrelationIds} at return time. @@ -370,7 +370,7 @@ async function createHookEvent({ /** * Handles a workflow suspension by processing all pending operations (hooks, steps, waits). - * Creates events for all operations but does NOT queue step messages — returns the pending + * Creates events for all operations but does NOT queue step messages; returns the pending * steps so the caller can decide which to execute inline vs queue to background. * * Processing order: @@ -405,7 +405,7 @@ export async function handleSuspension({ try { await runReadyBarrier; } catch { - // intentional: ordering barrier only — see above. + // intentional: ordering barrier only, see above. } } }; @@ -418,8 +418,8 @@ export async function handleSuspension({ * in flight. That matters for a 412: the caller reacts by reloading the event * log and restarting the replay, so a sibling create that lands after the * rejection escaped commits an event whose correlation id came from the - * abandoned replay's seeded sequence — an event the fresh replay never - * produces — and it races the restart's reload while doing so. Settling first + * abandoned replay's seeded sequence (an event the fresh replay never + * produces), and it races the restart's reload while doing so. Settling first * makes this phase's write set final before the caller acts on the failure. * It mirrors the runtime's inline step claim, which settles the in-flight * step executions before escalating a 412. @@ -449,7 +449,7 @@ export async function handleSuspension({ // Adds the optimistic-concurrency guard when the caller supplied a loaded // event log; without one it creates directly (callers with no replay // snapshot, e.g. tests). A stale (412) rejection propagates to the caller, - // which restarts the replay from a corrected log — it is not retried here, + // which restarts the replay from a corrected log. It is not retried here, // because the event's correlation id was minted by *this* replay's seeded // sequence, so re-committing it against a corrected log would persist an // event no correct replay produces. @@ -465,8 +465,8 @@ export async function handleSuspension({ }); // Bump-and-report: the write landed above the slot it asked for, so the // report holds the events it was decided without. Absorbing here rather - // than at each call site means the rest of this phase's writes — which read - // the same array to build their own snapshot — ask for a slot above them, + // than at each call site means the rest of this phase's writes (which read + // the same array to build their own snapshot) ask for a slot above them, // and the replay that resumes from this log sees them without a reload. const report = absorbSkippedSlotReport(log.events, result); reportedEvents += report.added; @@ -508,15 +508,15 @@ export async function handleSuspension({ // Group hook items that need work by token, preserving queue-insertion // (workflow code) order within each token. Operations on one token must // apply in code order: a dispose() of an earlier hook releases the token - // before a later same-token hook's creation is validated — otherwise the + // before a later same-token hook's creation is validated (otherwise the // new hook records a spurious hook_conflict against the run's own - // disposed hook — while a hook created and disposed within the same + // disposed hook), while a hook created and disposed within the same // suspension is still created before it is disposed. Different tokens // have no claim interaction, so token groups are processed in parallel. const hookItemsByToken = new Map(); for (const item of allHookItems) { if (item.hasCreatedEvent && !item.disposed) { - continue; // already committed and still live — nothing to do + continue; // already committed and still live: nothing to do } const group = hookItemsByToken.get(item.token); if (group) { @@ -549,7 +549,7 @@ export async function handleSuspension({ await createGuarded(hookDisposedEvent, { requestId }); } catch (err) { if (EntityConflictError.is(err)) { - // Hook was already disposed by a concurrent invocation — safe to skip + // Hook was already disposed by a concurrent invocation, safe to skip runtimeLogger.info( 'Hook already disposed, skipping duplicate disposal', { @@ -581,7 +581,7 @@ export async function handleSuspension({ } // Process hooks first to prevent race conditions with webhook receivers. - // Track any hook conflicts that occur — these are returned to the caller + // Track any hook conflicts that occur: these are returned to the caller // so the V2 handler can re-invoke immediately. let hasHookConflict = false; let hasAwaitedHookCreation = false; @@ -643,7 +643,7 @@ export async function handleSuspension({ hookCreationMs = Date.now() - hookPhaseStart; } - // Process abort requests — resume the hook with abort payload and write stream packet + // Process abort requests: resume the hook with abort payload and write stream packet const hooksNeedingAbort = allHookItems.filter( (item) => item.abortRequested && !item.disposed ); @@ -690,7 +690,7 @@ export async function handleSuspension({ ); await world.streams.close(runId, streamName); } catch { - // Best-effort stream write — hook event provides the durable fallback + // Best-effort stream write: hook event provides the durable fallback runtimeLogger.debug( 'Failed to write abort stream packet, hook event will provide fallback', { @@ -718,7 +718,7 @@ export async function handleSuspension({ } // Create step events for steps that don't have them yet. - // Unlike V1, we do NOT queue step messages from here — the caller + // Unlike V1, we do NOT queue step messages from here: the caller // decides which steps to execute inline vs. queue to background. // Wait events are also created in parallel below. const stepsNeedingCreation = new Set( @@ -729,12 +729,12 @@ export async function handleSuspension({ // Correlation IDs for which THIS suspension call actually wrote the // step_created event. Populated by the ops below after a successful - // events.create — used by the caller to claim ownership and avoid + // events.create, used by the caller to claim ownership and avoid // racing with concurrent handlers on step execution. const createdStepCorrelationIds = new Set(); // Correlation IDs of steps finalized as failed because their arguments - // refused to serialize — see finalizeUnserializableStep below. + // refused to serialize: see finalizeUnserializableStep below. const failedStepCorrelationIds = new Set(); /** @@ -748,7 +748,7 @@ export async function handleSuspension({ * precisely what refused to serialize) followed by `step_failed` carrying * the SerializationError. The next replay rejects the step's promise with * it, so a try/catch around the step call observes the error; uncaught, it - * propagates out of the workflow body and fails the run as a USER_ERROR — + * propagates out of the workflow body and fails the run as a USER_ERROR, * without burning queue redeliveries either way. */ const finalizeUnserializableStep = async ( @@ -793,7 +793,7 @@ export async function handleSuspension({ ); } catch (createErr) { if (EntityConflictError.is(createErr)) { - // A concurrent handler already created the step — the failure is + // A concurrent handler already created the step: the failure is // deterministic, so it is racing toward the same step_failed below. runtimeLogger.info('Step already exists, continuing', { workflowRunId: runId, @@ -801,7 +801,7 @@ export async function handleSuspension({ message: createErr.message, }); } else if (RunExpiredError.is(createErr)) { - // Run already finished — nothing to observe the failure. + // Run already finished: nothing to observe the failure. return; } else { throw createErr; @@ -816,7 +816,7 @@ export async function handleSuspension({ eventData: { stepName: queueItem.stepName, // The error itself is a plain WorkflowError (name, message with - // framed hint, cause chain) — serializable even though the step + // framed hint, cause chain), serializable even though the step // input was not. Error detection is realm-independent // (types.isNativeError), so the host-created error serializes // the same under either global; the VM global is passed for @@ -856,7 +856,7 @@ export async function handleSuspension({ // Release the inline slot bookkeeping: the step never runs, so it must // not appear in the rebuilt `lazyInlineSteps`. (Its slot in the first-N // selection and in `inlinePairFoldEligible`'s arithmetic was consumed - // before dehydration could reveal the failure — inherent to selecting + // before dehydration could reveal the failure, inherent to selecting // before serializing, and bounded to one wasted slot on a pass that // ends in a forced replay anyway.) lazyInlineCorrelationIds.delete(queueItem.correlationId); @@ -865,7 +865,7 @@ export async function handleSuspension({ // Serialization always runs through the one ordinary path below, so the // durable bytes cannot depend on retention. What retention needs to know is // whether that serialization *executed* workflow code (getters, proxy - // traps, custom serializers) — side effects a cold replay would not + // traps, custom serializers): side effects a cold replay would not // repeat, since a replay skips dehydration for already-recorded steps. // The hardened serializer records exactly that into this sink (see // ../serialization/hardened.ts); when any input in the batch records an @@ -880,9 +880,9 @@ export async function handleSuspension({ // (saving a round-trip per step). We never defer when a `hook.getConflict()` // awaiter is present, because in that case the caller executes nothing inline // (it re-invokes immediately to resolve the awaiter), so deferring would - // leave the steps uncreated and unqueued. We pick the first N uncreated steps - // — matching the caller's inline-candidate selection — and dehydrate their - // input here so executeStep can ship it as the step_started payload. + // leave the steps uncreated and unqueued. We pick the first N uncreated + // steps (matching the caller's inline-candidate selection) and dehydrate + // their input here so executeStep can ship it as the step_started payload. const lazyInlineCorrelationIds = new Set( hasAwaitedHookCreation === false ? stepItems @@ -910,13 +910,13 @@ export async function handleSuspension({ // Resilient step dispatch eligibility, shared by every step op below (the // per-step input-size check is applied inside the op). All must hold: // - // - The caller provided a dispatch target (`stepDispatch`) — terminal + // - The caller provided a dispatch target (`stepDispatch`): terminal // drains and other create-only callers never queue. // - The feature is enabled (`WORKFLOW_RESILIENT_STEP_DISPATCH` opt-in). // It is off by default because the publish races the create's verdict, // and a create can come back refused: as a duplicate the replay should - // stop pursuing, or — on a World that would rather refuse a stale write - // than report what it missed — as a 412. Either way the queue message + // stop pursuing, or (on a World that would rather refuse a stale write + // than report what it missed) as a 412. Either way the queue message // carrying the payload is already out, and the consumer can materialize a // step whose create was refused. Nothing orders that verdict before the // consumer's redelivery re-ensure, so no backend-side revocation @@ -934,8 +934,8 @@ export async function handleSuspension({ // Batched fan-out: fold this suspension's step_created + wait_created // writes into one `events.createBatch` call (one durable write, per-event // outcomes) instead of one write per event. Engages only for a CLEAN - // fan-out — no attribute writes, no hook writes, no resilient dispatch - // (whose creates are each paired with a queue publish) — on a World that + // fan-out (no attribute writes, no hook writes, no resilient dispatch + // whose creates are each paired with a queue publish) on a World that // implements the optional method and a run whose events are slot-numbered. // Everything outside the gate keeps the single-event path byte-for-byte. const batchFanoutEligible = @@ -965,14 +965,14 @@ export async function handleSuspension({ // Pre-claimed inline pairs: fold each lazy-inline step's deferred // `step_created` (carrying its input) AND its `step_started` claim (bare, - // ownership-stamped) into the batch, so the whole fan-out — the inline - // steps' claims included — commits in the one durable write and the caller + // ownership-stamped) into the batch, so the whole fan-out (the inline + // steps' claims included) commits in the one durable write and the caller // starts the bodies straight off that commit instead of posting one claim // per inline step. The lone-inline case (nothing else to batch with) is // excluded: a pair-only batch costs the same round trip as the single lazy // claim while giving up the optimistic claim/body overlap and the // bump-and-report that `createGuarded` provides, so it stays on the lazy - // path. Requires the caller's `ownerMessageId` — the started row must + // path. Requires the caller's `ownerMessageId`: the started row must // stamp ownership exactly like the lazy claim it replaces (and a caller // that does not inline-execute never provides one). const uncreatedWaitCount = waitItems.filter( @@ -1002,8 +1002,8 @@ export async function handleSuspension({ // Producer-side resilient recovery count for the suspension span attribute. let resilientDispatchRecovered = 0; - // Steps: create step_created events (no queuing — V2 returns pending steps - // to caller — EXCEPT on the resilient dispatch path, which parallelizes the + // Steps: create step_created events (no queuing, V2 returns pending steps + // to caller, EXCEPT on the resilient dispatch path, which parallelizes the // create with the step's queue publish and reports it in // `queuedStepCorrelationIds`). let batchOrderCounter = 0; @@ -1011,8 +1011,8 @@ export async function handleSuspension({ if (stepsNeedingCreation.has(queueItem.correlationId)) { // Deterministic position in the batched fold (assigned in stepItems // order, before the concurrent dehydration runs). A pair-folded inline - // step occupies two consecutive positions — created row then started - // row — which the flush keeps adjacent and never splits across chunks, + // step occupies two consecutive positions (created row then started + // row), which the flush keeps adjacent and never splits across chunks, // so a World can fold them into one born-running create. const pairFolded = inlinePairFoldEligible && @@ -1044,7 +1044,7 @@ export async function handleSuspension({ // ran before the failure still counts against retention. guestCodeStats.executions.push(...stepGuestCode.executions); if (!SerializationError.is(err)) { - // e.g. RuntimeDecryptionError — an SDK fault, not a user value + // e.g. RuntimeDecryptionError: an SDK fault, not a user value // problem. Keep its identity (RUNTIME_ERROR) and current // fail-the-suspension behavior. throw err; @@ -1055,7 +1055,7 @@ export async function handleSuspension({ // test caller). The run is already completing/failing, so // writing step_created + step_failed here would leave e.g. a // COMPLETED run carrying a failed step nothing can ever - // observe — reading as a bug from the dashboard. Rethrow + // observe, reading as a bug from the dashboard. Rethrow // instead; the drain's own catch swallows it, preserving its // pre-existing behavior (no rows for the unawaited step). throw err; @@ -1064,7 +1064,7 @@ export async function handleSuspension({ return; } guestCodeStats.executions.push(...stepGuestCode.executions); - // Deferred (lazy) inline step: skip the step_created write — the + // Deferred (lazy) inline step: skip the step_created write; the // caller's inline executeStep will send a lazy step_started carrying // this input, and the world creates the step (entity + synthetic // step_created event) atomically. We do NOT add it to @@ -1080,7 +1080,7 @@ export async function handleSuspension({ // Enqueue the pair the deferral would otherwise leave to the // caller's lazy `step_started`: the created row carries the // input (payloads ride creates in a batch), the started row is - // bare and stamps this invocation's ownership — the same claim + // bare and stamps this invocation's ownership, the same claim // shape the lazy start would have sent, settled by the batch. batchQueue.push({ order: stepOrder, @@ -1128,7 +1128,7 @@ export async function handleSuspension({ }; // Resilient step dispatch: fire the step_created write and the - // step-execution queue publish in parallel — the message carries the + // step-execution queue publish in parallel: the message carries the // same serialized input (`stepInput`) so the consumer can // idempotently re-ensure the event if the direct write failed // transiently. Mirrors the resilient start (`runInput`) and @@ -1156,10 +1156,10 @@ export async function handleSuspension({ stepInput: { input: dehydratedInput }, }, // Same key as the caller's dispatch pass and any concurrent - // handler's — redundant publishes for this step dedupe. The + // handler's, so redundant publishes for this step dedupe. The // key is step-identity-scoped so a revoked message for a // reassigned correlation id cannot absorb the corrected - // schedule's dispatch — see stepDispatchIdempotencyKey. + // schedule's dispatch: see stepDispatchIdempotencyKey. { idempotencyKey: stepDispatchIdempotencyKey( queueItem.correlationId, @@ -1172,7 +1172,7 @@ export async function handleSuspension({ // the message the step would rely on the create alone, and if the // create ALSO failed there would be no durable record at all. // Propagating redelivers the orchestrator message, which - // re-creates the (idempotent) step_created and re-dispatches — + // re-creates the (idempotent) step_created and re-dispatches, // the same recovery as the sequential path. if (queueResult.status === 'rejected') { throw queueResult.reason; @@ -1181,7 +1181,7 @@ export async function handleSuspension({ if (createResult.status === 'rejected') { const err = createResult.reason; if (EntityConflictError.is(err)) { - // Concurrent handler wrote it first — same as the sequential + // Concurrent handler wrote it first, same as the sequential // path. The step message is already out; a duplicate publish // by that handler dedupes on the shared idempotency key. runtimeLogger.info('Step already exists, continuing', { @@ -1191,8 +1191,8 @@ export async function handleSuspension({ }); } else if (isRetryableWorldError(err)) { // Resilient: the write failed transiently (429 / 5xx / - // transport) but the step message — carrying the same - // serialized input — was published, so the consumer + // transport) but the step message (carrying the same + // serialized input) was published, so the consumer // idempotently re-ensures the step_created before executing. resilientDispatchRecovered++; runtimeLogger.warn( @@ -1249,7 +1249,7 @@ export async function handleSuspension({ if (batchFanoutEligible) { // The flush op waits for every prep before committing; a prep that // rejected already surfaces through `ops`, so the flush's own wait - // swallows it and commits whatever was successfully enqueued — + // swallows it and commits whatever was successfully enqueued, // preserving today's per-op independence. batchPreps.push(stepOp.catch(() => {})); } @@ -1268,7 +1268,7 @@ export async function handleSuspension({ }, }; if (batchFanoutEligible) { - // Waits need no dehydration, so they enqueue synchronously — after + // Waits need no dehydration, so they enqueue synchronously, after // every step's order slot, preserving steps-then-waits scheduling // order in the log. batchQueue.push({ @@ -1301,20 +1301,20 @@ export async function handleSuspension({ } // The batched fold's flush: the clean fan-out commits through - // `createBatch` in chunks of MAX_BATCH_FANOUT_EVENTS — all chunks IN + // `createBatch` in chunks of MAX_BATCH_FANOUT_EVENTS, all chunks IN // FLIGHT CONCURRENTLY. Slot assignment is the server's, so parallel // chunks race for slot ranges exactly like the pre-fold path's parallel // single writes did; entity conditions, not commit order, carry // correctness (sibling fan-out events have no cross-order the replay - // depends on — it matches by correlation id). Each event reports the + // depends on; it matches by correlation id). Each event reports the // outcome its own single create would have had: a 409 is the same // already-exists tolerance as the single path, anything else fails the // delivery the way a single-path rejection would. // // Latency shape: only the chunk carrying the pre-claimed inline pairs // gates the handler's return (the caller starts bodies off its claims). - // Every other chunk's commit — and every chunk's step-message publishes, - // which fire the moment ITS creates are durable — rides + // Every other chunk's commit (and every chunk's step-message publishes, + // which fire the moment ITS creates are durable) rides // `deferredBatchWork` when the caller opted in, joined before ack. A slow // sibling chunk therefore delays neither the inline bodies nor another // chunk's queue messages, while publish-after-create still holds per @@ -1335,7 +1335,7 @@ export async function handleSuspension({ await ensureRunReady(); // A batch of ONE gains nothing over the single write (same round // trip) and loses the slot-snapshot params + bump-and-report that - // createGuarded provides — so a lone eager event takes the ordinary + // createGuarded provides, so a lone eager event takes the ordinary // single path, with the same conflict tolerance and ownership // bookkeeping it would have had without the fold. if (entries.length === 1) { @@ -1364,8 +1364,8 @@ export async function handleSuspension({ return; } // Seed for the foreign-interleaving diagnostic below. With chunks - // committing in parallel there is no per-chunk "expected next slot" - // — the whole fold's committed span is compared against the seed + // committing in parallel there is no per-chunk "expected next slot"; + // the whole fold's committed span is compared against the seed // once every chunk has settled: committed slots are dense per the // World's invariant, so any excess of (max committed slot − seed + // 1) over the fold's own committed count is events OTHER writers @@ -1374,8 +1374,8 @@ export async function handleSuspension({ ? (maxEventSlot(eventLog.events) ?? 0) + 1 : undefined; // Pair-aware chunking: a pre-claimed pair's two rows must land in - // the same createBatch call — adjacent, so a World can fold them - // into one born-running create — and never straddle a chunk + // the same createBatch call (adjacent, so a World can fold them + // into one born-running create) and never straddle a chunk // boundary, which would turn the started row into a standalone // claim racing its own create's commit. const chunks: (typeof entries)[] = []; @@ -1406,7 +1406,7 @@ export async function handleSuspension({ } // Steps whose queue messages THIS FLUSH will publish (the eager // creates), recorded before any chunk settles so the caller's - // dispatch pass — which runs off the handler's return — skips them. + // dispatch pass (which runs off the handler's return) skips them. // The sends are guaranteed-or-failed by the trailing work the // caller joins before acking, so "will be published by this flush" // and "already published" are equivalent from the caller's side. @@ -1427,7 +1427,7 @@ export async function handleSuspension({ const commitChunk = async (chunk: typeof entries): Promise => { // Anchors for the pre-claimed steps' telemetry: the POST instant // is the claim's "start POST sent" (RSFS's end), the return is the - // claim's completion (TTR's T6) — the same two instants the lazy + // claim's completion (TTR's T6), the same two instants the lazy // claim's own POST would have produced. const batchPostSentAtMs = Date.now(); // biome-ignore lint/style/noNonNullAssertion: batchFanoutEligible implies presence @@ -1458,7 +1458,7 @@ export async function handleSuspension({ // readback entity is authoritative where present; a World // that omitted it gets the same locally synthesized running // attempt-1 the optimistic path executes against. Either - // way the input is re-attached locally — batch responses + // way the input is re-attached locally: batch responses // return refs lazily, and the body's hydration wants the // exact bytes the pair's created row carried. (The created // row's success is deliberately NOT membership in @@ -1515,12 +1515,12 @@ export async function handleSuspension({ ) { // The pair lost its atomic create-claim: a concurrent writer // already owns this step (an earlier delivery's create, or a - // racing handler's claim). Recorded as a lost claim — the + // racing handler's claim). Recorded as a lost claim: the // caller's executeStep returns `skipped` without running the // body, the same outcome as losing the lazy claim. A World // that folds the pair reports the 409 on both rows (set // twice, harmless); one that evaluates rows independently - // has the started row — processed second — decide, which is + // has the started row (processed second) decide, which is // exactly the single path's semantics (create lost + claim // won still runs the body; create won + claim lost skips). inlineClaims.set(entry.correlationId, { owned: false }); @@ -1579,7 +1579,7 @@ export async function handleSuspension({ }; // Publish the chunk's eager steps' queue messages the moment ITS - // creates are durable — the per-chunk half of publish-after-create. + // creates are durable, the per-chunk half of publish-after-create. // Same message shape and step-identity-scoped idempotency key as the // caller's dispatch pass, so anything double-published dedupes. const publishChunkSteps = async ( @@ -1667,7 +1667,7 @@ export async function handleSuspension({ // `step_started` that would race this same fold's still-in-flight // pair for the same step. Today pairs always land in one chunk // (they sort first, and two rows per inline step fit inside one - // chunk — pinned by constants.test.ts), so this is at most one + // chunk, pinned by constants.test.ts), so this is at most one // commit; the filter is what keeps the property true if either cap // moves. const pairCommits = chunks.flatMap((chunk, index) => @@ -1679,7 +1679,7 @@ export async function handleSuspension({ // The trailing work is the caller's to join before ack. Attach a // handler now so a rejection that races that join (or a foreground // failure that prevents the caller from ever reaching it) is never - // an unhandledRejection — awaiting the promise still observes it. + // an unhandledRejection; awaiting the promise still observes it. trailing.catch(() => {}); deferredBatchWork = trailing; // Only the pair chunks gate the return: their claims are what the @@ -1691,7 +1691,7 @@ export async function handleSuspension({ await Promise.all(pairCommits); } catch (err) { // A pair chunk failed, so this phase's write set is NOT the - // caller's to join any more — `deferredBatchWork` never + // caller's to join any more: `deferredBatchWork` never // reaches it once handleSuspension throws. Settle the rest // before the rejection escapes, for the reason `settlePhase` // gives: a sibling create landing after the throw commits an @@ -1716,7 +1716,7 @@ export async function handleSuspension({ // Guarded like every other suspension write: an attr_set is a // replay-derived event with a correlation id from this replay's // seeded sequence, so it must not land on a log the replay never - // saw. Rejecting it is cheap — a run with attribute events already + // saw. Rejecting it is cheap: a run with attribute events already // forces an in-process replay, so the restart costs the replay it // was going to do anyway. await createGuarded( @@ -1745,7 +1745,7 @@ export async function handleSuspension({ } ); } else if (isWorldValidationFailure(err)) { - // Deterministic validation rejection from the World — e.g. the + // Deterministic validation rejection from the World, e.g. the // cumulative per-run attribute cap, which only the World can // check against the run's existing attributes. Redelivering the // orchestrator message replays the workflow into the exact same diff --git a/packages/core/src/runtime/unserializable-step.ts b/packages/core/src/runtime/unserializable-step.ts index c3fc642dc8..f5d86ba33c 100644 --- a/packages/core/src/runtime/unserializable-step.ts +++ b/packages/core/src/runtime/unserializable-step.ts @@ -3,7 +3,7 @@ * (used by both the node:vm suspension handler and the QuickJS entrypoint). * * The world requires a `step_created` before any terminal step event, and - * the step's real input is precisely what refused to serialize — so the + * the step's real input is precisely what refused to serialize, so the * finalization writes a placeholder input. The marker string makes the * placeholder distinguishable from a genuine zero-argument step in * `workflow inspect steps` and the observability UI: a reader sees @@ -14,8 +14,8 @@ export const UNSERIALIZABLE_STEP_INPUT_MARKER = /** * Structural discriminator on the placeholder's top level. The - * `{ args, closureVars, thisVal }` triple is built by the SDK — user code - * never controls its top-level keys — so this flag cannot false-positive on + * `{ args, closureVars, thisVal }` triple is built by the SDK (user code + * never controls its top-level keys), so this flag cannot false-positive on * a legitimate input, unlike the display marker inside `args`. */ const UNSERIALIZABLE_FLAG = '__workflowUnserializableStepInput'; @@ -43,7 +43,7 @@ export function unserializableStepInputPlaceholder(): Record { * Finalization writes `step_created` (placeholder) and `step_failed` as two * separate durable writes; a crash or transient failure between them leaves * a pending step whose stored input is the placeholder. Redelivery then - * dispatches that step through normal crash recovery — the executor calls + * dispatches that step through normal crash recovery: the executor calls * this before running user code and completes the intended failure (a fatal * SerializationError → `step_failed`) instead of silently invoking the step * body with placeholder arguments. diff --git a/packages/core/src/runtime/vm-mode.ts b/packages/core/src/runtime/vm-mode.ts index e8a7864003..1ea4349af7 100644 --- a/packages/core/src/runtime/vm-mode.ts +++ b/packages/core/src/runtime/vm-mode.ts @@ -29,7 +29,7 @@ export type WorkflowVmMode = (typeof WORKFLOW_VMS)[number]; * * Returns the configured engine, or `undefined` if unset/empty. * Throws {@link WorkflowRuntimeError} if the value is set but not one of - * the known engines — catching misconfiguration early is better than + * the known engines, since catching misconfiguration early is better than * silently falling back to the default. */ export function getWorkflowVmFromEnv( diff --git a/packages/core/src/runtime/wait-continuation.ts b/packages/core/src/runtime/wait-continuation.ts index 02eb1cffc8..59b3765c06 100644 --- a/packages/core/src/runtime/wait-continuation.ts +++ b/packages/core/src/runtime/wait-continuation.ts @@ -10,7 +10,7 @@ * The continuation is keyed on the wait's correlationId: while a wait is * pending, every replay pass over the run re-observes it (e.g., once per * step completion in `Promise.all([steps..., sleep()])`), and without - * dedupe each pass would enqueue another delayed continuation — each one + * dedupe each pass would enqueue another delayed continuation: each one * a spurious full replay when the wait elapses, and each a fresh message * that resets the delivery-attempt runaway guard. A key is attached in * all cases: some worlds (e.g. world-postgres) serialize key-less @@ -29,7 +29,7 @@ * situations exist, each with its own key variation: * * - Waits longer than the maximum queue delay are chained: the delay is - * clamped to `WAIT_CONTINUATION_MAX_DELAY_SECONDS` (23h — VQS messages + * clamped to `WAIT_CONTINUATION_MAX_DELAY_SECONDS` (23h: VQS messages * have a 24h retention limit, and one hour of buffer matches * world-vercel's own clamp for delayed re-enqueues), so the * continuation intentionally fires early, re-observes the wait, and @@ -37,7 +37,7 @@ * (`ceil(timeoutSeconds / maxDelay)`): stable for every re-observation * within the same hop window (so passes dedupe), decremented at each * hop delivery (so the chain always advances). Worlds without a delay - * limit (world-postgres, world-local) simply take the same ≤23h hops. + * limit (world-postgres, world-local) take the same ≤23h hops. * * - Near-elapsed waits (≤2s remaining) get a second-bucketed suffix. A * continuation delivered marginally early (clock skew between the @@ -108,11 +108,11 @@ export function getWaitContinuationDispatch( ): WaitContinuationDispatch { const maxDelaySeconds = getWaitContinuationMaxDelaySeconds(); // The near-elapsed branch returns the full remaining time as the delay, so - // its threshold can never exceed the max delay — otherwise a wait between the + // its threshold can never exceed the max delay. Otherwise a wait between the // max and the threshold would be dispatched with a delay above the max. Cap // the threshold at the max so every branch yields a delay within it. (With - // defaults — threshold 2s, max 82_800s — this is a no-op; it only bites when - // the max is tuned down below the threshold for testing.) + // defaults, threshold 2s and max 82_800s, this is a no-op; it only bites + // when the max is tuned down below the threshold for testing.) const nearElapsedThreshold = Math.min( getNearElapsedWaitThresholdSeconds(), maxDelaySeconds diff --git a/packages/core/src/runtime/wait-until.ts b/packages/core/src/runtime/wait-until.ts index 724f244a49..4f48454608 100644 --- a/packages/core/src/runtime/wait-until.ts +++ b/packages/core/src/runtime/wait-until.ts @@ -8,7 +8,7 @@ export function waitUntil(promise: Promise): void { * Schedule a background promise via `waitUntil`, guaranteeing that the * promise handed to `waitUntil` can never reject. Nothing consumes a * `waitUntil` promise, so a rejection surfaces as an `unhandledRejection` - * and can crash the process — even when the same underlying error is + * and can crash the process, even when the same underlying error is * correctly handled by an awaited copy elsewhere. * * Expected client-disconnect errors (`AbortError` / `ResponseAborted`) diff --git a/packages/core/src/runtime/world-compatibility.ts b/packages/core/src/runtime/world-compatibility.ts index f1afce9a29..d6a6987688 100644 --- a/packages/core/src/runtime/world-compatibility.ts +++ b/packages/core/src/runtime/world-compatibility.ts @@ -13,7 +13,7 @@ type WorldSpecVersionMetadata = Pick; * The accepted range is * `[SPEC_VERSION_SUPPORTS_SLOT_IDENTITY, SPEC_VERSION_MAX_SUPPORTED]`. Below * the floor means an old World package paired with a new runtime, which cannot - * serve the protocol this runtime speaks — a World that does not number events + * serve the protocol this runtime speaks. A World that does not number events * by position allocates ids the runtime cannot read positions out of. Above the * ceiling means a World built against a newer spec than this runtime knows how * to read. diff --git a/packages/core/src/runtime/world-init.ts b/packages/core/src/runtime/world-init.ts index 2809c2f63b..45adc11e38 100644 --- a/packages/core/src/runtime/world-init.ts +++ b/packages/core/src/runtime/world-init.ts @@ -1,15 +1,15 @@ /** * Server-only side-effect module that ensures `world.ts` is loaded so its - * module-load side effect — `globalThis[GetWorldFnKey] ??= getWorld` — + * module-load side effect (`globalThis[GetWorldFnKey] ??= getWorld`) * fires in host bundles. * * # Why this exists * * `getWorldLazy()` in `./get-world-lazy.ts` checks the globalThis cache * populated by `world.ts`'s module-load side effect. When a server route - * only consumes a helper that goes through `getWorldLazy` — for example - * `start` from `workflow/api`, or `defineHook().resume()` from `workflow` - * — webpack/turbopack can tree-shake the named import `{ getWorld }` out + * only consumes a helper that goes through `getWorldLazy` (for example + * `start` from `workflow/api`, or `defineHook().resume()` from `workflow`), + * webpack/turbopack can tree-shake the named import `{ getWorld }` out * of `runtime.ts`, taking `world.ts`'s module evaluation with it. The * globalThis registration never fires. * @@ -41,7 +41,7 @@ * * If you add another `getWorldLazy()` consumer that's reachable from a * host route without going through `workflow` or `workflow/api`, make - * sure that entry also imports this module — or that it transitively + * sure that entry also imports this module, or that it transitively * reaches `world.ts` via a non-tree-shakeable path. Adding a regression * test in `world-init.test.ts` is preferred to relying on careful manual * tracing. diff --git a/packages/core/src/runtime/world.ts b/packages/core/src/runtime/world.ts index bd676deca2..a39943a12f 100644 --- a/packages/core/src/runtime/world.ts +++ b/packages/core/src/runtime/world.ts @@ -134,7 +134,7 @@ export const createWorld = async (): Promise => { if (isVercelWorldTarget(targetWorld)) { // Warn if WORKFLOW_VERCEL_* env vars are set inside a Vercel serverless - // function (VERCEL=1) — they have no effect at runtime and likely indicate + // function (VERCEL=1): they have no effect at runtime and likely indicate // a misconfiguration (user manually added them as Vercel project env vars, // which is not needed). We gate on VERCEL=1 so the warning does not fire // when the CLI or web observability app sets these env vars intentionally. @@ -161,7 +161,7 @@ export const createWorld = async (): Promise => { }); } - // Try require() first for custom worlds — this avoids Turbopack tracing + // Try require() first for custom worlds: this avoids Turbopack tracing // a dynamic import() that it can't statically resolve. Fall back to // dynamic import() for ESM-only packages. let mod: any; diff --git a/packages/core/src/sealed-box.ts b/packages/core/src/sealed-box.ts index 113bd5d44d..859b8336b6 100644 --- a/packages/core/src/sealed-box.ts +++ b/packages/core/src/sealed-box.ts @@ -13,7 +13,7 @@ import { * ## Why this exists * * The symmetric `encr` path requires the writer to hold the recipient run's - * key — which also grants decrypt capability. For *cross-run* writes (a hook + * key, which also grants decrypt capability. For *cross-run* writes (a hook * resumption targeting another run, or a child workflow writing into a * parent's forwarded `WritableStream`) that is more authority than the writer * needs, and obtaining the symmetric key across a deployment boundary costs a @@ -23,7 +23,7 @@ import { * key, which is not secret and therefore travels on the run entity (and in * stream descriptors) at no cost. "Encrypt-only" stops being an honor-system * convention enforced by `importKey(raw, ['encrypt'])` and becomes a - * cryptographic guarantee — a writer holding just the public key provably + * cryptographic guarantee: a writer holding only the public key provably * cannot read anything. * * ## Key hierarchy @@ -65,7 +65,7 @@ import { * HPKE's `kem_context`): it prevents key-substitution/unknown-key-share * attacks in which an adversary replays a ciphertext as though it had been * sealed to a different recipient. Deviating from strict RFC 9180 framing is - * a deliberate, documented choice — the envelope is versioned via its format + * a deliberate, documented choice: the envelope is versioned via its format * prefix and the HKDF labels below, so a conformant profile can be added * later as a new version without touching existing payloads. * @@ -78,8 +78,8 @@ import { * * ## Nonce discipline * - * One-shot {@link seal} generates a fresh ephemeral keypair — and therefore a - * fresh content key — for every call, so nonce reuse is impossible. + * One-shot {@link seal} generates a fresh ephemeral keypair (and therefore a + * fresh content key) for every call, so nonce reuse is impossible. * * Callers that amortize the KEM across many frames via {@link encapsulate} * take on nonce discipline themselves, because the content key then outlives a @@ -87,7 +87,7 @@ import { * * 1. Every frame MUST use a fresh random nonce (which `aesGcmEncrypt` does). * Never a counter: a counter restarts at zero after a stream reconnect or a - * durable replay, reusing `(contentKey, nonce)` — which under AES-GCM leaks + * durable replay, reusing `(contentKey, nonce)`, which under AES-GCM leaks * the plaintext XOR of the two frames and the GCM auth subkey. * 2. A writer must not inherit a previous incarnation's content key, so * {@link encapsulate} is re-run per writer instance. @@ -148,7 +148,7 @@ const infoEncoder = new TextEncoder(); * A per-run X25519 keypair, derived from the run's key material. * * Callers should derive this once per run and memoize it alongside the - * symmetric key — derivation costs several Web Crypto round trips. + * symmetric key, since derivation costs several Web Crypto round trips. */ export interface RunKeyPair { /** Raw 32-byte X25519 private scalar. Secret. */ @@ -270,7 +270,7 @@ function importPublicKey(publicKey: Uint8Array): Promise { publicKey, { name: 'X25519' }, /* extractable */ true, - // Public keys carry no usages for X25519 — the private key does the + // Public keys carry no usages for X25519: the private key does the // deriving; the public key is only ever an argument to it. [] ); @@ -318,7 +318,7 @@ export function bytesToBase64(bytes: Uint8Array): string { * symmetric path) rather than crash a resumption. * * Validation is strict, because a lenient decoder is worse than a throwing one - * here — silently returning a short or truncated key makes a corrupt value look + * here: silently returning a short or truncated key makes a corrupt value look * *present*, so the caller seals to garbage instead of taking the fallback. * Rejected: characters outside the alphabet, a length that cannot describe a * whole number of bytes (`length % 4 === 1`), padding anywhere but the end, and @@ -365,7 +365,7 @@ export function base64ToBytes(value: string): Uint8Array | undefined { * Hand-rolled rather than using `atob` or `Buffer`: this module also runs * inside the workflow VM, whose global surface is deliberately minimal and * does not include either. The inputs here are fixed-size JWK key components, - * so a compact decoder is sufficient — it accepts unpadded base64url only, + * so a compact decoder is sufficient: it accepts unpadded base64url only, * which is what RFC 7515 §2 mandates for JWK members. */ function base64UrlToBytes(value: string): Uint8Array { @@ -439,7 +439,7 @@ async function deriveContentKey( * The writer half of the KEM: generate an ephemeral keypair and derive a * content key for a recipient's public key. * - * Use this when many payloads share one KEM operation — i.e. stream frames. + * Use this when many payloads share one KEM operation, i.e. stream frames. * The returned `contentKey` can only encrypt, so a stream writer provably * cannot read the recipient run's data even by mistake. * @@ -568,7 +568,7 @@ export async function decapsulate( * Seal a payload to a run's public key. * * Each call performs its own KEM operation, so every sealed payload gets an - * independent content key — nonce reuse across calls is impossible by + * independent content key: nonce reuse across calls is impossible by * construction. * * @param recipientPublicKey - The recipient run's raw 32-byte X25519 public key @@ -625,7 +625,7 @@ export async function open( /** * A writer-side session that amortizes one KEM operation across many sealed - * payloads — use it for streams, where one-shot {@link seal} would perform a + * payloads. Use it for streams, where one-shot {@link seal} would perform a * fresh keygen + ECDH + HKDF for every frame. * * Safety rests on two properties: @@ -672,7 +672,7 @@ export function createSealSession( * The mirror of {@link createSealSession}: because every frame from one writer * carries the same ephemeral public key, this turns an ECDH per frame into an * ECDH per writer. Correctness does not depend on the writer having used a - * session — a stream of independently sealed payloads simply misses the cache + * session: a stream of independently sealed payloads misses the cache * on each new ephemeral key. * * The cache is keyed by the ephemeral public key and holds one entry, which is @@ -741,7 +741,7 @@ function keyCacheId(publicKey: Uint8Array): string { * `ephemeralPublicKey ‖ recipientPublicKey`, and a recipient public key is * unique per (deployment key × project × run). Replaying a sealed payload at * a different run therefore fails at key agreement regardless. AAD is - * available for callers that want an additional, KDF-independent binding — + * available for callers that want an additional, KDF-independent binding: * both sides must supply byte-identical values or the payload will not open. */ export function runAad(projectId: string, runId: string): Uint8Array { diff --git a/packages/core/src/serialization-format.ts b/packages/core/src/serialization-format.ts index 831caaae6d..887efa740d 100644 --- a/packages/core/src/serialization-format.ts +++ b/packages/core/src/serialization-format.ts @@ -46,7 +46,7 @@ export const SerializationFormat = { /** Encrypted payload (inner payload has its own format prefix after decryption) */ ENCRYPTED: 'encr', /** - * Sealed payload — asymmetrically encrypted to a run's X25519 public key + * Sealed payload: asymmetrically encrypted to a run's X25519 public key * (inner payload has its own format prefix after opening). * * Written by cross-run writers that hold only the recipient run's public @@ -144,7 +144,7 @@ export const ENCRYPTED_PLACEHOLDER = '\u{1F512} Encrypted'; // --------------------------------------------------------------------------- /** - * Check if a plain object is `{ expiredAt: "" }` — a single-key + * Check if a plain object is `{ expiredAt: "" }`: a single-key * object with a string `expiredAt` value. */ function isExpiredObject(data: unknown): data is { expiredAt: string } { @@ -178,16 +178,16 @@ export function isExpiredStub(data: unknown): boolean { } /** - * Check if a binary value is ciphertext — either a symmetrically encrypted + * Check if a binary value is ciphertext: either a symmetrically encrypted * payload ('encr') or a sealed cross-run payload ('encp'). * * This is the predicate display layers want: both schemes are opaque bytes * that must not be fed to the devalue parser, and both render as the same * "Encrypted" affordance in the CLI and web UI. Use {@link isSealedData} when - * the *scheme* matters — notably when choosing a decryption path, since a + * the *scheme* matters, notably when choosing a decryption path, since a * sealed payload needs the run's private scalar rather than its symmetric key. * - * Browser-safe — does not depend on the full serialization module. + * Browser-safe: does not depend on the full serialization module. */ export function isEncryptedData(data: unknown): boolean { if (!(data instanceof Uint8Array) || data.length < FORMAT_PREFIX_LENGTH) { @@ -204,7 +204,7 @@ export function isEncryptedData(data: unknown): boolean { * Check if a binary value has the 'encp' format prefix, indicating a sealed * (asymmetrically encrypted) cross-run payload. * - * Browser-safe — does not depend on the full serialization module. + * Browser-safe: does not depend on the full serialization module. */ export function isSealedData(data: unknown): boolean { if (!(data instanceof Uint8Array) || data.length < FORMAT_PREFIX_LENGTH) { @@ -216,7 +216,7 @@ export function isSealedData(data: unknown): boolean { /** * Check if a binary value has a compression format prefix ('gzip' or 'zstd'). - * Browser-safe — does not depend on the full serialization module. + * Browser-safe: does not depend on the full serialization module. */ export function isCompressedData(data: unknown): boolean { if (!(data instanceof Uint8Array) || data.length < FORMAT_PREFIX_LENGTH) { @@ -234,7 +234,7 @@ interface NodeZlibDecode { } /** - * Resolve `node:zlib` via `process.getBuiltinModule` — no static Node + * Resolve `node:zlib` via `process.getBuiltinModule`: no static Node * dependency, invisible to browser bundlers. Returns undefined off Node. */ function getNodeZlib(): NodeZlibDecode | undefined { @@ -253,7 +253,7 @@ function getNodeZlib(): NodeZlibDecode | undefined { * Synchronously decompress a `gzip`/`zstd` payload when running on Node.js. * * Returns `undefined` when sync decompression isn't available (e.g. in the - * browser, or zstd on Node < 22.15) — callers fall back to leaving the data + * browser, or zstd on Node < 22.15); callers fall back to leaving the data * un-hydrated (the async `hydrateDataWithKey` path handles decompression in * browsers via `DecompressionStream` / a registered zstd decoder). */ @@ -270,7 +270,7 @@ function decompressSyncIfAvailable( return new Uint8Array(zlib.zstdDecompressSync(payload)); } } catch { - // Fall through — treat as unavailable + // Fall through: treat as unavailable } return undefined; } @@ -363,13 +363,13 @@ export type Revivers = Record any>; * * Encrypted data is intentionally left as a `Uint8Array` so that consumers * (CLI, web UI) can detect it with `isEncryptedData()` and decide how to - * handle it — the CLI replaces it with a styled placeholder, the web UI + * handle it: the CLI replaces it with a styled placeholder, the web UI * renders an "Encrypted" card with a Decrypt button that triggers * client-side decryption on demand. */ export function hydrateData(value: unknown, revivers: Revivers): unknown { if (value instanceof Uint8Array) { - // Encrypted data passes through untouched — o11y layers detect it with + // Encrypted data passes through untouched: o11y layers detect it with // isEncryptedData() and handle display (web: named constructor object, // CLI: EncryptedDataRef with util.inspect.custom). if (isEncryptedData(value)) { @@ -385,7 +385,7 @@ export function hydrateData(value: unknown, revivers: Revivers): unknown { format === SerializationFormat.GZIP || format === SerializationFormat.ZSTD ) { - // Compressed payload — decompress synchronously when running on + // Compressed payload: decompress synchronously when running on // Node.js (CLI, server o11y). In browsers there is no sync codec; // pass the data through untouched (like encrypted data) so async // consumers can route it through `hydrateDataWithKey`, which @@ -420,8 +420,8 @@ export function hydrateData(value: unknown, revivers: Revivers): unknown { * @param key - The run's key material. Pass `RunPayloadKeys` (from * `deriveRunPayloadKeys`) to open both symmetric (`encr`) and sealed * (`encp`) payloads; a bare `CryptoKey` opens only the symmetric ones. - * Typed as a read capability so a write-only seal target — which could open - * neither scheme — is rejected at compile time rather than failing here. + * Typed as a read capability so a write-only seal target (which could open + * neither scheme) is rejected at compile time rather than failing here. */ export async function hydrateDataWithKey( value: unknown, @@ -503,7 +503,7 @@ export const CLASS_INSTANCE_REF_TYPE = '__workflow_class_instance_ref__'; /** * A class instance reference for o11y display. * - * Browser-safe base class — no `util.inspect.custom`. Environment-specific + * Browser-safe base class: no `util.inspect.custom`. Environment-specific * rendering (CLI inspect, web component) is handled by each consumer. */ export class ClassInstanceRef { @@ -640,7 +640,7 @@ export const observabilityRevivers: Revivers = { // throws on the `["DOMException", ...]` tag and `hydrateStepIO`'s // try/catch leaves the raw flat-encoded string in the UI. AbortController // synthesizes a DOMException as the default `signal.reason` when abort() - // is called with no arg — so any abort that round-trips through a step + // is called with no arg, so any abort that round-trips through a step // boundary surfaces here. Reconstruct as a real DOMException when the // global is available (modern browsers + Node 18+), else fall back to // an Error preserving name/message/stack/cause for display. @@ -811,7 +811,7 @@ function hydrateHookMetadata( * Dispatches by resource type (step, hook, event, workflow) and calls * `hydrateData` with the provided revivers for each data field. * - * Each environment (web, CLI) provides its own revivers — this function + * Each environment (web, CLI) provides its own revivers; this function * only handles the dispatch logic and field mapping. */ export function hydrateResourceIO< diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index e626cd3f91..df964682dd 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -126,7 +126,7 @@ export { compress, decompress, type EncryptionKeyParam, - // Sealed-box ('encp') key variants — see serialization/encryption.ts. + // Sealed-box ('encp') key variants: see serialization/encryption.ts. type PayloadKey, type RunPayloadKeys, type SealTarget, @@ -208,7 +208,7 @@ function unwrapSerializationCause(error: unknown): unknown { * The compression layer populates `stats` only when it actually ran (binary * data on a spec >= 5 path); legacy / v1Compat paths leave it unrecorded, so * this no-ops for them and avoids the `getActiveSpan` lookup. Attributes land - * on whatever span is active — typically the dedicated `step.dehydrate` / + * on whatever span is active, typically the dedicated `step.dehydrate` / * `step.hydrate` span, otherwise the enclosing run/start span. */ async function recordCompression( @@ -216,7 +216,7 @@ async function recordCompression( operation: 'serialize' | 'deserialize' ): Promise { if (!stats.recorded) return; - // Telemetry must never break the serialize/deserialize data path — a + // Telemetry must never break the serialize/deserialize data path: a // missing/failing tracer is purely an observability loss. try { const span = await getActiveSpan(); @@ -243,7 +243,7 @@ async function recordCompression( /** * Emits OTel span attributes for workflow (guest) code executions that the * hardened serializer could not avoid (getters, proxies, custom - * serializers). No-ops when serialization was fully side-effect free — + * serializers). No-ops when serialization was fully side-effect free, * the common case. Same never-break-the-data-path contract as * `recordCompression` above. */ @@ -276,7 +276,7 @@ export function getSerializeStream( // Resolve the key input once on first use and cache the result. // Note: if resolving cryptoKey rejects (e.g., network error fetching // the derived key), the rejection won't surface until the first chunk - // is processed — not at stream construction time. + // is processed, not at stream construction time. const keyState = { resolved: false, key: undefined as PayloadKey | undefined, @@ -316,7 +316,7 @@ export function getSerializeStream( // On the sealed path the KEM is amortized across the stream via a // session (one ECDH per writer instead of one per frame). That is safe // precisely because the nonces stay random, and because the session is - // scoped to this stream instance — a replayed or reconnected writer + // scoped to this stream instance: a replayed or reconnected writer // builds a new one and never inherits a previous content key. if (sealSession) { prefixed = encodeWithFormatPrefix( @@ -417,8 +417,8 @@ export function getDeserializeStream( ) { const sealed = format === SerializationFormat.SEALED; // A sealed frame needs the run's keypair; a symmetric frame needs an - // AES key. Report the shortfall precisely — "no key at all" and "the - // wrong kind of key" have very different causes. + // AES key. Report the shortfall precisely: "no key at all" and "the + // wrong kind of key" have distinct causes. const usable = sealed ? isRunPayloadKeys(keyState.key) : aesKeyOf(keyState.key) !== undefined; @@ -454,7 +454,7 @@ export function getDeserializeStream( // The low-level crypto layer only sees the stripped payload, so it // cannot record the outer envelope prefix. We peeked it here, so // enrich the diagnostic context with the real format prefix before - // propagating — mirroring serialization/encryption.ts. + // propagating, mirroring serialization/encryption.ts. if (RuntimeDecryptionError.is(error) && error.context) { error.context.formatPrefix = format; } @@ -522,7 +522,7 @@ export function getDeserializeStream( // Byte streams (`type: 'bytes'` ReadableStreams passed across boundaries) // are written to the underlying world's stream transport one user chunk at // a time. Without an in-band envelope, the reader sees a flat stream of -// bytes — there is no way to tell where one user chunk ends and the next +// bytes: there is no way to tell where one user chunk ends and the next // begins, which makes mid-stream reconnect impossible (we don't know how // many server-side chunks have been consumed). // @@ -532,7 +532,7 @@ export function getDeserializeStream( // [4-byte big-endian length][user payload bytes] // // The envelope is identical in shape to `getSerializeStream`'s framing, -// but the payload here is *raw user bytes* — there is no inner +// but the payload here is *raw user bytes*: there is no inner // format-prefix, no devalue, no encryption. A framed byte stream stays // semantically a byte stream end-to-end; the framing is purely transport. // @@ -540,7 +540,7 @@ export function getDeserializeStream( // serialized stream ref (`framing: 'framed-v1'`), so both sides agree on // the wire format without runtime negotiation. Producers that target a // run whose deployment doesn't support framing (see `getRunCapabilities` -// in capabilities.ts) emit raw bytes and a ref without the field — which +// in capabilities.ts) emit raw bytes and a ref without the field, which // the reader treats as legacy raw bytes for backwards compatibility. /** @@ -557,7 +557,7 @@ const MAX_FRAME_SIZE = 100_000_000; * Wraps each chunk of a byte stream in a 4-byte big-endian length * prefix. Used by the producer side of a framed byte-stream pipe. * - * Empty chunks (length 0) are dropped — the resulting `[0x00 0x00 0x00 0x00]` + * Empty chunks (length 0) are dropped: the resulting `[0x00 0x00 0x00 0x00]` * frame would be ambiguous with the legacy "looks framed" detection in * `getDeserializeStream`, and it carries no information. * @@ -566,7 +566,7 @@ const MAX_FRAME_SIZE = 100_000_000; * writable performs one wire write per chunk, preserving boundaries). The * server therefore stores one frame per chunk index, which is what allows * a future reconnecting reader to resume a framed byte stream at - * `startIndex + consumedFrames` — the same arithmetic + * `startIndex + consumedFrames`, the same arithmetic * `createReconnectingFramedStream` relies on for object streams. Do not * coalesce or split frames here without revisiting that resume logic. */ @@ -600,7 +600,7 @@ export function getByteFramingStream(): TransformStream< * Unwraps length-prefixed byte-stream frames back into the original user * chunks. Used by the consumer side of a framed byte-stream pipe. * - * Buffers across read boundaries — the transport may split a single + * Buffers across read boundaries, since the transport may split a single * frame across multiple reads (header in one chunk, payload in another) * or coalesce multiple frames into a single read. The transform emits * whole user chunks regardless of transport chunking. @@ -822,7 +822,7 @@ export class WorkflowServerReadableStream extends ReadableStream { * Maximum consecutive reconnect attempts for a single framed stream session. * The counter resets to zero whenever a reconnect makes forward progress (a * frame is delivered), so this bounds *consecutive* failures, not the lifetime - * total — a long-lived serverless stream may legitimately reconnect far more + * total: a long-lived serverless stream may legitimately reconnect far more * than this many times as long as each reconnect keeps delivering data. We only * give up after this many reconnects in a row produce nothing. */ @@ -845,7 +845,7 @@ const getFramedStreamMaxReconnects = (): number => * correct for a well-behaved backend that honors `startIndex`. But if a World's * `streams.get` ever ignored `startIndex` and re-delivered earlier chunks, * "progress" would be reported every reconnect and the consecutive cap would - * never trip — turning a bounded failure into an unbounded reconnect loop. This + * never trip, turning a bounded failure into an unbounded reconnect loop. This * hard ceiling guarantees the loop always terminates. It is set high enough * (hours of streaming at realistic per-session timeouts) to never interfere * with legitimate long-lived streams. @@ -869,7 +869,7 @@ const getFramedStreamMaxTotalReconnects = (): number => * the writable buffers one frame per chunk when multi-writing). The wrapper * counts completed frames and, on upstream error, reopens the connection * with `startIndex = resolvedStartIndex + consumedFrames`. Partial-frame - * bytes buffered before the cut are discarded — the server will resend the + * bytes buffered before the cut are discarded; the server will resend the * in-flight chunk in full from the new startIndex. * * A clean upstream close (EOF with no error) is NOT trusted as completion @@ -885,7 +885,7 @@ const getFramedStreamMaxTotalReconnects = (): number => * * Negative `startIndex` values (last-N semantics) skip the reconnect * machinery because we cannot compute an absolute resume position without - * a tail-index lookup — the returned stream behaves as a single-shot read. + * a tail-index lookup; the returned stream behaves as a single-shot read. */ export function createReconnectingFramedStream( runId: string, @@ -922,9 +922,9 @@ export function createReconnectingFramedStream( * Whether an upstream EOF represents genuine completion: the stream's * authoritative metadata says it is done AND the frames delivered so far * cover every chunk up to the tail (one outer frame == one server chunk). - * A metadata failure trusts the EOF — turning a healthy completion into an - * error (or a reconnect loop) on a transient metadata blip would be worse - * than the legacy behavior this check guards against. + * A metadata failure trusts the EOF, since turning a healthy completion + * into an error (or a reconnect loop) on a transient metadata blip would + * be worse than the legacy behavior this check guards against. */ async function isVerifiedComplete(): Promise { try { @@ -942,15 +942,15 @@ export function createReconnectingFramedStream( reader = undefined; } // Advance the resume position past the frames already delivered, then - // drop any partial-frame bytes — the reopened connection re-sends from a + // drop any partial-frame bytes: the reopened connection re-sends from a // frame boundary at the new index. currentStartIndex += consumedFrames; consumedFrames = 0; buffer = new Uint8Array(0); // Retry the reopen itself against the reconnect budget. A transient - // failure of connect() — the server briefly unavailable during the - // reconnect window — is the exact blip this wrapper exists to survive, so + // failure of connect() (the server briefly unavailable during the + // reconnect window) is the exact blip this wrapper exists to survive, so // count it against the budget and try again rather than treating it as // fatal. Only budget exhaustion (a server that stays down) terminates the // stream. @@ -987,7 +987,7 @@ export function createReconnectingFramedStream( pull: async (controller) => { if (readStart === undefined) readStart = Date.now(); // Loop until we emit something, hit EOF, or fatally error. Reads that - // only extend the in-flight-frame buffer don't enqueue anything — we + // only extend the in-flight-frame buffer don't enqueue anything; we // keep reading rather than returning empty-handed. for (;;) { if (!reader) { @@ -1024,7 +1024,7 @@ export function createReconnectingFramedStream( // Infrastructure can normalize a mid-stream abort into a graceful // end (the server's max-duration cut is designed to arrive as an // errored body, but on some paths it reaches the client as a clean - // EOF), and a completed stream can still be cut mid-body — both + // EOF), and a completed stream can still be cut mid-body; both // would otherwise be silently read as a shorter, complete stream. if (reconnectSupported && !(await isVerifiedComplete())) { try { @@ -1090,13 +1090,13 @@ export function createReconnectingFramedStream( } if (emitted) { - // Forward progress on the current connection — clear the + // Forward progress on the current connection: clear the // consecutive-failure budget so a long stream that reconnects // many times (but keeps delivering) is never falsely capped. reconnectCount = 0; return; } - // Only partial bytes — read more. + // Only partial bytes: read more. } }, cancel: async () => { @@ -1117,7 +1117,7 @@ export function createReconnectingFramedStream( * rates (most agents: ~1.2 chunks per flush, >70% of chunks arriving more * than 10ms after the previous request already finished) show a fixed * leading-edge window taxes isolated-chunk delivery (~+20% on a ~50ms RTT) - * while batching almost nothing for slow producers — fast producers get + * while batching almost nothing for slow producers; fast producers get * their batching from in-flight accumulation regardless. Setting a positive * interval (env or `world.streamFlushIntervalMs`) opts a deployment into * windowed leading-edge batching, trading first-chunk latency for larger @@ -1144,7 +1144,7 @@ const getEnvStreamFlushIntervalMs = (): number | undefined => { * span's duration is therefore the app-perceived latency of the batch * (buffer dwell + backpressure + RPC); `buffer_dwell_ms` isolates the * pre-dispatch share so client-side batching cost can be told apart from - * network/server time. Named `workflow.stream.flush` — the per-request RPC + * network/server time. Named `workflow.stream.flush`; the per-request RPC * beneath it is world-vercel's `workflow.stream.write` span (chunk_rtt), and * the two must stay distinguishable. Fire-and-forget; no-op without OTEL. */ @@ -1230,7 +1230,7 @@ export class WorkflowServerWritableStream extends WritableStream { try { await pendingRunReady; } catch { - // intentional: ordering barrier only — see above. + // intentional: ordering barrier only, see above. } pendingRunReady = undefined; } @@ -1239,7 +1239,7 @@ export class WorkflowServerWritableStream extends WritableStream { // ------------------------------------------------------------------ // Group-commit buffering. // - // `write()` resolves as soon as the chunk enters this bounded buffer — + // `write()` resolves as soon as the chunk enters this bounded buffer, // NOT when it is durable. That is the property that makes batching // path-independent: a native `readable.pipeTo(serverWritable)` pulls the // next chunk the moment `write()` resolves, so chunks accumulate here @@ -1253,7 +1253,7 @@ export class WorkflowServerWritableStream extends WritableStream { // {@link STREAM_DRAIN_SYMBOL}) resolves only when the buffer is empty // and no request is in flight. `close()` awaits it before closing the // server stream, and the flushable-stream lock-release completion - // awaits it before letting a step finish — so "step completed" still + // awaits it before letting a step finish, so "step completed" still // implies "stream data durable", exactly as before. // // Encryption/decryption is handled at the framing level by @@ -1263,8 +1263,8 @@ export class WorkflowServerWritableStream extends WritableStream { let bufferBytes = 0; // The group currently inside a server request. Counted against the // buffer bound so `WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS` keeps its - // documented meaning — an upper bound across ALL read-but-not-durable - // chunks — not just the queued follow-up group. + // documented meaning (an upper bound across ALL read-but-not-durable + // chunks), not just the queued follow-up group. let inFlightChunks = 0; let inFlightBytes = 0; let flushTimer: ReturnType | null = null; @@ -1275,13 +1275,13 @@ export class WorkflowServerWritableStream extends WritableStream { * (retained, exactly as the previous implementation retained its buffer) * and every subsequent `write()`, `close()` and `drain()` rejects with * the original error. Chunks whose `write()` already resolved surface - * their failure at the durability barrier — that is the contract of an + * their failure at the durability barrier: that is the contract of an * early-ack sink. */ let sinkError: unknown; // Group-commit window. The env var, when set, overrides the World - // option; otherwise `world.streamFlushIntervalMs` governs (default 0) — - // including the very first chunk. When it must come from the world, + // option; otherwise `world.streamFlushIntervalMs` governs (default 0), + // including the first chunk. When it must come from the world, // `scheduleGroupCommit` waits for `worldPromise` before deciding, which // costs nothing: no request can leave before `sendGroup`'s own world // await either. @@ -1336,7 +1336,7 @@ export class WorkflowServerWritableStream extends WritableStream { * Dispatch loop: while chunks are buffered, send them group by group. * Exactly one loop runs at a time (`inFlight`), so groups reach the * server in write order. Chunks that arrive while a group's request is - * in flight accumulate and form the next group — the group-commit + * in flight accumulate and form the next group: the group-commit * behavior, now independent of how the producer pipes. */ /** @@ -1448,18 +1448,18 @@ export class WorkflowServerWritableStream extends WritableStream { /** * Leading-edge dispatch policy for an idle sink: * - window <= 0 (the default): dispatch the leading chunk immediately. - * Fast producers still batch via in-flight accumulation — chunks + * Fast producers still batch via in-flight accumulation: chunks * arriving during the request form the next group. The trade for an * idle burst is one extra request (a 30-chunk burst ships as 1 + 29 * instead of one 30-chunk group under a positive window) in exchange - * for zero fixed first-chunk delay — which matches measured producer + * for zero fixed first-chunk delay, which matches measured producer * behavior, where isolated chunks dominate. * - window > 0 (explicitly configured): arm the group-commit timer so - * the leading chunk waits up to the window collecting a group — + * the leading chunk waits up to the window collecting a group, * the opt-in trade for slow-but-steady producers. * Window resolution: `WORKFLOW_STREAM_FLUSH_INTERVAL_MS`, when set, * overrides `world.streamFlushIntervalMs`; otherwise the World option - * (default 0) governs — including the very first chunk. Deciding may + * (default 0) governs, including the first chunk. Deciding may * have to wait for the world to resolve; that adds no latency because * `sendGroup` awaits the same promise before any request leaves. */ @@ -1535,8 +1535,8 @@ export class WorkflowServerWritableStream extends WritableStream { } // Bounded buffer: accept the chunk (never drop), then block until - // the read-but-not-durable population — buffered AND in the active - // request — is back under the bound. Each durably-sent group + // the read-but-not-durable population (buffered AND in the active + // request) is back under the bound. Each durably-sent group // relieves this. while ( sinkError === undefined && @@ -1552,12 +1552,12 @@ export class WorkflowServerWritableStream extends WritableStream { }, async close() { // Everything accepted must be durable before the server stream is - // closed — the server fences post-close writes. + // closed: the server fences post-close writes. await drain(); // A close with an empty buffer skips the dispatch path (and its // barrier), but can itself be the first write to a brand-new - // stream — gate it too. + // stream, so gate it too. await ensureRunReady(); const world = await worldPromise; @@ -1568,14 +1568,14 @@ export class WorkflowServerWritableStream extends WritableStream { async abort(reason) { // Buffered chunks were already ACKED to their writers (early-ack // contract), and native pipeTo aborts this sink whenever its SOURCE - // errors — e.g. an AI stream that emits ten deltas and then throws. + // errors, e.g. an AI stream that emits ten deltas and then throws. // Discarding here would silently lose the accepted tail of the // prefix, so deliver it first; only the server stream close is // skipped. A dispatch failure during this drain is already sticky // and surfaces through the sink's error paths. // // Deliberately un-timeboxed (unlike the step-executor's 500ms - // inline flush): giving up early would drop acked chunks — the + // inline flush): giving up early would drop acked chunks, the // exact loss this path exists to prevent. It is still bounded in // practice by the World transport's own timeout/retry budget: a // stalled write ends in a terminal rejection after finite retries, @@ -1670,9 +1670,9 @@ function getAllBaseReducers( data.responseWritable = responseWritable; } // Forward the signal in two cases: - // 1. Already aborted — preserve aborted=true/reason so the hydrated + // 1. Already aborted: preserve aborted=true/reason so the hydrated // step sees the cancellation that happened before serialize. - // 2. Already tagged with workflow infrastructure — i.e. a signal + // 2. Already tagged with workflow infrastructure, i.e. a signal // from a workflow-managed AbortController, which has stream/hook // backing for cross-boundary propagation. // Plain non-aborted native signals are intentionally dropped (would @@ -1717,7 +1717,7 @@ type AbortSerializedData = { /** * Symbol-keyed internal fields tagged onto AbortController/AbortSignal - * instances (and `holder`s in reducer helpers). All optional — a plain + * instances (and `holder`s in reducer helpers). All optional: a plain * native instance has none of them set. */ type AbortInternals = { @@ -1762,7 +1762,7 @@ function reduceAbortWithListener( } } - // Deduped via ABORT_LISTENER_ATTACHED marker — see attachAbortListenerOnce. + // Deduped via ABORT_LISTENER_ATTACHED marker: see attachAbortListenerOnce. attachAbortListenerOnce( signal as AbortSignal, streamName, @@ -1806,7 +1806,7 @@ function reduceAbortBySymbol( * Attach a single abort listener to a signal, deduped across calls. * * Each serialization pass goes through the reducer, but a controller passed - * to N steps would otherwise accumulate N listeners — each writing the same + * to N steps would otherwise accumulate N listeners, each writing the same * stream packet and double-closing the stream on abort. The marker symbol * ensures the stream-write side-effect runs at most once per (signal, runId). */ @@ -1866,7 +1866,7 @@ function attachAbortListenerOnce( * @param framedByteStreams - When `true`, byte streams (`type: 'bytes'`) * are wrapped in length-prefixed frames on the wire so the consumer * can reconnect on transient errors. Should match the target run's - * capability — see `getRunCapabilities` in `capabilities.ts`. Defaults + * capability: see `getRunCapabilities` in `capabilities.ts`. Defaults * to `false` for backwards compatibility with older runs. * @returns */ @@ -1950,7 +1950,7 @@ export function getExternalReducers( // The receiving run's step-side reviver opens a server writable // against the original `(runId, name)` and resolves that run's // encryption key directly, so writes land on the original stream - // for the full lifetime of the receiving run — no in-process + // for the full lifetime of the receiving run, with no in-process // bridge tied to the dehydrating step's lifetime. const existingName = (value as any)[STREAM_NAME_SYMBOL]; const existingRunId = (value as any)[STREAM_SERVER_RUN_ID_SYMBOL]; @@ -2049,9 +2049,9 @@ function getHostClassPrototype( ): object | undefined { // Descriptor reads (`readProperty`), not bare gets: an accessor or Proxy // planted on the sandbox global (or the constructor) must be recorded in - // the guest-code sink — it gates VM retention — not silently executed. + // the guest-code sink (it gates VM retention), not silently executed. // Callers wrap this in `once(...)` per reducer set, so the read happens - // exactly once per serialize pass, on the first guard invocation — inside + // exactly once per serialize pass, on the first guard invocation, inside // the pass's sink scope, never per value. const ctor = readProperty(global, name) ?? readProperty(globalThis, name); if (typeof ctor !== 'function') return undefined; @@ -2077,13 +2077,13 @@ export function getWorkflowReducers( ...getAllBaseReducers(global), // Readable/Writable streams from within the workflow execution environment - // are simply "handles" that can be passed around to other steps. + // are "handles" that can be passed around to other steps. ReadableStream: (value) => { // Walk the prototype chain instead of `instanceof global.ReadableStream`: // the class is host-provided (injected into the sandbox), so its // prototype is in the chain of both real streams and the // `Object.create(ReadableStream.prototype)` handles used for request - // bodies — but a chain walk never consults `Symbol.hasInstance`, which + // bodies, but a chain walk never consults `Symbol.hasInstance`, which // the sandbox can define and which ran for every value the earlier // reducers did not claim. Reads below go through descriptors so a // getter on a step argument cannot run unreported. @@ -2147,7 +2147,7 @@ export function getWorkflowReducers( return s; }, - // AbortController/AbortSignal in workflow context — just read symbols (handles). + // AbortController/AbortSignal in workflow context: read only symbols (handles). // In the workflow VM, global.AbortController is a class but global.AbortSignal // is a plain object (not a class), so instanceof checks won't work for signals. // Detect instances by the presence of the ABORT_STREAM_NAME symbol instead. @@ -2163,7 +2163,7 @@ export function getWorkflowReducers( abortControllerPrototype.value ); if (ownSymbol === undefined && !isNativeAbortController) { - // Not ours and not a native controller — but a foreign controller + // Not ours and not a native controller, but a foreign controller // may still carry the symbol on its signal. const maybeSignal = readProperty(value, 'signal'); if ( @@ -2230,7 +2230,7 @@ function getStepReducers( } // Check if the stream already has the name symbol set, in which case - // it's already being sunk to the server and we can just return the + // it's already being sunk to the server and we can return the // name and type. let name = value[STREAM_NAME_SYMBOL]; let type = value[STREAM_TYPE_SYMBOL]; @@ -2238,7 +2238,7 @@ function getStepReducers( // handle from a previous step (see `getWorkflowRevivers`). When // present we must propagate the same framing choice on the way // back out, since the bytes already on the server's stream are in - // that format — switching format mid-stream would corrupt them. + // that format; switching format mid-stream would corrupt them. let framing: ByteStreamFraming | undefined = value[STREAM_FRAMING_SYMBOL]; if (!name) { @@ -2442,7 +2442,7 @@ function setupAbortStreamReader( ]); if (result.value && !result.done) { // An abort packet arrived: propagate it as fast as possible. Release - // the lock (synchronous) rather than cancelling here — on a + // the lock (synchronous) rather than cancelling here: on a // service-backed World `reader.cancel()` can do a network round-trip, // and awaiting it before `controller.abort()` would delay (or, if it // hangs, drop) real-time abort delivery to the in-flight step. @@ -2454,7 +2454,7 @@ function setupAbortStreamReader( try { // Hydrate via the same machinery the writer used so the reason // round-trips with full type fidelity. Encryption key (if any) - // comes from the step context — set up by the step executor before + // comes from the step context, set up by the step executor before // this reader runs. Fallback to undefined for external-context // revives (the hydrate path is encryption-key-tolerant). const ctxForKey = contextStorage.getStore(); @@ -2469,7 +2469,7 @@ function setupAbortStreamReader( } } else { // The step finished (or the reader was cancelled) without an abort. - // Cancel — not just release — so the underlying World stream is torn + // Cancel (not just release) so the underlying World stream is torn // down: a polling World (e.g. world-local) otherwise leaks a tail // reader (a 100ms filesystem poll plus emitter listeners) per step // invocation for the life of the process, since a signal-bearing step @@ -2479,7 +2479,7 @@ function setupAbortStreamReader( void reader.cancel().catch(() => {}); } } catch { - // Stream read failed — signal won't propagate in real-time, + // Stream read failed: signal won't propagate in real-time, // but hook-based propagation on next replay provides fallback } })() @@ -2512,7 +2512,7 @@ function tagAbortPair( /** * Propagate abort-internal symbols from one signal to another. Used by the * Request reviver because `new Request(url, { signal })` copies the signal - * internally — the constructed `request.signal` is a fresh AbortSignal that + * internally: the constructed `request.signal` is a fresh AbortSignal that * doesn't carry symbols from the source. */ function copyAbortInternals(src: AbortSignal, dest: AbortSignal): void { @@ -2574,7 +2574,7 @@ function reviveAbortController( // Dehydrate the abort payload through the same machinery the hook // event uses so the `reason` round-trips with full type fidelity // (DOMException, custom errors, etc.) and respects the run's - // encryption key — symmetric with what the suspension handler + // encryption key, symmetric with what the suspension handler // writes for workflow-initiated aborts. const payload = await dehydrateStepArguments( { aborted: true, reason }, @@ -2598,8 +2598,8 @@ function reviveAbortController( // The durable hook resume (which writes the `hook_received` event that // records this abort in the workflow's event log) must be committed // before the step completes. Otherwise the workflow continuation - // enqueued by `step_completed` can advance past the abort — dispatching - // a later step with a stale, non-aborted `signal` — before the event + // enqueued by `step_completed` can advance past the abort (dispatching + // a later step with a stale, non-aborted `signal`) before the event // exists. Route it to `preCompletionOps` (awaited inline before // completion) rather than `ops` (best-effort, background). The stream // write above stays in `ops`: it must fire ASAP to reach an in-flight @@ -2617,7 +2617,7 @@ function reviveAbortController( reason, }); } catch { - // Best-effort hook resume — retry on next replay + // Best-effort hook resume: retry on next replay } })(); ctx.preCompletionOps.push(hookResume); @@ -2629,7 +2629,7 @@ function reviveAbortController( } /** - * Revives just an AbortSignal without the patched abort() overhead. + * Revives only an AbortSignal without the patched abort() overhead. * Used when only a signal (not a controller) was serialized. */ function reviveAbortSignal( @@ -2676,17 +2676,17 @@ export function getCommonRevivers(global: Record = globalThis) { * * Three tiers, cheapest first: * - * 1. The descriptor carries the owner's X25519 public key — seal to it with + * 1. The descriptor carries the owner's X25519 public key: seal to it with * no I/O whatsoever. The owner published the key when it created the * stream, so this is the zero-round-trip path. - * 2. The descriptor carries the owner's deployment ID — resolve the owner's + * 2. The descriptor carries the owner's deployment ID: resolve the owner's * symmetric key, which cross-deployment means a key-API round trip. - * 3. Neither (descriptors written by older SDKs) — load the owning run first, + * 3. Neither (descriptors written by older SDKs): load the owning run first, * then resolve its symmetric key. * * Tiers 2 and 3 import the key encrypt-only, which is an honor-system * restriction: the same bytes could decrypt. Tier 1 makes it a cryptographic - * guarantee — a public key cannot read anything. + * guarantee: a public key cannot read anything. */ async function getForwardedWritableEncryptionKey( runId: string, @@ -2783,7 +2783,7 @@ export function getExternalRevivers( // For byte streams, use flushable pipe with lock polling. // If the producer wrote framed bytes (framing === 'framed-v1'), // unwrap the length-prefix envelope before handing chunks to - // the user. Absent / 'raw' framing means legacy raw bytes — + // the user. Absent / 'raw' framing means legacy raw bytes: // pipe through unchanged for backwards compatibility. // // No auto-reconnect here yet: raw byte streams have no wire @@ -2841,7 +2841,7 @@ export function getExternalRevivers( } }, WritableStream: (value) => { - // Same handling as `getStepRevivers.WritableStream` — see comments + // Same handling as `getStepRevivers.WritableStream`: see comments // there for the cross-run case (writable carries `runId` from // parent → child forwarding via `start()`). const targetRunId = typeof value.runId === 'string' ? value.runId : runId; @@ -2947,7 +2947,7 @@ export function getWorkflowRevivers( } return value; }, - // Workflow function reviver for workflow context — returns a function-like + // Workflow function reviver for workflow context: returns a function-like // object with .workflowId that mimics what the SWC compiler produces, WorkflowFunction: (value) => Object.assign( @@ -2991,7 +2991,7 @@ export function getWorkflowRevivers( // that when the handle is later passed to a step (which reads // the actual bytes from the server) we know whether to unframe. // Defaults to undefined for streams whose serialized ref didn't - // carry the field — those are treated as legacy raw bytes. + // carry the field; those are treated as legacy raw bytes. [STREAM_FRAMING_SYMBOL]: { value: value.framing, writable: false, @@ -3023,7 +3023,7 @@ export function getWorkflowRevivers( // Preserve the owner's public key for the same reason as the runId // above. Without it, forwarding a writable through a workflow to a step // silently drops the key, and the step falls back to fetching the - // owner's symmetric key — the round trip sealing exists to remove. + // owner's symmetric key, the round trip sealing exists to remove. if (typeof value.encryptionPublicKey === 'string') { descriptor[STREAM_SERVER_PUBLIC_KEY_SYMBOL] = { value: value.encryptionPublicKey, @@ -3036,9 +3036,9 @@ export function getWorkflowRevivers( // AbortController/AbortSignal revived inside the workflow VM. Use the // real WorkflowAbortSignal class so addEventListener('abort', fn) actually // fires when the signal aborts (the previous no-op stub silently dropped - // listener registrations — silent correctness bug for natural patterns + // listener registrations, a silent correctness bug for natural patterns // like `signal.addEventListener('abort', fn)` after receiving a deserialized - // signal). The signal does not own a hook subscription here — abort state + // signal). The signal does not own a hook subscription here; abort state // is delivered via the existing replay machinery on the source side. AbortController: (value) => { const signal = new WorkflowAbortSignal(value.streamName, value.hookToken); @@ -3089,7 +3089,7 @@ function getStepRevivers( // arrow steps). The wrapper invokes the body via // `stepFn.apply(boundThis, args)` so the body sees the same // `this` it would have had in the workflow bundle. Property - // presence — not truthiness — is significant because + // presence, not truthiness, is significant because // `bind(null)` and `bind(undefined)` are both legal and should // round-trip faithfully. // - `boundArgs`: prefilled args from @@ -3215,7 +3215,7 @@ function getStepRevivers( // For byte streams, use flushable pipe with lock polling. // If the producer wrote framed bytes (framing === 'framed-v1'), // unwrap the length-prefix envelope before handing chunks to - // the user step. Absent / 'raw' framing means legacy raw bytes — + // the user step. Absent / 'raw' framing means legacy raw bytes: // pipe through unchanged for backwards compatibility. const state = createFlushableState(); ops.push(state.promise); @@ -3262,11 +3262,11 @@ function getStepRevivers( // carries the original `runId` and `name`. Open a server writable // against the original `(runId, name)` and resolve THAT run's key // for encryption. The resolution is async but doesn't need to - // block reviver return — `getSerializeStream` accepts the + // block reviver return: `getSerializeStream` accepts the // `Promise` directly and awaits it lazily // on the first chunk written. The key is imported encrypt-only // so the receiving run can never decrypt anything else on the - // owning run's stream — it can only contribute new writes. + // owning run's stream; it can only contribute new writes. const targetRunId = typeof value.runId === 'string' ? value.runId : runId; const targetDeploymentId = typeof value.deploymentId === 'string' @@ -3306,8 +3306,8 @@ function getStepRevivers( // Record the underlying `(runId, name)` so downstream reducers can // recognize that this writable is already backed by a workflow - // server stream. When forwarded across `start()` again — e.g. - // the child passes this writable on to a grandchild — the + // server stream. When forwarded across `start()` again (e.g. + // the child passes this writable on to a grandchild), the // external reducer needs both to emit the original `runId` in // the descriptor. Object.defineProperty(serialize.writable, STREAM_NAME_SYMBOL, { @@ -3333,17 +3333,17 @@ function getStepRevivers( // // When the descriptor carries no key and the stream belongs to THIS run, // derive it. A writable taken with `getWritable()` in a workflow body has - // only a name on it — the workflow VM holds no key material by design — + // only a name on it (the workflow VM holds no key material by design), // so nothing could publish the key when the handle was created. Reviving // happens in a step, which does hold this run's key, and any later - // forward then just copies the symbol. + // forward then copies the symbol. // // It has to happen here rather than at serialization time: `start()` // dehydrates its arguments with the CHILD's runId and the CHILD's key, so // the owning run's key is no longer in scope by then. // // `targetRunId === runId` is the guard that matters. For a stream another - // run owns we must not advertise our key — the receiver would seal to us + // run owns we must not advertise our key: the receiver would seal to us // and the real owner could never open what it wrote. if ( typeof value.encryptionPublicKey !== 'string' && @@ -3519,7 +3519,7 @@ export function deserializePreparedStepError( * @param global - Global object for serialization context * @param v1Compat - Enable legacy v1 compatibility mode * @param framedByteStreams - Whether the target run can decode wire-framed - * byte streams. Should match the target deployment's capability — see + * byte streams. Should match the target deployment's capability: see * `getRunCapabilities` in `capabilities.ts`. Defaults to `false` for * backwards compatibility with older runs. * @returns The dehydrated value as binary data (Uint8Array) with format prefix @@ -3739,7 +3739,7 @@ export async function hydrateStepArguments( * @param global - Global object for serialization context * @param v1Compat - Enable legacy v1 compatibility mode * @param framedByteStreams - Whether the target run can decode wire-framed - * byte streams. Should match the target deployment's capability — see + * byte streams. Should match the target deployment's capability: see * `getRunCapabilities` in `capabilities.ts`. Defaults to `false` for * backwards compatibility with older runs. * @returns The dehydrated value as binary data (Uint8Array) with format prefix @@ -3832,7 +3832,7 @@ export async function dehydrateStepError( SerializationFormat.DEVALUE_V1, payload ) as Uint8Array; - // Compress before encrypting — encrypted bytes don't compress. + // Compress before encrypting: encrypted bytes don't compress. const compressionStats: CompressionStats = {}; const compressed = await compress( serialized, @@ -3905,7 +3905,7 @@ export async function dehydrateRunError( SerializationFormat.DEVALUE_V1, payload ) as Uint8Array; - // Compress before encrypting — encrypted bytes don't compress. + // Compress before encrypting: encrypted bytes don't compress. const compressionStats: CompressionStats = {}; const compressed = await compress( serialized, diff --git a/packages/core/src/serialization/client.ts b/packages/core/src/serialization/client.ts index 5d9b0e6d78..d08191aa9b 100644 --- a/packages/core/src/serialization/client.ts +++ b/packages/core/src/serialization/client.ts @@ -32,7 +32,7 @@ export async function serialize( SerializationFormat.DEVALUE_V1, payload ) as Uint8Array; - // Compress before encrypting — encrypted bytes don't compress. + // Compress before encrypting, since encrypted bytes don't compress. const compressed = await compress( prefixed, options?.compression === true, diff --git a/packages/core/src/serialization/codec-devalue-vm.ts b/packages/core/src/serialization/codec-devalue-vm.ts index 892ca3fed4..8af0082955 100644 --- a/packages/core/src/serialization/codec-devalue-vm.ts +++ b/packages/core/src/serialization/codec-devalue-vm.ts @@ -22,8 +22,8 @@ const decoder = new TextDecoder(); // Mirrors the node:vm engine's workflow-context abort reducers/revivers in // serialization.ts: reduce by reading the stream/hook symbols stamped at // controller construction; revive to the bootstrap's WorkflowAbortSignal -// class (looked up lazily on globalThis — the serde bundle is evaluated -// before the bootstrap defines it). +// class (looked up lazily on globalThis, since the serde bundle is +// evaluated before the bootstrap defines it). const ABORT_STREAM_NAME = Symbol.for('WORKFLOW_ABORT_STREAM_NAME'); const ABORT_HOOK_TOKEN = Symbol.for('WORKFLOW_ABORT_HOOK_TOKEN'); @@ -144,7 +144,7 @@ function getReviversForMode(mode: SerializationMode): Partial { } /** - * The workflow-mode reducer/reviver key sets — exported for the QuickJS + * The workflow-mode reducer/reviver key sets, exported for the QuickJS * host serde's exhaustiveness test (quickjs-serde.test.ts), which pins * that the handle-space codec implements exactly these. */ diff --git a/packages/core/src/serialization/codec-devalue.ts b/packages/core/src/serialization/codec-devalue.ts index 983574f6e5..d8c3cb839a 100644 --- a/packages/core/src/serialization/codec-devalue.ts +++ b/packages/core/src/serialization/codec-devalue.ts @@ -5,7 +5,7 @@ * reducers (serialize) and revivers (deserialize) which are composed * internally based on the serialization mode. * - * The reducer/reviver pattern is specific to devalue — other codecs + * The reducer/reviver pattern is specific to devalue: other codecs * (CBOR, JSON) would handle types differently (e.g. CBOR supports Date, * typed arrays, Map, Set natively). */ diff --git a/packages/core/src/serialization/codec.ts b/packages/core/src/serialization/codec.ts index 4ff6342863..40348b87ed 100644 --- a/packages/core/src/serialization/codec.ts +++ b/packages/core/src/serialization/codec.ts @@ -3,7 +3,7 @@ * * A codec handles the core serialize/deserialize logic for a specific * wire format (devalue, CBOR, JSON, etc.). Each codec is responsible - * for handling all supported data types internally — the caller only + * for handling all supported data types internally; the caller only * specifies which serialization mode to use. * * - **devalue**: Uses custom reducers/revivers for Date, Error, Map, Set, @@ -78,15 +78,15 @@ export interface CodecOptions { /** * Optional sink populated by the hardened serializer with every - * workflow (guest) code execution that serialization could not avoid — + * workflow (guest) code execution that serialization could not avoid: * getters, proxies, and custom `[WORKFLOW_SERIALIZE]` methods. A non-empty * `executions` array means serialization may have perturbed VM state * (it runs exactly once per payload and is never replayed, so any side * effect it triggers diverges from replay). * * Every dehydrate path already reports this as span attributes. Passing a - * sink is for callers that need the executions *programmatically* — a - * retained-VM gate deciding whether the VM is still reusable. No caller + * sink is for callers that need the executions *programmatically*, such + * as a retained-VM gate deciding whether the VM is still reusable. No caller * does that yet, so nothing in the runtime currently passes one. * * Serialize side only. diff --git a/packages/core/src/serialization/compression.ts b/packages/core/src/serialization/compression.ts index 2a99303be4..1d876d200a 100644 --- a/packages/core/src/serialization/compression.ts +++ b/packages/core/src/serialization/compression.ts @@ -5,22 +5,23 @@ * format prefix system to mark compressed data (e.g. 'zstd' or 'gzip' * wrapping the inner format: 'zstd' + zstd('devl' + payload)). * - * Codec selection (write side): zstd is preferred — it is markedly faster - * than gzip at a comparable-or-better ratio (see scripts/README.md), and - * compression runs at every step boundary so the write CPU is a per-step - * tax. zstd requires `node:zlib` >= 22.15 (Web `CompressionStream` has no - * zstd), so on a runtime without it we fall back to gzip via the portable + * Codec selection (write side): zstd is preferred because it is markedly + * faster than gzip at a comparable-or-better ratio (see scripts/README.md), + * and compression runs at every step boundary so the write CPU is a + * per-step tax. zstd requires `node:zlib` >= 22.15 (Web + * `CompressionStream` has no zstd), so on a runtime without it we fall back + * to gzip via the portable * `CompressionStream`. `WORKFLOW_COMPRESSION_CODEC=gzip` forces the * portable codec. * * Read side: dispatch on the format prefix, so both 'zstd' and 'gzip' * payloads are always decodable regardless of which codec wrote them. - * (The browser o11y read path decodes zstd via a registered WASM decoder — + * (The browser o11y read path decodes zstd via a registered WASM decoder, * see `serialization-format.ts`; this module's `decompress` is the Node * runtime/replay path and uses `node:zlib`.) * * Layering order with encryption: compression is applied BEFORE - * encryption (encr(zstd(devl))) — encrypted bytes are high-entropy and + * encryption (encr(zstd(devl))): encrypted bytes are high-entropy and * do not compress, so the reverse order would be a no-op. * * Compression is conditional: @@ -54,7 +55,7 @@ export const COMPRESSION_MIN_BYTES = 1024; */ export const COMPRESSION_MIN_SAVINGS_RATIO = 0.05; -/** Default zstd compression level — the sweet spot of speed vs ratio. */ +/** Default zstd compression level: the sweet spot of speed vs ratio. */ const ZSTD_LEVEL = 3; /** Which codec compressed a payload (or `none` when stored uncompressed). */ @@ -62,7 +63,7 @@ export type CompressionCodec = 'zstd' | 'gzip' | 'none'; /** * Escape hatch: set WORKFLOW_DISABLE_COMPRESSION=1 to disable - * write-side compression entirely. Reads are unaffected — payloads + * write-side compression entirely. Reads are unaffected: payloads * that were already written compressed remain readable. */ function isCompressionDisabledByEnv(): boolean { @@ -78,7 +79,7 @@ function isCompressionDisabledByEnv(): boolean { /** * Optional codec override (`WORKFLOW_COMPRESSION_CODEC=gzip|zstd`). Lets an - * operator pin the portable codec (gzip) — useful for A/B comparisons or + * operator pin the portable codec (gzip), useful for A/B comparisons or * runtimes where zstd read support isn't yet everywhere. */ function codecOverrideFromEnv(): 'gzip' | 'zstd' | undefined { @@ -97,7 +98,7 @@ interface NodeZlib { } /** - * Resolve `node:zlib` via `process.getBuiltinModule` — no static import, so + * Resolve `node:zlib` via `process.getBuiltinModule`: no static import, so * this module stays bundler-safe for browser/edge targets (where it returns * undefined and we fall back to gzip). */ @@ -142,7 +143,7 @@ async function pipeThroughTransform( } ): Promise { const writer = transform.writable.getWriter(); - // Don't await the write before reading — the transform's internal + // Don't await the write before reading: the transform's internal // queue can fill up on large payloads, deadlocking writer vs reader. const writePromise = writer.write(data).then(() => writer.close()); // If the transform errors, the reader.read() below rejects first and @@ -202,8 +203,8 @@ function unzstdBytes(data: Uint8Array): Uint8Array { * Telemetry sink describing what the compression layer did to a payload. * Populated by {@link compress} (write) and {@link decompress} (read) when * a `stats` object is passed. Sizes are measured at the compression - * boundary — i.e. before encryption is layered on the write side and after - * decryption on the read side — so they reflect compression's effect, not + * boundary (i.e. before encryption is layered on the write side and after + * decryption on the read side), so they reflect compression's effect, not * the at-rest size (which also includes the `encr` envelope and, on some * backends, base64 expansion). * @@ -260,8 +261,8 @@ function selectWriteCodec(): 'zstd' | 'gzip' | 'none' { * @param data - The format-prefixed serialized data (e.g. 'devl' + bytes) * @param enabled - Whether the target run supports compressed payloads * (run specVersion >= SPEC_VERSION_SUPPORTS_COMPRESSION, and for - * cross-deployment writes, the target deployment's capabilities — - * see `getRunCapabilities` in capabilities.ts). zstd and gzip read + * cross-deployment writes, the target deployment's capabilities; see + * `getRunCapabilities` in capabilities.ts). zstd and gzip read * support co-ship, so a single boolean is sufficient. * @param stats - Optional telemetry sink; populated when `data` is binary. * @returns The compressed data with a codec prefix, or the original data diff --git a/packages/core/src/serialization/encryption.ts b/packages/core/src/serialization/encryption.ts index e3942d0ce2..d42835e925 100644 --- a/packages/core/src/serialization/encryption.ts +++ b/packages/core/src/serialization/encryption.ts @@ -42,7 +42,7 @@ const RUN_KEYS_BRAND = Symbol.for('workflow.serialization.runKeys'); /** * A write-only capability: seal payloads to a run's X25519 public key. * - * This is what a *cross-run* writer holds — a hook resumption targeting + * This is what a *cross-run* writer holds: a hook resumption targeting * another run, or a child workflow writing into a parent's forwarded stream. * It carries no ability to decrypt anything, and because it is a distinct * nominal type it cannot be mistaken for symmetric key material. @@ -69,9 +69,9 @@ export interface SealTarget { */ export interface RunPayloadKeys { readonly [RUN_KEYS_BRAND]: true; - /** Symmetric AES-256-GCM key — the run's own `encr` payloads. */ + /** Symmetric AES-256-GCM key: the run's own `encr` payloads. */ readonly aes: CryptoKey; - /** X25519 keypair — opens `encp` payloads sealed to this run. */ + /** X25519 keypair: opens `encp` payloads sealed to this run. */ readonly keyPair: RunKeyPair; /** Additional authenticated data expected on sealed payloads. */ readonly aad?: Uint8Array; @@ -89,7 +89,7 @@ export interface RunPayloadKeys { * | ------------------ | ------- | -------------- | ------------------------ | * | `CryptoKey` | `encr` | `encr` | same-run (legacy shape) | * | {@link RunPayloadKeys} | `encr` | `encr`, `encp` | the owning run, o11y | - * | {@link SealTarget} | `encp` | — | cross-run writers | + * | {@link SealTarget} | `encp` | (none) | cross-run writers | * * A run's own payloads deliberately stay symmetric even when the writer could * seal: sealing costs a fresh ECDH per envelope and 32 extra bytes, and buys @@ -134,7 +134,7 @@ export function runPayloadKeys( } /** - * Build the full key capability for a run from its raw 32-byte key material — + * Build the full key capability for a run from its raw 32-byte key material, * the value `World.getEncryptionKeyForRun()` returns. * * Use this anywhere a run reads its own event log: it yields a key that opens @@ -199,8 +199,8 @@ export async function resolveEncryptionKey( * Encrypt a format-prefixed payload if a key is provided. * * Wraps the data with the `encr` prefix for symmetric keys, or the `encp` - * prefix when handed a {@link SealTarget} — a cross-run writer that holds only - * the recipient's public key. + * prefix when handed a {@link SealTarget}, a cross-run writer that holds + * only the recipient's public key. * * @param data - The format-prefixed serialized data * @param key - Encryption key (undefined to skip encryption) @@ -217,7 +217,7 @@ export async function encrypt( return encodeWithFormatPrefix(SerializationFormat.SEALED, sealed); } - // Symmetric path — a run's own payloads, including when the caller holds + // Symmetric path: a run's own payloads, including when the caller holds // the full `RunPayloadKeys` bundle and *could* seal. See `PayloadKey`. const aesKey = isRunPayloadKeys(key) ? key.aes : key; const encrypted = await aesGcmEncrypt(aesKey, data); @@ -229,7 +229,7 @@ export async function encrypt( * * Strips the `encr`/`encp` format prefix and recovers the inner payload. * Opening a sealed (`encp`) payload requires the run's X25519 keypair, so the - * caller must supply {@link RunPayloadKeys} — a bare symmetric key cannot do + * caller must supply {@link RunPayloadKeys}; a bare symmetric key cannot do * it, and neither can a {@link SealTarget} (which is write-only by design). * * @param data - The potentially encrypted data @@ -244,8 +244,8 @@ async function openSealedEnvelope( data: Uint8Array, key: PayloadKey | undefined ): Promise { - // Sealed payloads need the private scalar. Anything else — no key, a bare - // symmetric key, or a write-only seal target — cannot open them. + // Sealed payloads need the private scalar. Anything else (no key, a bare + // symmetric key, or a write-only seal target) cannot open them. if (!isRunPayloadKeys(key)) { throw new RuntimeDecryptionError( 'Sealed data encountered but no run keypair is available. ' + diff --git a/packages/core/src/serialization/errors.ts b/packages/core/src/serialization/errors.ts index 7000b49c0e..eda0197957 100644 --- a/packages/core/src/serialization/errors.ts +++ b/packages/core/src/serialization/errors.ts @@ -48,7 +48,7 @@ export function formatSerializationError( if (error instanceof DevalueError && error.path) { message += ` at path "${error.path}"`; } - // Workflow can serialize a much richer set than the devalue defaults — + // Workflow can serialize a much richer set than the devalue defaults: // classes registered via `WORKFLOW_SERIALIZE`, FatalError / RetryableError // subclasses, AbortSignal, etc. Pointing at the foundations doc keeps // this hint accurate as the supported set grows, instead of repeating diff --git a/packages/core/src/serialization/format.ts b/packages/core/src/serialization/format.ts index 28f1bc134d..dafad1dd59 100644 --- a/packages/core/src/serialization/format.ts +++ b/packages/core/src/serialization/format.ts @@ -4,14 +4,14 @@ * All serialized payloads are prefixed with a 4-byte format identifier that * allows the deserializer to determine how to decode the payload. This enables: * - * 1. Self-describing payloads — the World layer is agnostic to serialization format - * 2. Gradual migration — old runs keep working, new runs can use new formats - * 3. Composability — encryption can wrap any format ("encr" wrapping "devl") - * 4. Debugging — raw data inspection immediately reveals the format + * 1. Self-describing payloads: the World layer is agnostic to serialization format + * 2. Gradual migration: old runs keep working, new runs can use new formats + * 3. Composability: encryption can wrap any format ("encr" wrapping "devl") + * 4. Debugging: raw data inspection immediately reveals the format * * Format: [4 bytes: format identifier][payload] * - * The format prefix is open-ended — any 4-character [a-z0-9] string is valid. + * The format prefix is open-ended: any 4-character [a-z0-9] string is valid. * This allows new codecs to be added without modifying this module. */ @@ -82,7 +82,7 @@ export function isEncrypted(data: Uint8Array | unknown): boolean { * * Unlike the legacy implementation which only accepted known formats * (`devl`, `encr`), this function accepts any valid format prefix - * (`[a-z0-9]{4}`). This is intentional for forward compatibility — + * (`[a-z0-9]{4}`). This is intentional for forward compatibility, so * new codecs (e.g. `cbor`) can be added without modifying this module. * Callers are responsible for checking whether they support the returned * format and throwing an appropriate error if not (e.g. "Unsupported diff --git a/packages/core/src/serialization/hardened.ts b/packages/core/src/serialization/hardened.ts index 3c336518ab..da3b18612f 100644 --- a/packages/core/src/serialization/hardened.ts +++ b/packages/core/src/serialization/hardened.ts @@ -3,7 +3,7 @@ * workflow VM (`node:vm`) sandbox realm. * * Serialization runs on the host, but the values it inspects were - * constructed by workflow code — so a naive dynamic operation like + * constructed by workflow code, so a naive dynamic operation like * `value.toISOString()`, `Array.from(map)`, or `Object.prototype.toString` * dispatches into the sandbox realm and executes workflow code (patched * prototype methods, getters, proxy traps, `Symbol.toStringTag` accessors). @@ -15,26 +15,26 @@ * it, and *observable* where it does not: * * - **Classification** uses engine-level brand checks (`node:util` `types`, - * internal-slot probes) instead of `instanceof` / `Object.prototype.toString` - * — immune to `Symbol.hasInstance`, `Symbol.toStringTag`, and reassigned - * globals. + * internal-slot probes) instead of `instanceof` / `Object.prototype.toString`, + * which makes it immune to `Symbol.hasInstance`, `Symbol.toStringTag`, and + * reassigned globals. * - **Extraction** goes through intrinsics captured at module load (host * boot, before any workflow code runs). Internal slots are realm-agnostic, * so host intrinsics read VM-realm objects without touching the sandbox's * (patchable) prototypes. * - **Property access** reads through descriptors, so plain data never * invokes anything. Where workflow code *must* run because the data itself - * lives behind it — getters, proxies, custom `[WORKFLOW_SERIALIZE]` - * methods, `toString()` on toStringTag-branded objects (e.g. Temporal - * polyfills) — the execution is preserved for compatibility and recorded + * lives behind it (getters, proxies, custom `[WORKFLOW_SERIALIZE]` + * methods, `toString()` on toStringTag-branded objects such as Temporal + * polyfills), the execution is preserved for compatibility and recorded * in the active {@link GuestCodeStats} sink, so callers (e.g. a retained-VM * gate) can react. * * **Recording is not prevention.** For the recorded cases the determinism * hazard is still live: a getter that calls `Math.random()` advances the * run's seeded PRNG during serialization, and because serialization happens - * exactly once and is never replayed, every subsequent draw — including the - * correlation ids derived from that stream — shifts relative to replay. The + * exactly once and is never replayed, every subsequent draw (including the + * correlation ids derived from that stream) shifts relative to replay. The * report is the only trace of that; acting on it (warning, demoting a * retained VM to replay) is left to the caller. * @@ -60,8 +60,8 @@ export interface GuestCodeExecution { * implies a **shape change**: brand checks answer "not that type" for a * proxy, so a proxied `Map` serializes as a plain object rather than as a * `Map`, and this report is the only evidence of it. (Such values were - * never serializable before — the internal-slot reads in the previous - * implementation threw on them — so the shape change replaces a crash, + * never serializable before, since the internal-slot reads in the previous + * implementation threw on them, so the shape change replaces a crash, * but it is silent.) * - `method`: a workflow-defined function was invoked (e.g. a custom * `[WORKFLOW_SERIALIZE]` serializer, `toString()` on a @@ -110,13 +110,13 @@ export function withGuestCodeStats( /** * Closure-variable functions that arrived through `useStep` when a step * proxy was built. The step-function reducer must invoke `__closureVarsFn`, - * and the compiler-generated function is a pure sequence of lexical reads — - * but the *property* is reachable from workflow code, which can replace it + * and the compiler-generated function is a pure sequence of lexical reads. + * But the *property* is reachable from workflow code, which can replace it * with an arbitrary function. The reducer checks membership here instead of * assuming provenance, and reports anything it does not recognize. * * Membership proves the function was passed to `useStep`, not that the - * compiler generated it — workflow code can call `useStep` directly and + * compiler generated it: workflow code can call `useStep` directly and * launder a side-effectful function past the report. That costs a missing * report entry, never incorrect output; closing it means branding at the * compiler, which does not belong here. @@ -154,7 +154,7 @@ function recordProxy(value: object): void { // ---- Captured intrinsics ---------------------------------------------------- // -// Captured at module load — host boot, before any workflow bundle can run — +// Captured at module load (host boot, before any workflow bundle can run) // and invoked with explicit receivers, so no lookup ever resolves through a // sandbox-reachable prototype. Every member below exists on every supported // engine (Node 18+), so a missing one is a bug in this table: fail at import @@ -243,7 +243,7 @@ const sharedArrayBufferByteLength = protoGetter( ); // Host (WHATWG) classes. Injected into the sandbox by reference, so -// instances from any realm carry these prototypes — and the shared +// instances from any realm carry these prototypes, and the shared // prototypes are reachable from workflow code, which makes the boot-time // capture (rather than a live lookup) load-bearing. const headersIterator = uncurryThis(Headers.prototype[Symbol.iterator]); @@ -270,7 +270,7 @@ const functionToString = uncurryThis(Function.prototype.toString); * functions and callable Proxies also present as native code but run * workflow code, so both are excluded first. * - Host builtins implemented in JavaScript are ordinary functions, but they - * belong to the host realm — detected by comparing the function's + * belong to the host realm, detected by comparing the function's * prototype against the host `Function.prototype`. Workflow code that * reaches a host function can `setPrototypeOf` its own getter to * impersonate this; that costs a missing report entry, never incorrect @@ -281,7 +281,7 @@ const functionToString = uncurryThis(Function.prototype.toString); */ function isEngineAccessor(getter: object): boolean { if (isProxy(getter)) return false; - // A bound function stringifies as native code but runs its target — the + // A bound function stringifies as native code but runs its target. The // one passive distinguisher V8 exposes is the `name` own property // (`"bound fn"`). Workflow code can redefine the name to hide it; that // costs a missing report entry, like the other impersonation caveats. @@ -314,7 +314,7 @@ export function readProperty(value: unknown, key: PropertyKey): unknown { let current: object | null = value; while (current !== null) { if (isProxy(current)) { - // a proxy in the prototype chain — its traps answer the lookup + // a proxy in the prototype chain: its traps answer the lookup recordProxy(current); return (value as Record)[key]; } @@ -366,12 +366,12 @@ export function hasProperty(value: unknown, key: PropertyKey): boolean { * `value instanceof C` semantics for a known `C.prototype`, without * consulting `Symbol.hasInstance` (which workflow code can define). Used * for host classes that are injected into the sandbox (Headers, URL, - * URLSearchParams, DOMException), where the instances — from any realm the - * host handed the class to — carry the host prototype in their chain. + * URLSearchParams, DOMException), where the instances (from any realm the + * host handed the class to) carry the host prototype in their chain. * * Proxies are walked rather than rejected: `Reflect.getPrototypeOf` fires * the proxy's `getPrototypeOf` trap, matching `instanceof` semantics, and - * real values depend on that — Next.js hands the runtime a proxied + * real values depend on that: Next.js hands the runtime a proxied * `NextRequest`, and answering "not a Request" for it would silently break * webhooks. The traps are guest-observable, so the proxy is recorded. */ @@ -393,7 +393,7 @@ export function isInstanceOfPrototype( // ---- Intrinsic-backed extraction helpers (used by the reducers) ------------- // -// Intrinsics read internal slots, which a Proxy does not have — invoking one +// Intrinsics read internal slots, which a Proxy does not have: invoking one // with a proxy receiver throws, where the pre-existing dynamic read forwarded // through the trap. For the (rare) proxy case, fall back to the dynamic read // so behavior is unchanged, and record that the traps ran. @@ -407,7 +407,7 @@ export function urlHref(value: URL): string { return urlHrefGetter(value); } -/** `URLSearchParams.prototype.toString` — returns `''` iff empty. */ +/** `URLSearchParams.prototype.toString`: returns `''` iff empty. */ export function urlSearchParamsToString(value: URLSearchParams): string { if (isProxy(value)) { recordProxy(value); @@ -418,7 +418,7 @@ export function urlSearchParamsToString(value: URLSearchParams): string { /** * Iterates a Headers instance through the captured host iterator, so the - * iterator object — and its `next` — are host-realm. + * iterator object, and its `next`, are host-realm. */ export function headersToEntries(value: Headers): [string, string][] { if (isProxy(value)) { @@ -431,7 +431,7 @@ export function headersToEntries(value: Headers): [string, string][] { /** * Iterates a genuine Map's entries entirely through host intrinsics: the * iterator object is created by the host `Map.prototype.entries`, so its - * realm — and therefore its `next` — is the host's, not the sandbox's. + * realm, and therefore its `next`, is the host's, not the sandbox's. */ export function mapToEntries( value: Map @@ -445,7 +445,7 @@ export function setToValues(value: Set): unknown[] { } /** - * The bytes of an `ArrayBufferView`, read via internal-slot getters — + * The bytes of an `ArrayBufferView`, read via internal-slot getters, so * own-property shadowing and prototype patches cannot change which bytes * are serialized. */ @@ -472,7 +472,7 @@ export function viewInfo(value: ArrayBufferView): { // // The workflow reducers claim most special types before devalue's built-in // handling runs, so these operations mainly govern plain objects, arrays, -// boxed primitives, thenable probes — and classification (`tagOf`), which +// boxed primitives, thenable probes, and classification (`tagOf`), which // runs for every object the reducers did not claim. const KNOWN_VIEW_TAGS = new Set([ @@ -546,7 +546,7 @@ function brandOf(value: object): string | undefined { /** * Reads `Symbol.toStringTag` the way `Object.prototype.toString` would, - * but through descriptors — a data-property tag (the common case, e.g. + * but through descriptors: a data-property tag (the common case, e.g. * Temporal polyfills) costs no workflow-code execution; an accessor tag is * invoked (compat) and recorded. */ @@ -558,7 +558,7 @@ function readToStringTag(value: object): string | undefined { export const hardenedStringifyOperations: Partial = { tagOf: (value: object) => { if (isProxy(value)) { - // A proxy's classification is answered by its traps — that is the + // A proxy's classification is answered by its traps, which is the // only access path there is. Record it and preserve today's // behavior for everything downstream. recordProxy(value); diff --git a/packages/core/src/serialization/index.ts b/packages/core/src/serialization/index.ts index 8fe3d68213..9748121b7b 100644 --- a/packages/core/src/serialization/index.ts +++ b/packages/core/src/serialization/index.ts @@ -1,5 +1,5 @@ /** - * Serialization module — public API. + * Serialization module: public API. * * Re-exports the mode-specific serialize/deserialize functions and * the codec/format/encryption abstractions. diff --git a/packages/core/src/serialization/reducers/class-vm.ts b/packages/core/src/serialization/reducers/class-vm.ts index 94ca300368..ab79e1eacd 100644 --- a/packages/core/src/serialization/reducers/class-vm.ts +++ b/packages/core/src/serialization/reducers/class-vm.ts @@ -1,7 +1,7 @@ /** * VM-compatible copy: identical semantics to class.ts before the host-side * hardening (#3257) made that module depend on `serialization/hardened.ts` - * (which imports `node:util` and captures host intrinsics — meaningless + * (which imports `node:util` and captures host intrinsics, meaningless * and unbundleable inside the QuickJS VM, where the codec already runs in * the guest realm). The host/guest boundary hardening for the QuickJS * engine lands with the host-side serde (#3263), which retires this diff --git a/packages/core/src/serialization/reducers/class.ts b/packages/core/src/serialization/reducers/class.ts index 5fbcce92a8..1c03a3cb3d 100644 --- a/packages/core/src/serialization/reducers/class.ts +++ b/packages/core/src/serialization/reducers/class.ts @@ -39,7 +39,7 @@ export function getClassReducers(): Partial { ); } - // Custom serializers are workflow code by definition — the data only + // Custom serializers are workflow code by definition: the data only // exists behind them. Record the execution so retention-aware callers // can account for possible VM-state perturbation. recordGuestCode('method', `[WORKFLOW_SERIALIZE] (${classId})`); diff --git a/packages/core/src/serialization/reducers/common-vm.ts b/packages/core/src/serialization/reducers/common-vm.ts index 66cb458e56..6ffebecfe4 100644 --- a/packages/core/src/serialization/reducers/common-vm.ts +++ b/packages/core/src/serialization/reducers/common-vm.ts @@ -47,7 +47,7 @@ function reviveArrayBuffer(value: string): ArrayBuffer { // Creates a reducer for a built-in Error subclass whose serialized shape // is exactly { message, stack, cause? }. Matches by `value.name` -// (instance property) for cross-realm + bundler-output robustness — see +// (instance property) for cross-realm + bundler-output robustness; see // the host-side common.ts for full rationale. function makeNamedErrorSubclassReducer(subclassName: string) { return ( @@ -68,7 +68,7 @@ function makeNamedErrorSubclassReducer(subclassName: string) { // constructor on globalThis so the resulting object passes // `instanceof TypeError` etc. in the consuming realm. Falls back to // a base Error with the right `.name` if the constructor is not -// available (defensive — built-ins always exist). +// available (defensive: built-ins always exist). function makeNamedErrorSubclassReviver(subclassName: string) { return (value: { message: string; stack?: string; cause?: unknown }) => { const Cls = (globalThis as any)[subclassName]; @@ -201,7 +201,7 @@ export function getCommonReducers(): Partial { SyntaxError: makeNamedErrorSubclassReducer('SyntaxError'), TypeError: makeNamedErrorSubclassReducer('TypeError'), URIError: makeNamedErrorSubclassReducer('URIError'), - // Base Error reducer — catch-all. Matched LAST after subclass-specific + // Base Error reducer: catch-all. Matched LAST after subclass-specific // reducers above. Preserves `name` so user Error subclasses without // dedicated reducers retain their identity through the round-trip. Error: (value) => { @@ -228,7 +228,7 @@ export function getCommonReducers(): Partial { source: value.source, flags: value.flags, }, - // Request/Response/Headers — serialize using the polyfill constructors + // Request/Response/Headers: serialize using the polyfill constructors Headers: (value) => { const H = (globalThis as any).Headers; if (!H || !(value instanceof H)) return false; @@ -293,7 +293,7 @@ export function getCommonReducers(): Partial { const type = value[Symbol.for('WORKFLOW_STREAM_TYPE')]; if (type) s.type = type; // Preserve wire framing so the step-side reviver can unframe - // byte streams (framed-v1) — dropping it turns a framed webhook + // byte streams (framed-v1); dropping it turns a framed webhook // body into raw length-prefixed bytes for the consumer. const framing = value[Symbol.for('WORKFLOW_STREAM_FRAMING')]; if (framing) s.framing = framing; @@ -331,7 +331,7 @@ export function getCommonReducers(): Partial { WorkflowFunction: (value) => { // Only match function references with a workflowId property (set by // the SWC compiler on workflow functions). Plain { workflowId } objects - // are NOT matched — this prevents infinite recursion since the reduced + // are NOT matched; this prevents infinite recursion since the reduced // form { workflowId } is a plain object, not a function. if (typeof value !== 'function') return false; const workflowId = (value as any).workflowId; @@ -497,7 +497,7 @@ export function getCommonRevivers(): Partial { new Uint8ClampedArray(reviveArrayBuffer(value)), Uint16Array: (value: string) => new Uint16Array(reviveArrayBuffer(value)), Uint32Array: (value: string) => new Uint32Array(reviveArrayBuffer(value)), - // Web API types — revived as plain objects in the VM since the real + // Web API types: revived as plain objects in the VM since the real // constructors (Headers, Request, Response) are not available in QuickJS. // The workflow code can access the properties but not call Web API methods. Headers: (value) => { @@ -517,7 +517,7 @@ export function getCommonRevivers(): Partial { return value; }, Response: (value: any) => { - // Don't use Object.setPrototypeOf — devalue continues to set properties + // Don't use Object.setPrototypeOf: devalue continues to set properties // on the object after the reviver runs, and getter-only properties // (like 'ok') on the prototype would cause "no setter" errors. // Instead, copy methods directly onto the object. @@ -538,11 +538,11 @@ export function getCommonRevivers(): Partial { const RS = (globalThis as any).ReadableStream; const stream = Object.create(RS ? RS.prototype : {}); if (value && 'bodyInit' in value) { - // Body from Response/Request constructor — store the raw data + // Body from Response/Request constructor: store the raw data stream[Symbol.for('BODY_INIT')] = value.bodyInit; } else if (value && 'name' in value) { - // Named stream reference — preserve the name/type for re-serialization. - // Streams are opaque pointers in the VM — they can be passed to steps + // Named stream reference: preserve the name/type for re-serialization. + // Streams are opaque pointers in the VM; they can be passed to steps // but not consumed directly. stream[Symbol.for('WORKFLOW_STREAM_NAME')] = value.name; if (value.type) stream[Symbol.for('WORKFLOW_STREAM_TYPE')] = value.type; diff --git a/packages/core/src/serialization/reducers/common.ts b/packages/core/src/serialization/reducers/common.ts index 81276d587d..7afc3aae38 100644 --- a/packages/core/src/serialization/reducers/common.ts +++ b/packages/core/src/serialization/reducers/common.ts @@ -52,8 +52,8 @@ function arrayBufferToBase64( function viewToBase64(value: ArrayBufferView): string { // Read the view's range through internal-slot getters (see hardened.ts): - // own properties shadowing `buffer`/`byteOffset`/`byteLength` — or patched - // prototype getters in the sandbox realm — cannot change which bytes are + // own properties shadowing `buffer`/`byteOffset`/`byteLength` (or patched + // prototype getters in the sandbox realm) cannot change which bytes are // serialized. const info = viewInfo(value); return arrayBufferToBase64(info.buffer, info.byteOffset, info.byteLength); @@ -95,7 +95,7 @@ type BaseErrorPayload = { /** * Subset of `SerializableSpecial` keys whose payload shape is exactly the * `BaseErrorPayload`. `makeErrorSubclassReducer` is constrained to only - * these keys so its return type is sound — subclasses that need extra + * these keys so its return type is sound; subclasses that need extra * fields (like `AggregateError.errors` or `RetryableError.retryAfter`) use * `reduceErrorBase` directly and extend the result. */ @@ -113,8 +113,8 @@ type SimpleErrorSubclassKey = { * That read is not passive: it executes `Error.prepareStackTrace` when the * realm has one installed, and the format-and-cache itself is * workflow-visible (a formatter installed later never runs for an - * already-materialized error). A cold replay repeats neither — it skips - * dehydration entirely — so the read is recorded as guest code. A + * already-materialized error). A cold replay repeats neither (it skips + * dehydration entirely), so the read is recorded as guest code. A * data-property `stack` (rehydrated errors, workflow-assigned strings) * reads passively. */ @@ -161,8 +161,8 @@ function reduceErrorBase(value: unknown): BaseErrorPayload | false { * - Inline reducers for subclasses that extend the shape with additional * fields (e.g. `AggregateError.errors`, `RetryableError.retryAfter`). * - * Matching by `value.name` (instead of `value.constructor?.name`) is robust - * to bundlers that emit the class as an anonymous expression — e.g. Turbopack + * Matching by `value.name` (instead of `value.constructor?.name`) works with + * bundlers that emit the class as an anonymous expression. E.g. Turbopack * compiles `export class FatalError extends Error {…}` to a registration call * like `e.s(["FatalError", 0, class extends Error {…}])`, and the resulting * constructor has `name === ''`. Since every Error subclass we care about @@ -231,7 +231,7 @@ export function getCommonReducers( types.isArrayBuffer(value) && arrayBufferToBase64(value, 0, arrayBufferByteLength(value)), BigInt: (value) => - // String(bigint) is a spec-internal numeric conversion — unlike + // String(bigint) is a spec-internal numeric conversion: unlike // `value.toString()`, it never consults BigInt.prototype. typeof value === 'bigint' && String(value), BigInt64Array: (value) => @@ -360,7 +360,7 @@ export function getCommonReducers( errors: readProperty(value, 'errors') as AggregateError['errors'], } satisfies SerializableSpecial['AggregateError']; }, - // Base Error reducer — catch-all for any Error instance not matched by a + // Base Error reducer: catch-all for any Error instance not matched by a // specific subclass reducer above (including user Error subclasses without // WORKFLOW_SERIALIZE). Preserves `name` so the error's identity is retained // even though the exact class cannot be reconstructed. @@ -379,7 +379,7 @@ export function getCommonReducers( Float32Array: (value) => types.isFloat32Array(value) && viewToBase64(value), Float64Array: (value) => types.isFloat64Array(value) && viewToBase64(value), // Headers is a host class injected into the sandbox, so its (shared) - // prototype is reachable from workflow code — iterate through the + // prototype is reachable from workflow code; iterate through the // boot-captured iterator instead of a live Symbol.iterator lookup. Headers: (value) => isInstanceOfPrototype(value, Headers.prototype) && @@ -406,7 +406,7 @@ export function getCommonReducers( WorkflowFunction: (value) => { // Only match function references with a workflowId property (set by // the SWC compiler on workflow functions). Plain { workflowId } objects - // are NOT matched — this prevents infinite recursion since the reduced + // are NOT matched; this prevents infinite recursion since the reduced // form { workflowId } is a plain object, not a function. if (typeof value !== 'function') return false; const workflowId = readProperty(value, 'workflowId'); @@ -530,7 +530,7 @@ export function getCommonRevivers( if (value.stack !== undefined) error.stack = value.stack; return error; }, - // Base Error reviver — used for plain Error instances and unrecognized + // Base Error reviver: used for plain Error instances and unrecognized // Error subclasses. Preserves `name` so the error's identity is retained. Error: (value) => { const opts = 'cause' in value ? { cause: value.cause } : undefined; diff --git a/packages/core/src/serialization/reducers/step-function-vm.ts b/packages/core/src/serialization/reducers/step-function-vm.ts index 3560af34a1..9ed2345f03 100644 --- a/packages/core/src/serialization/reducers/step-function-vm.ts +++ b/packages/core/src/serialization/reducers/step-function-vm.ts @@ -1,7 +1,7 @@ /** * VM-compatible copy: identical semantics to step-function.ts before the host-side * hardening (#3257) made that module depend on `serialization/hardened.ts` - * (which imports `node:util` and captures host intrinsics — meaningless + * (which imports `node:util` and captures host intrinsics, meaningless * and unbundleable inside the QuickJS VM, where the codec already runs in * the guest realm). The host/guest boundary hardening for the QuickJS * engine lands with the host-side serde (#3263), which retires this diff --git a/packages/core/src/serialization/reducers/step-function.ts b/packages/core/src/serialization/reducers/step-function.ts index 7b57ce581e..6b577212ee 100644 --- a/packages/core/src/serialization/reducers/step-function.ts +++ b/packages/core/src/serialization/reducers/step-function.ts @@ -39,7 +39,7 @@ export function getStepFunctionReducer(): Partial { // The reducer has to invoke this to read the step's captured closure // variables. The compiler-generated function is a sequence of lexical // reads and cannot perturb observable VM state, so reporting it would - // flag every step that captures a variable — but the property is + // flag every step that captures a variable. But the property is // reachable from workflow code, which can replace it with anything. // `step.ts` marks the function that came through `useStep` when it // builds the proxy, so this is checked rather than assumed; anything diff --git a/packages/core/src/serialization/step.ts b/packages/core/src/serialization/step.ts index 7e8c8730d7..80a2cc6ba4 100644 --- a/packages/core/src/serialization/step.ts +++ b/packages/core/src/serialization/step.ts @@ -32,7 +32,7 @@ export async function serialize( SerializationFormat.DEVALUE_V1, payload ) as Uint8Array; - // Compress before encrypting — encrypted bytes don't compress. + // Compress before encrypting, since encrypted bytes don't compress. const compressed = await compress( prefixed, options?.compression === true, diff --git a/packages/core/src/serialization/types.ts b/packages/core/src/serialization/types.ts index 617c4cd205..d1c7511aee 100644 --- a/packages/core/src/serialization/types.ts +++ b/packages/core/src/serialization/types.ts @@ -9,7 +9,7 @@ import type { RuntimeDecryptionErrorContext } from '@workflow/errors'; /** * A format prefix is exactly 4 lowercase alphanumeric characters [a-z0-9]. * - * This is a branded string type — use `isFormatPrefix()` to validate + * This is a branded string type: use `isFormatPrefix()` to validate * at runtime. The `SerializationFormat` object provides well-known * constants, but codecs may define additional prefixes. */ @@ -33,7 +33,7 @@ export const SerializationFormat = { /** Encrypted payload (inner payload has its own format prefix) */ ENCRYPTED: 'encr' as FormatPrefix, /** - * Sealed payload — asymmetrically encrypted to a run's X25519 public key + * Sealed payload: asymmetrically encrypted to a run's X25519 public key * (inner payload has its own format prefix). * * Used for *cross-run* writes (hook payloads, forwarded stream frames), @@ -55,7 +55,7 @@ export const SerializationFormat = { * `ReadableStream` ref's `framing` field. * * - absent / `'raw'`: chunks are written to the transport verbatim - * (legacy format — no auto-reconnect support). + * (legacy format, no auto-reconnect support). * - `'framed-v1'`: each chunk is wrapped in a 4-byte big-endian length * prefix, allowing the reader to identify chunk boundaries and * transparently reconnect on transient stream errors. @@ -109,7 +109,7 @@ export interface SerializableSpecial { * * Only meaningful when `type === 'bytes'`. Absent on object streams * (which always use length-prefixed devalue framing) and on legacy - * byte streams written by SDKs that predate framing support — those + * byte streams written by SDKs that predate framing support; those * are interpreted as `'raw'` by the consumer. */ framing?: ByteStreamFraming; @@ -219,7 +219,7 @@ export interface SerializableSpecial { * The owning run's X25519 public key (base64), when it has one. * * Lets the receiving run seal frames to the stream's owner with no - * lookup at all — neither a run fetch nor a key-API round trip. The + * lookup at all: neither a run fetch nor a key-API round trip. The * owner derives this locally when it creates the stream, so including * it here is free. Absent for runs created by older SDKs, in which * case the receiver falls back to resolving the owner's symmetric key. diff --git a/packages/core/src/serialization/workflow-vm.ts b/packages/core/src/serialization/workflow-vm.ts index 7ebf7c938f..9d92d6d36a 100644 --- a/packages/core/src/serialization/workflow-vm.ts +++ b/packages/core/src/serialization/workflow-vm.ts @@ -9,7 +9,7 @@ * node:util), which is also what made it bundleable into the VM before the * serde moved host-side. * - * Produces and consumes the same wire format as the Node.js workflow.ts — + * Produces and consumes the same wire format as the Node.js workflow.ts: * format-prefixed devalue data ("devl" + devalue.stringify output). */ diff --git a/packages/core/src/set-attributes.ts b/packages/core/src/set-attributes.ts index e1d1c2cb0d..4986732234 100644 --- a/packages/core/src/set-attributes.ts +++ b/packages/core/src/set-attributes.ts @@ -42,7 +42,7 @@ export async function setAttributes( try { await store.runReadyBarrier; } catch { - // intentional: ordering barrier only — see above. + // intentional: ordering barrier only, see above. } } diff --git a/packages/core/src/source-map.ts b/packages/core/src/source-map.ts index 4167996104..83dec355c8 100644 --- a/packages/core/src/source-map.ts +++ b/packages/core/src/source-map.ts @@ -10,7 +10,7 @@ const INLINE_SOURCE_MAP_MARKER = * present. * * Use this on the host side before evaluating workflow bundles inside - * the QuickJS VM — the inline map can account for several MB of bundle + * the QuickJS VM: the inline map can account for several MB of bundle * text (measured ~30%+ of VM heap bytes on the example workbench's * bundle), and the VM never needs it; only host-side `remapErrorStack` * reads the map (and it can do so against the original, unstripped @@ -107,7 +107,7 @@ function extractInlineSourceMapBase64(source: string): string | undefined { * Keyed by the bundle `code`. Insertion-ordered LRU capped at `MAX_TRACERS`, * mirroring `vm/script-cache.ts`: production serves a single build-time bundle * literal for the process lifetime, while dev/watch produces a new bundle - * string per edit — the bound keeps the few most-recent ones and evicts the + * string per edit; the bound keeps the few most-recent ones and evicts the * rest instead of pinning every historical version. */ const tracerCache = new Map(); @@ -131,7 +131,7 @@ function getTraceMapForCode(workflowCode: string): TraceMap | null { // Use TraceMap (pure JS, no WASM required) tracer = new TraceMap(sourceMapData); } catch { - // Malformed inline map — treat as absent so we don't retry parsing it. + // Malformed inline map: treat as absent so we don't retry parsing it. tracer = null; } } diff --git a/packages/core/src/step.ts b/packages/core/src/step.ts index 1fcc4c11df..8b1538ce53 100644 --- a/packages/core/src/step.ts +++ b/packages/core/src/step.ts @@ -128,7 +128,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { } if (event.eventType === 'step_started') { - // Step was started but is not terminal — it stays in the + // Step was started but is not terminal, so it stays in the // invocationQueue so the suspension handler can decide how to // dispatch it. Record the inline-ownership state from the event: // the LATEST start wins, so a stamped start (inline execution or @@ -153,7 +153,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { } if (event.eventType === 'step_retrying') { - // Step is being retried — consume the event and wait for the next + // Step is being retried: consume the event and wait for the next // step_started. From here on the step is queue-owned (the delayed // retry handoff message, or the replay requeue), so inline // ownership is permanently lapsed for this correlation ID. @@ -176,7 +176,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // The rejection is as branch-deciding as a success: it decides // whether a `try`/`catch` continuation runs, and therefore which // ULIDs the follow-up `useStep` calls draw. So it is ordered by - // event-log position exactly like `step_completed` below — see there + // event-log position exactly like `step_completed` below; see there // for why the deferral is captured here, at event-consumption time, // and awaited off the serial queue. const eventIndex = ctx.eventsConsumer.eventIndex; @@ -254,7 +254,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // workflow awaiting this result on one branch and a `sleep()` or // hook payload on another would therefore allocate its follow-up // step ULIDs in a different order on a warm replay than the - // invocation that WROTE those `step_created` events did — a + // invocation that WROTE those `step_created` events did, which is a // permanent `ReplayDivergenceError`. So the result is ordered by // event-log position through the delivery-barrier registry (see // `ctx.pendingDeliveryBarriers`): @@ -269,7 +269,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // inside the queue slot. Two reasons, both load-bearing: // - Determinism: the set of earlier deliveries is then a function of // log position alone. Captured later it would depend on how much - // hydration the earlier deliveries had already finished — the very + // hydration the earlier deliveries had already finished, the exact // coupling this barrier exists to remove. // - Coverage: an earlier delivery whose own hydration slot runs // first on this serial queue has usually already resolved (and so @@ -370,7 +370,7 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // Store the closure variables function for serialization. Mark it so the // step-function reducer can tell a function that came through `useStep` - // apart from one workflow code assigned over the property afterwards — + // apart from one workflow code assigned over the property afterwards: // the reducer has to invoke whatever is there, and only the latter is // worth reporting. See `markUseStepClosureFn` for the limits of what // this proves. @@ -388,20 +388,20 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { // metadata that `getStepFunctionReducer` relies on for serialization. // Without this override, `Function.prototype.bind` would return a new // function that doesn't inherit `stepId`, `__closureVarsFn`, or any - // other own properties of the original proxy — so the StepFunction + // other own properties of the original proxy, so the StepFunction // reducer would refuse to serialize it (it'd look like a plain // function), and a `useStep(...).bind(this)` proxy that flowed // through workflow serialization would silently break. // // The override stashes three pieces of state on the bound function so // the round trip is faithful: - // - `stepId` — already set on the original proxy. - // - `__closureVarsFn` — only when the original proxy had one. - // - `__boundThis` — the receiver passed to `.bind(thisArg, …)`. + // - `stepId`: already set on the original proxy. + // - `__closureVarsFn`: only when the original proxy had one. + // - `__boundThis`: the receiver passed to `.bind(thisArg, …)`. // Always set (even when `thisArg` is // `null`/`undefined`) so the reducer can // distinguish "was bound" from "wasn't". - // - `__boundArgs` — only when the user supplied prefilled + // - `__boundArgs`: only when the user supplied prefilled // arguments (`.bind(thisArg, x, y)`). The // SWC plugin only ever emits `.bind(this)` // today, so this is rare in practice; we diff --git a/packages/core/src/step/context-storage.ts b/packages/core/src/step/context-storage.ts index 91f41e099e..c6a33c28d5 100644 --- a/packages/core/src/step/context-storage.ts +++ b/packages/core/src/step/context-storage.ts @@ -10,7 +10,7 @@ import type { StepMetadata } from './get-step-metadata.js'; * Holds the user-facing `WritableStream` and the shared `FlushableStreamState` * driving the background pipe to the workflow server. Re-used so repeat calls * to `getWritable()` within the same step return the same handle instead of - * spawning racing pipes — see https://github.com/vercel/workflow/issues/2058. + * spawning racing pipes; see https://github.com/vercel/workflow/issues/2058. */ export interface CachedWritable { writable: WritableStream; @@ -37,14 +37,14 @@ export type StepContext = { * The canonical case is a step-initiated `AbortController.abort()`: the * durable `hook_received` event records the cancellation in the workflow's * event log. If it is flushed in the background (like `ops`), the workflow - * continuation enqueued by `step_completed` can run — and advance past the - * abort, dispatching a later step with a stale, non-aborted `signal` — before + * continuation enqueued by `step_completed` can run (and advance past the + * abort, dispatching a later step with a stale, non-aborted `signal`) before * the `hook_received` event exists. Awaiting these inline before completion * guarantees the abort is ordered ahead of any continuation that observes the * step's result. Unlike these, `ops` holds best-effort real-time stream * writes that should fire ASAP and are intentionally left in the background. * - * Contract: producers MUST NOT push a promise that can reject — these are + * Contract: producers MUST NOT push a promise that can reject; these are * awaited only to enforce ordering, never to surface an outcome. A rejection * here would propagate as an infra error (queue re-delivery), not the * user-code failure path, so each producer swallows its own errors (see @@ -62,7 +62,7 @@ export type StepContext = { /** * Turbo mode only: a promise that resolves once the backgrounded * `run_started` has landed (the run exists). Set when the step body runs - * optimistically — before `run_started`/`step_started` are confirmed — so a + * optimistically (before `run_started`/`step_started` are confirmed), so a * direct step-body world write (e.g. `setAttributes`, which * resolves to a host-side `attr_set` create) can gate on it and never race * ahead of the run's creation. `undefined` outside turbo and on the await diff --git a/packages/core/src/step/writable-stream.ts b/packages/core/src/step/writable-stream.ts index f7c9d4eefc..b3bfd264ea 100644 --- a/packages/core/src/step/writable-stream.ts +++ b/packages/core/src/step/writable-stream.ts @@ -32,10 +32,10 @@ export interface WorkflowWritableStreamOptions { } /** - * Retrieves a writable stream that is associated with the current workflow. + * Retrieves the writable stream associated with the current workflow. * - * The writable stream is intended to be used within step functions to write - * data that can be read outside the workflow by using the readable method of getRun. + * Use the writable stream within step functions to write data. Read the data + * outside the workflow by using the readable method of `getRun`. * * @param options - Optional configuration for the writable stream * @returns The writable stream associated with the current workflow run @@ -59,8 +59,8 @@ export function getWritable( // Cache the writable per (runId, namespace) within the step context. // - // The previous behavior — constructing a fresh TransformStream and - // background pipe on every call — produced non-deterministic chunk + // The previous behavior (constructing a fresh TransformStream and + // background pipe on every call) produced non-deterministic chunk // ordering when callers acquired a new writer per write (e.g. a // per-chunk loop). Each pipe flushed to the same (runId, name) server // stream independently, and on Vercel the 50-100ms HTTP latency @@ -123,7 +123,7 @@ export function getWritable( // server stream. Calling `start(child, [args, theWritable])` from // the same step uses these tags to emit `{ name, runId }` in the // dehydrated descriptor, so the child's reviver can open the - // writable against the original `(runId, name)` directly — no + // writable against the original `(runId, name)` directly, with no // in-process bridge tied to this step's lifetime. Object.defineProperty(serialize.writable, STREAM_NAME_SYMBOL, { value: name, diff --git a/packages/core/src/symbols.ts b/packages/core/src/symbols.ts index 759e31ad82..c535b66faf 100644 --- a/packages/core/src/symbols.ts +++ b/packages/core/src/symbols.ts @@ -18,7 +18,7 @@ export const STREAM_FRAMING_SYMBOL = Symbol.for('WORKFLOW_STREAM_FRAMING'); * `start()`) sees both symbols on a writable, it includes the `runId` in * the descriptor it emits. The child run's step-side reviver then opens * a server writable against the original `(runId, name)` and resolves - * that run's encryption key directly — so the child's writes land on + * that run's encryption key directly, so the child's writes land on * the parent's stream as-is, with no client process in the loop. That * keeps the forwarding alive for the full lifetime of the child run, * not just for the parent step that initiated `start()`. @@ -43,7 +43,7 @@ export const STREAM_SERVER_DEPLOYMENT_ID_SYMBOL = Symbol.for( * when it creates the stream, so the key travels inside the serialized * descriptor; a child on another deployment can then seal frames immediately. * Without it the child would have to either fetch the owning run or fetch its - * symmetric key from the API — the round trip this whole mechanism exists to + * symmetric key from the API, the round trip this whole mechanism exists to * avoid. */ export const STREAM_SERVER_PUBLIC_KEY_SYMBOL = Symbol.for( diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index 1ae00dc7e9..b2a1102724 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -48,7 +48,7 @@ export function getWorkflowTraceMode(): WorkflowTraceMode { * Returns whether a serialized trace carrier is usable, i.e. present and * non-empty. `serializeTraceCarrier()` returns `{}` when no OTEL SDK is * registered or no span is active, and `start()` always attaches the - * carrier to the first queue message — so an empty carrier must be treated + * carrier to the first queue message, so an empty carrier must be treated * the same as an absent one wherever the trace-mode logic branches. */ export function isUsableTraceCarrier( @@ -61,7 +61,7 @@ export function isUsableTraceCarrier( * Returns the trace carrier to attach to messages the current invocation * enqueues. In `linked` mode the ORIGINAL run-origin carrier is forwarded * unchanged (when usable) so every future invocation links back to the same - * origin; otherwise — `continuous` mode, or no usable incoming carrier — + * origin; otherwise (`continuous` mode, or no usable incoming carrier) * the current (active) context is serialized, so the trace keeps chaining * (continuous) or the first instrumented invocation becomes the de-facto * origin (linked). @@ -81,7 +81,7 @@ export function getNextTraceCarrier( * * - In `linked` mode the invocation span is a CHILD of the local delivery * (flow-route) context, so the only link is to the run-origin context - * from the message's trace carrier — connecting this bounded per-invocation + * from the message's trace carrier, connecting this bounded per-invocation * trace back to where the run was started. The run-origin context is a * link, never a parent, and re-enqueues forward the original carrier * unchanged, so the whole run is never stitched into one giant trace. @@ -157,7 +157,7 @@ const OtelApi = once(async () => { // is intentional: esbuild-bundled targets (the CLI's // `vercel-build-output-api` build, Nitro, Astro) ship a self-contained // bundle with no node_modules, so the package must be *inlined* at build - // time for spans to work at runtime — a runtime-built specifier is opaque to + // time for spans to work at runtime; a runtime-built specifier is opaque to // esbuild and would silently disable tracing there. Bundlers that reject an // unresolvable static `import()` when the peer isn't installed (Rollup/Vite, // e.g. SvelteKit) instead externalize `@opentelemetry/api` in the workflow diff --git a/packages/core/src/telemetry/semantic-conventions.ts b/packages/core/src/telemetry/semantic-conventions.ts index dc72844cb0..9695d1a952 100644 --- a/packages/core/src/telemetry/semantic-conventions.ts +++ b/packages/core/src/telemetry/semantic-conventions.ts @@ -3,7 +3,7 @@ * * This module provides standardized telemetry attributes following OpenTelemetry semantic conventions * for instrumenting workflow execution, step processing, and related operations. Each exported function - * creates a properly formatted attribute object that can be used with OpenTelemetry spans. + * creates a properly formatted attribute object for use with OpenTelemetry spans. * * The semantic conventions are organized into several categories: * - **Workflow attributes**: Track workflow lifecycle, status, and metadata @@ -11,7 +11,7 @@ * - **Queue attributes**: Instrument message queue operations * - **Deployment attributes**: Capture deployment environment information * - * All attribute functions are type-safe and leverage existing backend types to ensure + * All attribute functions are type-safe and use existing backend types to ensure * consistency between telemetry data and actual system state. * * @example @@ -199,7 +199,7 @@ export const WorkflowWaitsCreated = SemanticConvention( /** * Number of steps this suspension finalized as failed because their * arguments refused to serialize (step_created placeholder + step_failed - * carrying the SerializationError — see finalizeUnserializableStep). + * carrying the SerializationError; see finalizeUnserializableStep). */ export const WorkflowStepsFailedSerialization = SemanticConvention( 'workflow.steps.failed_serialization' @@ -208,7 +208,7 @@ export const WorkflowStepsFailedSerialization = SemanticConvention( /** * Number of inline-owned steps this invocation re-executed because it is a * redelivery of their owning queue message (crash recovery for inline - * steps — see the inline step ownership changelog, workflow#2780). + * steps; see the inline step ownership changelog, workflow#2780). */ export const WorkflowOwnedRecoverySteps = SemanticConvention( 'workflow.inline_ownership.owned_recovery_steps' @@ -251,7 +251,7 @@ export const WorkflowRouteModuleBodyInitMs = SemanticConvention( ); /** - * Compute instance handling this route — the synthesized `COMPUTE_INSTANCE_ID`. + * Compute instance handling this route: the synthesized `COMPUTE_INSTANCE_ID`. * Uses OTEL `faas.instance` (execution-environment id, reused across * invocations to the same function): * https://opentelemetry.io/docs/specs/semconv/attributes-registry/faas/ @@ -308,11 +308,11 @@ export const StepRsfsMs = SemanticConvention('step.rsfs_ms'); /** * Client-measured synchronous workflow-function replay duration in * milliseconds, excluding awaited network I/O, of only the FINAL replay pass - * within the rsfs window — the pass that reached and scheduled the first + * within the rsfs window: the pass that reached and scheduled the first * step. Not accumulated across earlier pre-first-step passes (e.g. a * workflow-body `setAttributes()` detour replays more than once, and a * redelivery omits earlier invocations' work entirely), so this must not be - * read as "the replay portion of rsfs" — step.rsfs_ms covers the whole + * read as "the replay portion of rsfs"; step.rsfs_ms covers the whole * window. Only present alongside step.rsfs_ms and only for the run's first * step (see runtime/step-latency.ts). */ @@ -438,13 +438,13 @@ export const HookResilientResume = SemanticConvention( /** * Consumer-side signal (on the workflow execution span) that this replay * materialized the `hook_received` event from the queue message's `hookInput` - * because the producer's direct write had not landed — the completion of the + * because the producer's direct write had not landed, which completes the * recovery path {@link HookResilientResume} began. * * Legacy / non-atomic re-ensure signal only. Atomic lazy resumes * (resumeId + digest) go through the hoisted preload write instead, whose * response cannot tell whether the producer or the consumer won the - * `(runId, resumeId)` claim — so this attribute is deliberately NOT emitted + * `(runId, resumeId)` claim, so this attribute is deliberately NOT emitted * for them (emitting `true` unconditionally would count every producer-won * resume as a recovery). The producer-begin ({@link HookResilientResume}) / * consumer-materialized pairing is therefore no longer complete for atomic @@ -458,10 +458,10 @@ export const HookResilientResumeMaterialized = SemanticConvention( * Consumer-side signal (on the workflow execution span) of how a lazy hook * resume initialized its replay state: * - * - `hook_received_stream` — the hoisted `hook_received` write returned a + * - `hook_received_stream`: the hoisted `hook_received` write returned a * usable replay preload (run + complete event log), so the invocation * skipped both the `run_started` write and the initial `events.list`. - * - `hook_received_fallback` — the hoisted write succeeded but returned no + * - `hook_received_fallback`: the hoisted write succeeded but returned no * usable preload (a CBOR response from an older server, a World that * ignored the opt-in, a bounded `hasMore` page, or a preload that failed * validation); the invocation fell back to the `run_started` setup without @@ -472,8 +472,8 @@ export const HookResilientResumeMaterialized = SemanticConvention( * * This is a latency/setup-path signal: it says which requests initialized * the invocation, NOT that this consumer created the `hook_received` event - * (the hoisted write may equally have converged on the producer's — claim - * ownership is not observable client-side; cf. + * (the hoisted write may equally have converged on the producer's, since + * claim ownership is not observable client-side; cf. * {@link HookResilientResumeMaterialized}). */ export const HookResumeSetupSource = SemanticConvention( @@ -493,7 +493,7 @@ export const StepResilientDispatchRecovered = SemanticConvention( /** * Consumer-side signal (on the workflow execution span) that this delivery * materialized the `step_created` event from the queue message's `stepInput` - * because the producer's direct write had not landed — the completion of the + * because the producer's direct write had not landed, which completes the * recovery path {@link StepResilientDispatchRecovered} began. */ export const StepResilientDispatchMaterialized = SemanticConvention( @@ -506,7 +506,7 @@ export const StepResilientDispatchMaterialized = SemanticConvention( // hook resume, and only there: one resumption produces exactly one sample, so // a later step in the same invocation, a retry, or a redelivery never // re-reports it. The phases are non-overlapping and sum exactly to -// {@link ResumeTotalMs} — see runtime/resume-latency.ts for the boundary +// {@link ResumeTotalMs}; see runtime/resume-latency.ts for the boundary // definitions and the emission gate. // // Deliberately carries no `resumeId`, run ID, or token: the metric is meant to diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index a747075ac9..d3fe0312ae 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -23,7 +23,7 @@ const DIGEST_ALGORITHMS: Record = { // WebCrypto BufferSource conversion. The `util.types` brand checks work // across vm realms, and node:crypto's `hash.update()` reads view ranges from // internal slots (own properties shadowing `byteOffset`/`byteLength` are -// ignored) — matching WebCrypto, which also reads internal slots. +// ignored), matching WebCrypto, which also reads internal slots. function toDigestInput( data: ArrayBuffer | ArrayBufferView ): NodeJS.ArrayBufferView { @@ -108,7 +108,7 @@ export function createContext(options: CreateContextOptions) { // - Dynamic `import()` settles within a microtask (rejected: no // `importModuleDynamically`), so it needs no handling. // - All `crypto.subtle` methods except the deterministic `digest` override - // below throw explicitly (see the subtle proxy) — binding them to the + // below throw explicitly (see the subtle proxy); binding them to the // host subtle would hand workflows a host-timing promise. const intrinsics = g as unknown as Record>; delete intrinsics.Atomics.waitAsync; @@ -121,7 +121,7 @@ export function createContext(options: CreateContextOptions) { // `crypto.subtle.digest` computes synchronously via node:crypto, so its // promise settles on a deterministic microtask instead of host threadpool - // timing — a digest can never advance a suspended workflow, and + // timing: a digest can never advance a suspended workflow, and // digest-using VMs stay retainable. Values are byte-identical to WebCrypto. const digest = async ( algorithm: string | { name: string }, diff --git a/packages/core/src/vm/script-cache.ts b/packages/core/src/vm/script-cache.ts index d23bbc1624..a0f994da2b 100644 --- a/packages/core/src/vm/script-cache.ts +++ b/packages/core/src/vm/script-cache.ts @@ -11,17 +11,17 @@ import { type Context, Script } from 'node:vm'; * contains every workflow function in the app and registers them on * `globalThis.__private_workflows`. Previously each replay called * `vm.runInContext(workflowCode, context, { filename })`, which RE-PARSES and - * RE-COMPILES the entire bundle every time — O(N) full re-parses for a + * RE-COMPILES the entire bundle every time: O(N) full re-parses for a * sequential workflow of N steps, plus the same parse cost repeated across * every invocation in the process. * * Compilation is a pure function of `(code, filename)`: a `vm.Script` carries - * no realm/context state — it is only bound to a context at `runInContext` + * no realm/context state; it is only bound to a context at `runInContext` * time. So a single compiled `Script` can be reused across replays AND across * workflow invocations in the same process without affecting determinism: the * produced workflow function is identical to the previous re-parse-every-time * behaviour, with identical `filename` source attribution (see the precise - * claim — and its one caveat — in `runWorkflow`). + * claim, and its one caveat, in `runWorkflow`). * * Keying * ------ @@ -30,7 +30,7 @@ import { type Context, Script } from 'node:vm'; * attribution and surfaces in stack traces, where `remapErrorStack` keys on it * to map frames back to the user's source. Two workflows in the same bundle * share the same `code` but have different `filename`s, so they intentionally - * compile to distinct `Script`s — collapsing them onto a single shared `Script` + * compile to distinct `Script`s; collapsing them onto a single shared `Script` * would misattribute one workflow's stack frames to another file. The cost of * keeping them distinct is that the whole bundle is compiled once per distinct * `filename` (not once per bundle); in practice that is bounded by the number @@ -50,7 +50,7 @@ import { type Context, Script } from 'node:vm'; * (skew protection runs old versions as separate processes), so there is a * single `code` key for the process lifetime. The bound exists for dev/watch * mode, where the dev route re-reads `workflowCode` from disk and re-invokes - * the entrypoint on every edit — each edit produces a NEW bundle string, which + * the entrypoint on every edit: each edit produces a NEW bundle string, which * without a bound would pin every historical version forever (~0.8MB per edit, * growing monotonically with edit count). The dev path only ever needs the * latest bundle, so an LRU that keeps the few most-recent bundles and evicts @@ -66,7 +66,7 @@ const scriptCache = new Map>(); * Max number of distinct bundle (`code`) versions to retain. One is enough for * production; a handful covers pathological dev hot-reload / repeated-rebuild * churn within a single long-lived process (e.g. a watch session or a test - * file) without unbounded growth. Kept deliberately small — there is no value + * file) without unbounded growth. Kept deliberately small: there is no value * in retaining stale bundles, only a memory cost. */ const MAX_BUNDLES = 8; diff --git a/packages/core/src/vm/uint8array-base64.ts b/packages/core/src/vm/uint8array-base64.ts index 3d4c1f2252..952878fc7a 100644 --- a/packages/core/src/vm/uint8array-base64.ts +++ b/packages/core/src/vm/uint8array-base64.ts @@ -13,7 +13,7 @@ */ // Local type definitions for the polyfilled methods. These are intentionally -// NOT `declare global` to avoid leaking types to host-side code — the polyfill +// NOT `declare global` to avoid leaking types to host-side code; the polyfill // is only installed inside the workflow VM context. interface Uint8ArrayWithBase64 extends Uint8Array { toBase64(options?: { alphabet?: string; omitPadding?: boolean }): string; diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 5e71b3926d..6771faaa8d 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -84,7 +84,7 @@ async function drainPendingQueueItems( if (pendingQueue.size === 0) return; // Implicitly dispose any abort hooks (system hooks) that are still alive at // workflow completion so they don't leak rows in the hooks table for the - // run's lifetime. Skip hooks that already have an abort in flight — those + // run's lifetime. Skip hooks that already have an abort in flight; those // will emit hook_received via the abort processing path. User hooks // (isSystem !== true) are intentionally left alone: their lifetime is // managed by the user's code, not the runtime. @@ -164,7 +164,7 @@ export type WorkflowResult = }; /** - * `resume` can additionally decline — `{ type: 'replay' }` means "this + * `resume` can additionally decline: `{ type: 'replay' }` means "this * session is unusable, cold-replay instead". A fresh replay never declines. */ export type WorkflowResumeResult = WorkflowResult | { readonly type: 'replay' }; @@ -248,7 +248,7 @@ function recordResult( // a suspension: an out-of-band delivery that landed ahead of the code that // reads it waits for the pass that reaches that code, and failing here // would fail exactly the runs that tolerance exists for. The case that is - // not ordinary — the same event still held pass after pass — is a shape + // not ordinary (the same event still held pass after pass) is a shape // across these spans, which is why the eventId is on each one and no pass // tries to rule on it alone. if (result.parked) { @@ -265,7 +265,7 @@ function recordResult( /** * Single-shot replay: execute the workflow over `events` and either return * its output or throw its suspension. Kept for the extensive existing test - * suites — production code goes through `replayWorkflow`/`resumeWorkflow`. + * suites; production code goes through `replayWorkflow`/`resumeWorkflow`. */ export async function runWorkflow( workflowCode: string, @@ -376,7 +376,7 @@ async function createWorkflowSession({ } case 'suspended': // Same-boundary duplicates were staled by the generation bump above, - // so anything landing here is out-of-band — an unguarded sleep/hook/ + // so anything landing here is out-of-band: an unguarded sleep/hook/ // attribute signal or a divergence. Those boundaries are unretainable // (the runtime demotes them too), so fall back to replay. state = { type: 'replay' }; @@ -393,11 +393,11 @@ async function createWorkflowSession({ // delivery and a later server-backed replay, so use fixedTimestamp. // The draw counter is the progress metric for `quiesceEarlierCascades` // (WORKFLOW_LOG_ORDER_DRAWS): a quiet turn is one that drew nothing. It - // counts EVERY draw from this sequence — this same function is installed as + // counts EVERY draw from this sequence. This same function is installed as // the `STABLE_ULID` global below, which serialization draws stream ids - // from during dehydration — deliberately: quiescence must also wait out - // serialization-driven draws, and counting extra draws only extends the - // wait (see the termination note on `quiesceEarlierCascades`). + // from during dehydration. This is deliberate because quiescence must also + // wait out serialization-driven draws, and counting extra draws only extends + // the wait (see the termination note on `quiesceEarlierCascades`). let mintCount = 0; const generateUlid = () => { mintCount += 1; @@ -1122,7 +1122,7 @@ async function createWorkflowSession({ // Control-flow signals are handled by the runtime and do not mean the // workflow has terminally failed. `onWorkflowError` usually already moved // the state machine, but a divergence can also arrive via a step - // promise's direct rejection (bypassing `onWorkflowError`) — demote so + // promise's direct rejection (bypassing `onWorkflowError`); demote so // every control-flow path converges on `replay` and a later resume falls // back instead of throwing. if (WorkflowSuspension.is(error) || ReplayDivergenceError.is(error)) { diff --git a/packages/core/src/workflow/abort-controller.ts b/packages/core/src/workflow/abort-controller.ts index 4d5d9a08af..d34e5ee243 100644 --- a/packages/core/src/workflow/abort-controller.ts +++ b/packages/core/src/workflow/abort-controller.ts @@ -49,7 +49,7 @@ export class WorkflowAbortSignal { /** * @internal Sets aborted state and fires listeners. * Called by abort() on first-run, or by the events consumer on replay. - * Idempotent — second call is a no-op. + * Idempotent: second call is a no-op. */ _setAborted(reason?: unknown): void { if (this.aborted) return; @@ -172,20 +172,20 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) { // The abort was recorded in the event log (from a previous run's // abort() call, or from a step/external abort). Update signal // state and fire listeners at this deterministic point in the - // promiseQueue — same ordering as hook payload delivery. + // promiseQueue, the same ordering as hook payload delivery. // // The payload is the dehydrated form written by the suspension // handler (a Uint8Array, possibly encrypted). Hydrate it via the // same machinery as regular hook payloads (workflow/hook.ts:117) // so the reason round-trips with full type fidelity. Reading the - // raw payload here is a bug — it's not a plain object after + // raw payload here is a bug: it's not a plain object after // dehydration, so `'reason' in payload` is false and reason // ends up undefined on replay. const rawPayload = event.eventData?.payload; // An abort is a branch-deciding delivery: `_setAborted` fires the // signal's listeners, and a listener is free to invoke a step and // draw a ULID. So it registers in the delivery-barrier registry as a - // 'hook' — which is exactly what the event is — so that wait, hook + // 'hook' (which is exactly what the event is) so that wait, hook // and step deliveries order against it by event-log position rather // than by whose hydration finished first. It is always ARMED: unlike // a buffered user hook payload, nothing about its delivery waits on @@ -194,7 +194,7 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) { // Resolving straight off the queue slot was sufficient only while // every other delivery also resolved from its slot. Step results no // longer do (see step.ts), so an abort whose slot ran while a - // log-earlier step sat behind a barrier would overtake it — see + // log-earlier step sat behind a barrier would overtake it; see // `delivery-barrier-coverage.test.ts`. // // The deferral is captured HERE, at event-consumption time, for the @@ -212,8 +212,8 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) { // results (step.ts) and hook payloads (workflow/hook.ts) do. The // suspension handler dehydrates queued step arguments only once // `scheduleWhenIdle` observes `pendingDeliveries === 0`. Without - // this counter, a step dispatched right after the abort — e.g. one - // that receives `controller.signal` — can have its arguments + // this counter, a step dispatched right after the abort (e.g. one + // that receives `controller.signal`) can have its arguments // serialized while the abort is still in flight behind // `await hydrateStepReturnValue`, capturing `signal.aborted === false` // (and a missing reason). Bumping the counter holds the suspension @@ -222,7 +222,7 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) { // // It is released inside the slot, before the detached deferral, so // `scheduleWhenIdle` can still reach idle and retire the barriers - // that deferral may be waiting on — the same shape as the hook and + // that deferral may be waiting on, the same shape as the hook and // step paths. ctx.pendingDeliveries++; ctx.promiseQueue = ctx.promiseQueue.then(async () => { @@ -253,7 +253,7 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) { } } catch { // Best-effort: if hydration fails, fall back to undefined - // reason. The signal still aborts; the user just won't see + // reason. The signal still aborts; the user won't see // the original reason. Matches WorkflowAbortSignal's spec // fallback (DOMException AbortError). } @@ -262,7 +262,7 @@ export function createCreateAbortController(ctx: WorkflowOrchestratorContext) { ctx.pendingDeliveries--; } // Detached, like the other deliveries: `awaitEarlierDeliveries` - // may be waiting on a delivery this very queue drives, and + // may be waiting on a delivery this queue itself drives, and // blocking a slot on that would deadlock the queue. void earlierDelivered.then(() => { barrier.markDelivered(); @@ -351,7 +351,7 @@ export function createAbortSignalStatics(): { } } - // Listen to each signal — first one to abort wins. Track listeners so + // Listen to each signal; first one to abort wins. Track listeners so // we can remove them after the composite aborts; otherwise the closures // (capturing `composite`) prevent GC for any input signal that outlives // the composite (e.g. a long-lived external controller). diff --git a/packages/core/src/workflow/create-hook.ts b/packages/core/src/workflow/create-hook.ts index bc773c67d2..0673140d40 100644 --- a/packages/core/src/workflow/create-hook.ts +++ b/packages/core/src/workflow/create-hook.ts @@ -26,7 +26,7 @@ import { getWorkflowMetadata } from './get-workflow-metadata.js'; // executes inside the VM, so `Run` here is the plugin-compiled variant // whose methods are durable step proxies. No environment guard is // needed: the registry is keyed per-global, so a stray host-side import -// of this module registers the host's `Run` on the host's registry — +// of this module registers the host's `Run` on the host's registry, // which is the correct class for that context. // // The value import of `Run` also guarantees `runtime/run.js` is included diff --git a/packages/core/src/workflow/get-workflow-metadata.ts b/packages/core/src/workflow/get-workflow-metadata.ts index b4c08949ae..40dc340e5e 100644 --- a/packages/core/src/workflow/get-workflow-metadata.ts +++ b/packages/core/src/workflow/get-workflow-metadata.ts @@ -43,7 +43,7 @@ export function getWorkflowMetadata(): WorkflowMetadata { // behind a symbol. const ctx = (globalThis as any)[WORKFLOW_CONTEXT_SYMBOL] as WorkflowMetadata; if (!ctx) { - // Use the shared `NotInWorkflowOrStepContextError` — it lives in + // Use the shared `NotInWorkflowOrStepContextError`: it lives in // `context-violation-error.ts` specifically so this file can throw it // without creating a module-init cycle (the full `context-errors.ts` // depends on this file's `WORKFLOW_CONTEXT_SYMBOL`). diff --git a/packages/core/src/workflow/hook.ts b/packages/core/src/workflow/hook.ts index bcb714d89d..3cc7c1986b 100644 --- a/packages/core/src/workflow/hook.ts +++ b/packages/core/src/workflow/hook.ts @@ -29,11 +29,11 @@ import { hydrateStepReturnValue } from '../serialization.js'; * token, for resolution through `hook.getConflict()`. * * The instance is created through the serialization class registry on - * the VM's globalThis — the same channel that revives serialized `Run` + * the VM's globalThis, the same channel that revives serialized `Run` * instances (e.g. `start()` return values crossing from a step into the * workflow). The registered class is the VM bundle's plugin-compiled - * `Run`, whose methods are durable step proxies — safe to call from - * workflow code — and construction goes through its + * `Run`, whose methods are durable step proxies (safe to call from + * workflow code), and construction goes through its * `WORKFLOW_DESERIALIZE` hook, exactly as the `Instance` reviver would. * * Returns `null` when a real `Run` cannot be constructed: the conflict @@ -69,7 +69,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // (or `undefined`/`null`) to get a generated one, or be an // explicit non-empty string. An empty string is almost always an // accidental value (e.g. an unset variable) and would otherwise slip - // through the `??` below — which only falls back for nullish values — and + // through the `??` below (which only falls back for nullish values) and // be used as a meaningless, non-deterministic token. if (options.token === '') { throw new Error( @@ -148,7 +148,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // Lazy-resume dedup: `resumeHook()` mints a `resumeId` per resume // attempt and stamps it on the `hook_received` event. When the direct // event write fails transiently, the runtime materializes the event from - // the queue payload instead — and because `hook_received` has no + // the queue payload instead, and because `hook_received` has no // storage-level uniqueness constraint, concurrent redelivery of the same // queue message can commit that materialization twice. Two rows for ONE // resume attempt then share a `resumeId` (distinct resume attempts never @@ -156,7 +156,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // pure function of the persisted event log, keeping replay deterministic. // // Scope: this is defense-in-depth over the persisted log, not a - // cross-invocation exactly-once guarantee — an invocation replaying a + // cross-invocation exactly-once guarantee: an invocation replaying a // snapshot taken before the duplicate row committed only sees one row, // so two CONCURRENT invocations can each deliver from their own // snapshot. Every replay from a log containing both rows (i.e. all @@ -282,7 +282,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { if (event.eventType === 'hook_received') { // Drop duplicate deliveries of the same resume attempt (same - // `resumeId` — see `seenResumeIds` above). Events without a + // `resumeId`; see `seenResumeIds` above). Events without a // `resumeId` (older SDKs, legacy spec versions) are never deduped. // // Dedup off the top-level `resumeId` the backend hoists onto the event @@ -302,7 +302,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // Register a 'hook' delivery barrier at this event's log index so a // later-in-log `wait_completed` or step result is delivered only after // this hook, and so this hook is delivered only after every - // earlier-in-log `wait_completed` and step result — keeping any + // earlier-in-log `wait_completed` and step result, keeping any // `Promise.race` (or concurrent-branch ULID allocation) deterministic // and aligned with the committed event log, regardless of // microtask-hop count, hydration time, or race-argument order. @@ -322,7 +322,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { if (hasWaitingConsumer) { // A consumer is already awaiting, so this payload's delivery is // pinned to this log position: capture the deferral HERE, while - // consuming the event, not at the end of the hydration slot below — + // consuming the event, not at the end of the hydration slot below: // same reasoning as step.ts. An earlier step or hook whose slot runs // first on this serial queue has usually delivered, and so // deregistered its barrier, before this slot ends. Read then, it @@ -332,7 +332,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // window is consumed before any slot runs, so capturing at // consumption time sees all of them. // - // The BUFFERED branch below deliberately does NOT do this — see the + // The BUFFERED branch below deliberately does NOT do this; see the // comment on `claim()`. const earlierDelivered = awaitEarlierDeliveries( ctx, @@ -387,7 +387,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // at this log position and park the OUTCOME (value or error) for a // later `iterator.next()` / `await hook` claim. We capture the // outcome rather than eagerly resolving/rejecting a promise no - // consumer has attached to — a rejected unclaimed promise (e.g. a + // consumer has attached to: a rejected unclaimed promise (e.g. a // buffered encrypted payload with no key) would otherwise surface // as an unhandled rejection and crash the process. `claim()` builds // the consumer-facing promise on demand. @@ -406,8 +406,8 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // claim time, rather than when the event was consumed. A buffered // payload's delivery genuinely happens when the workflow reads the // hook, which may be many deliveries later; a consumption-time - // snapshot would make the claim wait on — and pay the macrotask - // yield for — barriers that were relevant to a moment this payload + // snapshot would make the claim wait on (and pay the macrotask + // yield for) barriers that were relevant to a moment this payload // never participated in. That is not theoretical: it stalls the // second payload in the e2e `hookWithSleepWorkflow` long enough // for the run to suspend before delivering it. @@ -495,7 +495,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { // The payload was hydrated through a promiseQueue slot at its log // position (buffering branch above). `claim()` builds the // consumer-facing promise from that outcome, deferring behind any - // earlier-in-log wait or step and marking this hook delivered — so + // earlier-in-log wait or step and marking this hook delivered, so // resolution order stays anchored to the event log, not this later // claim site. return nextDelivery.claim(); @@ -565,7 +565,7 @@ export function createCreateHook(ctx: WorkflowOrchestratorContext) { } isDisposed = true; - // If the event log already contains hook_disposed, this is a replay — no-op + // If the event log already contains hook_disposed, this is a replay: no-op if (hasDisposedEvent) { return; } diff --git a/packages/core/src/workflow/set-attributes.ts b/packages/core/src/workflow/set-attributes.ts index 70a355373b..b06cd20c8e 100644 --- a/packages/core/src/workflow/set-attributes.ts +++ b/packages/core/src/workflow/set-attributes.ts @@ -41,7 +41,7 @@ export interface SetAttributesOptions { * framework/library code (telemetry, agent metadata, etc.). User code * trying to write a `$`-prefixed key throws `FatalError`. If you are a * framework author and need to set a reserved key, pass - * `{ allowReservedAttributes: true }` as the second argument — see + * `{ allowReservedAttributes: true }` as the second argument; see * `SetAttributesOptions` for the trade-offs. * * **WARNING**: Calling e.g. diff --git a/packages/core/src/workflow/sleep.ts b/packages/core/src/workflow/sleep.ts index c8848d0c3a..1b93fb081f 100644 --- a/packages/core/src/workflow/sleep.ts +++ b/packages/core/src/workflow/sleep.ts @@ -102,7 +102,7 @@ export function createSleep(ctx: WorkflowOrchestratorContext) { const eventIndex = ctx.eventsConsumer.eventIndex; const barrier = registerDeliveryBarrier(ctx, eventIndex, 'wait'); // The deferral is captured HERE, while consuming the event, and not - // after the queue tail below — same reasoning as step.ts. An earlier + // after the queue tail below: same reasoning as step.ts. An earlier // step or hook whose hydration slot sits in that tail has usually // delivered, and so deregistered its barrier, by the time the tail // resolves. Read then, it would be invisible and this wait would skip diff --git a/packages/core/src/workflow/world-init-stub.ts b/packages/core/src/workflow/world-init-stub.ts index 185fe03830..e0953ca78c 100644 --- a/packages/core/src/workflow/world-init-stub.ts +++ b/packages/core/src/workflow/world-init-stub.ts @@ -1,7 +1,7 @@ /** * VM/step-bundle stub for `@workflow/core/runtime/world-init`. * - * Resolved via the `workflow` export condition. Empty by design — the host + * Resolved via the `workflow` export condition. Empty by design: the host * is responsible for loading `world.ts` and populating the globalThis * world cache before any VM or step code executes. Including this module * (rather than letting the resolver fall through to the host file) keeps diff --git a/packages/docs-typecheck/src/docs-globals.d.ts b/packages/docs-typecheck/src/docs-globals.d.ts index 547f71af6a..cbfe15b692 100644 --- a/packages/docs-typecheck/src/docs-globals.d.ts +++ b/packages/docs-typecheck/src/docs-globals.d.ts @@ -266,7 +266,7 @@ declare global { queue: (...args: any[]) => Promise; createQueueHandler: (...args: any[]) => any; }; - /** Resolves the configured World (async — may perform dynamic import / env-based setup). */ + /** Resolves the configured World (async, since it may perform dynamic import / env-based setup). */ function getWorld(): Promise; const streamId: string; diff --git a/packages/docs-typecheck/src/type-checker.ts b/packages/docs-typecheck/src/type-checker.ts index 2cbe343f62..eea24f512d 100644 --- a/packages/docs-typecheck/src/type-checker.ts +++ b/packages/docs-typecheck/src/type-checker.ts @@ -136,7 +136,7 @@ const RESOLVED_MODULES = new Set(Object.keys(compilerOptions.paths ?? {})); /** * Returns true if a missing-module diagnostic refers to a module we don't * expect to resolve (relative imports, framework deps, app aliases, etc.). - * Returns false for modules in our paths mapping — those failures are real. + * Returns false for modules in our paths mapping; those failures are real. */ function isExpectedMissingModule(diagnostic: ts.Diagnostic): boolean { const msg = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); diff --git a/packages/errors/src/ansi.ts b/packages/errors/src/ansi.ts index c556b867a8..5d79bcd646 100644 --- a/packages/errors/src/ansi.ts +++ b/packages/errors/src/ansi.ts @@ -1,5 +1,5 @@ // Imported from a sibling module rather than `chalk` proper so this file -// (and everything that statically imports it — including the workflow-VM +// (and everything that statically imports it, including the workflow-VM // reachable `context-violation-error.ts`) doesn't pull in chalk's // `supports-color` / `require('os')` chain. See `./internal-chalk.ts` // for the full rationale and the test mock that swaps it out. @@ -38,25 +38,25 @@ const styles = { error: chalk.red, }; -/** A "help:" line — use for the primary suggested fix. */ +/** A "help:" line: use for the primary suggested fix. */ export function help(messages: string | string[]): string { const message = Array.isArray(messages) ? messages.join('\n') : messages; return styles.help(`${chalk.bold('help:')} ${message}`); } -/** A "hint:" line — use for supplementary context or suggestions. */ +/** A "hint:" line: use for supplementary context or suggestions. */ export function hint(messages: string | string[]): string { const message = Array.isArray(messages) ? messages.join('\n') : messages; return styles.info(`${chalk.bold('hint:')} ${message}`); } -/** A "note:" line — use for informational context. */ +/** A "note:" line: use for informational context. */ export function note(messages: string | string[]): string { const message = Array.isArray(messages) ? messages.join('\n') : messages; return styles.info(`${chalk.bold('note:')} ${message}`); } -/** A "docs:" line — use for a single documentation URL. */ +/** A "docs:" line: use for a single documentation URL. */ export function docs(url: string): string { return styles.info(`${chalk.bold('docs:')} ${url}`); } diff --git a/packages/errors/src/index.ts b/packages/errors/src/index.ts index bd841df150..a6ccd7e994 100644 --- a/packages/errors/src/index.ts +++ b/packages/errors/src/index.ts @@ -4,7 +4,7 @@ import type { StringValue } from 'ms'; // Note: `Ansi` helpers live under the `@workflow/errors/ansi` subpath so the // main entry point doesn't pull `chalk` (and its ESM machinery) into every -// consumer — most places that `import from '@workflow/errors'` only want the +// consumer; most places that `import from '@workflow/errors'` only want the // error classes and never render framed messages. const BASE_URL = 'https://workflow-sdk.dev/err'; @@ -26,7 +26,7 @@ function isError(value: unknown): value is { name: string; message: string } { /** * @internal - * Compose a framed-detail body for an error message — same `╰▶` / + * Compose a framed-detail body for an error message, using the same `╰▶` / * `├▶` box-drawing structure used by `ContextViolationError` (in * `@workflow/core`), so every error class with a hint or docs slug * renders consistently: @@ -35,11 +35,11 @@ function isError(value: unknown): value is { name: string; message: string } { * ├▶ hint: * ╰▶ docs: https://workflow-sdk.dev/err/ * - * Plain text only — no ANSI here, since `@workflow/errors`'s main entry + * Plain text only, no ANSI here, since `@workflow/errors`'s main entry * stays chalk-free. The runtime logger renders the same chars with * dim styling at log time. * - * Returns just `title` when there are no details to frame. Multi-line + * Returns only `title` when there are no details to frame. Multi-line * detail values are indented under their branch so the tree stays * readable. */ @@ -153,7 +153,7 @@ export class WorkflowError extends Error { * This is the catch-all error for world implementations. Specific, * well-known failure modes have dedicated error types (e.g. * EntityConflictError, RunExpiredError, ThrottleError). This error - * covers everything else — validation failures, missing entities + * covers everything else: validation failures, missing entities * without a dedicated type, or unexpected HTTP errors from world-vercel. */ export class WorkflowWorldError extends WorkflowError { @@ -383,7 +383,7 @@ export class MaxEventsExceededError extends WorkflowError { * failure without poking through stacks. */ export interface RuntimeDecryptionErrorContext { - /** The operation that failed — useful to tell encrypt vs decrypt apart. */ + /** The operation that failed, useful to tell encrypt vs decrypt apart. */ operation?: 'encrypt' | 'decrypt'; /** Byte length of the input payload at the time of the failure. */ byteLength?: number; @@ -400,7 +400,7 @@ export interface RuntimeDecryptionErrorContext { * Thrown when the SDK's built-in AES-GCM encryption layer fails to encrypt * or decrypt a workflow payload. * - * This is an internal SDK failure — user code never invokes the SDK's + * This is an internal SDK failure: user code never invokes the SDK's * encryption primitives directly. Common causes: * * - A ciphertext / auth tag mismatch, typically surfaced as the native Web @@ -451,11 +451,11 @@ interface WorkflowBuildErrorOptions extends ErrorOptions { * discovery, bundler integration) fails in a way the user can act on. * * This is distinct from `WorkflowRuntimeError` (which is raised at runtime - * by the workflow engine) — `WorkflowBuildError` fires during `pnpm build`, + * by the workflow engine): `WorkflowBuildError` fires during `pnpm build`, * `next build`, or equivalent, before any workflow has started executing. * * Prefer attaching a short, actionable `hint` (e.g. `run \`pnpm install workflow\``) - * as plain text — the rendering layer is responsible for any styling or + * as plain text; the rendering layer is responsible for any styling or * "hint:" label. Keeping `hint` plain keeps it useful in non-TTY contexts * (CI logs, structured error serialization) where ANSI escapes are noise. */ @@ -501,13 +501,13 @@ interface SerializationErrorOptions extends ErrorOptions { * returning from a step. * * Internal invariants (corrupted buffers, unknown format bytes) should use - * `WorkflowRuntimeError` instead — this class is scoped to things the user + * `WorkflowRuntimeError` instead; this class is scoped to things the user * can fix in their own code. */ export class SerializationError extends WorkflowError { readonly hint?: string; /** - * Serialization errors are deterministic — if a step returns a non-POJO, + * Serialization errors are deterministic: if a step returns a non-POJO, * replaying the step will always produce the same non-serializable value. * Retrying is guaranteed to fail, so these errors are surfaced as fatal * and skip the step-retry loop. `FatalError.is()` recognizes any error @@ -520,7 +520,7 @@ export class SerializationError extends WorkflowError { // The hint carries its own docs URL (pointing at the foundations // serialization page, which is what users actually need to see what // round-trips), so we don't add a separate `╰▶ docs:` line here. - // Avoids two URLs on the message — one already-actionable, the other + // Avoids two URLs on the message: one already-actionable, the other // pointing at a generic error explainer. const body = appendFramedDetails( message, @@ -539,7 +539,7 @@ export class SerializationError extends WorkflowError { /** * Thrown when a step function is not registered in the current deployment. * - * This is an infrastructure error — not a user code error. It typically means + * This is an infrastructure error, not a user code error. It typically means * something went wrong with the bundling/build tooling that caused the step * to not get built correctly. * @@ -566,7 +566,7 @@ export class StepNotRegisteredError extends WorkflowRuntimeError { /** * Thrown when a workflow function is not registered in the current deployment. * - * This is an infrastructure error — not a user code error. It typically means: + * This is an infrastructure error, not a user code error. It typically means: * - A run was started against a deployment that does not have the workflow * (e.g., the workflow was renamed or moved and a new run targeted the latest deployment) * - Something went wrong with the bundling/build tooling that caused the workflow @@ -617,7 +617,7 @@ export class WorkflowDeploymentMismatchError extends WorkflowRuntimeError { ) { const recoveryAttempts = options?.recoveryAttempts ?? 0; // Carried in the persisted message, not just a log line: the attempt count - // separates racing routing from a deployment that is simply gone. + // separates racing routing from a deployment that is gone. const recovery = recoveryAttempts > 0 ? ` The runtime re-routed the message to "${expectedDeploymentId}" ${recoveryAttempts} ${pluralize('time', 'times', recoveryAttempts)} and it kept arriving elsewhere, so the run was stopped to protect against code-skew errors.` @@ -644,7 +644,7 @@ export class WorkflowDeploymentMismatchError extends WorkflowRuntimeError { * This error occurs when you call methods on a run object (e.g. `run.status`, * `run.cancel()`, `run.returnValue`) but the underlying run ID does not match * any known workflow run. Note that `getRun(id)` itself is synchronous and will - * not throw — this error is raised when subsequent operations discover the run + * not throw; this error is raised when subsequent operations discover the run * is missing. * * Use the static `WorkflowRunNotFoundError.is()` method for type-safe checking @@ -680,7 +680,7 @@ export class WorkflowRunNotFoundError extends WorkflowError { /** * Thrown when a hook token is already in use by another active workflow run. * - * This is a user error — it means the same custom token was passed to + * This is a user error: it means the same custom token was passed to * `createHook` in two or more concurrent runs. Use a unique token per run * (or omit the token to let the runtime generate one automatically). */ @@ -732,7 +732,7 @@ export class HookConflictError extends WorkflowError { * await resumeHook(token, payload); * } catch (error) { * if (HookNotFoundError.is(error)) { - * // Hook doesn't exist — start a new workflow run instead + * // Hook doesn't exist, so start a new workflow run instead * await startWorkflow("myWorkflow", payload); * } * } @@ -772,7 +772,7 @@ export class EntityConflictError extends WorkflowWorldError { } /** - * Thrown when a run is no longer available — either because it has been + * Thrown when a run is no longer available, either because it has been * cleaned up, expired, or already reached a terminal state (completed/failed). * * The workflow runtime handles this error automatically. Users interacting @@ -857,7 +857,7 @@ export class ThrottleError extends WorkflowWorldError { /** * Thrown when the backend rejects an event creation because the client's - * event-log snapshot is stale — the log the client replayed from is missing + * event-log snapshot is stale: the log the client replayed from is missing * an event the backend has already recorded (HTTP 412). * * The workflow runtime handles this automatically: it restarts the replay from @@ -873,7 +873,7 @@ export class ThrottleError extends WorkflowWorldError { * for the whole discrepancy the rejection reported or be omitted entirely; a * client that receives nothing does the authoritative full reload, which is * always correct. Typed `unknown` because this package cannot depend - * on the event type — consumers narrow it themselves and must treat a + * on the event type; consumers narrow it themselves and must treat a * missing or malformed value as "no detail" (a full reload is always * correct). */ @@ -984,9 +984,9 @@ export class RunNotSupportedError extends WorkflowError { * Any error can opt into the non-retry behavior by setting a `fatal: true` * own property. This is how structured error classes that aren't direct * `FatalError` subclasses (e.g. context-violation errors) signal to the - * step executor that retrying will never help — the user's code is calling - * a workflow-only API from the wrong context, or similar — and burning - * retry attempts just produces a wall of duplicated log output. + * step executor that retrying will never help (the user's code is calling + * a workflow-only API from the wrong context, or similar) and burning + * retry attempts produces a wall of duplicated log output. */ export class FatalError extends Error { fatal = true; @@ -1050,7 +1050,7 @@ export { RUN_ERROR_CODES, type RunErrorCode } from './error-codes.js'; // // `FatalError`, `RetryableError`, and `HookConflictError` are not built-ins, so different realms // (e.g. the workflow VM context vs. the host context that runs the queue -// handler) bundle and load their own copies of this module — meaning each +// handler) bundle and load their own copies of this module, meaning each // realm has its own distinct class identity. Cross-realm `instanceof` fails // because the prototype chains never meet. // diff --git a/packages/errors/src/internal-chalk.ts b/packages/errors/src/internal-chalk.ts index 3f89e0d188..9373b08e07 100644 --- a/packages/errors/src/internal-chalk.ts +++ b/packages/errors/src/internal-chalk.ts @@ -4,7 +4,7 @@ * `@workflow/errors/ansi` is reachable from the workflow-VM bundle (via * `@workflow/core/workflow` → `context-errors` → `context-violation-error` * → here), and the workflow VM has no `require()`. The real `chalk` package - * pulls in `supports-color`, which calls `require('os')` at module load — + * pulls in `supports-color`, which calls `require('os')` at module load, * so importing `chalk` here crashes every workflow with * `ReferenceError: require is not defined`. * @@ -13,7 +13,7 @@ * `magenta`). Color detection mirrors chalk's defaults at a coarse level: * `FORCE_COLOR` forces on, `NO_COLOR` forces off, otherwise we emit ANSI * only on a TTY stdout. In the workflow VM `process` is absent so this - * evaluates to "no color" and the helpers become identity functions — + * evaluates to "no color" and the helpers become identity functions, * which is what the runtime wants anyway, since the host catches and * re-renders the error. * diff --git a/packages/nest/README.md b/packages/nest/README.md index 2c664c9926..e2be31fb07 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -18,9 +18,11 @@ npm install -D @swc/cli @swc/core pnpm add -D @swc/cli @swc/core ``` -## Quick Start + -### 1. Initialize SWC Configuration +## Quick start + +### 1. Initialize SWC configuration After installing the package, run the init command to generate the SWC configuration: @@ -56,7 +58,7 @@ In your `app.module.ts`: {/*@skip-typecheck: Shows WorkflowModule import*/} -```typescript +```ts import { Module } from '@nestjs/common'; import { WorkflowModule } from '@workflow/nest'; @@ -66,13 +68,13 @@ import { WorkflowModule } from '@workflow/nest'; export class AppModule {} ``` -### 4. Create Workflow Files +### 4. Create workflow files Create workflow files in your `src/` directory with `"use workflow"` and `"use step"` directives: {/*@skip-typecheck: Shows workflow file*/} -```typescript +```ts // src/workflows/example.ts export async function myStep(data: string) { 'use step'; @@ -86,7 +88,7 @@ export async function myWorkflow(input: string) { } ``` -### 5. Add Pre-build Scripts +### 5. Add pre-build scripts Add scripts to regenerate configuration before builds: @@ -99,11 +101,11 @@ Add scripts to regenerate configuration before builds: } ``` -## Configuration Options +## Configuration options {/*@skip-typecheck: Shows WorkflowModule.forRoot options*/} -```typescript +```ts WorkflowModule.forRoot({ // Directory to scan for workflow files (default: ['src']) dirs: ['src'], @@ -139,14 +141,14 @@ deployed workflow runs stay `pending` because nothing consumes the queue. Create a `_vercel/entry.ts` that default-exports a Node request handler backed by your NestJS app (the `_vercel/` prefix avoids colliding with Vercel's automatic `api/` function detection). Import `AppModule` from the **compiled** `dist/` -output — `nest build` runs first and its SWC pass emits the decorator metadata +output. `nest build` runs first, and its SWC pass emits the decorator metadata NestJS DI relies on; importing raw `src/` TypeScript would route the app back through esbuild, which does not emit `emitDecoratorMetadata`. Also import `reflect-metadata` at the top so DI metadata is registered: {/*@skip-typecheck: Shows the Vercel entry module shape*/} -```typescript +```ts // _vercel/entry.ts import 'reflect-metadata'; import { NestFactory } from '@nestjs/core'; @@ -175,7 +177,7 @@ The Vercel Build Output already contains the compiled workflow bundles, so tell {/*@skip-typecheck: Shows WorkflowModule.forRoot on Vercel*/} -```typescript +```ts WorkflowModule.forRoot({ skipBuild: Boolean(process.env.VERCEL) }) ``` @@ -193,29 +195,29 @@ the `VERCEL` env var is set (pass `--vercel` to force it locally): } ``` -`nest build` (via SWC) compiles your app — including the decorator metadata and -the workflow client transform — and `@workflow/nest build` bundles the app plus +`nest build` (via SWC) compiles your app, including the decorator metadata and +the workflow client transform. Then, `@workflow/nest build` bundles the app and the workflow functions into `.vercel/output`. > **Note:** Native addons (`*.node`) are not bundled or traced into the deployed > function, so NestJS apps that depend on native modules are not yet supported by > `--vercel`. -## How It Works +## How it works The `@workflow/nest` package provides: -1. **WorkflowModule** - A NestJS module that handles workflow bundle building and HTTP routing -2. **WorkflowController** - Handles workflow and step execution requests at `.well-known/workflow/v1/` -3. **NestLocalBuilder** - Builds workflow bundles (steps.mjs, workflows.mjs) from your source files. Exposed at the `@workflow/nest/builder` subpath (not the package root — the root entry stays free of build-time dependencies so importing `WorkflowModule` never drags the compiler into your runtime bundle). -4. **NestVercelBuilder** - Emits a Vercel Build Output API directory for deploying on Vercel. Exposed at the `@workflow/nest/vercel-builder` subpath. -5. **CLI** - Generates `.swcrc` configuration with the SWC plugin properly resolved, and builds workflow bundles / the Vercel Build Output +1. **WorkflowModule**: A NestJS module that handles workflow bundle building and HTTP routing +2. **WorkflowController**: Handles workflow and step execution requests at `.well-known/workflow/v1/` +3. **NestLocalBuilder**: Builds workflow bundles (`steps.mjs` and `workflows.mjs`) from your source files. Exposed at the `@workflow/nest/builder` subpath (not the package root, which stays free of build-time dependencies so importing `WorkflowModule` never adds the compiler to your runtime bundle). +4. **NestVercelBuilder**: Emits a Vercel Build Output API directory for deploying on Vercel. Exposed at the `@workflow/nest/vercel-builder` subpath. +5. **CLI**: Generates `.swcrc` configuration with the SWC plugin resolved and builds workflow bundles or the Vercel Build Output ## Why the CLI? NestJS uses its own SWC builder that reads configuration from `.swcrc`. The Workflow SWC plugin needs to be referenced by path in this file. The CLI resolves the plugin path from `@workflow/nest`'s dependencies, eliminating the need for manual configuration or pnpm hoisting. -### Technical Details +### Technical details When you run `npx @workflow/nest init`, it: @@ -229,17 +231,17 @@ This approach ensures: - No pnpm hoisting configuration required in `.npmrc` - The plugin is always resolved from the correct location -### Why Workflows Must Be in `src/` +### Why workflows must be in `src/` NestJS's SWC builder only compiles files within the `sourceRoot` directory (typically `src/`). For the workflow client-mode transform to work, workflow files must be in `src/` so they get compiled with the SWC plugin that attaches `workflowId` properties needed by `start()`. -## API Reference +## API reference ### WorkflowModule {/*@skip-typecheck: Shows WorkflowModule usage*/} -```typescript +```ts import { WorkflowModule } from '@workflow/nest'; // Basic usage @@ -255,7 +257,7 @@ WorkflowModule.forRoot({ }) ``` -### CLI Commands +### CLI commands ```bash # Generate .swcrc configuration diff --git a/packages/nest/src/cjs-rewrite.ts b/packages/nest/src/cjs-rewrite.ts index 6355083fa8..d94d91ddcf 100644 --- a/packages/nest/src/cjs-rewrite.ts +++ b/packages/nest/src/cjs-rewrite.ts @@ -30,7 +30,7 @@ export function rewriteTsImportsInContent( // shared across all parseSync calls in the process (each parse leaves the // cursor at the end of the previous source). For a freshly-parsed source, // `module.span.start` points to the first byte SWC considers part of the - // module's text — which empirically: + // module's text, which empirically: // • starts BEFORE leading line/block comments and a leading BOM // (SWC skips those out of `module.span`), but // • starts AT a leading shebang line (SWC keeps the shebang inside the @@ -79,7 +79,7 @@ export function rewriteTsImportsInContent( * * When dirs includes ".", prefix is empty so no dir matches in the loop; we fall * through to the default which prepends distDir to the entire path. - * e.g. dirs: [".", "src"] — "src/foo.ts" matches "src", files outside match "." + * e.g. dirs: [".", "src"]: "src/foo.ts" matches "src", files outside match "." */ export function mapSourceToDistPath( relToWorkingDir: string, @@ -287,7 +287,7 @@ function createBytePositionMapper( /** * Compute the UTF-8 byte length of any leading content that SWC excludes from - * `module.span` — leading whitespace (including a BOM), `//` line comments, + * `module.span`: leading whitespace (including a BOM), `//` line comments, * and `/* … *\/` block comments. * * A leading shebang line is intentionally NOT skipped: SWC keeps the shebang diff --git a/packages/nest/src/vercel-builder.ts b/packages/nest/src/vercel-builder.ts index 0bb42c68ac..d1062c5aa4 100644 --- a/packages/nest/src/vercel-builder.ts +++ b/packages/nest/src/vercel-builder.ts @@ -22,7 +22,7 @@ export interface NestVercelBuilderOptions { dirs?: string[]; /** * Path (relative to workingDir) to the serverless entry module for the - * NestJS app. It must `export default` a Node request handler — e.g. the + * NestJS app. It must `export default` a Node request handler, e.g. the * Express instance from `app.getHttpAdapter().getInstance()`. Because the * NestJS app is compiled by `nest build` first, this typically imports the * compiled module from `dist/`. @@ -52,7 +52,7 @@ export interface NestVercelBuilderOptions { * * The workflow side (the combined `flow.func` consumer registered with * `experimentalTriggers`, the `webhook/[token].func`, the public manifest and - * routing) is produced by the shared {@link VercelBuildOutputAPIBuilder} — + * routing) is produced by the shared {@link VercelBuildOutputAPIBuilder}, * exactly the same code path the Nitro/Next/etc. integrations use, so the * queue consumer is discovered by VQS the same way. This class only adds the * NestJS app itself as the catch-all function and merges the routes. @@ -68,7 +68,7 @@ export class NestVercelBuilder extends VercelBuildOutputAPIBuilder { const dirs = options.dirs ?? ['src']; // Note: unlike the local-dev NestLocalBuilder (whose bundles run inside the // app's node_modules), the Build Output functions must be self-contained, - // so we do NOT externalize the target world — it is bundled into flow.func. + // so we do NOT externalize the target world; it is bundled into flow.func. super({ ...createBaseBuilderConfig({ workingDir, @@ -86,7 +86,7 @@ export class NestVercelBuilder extends VercelBuildOutputAPIBuilder { override async build(): Promise { // 1. Emit the workflow functions (flow.func + webhook + manifest + config) - // via the shared builder — identical to every other integration. + // via the shared builder, identical to every other integration. await super.build(); // 2. Bundle the NestJS app as the catch-all function. @@ -100,7 +100,7 @@ export class NestVercelBuilder extends VercelBuildOutputAPIBuilder { /** * Build the esbuild `external` list for the app function. * - * The build toolchain is always external — it is only reachable through + * The build toolchain is always external: it is only reachable through * WorkflowModule's lazy import when `skipBuild` is false (never on Vercel), * so bundling esbuild/SWC/native binaries would only bloat the function. * @@ -124,7 +124,7 @@ export class NestVercelBuilder extends VercelBuildOutputAPIBuilder { // Native addons are externalized so esbuild does not fail on a `.node` // file it cannot bundle. NOTE: this builder does not trace/copy native // artifacts into the .func, so an app that actually loads a native addon - // is not yet supported on Vercel — see the limitation called out in the + // is not yet supported on Vercel; see the limitation called out in the // README's "Deploying to Vercel" section and the changeset. '*.node', ]; diff --git a/packages/nest/src/workflow.module.ts b/packages/nest/src/workflow.module.ts index 9471b634a0..34d13abb0b 100644 --- a/packages/nest/src/workflow.module.ts +++ b/packages/nest/src/workflow.module.ts @@ -27,8 +27,8 @@ const DEFAULT_OUT_DIR = '.nestjs/workflow'; * controller that serves the `.well-known/workflow/v1` routes and, in local * dev, rebuilds the workflow bundles on init. * - * The build toolchain (`@workflow/builders`, esbuild, SWC) is imported lazily - * — only when a build actually runs (`skipBuild` false). Importing this module + * The build toolchain (`@workflow/builders`, esbuild, SWC) is imported lazily, + * only when a build actually runs (`skipBuild` false). Importing this module * must stay free of build-time dependencies so the runtime app can be bundled * into a serverless function without dragging in the compiler. */ diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index 7a2f131eb1..b6b9c33daf 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -425,7 +425,7 @@ export async function getNextBuilderEager( // Known gap: the initial build has the same two-read shape (the // combined build above consumed sources, and this refresh re-reads - // them), but no pinning — and the watcher below attaches with + // them), but no pinning, and the watcher below attaches with // `ignoreInitial: true`, so an edit landing inside the startup window // is absorbed with no straggler event to recover it. Bounded by dev // server startup rather than recurring per rebuild; knowingly out of diff --git a/packages/next/src/watch-ignore.ts b/packages/next/src/watch-ignore.ts index 349532c41f..5ee54205e7 100644 --- a/packages/next/src/watch-ignore.ts +++ b/packages/next/src/watch-ignore.ts @@ -90,7 +90,7 @@ function matcherWithout(entry: GitignoreMatcher, dropped: Set): Ignore { * applies them: a rule in a deeper `.gitignore` overrides a conflicting rule * from a shallower one. * - * Nested `.gitignore` files *below* `workingDir` are intentionally not read — + * Nested `.gitignore` files *below* `workingDir` are intentionally not read; * the {@link WATCH_IGNORED_PATHS_ENV} env var backstops anything they'd cover. */ function loadGitignoreMatchers( @@ -196,7 +196,7 @@ export function createWatchIgnorePredicate( ) => { const matcher = dropped ? matcherWithout(entry, dropped) : entry.matcher; // Test the trailing-slash form too so a directory node itself matches a - // `dir/` gitignore rule (not just its children) — this lets the walk and + // `dir/` gitignore rule (not just its children); this lets the walk and // chokidar prune the directory instead of descending into it. const asFile = matcher.test(rel); const asDir = matcher.test(`${rel}/`); diff --git a/packages/next/src/watch-rebuild.ts b/packages/next/src/watch-rebuild.ts index 557a8f7aac..2fc87ca13f 100644 --- a/packages/next/src/watch-rebuild.ts +++ b/packages/next/src/watch-rebuild.ts @@ -279,7 +279,7 @@ export const replaceSourceSnapshots = async ({ try { sourceSnapshots.set(file, await readSnapshot(file)); } catch { - // Unreadable (e.g. just deleted) files simply stay absent from the + // Unreadable (e.g. just deleted) files stay absent from the // freshly cleared map. } }) @@ -328,21 +328,21 @@ const captureSourceSnapshots = async ({ * once when the baseline is refreshed from disk afterwards (the `rebuild` * callback owns both, in that order). An edit that lands between those reads * would be absorbed into the baseline without ever being built, and its - * queued watcher event would then classify as a no-op — silently dropping + * queued watcher event would then classify as a no-op, silently dropping * the change until the next unrelated rebuild. * * To prevent that, the relevant files are re-read from disk immediately * before the rebuild starts, and files present both before and after get * that captured value restored. A mid-(multi-second-)rebuild edit then still * diffs against what the rebuild consumed, while a duplicate watcher event - * for content the rebuild already consumed — watchers routinely emit several - * events per edit, the triggering edit included — diffs equal and stays a + * for content the rebuild already consumed (watchers routinely emit several + * events per edit, the triggering edit included) diffs equal and stays a * no-op instead of cascading into back-to-back full rebuilds. * * The capture costs one serial read of the relevant set (~150-250ms at ~250 - * files) per full rediscovery. A zero-read formulation — cloning the live + * files) per full rediscovery. A zero-read formulation (cloning the live * baseline map and pinning the triggering batch to the snapshots - * `classifyRebuild` read — was tried and reverted: writes landing in the + * `classifyRebuild` read) was tried and reverted: writes landing in the * capture window (test-teardown restores, multi-flush setup bursts) are * content the imminent build consumes anyway, and the clone un-absorbs them * into follow-up full rebuilds; with real-world multi-second rebuilds that @@ -355,7 +355,7 @@ const captureSourceSnapshots = async ({ * its queued add event forces the follow-up rebuild, and a new file the * build did consume gets a baseline matching what it consumed. What stays * narrowed rather than closed is a file created and then edited again within - * one rebuild window — eviction-style conservatism was tried against that + * one rebuild window; eviction-style conservatism was tried against that * and rejected too: it turned every added file's routine duplicate watcher * events into redundant full rebuilds. */ diff --git a/packages/nitro/src/builders.ts b/packages/nitro/src/builders.ts index 66677e33db..ec63a321a6 100644 --- a/packages/nitro/src/builders.ts +++ b/packages/nitro/src/builders.ts @@ -12,9 +12,9 @@ import { join } from 'pathe'; * workflow builder's esbuild `external` option. RegExp and function entries * are skipped since esbuild's `external` only supports literal strings. * - * Note: `externals.external` is on Nitro v2's options shape — v3 dropped it - * in favour of `noExternals`. Reading it through a v2-shaped view lets us - * still pick it up on v2 setups; on v3 the chained optional access just + * Note: `externals.external` is on Nitro v2's options shape; v3 dropped it + * in favor of `noExternals`. Reading it through a v2-shaped view lets us + * still pick it up on v2 setups; on v3 the chained optional access * returns undefined. */ type NitroV2ExternalsOptions = { externals?: { external?: unknown[] } }; @@ -124,8 +124,8 @@ export class LocalBuilder extends BaseBuilder { stepsOutfile: join(this.#outDir, 'steps.mjs'), flowOutfile: join(this.#outDir, 'workflows.mjs'), format: 'esm', - // bundleFinalOutput: false — Nitro externalizes the workflow build dir - // during dev, and its own rollup pipeline handles bundling for prod. + // bundleFinalOutput: false, since Nitro externalizes the workflow build + // dir during dev, and its own rollup pipeline handles bundling for prod. // Using true causes "Dynamic require of X is not supported" errors // because esbuild wraps CJS require() calls in ESM output. bundleFinalOutput: false, diff --git a/packages/nitro/src/index.ts b/packages/nitro/src/index.ts index e7c8b69cde..7ee105fe80 100644 --- a/packages/nitro/src/index.ts +++ b/packages/nitro/src/index.ts @@ -87,7 +87,7 @@ export default { // Nitro bundles undici (via the world adapter) into the ESM server // output. undici loads most node: builtins as ESM imports, but pulls in // `node:http2` lazily via a bare `require('node:http2')` inside a - // try/catch — which the bundler leaves un-wired, so in the ESM bundle the + // try/catch, which the bundler leaves un-wired, so in the ESM bundle the // require throws and undici silently falls back to a stub whose // `http2.connect` is undefined. That breaks any HTTP/2 request (the // workflow flow-route callback fails with "fetch failed", so runs never @@ -147,7 +147,7 @@ export default { // resolution result while forcing `external: true`. Without // `pre` here, our `external: false` decision gets overwritten // and `@workflow/*` imports end up externalized in the dev - // bundle — which means the SWC-injected `static classId` IIFE + // bundle, which means the SWC-injected `static classId` IIFE // in (e.g.) `@workflow/core/dist/runtime/run.js` is never // applied, and step return values that include `Run` // instances fail to serialize at runtime. @@ -172,7 +172,7 @@ export default { // Resolve via other resolvers, skipping ourselves so we // get a path. We don't gate on `resolved.external` because // `nitro:externals` spreads our result and overrides - // `external: true` regardless of what we return — we want + // `external: true` regardless of what we return; we want // to win that race by returning first under `order: 'pre'`. const resolved = await this.resolve(source, importer, { ...options, @@ -180,7 +180,7 @@ export default { }); if (!resolved) return null; let resolvedId = resolved.id; - // Strip file:// protocol if present — Rollup needs a plain + // Strip file:// protocol if present, since Rollup needs a plain // filesystem path to load the module. `fileURLToPath` // correctly handles Windows paths (e.g., file:///C:/... // -> C:\...) and percent-decoding. @@ -253,7 +253,7 @@ export default { } catch (error) { // During dev, files may be added/removed while the builder // is rebuilding (e.g., during test cleanup). Log the error - // but don't crash — the next file change will trigger + // but don't crash; the next file change will trigger // another rebuild with the correct file list. console.warn('Warning: Workflow rebuild failed:', error); } @@ -263,7 +263,7 @@ export default { // Embedded observability dashboard. Defaults to on in dev / off in prod // builds; when disabled nothing is registered, so prod bundles never // import @workflow/web. Excluded on Vercel deploys (the on-disk build/ - // and node_modules import don't survive a serverless bundle — users use + // and node_modules import don't survive a serverless bundle; users use // the hosted Vercel dashboard there). const dashboardOption = nitro.options.workflow?.dashboard; const dashboardEnabled = @@ -286,7 +286,7 @@ export default { // V2: single combined handler for both workflow and step execution. // The step registrations are imported as side effects by the combined - // handler — no separate step route needed. + // handler, so no separate step route is needed. addVirtualHandler( nitro, '/.well-known/workflow/v1/flow', @@ -378,15 +378,15 @@ function normalizeDashboardPath(path: string): string { * Mount the observability dashboard in-process at `basename` (e.g. `/_workflow`). * * The entire `@workflow/web` UI (SSR + static client assets + RPC) is served by - * a single Web-standard fetch handler running inside this Nitro process — no + * a single Web-standard fetch handler running inside this Nitro process: no * second server, no separate port, no redirect. The handler is framework- - * neutral; this is just its first consumer. + * neutral; this is its first consumer. */ function addDashboardHandler(nitro: Nitro, basename: string) { // Resolve `@workflow/web/handler` relative to this module so consumers don't // need a direct dependency on `@workflow/web`. Inlined into the virtual // handler as a file:// URL and imported with @vite-ignore/webpackIgnore so - // Node `import()`s it from node_modules at runtime — keeping @workflow/web's + // Node `import()`s it from node_modules at runtime, keeping @workflow/web's // (large, React) dependency graph out of the Nitro server bundle. const require_ = createRequire(import.meta.url); let webHandlerUrl: string; diff --git a/packages/nitro/src/types.ts b/packages/nitro/src/types.ts index 85b47e1371..395b445ad7 100644 --- a/packages/nitro/src/types.ts +++ b/packages/nitro/src/types.ts @@ -28,8 +28,8 @@ export interface ModuleOptions { * for step/workflow bundles), `'linked'`, `'external'`, `'both'`, or * `false` to omit source maps. * - * Set to `false` for smaller function bundles — useful for staying under - * the Vercel 250MB function size limit — at the cost of stack traces that + * Set to `false` for smaller function bundles (useful for staying under + * the Vercel 250MB function size limit) at the cost of stack traces that * point at generated code instead of your source files. * * Can also be set via the `WORKFLOW_SOURCEMAP` environment variable. @@ -39,10 +39,10 @@ export interface ModuleOptions { /** * Embed the workflow observability dashboard in-process on this server, * served at `/_workflow` (configurable). The UI runs inside the same Nitro - * process — no separate server or port. + * process, with no separate server or port. * - * - `true` / `false` — force the dashboard on or off. - * - `{ enabled, path }` — toggle and/or change the mount path. + * - `true` / `false`: force the dashboard on or off. + * - `{ enabled, path }`: toggle and/or change the mount path. * * Defaults to **on in dev, off in production builds**. When disabled the * route is never registered, so production bundles carry no `@workflow/web` diff --git a/packages/rollup/src/index.ts b/packages/rollup/src/index.ts index 921fca756d..4e95c01903 100644 --- a/packages/rollup/src/index.ts +++ b/packages/rollup/src/index.ts @@ -33,7 +33,7 @@ export function workflowTransformPlugin( // Externalize it ONLY when it isn't installed: otherwise Rollup/Vite // fails the build with "failed to resolve import '@opentelemetry/api'" // when the peer is absent (observed in SvelteKit's pipeline). When the - // peer IS installed we must let it resolve and bundle normally — a + // peer IS installed we must let it resolve and bundle normally: a // self-contained output (Nitro's `.output/server`, esbuild) ships no // node_modules, so forcing it external there strands the runtime // `import('@opentelemetry/api')` and crashes the server with @@ -51,7 +51,7 @@ export function workflowTransformPlugin( } // `ws`'s optional native accelerators. Unlike the OTEL peer above these - // are externalized unconditionally, not only when unresolvable — `ws` + // are externalized unconditionally, not only when unresolvable: `ws` // requires them in a try/catch, so a failed runtime require is the // designed path while a partially bundled native module is not. This // plugin also runs under Vite, which substitutes a stub for an absent diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 34db464527..ed5c490252 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -50,7 +50,7 @@ function createWorkflowPlugins(builder: SvelteKitBuilder): Plugin[] { // SvelteKit bundles the server (including undici, via the world adapter) // into ESM output. undici loads most node: builtins as ESM imports, but // pulls in `node:http2` lazily via a bare `require('node:http2')` inside a - // try/catch — which the bundler leaves un-wired, so in the ESM bundle the + // try/catch, which the bundler leaves un-wired, so in the ESM bundle the // require throws and undici silently falls back to a stub whose // `http2.connect` is undefined. That breaks any HTTP/2 request (observed // as the workflow flow-route callback failing with "fetch failed" -> diff --git a/packages/swc-plugin-workflow/spec.md b/packages/swc-plugin-workflow/spec.md index 0efe7ff713..1449f97ac9 100644 --- a/packages/swc-plugin-workflow/spec.md +++ b/packages/swc-plugin-workflow/spec.md @@ -1,21 +1,21 @@ -# Workflow Directives Specification +# Workflow directives specification The `"use step"` and `"use workflow"` directives work similarly to `"use server"` in React. A function marked with `"use step"` represents a durable step that executes on the server. A function marked with `"use workflow"` represents a durable workflow that orchestrates steps. -The SWC plugin has 3 modes: **Step mode**, **Workflow mode**, and **Detect mode**. +The SWC plugin has three modes: **step mode**, **workflow mode**, and **detect mode**. -## Directive Placement +## Directive placement Directives can be placed: 1. At the **top of a file** (module-level) to mark all exported async functions 2. At the **start of a function body** to mark individual functions Directives must: -- Be at the very beginning (above any other code, including imports for module-level) +- Be at the beginning (above any other code, including imports for module-level) - Use single or double quotes (not backticks) - Comments before directives are allowed -## JSON Manifest +## JSON manifest All modes emit a JSON manifest comment at the top of the file containing metadata about discovered workflows, steps, and classes with custom serialization: @@ -30,7 +30,7 @@ The manifest includes: This manifest is used by bundlers and the runtime to discover and register workflows, steps, and serializable classes. -## ID Generation +## ID generation IDs use the format `{type}//{modulePath}//{identifier}` where: - `type` is `workflow`, `step`, or `class` @@ -39,7 +39,7 @@ IDs use the format `{type}//{modulePath}//{identifier}` where: - A **relative path** prefixed with `./` (e.g., `./src/jobs/order`) when no specifier is provided - `identifier` is the function/class name, with nested functions using `/` separators -### Module Specifier Support +### Module specifier support The plugin accepts an optional `moduleSpecifier` config option that allows IDs to be based on the import specifier rather than the file path. This is useful for: @@ -87,15 +87,15 @@ Note: File extensions are stripped from local paths for cleaner IDs. --- -## Step Mode +## Step mode In step mode, step function bodies are kept intact and registered using an inline IIFE that stores them in a global registry via `Symbol.for("@workflow/core//registeredSteps")`, with no module imports. Workflow functions throw an error if called directly (since they should only run in the workflow runtime). -After the step-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are preserved (unlike workflow mode where they are replaced with proxies), imports, helper functions, and other declarations referenced from step bodies are also preserved. However, code that is reachable only from workflow bodies that were replaced with throwing stubs can still be removed. A reference counts even when it appears only inside a destructuring-default initializer — e.g. `const { ttl = TTL } = options;` counts as a use of `TTL`, so the declaration is not stripped. +After the step-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are preserved (unlike workflow mode where they are replaced with proxies), imports, helper functions, and other declarations referenced from step bodies are also preserved. However, code that is reachable only from workflow bodies that were replaced with throwing stubs can still be removed. A reference counts even when it appears only inside a destructuring-default initializer. For example, `const { ttl = TTL } = options;` counts as a use of `TTL`, so the declaration is not stripped. Object property step functions are hoisted to module-level variables and the original call site is replaced with a reference to the hoisted variable, making `.stepId` accessible at the call site. -### Basic Step Function +### Basic step function Input: ```javascript @@ -118,7 +118,7 @@ export async function add(a, b) { })(add, "step//./input//add"); ``` -### Arrow Function Step +### Arrow function step Input: ```javascript @@ -141,7 +141,7 @@ export const multiply = async (a, b) => { })(multiply, "step//./input//multiply"); ``` -### Workflow Functions in Step Mode +### Workflow functions in step mode Workflow functions throw an error to prevent direct execution and have `workflowId` attached: @@ -162,7 +162,7 @@ export async function myWorkflow(data) { myWorkflow.workflowId = "workflow//./input//myWorkflow"; ``` -### Nested Steps in Workflows +### Nested steps in workflows Steps defined inside workflow functions are hoisted to module level with prefixed names: @@ -197,7 +197,7 @@ example.workflowId = "workflow//./input//example"; })(example$innerStep, "step//./input//example/innerStep"); ``` -### Steps in Nested Object Properties +### Steps in nested object properties Step functions can be defined inside deeply nested object properties, including function call arguments. The plugin recursively processes nested objects to find step functions, generating compound paths for the step IDs. @@ -257,7 +257,7 @@ Note: In step mode, nested object property step functions are hoisted and regist Note: The step ID includes the full path through nested objects (`vade/tools/VercelRequest/execute`), while the hoisted variable name uses `$` as the separator (`vade$tools$VercelRequest$execute`) to create a valid JavaScript identifier. -#### Shorthand Method Syntax +#### Shorthand method syntax Shorthand method syntax (non-arrow functions) is also supported in nested object properties: @@ -300,9 +300,9 @@ export const vade = agent({ Note: Shorthand methods are hoisted as regular function expressions (not arrow functions) to preserve `this` binding when called with `.call()` or `.apply()`. Closure variables are handled the same way as other step functions. -### Closure Variables +### Closure variables -When nested steps capture closure variables, they are extracted using an inline IIFE that reads from the workflow step context storage via `Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE")`. Closure variable detection recursively walks the step function body — including nested function, arrow, method, getter/setter, and class bodies — and collects identifiers that are not parameters, local declarations, known globals, module-level imports, or module-level declarations. TypeScript expression wrappers (`as`, `satisfies`, `!`, type assertions, `const` assertions, instantiation expressions) are traversed to reach the inner expression. Module-level imports and declarations (functions, variables, classes) are excluded since they are available directly in the step bundle and should not be serialized as closure values: +When nested steps capture closure variables, the plugin extracts them using an inline IIFE that reads from the workflow step context storage via `Symbol.for("WORKFLOW_STEP_CONTEXT_STORAGE")`. Closure variable detection recursively walks the step function body, including nested function, arrow, method, getter/setter, and class bodies. It collects identifiers that are not parameters, local declarations, known globals, module-level imports, or module-level declarations. TypeScript expression wrappers (`as`, `satisfies`, `!`, type assertions, `const` assertions, and instantiation expressions) are traversed to reach the inner expression. Module-level imports and declarations (functions, variables, and classes) are excluded because they are available directly in the step bundle and should not be serialized as closure values: Input: ```javascript @@ -339,7 +339,7 @@ function wrapper(multiplier) { Note: The hoisted copy (`wrapper$_anonymousStep0`) uses an inline IIFE to extract closure variables from the workflow step context for workflow-driven execution, while the original function body is preserved in `wrapper()` with the directive stripped. This allows the enclosing function to work correctly when called directly (non-workflow), since JavaScript's normal closure semantics naturally capture `multiplier`. -### Instance Method Step +### Instance method step Instance methods can use `"use step"` if the class provides custom serialization methods. The `this` context is serialized when calling the step and deserialized before execution. @@ -396,7 +396,7 @@ export class Counter { Note: Instance methods use `#` in the step ID (e.g., `Counter#add`) and are registered via `ClassName.prototype["methodName"]`. -### Module-Level Directive +### Module-level directive Input: ```javascript @@ -434,13 +434,13 @@ export async function subtract(a, b) { --- -## Workflow Mode +## Workflow mode In workflow mode, step function bodies are replaced with a `globalThis[Symbol.for("WORKFLOW_USE_STEP")]` call. Workflow functions keep their bodies and are registered with `globalThis.__private_workflows.set()`. -After the workflow-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are replaced with step proxies, imports, helper functions, nested steps, and other pure statements that were only referenced from those original step bodies become eligible for removal. Exports and any identifiers still referenced by the transformed workflow code are preserved. A reference counts even when it appears only inside a destructuring-default initializer — e.g. `const { ttl = TTL } = options;` counts as a use of `TTL`, so the declaration is not stripped. +After the workflow-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are replaced with step proxies, imports, helper functions, nested steps, and other pure statements that were only referenced from those original step bodies become eligible for removal. Exports and any identifiers still referenced by the transformed workflow code are preserved. A reference counts even when it appears only inside a destructuring-default initializer. For example, `const { ttl = TTL } = options;` counts as a use of `TTL`, so the declaration is not stripped. -### Step Functions +### Step functions Input: ```javascript @@ -456,7 +456,7 @@ Output: export var add = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//add"); ``` -### Workflow Functions +### Workflow functions Input: ```javascript @@ -478,7 +478,7 @@ myWorkflow.workflowId = "workflow//./input//myWorkflow"; globalThis.__private_workflows.set("workflow//./input//myWorkflow", myWorkflow); ``` -### Nested Steps with Closures +### Nested steps with closures When steps capture closure variables, a closure function is passed as the second argument: @@ -515,11 +515,11 @@ globalThis.__private_workflows.set("workflow//./input//myWorkflow", myWorkflow); --- -## Detect Mode +## Detect mode -Detect mode is a lightweight, non-transforming mode used during the build discovery phase. It walks the AST to find `"use workflow"`, `"use step"` directives and custom serialization classes, then emits the JSON manifest comment — but does **not** modify any code. +Detect mode is a lightweight, non-transforming mode used during the build discovery phase. It walks the AST to find `"use workflow"` and `"use step"` directives and custom serialization classes, then emits the JSON manifest comment without modifying any code. -This allows the build system to perform a fast regexp pre-scan to identify candidate files, then run the SWC plugin in detect mode only on those candidates to validate at the AST level. False positives (e.g. directive-like strings inside template literals) are eliminated because the plugin only recognises genuine directive expression statements. +This allows the build system to perform a fast regular expression pre-scan to identify candidate files, then run the SWC plugin in detect mode only on those candidates to validate at the AST level. The plugin eliminates false positives (for example, directive-like strings inside template literals) because it recognizes only genuine directive expression statements. **Plugin Config:** ```json @@ -538,11 +538,11 @@ Given the same input as the other mode examples, detect mode produces: --- -## Static Methods +## Static methods Static class methods can be marked with directives. Instance methods are **not supported**. -### Static Step Method +### Static step method Input: ```javascript @@ -587,7 +587,7 @@ MyService.process = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input// })(MyService, "class//./input//MyService"); ``` -### Static Workflow Method +### Static workflow method Input: ```javascript @@ -613,7 +613,7 @@ globalThis.__private_workflows.set("workflow//./input//JobRunner.runJob", JobRun --- -## Custom Serialization +## Custom serialization Classes can define custom serialization/deserialization using symbols. These are automatically registered for use across workflow boundaries. @@ -657,7 +657,7 @@ export class Point { })(Point, "class//./input//Point"); ``` -The registration is **inlined as a self-contained IIFE** that uses `Symbol.for("workflow-class-registry")` on `globalThis`. This ensures it works for 3rd-party packages that don't depend on the `workflow` package directly — no module imports are needed. +The registration is **inlined as a self-contained IIFE** that uses `Symbol.for("workflow-class-registry")` on `globalThis`. This approach works for third-party packages that don't depend on the `workflow` package directly and requires no module imports. You can also use imported symbols from `@workflow/serde`: @@ -670,11 +670,11 @@ export class Vector { } ``` -### CommonJS `require()` Patterns +### CommonJS `require()` patterns The plugin also detects serialization symbols obtained via CommonJS `require()` calls. This handles code that has been pre-compiled from ESM to CommonJS by tools like TypeScript (`tsc`), esbuild, or tsup. -**Namespace require** — when the entire module is assigned to a variable and symbols are accessed as properties: +**Namespace require** applies when the entire module is assigned to a variable and symbols are accessed as properties: ```javascript const serde_1 = require("@workflow/serde"); @@ -691,7 +691,7 @@ class Sandbox { } ``` -**Destructured require** — when symbols are destructured directly from the `require()` call: +**Destructured require** applies when symbols are destructured directly from the `require()` call: ```javascript const { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } = require("@workflow/serde"); @@ -708,7 +708,7 @@ class Sandbox { } ``` -Both patterns produce the same output as the ESM import version — a `registerSerializationClass()` call is appended and the class is included in the manifest. +Both patterns produce the same output as the ESM import version. The plugin appends a `registerSerializationClass()` call and includes the class in the manifest. Destructured require also supports renaming (analogous to `import { WORKFLOW_SERIALIZE as WS }`): @@ -716,7 +716,7 @@ Destructured require also supports renaming (analogous to `import { WORKFLOW_SER const { WORKFLOW_SERIALIZE: WS, WORKFLOW_DESERIALIZE: WD } = require("@workflow/serde"); ``` -### Class Expressions with Binding Names +### Class expressions with binding names When a class expression is assigned to a variable, the plugin uses the variable name (binding name) for registration, not the internal class name. This is important because the internal class name is only accessible inside the class body. @@ -803,7 +803,7 @@ Output (step mode): All references use `LanguageModel` (the binding name), not `_LanguageModel` (the internal class expression name). Only a single class registration IIFE is emitted. The step IDs also use the binding name. -### Anonymous Class Expression Name Re-insertion +### Anonymous class expression name re-insertion When a serializable class expression has no internal name (anonymous) but has a binding name from a variable declaration, the plugin re-inserts the binding name as the class expression's identifier. This handles the common case where upstream bundlers like esbuild/tsup transform `class Foo { ... }` into `var Foo = class { ... }` (stripping the class name). @@ -848,12 +848,12 @@ var Shell = class Shell { ``` Note that: -- The class expression `class { ... }` becomes `class Shell { ... }` — the binding name is inserted +- The class expression `class { ... }` becomes `class Shell { ... }` with the binding name inserted - For typical usage, behavior is preserved while ensuring the `.name` property survives subsequent bundling (an inner class name binding is introduced, which can differ in edge cases that depend on assigning to or shadowing that name inside the class body) - Classes that already have an internal name (e.g., `class _Bash { ... }`) are not modified - Only classes with serialization methods (`WORKFLOW_SERIALIZE` and `WORKFLOW_DESERIALIZE`) are affected -### Anonymous Default Class Export Rewriting +### Anonymous default class export rewriting When an anonymous class with serialization methods or step methods is exported as the default export, the plugin rewrites it into a `const` declaration + re-export so that the class has a binding name accessible at module scope. Without this, the generated registration code would reference an undefined variable. @@ -887,12 +887,12 @@ export default __DefaultClass; Note that: - The anonymous class `export default class { ... }` is rewritten to `const __DefaultClass = class __DefaultClass { ... }; export default __DefaultClass;` -- When the class has serialization methods, the class expression also gets the binding name re-inserted (e.g., `class __DefaultClass { ... }`). For step-only classes without serde, the class expression remains anonymous (e.g., `class { ... }`) — but the `const` binding name is what matters for module-scope registration code +- When the class has serialization methods, the class expression also gets the binding name re-inserted (e.g., `class __DefaultClass { ... }`). For step-only classes without serde, the class expression remains anonymous (e.g., `class { ... }`), but the `const` binding name is what matters for module-scope registration code - The generated name `__DefaultClass` is used for all registrations (step, class, serde) - If `__DefaultClass` is already declared in scope, the name is suffixed (`__DefaultClass$1`, etc.) -- Named default exports (e.g., `export default class MyService { ... }`) are NOT rewritten — the class name `MyService` is already in scope +- Named default exports (e.g., `export default class MyService { ... }`) are not rewritten because the class name `MyService` is already in scope -### File Discovery for Custom Serialization +### File discovery for custom serialization Files containing classes with custom serialization are automatically discovered for transformation, even if they don't contain `"use step"` or `"use workflow"` directives. The discovery mechanism looks for: @@ -902,7 +902,7 @@ Files containing classes with custom serialization are automatically discovered This allows serialization classes to be defined in separate files (such as Next.js API routes or utility modules) and still be registered in the serialization system when the application is built. -### Cross-Context Class Registration +### Cross-context class registration Classes with custom serialization are automatically included in **all bundle contexts** (step and workflow) to ensure they can be properly serialized and deserialized when crossing execution boundaries: @@ -921,7 +921,7 @@ This cross-registration happens automatically during the build process - no manu --- -## Default Exports +## Default exports Anonymous default exports are given the name `__default`: @@ -946,7 +946,7 @@ export default __default; --- -## Validation Errors +## Validation errors The plugin emits errors for invalid usage: @@ -963,7 +963,7 @@ The plugin emits errors for invalid usage: --- -## Supported Function Forms +## Supported function forms The plugin supports various function declaration styles. Step functions may be synchronous or asynchronous. Workflow functions must be async. @@ -988,11 +988,11 @@ The plugin supports various function declaration styles. Step functions may be s --- -## Getter Step Functions +## Getter step functions Getters (property accessors) can be marked with `"use step"` to make property access trigger a step invocation. Unlike regular step functions, getters cannot be `async` syntactically, but the framework treats them as async steps. The pattern `await obj.prop` works when `prop` is a getter step. -**Getters cannot be marked with `"use workflow"`** — only `"use step"` is supported. +**Getters cannot be marked with `"use workflow"`**. Only `"use step"` is supported. ### Instance getter transformation @@ -1059,7 +1059,7 @@ In workflow mode, after stripping `"use step"` methods and getters from a class - **JS native private members**: `#field`, `#method()` (`ClassMember::PrivateMethod`, `ClassMember::PrivateProp`) - **TypeScript `private` members**: `private field`, `private method()` (`ClassMethod`/`ClassProp` with `accessibility: Private`) -The algorithm is iterative: references are first collected from all public members, then the referenced set is expanded by scanning surviving private members' bodies for cross-references, repeating until the set stabilizes. This enables cascading elimination — a private field only referenced by a private method that is itself unreferenced will also be removed. +The algorithm is iterative. It first collects references from all public members, then expands the referenced set by scanning surviving private members' bodies for cross-references until the set stabilizes. This process enables cascading elimination: a private field referenced only by an unreferenced private method is also removed. Input: ```typescript @@ -1092,8 +1092,8 @@ export class Run { static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; } static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); } id; - // private encryptionKeyPromise — ELIMINATED (only referenced by getEncryptionKey) - // private getEncryptionKey() — ELIMINATED (only referenced by stripped getter) + // private encryptionKeyPromise: ELIMINATED (only referenced by getEncryptionKey) + // private getEncryptionKey(): ELIMINATED (only referenced by stripped getter) constructor(id) { this.id = id; } } // getter replaced with step proxy @@ -1104,11 +1104,11 @@ Object.defineProperty(Run.prototype, "value", { }); ``` -This optimization is critical for SDK classes like `Run` where private helper methods reference Node.js-only imports (encryption, world access, etc.) — eliminating them allows the downstream module-level DCE to also remove those imports from the workflow bundle. +This optimization is critical for SDK classes like `Run`, where private helper methods reference Node.js-only imports (encryption, world access, and others). Eliminating the methods allows the downstream module-level DCE to also remove those imports from the workflow bundle. --- -## Parameter Handling +## Parameter handling The plugin supports complex parameter patterns including: @@ -1120,7 +1120,7 @@ The plugin supports complex parameter patterns including: --- -## Disposable Resources (`using` declarations) +## Disposable resources (`using` declarations) The plugin supports directives inside functions that use TypeScript's `using` declarations (disposable resources). When TypeScript transforms `using` declarations, it wraps the function body in a try-catch-finally block: @@ -1158,12 +1158,12 @@ The plugin detects this pattern and correctly identifies the directive inside th --- -## Lexical `this` Capture in Nested Arrow Steps +## Lexical `this` capture in nested arrow steps When a nested arrow-function step references `this` from an enclosing function/method scope, the plugin captures that `this` so the workflow runtime can rebind it inside the executing step body. This makes the -following pattern work — the user's class is responsible for providing +following pattern work. The user's class is responsible for providing custom serialization (`WORKFLOW_SERIALIZE` / `WORKFLOW_DESERIALIZE`) so the captured `this` can survive the workflow→step boundary: @@ -1192,7 +1192,7 @@ export class ReadFileTool { } ``` -Output (Workflow Mode) — the proxy reference is wrapped with `.bind(this)` +Output (workflow mode): The proxy reference is wrapped with `.bind(this)` so the runtime's step proxy captures the caller's `this` as `thisVal` on the invocation queue item: ```javascript @@ -1206,7 +1206,7 @@ createTool(context) { } ``` -Output (Step Mode) — the step body is hoisted as a regular `function` (not +Output (step mode): The step body is hoisted as a regular `function` (not an arrow) so the runtime's `stepFn.apply(thisVal, args)` can rebind `this` to the value that was captured at call time: ```javascript @@ -1240,8 +1240,8 @@ instances without custom serialization will fail at proxy-invocation time. 1. **Instance-method steps** on a class with custom serialization (e.g. `Counter#add`). Calling `instance.add(...)` captures `instance` as `thisVal` so the step body sees `this === instance`. 2. **Nested arrow steps that lexically capture `this`** (see "Lexical `this` Capture in Nested Arrow Steps" above). The compiler emits `.bind(this)` on the proxy in workflow mode and hoists the body as a regular `function` in step mode so `stepFn.apply(thisVal, args)` rebinds correctly. - Other shapes (a top-level `async function` step that references `this`, an arrow step assigned to a module-level variable, etc.) compile without error but `this` will be whatever the caller of the step proxy passes — typically `null`/`undefined` — so referencing it is rarely useful. -- `arguments` is allowed inside `function`-form step bodies (it reflects the positional arguments the runtime passes via `stepFn.apply(thisVal, args)`). It does **not** work inside arrow-form steps — arrows don't have their own `arguments` binding, and the compiler doesn't capture the enclosing scope's `arguments` the way it does for `this`. Use rest parameters (`...args`) instead if you need that pattern in an arrow step. + Other shapes (a top-level `async function` step that references `this`, an arrow step assigned to a module-level variable, etc.) compile without error. However, `this` will be whatever the caller of the step proxy passes, typically `null` or `undefined`, so referencing it is rarely useful. +- `arguments` is allowed inside `function`-form step bodies (it reflects the positional arguments the runtime passes via `stepFn.apply(thisVal, args)`). It does **not** work inside arrow-form steps. Arrows don't have their own `arguments` binding, and the compiler doesn't capture the enclosing scope's `arguments` the way it does for `this`. Use rest parameters (`...args`) instead if you need that pattern in an arrow step. - `super` calls are not allowed in step functions - Imports from the module are excluded from closure variable detection - Module-level declarations (functions, variables, classes) are excluded from closure variable detection, since they are available directly in the step bundle and should not be serialized as closure values diff --git a/packages/utils/src/parse-name.ts b/packages/utils/src/parse-name.ts index 43f84429d1..b81cf855f5 100644 --- a/packages/utils/src/parse-name.ts +++ b/packages/utils/src/parse-name.ts @@ -23,7 +23,7 @@ function parseName( } const functionName = functionNameParts.join('//'); - // For nested functions like "processOrder/innerStep", get just "innerStep" + // For nested functions like "processOrder/innerStep", get "innerStep" let shortName = functionName.split('/').at(-1) ?? ''; // Extract a reasonable name for default exports @@ -110,7 +110,7 @@ export function formatWorkflowName(name: string): string { * Best-effort short display name for spans and UI labels. Accepts either the * raw machine name (`workflow//./src/jobs/order//processOrder`) or the * queue-sanitized form (`workflow----src-jobs-order--processOrder`, where - * every non-alphanumeric character was replaced with `-`) and returns just + * every non-alphanumeric character was replaced with `-`) and returns only * the function name (`processOrder`). Falls back to the input unchanged when * neither form is recognized. */ @@ -122,7 +122,7 @@ export function workflowDisplayName(name: string): string { ); } -/** See {@link workflowDisplayName} — the step-name equivalent. */ +/** See {@link workflowDisplayName}; this is the step-name equivalent. */ export function stepDisplayName(name: string): string { return ( parseStepName(name)?.shortName ?? @@ -137,7 +137,7 @@ function shortNameFromSanitized(tag: string, name: string): string | null { // nested-function `/` became `-`. Function names are mostly dash-free, so // the innermost name is the last dash-free segment. This is best-effort: // `$` is a valid JS identifier character but is also sanitized to `-`, so - // a name like `process$Order` displays as `Order` — accepted limitation. + // a name like `process$Order` displays as `Order` (accepted limitation). const segments = name.split('--').filter(Boolean); const functionPart = segments.at(-1); let shortName = functionPart?.split('-').filter(Boolean).at(-1) ?? ''; diff --git a/packages/utils/src/world-target.ts b/packages/utils/src/world-target.ts index 8b95987dd2..611c2d6ad2 100644 --- a/packages/utils/src/world-target.ts +++ b/packages/utils/src/world-target.ts @@ -6,7 +6,7 @@ export type WorkflowEnvironment = Record; * * `VERCEL_DEPLOYMENT_ID` is the signal for "running inside a Vercel * deployment": Vercel sets it in every deployed function, and nothing else - * does. Broader signals like `VERCEL=1` are deliberately not consulted — + * does. Broader signals like `VERCEL=1` are deliberately not consulted: * `vercel env pull` writes that into `.env.local`, so a production server * started on a developer machine would resolve to the Vercel world and then * fail for want of a deployment ID. diff --git a/packages/vite/src/hot-update.ts b/packages/vite/src/hot-update.ts index e00b39da3e..5444e2fe8b 100644 --- a/packages/vite/src/hot-update.ts +++ b/packages/vite/src/hot-update.ts @@ -30,7 +30,7 @@ export function workflowHotUpdatePlugin( ): Plugin { const { builder, enqueue } = options; - // Default enqueue just runs the function directly + // Default enqueue runs the function directly const runBuild = enqueue ?? ((fn: () => Promise) => fn()); return { diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md index 387c1f4716..9b88c76d24 100644 --- a/packages/web-shared/README.md +++ b/packages/web-shared/README.md @@ -31,15 +31,15 @@ and pass data + callbacks into these components. If you need world run helpers, > **Security notice:** If you implement server-side data fetching using `@workflow/world-vercel` or similar backends, > ensure that user-supplied IDs (runId, stepId, etc.) are validated before passing them to world functions. -> The server actions in `@workflow/web` do not include authentication — see that package's README for details on securing self-hosted deployments. +> The server actions in `@workflow/web` do not include authentication. See that package's README for details on securing self-hosted deployments. ## Styling -In order for tailwind classes to be picked up correctly, you might need to configure your NextJS app -to use the correct CSS processor. E.g. if you're using PostCSS with TailwindCSS, you can do the following: +To detect Tailwind CSS classes correctly, you might need to configure your Next.js app +to use the correct CSS processor. For example, with PostCSS and Tailwind CSS: ```tsx -// postcss.config.mjs in your NextJS app +// postcss.config.mjs in your Next.js app const config = { plugins: ['@tailwindcss/postcss'], }; diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 8bd874382d..edb5aa596f 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -246,7 +246,7 @@ export function buildDurationMap( type === 'workflow_started' ) { startedTimes.set(key, ts); - // The queued duration is anchored on the first start event only — + // The queued duration is anchored on the first start event only, since // subsequent step_started events come from retries. if (!firstStartedTimes.has(key)) { firstStartedTimes.set(key, ts); @@ -314,7 +314,7 @@ function isRunLevel(eventType: string): boolean { } // ────────────────────────────────────────────────────────────────────────── -// Tree gutter — fixed-width, shows branch lines only for the selected group +// Tree gutter: fixed-width, shows branch lines only for the selected group // ────────────────────────────────────────────────────────────────────────── /** Fixed gutter width: 20px root area + 16px for one branch lane */ @@ -465,7 +465,7 @@ function TreeGutter({ } // ────────────────────────────────────────────────────────────────────────── -// Copyable cell — shows a copy button on hover +// Copyable cell: shows a copy button on hover // ────────────────────────────────────────────────────────────────────────── function CopyableCell({ @@ -557,7 +557,7 @@ function deepParseJson(value: unknown): unknown { } if (value !== null && typeof value === 'object') { // Preserve objects with custom constructors (e.g., encrypted markers, - // class instance refs) — don't destructure them into plain objects + // class instance refs); don't destructure them into plain objects if (value.constructor !== Object) { return value; } @@ -645,13 +645,13 @@ function PayloadBlock({ ); } - // Attribute changes — render the changed keys and the writer instead of + // Attribute changes: render the changed keys and the writer instead of // the raw JSON payload. if (eventType === 'attr_set') { return ; } - // Cancellation reason — render the free-text reason as a readable line + // Cancellation reason: render the free-text reason as a readable line // instead of a raw JSON payload (the only field run_cancelled carries). if (eventType === 'run_cancelled') { const cancelReason = @@ -1079,7 +1079,7 @@ export function EventRow({ isLaneEnd={isLaneEnd} /> - {/* Content area — dims when unrelated */} + {/* Content area: dims when unrelated */}
- {/* Expanded details — tree lines continue through this area */} + {/* Expanded details: tree lines continue through this area */} {isExpanded && (
- {/* Continuation gutter — lane line continues if not at lane end */} + {/* Continuation gutter: lane line continues if not at lane end */} >(new Map()); const cacheEventData = useCallback((eventId: string, data: unknown) => { eventDataCacheRef.current.set(eventId, data); @@ -1870,7 +1870,7 @@ function EventListViewInner({ /> )} - {/* Fixed footer — count + load more */} + {/* Fixed footer: count + load more */}
{ @@ -449,7 +449,7 @@ const attributeToDisplayFn: Record< receivedCount: (value: unknown) => String(value), lastReceivedAt: localMillisecondTimeOrNull, disposedAt: localMillisecondTimeOrNull, - // Internal resume plumbing — not surfaced in the UI + // Internal resume plumbing, not surfaced in the UI resumeContext: (_value: unknown) => null, resumeId: (_value: unknown) => null, resumeCapabilities: (_value: unknown) => null, @@ -467,14 +467,14 @@ const attributeToDisplayFn: Record< projectId: (_value: unknown) => null, environment: (_value: unknown) => null, executionContext: (_value: unknown) => null, - // Attributes — string-string metadata attached to the run. + // Attributes: string-string metadata attached to the run. // Rendered as key-value rows in its own collapsible section; // if empty/missing, hidden by the hasDisplayContent gate. attributes: (value: unknown) => { if (!hasDisplayContent(value)) return null; return } />; }, - // Dates — wrapped with TimestampTooltip showing UTC/local + relative time + // Dates: wrapped with TimestampTooltip showing UTC/local + relative time occurredAt: timestampWithTooltipOrNull, createdAt: timestampWithTooltipOrNull, startedAt: timestampWithTooltipOrNull, @@ -678,7 +678,7 @@ const attributeToDisplayFn: Record< }, // Internal encryption plumbing: the run's X25519 public key, used by // cross-run writers to seal payloads to this run. Not actionable for users - // and not secret — hidden rather than rendered as 44 opaque base64 chars. + // and not secret, so hidden rather than rendered as 44 opaque base64 chars. encryptionPublicKey: (_value: unknown) => null, }; @@ -852,7 +852,7 @@ export const AttributePanel = ({ onDecrypt?: () => void; /** Whether decryption is currently in progress */ isDecrypting?: boolean; - /** Resource type of the selected span — used to show targeted loading skeletons. */ + /** Resource type of the selected span, used to show targeted loading skeletons. */ resource?: string; }) => { // Extract workflowCoreVersion from executionContext for display diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index 771b3dfd0e..42e86469cf 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -57,7 +57,7 @@ export interface SelectedSpanInfo { /** * Panel component for workflow traces that displays entity details. * - * This component is rendered OUTSIDE the trace viewer context — it + * This component is rendered OUTSIDE the trace viewer context: it * receives all data via props rather than reading from context. */ export function EntityDetailPanel({ diff --git a/packages/web-shared/src/components/sidebar/events-list.tsx b/packages/web-shared/src/components/sidebar/events-list.tsx index c6254b2493..f7a0d5fa99 100644 --- a/packages/web-shared/src/components/sidebar/events-list.tsx +++ b/packages/web-shared/src/components/sidebar/events-list.tsx @@ -252,7 +252,7 @@ function EventDataBlock({ eventType: string; data: unknown; }) { - // Expired data — show a simple message instead of the raw stub. + // Expired data: show a message instead of the raw stub. // Check both the top-level eventData and nested sub-fields (result, input, etc.) // since the server stubs each ref field independently. if (isExpiredMarker(data) || hasOnlyExpiredFields(data, eventType)) { @@ -274,7 +274,7 @@ function EventDataBlock({ return ; } - // Attribute changes — render the changed keys and the writer instead of + // Attribute changes: render the changed keys and the writer instead of // the raw JSON payload. if (eventType === 'attr_set') { return ; diff --git a/packages/web-shared/src/components/sidebar/resolve-hook-modal.tsx b/packages/web-shared/src/components/sidebar/resolve-hook-modal.tsx index c4debf0464..0c2852d759 100644 --- a/packages/web-shared/src/components/sidebar/resolve-hook-modal.tsx +++ b/packages/web-shared/src/components/sidebar/resolve-hook-modal.tsx @@ -116,13 +116,13 @@ export function ResolveHookModal({ aria-modal="true" aria-labelledby="resolve-hook-modal-title" > - {/* Backdrop — matches Geist dialog ::backdrop */} + {/* Backdrop: matches Geist dialog ::backdrop */}
- {/* Modal card — matches Geist dialog.geist-dialog */} + {/* Modal card: matches Geist dialog.geist-dialog */}
{/* Header */}
diff --git a/packages/web-shared/src/components/stream-viewer.tsx b/packages/web-shared/src/components/stream-viewer.tsx index e09d8f3713..3493df087c 100644 --- a/packages/web-shared/src/components/stream-viewer.tsx +++ b/packages/web-shared/src/components/stream-viewer.tsx @@ -30,7 +30,7 @@ interface StreamViewerProps { } // ────────────────────────────────────────────────────────────────────────── -// Chunk row — memoized to prevent remounts during polling +// Chunk row: memoized to prevent remounts during polling // ────────────────────────────────────────────────────────────────────────── const ChunkRow = React.memo(function ChunkRow({ chunk }: { chunk: Chunk }) { diff --git a/packages/web-shared/src/components/trace-viewer/components/detail-panel-width.ts b/packages/web-shared/src/components/trace-viewer/components/detail-panel-width.ts index a996705ce1..39fa552ed1 100644 --- a/packages/web-shared/src/components/trace-viewer/components/detail-panel-width.ts +++ b/packages/web-shared/src/components/trace-viewer/components/detail-panel-width.ts @@ -2,11 +2,11 @@ * Width model for the span detail panel: the stored value is the user's * preferred width (absolute px, persisted to localStorage on explicit * interaction only), while the container-relative maximum is applied at - * render time — so a width preferred on a wide screen survives a narrower + * render time, so a width preferred on a wide screen survives a narrower * session and restores when the viewer grows again. */ -/** Floor for the detail panel — matches the previous `clamp(280px, …)` floor. */ +/** Floor for the detail panel; matches the previous `clamp(280px, …)` floor. */ export const PANEL_MIN_WIDTH = 280; /** @@ -15,13 +15,13 @@ export const PANEL_MIN_WIDTH = 280; */ export const PANEL_HARD_MAX_WIDTH = 1300; -/** Default width — matches the previous fixed 360px column. */ +/** Default width; matches the previous fixed 360px column. */ export const PANEL_DEFAULT_WIDTH = 360; /** * Width the main (event list + timeline) area keeps while the panel is * resized or the container shrinks. Below `PANEL_MIN_WIDTH + MAIN_MIN_WIDTH` - * of container the panel gives way first, compressing below its own floor — + * of container the panel gives way first, compressing below its own floor: * the list and timeline stay usable at the panel's expense. */ export const MAIN_MIN_WIDTH = 400; diff --git a/packages/web-shared/src/components/trace-viewer/components/detail-panel.tsx b/packages/web-shared/src/components/trace-viewer/components/detail-panel.tsx index e1e039faae..7b917c08a8 100644 --- a/packages/web-shared/src/components/trace-viewer/components/detail-panel.tsx +++ b/packages/web-shared/src/components/trace-viewer/components/detail-panel.tsx @@ -64,7 +64,7 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null { * The span detail aside: content, prev/next/close header, J/K navigation, and * the resizable left border with its width model (see detail-panel-width.ts). * - * Always mounted — it renders null without a selection — so the panel width + * Always mounted (it renders null without a selection) so the panel width * survives closing and reopening within a session. */ export function TraceDetailPanel({ diff --git a/packages/web-shared/src/components/trace-viewer/components/draggable-border.tsx b/packages/web-shared/src/components/trace-viewer/components/draggable-border.tsx index be8eeca4ca..80f19f99b4 100644 --- a/packages/web-shared/src/components/trace-viewer/components/draggable-border.tsx +++ b/packages/web-shared/src/components/trace-viewer/components/draggable-border.tsx @@ -35,7 +35,7 @@ interface DraggableBorderProps { * double-click reset, pointer-capture dragging (works with touch/pen, no * ghost image), and keyboard/ARIA window-splitter support. * - * The panel's positioning ancestor must not clip overflow — the strip hangs + * The panel's positioning ancestor must not clip overflow: the strip hangs * ~8px past the panel edge. */ export function DraggableBorder({ diff --git a/packages/web-shared/src/components/trace-viewer/components/minimap.tsx b/packages/web-shared/src/components/trace-viewer/components/minimap.tsx index 8f4c24f4e0..b45d30494c 100644 --- a/packages/web-shared/src/components/trace-viewer/components/minimap.tsx +++ b/packages/web-shared/src/components/trace-viewer/components/minimap.tsx @@ -200,9 +200,9 @@ export const Minimap = memo(function Minimap({ // Density canvas: one thin line per span across the full run. Sizing is // read live and redraws run straight from the ResizeObserver (which fires - // before paint), so layout changes — e.g. the detail panel opening — never + // before paint), so layout changes (e.g. the detail panel opening) never // paint a frame with a stale, stretched bitmap. - // biome-ignore lint/correctness/useExhaustiveDependencies: themeVersion is a redraw trigger — the canvas re-resolves its token colors when the theme flips + // biome-ignore lint/correctness/useExhaustiveDependencies: themeVersion is a redraw trigger; the canvas re-resolves its token colors when the theme flips useLayoutEffect(() => { const canvas = canvasRef.current; const container = containerRef.current; @@ -276,7 +276,7 @@ export const Minimap = memo(function Minimap({ return { mode: 'resize-right' }; } // Panning a full-extent window is a no-op, so let a drag on the brush - // start a selection instead — the natural first gesture on the map. + // start a selection instead, the natural first gesture on the map. const { start, end } = viewportRef.current; const isFullExtent = start - rootStartMs < rootDurationMs * 0.001 && diff --git a/packages/web-shared/src/components/trace-viewer/components/span-markers.tsx b/packages/web-shared/src/components/trace-viewer/components/span-markers.tsx index eea769b71b..b7bc0fbedb 100644 --- a/packages/web-shared/src/components/trace-viewer/components/span-markers.tsx +++ b/packages/web-shared/src/components/trace-viewer/components/span-markers.tsx @@ -17,7 +17,7 @@ const MARKER_KIND_PREFIX: Record = { export interface VisibleMarker { leftPct: number; - /** Absolute (epoch) timestamp in ms — for the tooltip. */ + /** Absolute (epoch) timestamp in ms, for the tooltip. */ timeMs: number; kind: SpanMarkerKind; } @@ -55,7 +55,7 @@ const MARKER_MIN_GAP_PX = 16; /** * Thin out ticks that would visually collide: walk left-to-right and keep each * one unless it sits within MARKER_MIN_GAP_PX of the last kept tick. Well-spaced - * markers survive even when a tight cluster elsewhere on the bar gets thinned — + * markers survive even when a tight cluster elsewhere on the bar gets thinned; * zoom in to resolve a cluster. */ export function cullCollidingMarkers( @@ -87,7 +87,7 @@ function MarkerTick({ className }: { className?: string }): ReactNode { } /** - * Point-in-time markers overlaid on a bar — one vertical tick per event (hook + * Point-in-time markers overlaid on a bar: one vertical tick per event (hook * resumptions and attribute writes), centered on the bar. Each tick sits inside * a larger hit target and, on hover, shows the shared relative-time context * card, prefixed with the event kind. The position is clamped a hair inside @@ -152,7 +152,7 @@ export function OffscreenMarkerIndicator({ aria-label={label} title={label} onClick={(e) => { - // Don't let the row's onClick fire — revealing shouldn't also + // Don't let the row's onClick fire, since revealing shouldn't also // change the span selection. e.stopPropagation(); onReveal?.(targetMs); diff --git a/packages/web-shared/src/components/trace-viewer/components/timeline.tsx b/packages/web-shared/src/components/trace-viewer/components/timeline.tsx index bd9af074da..6732cd7c67 100644 --- a/packages/web-shared/src/components/trace-viewer/components/timeline.tsx +++ b/packages/web-shared/src/components/trace-viewer/components/timeline.tsx @@ -432,7 +432,7 @@ const TimelineBar = memo(function TimelineBar({ ); // Markers that fall outside the visible window (scrolled off while zoomed in) - // — surfaced as edge indicators so they aren't silently lost. + // are surfaced as edge indicators so they aren't silently lost. const offscreen = useMemo( () => geometry.mode.kind === 'full' @@ -543,7 +543,7 @@ export { TimelineBar }; // --------------------------------------------------------------------------- // Horizontal distance between the anchor bar's measured edge and the vertical -// guide — also the width of the connector stub bridging the two. +// guide; also the width of the connector stub bridging the two. const MEASURE_GUIDE_OUTSET_PX = 4; const DeltaMeasureLine = memo(function DeltaMeasureLine({ @@ -567,7 +567,7 @@ const DeltaMeasureLine = memo(function DeltaMeasureLine({ // It sits just outside the anchor bar's measured edge (so it doesn't blend // into the bar's border), joined to the bar by a short horizontal stub. The // line runs from the elbow corner (the guide's x) to the hovered span's - // measured edge — pulled short of the edge arrow when the hovered span is + // measured edge, pulled short of the edge arrow when the hovered span is // fully off-screen. const guideTop = Math.min(anchorCenterY, lineY); const guideBottom = Math.max(anchorCenterY, lineY); @@ -675,7 +675,7 @@ export function TimelineHeader({ export interface TimelineHover { /** Pointer x as a fraction of the timeline's content width, in [0, 1]. */ fraction: number; - /** Row index under the pointer; may be past the last row — not validated. */ + /** Row index under the pointer; may be past the last row (not validated). */ rowIndex: number; } diff --git a/packages/web-shared/src/components/trace-viewer/components/use-row-window.ts b/packages/web-shared/src/components/trace-viewer/components/use-row-window.ts index 3683485bdb..7640b9fbfe 100644 --- a/packages/web-shared/src/components/trace-viewer/components/use-row-window.ts +++ b/packages/web-shared/src/components/trace-viewer/components/use-row-window.ts @@ -96,7 +96,7 @@ export const ROW_HEIGHT_PX = 40; * Scroll a windowed row into view by index. * * The list is virtualized (fixed `rowHeight` rows), so an off-screen row has no - * DOM node to `scrollIntoView` — its target offset is computed from its index + * DOM node to `scrollIntoView`; its target offset is computed from its index * instead. Walks up from `listEl` to the shared scrollable ancestor (the same * one `useRowWindow` measures against), only scrolls when the row sits outside * the visible area, leaves a one-row `margin` of breathing room past it, and diff --git a/packages/web-shared/src/components/trace-viewer/trace-viewer.tsx b/packages/web-shared/src/components/trace-viewer/trace-viewer.tsx index 2b39d7b4ef..ffe7672048 100644 --- a/packages/web-shared/src/components/trace-viewer/trace-viewer.tsx +++ b/packages/web-shared/src/components/trace-viewer/trace-viewer.tsx @@ -231,7 +231,7 @@ function TraceViewerContent({ [root.startTime, root.endTime] ); - // Pan (keeping the current zoom) so `timeMs` is centered in view — used by the + // Pan (keeping the current zoom) so `timeMs` is centered in view; used by the // off-screen marker indicators to scroll their marker into view. const handleRevealTime = useCallback( (timeMs: number) => { @@ -307,7 +307,7 @@ function TraceViewerContent({ // Bring a row into view when keyboard/button navigation lands on a span that // sits outside the shared scroll container's visible area. The list is - // windowed, so an off-screen row has no DOM node to `scrollIntoView` — + // windowed, so an off-screen row has no DOM node to `scrollIntoView`; // `scrollRowIntoView` computes the target offset from the span's index. const scrollSpanIntoView = useCallback( (spanId: string) => { diff --git a/packages/web-shared/src/components/trace-viewer/utils.ts b/packages/web-shared/src/components/trace-viewer/utils.ts index f5d3032b49..5d49b18272 100644 --- a/packages/web-shared/src/components/trace-viewer/utils.ts +++ b/packages/web-shared/src/components/trace-viewer/utils.ts @@ -78,7 +78,7 @@ export function clampViewportToRoot( } // --------------------------------------------------------------------------- -// Wheel gestures — shared between the timeline and the minimap +// Wheel gestures: shared between the timeline and the minimap // --------------------------------------------------------------------------- /** Convert a wheel delta to pixel units (line-mode deltas arrive in lines). */ @@ -164,7 +164,7 @@ export function computeTimeMarkers( } // --------------------------------------------------------------------------- -// Span gaps — time deltas between consecutive spans (Alt-key overlay) +// Span gaps: time deltas between consecutive spans (Alt-key overlay) // --------------------------------------------------------------------------- export interface SpanGap { @@ -254,8 +254,8 @@ export function computeSpanDelta( const deltaMs = laterStart - originMs; // Entirely outside the viewport. Compared in time-space (not clamped - // fractions) so a zero-delta point sitting exactly on a viewport edge — - // e.g. the root span selected at default zoom — still renders. + // fractions) so a zero-delta point sitting exactly on a viewport edge + // (e.g. the root span selected at default zoom) still renders. if (laterStart < viewStart || originMs > viewEnd) { return null; } @@ -294,7 +294,7 @@ export const RESOURCE_CLASS_NAMES: Record< className: 'border-green-500 bg-green-200', errorClassName: 'border-red-500 bg-red-200', }, - // Passive spans (hooks) stay gray — matches event-list icons and the minimap. + // Passive spans (hooks) stay gray; matches event-list icons and the minimap. hook: { className: 'border-gray-500 bg-gray-200', errorClassName: 'border-red-500 bg-red-200', @@ -317,7 +317,7 @@ export function getResourceClassNames(resource: string): { } // --------------------------------------------------------------------------- -// Span segments — split a timeline bar into colored sections by event state +// Span segments: split a timeline bar into colored sections by event state // --------------------------------------------------------------------------- export type SegmentStatus = @@ -603,7 +603,7 @@ export function computeSpanSegments(span: Span): Segment[] { } // --------------------------------------------------------------------------- -// Span markers — point-in-time events rendered as ticks on top of a bar +// Span markers: point-in-time events rendered as ticks on top of a bar // --------------------------------------------------------------------------- export type SpanMarkerKind = 'hook_received' | 'attr_set'; @@ -625,7 +625,7 @@ export function computeSpanMarkers(span: Span): SpanMarker[] { export interface OffscreenSide { count: number; - /** Nearest off-screen marker — the one a reveal jumps to. */ + /** Nearest off-screen marker: the one a reveal jumps to. */ nearestMs: number; } diff --git a/packages/web-shared/src/components/ui/data-inspector.tsx b/packages/web-shared/src/components/ui/data-inspector.tsx index 7694e16cd9..6071db45e7 100644 --- a/packages/web-shared/src/components/ui/data-inspector.tsx +++ b/packages/web-shared/src/components/ui/data-inspector.tsx @@ -842,7 +842,7 @@ export function collapseRefs(data: unknown): unknown { if (data instanceof Set) { return new Set(Array.from(data.values(), collapseRefs)); } - // Only recurse into plain objects — leave class instances untouched + // Only recurse into plain objects; leave class instances untouched const proto = Object.getPrototypeOf(data); if (proto !== Object.prototype && proto !== null) return data; const result: Record = {}; diff --git a/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx b/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx index 1487167b09..fe394f963f 100644 --- a/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx +++ b/packages/web-shared/src/components/ui/duplicate-event-tooltip.tsx @@ -10,9 +10,10 @@ import { } from './tooltip'; /** - * Explains why an event row is shown greyed out — a repeat the runtime read - * past ({@link DUPLICATE_EVENT_MESSAGE}), a backend seal for an abandoned - * position (`SEALED_EVENT_MESSAGE`), or any other notice a list attaches. + * Explains why an event row is shown greyed out, whether it is a repeat the + * runtime read past ({@link DUPLICATE_EVENT_MESSAGE}), a backend seal for an + * abandoned position (`SEALED_EVENT_MESSAGE`), or any other notice a list + * attaches. * * Renders `children` untouched when `notice` is absent, so a call site can * wrap an event label unconditionally. Mounts its own {@link TooltipProvider} diff --git a/packages/web-shared/src/components/ui/error-stack-block.tsx b/packages/web-shared/src/components/ui/error-stack-block.tsx index 7c414d6a64..776f34ac01 100644 --- a/packages/web-shared/src/components/ui/error-stack-block.tsx +++ b/packages/web-shared/src/components/ui/error-stack-block.tsx @@ -39,10 +39,10 @@ export function isStructuredErrorWithStack( /** * Pull a short, single-line title out of an error message. * - * Workflow's structured error messages are multi-line — the first line is + * Workflow's structured error messages are multi-line: the first line is * the headline (`Failed to serialize step return value`) and the rest are * `╰▶ hint:` / `╰▶ docs:` framed details. The full message belongs in the - * body of the error block; the title should just be the headline so the + * body of the error block; the title should be the headline so the * card stays scannable. */ function deriveTitle(message: string): string { @@ -94,7 +94,7 @@ export function ErrorStackBlock({ value }: { value: StructuredErrorRecord }) {

{ * `isCompleteHistory` must be false whenever the caller holds a subset of the * run's log: one page of a paginated list, or the result of a search. Which * occurrence of a class came first is a property of the whole log, so on a - * subset the earlier event may simply be missing, and the fold would report + * subset the earlier event may be missing, and the fold would report * the surviving one. Nothing is classified in that case. * * Two other things make the answer unknowable and yield the same empty result: diff --git a/packages/web-shared/src/lib/hydration.ts b/packages/web-shared/src/lib/hydration.ts index 25297fb15f..2127a48aaa 100644 --- a/packages/web-shared/src/lib/hydration.ts +++ b/packages/web-shared/src/lib/hydration.ts @@ -137,8 +137,8 @@ export function getWebRevivers(): Revivers { // entry for each built-in Error subclass plus the workflow-specific // `FatalError` / `RetryableError` / `HookConflictError` / // `RuntimeDecryptionError` and `AggregateError`. Without - // matching revivers here, `devalue.unflatten` throws "Unknown type X" - // — which surfaces in the web o11y UI as "Failed to load resource + // matching revivers here, `devalue.unflatten` throws "Unknown type X", + // which surfaces in the web o11y UI as "Failed to load resource // details: Unknown type FatalError". Error: (value) => { const opts = 'cause' in value ? { cause: value.cause } : undefined; @@ -171,7 +171,7 @@ export function getWebRevivers(): Revivers { // `FatalError` and `RetryableError` are not built-in browser globals, // so we can't resolve a constructor from globalThis. The web o11y UI // doesn't need `instanceof FatalError` to pass (no user code runs - // here) — it just needs `name`, `message`, `stack`, and any extra + // here). The web o11y UI needs `name`, `message`, `stack`, and any extra // enumerable fields to render. Construct a plain `Error` with `name` // set; ObjectInspector reads `constructor.name` for the displayed // class label, but we don't have the real class, so we emit a tagged @@ -209,7 +209,7 @@ export function getWebRevivers(): Revivers { // RetryableError reducer for the rationale around realm-safety). // Rehydrate as a Date so o11y consumers can render it directly. // Guard against payloads from older runtime versions that predate - // the field — without this check, `new Date(undefined)` would + // the field: without this check, `new Date(undefined)` would // produce an Invalid Date rather than omitting the property. if (value.retryAfter != null) { error.retryAfter = new Date(value.retryAfter); @@ -492,17 +492,12 @@ export async function hydrateResourceIOAsync( ); // Payloads may be zstd-compressed (the Web DecompressionStream has no zstd); // register the WASM-backed browser decoder before hydrating. Idempotent and - // lazy — the WASM is only compiled when a zstd payload is actually decoded. + // lazy: the WASM is only compiled when a zstd payload is actually decoded. const { ensureZstdDecoderRegistered } = await import( './zstd-browser-decoder.js' ); ensureZstdDecoderRegistered(); - // Resolve the *full* key capability, not just the symmetric key: a run's - // event log can contain sealed ('encp') payloads that another run wrote to - // it (a cross-deployment hook resumption, say), and opening those needs the - // run's X25519 scalar in addition to its AES key. Both are derived from the - // same 32 bytes the key-retrieval endpoint returns. - // Resolve the *full* key capability, not just the symmetric key: a run's + // Resolve the *full* key capability: a run's // event log can contain sealed ('encp') payloads that another run wrote to // it (a cross-deployment hook resumption, say), and opening those needs the // run's X25519 scalar in addition to its AES key. Both derive from the same @@ -520,7 +515,7 @@ export async function hydrateResourceIOAsync( if (value instanceof Uint8Array) { return hydrateDataWithKey(value, revivers, cryptoKey); } - // Not serialized — return as-is. + // Not serialized, so return as-is. return value; } diff --git a/packages/web-shared/src/lib/sealed-events.ts b/packages/web-shared/src/lib/sealed-events.ts index d54b42ef9d..e3764a4f7d 100644 --- a/packages/web-shared/src/lib/sealed-events.ts +++ b/packages/web-shared/src/lib/sealed-events.ts @@ -6,8 +6,8 @@ import { type Event, isSealedNoopEvent as isSealedNoop } from '@workflow/world'; * A sealed-log backend hands each write its position before the write * commits, so a writer that dies after claiming a position leaves a hole. * The backend closes a provably abandoned hole by writing a `noop` event - * into it — a log-only row the run itself never observes: replay steps over - * it without offering it to any consumer and without advancing the + * into it, creating a log-only row the run itself never observes. Replay steps + * over it without offering it to any consumer and without advancing the * deterministic clock. * * The observability UI mirrors that treatment. A `noop` appears in event diff --git a/packages/web-shared/src/lib/utils.ts b/packages/web-shared/src/lib/utils.ts index 13c1a0a58f..8f8d2a89ce 100644 --- a/packages/web-shared/src/lib/utils.ts +++ b/packages/web-shared/src/lib/utils.ts @@ -80,7 +80,7 @@ export function formatDuration(ms: number, compact = false): string { * Unlike {@link formatDuration}, this keeps sub-second detail, so 1626ms * renders as "1.63s" rather than "2s". `fractionDigits` sets both the number of * decimals on the seconds component (trailing zeros trimmed, so 2000ms is "2s" - * and 1500ms is "1.5s") and the rounding granularity — the default of 2 rounds + * and 1500ms is "1.5s") and the rounding granularity: the default of 2 rounds * to 10ms; the timeline ruler passes fewer digits to match its tick step. * * - < 1s: shows whole milliseconds (e.g. "626ms") diff --git a/packages/web-shared/src/lib/zstd-browser-decoder.ts b/packages/web-shared/src/lib/zstd-browser-decoder.ts index 05cf339dfd..7a560c9c42 100644 --- a/packages/web-shared/src/lib/zstd-browser-decoder.ts +++ b/packages/web-shared/src/lib/zstd-browser-decoder.ts @@ -8,7 +8,7 @@ * * The package leaves WASM sourcing to the caller; we resolve the shipped * `zstd.wasm` as a bundler asset (`new URL(..., import.meta.url)`, the same - * pattern the trace-viewer Worker uses) and compile it once, lazily — the + * pattern the trace-viewer Worker uses) and compile it once, lazily: the * WASM is fetched only the first time a zstd payload is actually decoded. */ import { registerZstdDecoder } from '@workflow/core/serialization-format'; diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 53c8b6dc76..90c3c2e0af 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -8,9 +8,9 @@

Workflow SDK

Vercel logo -NPM version +npm version License -Join the community on GitHub +Join the community on GitHub
@@ -70,7 +70,9 @@ Vercel for managed storage, queuing, scaling, and observability. To self-host, use the Postgres backend or implement a custom [World](https://workflow-sdk.dev/docs/deploying). -There are many third-party Worlds (both self-hosted or managed), see [the Worlds page](https://workflow-sdk.dev/worlds) for a list of maintainer-curated third party worlds. Submit your world by opening updating the [Worlds Manifest](https://github.com/vercel/workflow/blob/main/worlds-manifest.json). +The [Worlds page](https://workflow-sdk.dev/worlds) lists maintainer-curated +third-party Worlds, including self-hosted and managed options. Submit your World +by updating the [Worlds Manifest](https://github.com/vercel/workflow/blob/main/worlds-manifest.json). ## Community @@ -88,7 +90,7 @@ with the team and wider community. By participating, you agree to our ## Security -If you believe you have found a security vulnerability in Workflow SDK, we encourage you to **_responsibly disclose this and not open a public issue_**. +If you find a security vulnerability in Workflow SDK, **_disclose it responsibly instead of opening a public issue_**. To participate in our Open Source Software Bug Bounty program, please email [responsible.disclosure@vercel.com](mailto:responsible.disclosure@vercel.com). diff --git a/packages/workflow/src/internal/builtins.ts b/packages/workflow/src/internal/builtins.ts index 1371fa5212..7b1d57015e 100644 --- a/packages/workflow/src/internal/builtins.ts +++ b/packages/workflow/src/internal/builtins.ts @@ -1,7 +1,7 @@ /** - * These are the built-in steps that are "automatically available" in the workflow scope. They are - * similar to "stdlib" except that are not meant to be imported by users, but are instead "just available" - * alongside user defined steps. They are used internally by the runtime + * Built-in steps available in the workflow scope. Like a standard library, + * they are available alongside user-defined steps, but users do not import + * them. The runtime uses these steps internally. */ export async function __builtin_response_array_buffer( @@ -44,7 +44,7 @@ function formatUnknownError(error: unknown) { * runs in normal Node context with full world access. * * The dispatch reads the world and current run id directly from - * `globalThis` symbols populated by the workflow/step runtime — this + * `globalThis` symbols populated by the workflow/step runtime. This * intentionally avoids importing `@workflow/core` so the Next.js * deferred-entries discoverer can't walk a chain into world adapters * and `@vercel/queue` from this step file. @@ -86,7 +86,7 @@ export async function __builtin_set_attributes( } | undefined; if (typeof world?.runs?.experimentalSetAttributes !== 'function') { - // World adapter doesn't implement attributes yet — no-op the call, + // World adapter doesn't implement attributes yet, so no-op the call, // but emit one process-wide warning so users know their writes are // being dropped. The VM-side validation already ran so the input // is well-formed. diff --git a/packages/world-local/src/config.ts b/packages/world-local/src/config.ts index 1ddb2128a8..a5a107c959 100644 --- a/packages/world-local/src/config.ts +++ b/packages/world-local/src/config.ts @@ -21,7 +21,7 @@ export type Config = { baseUrl?: string; /** * Whether start() should re-enqueue pending/running runs from storage. - * Defaults to true; the `WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS` env var is + * Defaults to true; the `WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS` environment variable is * used as a fallback when this option is unset. Test harnesses that always * start from a clean slate can disable recovery to avoid replaying stale * runs. @@ -52,12 +52,12 @@ export const config = once(() => { * Resolves whether start() should re-enqueue pending/running runs from * storage, following the priority order: * 1. config.recoverActiveRuns (explicit factory option) - * 2. WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS env var (`0`/`false` disables, - * `1`/`true` enables; read lazily to handle late env var setting) + * 2. WORKFLOW_LOCAL_RECOVER_ACTIVE_RUNS environment variable (`0`/`false` disables, + * `1`/`true` enables; read lazily to handle late environment variable setting) * 3. Default: true * - * An unrecognized env value falls through to the default — the env var is an - * escape hatch, not a hard requirement. + * An unrecognized value falls through to the default: the environment variable + * is an escape hatch, not a hard requirement. */ export function resolveRecoverActiveRuns(config: Partial): boolean { if (config.recoverActiveRuns !== undefined) { @@ -80,9 +80,9 @@ export function resolveDirectBaseUrl(config: Partial): string { /** * Resolves the base URL for queue requests following the priority order: * 1. config.baseUrl (highest priority - full override from args) - * 2. WORKFLOW_LOCAL_BASE_URL env var (checked directly to handle late env var setting) + * 2. WORKFLOW_LOCAL_BASE_URL environment variable (checked directly to handle late environment variable setting) * 3. config.port (explicit port override from args) - * 4. PORT env var (explicit configuration) + * 4. PORT environment variable (explicit configuration) * 5. Auto-detected port via getPort (detect actual listening port) */ export async function resolveBaseUrl(config: Partial): Promise { @@ -90,8 +90,8 @@ export async function resolveBaseUrl(config: Partial): Promise { return config.baseUrl; } - // Check env var directly in case it was set after the config was cached - // This is important for CLI tools that set the env var after module import + // Check the environment variable directly in case it was set after the config was cached. + // CLI tools may set the environment variable after module import. if (process.env.WORKFLOW_LOCAL_BASE_URL) { return process.env.WORKFLOW_LOCAL_BASE_URL; } diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index 29cc2990f8..cf35e49f91 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -179,8 +179,8 @@ export function hasTag(fileId: string, tag: string): boolean { * * An untagged world's reads (`readJSONWithFallback`) can only resolve untagged * files, so when it lists entities for recovery it must skip files tagged by - * other worlds (e.g. the vitest harness) sharing the same data directory — - * otherwise it would re-enqueue runs it cannot subsequently read back. + * other worlds (e.g. the vitest harness) sharing the same data directory. + * Otherwise it would re-enqueue runs it cannot subsequently read back. */ export function isUntagged(fileId: string): boolean { return !TAG_PATTERN.test(fileId); @@ -283,7 +283,7 @@ export async function ensureDir(dirPath: string): Promise { } catch (error) { // A filesystem that refuses the directory outright will refuse every write // into it too, and the caller's write would surface as a confusing ENOENT - // on the file rather than a missing directory. Report it here instead — + // on the file rather than a missing directory. Report it here instead, // unless the directory turns out to exist, in which case the failure was // incidental (a race, or an unsearchable parent that reads fine) and the // historical "ignore if already exists" behavior applies. @@ -370,8 +370,8 @@ export async function writeJSON( /** * Writes data to a file using atomic write-rename pattern. * - * Note: While this function uses temp files to avoid partial writes, - * it does not provide protection against concurrent writes from multiple + * This function uses temporary files to avoid partial writes, but it does not + * protect against concurrent writes from multiple * processes. In a multi-writer scenario, the last writer wins. * For production use with multiple writers, consider using a proper * database or locking mechanism. @@ -512,15 +512,15 @@ export async function writeExclusive( * to its visible destination via a hard link. The single `link(2)` call is * the linearization point: * - * - `'linked'` — this call made the destination visible. - * - `'exists'` — another writer published the destination first + * - `'linked'`: this call made the destination visible. + * - `'exists'`: another writer published the destination first * (same meaning as `writeExclusive` returning false). - * - `'missing'` — the staged file was concurrently unlinked, so the + * - `'missing'`: the staged file was concurrently unlinked, so the * promotion atomically lost to whoever removed it and * the destination was never made visible. * * The staged file is left in place on success; callers unlink it - * themselves (a leftover staged file is harmless — it is not at a + * themselves (a leftover staged file is harmless: it is not at a * reader-visible path). */ export async function promoteExclusive( @@ -606,8 +606,8 @@ interface PaginatedFileSystemQueryConfig { } // Cursor formats: -// "timestamp|id" — createdAt order, id for tie-breaking -// "key:" — sort-key order (see getSortKey) +// "timestamp|id": createdAt order, id for tie-breaking +// "key:": sort-key order (see getSortKey) // A run never mixes the two, so a cursor never has to cross formats mid-scan. export const SORT_KEY_CURSOR_PREFIX = 'key:'; @@ -668,8 +668,8 @@ export async function paginatedFileSystemQuery( // Validate filePrefix (typically `${runId}-`) so request-derived prefixes // consistently reject unsafe characters. filePrefix is only used below to - // filter readdir() results by prefix — it doesn't participate in path - // construction — but keeping the validation rule uniform across the + // filter readdir() results by prefix (it doesn't participate in path + // construction), but keeping the validation rule uniform across the // storage layer avoids special cases and catches bad values earlier. if (filePrefix !== undefined) { assertSafeEntityId('filePrefix', filePrefix); @@ -762,7 +762,7 @@ export async function paginatedFileSystemQuery( // We don't expect zod errors to happen, but if the JSON does get malformed, // we skip the item. Preferably, we'd have a way to mark items as malformed, // so that the UI can display them as such, with richer messaging. In the meantime, - // we just log a warning and skip the item. + // we log a warning and skip the item. if (error instanceof z.ZodError) { console.warn( `Skipping item ${fileId} due to malformed JSON: ${error.message}` @@ -783,7 +783,7 @@ export async function paginatedFileSystemQuery( if (parsedCursor?.sortKey) { // Sort-key cursor: the key alone is the total order, so there is no // tie to break. An item without a key cannot be placed relative to - // the cursor at all — that would mean a run mixed the two schemes — + // the cursor at all (that would mean a run mixed the two schemes), // so keep it and let the comparator below order it. if (itemSortKey) { const comparison = itemSortKey.localeCompare(parsedCursor.sortKey); diff --git a/packages/world-local/src/index.ts b/packages/world-local/src/index.ts index 764b43afc5..40e120ff62 100644 --- a/packages/world-local/src/index.ts +++ b/packages/world-local/src/index.ts @@ -99,7 +99,7 @@ export function createWorld(args?: Partial): LocalWorld { // untagged filter, an untagged dev server sharing the data directory with // the vitest harness would list tagged runs (list enumerates every file) // and re-enqueue them, but run_started's tagged-or-untagged read can't - // resolve a foreign tag — yielding "did not return the run entity" 500s + // resolve a foreign tag, yielding "did not return the run entity" 500s // on startup until the message exhausts its deliveries. const fileIdFilter = tag ? (fileId: string) => hasTag(fileId, tag) @@ -129,7 +129,7 @@ export function createWorld(args?: Partial): LocalWorld { // to read each hook to extract its token hash. Constraint // files and markers are untagged (`{sha256}.json` and // `{sha256}.recovery.json`) so listTaggedFiles won't find - // them — we must resolve them via the hook data. + // them. We must resolve them via the hook data. const hooksDir = path.join(basedir, 'hooks'); const taggedHookFiles = await listTaggedFiles(hooksDir, tag); const { HookSchema } = await import('@workflow/world'); @@ -203,7 +203,7 @@ export function createWorld(args?: Partial): LocalWorld { .catch(() => {}); // Delete tagged stream chunks (.{tag}.bin files). Chunks are sharded // one directory per stream (streams/chunks//.{tag}.bin), - // so iterate each per-stream directory — the top-level chunks dir now + // so iterate each per-stream directory: the top-level chunks dir now // holds only subdirectories, so listing it directly would match nothing // and silently leak tagged chunk files across test sessions. const chunksDir = path.join(basedir, 'streams', 'chunks'); diff --git a/packages/world-local/src/init.ts b/packages/world-local/src/init.ts index 5b0f583a0f..1190664fdd 100644 --- a/packages/world-local/src/init.ts +++ b/packages/world-local/src/init.ts @@ -244,7 +244,7 @@ export async function ensureDataDir(dataDir: string): Promise { ); try { await writeFile(testFile, ''); - // Clean up test file — ENOENT during cleanup is harmless (may race + // Clean up test file. ENOENT during cleanup is harmless (may race // with another process performing the same check concurrently) await unlink(testFile).catch((e: NodeJS.ErrnoException) => { if (e.code !== 'ENOENT') throw e; diff --git a/packages/world-local/src/queue.ts b/packages/world-local/src/queue.ts index 071adc4603..84ddefde8d 100644 --- a/packages/world-local/src/queue.ts +++ b/packages/world-local/src/queue.ts @@ -85,7 +85,7 @@ function envTimeoutMs(name: string, fallback: number): number { * transport timeout is retried by the delivery loop with the same durable * message; `0` remains available for applications that need unbounded calls. * - * Both transports honour every field. Over undici this is the `Agent`'s own + * Both transports honor every field. Over undici this is the `Agent`'s own * configuration; over `node:http` (`WORKFLOW_NODE_HTTP`) the two timeouts are * passed per request and `connections` / `keepAliveTimeout` size the socket * pool, so the two env vars tune a delivery identically either way. @@ -201,7 +201,7 @@ export function createQueue(config: Partial): LocalQueue { (async () => { // Honor the caller's requested delivery delay before acquiring a queue // slot. Sleeping outside the semaphore so a delayed message doesn't - // hold a worker hostage for its delay window — the worker should be + // hold a worker hostage for its delay window: the worker should be // free to process other (immediate) messages until this one is ready. // VQS-side queues honor delaySeconds at the broker, so this brings // world-local in line with production behavior. @@ -219,11 +219,11 @@ export function createQueue(config: Partial): LocalQueue { } // Safety limit to prevent infinite loops in the local queue. // The actual max delivery enforcement happens in the workflow handler - // (at MAX_QUEUE_DELIVERIES = 48), so this just needs to be comfortably higher. + // (at MAX_QUEUE_DELIVERIES = 48), so this only needs to be comfortably higher. const MAX_LOCAL_SAFETY_LIMIT = 256; // Number of times the message has actually reached a handler (returned - // ok, a timeoutSeconds re-delivery, or an HTTP error response). This — - // not the loop counter — is the attempt the handler sees via + // ok, a timeoutSeconds re-delivery, or an HTTP error response). This, + // not the loop counter, is the attempt the handler sees via // `x-vqs-message-attempt`, which it counts against MAX_QUEUE_DELIVERIES. // Failures before response headers do not advance this; body failures do, // because the handler has already accepted that delivery. @@ -275,11 +275,11 @@ export function createQueue(config: Partial): LocalQueue { text = await response.text(); } catch (err) { // A transport can fail before response headers or while consuming - // the body. Both are transient — back off and retry the same + // the body. Both are transient: back off and retry the same // durable message rather than leaving its run stalled. Two // failures are not retryable: // - shutdown: close() aborted the agent / the backoff sleep. - // - a detached-ArrayBuffer proxy misconfig, which never succeeds — + // - a detached-ArrayBuffer proxy misconfig, which never succeeds: // rethrow so the outer catch surfaces the actionable guidance. const name = (err as { name?: string } | undefined)?.name; if ( @@ -340,7 +340,7 @@ export function createQueue(config: Partial): LocalQueue { // 5s linear backoff to approximate VQS retry timing in local dev. // VQS uses 5s linear for attempts 1–32, then exponential, but for - // local dev linear 5s is sufficient — the handler enforces the real + // local dev linear 5s is sufficient: the handler enforces the real // cap at MAX_QUEUE_DELIVERIES (48) which keeps total time under ~4min. await setTimeout(5000, undefined, { signal: closeSignal }); } diff --git a/packages/world-local/src/storage/events-storage.ts b/packages/world-local/src/storage/events-storage.ts index 2db488974d..91fb379de6 100644 --- a/packages/world-local/src/storage/events-storage.ts +++ b/packages/world-local/src/storage/events-storage.ts @@ -134,9 +134,9 @@ function getHookRetentionLimitMs(): number { * Per-step in-process async mutex. Serializes concurrent `events.create` calls * that target the same step, so that the "check terminal state, then write step * entity + event" sequence is atomic. Without this, two concurrent step_started - * calls can both pass the not-terminal check and both write step_started events - * — or a step_started can land in the log after step_completed has already - * written, producing unconsumed events on replay. + * calls can both pass the not-terminal check and both write step_started + * events, or a step_started can land in the log after step_completed has + * already written, producing unconsumed events on replay. * * Duplicate step_started events for a non-terminal step are still allowed * (retries legitimately re-start a step), only writes to an already-terminal @@ -149,17 +149,17 @@ function getHookRetentionLimitMs(): number { /** * Sidecar recovery marker that pins a canonical `hook_created` - * eventId for a legacy token claim — one written by a version of + * eventId for a legacy token claim: one written by a version of * this storage that did not yet persist `eventId` inline in the * claim file. Without this marker, two cross-process retries * reading a legacy claim each generate their own eventId, land * their `writeExclusive(eventPath)` calls at different paths, and * append two `hook_created` events for the same `(runId, hookId)`. * - * The marker is written via `writeExclusive` — the first retry to + * The marker is written via `writeExclusive`: the first retry to * land it pins its candidate eventId as canonical, and every * subsequent retry reads and adopts that eventId before the common - * event publish. Schema is just `{ eventId }` because identity is + * event publish. The schema is `{ eventId }` because identity is * already encoded in the marker's filename hash, so different token * lifetimes can never share one marker (see * `hookRecoveryMarkerPath`). @@ -193,7 +193,7 @@ const HookResumeClaimSchema = z.object({ * a reservation: the allocator is per storage instance, so an instance sharing * the directory can publish an unrelated event at the same position first, and * the resume then lands somewhere else. An event read back at the claimed id - * therefore has to be identified, not assumed — returning whatever occupies the + * therefore has to be identified, not assumed: returning whatever occupies the * position reports a `run_started` as the resume's own event and silently drops * the payload. */ @@ -249,7 +249,7 @@ async function findCommittedResumeEvent( * Whether a token claim held by another `(runId, hookId)` can never become * live again and may therefore be released by a new claimant: * - * - the claimed hook's disposal is committed (its dispose lock exists — + * - the claimed hook's disposal is committed (its dispose lock exists: * the durable release of the claim file just hasn't landed yet, or was * lost to a crash between the lock write and the claim delete), or * - the owning run is terminal and its minimum retention has ended, or @@ -306,7 +306,7 @@ async function readHookRecoveryMarker( * Probe the run's event log for an existing `hook_created` event * with the given correlationId. Used by the legacy-claim recovery * path to detect "already published by a pre-upgrade write" before - * pinning a canonical eventId — without this check, a post-upgrade + * pinning a canonical eventId: without this check, a post-upgrade * retry encountering a legacy claim whose `hook_created` was * already written (with the pre-upgrade writer's own eventId) would * pin a *different* eventId via the marker and publish a duplicate @@ -315,7 +315,7 @@ async function readHookRecoveryMarker( * The inline-`eventId` fast path does NOT need this probe: the * canonical eventId is durable in the claim file, so the existing * publish (`writeExclusive(eventPath)`) will fail iff the event - * already exists at that exact path — which is the correct + * already exists at that exact path, which is the correct * "already-published" semantic. */ /** @@ -327,7 +327,7 @@ async function readHookRecoveryMarker( * position-addressable, so it wins. * * Returns `null` for a ULID-numbered run, which falls back to - * `(createdAt, eventId)` — the two never mix within one run. + * `(createdAt, eventId)`. The two never mix within one run. */ function eventSortKey(event: Event): string | null { return isSlotEventId(event.eventId) ? event.eventId : null; @@ -371,17 +371,17 @@ async function findExistingHookCreatedEventId( /** * Repair an "event-first orphan": the hook entity write is deferred * until after the `hook_created` event publish commits (so a failed - * publish cannot mutate already-committed state — see the comment on - * the deferred write), which opens the inverse crash window — a + * publish cannot mutate already-committed state: see the comment on + * the deferred write), which opens the inverse crash window: a * crash AFTER the event publish but BEFORE the deferred entity write * leaves the event in the log with the hook entity missing. A retry * then collides at the event publish and throws - * `EntityConflictError` (correct — the event IS committed), but + * `EntityConflictError` (correct: the event IS committed), but * without this repair the entity would stay missing forever and the * hook would be unresolvable. * * The entity MUST be reconstructed from the persisted canonical - * event's payload — NOT the retry's `eventData` — otherwise a retry + * event's payload (NOT the retry's `eventData`). Otherwise a retry * carrying different `metadata` / `isWebhook` would silently change * committed state. The write uses `writeExclusive` (create-if-absent) * so a concurrent writer racing this repair cannot be overwritten; @@ -419,7 +419,7 @@ async function repairHookEntityFromPersistedEvent( tag ); if (existingHook) { - // Entity already present — not an orphan, leave it untouched. + // Entity already present: not an orphan, leave it untouched. return; } const hook = hookFromCreatedEvent(persistedEvent); @@ -478,7 +478,7 @@ async function pinCanonicalEventIdForLegacyClaim( * In-process per-key async mutex backed by a caller-supplied `Map`. * Used by `createEventsStorage` to serialize same-key event writes * (`step_*` for the same step, `hook_created` for the same hook). - * The map is instantiated per-storage-instance — different + * The map is instantiated per-storage-instance: different * instances do NOT share locks, so two instances sharing one data * directory behave exactly like two separate OS processes from the * locking standpoint. Cross-instance / cross-process arbitration @@ -640,7 +640,7 @@ export function createEventsStorage( // // Runs created before slot ids keep their ULIDs for life. A log may not mix // the two schemes (`events.list` sorts on the id, and they do not - // interleave), so the ids already on disk are the authoritative pin — no + // interleave), so the ids already on disk are the authoritative pin, and no // spec-version negotiation is involved. `null` state below means "this run // is ULID-numbered". // @@ -648,7 +648,7 @@ export function createEventsStorage( // run, never a reservation. A draw reports `published + 1` and leaves the // entry alone, so a write rejected anywhere between its draw and its // publish costs nothing: the next writer draws the same position. That is - // what keeps the log dense, and it is not a rare path — a duplicate + // what keeps the log dense, and it is not a rare path: a duplicate // `step_started` from a concurrent replay is rejected on every storm. // // The cost is that two in-flight writers hold the same candidate. The @@ -693,7 +693,7 @@ export function createEventsStorage( * The watermark alone is a lower bound: it only counts publishes this * instance made or last scanned for, so another instance sharing the * directory can be ahead of it. The candidate is therefore probed upward - * until it lands on a free position — one `stat` that returns ENOENT in the + * until it lands on a free position: one `stat` that returns ENOENT in the * uncontended case. Skipping the probe would be tolerable for an ordinary * write (the exclusive publish bumps it), but not for the writes that * record their candidate in a durable claim for other writers to converge @@ -741,7 +741,8 @@ export function createEventsStorage( * leaves no trace, which is the whole reason the log has no holes. * * A no-op for ULID-numbered runs, where ids are not positions, and for runs - * this instance has never drawn for — the first draw scans the directory. + * this instance has never drawn for, where the first draw scans the + * directory. */ function notePublishedSlot(runId: string, eventId: string): void { const slot = eventIdToSlot(eventId); @@ -898,7 +899,7 @@ export function createEventsStorage( // Per-instance in-process mutexes. Two storage instances sharing // one data directory get independent lock maps, which makes them // behave like two separate OS processes from the locking - // standpoint — cross-instance arbitration relies on the on-disk + // standpoint: cross-instance arbitration relies on the on-disk // `writeExclusive` constraint / claim files instead. Tests use // this to exercise cross-process convergence without spawning // subprocesses. @@ -968,7 +969,7 @@ export function createEventsStorage( // same-hook dedup branch. Without this, two same-tick concurrent // callers can race between the winner's `writeExclusive(claim)` // and `writeJSON(hook)`, making the second caller momentarily - // observe a claim with no matching hook entity — which the + // observe a claim with no matching hook entity, which the // crash-recovery path below would misinterpret as a prior crash // and incorrectly fall through to a second hook entity write. // `hook_received` and `hook_disposed` share the same per-hook lock @@ -976,7 +977,7 @@ export function createEventsStorage( // sequence is atomic with respect to the disposer's "write dispose // lock, delete entity, then append" sequence. Without this, a // resume that passed its existence check before the disposal began - // could append its `hook_received` AFTER `hook_disposed` — an + // could append its `hook_received` AFTER `hook_disposed`, an // ordering that is journaled durably and makes every subsequent // replay of the owning run diverge at that event // (https://github.com/vercel/workflow/issues/2781). @@ -1119,7 +1120,7 @@ export function createEventsStorage( ); if (created) { - // We created the run — also write the run_created event. + // We created the run, so also write the run_created event. // Drawn before this invocation's own id so it takes the // earlier slot: it must replay first. const runCreatedEventId = await mintEventId(effectiveRunId); @@ -1209,8 +1210,8 @@ export function createEventsStorage( // ============================================================ // Lazy step start: a step_started carrying step-creation data - // (stepName + input) is allowed to arrive with no prior step_created - // — it creates the step on the fly (see the materialization block + // (stepName + input) is allowed to arrive with no prior step_created: + // it creates the step on the fly (see the materialization block // below). This mirrors the resilient run_started path. Detect it here // so the entity-creation terminal-run guard treats it like a creation // and the "step must exist" ordering guard doesn't reject it. @@ -1258,7 +1259,7 @@ export function createEventsStorage( } // Creating new entities on terminal runs is not allowed. A lazy - // step_started creates a step, so it is rejected here too — a bare + // step_started creates a step, so it is rejected here too. A bare // (non-lazy) step_started falls through to the step-validation // block below, which uses RunExpiredError for terminal runs. if (createsChildEntity) { @@ -1289,7 +1290,7 @@ export function createEventsStorage( tag ); - // Event ordering: step must exist before these events — except on + // Event ordering: step must exist before these events, except on // the lazy-start path, where step_started creates the step itself. if (!validatedStep && !lazyStepStart) { throw new WorkflowWorldError( @@ -1300,7 +1301,7 @@ export function createEventsStorage( // Lazy start exactly-once gate: a lazy step_started always CREATES // the step (the owned-inline path only sends one for a step whose // step_created it deferred). If the step already exists, a concurrent - // handler won the create — this caller is a loser and must not start + // handler won the create: this caller is a loser and must not start // or run the step. Throw EntityConflictError so the runtime's // executeStep maps it to `skipped`. This is critical: the plain start // transition below permits re-starting a non-terminal step (retries @@ -1313,7 +1314,7 @@ export function createEventsStorage( } // Step terminal state validation. validatedStep can be null only on - // the lazy-start path (no step yet) — there is nothing terminal to + // the lazy-start path (no step yet): there is nothing terminal to // guard against in that case, so these checks are skipped. if (validatedStep) { if (isTerminalStepStatus(validatedStep.status)) { @@ -1338,14 +1339,14 @@ export function createEventsStorage( isHookEventRequiringExistence(data.eventType) && data.correlationId ) { - // Redelivery convergence — checked BEFORE the disposal/existence + // Redelivery convergence, checked BEFORE the disposal/existence // rejections below: if this resume's `(runId, resumeId)` claim is // already committed AND its pinned event is journaled, return that // event as success. The claim proves this exact resume was accepted // while the hook was alive, and the event is already in the log, so // replay observes it either way. Without this, a queue redelivery // of the consumer's re-ensure after the workflow disposed the hook - // (dispose → sleep) is rejected with HookNotFound — which the + // (dispose → sleep) is rejected with HookNotFound, which the // consumer treats as "nothing left to resume" and acks, losing // whatever continuation the message carried. A claim with a // mismatched hookId or payload digest is NOT converged here; it @@ -1389,7 +1390,7 @@ export function createEventsStorage( // delete → `hook_disposed` append, so the hook entity can still // exist (or the disposer may have crashed mid-teardown) while // disposal is already committed. Re-validate the dispose lock - // here — under the per-hook in-process lock taken above — so + // here (under the per-hook in-process lock taken above) so // acceptance observes the same order replay will: once disposal // has committed, the resume is rejected exactly like one that // arrived after teardown finished. @@ -1415,7 +1416,7 @@ export function createEventsStorage( // `hook_received` directly AND has the queue consumer re-ensure it, // both carrying the same `resumeId`, so both may reach here under the // per-hook lock. They must converge on ONE event. Keyed on - // `(runId, resumeId)` — NOT on the hookId — because a reusable hook + // `(runId, resumeId)` (NOT on the hookId) because a reusable hook // receives many distinct resumes and each must record its own event; // only the two writers of a single resume collapse. The claim pins // the canonical eventId BEFORE the append so a cross-process writer @@ -1435,7 +1436,7 @@ export function createEventsStorage( // the first hook's event for a second hook would attribute a // resume to the wrong hook. The two writers of ONE resume always // carry the same hookId, so a mismatch can only mean the claim - // belongs to another hook — reject, mirroring the server's + // belongs to another hook: reject, mirroring the server's // `(runId, resumeId)` constraint identity. if (claim.hookId !== data.correlationId) { throw new EntityConflictError( @@ -1443,7 +1444,7 @@ export function createEventsStorage( ); } // A reused resumeId carrying a different payload is a caller bug, - // not a benign redelivery — reject it exactly like the server's + // not a benign redelivery: reject it exactly like the server's // constraint (which keys the digest into the claim). if ( params.resumePayloadDigest && @@ -1480,7 +1481,7 @@ export function createEventsStorage( } // The resume really is uncommitted: a crash between the claim // write and the append. Take over the append. Adopt the claimed - // position when it is still free — under ULIDs it always is, and + // position when it is still free: under ULIDs it always is, and // adopting keeps two takers writing the same path so one loses // the exclusive create instead of publishing a second event. // When an unrelated event holds it, there is nothing to converge @@ -1569,11 +1570,11 @@ export function createEventsStorage( ? { resumeId: params.resumeId } : {}), }; - // Strip eventData from run_started — it belongs on run_created only. + // Strip eventData from run_started: it belongs on run_created only. if (data.eventType === 'run_started' && 'eventData' in event) { delete (event as any).eventData; } - // Strip only the step `input` from the lazy step_started event row — + // Strip only the step `input` from the lazy step_started event row: // it belongs on the synthetic step_created written above. stepName is // preserved for the client replay consumer's step-name divergence // check (packages/core/src/step.ts). @@ -1612,11 +1613,11 @@ export function createEventsStorage( // // 1. Publish the durable run-terminal marker. Its existence is // the earliest cross-process evidence that the run can never - // accept a new `hook_received` again — the run-level analogue + // accept a new `hook_received` again, the run-level analogue // of the hook dispose marker. // 2. Reap the run's staged (not yet reader-visible) hook_received - // events. A resume publishes in three steps — stage, re-check - // this marker, promote via atomic hard link into `events/` — + // events. A resume publishes in three steps (stage, re-check + // this marker, promote via atomic hard link into `events/`), // so the reap's unlink and the resume's link race on the SAME // staged file and the filesystem decides a single winner: // either the resume's event was already visible before this @@ -1796,7 +1797,7 @@ export function createEventsStorage( // Reuse currentRun from validation (already read above) if (currentRun) { // The error field is SerializedData (Uint8Array) produced by - // dehydrateRunError. We store it verbatim — consumers hydrate it + // dehydrateRunError. We store it verbatim. Consumers hydrate it // via hydrateRunError to reconstruct the original thrown value. run = await writeRunUnderLifecycleLock( basedir, @@ -1921,8 +1922,8 @@ export function createEventsStorage( // step_created: Creates step entity with status 'pending', attempt=0, createdAt set. // Two concurrent invocations with identical correlationIds (e.g. the // snapshot runtime's deterministic correlationIds across replays) - // must be deduped — otherwise both writes succeed and the event log - // ends up with duplicate step_created entries. The outer + // must be deduped, since otherwise both writes succeed and the event + // log ends up with duplicate step_created entries. The outer // withStepLock mutex serializes within a single process; this // The exclusive constraint file additionally protects against // cross-process races (two pnpm workers, redelivered queue messages, @@ -1977,7 +1978,7 @@ export function createEventsStorage( // Sets startedAt only on the first start (not updated on retries) // Reuse validatedStep from validation (already read above) - // Lazy step start: no prior step_created — create the step entity + // Lazy step start: no prior step_created, so create the step entity // and a synthetic step_created event now, then fall through to the // start transition below. Mirrors the resilient run_started path: // the step entity is claimed atomically (first writer wins) and the @@ -2002,7 +2003,7 @@ export function createEventsStorage( // A concurrent handler already claimed the create for this // step. The atomic claim is the exactly-once ownership gate: // only the winner runs the step body inline. Throw - // EntityConflictError — the runtime's executeStep maps this to + // EntityConflictError: the runtime's executeStep maps this to // `skipped`, so the loser does not start or run the step. This // preserves the same "exactly one handler owns each step" // guarantee the separate step_created claim provides today. @@ -2177,7 +2178,7 @@ export function createEventsStorage( ); } // The error field is SerializedData (Uint8Array) produced by - // dehydrateStepError. We store it verbatim — consumers hydrate it + // dehydrateStepError. We store it verbatim. Consumers hydrate it // via hydrateStepError to reconstruct the original thrown value. step = { ...validatedStep, @@ -2426,7 +2427,7 @@ export function createEventsStorage( claimResult.status === 'owned' ? { overwrite: true } : undefined; // Index entries before the event publish (see hook-index.ts - // crash-ordering invariant). `eventId` is final here — the + // crash-ordering invariant). `eventId` is final here: the // dedup-recovery branch above already reassigned it to the // canonical id when applicable. await writeHookCreatedIndexEntries( @@ -2471,7 +2472,7 @@ export function createEventsStorage( tag ); if (existingHook) { - // Release the token claim to free up the token for reuse — + // Release the token claim to free up the token for reuse, // but only if it still points at this hook. A claimant that // force-released this hook's stale claim (see // `isHookTokenClaimReleasable`) may already hold a fresh @@ -2574,14 +2575,14 @@ export function createEventsStorage( tag ); if (!existingWait) { - // Clean up the lock file we just claimed — the wait doesn't exist + // Clean up the lock file we just claimed: the wait doesn't exist await fs.unlink(lockPath).catch(() => {}); throw new WorkflowWorldError( `Wait "${data.correlationId}" not found` ); } // The lock file (writeExclusive above) already prevents concurrent - // completions — no additional status check needed. + // completions, so no additional status check is needed. wait = { ...existingWait, status: 'completed', @@ -2603,22 +2604,22 @@ export function createEventsStorage( // is the cross-process atomic publish primitive: if the file // already exists, returns false instead of overwriting. This // is critical for the hook_created dedup-recovery convergence - // (above) — two workers that adopt the same canonical eventId + // (above): two workers that adopt the same canonical eventId // race here; whoever links the file first wins, the loser // throws EntityConflictError, and the runtime's existing // concurrent-replay catch path at suspension-handler.ts:142 // swallows it. For all other event types, eventIds are // monotonic ULIDs (globally unique by construction) so a // collision indicates a real bug and EntityConflictError is - // also the right surface — same shape as step_created's + // also the right surface, the same shape as step_created's // claim-file behavior. // Last-instant re-validation for `hook_received` (see the acceptance // check above). The per-hook in-process lock already serializes // resume vs. dispose within one storage instance; this second check // narrows the cross-instance window (independent lock maps, shared // filesystem) to the single event write below, matching the - // module's convention that the on-disk lock file — not the - // in-process mutex — is the durable source of truth. + // module's convention that the on-disk lock file (not the + // in-process mutex) is the durable source of truth. if ( data.eventType === 'hook_received' && data.correlationId && @@ -2644,7 +2645,7 @@ export function createEventsStorage( * * A slot id is a position in the run's log, not a globally unique * token, so losing the publish means another writer took the - * position — an ordinary concurrent write. The World's contract is to + * position, an ordinary concurrent write. The World's contract is to * bump and commit rather than reject: `create` must not fail for a * reason its caller could not have avoided. Bumping is refused for: * @@ -2652,7 +2653,7 @@ export function createEventsStorage( * really is a duplicate publish that must surface; * - ids pinned by a durable claim (`hook_created`'s canonical id, * `hook_received`'s resume claim), which exist precisely so two - * writers converge on ONE event — bumping would publish a second. + * writers converge on ONE event (bumping would publish a second). * * A pinned id is only ever read back from a claim its own writer * recorded before publishing, and that writer bumps only when the @@ -2697,13 +2698,13 @@ export function createEventsStorage( // the terminal-transition block earlier in this function). In-memory // locks cannot close the shared-filesystem race this backend // explicitly supports, and a published event file is immediately - // visible to `events.list()` in other processes — so it can never be + // visible to `events.list()` in other processes, so it can never be // "rolled back" after the fact. Instead, the event stays INVISIBLE // to readers until a single atomic filesystem operation decides its // fate: // - // 1. (fast path) reject if the run is already terminal — by - // marker, or by run state for runs that predate the marker — + // 1. (fast path) reject if the run is already terminal (by + // marker, or by run state for runs that predate the marker) // so the common case never creates a file. // 2. STAGE the event at a non-reader-visible path under `.locks`. // 3. re-CHECK the terminal marker; reject if present. @@ -2711,12 +2712,12 @@ export function createEventsStorage( // link; reject if the staged file was reaped (`'missing'`). // // Correctness: the reap's `unlink` and step 4's `link` target the - // same staged file, so the filesystem serializes them — exactly one + // same staged file, so the filesystem serializes them: exactly one // wins. If the link wins, the event was reader-visible before the // reap completed, and therefore before the terminal state and // terminal event were written: acceptance happened-before the // termination and legitimately precedes it. If the unlink wins, - // promotion fails and the event is never visible to any reader — + // promotion fails and the event is never visible to any reader: // there is nothing to roll back. A resume that stages after the // reap has passed necessarily stages after the marker was // committed, so step 3 rejects it. Rejections before step 4 unlink @@ -2788,7 +2789,7 @@ export function createEventsStorage( const promoted = await promoteExclusive(stagedPath, eventPath); if (promoted === 'missing') { // A terminal transition reaped the staged file between the - // check and the link — the atomic loss of the arbitration. + // check and the link, the atomic loss of the arbitration. throw new RunExpiredError( `Workflow run "${effectiveRunId}" is already in a terminal state` ); @@ -2821,7 +2822,7 @@ export function createEventsStorage( // dense, and replay delivers the resume twice. // // `converge` above already answers this case with the committed - // event — it just could not see it yet, because the other taker had + // event. It could not see it yet because the other taker had // not published when this attempt read. Answer it the same way. An // occupant that is NOT this resume is the unrelated-event collision // the bump is for, and still bumps (or conflicts, when pinned). @@ -2862,7 +2863,7 @@ export function createEventsStorage( // leaving an event-first orphan: the event is in the log // but the entity is missing and the hook is unresolvable. // Repair the entity from the PERSISTED event's payload - // (never the retry's — different retry metadata must not + // (never the retry's: different retry metadata must not // change committed state) before surfacing the benign // duplicate to the runtime's concurrent-replay catch path. if (data.eventType === 'hook_created' && data.correlationId) { @@ -2920,7 +2921,7 @@ export function createEventsStorage( // branch above) would mutate an already-committed hook // entity with the retry's payload before the event publish // proved whether this attempt was repairing a missing event - // or just colliding with an already-published `hook_created`. + // or colliding with an already-published `hook_created`. // The branch sets `hookEntityWriteOptions` iff this event // type writes an entity. if (hook && data.eventType === 'hook_created') { @@ -2948,17 +2949,17 @@ export function createEventsStorage( // Inline-delta optimization: a writer can pass `sinceCursor` (the // cursor of the event log as it last saw it). We return the delta of - // events written strictly after that cursor — exactly what an + // events written strictly after that cursor (exactly what an // `events.list({ cursor: sinceCursor, sortOrder: 'asc' })` would - // return right now — so the caller can skip a redundant round-trip. + // return right now) so the caller can skip a redundant round-trip. // // This is computed against the same on-disk log the list path // reads, so it captures everything the fetch would: the event just // written, any attr_set a step body wrote, and any in-band events // (e.g. hook_received, wait_completed) another writer appended since - // the cursor. That equivalence is what makes skipping the fetch safe - // — a missed in-band event cannot diverge replay because the delta - // is the fetch. + // the cursor. That equivalence is what makes skipping the fetch + // safe: a missed in-band event cannot diverge replay because the + // delta is the fetch. // // Any event type qualifies. The write itself decides nothing here; // whether the delta is worth requesting is the caller's call, and @@ -2968,8 +2969,8 @@ export function createEventsStorage( if (typeof params?.sinceCursor === 'string') { // Intentionally no `limit`: this returns a single default-size page, // unlike the `events.list` path which loops `while (hasMore)` to - // exhaustion. That is safe — and must NOT be "fixed" by paginating - // here — because the contract is single-page-or-fallback, not + // exhaustion. That is safe, and must NOT be "fixed" by paginating + // here, because the contract is single-page-or-fallback, not // complete-delta. When the delta overflows one page, // paginatedFileSystemQuery sets `hasMore: true` and slices `data` to // the page (see fs.ts), which we forward verbatim below. The SDK diff --git a/packages/world-local/src/storage/helpers.ts b/packages/world-local/src/storage/helpers.ts index 9d599dc670..e748d4b55b 100644 --- a/packages/world-local/src/storage/helpers.ts +++ b/packages/world-local/src/storage/helpers.ts @@ -106,7 +106,7 @@ export async function isRunTerminalCommitted( // Only ENOENT proves the marker is absent. This check is what // rejects a resume that staged AFTER the terminal reap passed, so a // swallowed EACCES/EMFILE here would let that resume promote its - // event after termination — propagate anything else and fail the + // event after termination, so propagate anything else and fail the // resume instead. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; @@ -118,7 +118,7 @@ export async function isRunTerminalCommitted( /** * Directory holding staged (not yet reader-visible) `hook_received` events - * for a run. Staging lives under `.locks` — outside the `events` directory — + * for a run. Staging lives under `.locks` (outside the `events` directory) * so no list/read path can ever observe an event that has not been promoted. * * Protocol (see the `hook_received` publish block in `events-storage.ts`): @@ -158,11 +158,11 @@ export function pendingHookEventPath( * the terminal run state, so that: * * - any staged event whose promotion has not happened yet is unlinked - * here, making its later `promoteExclusive` fail (`'missing'`) — the + * here, making its later `promoteExclusive` fail (`'missing'`): the * resume is rejected and its event is never reader-visible; * - any event already promoted was, by construction, visible before this - * reap completed — i.e. before the run's terminal state and terminal - * event were written — so it legitimately precedes the termination; + * reap completed (i.e. before the run's terminal state and terminal + * event were written), so it legitimately precedes the termination; * - any event staged after this reap started necessarily staged after the * marker was committed, and the stage→promote path re-checks the marker * between those two operations, so it self-rejects. @@ -185,7 +185,7 @@ export async function reapPendingHookEvents( entries = await fs.readdir(dir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - // No staging directory — nothing was ever staged for this run. + // No staging directory: nothing was ever staged for this run. continue; } // Any other failure means staged files may remain, and this reap is @@ -219,11 +219,11 @@ export async function reapPendingHookEvents( * reader-visible event of the run in the given tag's view. * * `events.list()` orders by `(createdAt, eventId)`, and both are normally - * allocated at `createImpl()` entry — BEFORE the terminal transition's + * allocated at `createImpl()` entry, BEFORE the terminal transition's * marker + reap linearization point. A terminal invocation can therefore * allocate an older key, stall, lose the promote arbitration to a later * `hook_received` (legitimately), and then append its terminal event with - * the stale key — replaying the accepted hook AFTER the terminal event. + * the stale key, replaying the accepted hook AFTER the terminal event. * Terminal transitions call this after their reap to re-derive the key at * the linearization point instead. * @@ -233,12 +233,12 @@ export async function reapPendingHookEvents( * lexicographically greater than every visible eventId regardless of * another process's random ULID bits; and `createdAt` (same timestamp) is * >= every visible event's `createdAt`, which was stamped at that event's - * `createImpl()` entry — before its publish, and thus before this call. + * `createImpl()` entry, before its publish, and thus before this call. * Equal-`createdAt` ties fall to the strictly-dominant eventId. * * ULID-numbered runs only. A slot-numbered run needs no temporal argument: * the next slot dominates every allocated one by construction, so its - * terminal transition just draws from the run's slot allocator after the + * terminal transition draws from the run's slot allocator after the * reap. Callers pick the branch (see `mintDominantEventKey` in * events-storage.ts). */ @@ -257,7 +257,7 @@ export async function mintRunDominantEventKey( ts = maxTs + 1; } } catch { - // Malformed eventId in the log — fall back to the wall clock. + // Malformed eventId in the log: fall back to the wall clock. } } return { eventId: `evnt_${monotonicUlid(ts)}`, createdAt: new Date(ts) }; @@ -267,7 +267,7 @@ export async function mintRunDominantEventKey( * What a run's already-published event ids say about its identity scheme. * * A run keeps the scheme it was created under for its whole life (a log may - * not mix ULID and slot ids — `events.list` sorts on the id, and the two + * not mix ULID and slot ids: `events.list` sorts on the id, and the two * schemes do not interleave), so the ids on disk are the authoritative pin. * `usesSlots` is false for a run with no events yet; the caller decides what a * brand-new run gets. @@ -303,7 +303,7 @@ export async function scanRunEventIds( } catch (error) { // Only ENOENT ("no events directory yet") means there is provably // nothing visible. Any other failure would silently report an empty run, - // which would mint a colliding slot / a non-dominant ULID — let the + // which would mint a colliding slot / a non-dominant ULID, so let the // caller's retry re-run the scan instead. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { throw error; @@ -441,7 +441,7 @@ export async function withHookTokenClaimLock( * event. The claim pins the canonical eventId and records the payload digest * so a reused `resumeId` carrying a different payload can be rejected. * - * Keyed on `(runId, resumeId)` — NOT on the hookId — so a reusable hook + * Keyed on `(runId, resumeId)` (NOT on the hookId) so a reusable hook * (AsyncIterable) that receives multiple distinct resumes each records its * own event, while the two writers of a single resume still collapse. */ @@ -479,7 +479,7 @@ export async function releaseHookTokenClaimIfOwnedBy( * `(token, runId, hookId)` triple. Identity is encoded in the * filename hash so different token lifetimes (e.g. the same token * reused by a later run after the first run was deleted) never - * contend on a single sidecar — without per-lifetime identity, a + * contend on a single sidecar. Without per-lifetime identity, a * stale marker surviving prior-run cleanup could "leak" its * eventId into the new lifetime's recovery and cause divergent * publication. diff --git a/packages/world-local/src/storage/hook-index.ts b/packages/world-local/src/storage/hook-index.ts index 7658a95cb1..c85ebf718f 100644 --- a/packages/world-local/src/storage/hook-index.ts +++ b/packages/world-local/src/storage/hook-index.ts @@ -21,7 +21,7 @@ import { hashToken } from './helpers.js'; * Durable secondary indexes for hook lookups. Event files are keyed by * `{runId}-{eventId}`, so answering "find the live hook_created event * for this token/hookId" used to require scanning the entire global - * event log — O(total history) on every first-time hook creation. + * event log: O(total history) on every first-time hook creation. * * Indexes maintained here: * - `hooks/token-index/{sha256(token)}/{eventId}[.tag].json` → `{runId}` @@ -31,7 +31,7 @@ import { hashToken } from './helpers.js'; * * Crash-ordering invariant: entries are written BEFORE the write they * index (event publish / entity write), so a crash can only leave a - * dangling entry pointing at a write that never landed — readers skip + * dangling entry pointing at a write that never landed. Readers skip * those. A committed event/entity invisible to the index cannot occur. * * Pre-index data directories are handled by a one-time backfill @@ -206,7 +206,7 @@ export function resetHookIndexEnsureCache(): void { /** * One-time backfill of the indexes for data directories created before - * they existed — a single full scan, guarded by a completion marker. + * they existed: a single full scan, guarded by a completion marker. * Concurrent backfills are safe: all writes are idempotent * `writeExclusive` calls with byte-identical content. */ @@ -239,7 +239,7 @@ async function ensureHookIndexesImpl(basedir: string): Promise { await fs.access(markerPath); return; } catch { - // Marker absent — backfill below. + // Marker absent, so backfill below. } const eventsDir = path.join(basedir, 'events'); diff --git a/packages/world-local/src/storage/hooks-storage.ts b/packages/world-local/src/storage/hooks-storage.ts index 621c72e3f3..79f13a5946 100644 --- a/packages/world-local/src/storage/hooks-storage.ts +++ b/packages/world-local/src/storage/hooks-storage.ts @@ -102,7 +102,7 @@ async function isTerminalRunCache( * The liveness checks below subsume the old scan's in-log closure * replay: the dispose lock is written before `hook_disposed` is * appended, the run entity is terminal before any terminal run event - * is appended, and neither is ever deleted — so any closure visible + * is appended, and neither is ever deleted, so any closure visible * in the log is also visible to these checks. */ async function findAvailableHookCreatedEvent( @@ -129,7 +129,7 @@ async function findAvailableHookCreatedEvent( } // A committed disposal (dispose lock on disk) closes the hook even when - // its `hook_disposed` event has not landed in the log yet — the disposer + // its `hook_disposed` event has not landed in the log yet: the disposer // writes the lock, releases the token claim and hook entity, and only // then appends the event. Rebuilding the caches from the log in that // window would resurrect a claim for a hook that is being torn down. @@ -368,7 +368,7 @@ export async function deleteAllHooksForRun( ) { continue; } - // Release the claim only if it still points at this hook — a + // Release the claim only if it still points at this hook, since a // claimant may already hold a fresh claim for the token (see // `isHookTokenClaimReleasable`). await releaseHookTokenClaimIfOwnedBy( diff --git a/packages/world-local/src/storage/legacy.ts b/packages/world-local/src/storage/legacy.ts index 6f1e8eba6a..b3d9004d42 100644 --- a/packages/world-local/src/storage/legacy.ts +++ b/packages/world-local/src/storage/legacy.ts @@ -58,7 +58,7 @@ async function publishLegacyHookReceived( } const promoted = await promoteExclusive(stagedPath, eventPath); if (promoted !== 'linked') { - // 'missing': a terminal transition reaped the staged file — the + // 'missing': a terminal transition reaped the staged file, the // atomic loss of the arbitration. 'exists' cannot happen for a // freshly generated ULID; treat it the same way rather than report // a publish that did not happen. @@ -148,7 +148,7 @@ export async function handleLegacyEvent( case 'hook_received': { // Legacy: Store event only (no entity mutation) // - wait_completed: for replay purposes - // - hook_received: hooks exist via old system, just record the event + // - hook_received: hooks exist via the old system; record the event const eventId = `evnt_${monotonicUlid()}`; const now = new Date(); const event: Event = { diff --git a/packages/world-local/src/storage/run-status-signal.ts b/packages/world-local/src/storage/run-status-signal.ts index 1c9d4f6cbc..99ecf96477 100644 --- a/packages/world-local/src/storage/run-status-signal.ts +++ b/packages/world-local/src/storage/run-status-signal.ts @@ -7,7 +7,7 @@ import { envNumber } from '@workflow/world'; * world-local's store is the filesystem, which has no change notification a * reader can subscribe to. So the wait is built from two halves: * - * - **The emitter below**, signalled by the run-lifecycle writer + * - **The emitter below**, signaled by the run-lifecycle writer * (`writeRunUnderLifecycleLock` in `events-storage.ts`) whenever it commits * a terminal run. In the ordinary local-dev topology the workflow and the * caller awaiting its result live in the same process, so this is the path @@ -19,7 +19,7 @@ import { envNumber } from '@workflow/world'; * invocation, or several workers over one data dir), and the narrow window * between a waiter's read and its subscribe. * - * Neither half is trusted for the status itself — the waiter always re-reads + * Neither half is trusted for the status itself: the waiter always re-reads * the run file, so a missed or duplicated signal only ever costs latency. */ @@ -57,7 +57,7 @@ export function signalRunTerminal(runId: string): void { /** * Wait for the next in-process terminal signal for `runId`, the timeout, or an - * abort — whichever comes first. Resolves either way; the caller decides what + * abort, whichever comes first. Resolves either way; the caller decides what * to do by re-reading the run. */ export function waitForRunTerminalSignal( @@ -84,7 +84,7 @@ export function waitForRunTerminalSignal( // invisible to it, and world-local's store is the filesystem, so between // two backstop reads a process whose only job is `await run.returnValue` // has no active handle at all. Unref'ing this timer let such a process - // drain its loop and exit 0 with the wait unsettled — silently returning + // drain its loop and exit 0 with the wait unsettled, silently returning // nothing for a run that was merely still going. The interval poll this // replaces (`Run#pollReturnValue`) sleeps on a ref'd timer for exactly // this reason, so keeping it ref'd is parity, not a new cost: a caller diff --git a/packages/world-local/src/storage/runs-storage.ts b/packages/world-local/src/storage/runs-storage.ts index 0448b352db..ac092918e0 100644 --- a/packages/world-local/src/storage/runs-storage.ts +++ b/packages/world-local/src/storage/runs-storage.ts @@ -34,7 +34,7 @@ import { /** * Internal extension of `ListWorkflowRunsParams` that adds a `fileIdFilter` * for scoping queries by raw filename (e.g., by tag suffix). Kept out of the - * public `Storage['runs']['list']` surface — consumers of `@workflow/world` + * public `Storage['runs']['list']` surface: consumers of `@workflow/world` * must not see this option. */ export interface LocalListWorkflowRunsParams extends ListWorkflowRunsParams { @@ -65,7 +65,7 @@ export interface LocalRunsStorage { /** * Per-run in-process async mutex. Serializes concurrent writes that - * touch the same run JSON file — both attribute writes via + * touch the same run JSON file: both attribute writes via * `experimentalSetAttributes` and run-lifecycle writes (run_started, * run_completed, run_failed, run_cancelled) acquire it. Without the * shared lock, an attribute write that lands between a lifecycle @@ -128,7 +128,7 @@ export function createRunsStorage( get, /** - * Long poll for a terminal run status — see + * Long poll for a terminal run status. See * `Storage['runs'].waitForTerminalStatus`. * * Reads the run, and while it is non-terminal waits for either an @@ -231,7 +231,7 @@ export function createRunsStorage( } // Server-side validation. The SDK validates before sending, but - // the world is the final authority — re-check so direct callers + // the world is the final authority: re-check so direct callers // (tests, other consumers) cannot bypass the limits. try { validateAttributeChanges(changes, { diff --git a/packages/world-local/src/streamer.ts b/packages/world-local/src/streamer.ts index ccc99e1b3a..2d514b26f2 100644 --- a/packages/world-local/src/streamer.ts +++ b/packages/world-local/src/streamer.ts @@ -91,7 +91,7 @@ function addChunkFilesByExtension( * per stream (`streams/chunks//`) so that listing a stream's * chunks costs O(chunks in that stream) rather than O(chunks in the whole * world). A tail reader polling for new chunks would otherwise `readdir` the - * entire global chunks directory every 100ms — see vercel/workflow#2797. + * entire global chunks directory every 100ms. See vercel/workflow#2797. */ function chunkDirForStream(chunksBaseDir: string, name: string): string { // Name becomes a path segment below; validate it can't escape chunksBaseDir. @@ -106,7 +106,8 @@ function chunkDirForStream(chunksBaseDir: string, name: string): string { * the files live in. Handles tagged and legacy (.json) formats. * * Files are stored per-stream (`/.bin`), so the - * key returned here is already the chunk id — no stream-name prefix to strip. + * key returned here is already the chunk id, with no stream-name prefix to + * strip. */ async function listChunkFilesForStream( chunksBaseDir: string, @@ -378,7 +379,7 @@ export function createStreamer(basedir: string, tag?: string): Streamer { continue; } - // Collected enough data chunks — peek at the next file for EOF/hasMore + // Collected enough data chunks: peek at the next file for EOF/hasMore if (resultChunks.length >= limit) { if (isEofByte(await readFirstByte(filePath))) { streamDone = true; @@ -450,8 +451,8 @@ export function createStreamer(basedir: string, tag?: string): Streamer { // Tears down everything the reader holds open: both emitter listeners // and the filesystem poll interval. Assigned once listeners are wired // up in start(); called on cancel() and on terminal (EOF/close) paths. - // Kept robust (unconditional) so a cancel() while still reading from - // disk can't leak a listener/poll — a signal-bearing step opens one of + // Kept unconditional so a cancel() while still reading from + // disk can't leak a listener/poll: a signal-bearing step opens one of // these readers per invocation, so any leak accumulates fast. let teardown = () => {}; let pollInterval: ReturnType | null = null; @@ -641,7 +642,7 @@ export function createStreamer(basedir: string, tag?: string): Streamer { // If the reader was already cancelled/closed while we were reading // from disk above (start() yields at every await), don't arm the - // poll — cancel()'s teardown ran before this point and would leave + // poll: cancel()'s teardown ran before this point and would leave // the freshly-created interval orphaned. if (streamClosed) { teardown(); diff --git a/packages/world-local/src/telemetry.ts b/packages/world-local/src/telemetry.ts index b18b76e6d3..9d78d8e6e9 100644 --- a/packages/world-local/src/telemetry.ts +++ b/packages/world-local/src/telemetry.ts @@ -19,7 +19,7 @@ async function getOtelApi(): Promise { // Static specifier is intentional: esbuild-bundled targets (the CLI's // `vercel-build-output-api` build, Nitro, Astro) ship a self-contained // bundle with no node_modules, so `@opentelemetry/api` (an optional peer) - // must be inlined at build time — a runtime-built specifier is opaque to + // must be inlined at build time. A runtime-built specifier is opaque to // esbuild and would silently disable tracing there. Bundlers that reject // an unresolvable static `import()` when the peer is absent (Rollup/Vite, // e.g. SvelteKit) externalize it in the framework integration instead. diff --git a/packages/world-postgres/README.md b/packages/world-postgres/README.md index cf37651eaa..a782e135ab 100644 --- a/packages/world-postgres/README.md +++ b/packages/world-postgres/README.md @@ -1,6 +1,6 @@ # @workflow/world-postgres -An embedded worker/workflow system backed by PostgreSQL for multi-host self-hosted solutions. This is a reference implementation - a production-ready solution might run workers in separate processes with a more robust queuing system. +An embedded worker and workflow system backed by PostgreSQL for multi-host self-hosted solutions. This is a reference implementation. A production system might run workers in separate processes with a dedicated queuing system. ## Installation @@ -14,9 +14,9 @@ yarn add @workflow/world-postgres ## Usage -### Basic Setup +### Basic setup -The postgres world can be configured by setting the `WORKFLOW_TARGET_WORLD` environment variable to the package name: +The PostgreSQL World can be configured by setting the `WORKFLOW_TARGET_WORLD` environment variable to the package name: ```bash export WORKFLOW_TARGET_WORLD="@workflow/world-postgres" @@ -46,7 +46,7 @@ export WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN="1" export WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS="30" ``` -### Programmatic Usage +### Programmatic usage You can also create a PostgreSQL world directly in your code: @@ -67,7 +67,7 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const worldFromPool = createWorld({ pool }); ``` -### Application-managed Shutdown +### Application-managed shutdown By default, Graphile Worker responds automatically when the application is asked to shut down. If your application already coordinates shutdown, set `WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN=1` when selecting the package with `WORKFLOW_TARGET_WORLD`, or set `applicationManagedShutdown: true` when calling `createWorld()` directly. Await `world.close()` from your shutdown path so Graphile Worker cannot terminate the process as soon as its queue stops, before your application finishes closing dependent resources: @@ -84,22 +84,22 @@ await world.start(); Use this option only when your application or framework has its own shutdown hook. Handle cleanup errors there and await `world.close()` first, then close the workflow HTTP server and any caller-owned `pg.Pool`. -Closing the world stops the queue from accepting new jobs and waits for active jobs. After Graphile Worker's graceful-shutdown timeout (5 seconds by default), it aborts any workflow HTTP request that is still pending. Graphile Worker then unlocks the same row through its normal failure handling. Graphile counts a delivery attempt when it claims the row, so the aborted delivery consumes that attempt and is retried only if its Graphile attempt budget remains. A one-attempt or final-attempt job is unlocked but not retried. The shutdown handler does not create a replacement row. +Closing the world stops the queue from accepting new jobs and waits for active jobs. After Graphile Worker's graceful-shutdown timeout (5s by default), it aborts any workflow HTTP request that is still pending. Graphile Worker then unlocks the same row through its normal failure handling. Graphile counts a delivery attempt when it claims the row, so the aborted delivery consumes that attempt and is retried only if its Graphile attempt budget remains. A one-attempt or final-attempt job is unlocked but not retried. The shutdown handler does not create a replacement row. An aborted HTTP request does not guarantee that its server-side handler stopped, so workflow and step handlers must continue to tolerate at-least-once execution. Keep the workflow HTTP routes and any caller-owned pool available until `world.close()` resolves. -## Configuration Options +## Configuration options | Option | Type | Default | Description | | ------------------ | --------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `connectionString` | `string` | `process.env.WORKFLOW_POSTGRES_URL`, `process.env.DATABASE_URL`, or `'postgres://world:world@localhost:5432/world'` | Used only when `pool` is omitted, to construct an internal pool | | `maxPoolSize` | `number` | `process.env.WORKFLOW_POSTGRES_MAX_POOL_SIZE` or `pg.Pool` default (`10`) | Optional. Sets the internal `pg.Pool` max size when `createWorld()` creates the pool | -| `pool` | `pg.Pool` | — | Optional. When set, used for Drizzle, Graphile Worker, and stream writes. `world.close()` does not end it. | +| `pool` | `pg.Pool` | Not applicable | Optional. When set, used for Drizzle, Graphile Worker, and stream writes. `world.close()` does not end it. | | `jobPrefix` | `string` | `process.env.WORKFLOW_POSTGRES_JOB_PREFIX` | Optional prefix for queue job names | -| `queueConcurrency` | `number` | `50` | Number of concurrent active step executions per process. Must be high enough to cover any parent→child workflow polling in flight — each `Run#returnValue` await holds a worker slot until the child run terminates. | +| `queueConcurrency` | `number` | `50` | Number of concurrent active step executions per process. Must be high enough to cover any parent→child workflow polling in flight because each `Run#returnValue` await holds a worker slot until the child run terminates. | | `applicationManagedShutdown` | `boolean` | `false`; `WORKFLOW_POSTGRES_APPLICATION_MANAGED_SHUTDOWN=1` enables it for the default package configuration | Whether the application coordinates shutdown and awaits `world.close()` instead of Graphile Worker responding automatically. | -## Environment Variables +## Environment variables | Variable | Description | Default | | -------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------- | @@ -115,17 +115,17 @@ When `pool` is omitted, `maxPoolSize` precedence is: `createWorld({ maxPoolSize For higher worker concurrency, Graphile Worker recommends setting `maxPoolSize` to `10` or `queueConcurrency + 2`, whichever is larger. -## Database Setup +## Database setup This package uses PostgreSQL with the following components: -- **graphile-worker**: For queue processing and job management +- **Graphile Worker**: For queue processing and job management - **Drizzle ORM**: For database operations and schema management - **pg** (node-postgres): For PostgreSQL client connections. Drizzle and Graphile Worker share a `pg.Pool`, while LISTEN uses a dedicated `pg.Client` created from the same connection options. -### Quick Setup with CLI +### Quick setup with CLI -The easiest way to set up your database is using the included CLI tool: +Set up your database with the included CLI tool: ```bash # npm @@ -146,15 +146,15 @@ The CLI and runtime World automatically load the connection string from: 2. `DATABASE_URL` environment variable 3. Default: `postgres://world:world@localhost:5432/world` -### Database Schema +### Database schema The setup creates the following tables: -- `workflow_runs` - Stores workflow execution runs -- `workflow_events` - Stores workflow events -- `workflow_steps` - Stores individual workflow steps -- `workflow_hooks` - Stores webhook hooks -- `workflow_stream_chunks` - Stores streaming data chunks +- `workflow_runs`: Stores workflow execution runs +- `workflow_events`: Stores workflow events +- `workflow_steps`: Stores individual workflow steps +- `workflow_hooks`: Stores webhook hooks +- `workflow_stream_chunks`: Stores streaming data chunks You can also access the schema programmatically: @@ -166,7 +166,7 @@ import * as schema from '@workflow/world-postgres/schema'; Make sure your PostgreSQL database is accessible and the user has sufficient permissions to create tables and manage jobs. -### Data Retention +### Data retention Postgres World does not yet perform general workflow-run cleanup. After a retained Hook's run ends and its deadline passes, reads treat the Hook as absent @@ -175,14 +175,14 @@ and its token can be reused. If the token is never reused, the expired ## Features -- **Durable Storage**: Stores workflow runs, events, steps, hooks, and webhooks in PostgreSQL -- **Queue Processing**: Uses graphile-worker as the durable queue and executes jobs over the workflow HTTP routes -- **Durable Delays**: Re-schedules waits and retries in PostgreSQL +- **Durable storage**: Stores workflow runs, events, steps, hooks, and webhooks in PostgreSQL +- **Queue processing**: Uses Graphile Worker as the durable queue and executes jobs over the workflow HTTP routes +- **Durable delays**: Reschedules waits and retries in PostgreSQL - **Streaming**: Real-time event streaming capabilities -- **Health Checks**: Built-in connection health monitoring -- **Configurable Concurrency**: Adjustable worker concurrency for queue processing +- **Health checks**: Built-in connection health monitoring +- **Configurable concurrency**: Adjustable worker concurrency for queue processing -## Queue Behavior +## Queue behavior - Graphile jobs are acknowledged only after execution finishes, or after the worker durably schedules a delayed follow-up job - Backlog stays in PostgreSQL when all execution slots are busy @@ -220,7 +220,7 @@ pnpm build pnpm test ``` -## World Selection +## World selection To use the PostgreSQL world, set the `WORKFLOW_TARGET_WORLD` environment variable to the package name: diff --git a/packages/world-postgres/src/drizzle/schema.ts b/packages/world-postgres/src/drizzle/schema.ts index c12f28e087..9884182045 100644 --- a/packages/world-postgres/src/drizzle/schema.ts +++ b/packages/world-postgres/src/drizzle/schema.ts @@ -58,7 +58,7 @@ type DrizzlishOfType = { }; /** - * Sadly we do `any[]` right now + * Serialization currently uses `any[]`. */ export type SerializedContent = any[]; @@ -90,8 +90,8 @@ export const runs = schema.table( error: Cbor()('error_cbor'), /** * The high-level error category (USER_ERROR, RUNTIME_ERROR, etc.) from - * a run_failed event. Plaintext metadata for routing — does not require - * decryption or hydration. + * a run_failed event. Plaintext metadata for routing, so it does not + * require decryption or hydration. */ errorCode: varchar('error_code'), /** @@ -108,7 +108,7 @@ export const runs = schema.table( /** * The run's X25519 public key (base64), stamped at creation by SDKs that * support sealed (`encp`) envelopes. Lets cross-run writers seal payloads - * to this run without holding its symmetric key. Not secret — the private + * to this run without holding its symmetric key. Not secret. The private * scalar is re-derived on demand and never stored. Null on runs created by * older SDKs, which fall back to the symmetric path. */ @@ -162,8 +162,8 @@ export const events = schema.table( // by-run lookup and range scan is served by that index already. Keeping one // would cost a second write per event on the table's hottest path. index().on(tb.correlationId), - // Runtime-correlated one-shot events must be unique per (run, correlation) - // — without + // Runtime-correlated one-shot events must be unique per (run, correlation). + // Without // this, two concurrent invocations producing identical correlationIds // (e.g. the snapshot runtime's deterministic ULIDs across replays) can // both insert events, causing duplicate operations in the log. @@ -179,8 +179,8 @@ export const events = schema.table( /** * Which runs are slot-numbered. A row exists iff the run is, so its absence is - * exactly the "this run predates slots, keep minting ULIDs" signal — no scan of - * the event log is needed to tell the two schemes apart. + * exactly the "this run predates slots, keep minting ULIDs" signal, so no scan + * of the event log is needed to tell the two schemes apart. * * A marker, not a counter. Positions are allocated by the insert that occupies * them (`MAX(slot) + 1` read from the log inside the INSERT), so nothing is @@ -257,8 +257,8 @@ export const hooks = schema.table( // Server-synthesized resume slice. Not carried by the hook_created event, // so this backend leaves it null; reads fall back to runs.get. resumeContext: Cbor>()('resume_context'), - // `resumeCapabilities` is deliberately response-only — attested fresh on - // each by-token lookup, never persisted — so it must not become a column. + // `resumeCapabilities` is deliberately response-only (attested fresh on + // each by-token lookup, never persisted), so it must not become a column. } satisfies DrizzlishOfType< Cborized, 'metadata'> >, diff --git a/packages/world-postgres/src/queue.ts b/packages/world-postgres/src/queue.ts index b26622fdfc..38655120e4 100644 --- a/packages/world-postgres/src/queue.ts +++ b/packages/world-postgres/src/queue.ts @@ -381,7 +381,7 @@ export function createQueue( } async function migratePgBossJobs(utils: WorkerUtils): Promise { - // Scenario A: Drizzle migration already ran — staging table exists + // Scenario A: Drizzle migration already ran, so the staging table exists const hasStaging = await pool.query( `SELECT EXISTS ( SELECT 1 FROM information_schema.tables @@ -407,7 +407,8 @@ export function createQueue( return; } - // Scenario B: Drizzle migration didn't run — pgboss schema still exists + // Scenario B: Drizzle migration didn't run, so the pgboss schema still + // exists const hasPgBoss = await pool.query( `SELECT EXISTS ( SELECT 1 FROM information_schema.schemata @@ -634,8 +635,8 @@ export function createQueue( // workflows that use parent→child polling patterns (e.g. awaiting a // child workflow via `childRun.returnValue` inside the parent). // Every such poll holds a worker slot for the duration of the child - // run. Recursive workflows like `fibonacciWorkflow` fan out quickly - // — fib(6) produces ~24 concurrent polling steps at peak, and at + // run. Recursive workflows like `fibonacciWorkflow` fan out rapidly. + // fib(6) produces ~24 concurrent polling steps at peak, and at // concurrency=10 (the previous default) it would deadlock on the // default Postgres setup. See packages/core/src/runtime/run.ts and // docs/content/docs/changelog/eager-processing.mdx for context. diff --git a/packages/world-postgres/src/run-status.ts b/packages/world-postgres/src/run-status.ts index 231efac42e..a53477e9f0 100644 --- a/packages/world-postgres/src/run-status.ts +++ b/packages/world-postgres/src/run-status.ts @@ -12,13 +12,13 @@ import { listenChannel } from './streamer.js'; * to hold the read until the run finishes, instead of re-reading it every * second and paying up to a full interval of quantization. Postgres already * has the primitive for that: the run-terminal write issues a `NOTIFY` (see - * {@link notifyRunTerminal}) and the waiter is parked on a `LISTEN` for it — + * {@link notifyRunTerminal}) and the waiter is parked on a `LISTEN` for it, * the same mechanism `createStreamer` uses for stream chunks. * * The notification is a *signal only*: waiters re-read the run row, so a * duplicate or lost message can never produce a wrong answer. Because it can - * be lost — a `NOTIFY` that fires between a waiter's read and its `LISTEN`, a - * dropped listener connection — the wait is also backstopped by a periodic + * be lost (a `NOTIFY` that fires between a waiter's read and its `LISTEN`, or a + * dropped listener connection), the wait is also backstopped by a periodic * re-read ({@link getRunStatusPollIntervalMs}), which bounds the damage of a * miss to one interval. * @@ -46,7 +46,7 @@ const RUN_STATUS_POLL_INTERVAL_MS = 1_000; * * The `NOTIFY` is what makes the wait fast; this only bounds how long a *lost* * notification can go unnoticed, so it is kept at the interval the SDK would - * have polled at anyway — the wait is then never slower than the poll it + * have polled at anyway. The wait is then never slower than the poll it * replaces, and normally three orders of magnitude faster. */ export function getRunStatusPollIntervalMs(): number { @@ -71,14 +71,14 @@ export async function notifyRunTerminal( try { await drizzle.execute(sql`SELECT pg_notify(${RUN_STATUS_TOPIC}, ${runId})`); } catch { - // Intentionally ignored — see above. + // Intentionally ignored. See above. } } export interface RunStatusListener { /** * Resolve when `runId` is announced terminal, when `timeoutMs` elapses, or - * when `signal` aborts — whichever is first. The caller decides what + * when `signal` aborts, whichever is first. The caller decides what * happened by re-reading the run. */ wait(runId: string, timeoutMs: number, signal?: AbortSignal): Promise; @@ -99,7 +99,7 @@ export function createRunStatusListener(pool: Pool): RunStatusListener { const ensureSubscribed = () => { if (subscription) return subscription; - // A failed LISTEN must be re-attemptable — a database restart or a brief + // A failed LISTEN must be re-attemptable. A database restart or a brief // network blip at process start would otherwise degrade every wait to // backstop polling for the lifetime of the process. Bounded by a backoff // so that a genuinely unavailable listener does not turn every waiting @@ -111,7 +111,7 @@ export function createRunStatusListener(pool: Pool): RunStatusListener { }).catch(() => { // No listener connection available (pool options that don't permit a // second client, a database without LISTEN, a restarting server). Waits - // degrade to the backstop re-read — the behavior of a plain poll — and + // degrade to the backstop re-read (the behavior of a plain poll), and // the next wait past the backoff tries again. subscription = undefined; retrySubscribeAfter = Date.now() + LISTEN_RETRY_BACKOFF_MS; @@ -126,7 +126,7 @@ export function createRunStatusListener(pool: Pool): RunStatusListener { const key = `run:${runId}` as const; // Kick off (or reuse) the shared subscription without awaiting it, so - // the listener below is registered in this same tick — a notification + // the listener below is registered in this same tick. A notification // delivered while the connection is still coming up then lands on this // waiter instead of slipping past it. void ensureSubscribed(); diff --git a/packages/world-postgres/src/storage.ts b/packages/world-postgres/src/storage.ts index 6b12dae2f9..f9e572b014 100644 --- a/packages/world-postgres/src/storage.ts +++ b/packages/world-postgres/src/storage.ts @@ -204,8 +204,8 @@ async function allocateEventId( * Inserts one event row, retrying while the position it computed is taken. * * The primary-key conflict is absorbed by `ON CONFLICT DO NOTHING` rather than - * raised, so a lost race costs a retry instead of the enclosing transaction — - * an error inside a transaction would poison it, and these inserts run in one. + * raised, so a lost race costs a retry instead of the enclosing transaction. + * An error inside a transaction would poison it, and these inserts run in one. * Every other unique violation still raises, which is what lets callers * translate a dedup conflict on `workflow_events_entity_creation_unique`. * @@ -276,7 +276,7 @@ async function openEventSlots(db: DrizzleLike, runId: string): Promise { * The report half of bump-and-report: the events sitting on the slots between * the one the writer asked for and the one its write actually landed on. * - * Returns `undefined` when there is nothing to report — the write took the slot + * Returns `undefined` when there is nothing to report: the write took the slot * it asked for, the run is not slot-numbered, or the caller sent a count from a * log that is already ahead of this write. * @@ -382,7 +382,7 @@ export function createRunsStorage( drizzle: Drizzle, /** * Shared `LISTEN` subscription used by `waitForTerminalStatus`. Omit it and - * the wait still works, purely on its backstop re-read — which is what a + * the wait still works, purely on its backstop re-read, which is what a * direct caller constructing storage without a pool gets. */ runStatusListener?: RunStatusListener @@ -414,7 +414,7 @@ export function createRunsStorage( get: getRun, /** - * Long poll for a terminal run status — see + * Long poll for a terminal run status. See * `Storage['runs'].waitForTerminalStatus`. * * Reads the run, and while it is non-terminal parks on the run-terminal @@ -508,7 +508,7 @@ export function createRunsStorage( // Load existing attributes so the SDK-shape validator can produce // a precise error message (cap, duplicate keys, reserved prefix, // byte length). The authoritative cap enforcement happens inside - // the UPDATE statement below — see the `WHERE` clause — so the + // the UPDATE statement below (see the `WHERE` clause), so the // race between this read and the UPDATE cannot push the row past // the per-run cap. const [existing] = await drizzle @@ -655,7 +655,7 @@ async function handleLegacyEventPostgres( case 'hook_received': { // Legacy: Store event only (no entity mutation) // - wait_completed: for replay purposes - // - hook_received: hooks exist via old system, just record the event + // - hook_received: hooks exist via the old system; record the event // // hook_received additionally guards against a concurrent (or already // committed) terminal transition, mirroring the current-spec @@ -826,7 +826,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Lazy, because on a legacy run this mints a ULID and on a slot run it // reads which of the two schemes applies. Every caller below awaits it // immediately before its insert. A caller that has already fixed the id - // — run_created, which always takes the first slot — gets that back. + // (run_created, which always takes the first slot) gets that back. const getEventId = async ( db: DrizzleLike = drizzle ): Promise> => @@ -1025,7 +1025,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } // Lazy step start: a step_started carrying step-creation data - // (stepName + input) may arrive with no prior step_created — it creates + // (stepName + input) may arrive with no prior step_created. It creates // the step on the fly (see the materialization block below). This // mirrors the resilient run_started path. Detect it here so the // entity-creation terminal-run guard treats it like a creation and the @@ -1092,7 +1092,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } // Creating new entities on terminal runs is not allowed. A lazy - // step_started creates a step, so it is rejected here too — a bare + // step_started creates a step, so it is rejected here too. A bare // (non-lazy) step_started falls through to the step-validation block // below, which uses RunExpiredError for terminal runs. if (createsChildEntity) { @@ -1129,7 +1129,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { validatedStep = existingStep ?? null; - // Event ordering: step must exist before these events — except on the + // Event ordering: step must exist before these events, except on the // lazy-start path, where step_started creates the step itself. if (!validatedStep && !lazyStepStart) { throw new WorkflowWorldError( @@ -1140,7 +1140,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Lazy start exactly-once gate: a lazy step_started always CREATES the // step (the owned-inline path only sends one for a step whose // step_created it deferred). If the step already exists, a concurrent - // handler won the create — this caller is a loser and must not start or + // handler won the create. This caller is a loser and must not start or // run the step. Throw EntityConflictError so the runtime's executeStep // maps it to `skipped`. Critical: the start UPDATE below permits // re-starting a non-terminal step (retries rely on that), so without @@ -1181,7 +1181,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // where the hook was already gone when the request arrived. It is NOT // what orders a delivery against a disposal: the disposal can commit in // the gap between this read and the append. Both writers take the hook's - // row lock for that — see the `hook_disposed` and `hook_received` + // row lock for that. See the `hook_disposed` and `hook_received` // branches below. if (isHookEventRequiringExistence(data.eventType) && data.correlationId) { const [existingHook] = await drizzle @@ -1239,8 +1239,8 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { .returning(); // No row back means the run already exists: the resilient start path // (run_started on a non-existent run) won a TOCTOU race and created - // it. Surface the conflict rather than returning `{ run: undefined }` - // — start() already treats EntityConflictError as benign, and falling + // it. Surface the conflict rather than returning `{ run: undefined }`. + // start() already treats EntityConflictError as benign, and falling // through would append a duplicate run_created event to the log. if (!runValue) { throw new EntityConflictError( @@ -1260,8 +1260,8 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // duplicate run_started event. This makes run_started idempotent // for concurrent invocations: replay is deterministic, so letting // multiple callers proceed with the same run is safe. We skip - // preloaded events here because this is a rare race-condition path - // — the runtime falls back to loadWorkflowRunEvents(). + // preloaded events here because this is a rare race-condition path. + // The runtime falls back to loadWorkflowRunEvents(). if (currentRun?.status === 'running') { const [fullRun] = await drizzle .select() @@ -1421,9 +1421,9 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { const { changes, allowReservedAttributes } = data.eventData; // Dedup pre-check for correlated workflow writes: if the event is // already in the log (a redelivered/replayed duplicate), reject - // BEFORE materializing onto the run. Without this, a duplicate — + // BEFORE materializing onto the run. Without this, a duplicate, // including a pathological one carrying different changes for the - // same correlationId — would mutate `run.attributes` and then fail + // same correlationId, would mutate `run.attributes` and then fail // the event insert, leaving the snapshot out of sync with the // event log. The unique index on the insert below still guards the // truly-concurrent race; both writers of that race carry identical @@ -1485,7 +1485,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { if (!runValue) { // The guarded update matches zero rows either because the cap // condition failed or because the run row disappeared between the - // existence check above and this update — distinguish the two so + // existence check above and this update. Distinguish the two so // the error is not misattributed. const [stillExists] = await drizzle .select({ runId: Schema.runs.runId }) @@ -1502,7 +1502,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { run = deserializeRunError(compact(runValue)); } - // Strip eventData from run_started — it belongs on run_created only. + // Strip eventData from run_started. It belongs on run_created only. // For step_started on the lazy-start path, strip only the step `input` // (it belongs on the synthetic step_created written below); `stepName` // is preserved for the client replay consumer's step-name divergence @@ -1632,7 +1632,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { ); } - // The terminal-state guard is part of the UPDATE, not just the + // The UPDATE includes the terminal-state guard in addition to the // earlier validation read. That closes the race where another // writer completes/fails the step between validation and start. const [stepValue] = await tx @@ -1844,7 +1844,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // we are trying to create, this is either a duplicate / replayed // processing of the same hook_created (not a real conflict), or // an orphaned hook row from a prior crashed attempt (the hook - // INSERT below landed but the events INSERT below didn't — + // INSERT below landed but the events INSERT below didn't; // these writes are not in one transaction). Distinguish by // checking whether the `hook_created` event actually exists in // the event log: @@ -1891,7 +1891,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { } else { // Cross-hook / cross-run conflict: a different // (runId, hookId) holds this token. Create a hook_conflict - // event instead of throwing 409 — this lets the workflow + // event instead of throwing 409. This lets the workflow // continue and fail gracefully when the hook is awaited. const conflictEventData = { token: eventData.token, @@ -1970,7 +1970,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Handle hook_disposed event: delete the hook entity and append the // disposal in ONE transaction. // - // `DELETE ... RETURNING` ensures only one concurrent caller succeeds — if + // `DELETE ... RETURNING` ensures only one concurrent caller succeeds. If // no rows are returned, the hook was already disposed. The delete also // takes the hook row's lock, and the transaction is what holds it until // the `hook_disposed` row exists. Committed separately (as this used to @@ -2023,8 +2023,8 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // could append a hook_received event after a concurrent // run_completed / run_failed / run_cancelled has already committed. // `FOR UPDATE` takes the run row lock inside this transaction: it - // blocks until any in-flight terminal transition — whose own - // conditional UPDATE takes the same row lock — commits, then + // blocks until any in-flight terminal transition (whose own + // conditional UPDATE takes the same row lock) commits, then // observes the post-commit status. That linearizes this insert // against the run's terminal transition the same way step_started's // guarded UPDATE linearizes against a concurrent terminal step @@ -2053,7 +2053,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // delivery got the lock first and its `hook_received` is ordered // BEFORE the disposal, or the disposer got it and the row is gone and // this delivery is refused. The one order that is unreachable is the - // one that corrupts the run — a `hook_received` journaled behind its + // one that corrupts the run: a `hook_received` journaled behind its // hook's `hook_disposed`, which no replay can consume. // // Under READ COMMITTED (see SLOT_INSERT_TRANSACTION) a locked read of @@ -2299,7 +2299,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // Inline delta: the caller told us the cursor of the log it holds, so // return the page `events.list({ cursor: sinceCursor, sortOrder: 'asc' })` // would return right now and save it the round-trip. Same query, same - // page size, same cursor semantics as `list` below — deliberately not + // page size, same cursor semantics as `list` below. Deliberately not // paginated to exhaustion, since the contract is // single-page-or-fall-back and the caller ignores a delta with // `hasMore: true`. @@ -2332,7 +2332,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { // run-terminal transition passes through here (run_completed / // run_failed / run_cancelled all update the row above), and the update // has committed by now, so a woken waiter re-reads a terminal run. The - // early-return paths above are the idempotent ones — a run that was + // early-return paths above are the idempotent ones: a run that was // *already* terminal, whose original transition announced itself. The // one terminal write that does NOT reach here is the legacy // (specVersion < 2) `run_cancelled` shortcut, which returns from @@ -2444,7 +2444,7 @@ export function createEventsStorage(drizzle: Drizzle): Storage['events'] { eq(events.correlationId, params.correlationId), // A correlation id names a step or wait within its run, so an // unscoped query matches one event per run that allocated the same - // id — and the cursor, an event id, cannot tell two such rows + // id, and the cursor, an event id, cannot tell two such rows // apart. Scoped, `(run_id, id)` is the primary key, so it can. eq(events.runId, params.runId), map(params.pagination?.cursor, (c) => diff --git a/packages/world-sim/DESIGN.md b/packages/world-sim/DESIGN.md index 893fb03fd7..134b42df84 100644 --- a/packages/world-sim/DESIGN.md +++ b/packages/world-sim/DESIGN.md @@ -1,7 +1,8 @@ # world-sim: design -How `@workflow/world-sim` is built and why it is built that way. The package -README is the introduction; this is the implementation. +`@workflow/world-sim` models runtime interleavings with a deterministic World, +scheduler, and scenario runner. Its implementation follows the constraints and +tradeoffs below; the package README provides an introduction. Two workspaces: @@ -17,7 +18,7 @@ Two workspaces: | module | responsibility | |---|---| | `world.ts` | the `World` implementation; wraps every method as a call point and attributes it to a writer | -| `store.ts` | in-memory event store — the event → entity state machine, plus the write-time guards | +| `store.ts` | in-memory event store: the event → entity state machine, plus the write-time guards | | `queue.ts` | deterministic queue: records messages, never delivers on its own | | `clock.ts` | virtual clock; patches `Date.now()` readings, not timers | | `ids.ts` | deterministic ULID minting from (virtual time, counter) | @@ -27,10 +28,10 @@ Two workspaces: | `scenario.ts` | runs one `ScenarioSpec` end to end and produces a `ScenarioResult` | | `replay.ts` | cold-start replay verification of a finished log | | `invariants.ts` | consistency checks re-derived from the event log alone | -| `report.ts` | renders a scenario; log positions for every event reference, colour only when the destination is a terminal | +| `report.ts` | renders a scenario; log positions for every event reference, color only when the destination is a terminal | | `streams.ts` | in-memory streamer | | `build.ts` | bundles a project's workflows so the runtime can be handed real compiled code. Its own entry (`@workflow/world-sim/build`), because it reaches a compiler and playing a scenario should not | -| `load.ts` | loads a built bundle's flow handler — the half of the old `build.ts` that needs no compiler | +| `load.ts` | loads a built bundle's flow handler, the half of the old `build.ts` that needs no compiler | | `types.ts` | the public vocabulary | --- @@ -52,7 +53,7 @@ return a fixed timestamp advanced only from each consumed event's `createdAt`. A pass is therefore a pure function of (workflow code, run identity, event log prefix). Stated precisely: **same log prefix → same decisions.** That is the whole basis of durability, and it is also the property the simulator exists to -attack — the interesting bug class is not "the workflow behaved randomly" but +attack. The interesting bug class is not "the workflow behaved randomly" but "the decisions and the persisted log disagree", which requires the decisions to have been made against a *different* log than the one that ended up durable. @@ -67,7 +68,7 @@ This is why a flipped branch is dangerous. It does not produce a different step; it produces *a different step wearing the same name badge*. The runtime's divergence check is a step-name comparison at the same ordinal: -``` +```text Replay divergence: step event step_created for step_…445J belongs to "…//settle", but the current step consumer is "…//recoverFirst" ``` @@ -80,14 +81,14 @@ differs is what the consumer finds. On replay the log holds `step_created` / `step_started` / `step_completed` for that correlation id, so the consumer hydrates the recorded result and the step body is never called. First time through, the consumer reaches the end of the log, returns -`NotConsumed`, and the promise never resolves — so the workflow cannot proceed. +`NotConsumed`, and the promise never resolves, so the workflow cannot proceed. When nothing can make further progress a `WorkflowSuspension` is raised carrying the whole `invocationsQueue`. The runtime commits the pending `*_created` events, executes what it can, and runs the workflow again from the top against a longer log: -``` +```text load log → run workflow from top → suspend → commit + execute → run from top → … ``` @@ -98,7 +99,7 @@ message**, so the run wakes up. Payloads landing before the workflow awaits are buffered in a `payloadsQueue`, which is why a duplicate delivery is absorbed rather than lost. -There is no hook state a workflow can read — the surface is `token`, +There is no hook state a workflow can read. The surface is `token`, `getConflict()`, `dispose()`, `then`, `[Symbol.asyncIterator]`. The only way to observe a hook is to attach a continuation and see whether it resolves, which is a *timing* observation, not a state read. That is why the scenario API @@ -165,13 +166,13 @@ type CallPhase = 'before' | 'after'; after `step_started` is durable and before the workflow resumes". Neither phase produces the opposite order, and a write is not atomic, so the -opposite order is reachable: a real backend mints the event id *first* — -DynamoDB does not generate ids, and that id is the log's sort key — and only +opposite order is reachable: a real backend mints the event ID *first*. +DynamoDB does not generate IDs, and that ID is the log's sort key. Only then attempts the storage write. Between the two the event has a position but no visibility, and a write that commits in that window sorts **behind** it. That gap is the point, not a detail. It is the only way to produce an event -*behind* a position a reader has already read past — a complete, consistent log +*behind* a position a reader has already read past: a complete, consistent log prefix that is simply missing an event still in flight. No high-water-mark fence can represent that shape. @@ -184,15 +185,15 @@ phase would have been a second way to say the same thing, and it went unused. Calls made from inside another watch's action are not call points. Without that rule a watch on `events.create` would re-trigger on the `hook_received` -it just wrote, and every scenario using `deliverHook` would recurse forever. +the action wrote, and every scenario using `deliverHook` would recurse forever. The depth is tracked and surfaced in the trace, so a line committed from inside a held call is visibly at depth > 0. -A related rule is easy to get wrong: the depth counter must be raised only by -`asExternal`, which brackets exactly one call. Raising it for the whole +A related rule is error-prone: only `asExternal` may raise the depth counter. +It brackets exactly one call. Raising the counter for the whole duration of a watch *action* is correct for something that returns immediately -and wrong for a hold, which does not return until the scenario releases it — -under that rule, holding one writer makes every other writer's call stop being +and wrong for a hold, which does not return until the scenario releases it. +Under that rule, holding one writer makes every other writer's call stop being a call point, so a held step body's sibling becomes invisible and unsteerable. ### Writer attribution is derived, not instrumented @@ -222,16 +223,16 @@ The writer is printed as a column in every event stream, so a rendered log says read the virtual clock. Timers are deliberately **not** patched: `@workflow/core` uses `setTimeout(fn, 0)` as a macrotask barrier in several ordering-sensitive places (`events-consumer.ts`, `private.ts`), and swapping -those for fake timers would change the very interleavings the simulation exists +those for fake timers would change the interleavings the simulation exists to observe. Real zero-delay timers stay real; only the *readings* of wall time move. The clock never moves on its own. Only the scheduler calls `advanceTo` / `advanceBy`, so two runs of a scenario see the same sequence of timestamps. -### Ids +### IDs -Every id is a function of (virtual time, per-scenario counter) — never +Every ID is a function of (virtual time, per-scenario counter), never `Math.random()` or the host clock. They still have to be real ULIDs, because `@workflow/world` validates run ids with `z.string().ulid()` and decodes the embedded timestamp, so the encoding is standard Crockford base32 with the @@ -248,13 +249,13 @@ production, useless for a simulation. Here `queue()` only *records*. Delivery happens when the scheduler asks, and it always takes the same message: the minimum by `(readyAtMs, enqueueSeq)`. Delays -are virtual — a message 23 hours out is delivered by jumping the clock. +are virtual. The scheduler delivers a message 23 hours out by jumping the clock. `ScenarioSpec.selectNext` can override the choice to pin an order the default would not produce. ### Scheduler -``` +```text take the next message → jump the clock to its delivery time → hand it to the flow handler → wait → repeat until the queue is empty or a budget stops it ``` @@ -268,7 +269,7 @@ scenario would report a spurious stall. The scheduler lives apart from `scenario.ts` because two things drive it: a scenario, and the replay verification that cold-starts a second world. -**One delivery at a time.** This is the deliberate limit of the model — see +**One delivery at a time.** This is the deliberate limit of the model. See §9. --- @@ -283,7 +284,7 @@ staged/promoted hook events, canonical event-id pinning after a crash). One delivery at a time in one process means those races cannot occur, and their absence keeps the file small enough to audit. -What is deliberately kept is every validation that *rejects* an event — +The store deliberately keeps every validation that *rejects* an event: terminal-run guards, step lifecycle ordering, hook token uniqueness, wait duplication. Those rejections are the observable contract the runtime is written against; a simulation that relaxed them would agree with the runtime @@ -293,15 +294,15 @@ about nothing interesting. The fence is off by default and set per scenario (`ScenarioSpec.preconditionGuard`, which flows into `SimWorldOptions`), which is -how a scenario can be run one flag apart from its neighbour. `countGuard` +how a scenario can be run one flag apart from its neighbor. `countGuard` **follows the fence** unless a spec says otherwise, because a World that fences -arms both halves — see below. +arms both halves. See below. **`preconditionGuard`** rejects a replay-context write whose snapshot predates the newest externally-originated event. It is a store option here and not a World capability: the runtime assumes any World may refuse a stale write, so a scenario can change what the store does about one but never what the runtime -expects. It does not describe what world-vercel does to a run either — a +expects. It also does not describe what world-vercel does to a run. A slot-identity run has no snapshot to reject, because the World allocates the event's position at commit time and reports the positions the write skipped over. What the sim's fence covers is the 412 *reception* path the runtime keeps @@ -309,11 +310,11 @@ for Worlds that do fence, and the predicate itself. Its predicate is narrower than the bug class, and the reason is its *shape*, not the event type it watches. The marker advances on `hook_received` **or** -`step_completed`, but it is a **high-water mark** — the newest such write — and +`step_completed`, but it is a **high-water mark**, the newest such write, and the test is `snapshot.updatedAt < marker`, strictly. So it detects a log truncated at the end and is blind to a hole in the middle: when the withheld event is *older* than one the reader can see, the reader's snapshot is never strictly -older than the mark. The hook direction is caught for the mirror-image reason — +older than the mark. The hook direction is caught for the mirror-image reason: the withheld `hook_received` is the newest out-of-band write and the orchestrator's snapshot predates the sleep, so the fence fires and the run reconciles. @@ -358,12 +359,12 @@ the only shape it ever ran against. Both differences are deliberate, and both mean a fenced green here is a claim about the *predicate*, not about any deployment of it: -- The server's retained-id window is a FIFO in **insertion (commit) order** — - its Lua script prunes with `table.remove(ids, 1)`, oldest-inserted — while +- The server's retained-ID window is a FIFO in **insertion (commit) order**. + Its Lua script prunes with `table.remove(ids, 1)`, oldest-inserted, while `pruneRunEventIndex` here sorts by id and drops the smallest, i.e. mint order. The two differ exactly when commits happen out of mint order, which is these scenarios' whole subject, and they differ in *when* - `countRecordedAtOrBelow` goes indeterminate once a run passes 16 events. + `countRecordedAtOrBelow` becomes indeterminate once a run passes 16 events. - Production's watermark is best-effort: region-local Redis, failing open on Redis errors, and blind to a webhook served in another region entirely (see `outside-event-tracker.ts`'s own docs). The sim's is exact and in-process. @@ -395,7 +396,7 @@ the top of `createImpl` (`packages/world-local/src/storage/events-storage.ts`) and writes the file much later, so two concurrent creates take positions N and N+1 and can land in the opposite order. Postgres does the same via `nextval` before `COMMIT`. `world-local` defends this with `mintRunDominantEventKey` -(`src/storage/helpers.ts`) — but only for terminal run events; +(`src/storage/helpers.ts`), but only for terminal run events; `wait_completed` gets no re-derivation. One withheld read poisons a whole invocation, which is worth knowing when @@ -404,7 +405,7 @@ cursor, fetching only events written strictly *after* that position. A withheld event sitting before the cursor can never re-enter that invocation's view. Incremental reads make the hole permanent. -**`beginHookDelivery(token, payload)`** returns an `InFlightWrite` — a write +**`beginHookDelivery(token, payload)`** returns an `InFlightWrite`, a write held between mint and commit, with `eventId` already fixed and `commit()` still pending. Unlike a held writer, nothing is blocked meanwhile, because the receiver is a separate process from the run's invocation. Holding an *inline* @@ -421,7 +422,7 @@ it was held re-mints and takes the tail. That single move collapses both faults above into the same, weaker one. A hold between mint and commit can no longer open a hole, because the held write is not -claiming a position while it waits — it has none until it lands. And +claiming a position while it waits because it has none until it lands. `withholdNextEvent` degrades from serving a read *around* the withheld event to stopping it *at* the event, because a hole is not expressible in a log whose order is its commit order. Both leave the reader short rather than wrong, and @@ -429,7 +430,7 @@ short is precisely what the fence's watermark was designed to catch. Off by default: the sim exists to model the world that exists, and production mints at the boundary because DynamoDB does not generate ids. The value of the -switch is differential — play the book both ways and the diff separates "fails +switch is differential. Play the book both ways, and the diff separates "fails because of the mint-before-commit window" from "fails for some other reason". No scenario in the book sets it; it is meant to be driven from `RunScenarioOptions` or `pnpm sim --append-only`. @@ -476,7 +477,7 @@ seen once more. ### Scripting -`ScenarioApi` is the complete set of sanctioned external inputs — anything a +`ScenarioApi` is the complete set of sanctioned external inputs. Anything a real deployment could do out-of-band has an entry, so the script is a complete description of what happened: @@ -485,7 +486,7 @@ description of what happened: `Tempo` adds the steering: `writer` handles, plus the raw `park` / `until` / `during` primitives. The vocabulary is borrowed from Python's `blanket`, which -does the same for `threading` primitives — the call *parks*, the script issues +does the same for `threading` primitives: the call *parks*, the script issues the *permit*, and the resulting order of permits is the *tempo*. ### Writers @@ -503,7 +504,7 @@ before the step exists and binds to whichever writer shows up under it. Two implementation details of `release()` matter to scenario authors. It is guarded by a `done` flag so double release is a no-op. And it awaits a full -macrotask turn before resolving — without that, `await release()` returns while +macrotask turn before resolving. Otherwise, `await release()` returns while the resumed call is still queued as a microtask, and a scenario reading the log on the next line sees the state it was trying to leave. @@ -513,7 +514,7 @@ It consults the history of points the writer has already reached *before* arming anything, and throws `AlreadyPassedError` naming the call it happened at if the point has gone by. -The alternative — arm a watch and wait — is a hang. A held call blocks its +The alternative, arming a watch and waiting, causes a hang. A held call blocks its writer, and when that writer is the one the scheduler is inside, it blocks the loop; so there is no quiescence to fall back on and no timer to eventually fire. An edge-triggered wait on an edge that has passed is the one way to lose @@ -527,8 +528,8 @@ Three consequences: starting the second yields the event loop, and the other writer may sail past. - **`runTo` on an already-held writer releases it first**, and arms the new watch *before* releasing. That order is load-bearing: the released writer can - reach the next point within the same turn — the `after` phase of the very - call it was held in is the common case — and a watch armed afterwards would + reach the next point within the same turn. The `after` phase of the call it + was held in is the common case, and a watch armed afterwards would miss it. The same rule applies to authors sequencing two writers: arm B before releasing A. - **A call is two records, so `seq` cannot order them.** The `before` and @@ -536,13 +537,13 @@ Three consequences: own `ordinal` and the level check compares against that. A watermark tracks how far each writer has been advanced. Points at or before -it are "already consumed" and do not count as already-passed — asking twice for +it are "already consumed" and do not count as already-passed. Asking twice for `step_completed` means the *next* one, which is what the duplicate-delivery scenarios need. ### What is not offered -Writers form a dependency graph — the orchestrator awaits its own step bodies — +Writers form a dependency graph because the orchestrator awaits its own step bodies, so not every interleaving exists to be asked for, and an unsatisfiable `runTo` can only be reported, not prevented. The runtime's await graph is not visible from here, so true deadlock detection is out of reach; the substitute is a @@ -569,15 +570,15 @@ deadline can offer. It is clamped to `maxWallMs` so lowering the global budget does not require remembering to lower this one. The scenario's global deadline must **not** be `unref`'d. An unref'd timer does -not hold the event loop open, so a total deadlock — every writer held, scheduler -blocked inside a held call, script awaiting the impossible — empties the loop +not hold the event loop open. A total deadlock, with every writer held, the scheduler +blocked inside a held call, and the script awaiting the impossible, empties the loop and exits Node with a bare "unsettled top-level await" instead of firing the watchdog, which is precisely the case the watchdog exists for. The `finally` already clears it, so it cannot outlive a scenario. Stream readers get the same treatment: a reader that parked on an unfinished stream would deadlock the scenario, so readers park on a promise the *writer* -resolves and `abortOpenReaders()` releases any still parked at teardown — +resolves and `abortOpenReaders()` releases any still parked at teardown, turning a hang into a reported diagnostic. Outcomes are `WorkflowRunStatus | 'stalled' | 'budget-exceeded' | 'error'`. A @@ -592,14 +593,14 @@ Two independent checkers run over every scenario. ### Invariants -The store enforces most rules at write time by rejecting bad events — but "the +The store enforces most rules at write time by rejecting bad events, but "the store rejected it" and "the log is actually consistent" are different claims, and only the second is worth trusting. So `invariants.ts` re-derives everything from the event log alone and compares against the entity rows. 25 rules, grouped: -``` +```text log.monotonic-order log.unique-event-id run.created-first run.created-once run.terminal-is-last run.entity-matches-log run.attributes-match-log run.output-materialized @@ -613,7 +614,7 @@ wait.created-once wait.completed-after-created wait.completed-once wait.resume-at-stable ``` -A violation is a bug somewhere — in the runtime that produced the sequence, in +A violation indicates a bug in the runtime that produced the sequence, in the store that accepted it, or in the scenario that injected something impossible. Which one is a question for the reader; the checker's job is only to notice. @@ -626,8 +627,8 @@ would it reconstruct the same run? The check is a **cold start with the answer withheld**. Take the finished log, drop its terminal `run_*` event, load the rest into an empty world as durable -history, and deliver one queue message. The real runtime — the same -`workflowEntrypoint` a deployment serves — replays from the log and must +history, and deliver one queue message. The real runtime (the same +`workflowEntrypoint` a deployment serves) replays from the log and must re-derive the event that was removed, with the same output. No step body re-executes, since every `step_completed` is in the log and the step consumer resolves from it, so anything the replay produces came from the log alone. @@ -642,7 +643,7 @@ equivalent. Six failure ids: `replay.diverged` is the runtime raising `ReplayDivergenceError`, exhausting its recovery replays, and failing the run with `CorruptedEventLogError`. `replay.suspended` means the replay ran out of log before the workflow -finished — the log did not contain enough to rebuild the run. +finished because the log did not contain enough to rebuild the run. --- @@ -650,17 +651,17 @@ finished — the log did not contain enough to rebuild the run. Measured on branch `sim-world`. -**Unit tests** — 72 passing across 8 files (`pnpm --filter @workflow/world-sim test`). +**Unit tests:** 72 passing across 8 files (`pnpm --filter @workflow/world-sim test`). -**Scenarios** — `pnpm sim` in `workbench/sim-world`: +**Scenarios:** `pnpm sim` in `workbench/sim-world`: -``` +```text 41 scenario(s): 38 passed, 3 failed, 3 consistency violation(s) ``` And the same book against an append-only log (`pnpm sim --append-only`): -``` +```text 41 scenario(s): 41 passed, 0 failed, 0 consistency violation(s) ``` @@ -671,14 +672,14 @@ because the correct answer itself changes. There was a seventh red until recently, `unclaimed-payload-under-fork`, and it was a different animal: it tripped a `sim.check` rather than the replay invariant, and it was red in *both* worlds, because nothing was wrong with its -log's positions — the runtime handed the workflow two resolutions in an order +log's positions. The runtime handed the workflow two resolutions in an order the log did not record, so live and replay ran the same code, made the same mistake, and agreed. Only the log disagreeing with itself caught it. #3406 fixed the delivery-barrier ordering and it is now green in both worlds; the scenario stays as that fix's regression test. With the fence forced off (`pnpm sim --no-fence`), violations go to **5** -mint-ordered and stay at **0** append-only — see §5. +mint-ordered and stay at **0** append-only. See §5. Replay verification across the book: **36 `ok`, 3 `MISMATCH`, 2 `skipped`** (skipped where the run did not reach a terminal status). @@ -695,9 +696,9 @@ two means something got fixed and a scenario is ready to retire. That makes the book a poor plain CI gate, which is what `--report-only` is for: it prints every failure and exits 0, so a job can *publish* the book's current state rather than block on it. `--summary-file` writes one collapsed -`
` — a visible line carrying the count and a green or orange dot, the -whole table behind it — for a PR comment or `$GITHUB_STEP_SUMMARY`, and -`--detail-file` writes the full colour-free trace as an artifact to read when a +`
` element with a visible line carrying the count and a green or orange dot, +and the whole table behind it, for a PR comment or `$GITHUB_STEP_SUMMARY`. +`--detail-file` writes the full color-free trace as an artifact to read when a number moves. Deliberately nothing above the fold but the count: three are red on purpose, so a comment that leads with the failures leads with the part that is not news, and grows a wall of text on exactly the PRs that changed nothing. The @@ -711,15 +712,15 @@ strict, so running it by hand fails loudly. |---|---|---| | `in-flight-before-decision` (doc-29) | `beginHookDelivery`, committed before the decision is written | none in the SDK. The hole is a live reservation, so re-reading finds it still empty | | `in-flight-before-decision-counted` (doc-30) | same tempo, count half of the fence armed | same. Mint-ordered the write never reaches the fence | -| `in-flight-after-decision` (doc-31) | `beginHookDelivery`, committed after the decision | none in the SDK. Needs an append-tail fence — `assertSlotAboveTail`, `vercel/workflow-server#692` | +| `in-flight-after-decision` (doc-31) | `beginHookDelivery`, committed after the decision | none in the SDK. Needs an append-tail fence: `assertSlotAboveTail`, `vercel/workflow-server#692` | Those handles are `ScenarioSpec.id`, and they select: `pnpm sim in-flight-after-decision` plays one row of this table. **There used to be six, and slot-numbered event ids closed half of them.** The -four that closed — `stale-read-step-count-fork` (doc-23), +four that closed (`stale-read-step-count-fork` (doc-23), `stale-read-equal-step-counts` (doc-25), `step-vs-step-fork` (doc-26), -`step-vs-step-fork-fenced` (doc-27) — all staged a *read* that was missing an +`step-vs-step-fork-fenced` (doc-27)) all staged a *read* that was missing an event the log already held. Under ULIDs that read was indistinguishable from a complete one, and the fence was the only thing that could have caught it, which is why their `fix` column used to name a predicate. Under slot ids a missing @@ -732,8 +733,8 @@ is now a pairing between two green scenarios. What is left is the family the audit cannot repair by re-reading, because the position really is empty at the moment of the read: a writer has reserved it and has not committed. Mint-ordered, doc-29 and doc-30 now fail *loudly* rather than -silently — the replay refuses a log it cannot follow instead of following it -into the wrong branch — which is a better outcome than the divergence they used +silently. The replay refuses a log it cannot follow instead of following it +into the wrong branch. This outcome is better than the divergence they used to produce, and still a failure. **The append-only log closes all three, and by construction rather than by @@ -742,13 +743,13 @@ a hook that commits after the timeout genuinely *is* after it. The log records the timeout first and the run that settled is the run the log describes. **No expectation is restated per world, and there is no mechanism to.** The -first cut of this had one — an `expectAppendOnly` field on three scenarios, +first cut of this had an `expectAppendOnly` field on three scenarios, naming a second correct output. It was the wrong instrument, for a reason worth keeping written down. A scenario is one sequence of advances. The only thing a world changes is what a read returns. The branch a run ends on is decided by what it read, so pinning the branch pins a consequence of the world rather than a property of the run, and any expectation that then has to be restated per -world is evidence the pin was wrong — not evidence that a second answer is +world is evidence the pin was wrong, not that a second answer is needed. The three now assert what holds in both worlds (the run completes) and report the branch in the trace. @@ -756,12 +757,12 @@ That costs nothing, because the expectations were never what caught the fault. The load-bearing assertion is the invariant: **the log a run wrote must be a log the runtime can replay back into that same run**. It is world-independent, on by default (`verifyReplay`), and it is what all three reds trip. Measured, not -assumed: strip every `expect` in the book and the violation counts do not move -— 3 mint-ordered, 0 append-only, the same three by name. (Pass/fail does move by +assumed: strip every `expect` in the book and the violation counts do not move: +3 mint-ordered and 0 append-only, with the same three by name. (Pass/fail moves by one, and only for a bookkeeping reason: `hook-never-arrives` expects `stalled`, and a stall's reason is reported as a problem unless the scenario said it was expecting one.) That also removes -the one place where the flag's scoreboard rested on a judgement about what the +the one place where the flag's scoreboard rested on a judgment about what the right answer *is* rather than on something the harness checks on its own. doc-30 is worth a line because it was the third `expectAppendOnly`. Its branch @@ -770,8 +771,8 @@ pinned output would have turned a scenario red for ending on the world's answer rather than on a fault. What makes it distinct from doc-29 was never the branch anyway; it is that the fence fires at all. That is asserted directly, matched on `PreconditionFailedError` rather than on "something was rejected", since doc-29 -rejects too. The assertion is scoped to the append-only world, because -mint-ordered the write never reaches the fence — the reservation ahead of it +rejects too. The assertion is scoped to the append-only world because, in +mint-ordered mode, the write never reaches the fence. The reservation ahead of it makes the log unreadable first. Two details worth keeping: @@ -785,7 +786,7 @@ Two details worth keeping: fence once the log is append-only, and doc-30's trace is where to see it. All three are hook-driven, and the two that were not (doc-26 and doc-27, two of -the run's own `step_completed` events) are the ones the gap audit closed — so +the run's own `step_completed` events) are the ones the gap audit closed, so the book no longer holds an open reproduction that needs no out-of-band event type. All the pure hook-timing scenarios pass: placing a hook precisely is what works. What fails is a hook whose position is spoken for but whose write has not @@ -806,8 +807,8 @@ the run makes no writes and so meets no checks. `assertSlotAboveTail` in Reaching that would need concurrent delivery with hold points to pin the interleaving. The gap matters because it is a real production route: `resumeHook` writes `hook_received` *and* enqueues a flow message, so two -deliveries end up in flight — one writing `wait_completed` and deciding -no-hook, one seeing the hook and deciding hook-branch — racing to create the +deliveries end up in flight. One writes `wait_completed` and decides +no-hook, while the other sees the hook and decides hook-branch. They race to create the same ordinal, with every reader holding a perfectly consistent view. Just different ones. @@ -822,17 +823,17 @@ it per lookup, so **every real world takes the parallel path**, where the queue publish races the `hook_received` write and the consumer re-ensures the event through the durable `(runId, resumeId)` claim. The sim advertises neither the capability nor a `resumeId` dedupe, so every sim hook delivery takes the -sequential path — meaning the hook-timing shapes in this book are the *legacy* +sequential path, meaning the hook-timing shapes in this book are the *legacy* shape, not the one production runs. Closing this needs `(runId, resumeId)` dedupe in the store plus the capability; it is the largest single gap for a package about hook races. -**Also untested:** turbo / optimistic-inline-start, which skip replays and so -give a stale branch somewhere to hide; and the fence's same-millisecond -behaviour, where an equal snapshot watermark passes by design as anti-livelock. +**Also untested:** turbo / optimistic-inline-start, which skip replays and give +a stale branch somewhere to hide; and the fence's same-millisecond behavior, +where an equal snapshot watermark passes by design as anti-livelock. -**Not modelled at all:** the concurrency machinery `world-local` needs and this -store omits — claim files, per-entity locks, staged/promoted hook events, +**Not modeled at all:** the concurrency machinery `world-local` needs and this +store omits, including claim files, per-entity locks, staged/promoted hook events, canonical event-id pinning after a crash. Bugs in those are invisible here. --- @@ -844,5 +845,5 @@ reality. Every simplification in §5 and every limit in §10 is a place where a green scenario could be green for the wrong reason. The mitigations are that the store keeps every *rejection* the real one performs, that the runtime under test is the real `workflowEntrypoint` running real compiled workflow code, and that -every scenario ends by replaying its own log through that same runtime — but +every scenario ends by replaying its own log through that same runtime, but none of those is a proof, and a red here is worth more than a green. diff --git a/packages/world-sim/README.md b/packages/world-sim/README.md index f9ab4f4925..7dad4c76ac 100644 --- a/packages/world-sim/README.md +++ b/packages/world-sim/README.md @@ -10,7 +10,7 @@ real World they are races: > durable but *before* the workflow gets control back? In `@workflow/world-local` you would answer that by polling in a loop and -hoping. Here you state it, and it is what happens — every time, byte for byte. +hoping. Here you state it, and it happens the same way every time, byte for byte. @@ -24,7 +24,7 @@ await wf.release(); The resulting event stream: -``` +```text 0 +0ms wf run_created approvalWorkflow input=<17B> 1 +0ms wf run_started 2 +0ms wf hook_created hook_…KX token="approval:doc-1" @@ -39,13 +39,13 @@ The resulting event stream: The second column names the writer. The indented `hook_received` is written by the scenario (`ext`) from *inside* the `events.create` call that committed `step_started`, while the orchestrator is held in it. Advance a different -writer instead — `sim.writer.step('reserveInventory')` — and the same workflow, -same input and same output produce a different log, which is the point. +writer instead, such as `sim.writer.step('reserveInventory')`, and the same workflow, +same input, and same output produce a different log, which is the point. --- -For how it is built — the interception model, the store's guards, the -determinism machinery, and the test status — see [DESIGN.md](./DESIGN.md). +For details about the interception model, the store's guards, the determinism +machinery, and the test status, see [DESIGN.md](./DESIGN.md). --- @@ -59,19 +59,19 @@ the awaiting caller is resumed. Since the World API is the only channel between the runtime and the outside, that is a complete set of injection points. **2. Nothing happens on its own.** `queue()` records a message and returns; it -never dispatches. The scheduler picks the next message — always the minimum by -`(readyAt, enqueueSeq)` — hands it to the flow handler, and waits for it to +never dispatches. The scheduler picks the next message (always the minimum by +`(readyAt, enqueueSeq)`), hands it to the flow handler, and waits for it to finish before looking again. One delivery is in flight at a time. **3. Time is a number the scheduler assigns.** `sleep('30d')` becomes a queue message dated 30 days out; delivering it means moving the clock, not waiting. `Date.now()` and `new Date()` read the virtual clock while a scenario runs -(timers are left alone — the runtime uses zero-delay macrotasks as ordering +(timers are left alone because the runtime uses zero-delay macrotasks as ordering barriers, and faking those would change the interleavings we came to observe). Consequence: **scenarios terminate**. A month-long sleep costs microseconds. A hook nobody delivers drains the queue and is reported as a *stall*, naming the -token that was never sent, instead of hanging. Delivery count, virtual span and +token that was never sent, instead of hanging. Delivery count, virtual span, and wall time are all capped as a backstop. ## Consistency checking @@ -81,7 +81,7 @@ re-derived from it. `checkInvariants` verifies, among others: | Rule | What it means | | --- | --- | -| `log.monotonic-order` | Append order equals `(createdAt, eventId)` sort order — replay sees what happened | +| `log.monotonic-order` | Append order equals `(createdAt, eventId)` sort order, so replay sees what happened | | `run.created-first`, `run.created-once`, `run.terminal-is-last` | Run lifecycle shape (a step already running may still close out after termination) | | `step.no-restart-after-terminal`, `step.terminal-once` | A finished step stays finished | | `step.entity-matches-log`, `step.attempt-matches-log`, `run.entity-matches-log` | Materialized rows are a pure fold of the log | @@ -121,24 +121,24 @@ every *validation* is kept, because rejections are the observable contract. ## World behaviors -**Event log** — positions are assigned at commit. Two things follow: +**Event log:** Positions are assigned at commit. Two things follow: - Log order is commit order. Nothing is inserted behind a row a reader has already seen, so no two reads can disagree about the past. -- Every read is a *prefix* of the log. A read can be short — missing a write - that has not committed yet — but never self-inconsistent. Staleness collapses +- Every read is a *prefix* of the log. A read can be short, missing a write + that has not committed yet, but never self-inconsistent. Staleness collapses into lag, and lag is what an optimistic-concurrency fence can see; a hole is what it cannot. `withholdNextEvent` models a lagging replica by truncating the visible tail; `StaleRead` reports `{ eventId, hidden, truncated }` for that read. -**Precondition fence** (`preconditionGuard: true`) — rejects a write whose +**Precondition fence** (`preconditionGuard: true`): Rejects a write whose snapshot is strictly older than the newest externally originated event. It is a high-water mark, so it sees a log truncated at the end and is blind to a hole in the middle. -**Count guard** (`countGuard: true`) — adds the other half: how many events the +**Count guard** (`countGuard: true`): Adds the other half, which is how many events the log holds at or below that watermark, against how many the caller loaded. It closes the hole a watermark cannot see. It is evaluated inside the fence's predicate, so it is only live when the fence is. @@ -147,7 +147,7 @@ Both halves read one snapshot, and the sim reconstructs it rather than reading it off the wire: a client on slot-numbered event IDs sends a slot count, the sim mints ULIDs, so the facade derives `{ updatedAt, count }` from the pages the writer actually read, within the delivery that read them. The derivation is the -client's own — newest loaded position, and how many loaded events sit at or +client's own: the newest loaded position and how many loaded events sit at or below it. A write the facade attached no snapshot to did not come from a replay context and is never fenced. @@ -193,7 +193,7 @@ const spec: ScenarioSpec = { // The prose `name` beside it is free to be reworded. id: 'b-lands-first', name: 'stepB lands in the log before stepA', - // Named from the build manifest — no client transform needed. + // Named from the build manifest; no client transform is needed. workflow: 'twoStepsWorkflow', input: ['x'], script: async (sim) => { @@ -201,7 +201,7 @@ const spec: ScenarioSpec = { const b = sim.writer.step('stepB'); // Calling an advance starts watching for its point; awaiting it waits for - // the writer to get there. Start both watches, then await both — asking + // the writer to get there. Start both watches, then await both. Asking // for a point that has already gone by is an error, not a wait. const watchA = a.runToEventProduced('step_completed'); const watchB = b.runToEventCommitted('step_completed'); @@ -215,8 +215,8 @@ const spec: ScenarioSpec = { }; ``` -`stepA` is held before it takes a position, so `stepB` gets the earlier one — -`#6 stepB`, `#7 stepA` — on every run, in either order the runtime would +`stepA` is held before it takes a position, so `stepB` gets the earlier one +(`#6 stepB`, `#7 stepA`) on every run, in either order the runtime would otherwise have picked. Playing it needs the compiled bundle, because the orchestrator runs from a code @@ -253,7 +253,7 @@ stays red until the runtime delivers it, because a suite that goes green by recording the bug gives no signal on the day someone fixes it. [`workbench/sim-world`](../../workbench/sim-world/README.md) is the worked -example — a book of scenarios, a CLI that plays them, and a guide to adding +example, with a book of scenarios, a CLI that plays them, and a guide to adding one. ## Reading the output @@ -262,25 +262,25 @@ Events are referred to one way and one way only: **by log position**, so a claim about the output is one a reader can check against it. `#12` is the twelfth event in the log sorted the way `events.list` sorts it, -`(createdAt, eventId)`; `@7` is the resource created at position 7. Ids in +`(createdAt, eventId)`; `@7` is the resource created at position 7. IDs in violation messages are rewritten to positions on the way out. The trace prints in **commit** order and is numbered in **log** order, so a run whose log disagrees with the order its writers committed in shows up as positions counting backwards: -``` +```text # 8 +1.0m wf wait_completed @6 # 7 +1.0m ext hook_received @2 token="count:doc-29" # 9 +1.0m wf step_created @9 settle ``` The hook owns position 7, the timeout at 8 was committed first, and the branch -at 9 went with the timeout. Out-of-order positions are highlighted when colour +at 9 went with the timeout. Out-of-order positions are highlighted when color is on. -Colour is applied only when stdout is a terminal, and is off under `NO_COLOR` -or `--no-color`; pass `{ color: true }` to force it. With colour off the output +Color is applied only when stdout is a terminal and is off under `NO_COLOR` +or `--no-color`; pass `{ color: true }` to force it. With color off, the output is plain ASCII, stable enough to check in as a golden file. ## API reference @@ -295,20 +295,20 @@ A run is not one program: several writers append to one event log, and each write crosses the world boundary, is assigned a position in the event log, and is committed to storage. -| writer | handle | what it is | what it writes | +| Writer | Handle | What it is | What it writes | | --- | --- | --- | --- | | `orchestrator` | `sim.writer.orchestrator()` | The workflow function and the runtime around it, committing at a suspension point. One per queue delivery. | the run lifecycle, `step_created` / `step_started`, `hook_created`, `wait_*` | | `step:` | `sim.writer.step('')` | One step body, running inline with full Node access. Two steps sharing a function name share the writer. | its own `step_completed` / `step_failed` / `step_retrying`, and any `attr_set` from step context | -| `external` | none — see [Withholdings](#withholdings) | The scenario, acting as a webhook receiver or an operator | `hook_received`, `run_cancelled` | +| `external` | none (see [Withholdings](#withholdings)) | The scenario, acting as a webhook receiver or an operator | `hook_received`, `run_cancelled` | Two step bodies in a *single* delivery are already two writers racing to the same log: no second invocation and no real threads are required. That is why the vocabulary is per-writer rather than per-invocation. `sim.writer.anyStep()` and `sim.writer.any()` are handles that match more than -one writer — whichever reaches the advance first. A handle is a *name*, not a +one writer, whichever reaches the advance first. A handle is a *name*, not a live object, so `sim.writer.step('slow')` can be taken before that step exists. -`sim.writer.seen()` lists the ids observed so far, in first-appearance order. +`sim.writer.seen()` lists the IDs observed so far, in first-appearance order. ### Advances @@ -330,7 +330,7 @@ const script: ScenarioScript = async (sim) => { const reserve = sim.writer.step('reserveInventory'); // Hold just after step_started is committed and before the orchestrator is - // resumed — the window the whole instrument exists for. + // resumed, which is the window the whole instrument exists for. await wf.runToEventCommitted('step_started', 'reserveInventory'); sim.check('no payload yet', !sim.world.events().some((e) => e.eventType === 'hook_received')); await sim.deliverHook('approval:doc-1', { approved: true }); @@ -345,9 +345,9 @@ const script: ScenarioScript = async (sim) => { }; ``` -| method | writer | description | +| Method | Writer | Description | | --- | --- | --- | -| `wf.runToEventProduced(type, opts?)` | any | Hold once the event has crossed the world boundary — formed, attributed, in the trace — and before it is assigned a position in the event log. Anything committed to storage during the hold sorts *ahead* of it. | +| `wf.runToEventProduced(type, opts?)` | any | Hold once the event has crossed the world boundary (formed, attributed, and in the trace) and before it is assigned a position in the event log. Anything committed to storage during the hold sorts *ahead* of it. | | `wf.runToEventCommitted(type, opts?)` | any | Hold once the event is committed to storage, before the writer resumes. | | `wf.release()` | the held one | Let the writer go. Idempotent; awaiting it yields the event loop, so the writer has really moved by the time it resolves. | | `wf.isHeld()` / `wf.history()` | — | Is it held / where it has been. | @@ -369,7 +369,7 @@ watchdog (`limits.maxRunToWallMs`) whose timeout reports where *every* writer was standing, which is a diagnosis rather than the scenario's global budget running out. -Two mistakes are worth knowing, and the errors name both: +Two common mistakes produce errors that name the problem: - **Watching too late.** Releasing writer A before B's watch has started. B's step body may already be in flight and commit during the release. @@ -381,7 +381,7 @@ handles are built from. Fields are ANDed; `eventType` implies `events.create`, `stepName` accepts the machine name or the plain function name, `where` covers what the declarative fields cannot say, and `phase` defaults to `'after'`: -``` +```text { call: 'events.create' | 'queue' | 'runs.get' | … , phase: 'before' | 'after', eventType, stepName, correlationId, token, runId, writer, failed, where } ``` @@ -390,7 +390,7 @@ Reach for them when the point is a *state* rather than a name. `where` is the one thing a level-triggered `runTo` cannot re-check against history, so a `where` wait is edge-triggered and leans on its timeout. -The park/permit model — and the word *tempo* for the resulting order — is lifted +The park/permit model, and the word *tempo* for the resulting order, is lifted from [`blanket`](https://bernat.tech/posts/blanket-deterministic-threading/), which does this for Python's `threading` primitives. The mapping is direct: a world call is a transaction, the `after` phase is its parking state, and @@ -410,7 +410,7 @@ A withholding hides something from readers without holding the writer that produced it. An advance stops one thread; a withholding lets every thread run and changes what storage answers. -| method | writer | description | +| Method | Writer | Description | | --- | --- | --- | | `sim.withholdNextEvent(reads?)` | whichever commits next | Hide the next event committed to storage from the next `reads` event-log reads (default 1). Call it immediately before the write to hide. | | `sim.beginHookDelivery(token, payload)` | `external` | Begin an external hook delivery and return `commit()`, which writes it at the log tail. | @@ -428,7 +428,7 @@ wrong. | | | | --- | --- | -| `deliverHook(token, payload)` | Runs the real `resumeHook()` — the same code an out-of-band webhook receiver would | +| `deliverHook(token, payload)` | Runs the real `resumeHook()`, the same code an out-of-band webhook receiver would | | `cancelRun(reason?)` | Cancel the run under test | | `advanceTime(ms)` | Jump the virtual clock | | `deliverQueued(select?)` | Deliver one queued message now, concurrently with a held writer | @@ -443,7 +443,7 @@ schedule, and the only question is whether the log it leaves reproduces it. The scheduler is strictly serial: one message at a time, and the clock only moves when it picks the next one up. So a held writer freezes virtual time along with everything else, and a whole family of interleavings is simply -unreachable from the advances above — anything of the form *a timer fires while +unreachable from the advances above, including anything of the form *a timer fires while a step result is outstanding*. Both halves need to be in flight at once, and the loop will only ever have one. @@ -453,7 +453,7 @@ there in the script, so it runs alongside the held writer rather than after it. two are different deliveries running concurrently, not a race for one. That concurrency is real, and so is its fallout. Two flow deliveries for one run -will collide the way they do in production — expect `EntityConflictError` and +will collide the way they do in production. Expect `EntityConflictError` and `HookNotFoundError` in the rejection list once both branches finish. Those are the deliveries losing races they are supposed to lose, not violations. @@ -471,53 +471,53 @@ const fired = sim.deliverQueued( ); ``` -Note the missing `await` — awaiting it here would wait for the delivery to +Note the missing `await`. Awaiting it here would wait for the delivery to *finish*, which defeats the purpose. Arm a hold on the writer that delivery will wake, fire it, await the hold, and the two are now interleaved. Await the returned promise at the end to assert it found something. ## Extending the simulator -Changing the instrument itself, routed by task — adding a *scenario* needs none -of it, and is +The following table routes changes to the instrument itself by task. Adding a +*scenario* needs none of it and is covered in [`workbench/sim-world/README.md`](../../workbench/sim-world/README.md#adding-a-scenario). The module map is [DESIGN.md §1](./DESIGN.md#1-module-map). | I want to… | Change | Read first | | --- | --- | --- | -| let scripts hold at a point the API can't name | `world.ts` — the call-point wrapper, and `CallMatch` in `types.ts` | [§3 Interception](./DESIGN.md#3-interception) | +| let scripts hold at a point the API can't name | `world.ts` (the call-point wrapper) and `CallMatch` in `types.ts` | [§3 Interception](./DESIGN.md#3-interception) | | add a phase to an existing call | `CallPhase` in `types.ts`, where `world.ts` parks on it, plus the writer op that names it | [§3 Two phases](./DESIGN.md#two-phases-and-a-third-hold-that-is-not-one) | | add a rule the log must satisfy | `invariants.ts`, plus the rule table above | [§8 Consistency checking](./DESIGN.md#8-consistency-checking) | | add or change a writer kind | `writers.ts` for the handles, `world.ts` for attribution | [§3 Writer attribution](./DESIGN.md#writer-attribution-is-derived-not-instrumented) | -| add a fault injector | `store.ts` — next to `withholdNextEvent` and the guards | [§5 Fault injection](./DESIGN.md#fault-injection) | +| add a fault injector | `store.ts`, next to `withholdNextEvent` and the guards | [§5 Fault injection](./DESIGN.md#fault-injection) | | change what a read returns | `store.ts` `applyWithhold` | [§5 The store](./DESIGN.md#5-the-store) | | change where an event lands | `store.ts` `positionAtCommit` / `mintEvent` | [World behaviors](#world-behaviors) above | | add a spec field | `ScenarioSpec` in `scenario.ts`, `RunScenarioOptions` beside it, then `run.ts` for the CLI flag | [§6 Spec](./DESIGN.md#spec) | | change the replay check | `replay.ts` | [§8 Replay verification](./DESIGN.md#replay-verification) | -| change the output | `report.ts` — `renderScenario`, `renderSummary`, `renderMarkdownSummary` | [Reading the output](#reading-the-output) above | +| change the output | `report.ts`: `renderScenario`, `renderSummary`, `renderMarkdownSummary` | [Reading the output](#reading-the-output) above | Four things worth knowing before you start: **The package entry is the scenario surface, not the whole package.** -`index.ts` exports what it takes to write a scenario, play it and render the -result. The construction kit — `createSimWorld`, `createSimStore`, `driveQueue`, -`verifyReplay`, `checkInvariants`, the clock — is imported from its own module, +`index.ts` exports what it takes to write a scenario, play it, and render the +result. The construction kit (`createSimWorld`, `createSimStore`, `driveQueue`, +`verifyReplay`, `checkInvariants`, and the clock) is imported from its own module, so adding an option to one of them is not a change to the package's public signature. Promote a name to the entry when something outside the package needs it, not before. **Anything a scenario can observe has to survive replay.** `verifyReplay` -re-plays the log in a fresh world, so a store rule that is not applied there +replays the log in a fresh world, so a store rule that is not applied there turns every scenario using it red for the wrong reason. -**Tests come in two shapes.** `src/*.test.ts` are vitest units against the - pieces in isolation — copy `store.test.ts` for anything that changes what the +**Tests come in two shapes.** `src/*.test.ts` are Vitest units against the + pieces in isolation. Copy `store.test.ts` for anything that changes what the log looks like. The scenario book is the integration test; run it before and after and diff the counts. ## What this does *not* give you -Worth being explicit, because the guarantees are narrower than "deterministic": +The guarantees are narrower than "deterministic": - **Determinism is world-level.** Step bodies are ordinary Node code. A step that calls `Math.random()`, reads a file, or hits the network is as @@ -535,7 +535,7 @@ Worth being explicit, because the guarantees are narrower than "deterministic": - **"Before the workflow resumes" is about the log, not the CPU.** The hook is committed before the intercepted call returns, so it is in the log before the runtime's next read of it. Whether the runtime *observes* it on the next - replay depends on optimizations that can skip a re-read — visible in the + replay depends on optimizations that can skip a re-read, as shown in the trace. - **One scenario at a time per process.** The virtual clock and the World are process-global singletons. diff --git a/packages/world-sim/src/build.ts b/packages/world-sim/src/build.ts index 5830b2daca..0a23cf118c 100644 --- a/packages/world-sim/src/build.ts +++ b/packages/world-sim/src/build.ts @@ -3,14 +3,14 @@ * * The workflow orchestrator runs from a *code string* inside a VM * (`workflowEntrypoint(workflowCode)`), so there is no way to hand the runtime - * a live function reference — a scenario needs the same compiled combined + * a live function reference. A scenario needs the same compiled combined * bundle a real deployment would serve. This mirrors what `@workflow/vitest` * does in its global setup, with one addition: the build's manifest is * returned, which is how a scenario can name a workflow by its plain function * name instead of importing a client-transformed reference. * * This module reaches SWC and esbuild through `@workflow/builders`, so it is - * deliberately *not* part of the package's main entry — see `load.ts`. Import + * deliberately *not* part of the package's main entry. See `load.ts`. Import * it as `@workflow/world-sim/build`, and only from something that compiles. */ @@ -38,7 +38,7 @@ export interface SimBundle { /** * Workflow function name → machine workflow id, flattened from the manifest. * Ambiguous short names (same function name in two files) are omitted in - * favour of their `#` keys, which are always present. + * favor of their `#` keys, which are always present. */ workflowIds: Record; } @@ -70,7 +70,7 @@ class SimBuilder extends BaseBuilder { format: 'esm', bundleFinalOutput: false, externalizeNonSteps: true, - // Nothing downstream bundles this output — Node imports it directly — so + // Nothing downstream bundles this output (Node imports it directly), so // project-local imports have to be inlined rather than left as bare `.ts` // specifiers. bundleTransitiveLocalStepDependencies: true, diff --git a/packages/world-sim/src/clock.ts b/packages/world-sim/src/clock.ts index 0eefc617ef..7b4cd09592 100644 --- a/packages/world-sim/src/clock.ts +++ b/packages/world-sim/src/clock.ts @@ -13,11 +13,11 @@ * Timers are deliberately NOT patched: `@workflow/core` uses * `setTimeout(fn, 0)` as a macrotask barrier in several ordering-sensitive * places (`events-consumer.ts`, `private.ts`), and swapping those for fake - * timers would change the very interleavings the simulation exists to + * timers would change the exact interleavings the simulation exists to * observe. Real zero-delay timers stay real; only the *readings* of wall * time move under our control. * - * The clock never moves on its own — only `advanceTo`/`advanceBy` move it, + * The clock never moves on its own. Only `advanceTo`/`advanceBy` move it, * and only the scheduler calls those. Two runs of the same scenario see the * exact same sequence of timestamps. */ @@ -76,7 +76,7 @@ export function createVirtualClock(epochMs = DEFAULT_EPOCH_MS): VirtualClock { // A Proxy, deliberately not a subclass. // // Subclassing works right up until two clocks are installed in - // succession — a scenario's, then the replay check's. Every `Date` the + // succession: a scenario's, then the replay check's. Every `Date` the // first clock produced is an instance of *that* subclass, so once the // second one is installed `x instanceof Date` is false for all of them, // and any code branching on it (a structural clone, a serializer) diff --git a/packages/world-sim/src/drive.ts b/packages/world-sim/src/drive.ts index 7313751663..c62909b9a4 100644 --- a/packages/world-sim/src/drive.ts +++ b/packages/world-sim/src/drive.ts @@ -54,7 +54,7 @@ export const DEFAULT_LIMITS: Required = { * The runtime uses zero-delay macrotasks as ordering barriers in the replay * consumer, and `waitUntil`-style background work is not awaited by anyone. * Between deliveries we drain both so the next delivery starts from a quiet - * process — otherwise a message enqueued from a trailing microtask would be + * process. Otherwise, a message enqueued from a trailing microtask would be * missed and the scenario would report a spurious stall. */ async function settle(rounds = 4): Promise { @@ -77,7 +77,7 @@ export interface DriveResult { * * Take the next message, jump the clock to its delivery time, hand it to the * flow handler, repeat until the queue is empty or a budget says stop. This is - * the whole of "nothing happens on its own" — extracted so the replay + * the whole of "nothing happens on its own," extracted so the replay * verification can drive a second world through exactly the same loop. */ export async function driveQueue(options: { diff --git a/packages/world-sim/src/ids.ts b/packages/world-sim/src/ids.ts index 0a26e47b87..ddd5839ff5 100644 --- a/packages/world-sim/src/ids.ts +++ b/packages/world-sim/src/ids.ts @@ -2,14 +2,14 @@ * Deterministic identifier minting. * * Every ID the simulation hands out is a function of (virtual time, a - * per-scenario counter) — never of `Math.random()` or the host clock. Two + * per-scenario counter), never of `Math.random()` or the host clock. Two * runs of the same scenario produce byte-identical run IDs and message IDs, * which is what makes an event-stream dump usable as a golden file. * * Run and message IDs have to be *real* ULIDs: `@workflow/world` validates run * IDs with `z.string().ulid()` and decodes their embedded timestamp (both to * reject clock-skewed clients and to seed the workflow VM's fixed clock), so - * the encoding below is the standard Crockford base32 layout — 10 timestamp + * the encoding below is the standard Crockford base32 layout: 10 timestamp * characters followed by 16 characters of "randomness" that we fill from the * counter instead. * @@ -48,7 +48,7 @@ export interface IdFactory { runId(): string; /** Mint a monotonically increasing message id. */ messageId(): string; - /** Number of IDs minted so far — also the tiebreak counter. */ + /** Number of IDs minted so far, also the tiebreak counter. */ count(): number; } @@ -57,7 +57,7 @@ export function createIdFactory(now: () => number): IdFactory { const ulid = (): string => { counter++; - // 48-bit timestamp, 10 base32 chars — the standard ULID time component. + // 48-bit timestamp, 10 base32 chars: the standard ULID time component. // `Math.floor` rather than trusting the caller: the clock guards its own // arithmetic, but `now` is an arbitrary function and a fractional // millisecond here would silently mint an id that sorts nowhere sensible. diff --git a/packages/world-sim/src/index.ts b/packages/world-sim/src/index.ts index 3724d07db0..25d3ca4631 100644 --- a/packages/world-sim/src/index.ts +++ b/packages/world-sim/src/index.ts @@ -1,5 +1,5 @@ /** - * `@workflow/world-sim` — a deterministic, fully in-memory World for playing + * `@workflow/world-sim`: a deterministic, fully in-memory World for playing * out workflow scenarios and checking the world contract holds. * * See the package README for the model. The short version: nothing in this @@ -9,8 +9,8 @@ * * This entry is the *scenario* surface: write one, play it, render the result, * and name anything those three hand you. The pieces that build or inspect the - * simulator itself — `createSimWorld`, `createSimStore`, `driveQueue`, - * `verifyReplay`, `checkInvariants`, the clock — are deliberately not here. + * simulator itself (`createSimWorld`, `createSimStore`, `driveQueue`, + * `verifyReplay`, `checkInvariants`, the clock) are deliberately not here. * Nothing outside the package has wanted them, and re-exporting them makes * every one of their signatures a compatibility promise. Import them from their * module if you are extending the simulator; see `DESIGN.md`. diff --git a/packages/world-sim/src/invariants.ts b/packages/world-sim/src/invariants.ts index f824234f40..50b011dd01 100644 --- a/packages/world-sim/src/invariants.ts +++ b/packages/world-sim/src/invariants.ts @@ -5,7 +5,7 @@ * verifying: that the log is ordered, that entity rows are a pure fold of the * log, that a step is never restarted after it finished, that a terminal run * accepts nothing afterwards. The store enforces most of them at write time by - * rejecting bad events — but "the store rejected it" and "the log is actually + * rejecting bad events, but "the store rejected it" and "the log is actually * consistent" are different claims, and only the second one is worth trusting. * So this module re-derives everything from the event log alone and compares. * @@ -26,7 +26,7 @@ import type { InvariantViolation } from './types.js'; export interface InvariantInput { runId: string; - /** The run's events in log order — the order every reader sees them in. */ + /** The run's events in log order, the order every reader sees them in. */ events: Event[]; /** The same events in the order they were committed. */ eventsInCommitOrder: Event[]; @@ -69,7 +69,7 @@ export function checkInvariants(input: InvariantInput): InvariantViolation[] { // that order. Commit order must be that order; if it // is not, the log gained a row behind a position readers had already passed, // so a read taken in between saw a sequence the finished log contradicts. - // Walking the sorted array could never notice — it is sorted, so it is + // Walking the sorted array could never notice: it is sorted, so it is // monotonic by construction. This is the check that the promise was kept. let previousKey = ''; for (const event of input.eventsInCommitOrder) { diff --git a/packages/world-sim/src/load.ts b/packages/world-sim/src/load.ts index bf1c547080..5a5a225160 100644 --- a/packages/world-sim/src/load.ts +++ b/packages/world-sim/src/load.ts @@ -1,8 +1,8 @@ /** * Loading a built bundle, separated from building one. * - * These two halves have very different dependency footprints. Building pulls - * in `@workflow/builders`, and through it SWC and esbuild — tens of megabytes + * These two halves have different dependency footprints. Building pulls + * in `@workflow/builders`, and through it SWC and esbuild: tens of megabytes * of native binaries. Loading needs nothing but `import()`. * * Keeping them in one module meant anything that wanted to *run* a bundle also @@ -24,8 +24,8 @@ import { pathToFileURL } from 'node:url'; * runs, so that import-time work never lands in the middle of a measured * sequence. * - * The path is only known at runtime — it is either a file this process wrote - * seconds ago or one `next build` left on disk — so the ignore hints are + * The path is only known at runtime (it is either a file this process wrote + * seconds ago or one `next build` left on disk), so the ignore hints are * load-bearing wherever a bundler is in the graph. Without them a bundler * tries to resolve the specifier at build time and fails with "expression is * too dynamic". They are inert comments under plain Node. diff --git a/packages/world-sim/src/queue.ts b/packages/world-sim/src/queue.ts index 06b75b6996..5334c79da9 100644 --- a/packages/world-sim/src/queue.ts +++ b/packages/world-sim/src/queue.ts @@ -8,8 +8,8 @@ * * Here `queue()` only *records* a message. Nothing is ever delivered until the * scheduler asks for the next one, and the scheduler always takes the same one: - * the minimum by `(readyAtMs, enqueueSeq)`. Delays are virtual — a message - * scheduled 23 hours out is delivered by jumping the clock, not by waiting — + * the minimum by `(readyAtMs, enqueueSeq)`. Delays are virtual (a message + * scheduled 23 hours out is delivered by jumping the clock, not by waiting), * which is what lets a scenario containing `sleep('30d')` finish in * microseconds. */ @@ -99,7 +99,7 @@ export function createSimQueue(opts: { * Idempotency keys of messages that are enqueued but not yet settled. This * matches world-local's in-flight-only dedupe window (VQS holds keys for * longer); the wait-continuation logic in core is written against exactly - * this behaviour, and widening the window here would silently drop the + * this behavior, and widening the window here would silently drop the * re-enqueues it relies on. */ const inflightKeys = new Map(); diff --git a/packages/world-sim/src/replay.ts b/packages/world-sim/src/replay.ts index 1b059e9cac..0b8a4a0a27 100644 --- a/packages/world-sim/src/replay.ts +++ b/packages/world-sim/src/replay.ts @@ -1,15 +1,15 @@ /** * Replay verification: does the committed log actually regenerate the state? * - * Everything else in this package checks the log's *shape* — ordering, entity + * Everything else in this package checks the log's *shape*: ordering, entity * rows folding back out of it, lifecycle rules. None of that answers the * question the durability model actually rests on: if a fresh process picked * up this log tomorrow, would it reconstruct the same run? * * The check is a cold start with the answer withheld. Take the finished log, * drop its terminal `run_*` event, load the rest into an empty world as - * durable history, and deliver one queue message. The real runtime — the same - * `workflowEntrypoint` a deployment serves — replays the workflow from the log + * durable history, and deliver one queue message. The real runtime (the same + * `workflowEntrypoint` a deployment serves) replays the workflow from the log * and must re-derive the event we removed, with the same output. No step body * re-executes (every `step_completed` is in the log, so the step consumer * resolves from it), so anything the replay produces came from the log alone. @@ -19,7 +19,7 @@ * - The replay cannot follow the log: the runtime raises * `ReplayDivergenceError`, retries its recovery replays, and then fails the * run with `CorruptedEventLogError`. Surfaced here as `replay.diverged`. - * - The replay runs out of log before the workflow finishes, and suspends — + * - The replay runs out of log before the workflow finishes, and suspends: * the log did not contain enough to rebuild the run. `replay.suspended`. * - The replay finishes but derives a different answer. `replay.output-differs` * / `replay.log-differs`. @@ -69,7 +69,7 @@ function splitAtTerminal(events: readonly Event[]): { }; } -/** `(eventType, correlationId)` — the part of a log that must be reproducible. */ +/** `(eventType, correlationId)`: the part of a log that must be reproducible. */ function shape(events: readonly Event[]): string[] { return events.map((e) => e.correlationId ? `${e.eventType}#${e.correlationId}` : e.eventType @@ -106,15 +106,16 @@ export async function verifyReplay( } // A step that closed out after the run terminated cannot be re-derived by a - // replay — it was driven by an inline body that already ran, not by the log. + // replay, since it was driven by an inline body that already ran, not by the + // log. // Seed those as history too so the replay sees the same durable state a // fresh process would. const seeded = [...history, ...trailing]; // The replay must happen at the instant the run ended, not whenever the // scenario happened to finish draining its queue. Replaying later is not - // wrong — the runtime would legitimately complete any wait that has since - // elapsed — but it answers a different question than "does this log + // wrong (the runtime would legitimately complete any wait that has since + // elapsed), but it answers a different question than "does this log // reproduce this run", and the comparison below would flag the difference as // a divergence when nothing diverged. // @@ -229,7 +230,7 @@ async function describeRunError(error: unknown): Promise { try { // Go through the real hydration path, not the observability one: a run // error is written by `dehydrateRunError` and may be compressed, which the - // synchronous o11y reviver cannot undo. + // synchronous observability reviver cannot undo. const { hydrateRunError } = await import('@workflow/core/serialization'); const value = await hydrateRunError(error, 'wrun_replay', undefined); if (value instanceof Error) return `${value.name}: ${value.message}`; diff --git a/packages/world-sim/src/report.ts b/packages/world-sim/src/report.ts index 2da4ec64dc..76d105ff45 100644 --- a/packages/world-sim/src/report.ts +++ b/packages/world-sim/src/report.ts @@ -1,15 +1,15 @@ /** * Rendering a scenario as text. * - * The event stream is the primary artifact — it is the thing a reader checks + * The event stream is the primary artifact: it is the thing a reader checks * to answer "did the hook really land between `step_started` and the replay * that followed it?". So cues, deliveries and notes are interleaved into the * same column as events rather than printed as a separate log: the whole * point is their position relative to the events around them. * * **Events are referred to one way and one way only: by log position.** `#12` - * is the twelfth event in the durable log — the log sorted the way - * `events.list` sorts it, `(createdAt, eventId)` — and `@7` is a reference to + * is the twelfth event in the durable log (the log sorted the way + * `events.list` sorts it, `(createdAt, eventId)`), and `@7` is a reference to * the resource created at position 7. Raw ULIDs never appear; the ids in * violation messages are rewritten to positions on the way out. One scheme, * so "the hook is at 7 and `wait_completed` at 8" is a claim a reader can @@ -21,8 +21,8 @@ * Those lines are highlighted. That disagreement is the entire subject of the * red scenarios, and this is where you see it. * - * Colour is decoration over that: it is applied only when the destination is a - * terminal, and is off under `NO_COLOR` or `--no-color`. With colour off the + * Color is decoration over that: it is applied only when the destination is a + * terminal, and is off under `NO_COLOR` or `--no-color`. With color off the * output is the same plain ASCII it always was, stable enough to check in as a * golden file. */ @@ -37,7 +37,7 @@ export interface RenderOptions { /** Maximum characters of decoded payload to show per event. */ payloadWidth?: number; /** - * ANSI colour. Defaults to "on if stdout is a TTY and `NO_COLOR` is unset", + * ANSI color. Defaults to "on if stdout is a TTY and `NO_COLOR` is unset", * so piping to a file or a golden-file comparison gets plain ASCII without * anyone having to remember a flag. */ @@ -48,10 +48,10 @@ const CHECK = 'ok'; const CROSS = 'FAIL'; // --------------------------------------------------------------------------- -// Colour +// Color // --------------------------------------------------------------------------- -/** SGR codes, applied through `Paint` so a no-colour render never sees them. */ +/** SGR codes, applied through `Paint` so a no-color render never sees them. */ const SGR = { reset: 0, bold: 1, @@ -71,7 +71,7 @@ type Style = keyof typeof SGR; * A `paint(text, ...styles)` function that is either real or the identity. * * Resolving the on/off decision once, into a function, keeps every call site - * free of `if (color)` — which matters because getting one of them wrong is + * free of `if (color)`, which matters because getting one of them wrong is * how escape codes leak into a file that was supposed to be diffable. */ type Paint = (text: string, ...styles: Style[]) => string; @@ -119,7 +119,7 @@ function eventStyle(eventType: string): Style { * Log positions for every event in a trace. * * Built by sorting the events the way `events.list` does, *not* by the order - * they were committed in — the two differ exactly when something interesting + * they were committed in: the two differ exactly when something interesting * happened, and the number has to describe the durable log for a reader to be * able to reason about what a replay will see. */ @@ -169,7 +169,7 @@ function eventRef(position: number | undefined, width: number): string { : `#${String(position).padStart(width)}`; } -/** `@7` — the resource created at position 7. */ +/** `@7`: the resource created at position 7. */ function resourceRef(position: number | undefined): string { return position === undefined ? '@?' : `@${position}`; } @@ -313,7 +313,7 @@ export function renderTrace( const blank = ' '.repeat(index.width + 1); const lines: string[] = []; // Highest log position printed so far. A line below it is an event that was - // committed after one that outranks it in the log — the disagreement the six + // committed after one that outranks it in the log, the disagreement the six // red scenarios are about. let highWater = -1; @@ -327,7 +327,7 @@ export function renderTrace( entry.kind === 'event' || entry.kind === 'hold' ? shortWriter(entry.writer) : ''; - // Pad before painting: `padEnd` counts escape bytes, so a coloured column + // Pad before painting: `padEnd` counts escape bytes, so a colored column // padded afterwards comes out short by however long the escape is. const writer = entry.kind === 'event' || entry.kind === 'hold' @@ -489,9 +489,9 @@ export interface MarkdownSummaryOptions { title?: string; /** * Which world produced these results, as short `key=value` chips above the - * table — `log=append-only`, `fence=off`. A summary that does not say which - * world it ran in is unreadable next to another one, and the whole point of - * this book is comparing two runs of it. + * table, such as `log=append-only`, `fence=off`. A summary that does not + * say which world it ran in is unreadable next to another one, and the + * whole point of this book is comparing two runs of it. */ chips?: readonly string[]; /** @@ -506,8 +506,8 @@ export interface MarkdownSummaryOptions { * PR comment or `$GITHUB_STEP_SUMMARY`. * * One collapsed `
`: a visible line carrying the count and a green or - * orange dot, and the whole table behind it. Built to be stacked — a CI job - * plays the book once per world and puts two of these under one heading — so + * orange dot, and the whole table behind it. Built to be stacked (a CI job + * plays the book once per world and puts two of these under one heading), so * it renders no heading of its own, and nothing above the fold but the count. * * There is deliberately no list of failures. Six of them are red on purpose, @@ -515,7 +515,7 @@ export interface MarkdownSummaryOptions { * news, and it grows a wall of text on exactly the PRs that changed nothing. * The count is the signal; the names are one click away. * - * Never coloured — ANSI in a markdown file renders as garbage. + * Never colored, since ANSI in a markdown file renders as garbage. */ export function renderMarkdownSummary( results: readonly ScenarioResult[], @@ -525,7 +525,7 @@ export function renderMarkdownSummary( const out: string[] = []; // A dot rather than words: `` is one line of a collapsed comment, - // and markdown has no colour, so this is the only way the two worlds read as + // and markdown has no color, so this is the only way the two worlds read as // different at a glance without being read at all. out.push('
'); out.push( diff --git a/packages/world-sim/src/scenario.ts b/packages/world-sim/src/scenario.ts index cb4e7486b8..9b4d70c4bd 100644 --- a/packages/world-sim/src/scenario.ts +++ b/packages/world-sim/src/scenario.ts @@ -5,7 +5,7 @@ * writers. Playing it consists of exactly one loop: take the next queue * message, jump the virtual clock to its delivery time, hand it to the flow * handler, repeat until the queue is empty. Nothing else can advance the world - * — no timers, no background delivery, no wall-clock waiting — so the sequence + * (no timers, no background delivery, no wall-clock waiting), so the sequence * of world calls is reproducible. * * Termination is a hard requirement, and three separate things enforce it: @@ -13,7 +13,7 @@ * - **Virtual time.** A `sleep('30d')` is a queue message dated 30 days out; * delivering it means moving a number, not waiting. * - **Quiescence.** An empty queue ends the loop. If the run has not reached - * a terminal state at that point the scenario is *stalled* — reported, with + * a terminal state at that point the scenario is *stalled*: reported, with * the open hooks and waits that explain it, rather than hung. * - **Budgets.** Delivery count, virtual span and wall time are all capped, * so even a workflow that genuinely loops forever ends as a failed scenario @@ -77,7 +77,7 @@ export interface ScenarioSpec { /** * When external input arrives and how the run's writers interleave, written * as a sequence of writer advances. Runs concurrently with the delivery loop - * and starts before the run does, so it can hold the very first world call. + * and starts before the run does, so it can hold the first world call. * * A scenario with no script is a control: the run plays out on the default * schedule and the only question is whether the log it leaves reproduces it. @@ -86,8 +86,8 @@ export interface ScenarioSpec { /** * Override which queue message is delivered next. * - * The default is total and deterministic — earliest `readyAt`, then enqueue - * order — which is the right model for a queue whose delays are real + * The default is total and deterministic (earliest `readyAt`, then enqueue + * order), which is the right model for a queue whose delays are real * deadlines. Override it to pin an order the default would not produce, e.g. * to deliver a later message first and check the run tolerates it. Return a * `messageId` from `pending`, or `undefined` to fall back to the default. @@ -100,7 +100,7 @@ export interface ScenarioSpec { */ verifyReplay?: boolean; /** - * Assertions about how the run should end — what *correct* looks like, which + * Assertions about how the run should end: what *correct* looks like, which * is not always what happens today. * * `status` accepts the non-run outcomes too (`stalled`, `budget-exceeded`), @@ -115,14 +115,14 @@ export interface ScenarioSpec { * * There is also deliberately no per-world variant of this field. A scenario * is one sequence of advances, and the only thing a world changes is what a - * read returns — so an expectation that has to be restated per world is + * read returns, so an expectation that has to be restated per world is * pinning a consequence of the reads rather than a property of the run. Pin * the part that holds in every world; where the branch a run takes is decided * by what it read, do not pin the branch. What catches the fault there is the * invariant, not the expectation: the log a run wrote must be a log the * runtime can replay back into that same run, and that sentence is true in * both worlds. `verifyReplay` is on by default for exactly this reason, and - * every red in the book is red on the invariant alone — the expectations + * every red in the book is red on the invariant alone: the expectations * could all be deleted without changing which scenarios fail. */ expect?: ScenarioExpectation; @@ -165,7 +165,7 @@ export interface ScenarioResult { /** World-contract violations found by re-deriving state from the log. */ violations: InvariantViolation[]; /** - * Outcome of the cold-replay check, when it ran. `skipped` carries why — + * Outcome of the cold-replay check, when it ran. `skipped` carries why: * a cancelled or stalled run has no workflow-derived terminal event for a * replay to re-derive. */ @@ -195,7 +195,7 @@ export interface RunScenarioOptions { * overriding whatever the spec asked for. * * The fence exists to reject a write whose snapshot predates an out-of-band - * event — that is, a write an extended prefix invalidated. So turning it off + * event, that is, a write an extended prefix invalidated. So turning it off * across the whole book answers a question the book cannot otherwise ask: is * any scenario relying on it? If none is, then no emitter here is * prefix-sensitive and the fence guards against nothing; if one goes red that @@ -248,8 +248,8 @@ export async function runScenario( const problems: string[] = []; /** * Problems that are only problems if the scenario did not ask for them. A - * scenario may legitimately assert that a run stalls or blows its budget — - * those are properties worth pinning down — so the diagnosis is always + * scenario may legitimately assert that a run stalls or blows its budget + * (those are properties worth pinning down), so the diagnosis is always * recorded but only counted as a failure when it was a surprise. */ const outcomeProblems: string[] = []; @@ -333,8 +333,8 @@ export async function runScenario( world.setScenarioApi(() => api); // The script steers writers by watching world calls, so its machinery must - // exist before anything can fire — and it is launched before `start()` so it - // can hold the very first world call the run makes. + // exist before anything can fire, and it is launched before `start()` so it + // can hold the first world call the run makes. const controller = createTempo(world, api, { runToWallMs: Math.min(limits.maxRunToWallMs, limits.maxWallMs), }); @@ -352,7 +352,7 @@ export async function runScenario( /** * Real-time backstop. A parked call blocks the scheduler, so a script that - * waits for something that never happens is a hang, not a stall — the one + * waits for something that never happens is a hang, not a stall: the one * way to lose the termination guarantee. This buys it back, and it has to be * wall-clock: the virtual clock is precisely what stops advancing. * @@ -368,9 +368,9 @@ export async function runScenario( problems.push(reason); controller.abort(reason); }, limits.maxWallMs + 250); - // Deliberately NOT unref'd. A total deadlock — every writer parked, the + // Deliberately NOT unref'd. A total deadlock (every writer parked, the // scheduler blocked inside a held call, the script awaiting something that - // can never happen — leaves nothing else on the event loop. An unref'd + // can never happen) leaves nothing else on the event loop. An unref'd // watchdog does not hold the loop open, so Node would empty it and exit with // a bare "unsettled top-level await" instead of this timer firing: the // watchdog would be absent from the one case it exists for. The `finally` @@ -396,7 +396,8 @@ export async function runScenario( // ---- Replay verification --------------------------------------------- // Snapshot the run's own virtual span first: the replay reuses the clock - // (it has to — `Date.now()` is what tells the runtime a wait has elapsed) + // (it has to, since `Date.now()` is what tells the runtime a wait has + // elapsed) // and would otherwise show up in the scenario's reported timings. virtualElapsedMs = clock.elapsed(); const finished = world.store.allRuns().find((r) => r.runId === runId); @@ -409,7 +410,7 @@ export async function runScenario( finished.status !== 'failed' ) { // A cancelled run's terminal event came from an operator, not from the - // workflow, and a stalled run has none at all — in neither case is there + // workflow, and a stalled run has none at all; in neither case is there // a workflow-derived answer for a replay to reproduce. replay = { skipped: `run ended "${finished.status}", which the workflow did not derive`, @@ -460,7 +461,7 @@ export async function runScenario( clearTimeout(deadline); // Give the script a bounded moment to unwind from the rejections `abort` - // just raised into it. Awaiting it outright would reintroduce the hang + // raised into it. Awaiting it outright would reintroduce the hang // this whole mechanism exists to prevent: a script parked on a promise // that never settles (rather than on the world) is unreachable from here. await Promise.race([scriptDone, sleep(SCRIPT_UNWIND_MS)]); diff --git a/packages/world-sim/src/store.ts b/packages/world-sim/src/store.ts index 4406e6c658..cb99af9cb5 100644 --- a/packages/world-sim/src/store.ts +++ b/packages/world-sim/src/store.ts @@ -82,8 +82,8 @@ interface SimCreateParams { * the runtime no longer states it: `@workflow/core` describes its snapshot as * a slot count, and the sim mints ULIDs, so there is nothing on the wire for * the fence to read. The reconstruction is the same derivation the client - * used to make — the newest loaded position, and how many events sit at or - * below it — which is what lets the fence spot a hole *behind* the watermark + * used to make (the newest loaded position, and how many events sit at or + * below it), which is what lets the fence spot a hole *behind* the watermark * that no comparison against the watermark alone can see. * * See `SimStoreOptions.preconditionGuard` and `SimWorldOptions.countGuard`. @@ -113,7 +113,7 @@ interface RunEventIndex { * exactness argument: pruning always drops the oldest id, so `total - above` is * exact whenever the window still reaches back past the snapshot. The one case * it refuses to evaluate is a pruned window whose every retained id is above - * the snapshot — the dropped ids may have been above it too. + * the snapshot, since the dropped ids may have been above it too. */ function countRecordedAtOrBelow( index: RunEventIndex, @@ -135,8 +135,8 @@ export interface SimStoreOptions { * `WorldCapabilities.preconditionGuard`: reject a replay-context write whose * snapshot predates the newest externally-originated event. * - * Off by default. Turning it on is the point of a simulation — it lets a - * scenario check that the runtime recovers from a 412 fence — but it also + * Off by default. Turning it on is the point of a simulation (it lets a + * scenario check that the runtime recovers from a 412 fence), but it also * changes which runtime fast paths engage, so it is never implicit. */ preconditionGuard?: boolean; @@ -176,8 +176,8 @@ export interface StaleRead { export interface SimStore extends Storage { /** - * Load a previously committed log into an empty store, verbatim — same - * event ids, same timestamps — and fold the entity state back out of it. + * Load a previously committed log into an empty store, verbatim (same + * event ids, same timestamps), and fold the entity state back out of it. * * This is the "cold start" primitive: it reconstructs the durable state a * fresh process would find, without re-validating writes that were already @@ -198,7 +198,7 @@ export interface SimStore extends Storage { /** * The same events in the order they were *committed*, which is the order this * array was appended to. Differs from `allEvents` exactly when a write was - * minted before another and committed after it — so the two together are what + * minted before another and committed after it, so the two together are what * `log.monotonic-order` compares. */ allEventsInCommitOrder(runId?: string): Event[]; @@ -329,7 +329,7 @@ export function createSimStore(options: SimStoreOptions): SimStore { const externalWriteMarker = new Map(); /** * Per run: the tail of the log, for the count guard. Records *every* event, - * replay-origin included — the corruption it guards against is one replay + * replay-origin included: the corruption it guards against is one replay * racing another, which the out-of-band marker cannot see by construction. */ const runEventIndex = new Map(); @@ -386,7 +386,7 @@ export function createSimStore(options: SimStoreOptions): SimStore { }; index.recentEventIds.push(event.eventId); // Keep the window in mint order, so "oldest id" and "oldest event" stay the - // same thing — `countRecordedAtOrBelow`'s exactness argument depends on it. + // same thing: `countRecordedAtOrBelow`'s exactness argument depends on it. index.recentEventIds.sort((a, b) => a.localeCompare(b)); if (index.recentEventIds.length > RUN_EVENT_INDEX_WINDOW) { index.recentEventIds.shift(); @@ -458,7 +458,7 @@ export function createSimStore(options: SimStoreOptions): SimStore { * * The single copy of the event → entity state machine. Both paths into the * store end here: `create` runs its validation and then calls this, and - * `seedFromLog` calls it with no validation at all — those events were + * `seedFromLog` calls it with no validation at all, since those events were * accepted once already, and re-litigating them would reject legitimate * history (a `step_completed` recorded after the run was cancelled, say). * @@ -774,7 +774,7 @@ export function createSimStore(options: SimStoreOptions): SimStore { // loaded: a hole, which the marker comparison above passes by // construction because the missing event is *older* than the newest one // the caller did see. A `null` count is indeterminate (the window pruned - // past the snapshot) and is never treated as stale — the guard is + // past the snapshot) and is never treated as stale: the guard is // deliberately one-sided. if (options.countGuard) { const index = runEventIndex.get(runId); @@ -877,8 +877,8 @@ export function createSimStore(options: SimStoreOptions): SimStore { } if (currentRun && isTerminalWorkflowRunStatus(currentRun.status)) { // A terminal run still accepts the terminal write of a step that was - // already running when the run ended — that write is how an inline - // step reports back — but nothing else. + // already running when the run ended (that write is how an inline + // step reports back), but nothing else. if (validatedStep.status !== 'running') { throw new RunExpiredError( `Cannot modify non-running step on run in terminal state "${currentRun.status}"` @@ -906,14 +906,14 @@ export function createSimStore(options: SimStoreOptions): SimStore { } as Event; // `run_started`'s eventData is a bootstrap payload for the resilient path - // above, not log content — the canonical copy lives on `run_created`. + // above, not log content: the canonical copy lives on `run_created`. if (data.eventType === 'run_started' && 'eventData' in event) { delete (event as Record).eventData; } // ---- Per-event-type validation ---------------------------------------- // Everything the write path *refuses*. What it does to the entity rows is - // `applyEvent` below — the same fold the seed path runs. + // `applyEvent` below: the same fold the seed path runs. switch (data.eventType) { case 'run_created': { if (runs.has(runId)) { @@ -927,7 +927,7 @@ export function createSimStore(options: SimStoreOptions): SimStore { case 'run_started': { if (currentRun?.status === 'running') { // Idempotent: a concurrent invocation already started the run. No - // event is appended — replay must not see two `run_started`. + // event is appended, since replay must not see two `run_started`. return { run: clone(currentRun), maxEvents: MAX_EVENTS_PER_RUN }; } break; @@ -947,8 +947,8 @@ export function createSimStore(options: SimStoreOptions): SimStore { const owner = tokenOwners.get(token); if (owner && owner !== data.correlationId) { // Someone else holds the token. This is not an error for the - // *caller* — the workflow needs to observe it and fail its awaited - // hook — so it is journaled as a `hook_conflict` event instead. + // *caller* (the workflow needs to observe it and fail its awaited + // hook), so it is journaled as a `hook_conflict` event instead. const conflict = append({ eventType: 'hook_conflict', runId, @@ -1063,7 +1063,7 @@ export function createSimStore(options: SimStoreOptions): SimStore { // the same unit as a caller's newest loaded position or a caller holding // exactly this event would compare as older and 412 forever. // - The write is forward-only. Concurrent out-of-band events can commit out - // of position order — the whole subject of these scenarios — and letting a + // of position order (the whole subject of these scenarios), and letting a // late-committing older event drag the mark backwards would silently // disarm the guard for the newer one. if ( diff --git a/packages/world-sim/src/streams.ts b/packages/world-sim/src/streams.ts index 78e717b6f1..1d3bed6ebc 100644 --- a/packages/world-sim/src/streams.ts +++ b/packages/world-sim/src/streams.ts @@ -11,7 +11,7 @@ * one delivery at a time and never blocks on wall-clock time, a reader that * parked on an unfinished stream would deadlock the scenario. So readers park * on a promise that the *writer* resolves, and `abortOpenReaders()` releases - * any that are still parked when the scenario ends — turning what would be a + * any that are still parked when the scenario ends, turning what would be a * hang into a reported diagnostic. */ diff --git a/packages/world-sim/src/tempo.ts b/packages/world-sim/src/tempo.ts index e0c7e1363a..20b53c198a 100644 --- a/packages/world-sim/src/tempo.ts +++ b/packages/world-sim/src/tempo.ts @@ -2,16 +2,16 @@ * The scripting layer: stop a writer inside a world call, act while it is held, * let it go. * - * Everything here compiles down to one thing — a watch on a call point whose + * Everything here compiles down to one thing: a watch on a call point whose * action returns a promise, which blocks the intercepted call until that * promise settles. `Writer.runTo*` (see `writers.ts`) is the vocabulary a * scenario should reach for; `park` / `until` / `during` are the primitive it is * built from, kept public for points no writer op names. * * The hazard a script introduces is that it can wait for something that will - * never happen, and because a held call blocks the writer that made it — and, - * when that writer is the one the scheduler is inside, the whole loop — that is - * a hang rather than a quiescent stall. Three things buy the termination + * never happen, and because a held call blocks the writer that made it (and, + * when that writer is the one the scheduler is inside, the whole loop), that + * is a hang rather than a quiescent stall. Three things buy the termination * guarantee back: * * - `runTo` is **level-triggered**: a point that has already gone by is an @@ -34,7 +34,7 @@ import { type ArmedHold, createWriters } from './writers.js'; /** * Raised into a script's pending waits when the scenario tears down. It means - * "the world stopped, stop waiting" — not a failure of the script — so the + * "the world stopped, stop waiting", not a failure of the script, so the * runner does not report it as one. */ export class ScenarioAborted extends Error { @@ -118,7 +118,7 @@ export function createTempo( // Indirect through `settle` rather than storing `reject`: `settle` is // replaced below with the wrapper that also disposes the watch, and // `abort()` walks this map. Storing the raw `reject` here would reject - // the script's promise while leaving the watch armed — and a watch that + // the script's promise while leaving the watch armed, and a watch that // fires after an abort blocks its call on a promise whose `release` is // no longer reachable from anywhere, which is a hang rather than a // late wake-up (the global deadline is one-shot and already spent). @@ -186,7 +186,7 @@ export function createTempo( return new Promise((resolve, reject) => { // Same disposing-reject shape as `armHold`. A leaked watch here does not - // hang anything — this action resolves rather than blocking — but it + // hang anything (this action resolves rather than blocking), but it // still stops a writer for a wait nobody is listening to. waiting.set(id, { label: name, diff --git a/packages/world-sim/src/types.ts b/packages/world-sim/src/types.ts index d401a4a5f0..49c32f103f 100644 --- a/packages/world-sim/src/types.ts +++ b/packages/world-sim/src/types.ts @@ -12,7 +12,7 @@ import type { /** * Every World method the simulation can be paused on. These names are the * scheduling vocabulary: the requirement is that the deterministic sequence be - * expressed "from whatever the world api is", so a call point is always + * expressed "from whatever the World API is", so a call point is always * `(one writer, one of these calls, before|after)`. */ export type WorldCallName = @@ -52,15 +52,15 @@ export type CallPhase = 'before' | 'after'; * The simulation's whole subject is concurrent writers to one event log, so * every intercepted call is attributed to one. There are three kinds: * - * - `'orchestrator'` — the workflow function and the machinery around it: the + * - `'orchestrator'`: the workflow function and the machinery around it, the * suspension handler committing `step_created` / `step_started` / * `hook_created` / `wait_created`, the run lifecycle writes, and the event * log *reads* that decide what to do next. One per queue delivery. - * - `` `step:${shortName}` `` — one step body. Several are in flight at once + * - `` `step:${shortName}` ``: one step body. Several are in flight at once * inside a single delivery, each an independently advanceable async context, * each writing its own `step_completed` / `step_failed`. This is the writer * pair that corrupts a log with no out-of-band event involved at all. - * - `'external'` — the scenario itself, standing in for everything a real + * - `'external'`: the scenario itself, standing in for everything a real * deployment does out of band: a webhook receiver calling `resumeHook`, an * operator cancelling a run. * @@ -68,8 +68,8 @@ export type CallPhase = 'before' | 'after'; * limitation and not worth fixing: a scenario that needs to tell them apart * should give them distinct names. * - * The run's very first call — the `runs.create` that `start()` makes before any - * workflow code exists — is attributed to `'orchestrator'` rather than + * The run's first call (the `runs.create` that `start()` makes before any + * workflow code exists) is attributed to `'orchestrator'` rather than * `'external'`. Formally the client is out of band, but a scenario reads * `wf.runToEventCommitted('run_created')` as "let the run get created", and * giving that call to a different writer than every other step of the run's own @@ -81,7 +81,7 @@ export type WriterId = 'orchestrator' | 'external' | (string & {}); * What the simulation knows at a call point. * * `phase: 'after'` means the call's effect is committed to the store but the - * awaiting caller has not been resumed yet — this is the window the + * awaiting caller has not been resumed yet: this is the window the * requirement calls out ("the world will add the hook before returning from * whatever call commits the step started"). */ @@ -134,7 +134,7 @@ export interface ObservedPoint { /** * Nesting depth at which the point occurred. Depth > 0 means it happened * inside another call the scenario was already inside, where a hold is not - * possible — worth distinguishing in an error message. + * possible, which is worth distinguishing in an error message. */ depth: number; /** @@ -144,8 +144,8 @@ export interface ObservedPoint { * * Recorded so the level-triggered check agrees with `CallMatch.failed`. A * `runToEventCommitted` that ignored this would count a rejected write as the - * commit it was waiting for — routine under the fence, where a 412 is an - * expected step on the way to a successful retry. + * commit it was waiting for, which is routine under the fence, where a 412 + * is an expected step on the way to a successful retry. */ failed: boolean; } @@ -175,9 +175,9 @@ export interface WorldSnapshot { /** * Every intercepted world call that threw, in order. * - * Rejections are the visible mechanism behind a run that self-corrects — a + * Rejections are the visible mechanism behind a run that self-corrects (a * `PreconditionFailedError` from the optimistic-concurrency fence, an - * `EntityConflictError` from a write against an already-terminal run — so + * `EntityConflictError` from a write against an already-terminal run), so * they are recorded unconditionally rather than left to a scenario to * instrument. */ @@ -206,7 +206,7 @@ export interface PendingMessageView { export interface CallMatch { call?: WorldCallName | WorldCallName[]; /** - * Which side of the call to match. Defaults to `'after'` — the window where + * Which side of the call to match. Defaults to `'after'`: the window where * the effect is committed but the caller has not been resumed, which is the * one worth injecting into. Set `'before'` to act ahead of the write. */ @@ -240,7 +240,7 @@ export interface RunToOptions { correlationId?: string; /** * Extra condition on world state, evaluated at the candidate point. Use it - * for "once two steps have completed" — a condition about the world rather + * for "once two steps have completed": a condition about the world rather * than about one event. */ where?: (world: WorldSnapshot) => boolean; @@ -252,7 +252,7 @@ export interface RunToOptions { /** A writer stopped at a point, waiting to be let go. */ export interface Held { - /** The writer actually caught — concrete even when the handle was `anyStep()`. */ + /** The writer actually caught: concrete even when the handle was `anyStep()`. */ writer: WriterId; /** What the world was asked to do, and (once committed) what it did. */ ctx: CallContext; @@ -288,8 +288,8 @@ export interface Held { export interface Writer { readonly id: WriterId; /** - * Stop once the event has crossed the world boundary — fully formed, - * attributed to this writer, already in the trace — and before it is assigned + * Stop once the event has crossed the world boundary (fully formed, + * attributed to this writer, already in the trace) and before it is assigned * a position in the event log. * * A write that commits to storage while this one is held sorts ahead of it. @@ -338,8 +338,8 @@ export interface WriterHandles { * Everything the scenario script can do to the world. * * These are the only sanctioned sources of external input. Anything a real - * deployment could do out-of-band — a webhook arriving, an operator - * cancelling a run, time passing — has an entry here, so the scenario script + * deployment could do out-of-band (a webhook arriving, an operator + * cancelling a run, time passing) has an entry here, so the scenario script * is a complete description of what happened. */ export interface ScenarioApi { @@ -374,8 +374,8 @@ export interface ScenarioApi { * one reason: the delivery loop is serial, so while a script holds an inline * step body the loop is stopped inside that same delivery and no timer can * fire. Every interleaving in which a `wait_completed` lands *while a step - * result is still outstanding* is therefore unreachable from the loop alone - * — and that is not an exotic corner, it is what + * result is still outstanding* is therefore unreachable from the loop + * alone, and that is not an exotic corner, it is what * `Promise.race([step, sleep])` does whenever the step is slower than the * sleep. * @@ -383,11 +383,11 @@ export interface ScenarioApi { * which is what a real queue does with two messages for the same run. * Concurrency in this simulator is otherwise structural rather than * scheduled, so this is the one place a script creates some; it stays - * deterministic because the script decides both when it starts and — through - * the writer it is holding — when the other delivery resumes. + * deterministic because the script decides both when it starts and (through + * the writer it is holding) when the other delivery resumes. * - * `select` receives the pending messages in the loop's own order — earliest - * `readyAt`, then enqueue order — and returns a `messageId`. The default + * `select` receives the pending messages in the loop's own order (earliest + * `readyAt`, then enqueue order) and returns a `messageId`. The default * takes the first, i.e. exactly what the loop would have done next. A script * that wants a timer specifically should say so rather than rely on the * default: a hook delivery enqueues a flow message too, and it will usually @@ -399,11 +399,11 @@ export interface ScenarioApi { ): Promise; /** * Hide the next event this scenario commits from the following `reads` - * event-log reads, modelling one concurrent writer the reader missed. + * event-log reads, modeling one concurrent writer the reader missed. * * Call it immediately before the write you want hidden. This is the only way * a serial simulation can produce "a write derived from an incomplete event - * load" — the precondition a real deployment reaches through concurrency. + * load", the precondition a real deployment reaches through concurrency. */ withholdNextEvent(reads?: number): void; /** Record a free-text marker in the scenario trace. */ @@ -440,7 +440,7 @@ export interface Parked { * * The scenario vocabulary is writers: name them, advance them one point at a * time, and the interleaving is the script's control flow rather than a race. - * `park` / `until` / `during` are the primitive underneath — reach for them for + * `park` / `until` / `during` are the primitive underneath; reach for them for * a point no writer op names, such as a plain world *read*. */ export interface Tempo extends ScenarioApi { @@ -450,7 +450,7 @@ export interface Tempo extends ScenarioApi { * Wait until a world call reaches a matching point, and hold it there. * * Everything that writer would go on to do is suspended while the call is - * parked: the caller is blocked inside the world. That is the point — + * parked: the caller is blocked inside the world. That is the point: * whatever the script does next is guaranteed to land before the call * returns. * @@ -470,11 +470,11 @@ export interface Tempo extends ScenarioApi { /** * A scenario body. Runs concurrently with the delivery loop, starting before - * the run does so it can hold the very first world call. + * the run does so it can hold the first world call. */ export type ScenarioScript = (sim: Tempo) => void | Promise; -/** One line of the scenario trace — either a world event or a simulation action. */ +/** One line of the scenario trace: either a world event or a simulation action. */ export type TraceEntry = | { kind: 'event'; diff --git a/packages/world-sim/src/world.ts b/packages/world-sim/src/world.ts index c0ebcd07ac..a8ddffd019 100644 --- a/packages/world-sim/src/world.ts +++ b/packages/world-sim/src/world.ts @@ -21,7 +21,7 @@ * * Watches do not fire for calls made from inside another watch's action. * Without that rule, a watch on `events.create` would re-trigger on the - * `hook_received` it just wrote, and any scenario using `deliverHook` would + * `hook_received` it wrote, and any scenario using `deliverHook` would * recurse forever. */ @@ -144,7 +144,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { * `external` and is not itself a call point. * * Async-context-scoped rather than a plain counter, because `asExternal` - * brackets whole operations — `scenario.ts` wraps all of `resumeHook`, which + * brackets whole operations: `scenario.ts` wraps all of `resumeHook`, which * spans several awaits. A counter is a global flag for that whole window, so * a step body committing concurrently gets read as the scenario's own call: * attributed `external`, skipped by `fireWatches` (a `runTo` armed on it waits @@ -153,7 +153,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { * step's own `step_completed`, in the one scenario whose subject is the * writer column. * - * Deliberately *not* set for the duration of a watch action — see + * Deliberately *not* set for the duration of a watch action; see * `fireWatches`. A held action outlives the call it fired from, and a flag * held that long would silence every other writer. */ @@ -235,14 +235,14 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { /** * Which writer is responsible for an event. * - * Everything the *scenario* does is `external` — that check comes first, + * Everything the *scenario* does is `external`. That check comes first, * because a `run_cancelled` from an operator and a `run_cancelled` from the - * runtime are the same event type written by very different writers, and only + * runtime are the same event type written by different writers, and only * the call stack can tell them apart. * * Otherwise: a step's own result events belong to that step body, an * attribute write names its writer explicitly in the event, and everything - * else — the step and hook and wait *creations*, the run lifecycle — is the + * else (the step and hook and wait *creations*, the run lifecycle) is the * orchestrator committing at a suspension point. */ function writerOfEvent(event: { @@ -299,7 +299,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { const { match } = watch; // An unspecified phase means `after`, never "both". Matching both would - // fire every watch twice — and, worse, fire a `nth: 1` watch at `before`, + // fire every watch twice and, worse, fire a `nth: 1` watch at `before`, // where the effect it is keyed on has not happened yet and `ctx.event` is // absent. `before` is the case you opt into. if ((match.phase ?? 'after') !== ctx.phase) return false; @@ -368,7 +368,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { } if (match.where) { - // A predicate that throws must not become a world-call failure — see the + // A predicate that throws must not become a world-call failure; see the // note on watch actions below. Treat it as "did not match" and report. try { if (!match.where(ctx, snapshot)) return false; @@ -387,7 +387,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { async function fireWatches(ctx: CallContext): Promise { // Calls the scenario itself makes are not call points: they would otherwise - // trip the very watches they were made from inside of. + // trip the watches they were made from inside of. if (isExternal()) return; // Iterate a copy: an action may dispose its own watch, or arm a new one. @@ -410,7 +410,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { // it, so raising the depth for the duration would mean: for as long as one // writer is held, every *other* writer's call stops being a call point and // every event it commits is attributed to the scenario. Holding one step - // body would make its sibling both invisible and unsteerable — the exact + // body would make its sibling both invisible and unsteerable, the exact // interleaving the writer vocabulary exists to state. Scenario-originated // writes get their attribution from `asExternal` instead, which follows // the call chain rather than the wall clock. @@ -502,7 +502,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { * This reconstructs the array the client has in memory, which is the whole * input to the fence. The runtime does not state it: `@workflow/core` * describes its snapshot as a slot count and the sim mints ULIDs, so the - * facade derives the pair the fence needs — the newest loaded position, and + * facade derives the pair the fence needs: the newest loaded position, and * how many events sit at or below it. Since the watermark *is* the maximum of * those times, the count is the size of the array. "I loaded N events, the * newest at T" is what lets the world spot a hole *behind* T, which no @@ -534,7 +534,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { * count below the watermark it is paired with and reject current writes. * * A scan that starts without a cursor replaces the set rather than adding to - * it — that is the runtime re-reading the log from the beginning, and its + * it: that is the runtime re-reading the log from the beginning, and its * earlier view should not linger. */ const deliveryCtx = new AsyncLocalStorage>>(); @@ -634,7 +634,7 @@ export function createSimWorld(options: SimWorldOptions = {}): SimWorld { } // For a create by a writer that has already loaded the log, what that log - // held going in — so the caller can be credited with everything its own + // held going in, so the caller can be credited with everything its own // write appended, not just the event the call handed back. A // `step_started` claim also appends the `step_created` ahead of it, and a // client that did not count both would look like it was holding a hole it diff --git a/packages/world-sim/src/writers.ts b/packages/world-sim/src/writers.ts index 61814ce491..67c177e7f4 100644 --- a/packages/world-sim/src/writers.ts +++ b/packages/world-sim/src/writers.ts @@ -6,7 +6,7 @@ * them**. The orchestrator loads the log, replays the workflow against it, and * commits what that decides; each step body writes its own result; a webhook * receiver writes a `hook_received` whenever it likes. Nothing sequences those - * against each other, and the World API exposes no primitive that could — at + * against each other, and the World API exposes no primitive that could; at * most an optimistic fence, which is two checks and not isolation: * * - a watermark is a high-water mark on one class of write ("is there an @@ -40,7 +40,7 @@ * * `runTo` consults the history of points each writer has already reached before * arming anything. If the point has gone by it throws, naming the call it - * happened at. The alternative — arm a watch and wait — is a hang: a held call + * happened at. The alternative (arm a watch and wait) is a hang: a held call * blocks its writer, and in the limit blocks the scheduler, so there is no * quiescence to fall back on and no timer to eventually fire. An edge-triggered * wait on an edge that has passed is the one way to lose the termination @@ -49,8 +49,8 @@ * * ## What is not offered * - * Writers form a dependency graph — the orchestrator awaits its own step - * bodies — so not every interleaving exists to be asked for, and an + * Writers form a dependency graph (the orchestrator awaits its own step + * bodies), so not every interleaving exists to be asked for, and an * unsatisfiable `runTo` can only be reported, not prevented. Each wait * therefore carries its own wall-clock budget and, on expiry, reports where * every writer was standing. @@ -77,7 +77,7 @@ import type { SimWorld } from './world.js'; /** * Thrown when a `runTo` names a point its writer has already gone past. Not a - * simulator failure — a scenario that armed too late. + * simulator failure, but a scenario that armed too late. */ export class AlreadyPassedError extends Error { override readonly name = 'AlreadyPassedError'; @@ -191,7 +191,7 @@ export function createWriters(deps: { * The watch hands over a `CallContext`, and a call is recorded once per phase * under the same `seq`, so the pair identifies the record. Searching from the * end finds it immediately in the normal case. A history that hit its cap has - * stopped recording, so fall back to "everything recorded is behind us" — + * stopped recording, so fall back to "everything recorded is behind us": * over-consuming is safe, under-consuming would report a spurious miss. */ function ordinalReached(ctx: Held['ctx']): number { @@ -225,7 +225,7 @@ export function createWriters(deps: { * before it are "already consumed" and do not count as already-passed: * asking twice for `step_completed` means the *next* one, which is what the * duplicate-delivery scenarios need. A consumed point with no successor is - * therefore not an error here — it is the per-`runTo` timeout's business. + * therefore not an error here; it is the per-`runTo` timeout's business. */ let watermark = -1; let hold: { ctx: Held['ctx']; release(): void; done: boolean } | undefined; @@ -309,11 +309,11 @@ export function createWriters(deps: { try { // Advancing a writer that is already held means "let it go, then stop - // it at the next thing" — the reading `runTo` after `runTo` invites. + // it at the next thing", the reading `runTo` after `runTo` invites. // The release happens *after* the watch is armed, and that order is // load-bearing: the released writer can reach the next point within the - // same turn — the `after` phase of the very call it was held in is the - // common case — and a watch armed afterwards would have missed it. + // same turn (the `after` phase of the call it was held in is the + // common case), and a watch armed afterwards would have missed it. await release(); const reached = await armed.reached; watermark = ordinalReached(reached.ctx); @@ -352,7 +352,7 @@ export function createWriters(deps: { phase: 'after', // "Committed" means committed. Without this a rejected create // matches too, and under the fence a `PreconditionFailedError` is - // routine — the script would resume believing a write is durable + // routine: the script would resume believing a write is durable // when it 412'd, and the watermark would consume the point, so the // retry's real commit would read as the *next* one. failed: false, diff --git a/packages/world-vercel/README.md b/packages/world-vercel/README.md index 391de4db1a..657c417188 100644 --- a/packages/world-vercel/README.md +++ b/packages/world-vercel/README.md @@ -8,7 +8,7 @@ Used by default for deployments on Vercel. Authentication and API endpoints are ## Custom dispatcher -HTTP requests (including the queue) default to a shared undici `RetryAgent` that handles connection pooling and retries. Pass a custom `dispatcher` to override it — e.g. to tune undici on newer Node runtimes: +HTTP requests (including the queue) default to a shared undici `RetryAgent` that handles connection pooling and retries. Pass a custom `dispatcher` to override it, for example, to tune undici on newer Node.js runtimes: ```ts import { Agent } from 'undici'; diff --git a/packages/world-vercel/src/analytics.ts b/packages/world-vercel/src/analytics.ts index b587d52078..522cffaada 100644 --- a/packages/world-vercel/src/analytics.ts +++ b/packages/world-vercel/src/analytics.ts @@ -161,7 +161,7 @@ export function createAnalytics(config?: APIConfig): Analytics { searchParams.set('correlationId', params.correlationId); appendPagination(searchParams, params.pagination); - // A correlation id is unique per run, not globally — a slot-numbered + // A correlation id is unique per run, not globally: a slot-numbered // run numbers its own steps, so `step_…001` names the first step of // every such run. The run-scoped endpoint takes the same // correlation-id filter, so scoping costs nothing here. diff --git a/packages/world-vercel/src/create-run-id.ts b/packages/world-vercel/src/create-run-id.ts index 142d284e26..012c33c343 100644 --- a/packages/world-vercel/src/create-run-id.ts +++ b/packages/world-vercel/src/create-run-id.ts @@ -28,7 +28,7 @@ let lastRunId: string | undefined; * Increment the bit immediately above the 11-bit metadata window of a * 26-char tagged ULID. The metadata occupies the top 11 bits of the * randomness section (all of byte 6 + the top 3 bits of byte 7), so the - * next bit up is the lowest bit of the 48-bit timestamp (byte 5) — the + * next bit up is the lowest bit of the 48-bit timestamp (byte 5), so the * result is effectively the same ULID time-stamped 1ms later. This lets * us produce a strictly-larger ULID regardless of what region/version * metadata is subsequently stamped on top. @@ -46,7 +46,7 @@ function bumpAboveMetadata(ulidStr: string): string { i--; } if (carry > 0) { - // 48-bit timestamp space exhausted — astronomically unlikely. + // 48-bit timestamp space exhausted, astronomically unlikely. throw new Error('ULID space exhausted'); } return bytesToUlid(bytes); @@ -69,9 +69,9 @@ function coerceRegion(value: unknown): RegionCode | null { * Resolve the effective region for a run, preferring an explicit value * supplied via the `start()` options bag over the `VERCEL_REGION` * environment variable. Falls back to {@link DEFAULT_REGION_CODE} (iad1) - * when neither source yields a recognised region, so a run ID is always + * when neither source yields a recognized region, so a run ID is always * tagged with a concrete, routable region rather than the `unknown` (0) - * sentinel — matching the server's default-region resolution. + * sentinel, matching the server's default-region resolution. */ function resolveRegion( options: Readonly> | undefined @@ -87,11 +87,11 @@ function resolveRegion( * `World.createRunId` implementation that mints region-tagged ULIDs. * * Region resolution order (first non-empty wins): - * 1. `options.region` — explicit caller-supplied region forwarded by + * 1. `options.region`: explicit caller-supplied region forwarded by * `start({ region })`. - * 2. `process.env.VERCEL_REGION` — the region the current Vercel function + * 2. `process.env.VERCEL_REGION`: the region the current Vercel function * is executing in. - * 3. {@link DEFAULT_REGION_CODE} (iad1) — the server-side default region. + * 3. {@link DEFAULT_REGION_CODE} (iad1): the server-side default region. * A run ID is therefore always tagged with a concrete, routable region; * the `unknown` (0) sentinel is never minted here. * @@ -124,8 +124,8 @@ export function createRunId( * * - Region-tagged IDs (minted by {@link createRunId}) decode to their * embedded region. - * - Untagged legacy IDs — and tagged IDs whose region code is unknown to - * this SDK version — resolve to {@link DEFAULT_REGION_CODE}: all + * - Untagged legacy IDs, and tagged IDs whose region code is unknown to + * this SDK version, resolve to {@link DEFAULT_REGION_CODE}: all * pre-tagging data lives there by convention, matching the backend's * routing. * - Malformed IDs return `null` (never throws). @@ -146,8 +146,8 @@ export function regionForRunId(runId: string): string | null { * `World.describeRun` implementation: Vercel-specific display fields * for a run. * - * Currently a single field — `region`, decoded from the run ID's - * region tag (see {@link regionForRunId}) — but the shape leaves room + * Currently a single field (`region`, decoded from the run ID's + * region tag; see {@link regionForRunId}), but the shape leaves room * for additional fields derived from other run-entity properties * (e.g. `executionContext`) without another interface change. A * `null` region means the run ID was present but undecodable; diff --git a/packages/world-vercel/src/encryption.ts b/packages/world-vercel/src/encryption.ts index 5a3d223322..63ececad91 100644 --- a/packages/world-vercel/src/encryption.ts +++ b/packages/world-vercel/src/encryption.ts @@ -88,8 +88,9 @@ export async function deriveRunKey( * deployment key never leaves the API boundary. The returned key * is ready-to-use for AES-GCM encrypt/decrypt operations. * - * Uses OIDC token authentication (for cross-deployment runtime calls like - * resumeHook) or falls back to VERCEL_TOKEN (for external tooling like o11y). + * Uses OpenID Connect (OIDC) token authentication (for cross-deployment runtime + * calls like resumeHook) or falls back to VERCEL_TOKEN (for external tooling + * like observability). * * @param deploymentId - The deployment ID that holds the base key material * @param projectId - The project ID for HKDF context isolation @@ -123,8 +124,8 @@ export async function fetchRunKey( params.set('teamId', options.teamId); } // 429/5xx retries are handled by the shared RetryAgent from getDispatcher(). - // instrumentedFetch adds the OTEL client span + DEBUG logging the v3/v4 - // paths have. + // instrumentedFetch adds the OpenTelemetry client span + DEBUG logging the + // v3/v4 paths have. const response = await instrumentedFetch({ method: 'GET', url: `https://api.vercel.com/v1/workflow/run-key/${deploymentId}?${params}`, @@ -181,7 +182,7 @@ export function createGetEncryptionKeyForRun( ): World['getEncryptionKeyForRun'] { if (!projectId) return undefined; - // VERCEL=1 is set inside Vercel serverless functions. When true, we can + // VERCEL=1 is set inside Vercel Functions. When true, we can // use the local deployment key for HKDF derivation. When false (e.g., e2e // test runner, CLI, external tooling), we must fetch the key from the API // even for same-deployment runs. diff --git a/packages/world-vercel/src/event-retry.ts b/packages/world-vercel/src/event-retry.ts index 0310309e6f..6d959ada52 100644 --- a/packages/world-vercel/src/event-retry.ts +++ b/packages/world-vercel/src/event-retry.ts @@ -2,8 +2,8 @@ * In-process retry for event POSTs. * * undici's `RetryAgent` never retries a POST (a non-idempotent method), so a - * trivially-recoverable transport blip — `UND_ERR_REQ_RETRY`, `ECONNRESET`, a - * socket/headers timeout, a transient 5xx — bubbles straight out of an event + * trivially-recoverable transport blip (`UND_ERR_REQ_RETRY`, `ECONNRESET`, a + * socket/headers timeout, a transient 5xx) bubbles straight out of an event * write. For a `step_completed`/`step_failed` write that means the queue message * is not acked, it redelivers, the workflow replays, and the step's user code is * re-executed with `attempt++` even though it already ran to completion once. @@ -12,7 +12,7 @@ * idempotent *in outcome*: the entity handlers run before the event-log row is * inserted, and state transitions are conditional writes that exclude * already-terminal states. A retry whose original already landed therefore - * throws before any row is written and surfaces as a 409 for most types — which + * throws before any row is written and surfaces as a 409 for most types, which * the SDK maps to `EntityConflictError` and existing callers already handle (e.g. * the step executor swallows it as `{ type: 'skipped' }`). The two non-conflict * cases still resolve as plain success the caller already handles: `run_started` @@ -24,18 +24,18 @@ * idempotent-on-retry, and leaves the rest at a single attempt. The three * excluded types are NOT safe to blindly retry: * - * - `step_started` — its handler does an unconditional `attempt += 1` with a + * - `step_started`: its handler does an unconditional `attempt += 1` with a * `.where()` that only excludes terminal states, so a * retried start double-increments the attempt counter. - * - `step_retrying` — re-applies `pending` (idempotent state) but its handler + * - `step_retrying`: re-applies `pending` (idempotent state) but its handler * does NOT throw on a duplicate, so a retry appends a * second event-log row. - * - `hook_received` — an ORDINARY hook write has no server-side guard, so a + * - `hook_received`: an ORDINARY hook write has no server-side guard, so a * retry appends a duplicate row and can re-deliver the * payload. The atomic lazy-resume shape (resumeId + - * resumePayloadDigest) IS guarded — the server's + * resumePayloadDigest) IS guarded (the server's * (runId, resumeId) claim converges a retry on the same - * canonical event — so those writes opt back into the + * canonical event), so those writes opt back into the * standard policy via * {@link EventPostRetryOptions.idempotentHookResume}. * @@ -45,20 +45,20 @@ * * A 429 (`ThrottleError`) is handled by a separate in-process policy that * applies to EVERY event type: a genuine application 429 means the server - * rejected the write outright — nothing landed — so none of the duplicate-row / + * rejected the write outright (nothing landed), so none of the duplicate-row / * attempt-double-count hazards above apply (the same definitive-no-write * reasoning `STREAM_RETRY_OPTIONS` uses to retry 429 on stream PUTs). Firewall * challenges never reach here as `ThrottleError`: `errorForResponse` maps a * 429 + `x-vercel-mitigated: challenge` to a transport `WorkflowWorldError` * instead (see isFirewallChallenge429), so throttle retries cannot hot-loop * against the firewall. Each retry honors the server's `retryAfter`, and the - * cumulative wait is capped by THROTTLE_RETRY_BUDGET_MS — beyond that the + * cumulative wait is capped by THROTTLE_RETRY_BUDGET_MS; beyond that the * ThrottleError surfaces and the queue's redelivery takes over, exactly as * before this policy existed. * * This is the *only* retry loop on the event-write path, for both transports. - * The WebSocket transport raises `code: 'TRANSPORT'` — the shape `utils.ts` - * produces for a failed `fetch` — so it lands here rather than carrying a + * The WebSocket transport raises `code: 'TRANSPORT'` (the shape `utils.ts` + * produces for a failed `fetch`), so it lands here rather than carrying a * second policy blind to `EVENT_RETRY_ELIGIBILITY`. */ @@ -79,7 +79,7 @@ type WorkflowEventType = z.infer; export interface EventRetryPolicy { /** Whether a failed POST of this event type may be retried in-process. */ retryable: boolean; - /** Why — kept in code so the validated classification is self-documenting. */ + /** Why, kept in code so the validated classification is self-documenting. */ reason: string; } @@ -150,7 +150,7 @@ export const EVENT_RETRY_ELIGIBILITY = { retryable: true, reason: 'correlationId constraint reuses eventId (idempotent replay)', }, - // NOT safe to retry — see the module comment. + // NOT safe to retry; see the module comment. step_started: { retryable: false, reason: 'unconditional attempt increment → a retry double-counts attempts', @@ -173,7 +173,7 @@ export const EVENT_RETRY_ELIGIBILITY = { reason: 'server-originated; never POSTed by the SDK', }, // Server-originated sealed-log filler (specVersion 7); the SDK never - // POSTs it — the server's read path writes it to seal an abandoned slot. + // POSTs it. The server's read path writes it to seal an abandoned slot. noop: { retryable: false, reason: 'server-originated; never POSTed by the SDK', @@ -186,7 +186,7 @@ export const MAX_EVENT_POST_RETRIES = 2; /** * Cumulative in-process wait budget for 429 (`ThrottleError`) retries, per * event POST. Bounds the "no attempt tracking" failure mode: a server that - * keeps 429ing cannot pin the invocation — once the budget cannot cover the + * keeps 429ing cannot pin the invocation: once the budget cannot cover the * next `retryAfter`, the ThrottleError surfaces and the queue's (delivery- * counted, backed-off) redelivery takes over. Sized so a couple of attempts * fit at the Retry-After magnitudes the backend sends under write contention @@ -196,7 +196,7 @@ export const MAX_EVENT_POST_RETRIES = 2; export const THROTTLE_RETRY_BUDGET_MS = 30_000; /** Backoff when a 429 carries no usable Retry-After. */ const DEFAULT_THROTTLE_RETRY_AFTER_SECONDS = 1; -/** Base backoff; doubles per attempt. Kept tiny — the goal is riding out a +/** Base backoff; doubles per attempt. Kept tiny: the goal is riding out a * brief blip inline, not waiting out an outage (that falls through to the * queue's redelivery). */ export const EVENT_POST_RETRY_BASE_MS = 100; @@ -221,11 +221,11 @@ const TRANSIENT_CODES = new Set([ 'UND_ERR_BODY_TIMEOUT', // Names (undici/Node surface these on the error or its cause). // `TimeoutError` is our own per-request deadline (`AbortSignal.timeout` in - // makeRequest) — a genuinely ambiguous failure worth retrying. We deliberately + // makeRequest), a genuinely ambiguous failure worth retrying. We deliberately // do NOT include `AbortError`: that is how an external/caller-supplied // cancellation surfaces (makeRequest composes the caller's signal via - // `AbortSignal.any`), and re-issuing a write the caller asked to cancel — - // stalling the abort by the full backoff budget — would be wrong. + // `AbortSignal.any`), and re-issuing a write the caller asked to cancel + // (stalling the abort by the full backoff budget) would be wrong. 'TimeoutError', 'RequestRetryError', ]); @@ -247,11 +247,11 @@ function collectErrorMarkers(err: unknown, depth = 0): string[] { * Whether a failed event POST should be retried as a transient failure. * Retries transient/ambiguous transport failures and transient 5xx; never * retries a definitive response (409/410/425/429 and other 4xx). 429 is not - * "transient" in this classification — `withEventPostRetry` gives it its own + * "transient" in this classification: `withEventPostRetry` gives it its own * budgeted, Retry-After-honoring policy. */ export function isRetryableEventPostError(err: unknown): boolean { - // Definitive, server-considered outcomes — never retried as *transient*. + // Definitive, server-considered outcomes, never retried as *transient*. // (425 is left to the runtime's retry-after handling; 429 has its own // in-process policy in withEventPostRetry, gated by THROTTLE_RETRY_BUDGET_MS // rather than this transient classification.) @@ -265,7 +265,7 @@ export function isRetryableEventPostError(err: unknown): boolean { } if (WorkflowWorldError.is(err)) { - // Body parsed past the response but the write may have landed — safe to + // Body parsed past the response but the write may have landed: safe to // retry for eligible events (a landed original re-surfaces as 409). if (err.code === 'PARSE_ERROR') return true; // A transport failure the world layer already classified as transient: @@ -288,7 +288,7 @@ export function isRetryableEventPostError(err: unknown): boolean { // Transient server errors; 4xx are definitive and not retried. return err.status >= 500 && err.status <= 599; } - // No status (e.g. a timeout wrapped by makeRequest) — fall through to the + // No status (e.g. a timeout wrapped by makeRequest): fall through to the // transport-marker check on the error/cause chain. } @@ -299,7 +299,7 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); /** Gated like the rest of world-vercel's HTTP layer (`DEBUG=workflow:*`). Keeps - * in-process retries visible during a latency/outage investigation — otherwise a + * in-process retries visible during a latency/outage investigation; otherwise a * step that quietly rode out a blip and one that exhausted its retries and fell * through to queue redelivery look identical in logs/traces. */ const RETRY_DEBUG_ENABLED = @@ -329,9 +329,9 @@ export interface EventPostRetryOptions { /** * Narrow opt-in for `hook_received` writes carrying the atomic lazy-resume * idempotency pair (`resumeId` + `resumePayloadDigest`). Those writes are - * deduplicated server-side by the `(runId, resumeId)` claim — a retry whose + * deduplicated server-side by the `(runId, resumeId)` claim (a retry whose * original landed converges on the same canonical event instead of - * appending a duplicate row — so they get the standard transient retry + * appending a duplicate row), so they get the standard transient retry * policy. Legacy `hook_received` (no pair, or an incomplete one) stays * single-attempt per {@link EVENT_RETRY_ELIGIBILITY}; definitive 4xx * responses stay non-retryable regardless. @@ -340,15 +340,15 @@ export interface EventPostRetryOptions { /** * Batch POST retry verdict, set by `createWorkflowRunEventBatch` from the * batch's CONTENTS. `true` means every event in the batch converges on a - * retry of a committed attempt — entity-conditioned events (creates, + * retry of a committed attempt: entity-conditioned events (creates, * terminal transitions) re-reject with 409, and a `step_started` is fenced - * by its born-running pair's create-claim — so the whole POST gets the + * by its born-running pair's create-claim, so the whole POST gets the * standard transient retry policy and a re-send converges to per-event * 409s with nothing written twice. `false` means at least one event does * NOT converge (a standalone bare `step_started` re-increments `attempt`, * a `step_retrying` re-patches its step), so the batch runs * single-attempt and recovery is left to queue redelivery. When set (a - * batch call), this verdict REPLACES the per-type matrix entirely — the + * batch call), this verdict REPLACES the per-type matrix entirely: the * matrix classifies single posts and its entry for any one type says * nothing about a mixed batch. Definitive 4xx responses stay * non-retryable regardless. @@ -359,8 +359,9 @@ export interface EventPostRetryOptions { /** * Per-POST throttle-wait accounting. The returned function waits out a 429's * `retryAfter` in-process (so the caller can re-attempt), or rethrows the - * ThrottleError once the cumulative wait would exceed THROTTLE_RETRY_BUDGET_MS - * — at which point the queue's delivery-counted redelivery takes over. + * ThrottleError once the cumulative wait would exceed + * THROTTLE_RETRY_BUDGET_MS, at which point the queue's delivery-counted + * redelivery takes over. */ function createThrottleWaiter( eventType: WorkflowEventType @@ -413,7 +414,7 @@ function isEligibleForTransientRetry( /** * Run an event POST, retrying transient transport failures in-process when the * event type is idempotent-on-retry, and 429 throttles in-process for every - * event type (a 429 is a definitive no-write — see the module comment) while + * event type (a 429 is a definitive no-write; see the module comment) while * the cumulative `retryAfter` wait fits THROTTLE_RETRY_BUDGET_MS. Other * definitive responses run/throw on the first attempt, preserving existing * behavior. diff --git a/packages/world-vercel/src/events-v4.ts b/packages/world-vercel/src/events-v4.ts index 4cd71ba695..d454b4114f 100644 --- a/packages/world-vercel/src/events-v4.ts +++ b/packages/world-vercel/src/events-v4.ts @@ -1,5 +1,5 @@ /** - * v4 event endpoints — fully framed wire protocol. + * v4 event endpoints: fully framed wire protocol. * * Both directions use the same length-prefixed binary frame layout: * @@ -18,7 +18,7 @@ * * Higher-level callers (the world-vercel adapter) CBOR-encode their JS * values into the `payload` parameter and CBOR-decode returned `body` - * bytes — this module stays at the wire-bytes layer. + * bytes. This module stays at the wire-bytes layer. */ import assert from 'node:assert/strict'; @@ -76,7 +76,7 @@ import type { WsFrameReply } from './ws-transport.js'; import { isWsEventsTransportEnabled } from './ws-transport-enabled.js'; /** - * Issue an instrumented v4 request through the global `fetch` — NOT undici's + * Issue an instrumented v4 request through the global `fetch`, NOT undici's * `request`. * * Vercel's observability "outgoing requests" view instruments the global @@ -84,18 +84,18 @@ import { isWsEventsTransportEnabled } from './ws-transport-enabled.js'; * so v4 event traffic disappeared from the log viewer while queue traffic * (which uses `fetch`) kept showing. `instrumentedFetch` routes through the * global `fetch` with the custom dispatcher, restoring visibility while also - * opening the OTEL client span, injecting trace context, setting the - * cache-bust header (see #618), and emitting `DEBUG` logs — the same envelope - * the v3 `makeRequest` path has always had. + * opening the OpenTelemetry client span, injecting trace context, setting the + * cache-bust header (see #618), and emitting `DEBUG` logs. This is the same + * envelope the v3 `makeRequest` path has always had. * * The events API uses its own HTTP/2-enabled dispatcher * (`getEventsDispatcher`): these reads/writes are plain request/response (or a * streamed LIST response) and benefit from multiplexing. The default dispatcher * stays on HTTP/1.1 because H2 deadlocks the queue's webhook respondWith - * mechanism — see http-client.ts. + * mechanism. See http-client.ts. * * No per-request timeout: a LIST response streams the full event-log page, which - * for a large run can legitimately take a while to drain — a whole-request + * for a large run can legitimately take a while to drain, so a whole-request * deadline would abort it mid-stream. */ async function fetchV4( @@ -116,7 +116,7 @@ async function fetchV4( // Repeated transport failures retire the shared events pool and the next // request builds a fresh one. undici keeps a black-holed HTTP/2 session in // service indefinitely, so without this every request routed onto it fails - // until the compute instance is recycled — see noteEventsTransportOutcome. + // until the compute instance is recycled. See noteEventsTransportOutcome. onTransportOutcome: (error) => noteEventsTransportOutcome(dispatcher, error), timeoutMs: null, @@ -173,7 +173,7 @@ interface CreateEventV4InputBase { stepName?: string; attempt?: number; /** cbor-x encodes Date as CBOR tag 1 (epoch) and the server decodes it - * back to a Date — the round-trip is symmetric, so wait_created / + * back to a Date. The round-trip is symmetric, so wait_created / * step_retrying / etc. see a Date in eventData.resumeAt on the read * side. */ resumeAt?: Date; @@ -241,7 +241,7 @@ interface CreateEventV4InputBase { rsfs?: number; /** Client-measured synchronous replay-compute ms of only the FINAL replay * pass within the rsfs window (the pass that scheduled the first step), - * excluding awaited network I/O — not accumulated across earlier + * excluding awaited network I/O, not accumulated across earlier * pre-first-step passes, so it is not "the replay portion of rsfs". * Only present alongside rsfs, and only for the run's first step. */ finalSchedulingReplay?: number; @@ -275,7 +275,7 @@ interface CreateEventV4InputBase { * is not content-stable server-side). Older servers ignore it. */ resumePayloadDigest?: string; /** Marks a `step_created` as the queue consumer's re-ensure of a resilient - * step dispatch (`stepInput`-carrying step message). Advisory — see + * step dispatch (`stepInput`-carrying step message). Advisory. See * CreateEventParams.viaStepDispatch in @workflow/world: the server MAY * refuse it with 410 (`step-dispatch-revoked` → RunExpiredError) as * defense-in-depth when it recorded a 412 rejection for this correlation @@ -293,7 +293,7 @@ export type CreateEventV4Input = CreateEventV4InputBase & * Shape the v4 client attaches to `PreconditionFailedError.details` when a * rejecting server returned the missing events inline. `@workflow/errors` * types `details` as `unknown` (it cannot depend on the event type), so - * consumers narrow structurally — this interface is the contract they narrow + * consumers narrow structurally; this interface is the contract they narrow * to. */ export interface PreconditionFailureDetails { @@ -493,7 +493,7 @@ function buildPostFrameMeta( * Build the typed error for a non-2xx v4 response. Reuses the shared * `errorForResponse` status → error-type contract (409→EntityConflictError, * 410→RunExpiredError, 412→PreconditionFailedError, 425→TooEarlyError, - * 429→ThrottleError, else →WorkflowWorldError) so v3 and v4 stay in lockstep — + * 429→ThrottleError, else →WorkflowWorldError) so v3 and v4 stay in lockstep. * only the message *string* is v4-specific (`v4 {opName} failed: HTTP …`, * which the runtime and log tooling key on; the hook 404 → * HookNotFoundError translation in events.ts keys off status === 404). @@ -517,7 +517,7 @@ function errorFromV4Response( if (typeof record.code === 'string') code = record.code; if (statusCode === 412) details = decodePreconditionDetails(record); } else if (text) { - // body wasn't a structured object — keep the default message and append + // body wasn't a structured object, so keep the default message and append // whatever the server did send message += ` ${text}`; } @@ -580,7 +580,7 @@ function parseV4ErrorBody( return { record: json as V4ErrorBody }; } } catch { - // not JSON either — fall through to the raw text + // not JSON either; fall through to the raw text } return { text }; } @@ -590,9 +590,9 @@ function parseV4ErrorBody( * * A rejecting server MAY attach the events the client's snapshot was missing, * so the runtime can correct its event log without a follow-up events.list. - * The *presence* of `events` is the server's completeness signal — it omits + * The *presence* of `events` is the server's completeness signal: it omits * them entirely when it cannot prove the set accounts for the whole - * discrepancy — which also means an older or non-supporting server produces + * discrepancy, which also means an older or non-supporting server produces * the same "no delta" shape as one that declined to prove it, and the client * needs a single fallback path for both. * @@ -625,12 +625,12 @@ function decodePreconditionDetails( * represent, which disqualifies the whole delta. * * Payload fields (input / output / result / error / payload / metadata) are - * `Uint8Array` everywhere else in this client — the runtime dehydrates before + * `Uint8Array` everywhere else in this client. The runtime dehydrates before * writing and rehydrates after reading, and the write path throws on anything * else. A JSON 412 body cannot hold that: resolved bytes serialize to * `{"type":"Buffer","data":[…]}` or an index-keyed object depending on the - * backend's serializer. `EventSchema` accepts either — its payload fields are - * unions that bottom out in `z.any()` — so nothing downstream would flag the + * backend's serializer. `EventSchema` accepts either. Its payload fields are + * unions that bottom out in `z.any()`, so nothing downstream would flag the * mangled value; the runtime would hydrate garbage from it instead. A CBOR * body round-trips the bytes intact and passes this check on its own merits, * which is why a backend that attaches an event delta to a 412 encodes it that @@ -684,7 +684,7 @@ export function throwForErrorResponse( * The trailing `:eventType` path segment is an alias of the canonical * `/events` route: it exists purely so the event type is visible in * access logs / traces / route metrics without decoding the frame body. - * The frame meta's `eventType` remains authoritative — the backend + * The frame meta's `eventType` remains authoritative, and the backend * cross-checks the two and logs (but does not reject) a mismatch. */ async function postWorkflowRunEventV4( @@ -737,7 +737,7 @@ export async function createWorkflowRunEventV4( ): Promise & { event: Event }> { if (isWsEventsTransportEnabled()) { // Absent means no socket was resolvable for this run, not that the write - // failed — fall through to HTTP. + // failed, so fall through to HTTP. const reply = await postEventFrameOverWs(input, config); if (reply) return decodeCreateEventResponse(reply, input.eventType); } @@ -753,7 +753,7 @@ export async function createWorkflowRunEventV4( } /** Takes `FrameResponseLike` rather than `Response` because the WS branch has - * none to hand over — it synthesizes one. A real `Response` satisfies the + * none to hand over; it synthesizes one. A real `Response` satisfies the * interface, so the HTTP callers are unaffected. */ async function decodeCreateEventResponse( response: FrameResponseLike, @@ -812,7 +812,7 @@ export type CreateEventBatchV4Event = CreateEventV4InputBase & { export interface CreateEventBatchV4Input { runId: string; - /** Events in request order — the order they land in the run's log. */ + /** Events in request order: the order they land in the run's log. */ events: CreateEventBatchV4Event[]; } @@ -846,13 +846,13 @@ const BatchItemFailureSchema = z.object({ * HTTP 200 CBOR `{ results }` whenever the batch was processed, one entry per * frame in request order. * - * Slot-identity runs only — an older server 404s the route and a pre-slot + * Slot-identity runs only: an older server 404s the route and a pre-slot * run is rejected with a 400. There is NO automatic fallback to single-event * posts on either: the runtime never sends a batch for a pre-slot run (it * gates on the run's spec version), and against a backend without the route * the batch fails and the suspension redelivers until the operator disables * batching via `WORKFLOW_BATCH_TRANSITIONS=0`. Ambiguous failures (timeouts, - * resets, 5xx, malformed responses) never convert to single posts either — + * resets, 5xx, malformed responses) never convert to single posts either; * the wrapper either re-sends the SAME batch (only when its shape is * retry-convergent; see `createWorkflowRunEventBatch`) or surfaces the error * for queue redelivery, whose replay re-derives an idempotent batch. @@ -868,7 +868,7 @@ export async function createWorkflowRunEventsBatchV4( assert(input.events.length > 0, 'v4 createEventBatch: empty batch'); const { baseUrl, headers: baseHeaders } = await getHttpConfig(config); const headers = new Headers(baseHeaders); - // Match the single-event POST content type — the batch route runs on the + // Match the single-event POST content type: the batch route runs on the // same authed + v4 middleware chain and the frame bytes are identical. headers.set('Content-Type', 'application/octet-stream'); @@ -888,7 +888,7 @@ export async function createWorkflowRunEventsBatchV4( // Batch identity attributes (size, per-type shape) live on the // world.events.createBatch span (see instrumentObject); this transport // span carries only wire-level facts. workflow.event.type is deliberately - // absent — it names a single event write, and tagging a batch with its + // absent, since it names a single event write, and tagging a batch with its // first event's type misclassifies the traffic. const response = await fetchV4( url, @@ -907,7 +907,7 @@ export async function createWorkflowRunEventsBatchV4( ? (decode(bodyBytes) as { results?: unknown[] }) : {}; // A 200 MUST carry exactly one outcome per submitted frame, in request - // order — callers index `results` positionally. A missing / non-array / + // order: callers index `results` positionally. A missing / non-array / // short `results` is a server protocol violation; silently coercing it // would masquerade as per-event failures and hide the server bug. The // batch POST is idempotent-on-retry (per-event entity conditions), so @@ -983,7 +983,7 @@ interface FrameResponseLike { * * Left unmapped, deliberately: `meta.deprecated`, which the server copies from * `X-API-Deprecated`. Inert while the v4 route's middleware chain has no - * deprecation middleware to set it — but this record is the only header source + * deprecation middleware to set it, but this record is the only header source * a WS reply has, so an unmapped key is gone rather than merely unread, which * is not true of the real `Response` the HTTP path returns. */ function replyMetaToHeaderRecord( @@ -1003,8 +1003,8 @@ function replyMetaToHeaderRecord( * `EVENT_RETRY_ELIGIBILITY` and replay the event types whose handlers have no * duplicate guard (a second `step_started` double-increments `attempt`), and * would multiply that policy's backoff. Note undici's `RetryAgent` - * (http-client.ts) retries POSTs on neither transport — `RetryHandler` - * defaults `methods` to GET/HEAD/OPTIONS/PUT/DELETE/TRACE — which is why + * (http-client.ts) retries POSTs on neither transport; `RetryHandler` + * defaults `methods` to GET/HEAD/OPTIONS/PUT/DELETE/TRACE, which is why * event-retry.ts exists at all. So `postEventFrameOverWs` makes exactly one * attempt and reports in that policy's vocabulary; a retry from it re-enters * `transport.request()`, which reconnects on the way through. @@ -1015,7 +1015,7 @@ function replyMetaToHeaderRecord( * defaulting to 200 would report success for any frame this client doesn't * understand, and the protocol is designed to grow new response variants (see * the server's docs/ws-protocol.md). `PARSE_ERROR` is the code `utils.ts` uses - * for an unreadable HTTP body — the same situation — and unlike a bare `Error` + * for an unreadable HTTP body (the same situation), and unlike a bare `Error` * it satisfies `WorkflowWorldError.is()` instead of surfacing as a USER_ERROR. */ function wsReplyStatus(reply: WsFrameReply, endpoint: string): number { @@ -1039,8 +1039,8 @@ function wsReplyStatus(reply: WsFrameReply, endpoint: string): number { * On HTTP every event write goes through `fetchV4` → `instrumentedFetch`, which * opens an `http POST` CLIENT span, times it, and stamps the response status on * it. A frame multiplexed onto a shared socket makes no `fetch` call and - * produces no `Response`, so that span simply disappeared when the transport - * flipped — and with it the per-event view of a run's writes, which is the + * produces no `Response`, so that span disappeared when the transport + * flipped, and with it the per-event view of a run's writes, which is the * thing a trace of a step execution is mostly made of. * * Nothing about the request/response *semantics* changed, though: one frame out, @@ -1061,14 +1061,14 @@ function wsReplyStatus(reply: WsFrameReply, endpoint: string): number { * * Two things the HTTP envelope has that this one deliberately does not: the * cache-bust header (a frame is memoized by nothing) and a per-frame - * `traceparent` (frames carry no headers — trace context rides the upgrade + * `traceparent` (frames carry no headers; trace context rides the upgrade * instead, so the server parents to the connection's span, not to this one). * * One gap this cannot close: Vercel's observability *outgoing requests* view is - * built by instrumenting the global `fetch`, not by reading OTEL spans, so WS - * event writes stay absent from it however faithful the span is. Traces get the - * writes back; that view needs a real request, which is the transport's whole - * point to avoid. + * built by instrumenting the global `fetch`, not by reading OpenTelemetry spans, + * so WS event writes stay absent from it however faithful the span is. Traces + * get the writes back; that view needs a real request, which is the transport's + * whole point to avoid. */ async function postEventFrameOverWs( input: CreateEventV4InputBase & { @@ -1077,14 +1077,14 @@ async function postEventFrameOverWs( }, config: APIConfig | undefined ): Promise { - // Dynamic so `ws` initializes only on a deployment that opted in — the gate + // Dynamic so `ws` initializes only on a deployment that opted in. The gate // at the call site lives in its own import-free module for exactly this // reason. The module is cached after the first write, and on the queue path // the pre-warm has already paid for it. const { resolveWsTransport } = await import('./ws-transport.js'); const { runId } = input; const resolved = resolveWsTransport(runId, config); - // No span: resolving nothing means no write was attempted here at all — the + // No span: resolving nothing means no write was attempted here at all. The // caller falls through to HTTP, which opens its own. if (!resolved) return undefined; const { transport, wsUrl } = resolved; @@ -1118,8 +1118,9 @@ async function postEventFrameOverWs( const start = Date.now(); let reply: WsFrameReply; try { - // `runId` isn't repeated here — it's already in `wsUrl`, one connection - // per run. The server's request-frame schema is a discriminated union on + // `runId` isn't repeated here, since it's already in `wsUrl`, one + // connection per run. The server's request-frame schema is a + // discriminated union on // `type` with each type's payload nested under its own name, so a future // request type is a new variant rather than a reshape of this one. reply = await transport.request((reqId) => { @@ -1136,8 +1137,9 @@ async function postEventFrameOverWs( } catch (err) { // Anything `transport.request()` throws means the frame was never acked. // `code: 'TRANSPORT'` is the shape `utils.ts` gives a failed `fetch`, so - // one classification drives both transports — in-process retry gated by - // event type, then queue redelivery. An unwrapped `WsTransportError` + // one classification drives both transports, with in-process retry + // gated by event type, then queue redelivery. An unwrapped + // `WsTransportError` // would fail `WorkflowWorldError.is()` and classify as a USER_ERROR. // Application errors are raised below, outside this try. const error = new WorkflowWorldError( @@ -1196,7 +1198,7 @@ export type HookReceivedPreloadV4Result = kind: 'stream'; /** * The canonical event this write created or converged on (the resume - * claim winner's — ours or the producer's), named by the + * claim winner's (ours or the producer's), named by the * event-id response header. Undefined when the server did not send it. */ canonicalEventId: string | undefined; @@ -1204,7 +1206,7 @@ export type HookReceivedPreloadV4Result = maxEvents: number | undefined; }) /** - * The server answered with the normal materialized CBOR body instead — + * The server answered with the normal materialized CBOR body instead: * an older server, or one that declined the optimization. The * hook_received write itself has still succeeded; callers must not * re-post it. @@ -1219,7 +1221,7 @@ export type HookReceivedPreloadV4Result = * consuming either response mode. * * A server that supports the lazy-hook replay stream answers the consumer's - * idempotent re-ensure with the run's complete replay log as v4 frames — + * idempotent re-ensure with the run's complete replay log as v4 frames: * the same event-frame sequence LIST uses, ending with the `_end` sentinel. * A truncated stream (EOF without the sentinel) throws; the write is * deduplicated by the server's `(runId, resumeId)` constraint, so retrying @@ -1298,7 +1300,7 @@ export async function getEventV4( } // fetch's `Response.body` is a web ReadableStream, which is async-iterable - // on Node (readableStream async iteration, since v16.5.0) — feed it straight + // on Node (readableStream async iteration, since v16.5.0), so feed it straight // to decodeFrames. The cast is only because TS's lib `ReadableStream` type // omits the async iterator. Do NOT round-trip through `node:stream` // Readable.toWeb: a dynamic `import('node:stream')` resolves to an empty @@ -1317,9 +1319,8 @@ export interface ListEventsV4Params extends PaginationOptions { /** * Whether the backend resolves payload bytes into each frame body. * `resolve` (default) streams the bytes; `lazy` emits empty-body frames - * (the ref descriptor stays in the frame meta) — for metadata-only - * listings that would otherwise download every payload just to discard - * it. + * (the ref descriptor stays in the frame meta), for metadata-only + * listings that would otherwise download and discard every payload. */ remoteRefBehavior?: 'resolve' | 'lazy'; } @@ -1365,7 +1366,7 @@ async function consumeEventFrameStream( /** * Drive a v4 frame-stream list response into an in-memory page. Used by - * both the by-runId and by-correlationId list endpoints — the wire + * both the by-runId and by-correlationId list endpoints. The wire * shape is identical, only the URL differs. * * `headers` come from the caller's single getHttpConfig resolution (the @@ -1461,7 +1462,7 @@ export async function getWorkflowRunEventsV4( * * Same frame stream as getWorkflowRunEventsV4 but selected by correlation id * instead of run id alone. Used by the storage adapter's - * `events.listByCorrelationId` path — the v3 client used + * `events.listByCorrelationId` path. The v3 client used * `/v2/events?correlationId=...` for the equivalent query. * * `runId` scopes the lookup. A correlation id names a step, hook or wait diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index 9706a59af9..e450a57f4f 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -1,5 +1,5 @@ /** - * world-vercel event functions — v4 wire format throughout. + * world-vercel event functions, v4 wire format throughout. * * This module replaces the previous v2/v3 implementation. The v4 wire * format uses a single length-prefixed binary frame layout in both @@ -157,7 +157,7 @@ interface SplitEventData { /** Client-measured run_started-to-first-step ms (step_completed / step_failed). */ rsfs?: number; /** Client-measured synchronous replay-compute ms of only the FINAL replay - * pass within the rsfs window — not accumulated across earlier + * pass within the rsfs window, not accumulated across earlier * pre-first-step passes, so it is not "the replay portion of rsfs". */ finalSchedulingReplay?: number; /** Runtime optimizations active for the ttfs/stso measurement. */ @@ -213,7 +213,7 @@ type MetaSourceField = * Both must be `never`. Add a field to a @workflow/world event schema * without routing it here and the `assertEventDataWireContractExhaustive` * call fails to compile with `Type '["theField", never]' does not satisfy - * the constraint '[never, never]'` — the historical "silently dropped" + * the constraint '[never, never]'`: the historical "silently dropped" * footgun, now a build break that names the field. */ type Unhandled = Exclude< @@ -234,7 +234,7 @@ assertEventDataWireContractExhaustive<[Unhandled, Stale]>(); * CBOR-encoded meta block of the same frame. * * Exported for unit tests (the meta allowlist is the eventData wire - * contract — see the warning on EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE in + * contract; see the warning on EVENT_DATA_PAYLOAD_FIELD_BY_EVENT_TYPE in * @workflow/world). */ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { @@ -271,7 +271,7 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { } // step_retrying carries the RetryableError backoff timestamp. The queue // enforces the actual retry delay, but the server persists this on the - // step entity (premature-delivery pacing + observability) — dropping it + // step entity (premature-delivery pacing + observability); dropping it // here would silently disable both. if (eventData.retryAfter instanceof Date) { meta.retryAfter = eventData.retryAfter; @@ -308,7 +308,7 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { // step_started's inline-ownership stamp: the queue message ID of the // invocation running this step's body inline. The backend persists it on // the step_started event row and re-emits it on event lists so wake - // replays can observe the active owner — dropping it here would silently + // replays can observe the active owner; dropping it here would silently // disable ownership (replays would requeue in-flight inline steps again). if (typeof eventData.ownerMessageId === 'string') { meta.ownerMessageId = eventData.ownerMessageId; @@ -326,7 +326,7 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { // Native run attributes (spec v4): initial attributes ride on // run_created (and run_started for resilient start); attr_set carries // the change list + writer provenance. All of these are structured - // metadata, not user payloads — they ride in the frame meta and the + // metadata, not user payloads: they ride in the frame meta and the // server validates them against the attribute caps before // materializing run.attributes. if ( @@ -392,7 +392,7 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { const value = eventData[payloadField]; if (value !== undefined) { // Payload fields (input / output / result / error / payload / - // metadata) reach this layer already serialized as Uint8Array — the + // metadata) reach this layer already serialized as Uint8Array: the // runtime calls dehydrateRunError / dehydrateStepReturnValue / etc. // before invoking events.create. Pass the bytes through unchanged // so runs.get and the events stream return the same raw form that @@ -401,7 +401,7 @@ export function splitEventDataForV4(data: AnyEventRequest): SplitEventData { // decode) leave the consumer with cbor(Uint8Array) rather than the // devalue blob it was looking for. if (!(value instanceof Uint8Array)) { - // Surface non-Uint8Array values loudly — current SDK callers go + // Surface non-Uint8Array values loudly: current SDK callers go // through the dehydrate helpers, so anything else is either a // legacy caller or a bug. throw new TypeError( @@ -457,7 +457,7 @@ export async function getWorkflowRunEvents( ) : getWorkflowRunEventsV4(params.runId, listParams, config)); - // A correlation id is unique per run, not globally — a slot-numbered run + // A correlation id is unique per run, not globally: a slot-numbered run // numbers its own steps, so `step_…001` names the first step of every such // run. The run id scopes the backend query; the filter also protects against // an older backend that ignores that parameter. @@ -477,7 +477,7 @@ export async function getWorkflowRunEvents( /** * Batch write: append an ordered list of events to the run's log in one - * request with per-event outcomes — the world-vercel implementation of + * request with per-event outcomes: the world-vercel implementation of * `Storage['events']['createBatch']`. * * The whole POST retries transient transport failures and 429s like a single @@ -499,7 +499,7 @@ export async function createWorkflowRunEventBatch( } // Advisory `hook_received` has no entity condition: the server appends a // fresh row on every attempt, so a retried batch would deliver the hook - // payload twice — and the atomic lazy-resume shape (the one dedupable + // payload twice, and the atomic lazy-resume shape (the one dedupable // form) is rejected by the batch route anyway. Nothing batches hook // deliveries today; reject them here so the retry contract below stays // honest instead of silently double-appending. @@ -521,10 +521,10 @@ export async function createWorkflowRunEventBatch( // Under slot identity this is the source of the durable createdAt, so // the caller's logical time is what every replay observes. occurredAt: occurredAt ?? new Date(), - // Per-event compute attribution (pre-claimed inline starts) — rides the + // Per-event compute attribution (pre-claimed inline starts); rides the // frame meta exactly like the single POST's CreateEventParams field. ...(computeInstanceId !== undefined ? { computeInstanceId } : {}), - // Batch responses carry entities for bookkeeping, not payload reads — + // Batch responses carry entities for bookkeeping, not payload reads, so // default to lazy refs unless the caller explicitly asks for resolved // data (the same `resolveData` mapping the read paths use). remoteRefBehavior: @@ -532,7 +532,7 @@ export async function createWorkflowRunEventBatch( ? ('resolve' as const) : ('lazy' as const), // Per-write request attribution, exactly like the single POST's - // `params.requestId` → `vercelId` threading — stamped per frame so + // `params.requestId` → `vercelId` threading, stamped per frame so // batched usage facts carry the same attribution. ...(params?.requestId ? { vercelId: params.requestId } : {}), payload, @@ -544,7 +544,7 @@ export async function createWorkflowRunEventBatch( // converges on a retry of a committed attempt AND the caller can act on the // converged answer. Entity-conditioned events (creates, terminal // transitions) re-reject with 409, which their callers already treat as - // "someone got here first" — no information lost. + // "someone got here first", so no information lost. // // A `step_started` is excluded on both counts. A standalone bare start // re-patches a running step (attempt++) and `step_retrying` re-patches a @@ -611,7 +611,7 @@ export async function createWorkflowRunEvent( { // The atomic lazy-resume shape is deduplicated server-side by the // (runId, resumeId) claim, so its POST is idempotent-on-retry even - // though plain hook_received is not — see EVENT_RETRY_ELIGIBILITY. + // though plain hook_received is not; see EVENT_RETRY_ELIGIBILITY. idempotentHookResume: data.eventType === 'hook_received' && params?.resumeId !== undefined && @@ -659,7 +659,7 @@ async function createWorkflowRunEventInner( ): Promise { // v1Compat: caller wants the legacy entity-mutation endpoints (used // for legacy spec-version runs that predate event sourcing). Keep all - // of this on v1 routes — the v4 protocol does not cover legacy runs. + // of this on v1 routes, since the v4 protocol does not cover legacy runs. if (params?.v1Compat) { if (data.eventType === 'run_cancelled' && id) { const run = await cancelWorkflowRunV1(id, params, config); @@ -699,7 +699,7 @@ async function createWorkflowRunEventInner( } // Defensive check for client-generated run_created IDs that ride too - // far ahead of wall-clock time — same threshold the v3 path enforced. + // far ahead of wall-clock time, same threshold the v3 path enforced. if (data.eventType === 'run_created') { const validationError = validateWorkflowRunIdTimestamp(id); if (validationError) { @@ -734,15 +734,15 @@ async function createWorkflowRunEventInner( // delta on the response (events/cursor/hasMore), letting the caller // skip a follow-up events.list. Outside turbo the runtime sends this on // every write, but a server may act on only some event types (or none); - // a response without a delta just means the runtime keeps its cursor + // a response without a delta means the runtime keeps its cursor // and fetches when it next needs to. ...(params?.sinceCursor ? { sinceCursor: params.sinceCursor } : {}), ...(params?.resumeId ? { resumeId: params.resumeId } : {}), ...(params?.resumePayloadDigest ? { resumePayloadDigest: params.resumePayloadDigest } : {}), - // Resilient step dispatch re-ensure marker (step_created only). Advisory - // — the server MAY refuse it with 410 → RunExpiredError as + // Resilient step dispatch re-ensure marker (step_created only). + // Advisory: the server MAY refuse it with 410 → RunExpiredError as // defense-in-depth when it recorded a 412 rejection for this correlation // id and no step entity exists. ...(params?.viaStepDispatch ? { viaStepDispatch: true } : {}), @@ -810,8 +810,8 @@ async function createWorkflowRunEventInner( ) { // Lazy hook resume: the queue consumer's idempotent re-ensure doubles // as the invocation's setup request. A supporting server streams the - // complete replay log back in this response with resolved frame bodies - // — the SERVER owns that resolution (the preload contract requires + // complete replay log back in this response with resolved frame bodies; + // the SERVER owns that resolution (the preload contract requires // replay-ready bytes; v4 has no /refs endpoint to hydrate a lazy // descriptor during replay), so the request keeps hook_received's lazy // default. Against an older server this makes the CBOR fallback @@ -858,7 +858,7 @@ async function createWorkflowRunEventInner( * from `run_created`, start time from `run_started`, later `attr_set` events * folded into `attributes`/`updatedAt`. Returns undefined when the log does * not contain both lifecycle events (the caller decides whether that is - * fatal). The reconstructed status is always `running` — a terminal event + * fatal). The reconstructed status is always `running`: a terminal event * committed concurrently still rides in the log itself, and the runtime's * replay-time terminal detection handles it. */ diff --git a/packages/world-vercel/src/frames.ts b/packages/world-vercel/src/frames.ts index f5de347979..93809ab179 100644 --- a/packages/world-vercel/src/frames.ts +++ b/packages/world-vercel/src/frames.ts @@ -41,7 +41,7 @@ export function encodeFrame( /** * Async-iterable parser for a frame stream. Yields one `DecodedFrame` * per frame in source order, terminating at the sentinel frame whose - * meta contains `_end: 1`. The sentinel frame itself IS yielded — the + * meta contains `_end: 1`. The sentinel frame itself IS yielded: the * caller inspects `meta._end` to detect end-of-stream and reads * `meta.next` for the pagination cursor. * @@ -121,7 +121,7 @@ export async function* decodeFrames( if (bodyLen > 0 && !(await refill(bodyLen))) { throw new Error('decodeFrames: truncated body bytes'); } - // Slice (not subarray) so the yielded body owns its bytes — later + // Slice (not subarray) so the yielded body owns its bytes, so later // reads into the buffer won't overwrite it; bodyLen 0 yields empty. yield { meta, body: buffer.slice(0, bodyLen) }; take(bodyLen); @@ -137,7 +137,7 @@ export async function* decodeFrames( // can hit the network (an H2 stream reset, or an H1 socket teardown), // and awaiting it here would block every early-exit caller (getEventV4, // every replay's list read) on that round-trip. Awaiting it previously - // hung indefinitely on the Next.js Vercel Function lanes specifically — + // hung indefinitely on the Next.js Vercel Function lanes specifically, // same class of bug as the abort-stream reader in #2807. closeQuietly(() => chunks.return?.()); } @@ -166,7 +166,7 @@ async function* readerToIterator( } } finally { // Cancel on early exit so the socket is released, not just unlocked. - // Fire-and-forget — see closeQuietly's call site above. + // Fire-and-forget; see closeQuietly's call site above. closeQuietly(() => reader.cancel()); } } diff --git a/packages/world-vercel/src/http-client.ts b/packages/world-vercel/src/http-client.ts index 1e6dcbc2c5..2b4799f8d6 100644 --- a/packages/world-vercel/src/http-client.ts +++ b/packages/world-vercel/src/http-client.ts @@ -13,7 +13,7 @@ let _streamCloseDispatcher: RetryAgent | undefined; let _nodeHttpAgents: NodeHttpAgents | undefined; /** - * Shared between all agents — connection pooling only. `pipelining` is + * Shared between all agents: connection pooling only. `pipelining` is * deliberately NOT set here: undici overloads that single option to mean both * "H1 pipelining depth" and "max in-flight H2 streams per connection", and the * two paths want opposite values. Each agent sets it explicitly below. @@ -30,7 +30,7 @@ const BASE_AGENT_OPTIONS = { * The undici agents configured in this module never set these explicitly, so * they inherit undici's 300s defaults, which bound a dead-but-not-reset socket. * `nodeHttpFetch` arms neither timer unless a value is passed, so the same - * values must be supplied at every node:http call site — otherwise a request + * values must be supplied at every node:http call site; otherwise a request * that opts out of the whole-request deadline (`timeoutMs: null`) would have no * per-phase deadline at all and could hang until the function itself times out. */ @@ -40,13 +40,13 @@ export const NODE_HTTP_BODY_TIMEOUT_MS = 300_000; /** * In-flight H2 streams allowed per connection. Matches undici's * `maxConcurrentStreams` default (100), which is the real ceiling once the - * server's SETTINGS_MAX_CONCURRENT_STREAMS is known — so this only has to be + * server's SETTINGS_MAX_CONCURRENT_STREAMS is known, so this only has to be * large enough not to be the binding constraint. The Vercel edge advertises * SETTINGS_MAX_CONCURRENT_STREAMS 160, so it isn't. * * Note that undici's `maxConcurrentStreams` *option* is not this gate: it only * seeds `peerMaxConcurrentStreams` at connect time and is then overwritten by - * the server's SETTINGS. Raising it is inert — measured at ±1.6% (inside a 6.1% + * the server's SETTINGS. Raising it is inert: measured at ±1.6% (inside a 6.1% * noise floor) across every workload shape. `pipelining` is the only knob that * actually gates in-flight streams; see EVENTS_AGENT_OPTIONS. */ @@ -67,7 +67,7 @@ const H2_MAX_IN_FLIGHT_STREAMS = 100; * - a single read (one in-flight stream) is capped by the *stream* window; * raising only the connection window changes nothing. * - concurrent reads share the *connection* window; raising only the stream - * window just relocates the stall to the connection level, and measured + * window relocates the stall to the connection level, and measured * slightly *worse* than leaving both alone. * Measured against a loopback H2 origin mirroring the edge's SETTINGS at 10 ms * RTT, these cut read wall time by 76–86% versus undici's defaults across 1, 4 @@ -79,7 +79,7 @@ const H2_MAX_IN_FLIGHT_STREAMS = 100; * pages are read sequentially and page size is server-driven with no byte cap, so * a page carrying large step payloads can be multiple MiB. The connection window * has to be several times the stream window or concurrent reads stall on it - * instead — at 32 concurrent reads 8 MiB is no better than 1 MiB/8 MiB while + * instead: at 32 concurrent reads 8 MiB is no better than 1 MiB/8 MiB while * 16 MiB is 25% faster, and 32 MiB adds nothing further. * * Cost: a receive window is a ceiling on how many unacked bytes the origin may @@ -89,21 +89,21 @@ const H2_MAX_IN_FLIGHT_STREAMS = 100; * above the smaller settings. It is a ceiling rather than an allocation, and only * fills when the origin outruns the consumer; the events readers decode a whole * response body anyway, so those bytes reach app memory either way. The write - * path is unaffected — receive windows don't govern uploads, and with four + * path is unaffected, since receive windows don't govern uploads, and with four * interleaved controls every setting lands within a 1.3% noise floor. */ const H2_STREAM_WINDOW_BYTES = 4 * 1024 * 1024; const H2_CONNECTION_WINDOW_BYTES = 16 * 1024 * 1024; /** - * Options for the default undici Agent — the queue client (webhook + * Options for the default undici Agent: the queue client (webhook * respondWith), v3 `makeRequest`, deployment resolution, and run-key fetch. * Exported so tests can assert the transport configuration. * * HTTP/2 is intentionally OFF here: it deadlocks the webhook respondWith * mechanism and hangs duplex streaming in Vercel Functions (observed as 120s * E2E timeouts on the webhook/hook workflows). Only the events API, which - * doesn't use those mechanisms, opts into H2 — see EVENTS_AGENT_OPTIONS. + * doesn't use those mechanisms, opts into H2; see EVENTS_AGENT_OPTIONS. */ export const DEFAULT_AGENT_OPTIONS = { ...BASE_AGENT_OPTIONS, @@ -118,8 +118,8 @@ export const DEFAULT_AGENT_OPTIONS = { * HTTP/2 stays enabled *and* that it is actually configured to multiplex. * * The v4 events endpoints are the hottest path (an event write per step - * transition, plus event-log reads on replay) and are plain request/response — - * or, for LIST, a streamed *response* — none of which trip the webhook / + * transition, plus event-log reads on replay) and are plain request/response + * (or, for LIST, a streamed *response*), none of which trip the webhook / * duplex-streaming H2 issues that keep the default agent on H1. Multiplexing * removes per-request connection setup and head-of-line blocking here. * Re-enabling H2 more broadly is gated on resolving those issues (notably the @@ -130,7 +130,7 @@ export const DEFAULT_AGENT_OPTIONS = { * `client[kPipelining] ?? httpContext.defaultPipelining ?? 1`. `client-h2.js` * sets `defaultPipelining: Infinity`, but the Client constructor coerces * `pipelining` to a number (`pipelining != null ? pipelining : 1`), so - * `kPipelining` is never nullish and H2's Infinity is unreachable — leaving one + * `kPipelining` is never nullish and H2's Infinity is unreachable, leaving one * in-flight stream per connection. Before this was set, the H2 agent behaved * byte-for-byte like the H1 agent: 16 concurrent requests produced 8 in-flight * requests over 8 TCP connections. See nodejs/undici#4143. @@ -141,7 +141,7 @@ export const DEFAULT_AGENT_OPTIONS = { * multiplexing improves unless the window grows with it: at 32 concurrent reads, * `pipelining: 1` measured 70% faster than `pipelining: 100` on downloads purely * because spreading streams over 8 connections gave them 8 separate windows. - * Raising the windows removes that trade-off — the multiplexed agent then beats + * Raising the windows removes that trade-off: the multiplexed agent then beats * both. */ export const EVENTS_AGENT_OPTIONS = { @@ -153,7 +153,7 @@ export const EVENTS_AGENT_OPTIONS = { } as const; /** - * Events-agent options with HTTP/2 off entirely — what `WORKFLOW_H2_MULTIPLEX=0` + * Events-agent options with HTTP/2 off entirely, which `WORKFLOW_H2_MULTIPLEX=0` * selects. Identical to the H1 default agent, so the kill switch puts the events * path back on the transport it used before H2 was enabled there. * @@ -178,12 +178,12 @@ export const EVENTS_AGENT_OPTIONS_NO_H2 = { * Options for the stream write/close Agents. H2 is enabled (these send a * fully-buffered body, or none, so they avoid the duplex-streaming issues that * keep the long-lived live-read on plain `fetch`), but multiplexing is - * deliberately left OFF — `pipelining: 1`, one in-flight request per + * deliberately left OFF: `pipelining: 1`, one in-flight request per * connection. * * Stream appends are not idempotent. Multiplexing N appends onto one connection * makes a single RST_STREAM / GOAWAY / socket reset fail all N at once, and - * STREAM_RETRY_OPTIONS retries PUT on exactly those transient `errorCodes` — so + * STREAM_RETRY_OPTIONS retries PUT on exactly those transient `errorCodes`, so * a connection-level blip would resend chunks the server may already have * applied and duplicate them. Serializing keeps the existing * one-request-per-connection failure isolation that policy was written against. @@ -205,11 +205,11 @@ const RETRY_AGENT_OPTIONS: RetryHandler.RetryOptions = { retryAfter: true, // Retry 5xx in-process (genuine transient blips recover fast), but NOT 429. // The Vercel firewall issues a challenge as a 429: our server-to-server - // client cannot solve a challenge, so in-process retries just re-trigger it + // client cannot solve a challenge, so in-process retries re-trigger it // ~5× per request and amplify load against an already-overloaded firewall // during an incident. Letting 429 pass through surfaces it immediately to - // makeRequest — which maps it to a ThrottleError carrying the - // `x-vercel-mitigated` / `x-vercel-id` headers — and the queue does the + // makeRequest, which maps it to a ThrottleError carrying the + // `x-vercel-mitigated` / `x-vercel-id` headers, and the queue does the // (backed-off) retry instead. This is the long-standing "let 429s pass // through" intent. (undici default is [500, 502, 503, 504, 429].) statusCodes: [500, 502, 503, 504], @@ -221,9 +221,9 @@ const RETRY_AGENT_OPTIONS: RetryHandler.RetryOptions = { * narrow undici's defaults to only the conditions that guarantee the request was * rejected *before* the chunk was persisted: * - transient connection errors (undici's default `errorCodes`: ECONNRESET, - * ECONNREFUSED, ENOTFOUND, …) — the request never reached, or was not + * ECONNREFUSED, ENOTFOUND, …): the request never reached, or was not * accepted by, the server, and - * - HTTP 429 — the server rejected the request outright (rate limited), so no + * - HTTP 429: the server rejected the request outright (rate limited), so no * chunk was written; honoring Retry-After backs off cleanly. * * Crucially, 5xx is excluded from the default `[500, 502, 503, 504, 429]`: a @@ -244,7 +244,7 @@ export const STREAM_RETRY_OPTIONS: RetryHandler.RetryOptions = { * appends, close is idempotent on the server: a duplicate close of a * completed stream early-returns, and the close-barrier protocol's durable * `closing` fence is an if_not_exists stamp that a re-entered close resumes - * — so a 5xx whose effect may or may not have applied is safe to retry, + * so a 5xx whose effect may or may not have applied is safe to retry, * and the server's close barrier *relies* on it: a transient reconciliation * failure (or an unsafe close shape awaiting in-flight backups) is surfaced * as a retriable 503 with the stream left durably closing, expecting the @@ -294,15 +294,15 @@ function contentLength(headers: unknown): number { * Undici interceptor that lets the events API actually multiplex over H2. * * `pipelining` (see EVENTS_AGENT_OPTIONS) is necessary but not sufficient: - * undici's H2 `busy()` check reports the connection busy — serializing the - * request behind whatever is in flight — for two more reasons, both of which + * undici's H2 `busy()` check reports the connection busy, serializing the + * request behind whatever is in flight, for two more reasons, both of which * every events request trips. * * 1. Non-idempotent method. `client-h2.js` returns busy when * `request.idempotent === false`, and undici only treats GET/HEAD as * idempotent by default (`core/request.js`), so every event-write POST * serializes. We mark them idempotent for *concurrency* purposes only: in - * undici 7 the flag feeds nothing but the H1/H2 `busy()` gates — it does not + * undici 7 the flag feeds nothing but the H1/H2 `busy()` gates. It does not * cause resends. Retries are governed solely by RetryAgent, whose * `methods` default (`['GET','HEAD','OPTIONS','PUT','DELETE','TRACE']`) * excludes POST, so an event write is still never replayed. See @@ -312,8 +312,9 @@ function contentLength(headers: unknown): number { * a stream / async iterable, because such a body can error mid-flight and * take unrelated in-flight requests down with it. events-v4 hands us a fully * materialized `Uint8Array`, but it dispatches through the global `fetch` - * (deliberately — that is what keeps v4 traffic visible in Vercel's outgoing - * -requests view, see events-v4.ts), and `fetch` converts every body into an + * (deliberately, since that is what keeps v4 traffic visible in Vercel's + * outgoing-requests view, see events-v4.ts), and `fetch` converts every body + * into an * async iterable on the way down. Draining it back into a Buffer restores the * buffered-body shape undici needs, at the cost of one copy of an * already-in-memory payload. Bodies without a usable `content-length`, or @@ -375,7 +376,7 @@ export function h2MultiplexInterceptor( * case this exists for is the one where it doesn't: an H2 stream timeout leaves * the session in place by design, so if the flow underneath it has been * black-holed while TCP stays established, every later request routed onto that - * session times out too — indefinitely. Requiring several failures in a row means + * session times out too, indefinitely. Requiring several failures in a row means * the self-healing paths never trigger a rebuild, and the wedged-session path * always does. */ @@ -391,7 +392,7 @@ const RECYCLE_MIN_INTERVAL_MS = 5_000; * How long a retired dispatcher is left dispatchable before it is closed. * * A request that resolved the dispatcher just before the swap has not dispatched - * yet, and `close()` would reject it with ClientClosedError — turning a healthy + * yet, and `close()` would reject it with ClientClosedError, turning a healthy * event write into a step retry. The delay makes that race impossible in * practice; the retired agent serves those few requests and then closes. */ @@ -445,7 +446,7 @@ export interface DispatcherRecycler { /** * Record the transport-level outcome of one request: `error` when the * `fetch()`/dispatch itself failed, nothing when a response arrived (whatever - * its status — an HTTP error is the origin answering, so the transport worked). + * its status; an HTTP error is the origin answering, so the transport worked). * * `dispatcher` is the dispatcher the request actually used. Outcomes from any * other dispatcher are ignored, which covers both a caller-supplied override @@ -542,7 +543,7 @@ export function noteEventsTransportOutcome( /** * Resolution order shared by every `get*Dispatcher` below: * - * 1. `config.dispatcher` — an explicit caller override always wins, including + * 1. `config.dispatcher`: an explicit caller override always wins, including * under `WORKFLOW_NODE_HTTP`. Supplying a dispatcher is an instruction to * use undici, so the request stays on `fetch` with that dispatcher. * 2. `undefined` when `WORKFLOW_NODE_HTTP` is on. Nothing then dispatches @@ -611,7 +612,7 @@ export function getDispatcher(config?: APIConfig): unknown { * `WORKFLOW_NODE_HTTP`. The `QueueClient` exposes no `fetch` override, so it * cannot be moved onto `node:http` the way `instrumentedFetch` / `makeRequest` * are: the flag has nothing to hand the request off to on this path. Returning - * `undefined` there would therefore not switch transports — it would just drop + * `undefined` there would therefore not switch transports. It would drop * the tuned shared agent (`DEFAULT_AGENT_OPTIONS`: 8 connections, ~10s * keep-alive) and let undici fall back to its GLOBAL agent (unlimited * connections, 4s keep-alive), an unintended regression from a flag this path @@ -646,8 +647,8 @@ export function getEventsDispatcher(config?: APIConfig): unknown { * Resolves the dispatcher for stream writes (the PUT write/close path): the * caller's override, or the shared HTTP/2 stream agent. See * getDefaultStreamDispatcher (and STREAM_RETRY_OPTIONS) for its deliberately - * narrowed retry policy — transient connection errors + HTTP 429 only, never - * 5xx — chosen because stream appends are not idempotent. + * narrowed retry policy (transient connection errors + HTTP 429 only, never + * 5xx), chosen because stream appends are not idempotent. */ export function getStreamDispatcher(config?: APIConfig): unknown { return resolveDispatcher(config, getDefaultStreamDispatcher); @@ -655,7 +656,7 @@ export function getStreamDispatcher(config?: APIConfig): unknown { /** * Resolves the dispatcher for stream CLOSE: the caller's override, or the - * shared close agent whose retry policy includes 5xx — close is idempotent + * shared close agent whose retry policy includes 5xx, since close is idempotent * (see STREAM_CLOSE_RETRY_OPTIONS), unlike chunk appends. */ export function getStreamCloseDispatcher(config?: APIConfig): unknown { @@ -674,7 +675,7 @@ function makeRetryDispatcher( * Builds the events-API dispatcher: the H2 agent plus the interceptor that makes * H2 actually multiplex. Exported (rather than only reachable through the * `getEventsDispatcher` singleton) so a test can exercise this exact wiring - * against a loopback server via `agentOverrides` — asserting on + * against a loopback server via `agentOverrides`. Asserting on * EVENTS_AGENT_OPTIONS alone cannot catch the composition being dropped. */ export function createEventsDispatcher( @@ -693,7 +694,7 @@ export function createEventsDispatcher( } // The interceptor wraps the RetryAgent (rather than the Agent inside it) so // that retries re-send the *drained* body. RetryHandler captures its own copy - // of the request body up front — `wrapRequestBody` (undici core/util.js) hands + // of the request body up front: `wrapRequestBody` (undici core/util.js) hands // an async-iterable body to a fresh `BodyAsyncIterable`. Composed inside, that // copy would wrap the stream the interceptor is about to consume, so a retry // would re-iterate an exhausted stream and send an empty body. Composed @@ -728,7 +729,7 @@ function withBoundLifecycle( /** * Builds a stream write/close dispatcher. Exported for the same reason as - * `createEventsDispatcher` — so a test can assert the inverse property, that + * `createEventsDispatcher`, so a test can assert the inverse property, that * these deliberately do NOT multiplex. */ export function createStreamDispatcher( @@ -747,7 +748,7 @@ export function createStreamDispatcher( * - HTTP/1.1 (see DEFAULT_AGENT_OPTIONS) * - Connection pooling (up to 8 connections per origin) * - Retry: Automatic retry on 5xx or network errors with exponential backoff - * (idempotent methods only — undici's default never retries POST), observing + * (idempotent methods only; undici's default never retries POST), observing * the `Retry-After` header when present. */ function getDefaultDispatcher(): RetryAgent { @@ -763,12 +764,12 @@ function getDefaultDispatcher(): RetryAgent { * * Stream writes append chunks and are NOT idempotent, so this dispatcher uses a * deliberately narrowed retry policy (see STREAM_RETRY_OPTIONS): it retries only - * on transient connection errors and HTTP 429 — both of which guarantee the - * chunk was not persisted — and never on 5xx or other 4xx, where a retry could + * on transient connection errors and HTTP 429 (both of which guarantee the + * chunk was not persisted) and never on 5xx or other 4xx, where a retry could * duplicate an already-applied write. It opts into H2 (the write/close requests * send a fully-buffered body, or none, so they don't hit the duplex-streaming H2 * issues that keep the long-lived live-read on plain `fetch`) via - * STREAM_AGENT_OPTIONS — which, unlike the events agent, keeps multiplexing off + * STREAM_AGENT_OPTIONS, which, unlike the events agent, keeps multiplexing off * so one connection-level failure cannot fail (and thus retry) several appends * at once. */ diff --git a/packages/world-vercel/src/http-core.ts b/packages/world-vercel/src/http-core.ts index c05cfb0238..fae66713ac 100644 --- a/packages/world-vercel/src/http-core.ts +++ b/packages/world-vercel/src/http-core.ts @@ -2,7 +2,7 @@ * Shared HTTP request core for the world-vercel adapter. * * Every outgoing request from world-vercel goes through one of a few - * higher-level clients — the v3 `makeRequest`, the v4 events client, the + * higher-level clients: the v3 `makeRequest`, the v4 events client, the * streamer, and the direct Vercel-API calls (run-key / resolve-deployment). * They differ in how they shape the *body* (CBOR + schema, binary frames, raw * chunks, JSON), but they share the same cross-cutting envelope: an OTEL client @@ -55,14 +55,14 @@ import { * Per-request timeout for HTTP calls to workflow-server (in ms). * * Without this, a hung workflow-server response would keep the caller blocked - * until the platform's `maxDuration` SIGTERM — burning compute and defeating + * until the platform's `maxDuration` SIGTERM, burning compute and defeating * upstream timeout handlers (e.g. the replay timeout). */ export const REQUEST_TIMEOUT_MS = 60_000; /** * Effective per-request timeout. Override via `WORKFLOW_REQUEST_TIMEOUT_MS` - * (e.g. dialled down on an e2e deployment to exercise the timeout path). + * (e.g. dialed down on an e2e deployment to exercise the timeout path). * * Clamped to `[10s, 120s]`, with a warning when a configured value is pulled * into range: @@ -97,7 +97,7 @@ export const getRequestTimeoutMs = (): number => * env var contains "workflow:" or is "*". * * Note: this does not implement full `debug` module semantics (e.g. - * comma-separated globs, negation with `-`). It is a simple check sufficient + * comma-separated globs, negation with `-`). This limited check is sufficient * for enabling HTTP-level debug output. */ export const HTTP_DEBUG_ENABLED = @@ -107,7 +107,7 @@ export const HTTP_DEBUG_ENABLED = /** Diagnostic response headers worth surfacing in logs and error messages. * `x-vercel-mitigated` (`challenge` | `deny`) is set by the Vercel firewall - * when it intercepts a request in front of the backend — surfacing it makes a + * when it intercepts a request in front of the backend; surfacing it makes a * firewall block diagnosable from the error message and DEBUG logs. */ const DIAGNOSTIC_HEADERS = [ 'x-vercel-id', @@ -118,7 +118,7 @@ const DIAGNOSTIC_HEADERS = [ /** * The one member the diagnostic/log helpers read headers through. `Headers` * satisfies it, and so does the header record a WS reply frame's meta is - * flattened into — which has no `Headers` to offer. + * flattened into, which has no `Headers` to offer. */ export interface HeaderLookup { get(name: string): string | null; @@ -218,9 +218,9 @@ export function headersToRecord(headers: Headers): Record { * - 410 → StreamExpiredError when the response code is `stream-expired`, * otherwise RunExpiredError (both terminal) * - 412 → PreconditionFailedError + retryAfter + details (stale precondition - * snapshot — the optimistic-concurrency guard on event creation; `details` + * snapshot, the optimistic-concurrency guard on event creation; `details` * carries the events the backend returned inline, when it did) - * - 425 → TooEarlyError + retryAfter (step retry pacing — see #1806 for what + * - 425 → TooEarlyError + retryAfter (step retry pacing; see #1806 for what * happens when a 425 degrades into an untyped error) * - 429 → ThrottleError + retryAfter, EXCEPT a firewall challenge (429 + * `x-vercel-mitigated: challenge`) → retryable transport WorkflowWorldError @@ -279,7 +279,7 @@ export function errorForResponse( if (status === 425) return new TooEarlyError(message, { retryAfter }); if (status === 429) { // A firewall challenge can't be solved by a server-to-server client, so map - // it to the retryable transport path instead of ThrottleError — see + // it to the retryable transport path instead of ThrottleError; see // isFirewallChallenge429. A genuine application 429 stays a ThrottleError. if (isFirewallChallenge429(status, mitigated)) { return new WorkflowWorldError( @@ -305,10 +305,10 @@ export function errorForResponse( * * Such a 429 must NOT surface as a `ThrottleError`: on the `step_started` write * the runtime defers a `ThrottleError` by self-enqueuing a FRESH queue message, - * which resets the delivery count — so it never backs off past `retryAfter` and + * which resets the delivery count, so it never backs off past `retryAfter` and * never reaches `MAX_QUEUE_DELIVERIES`, hot-looping against an already-overloaded * firewall. Mapping it to a retryable transport `WorkflowWorldError` (`code: - * 'TRANSPORT'`) instead lets the runtime rethrow it to the queue handler — + * 'TRANSPORT'`) instead lets the runtime rethrow it to the queue handler, * earning the delivery-count backoff AND the delivery cap. */ export function isFirewallChallenge429( @@ -403,12 +403,12 @@ export interface HttpClientSpanOptions { * Split out of `instrumentedFetch` so a request path that cannot go through * `fetch` still reports the *same* span: name, kind and the full * `httpClientSpanAttributes` set. The WS events transport is the reason this - * exists — a frame on a multiplexed socket is a request in every sense the + * exists: a frame on a multiplexed socket is a request in every sense the * caller's trace cares about, but there is no `Response` and no `fetch` call to * hang a span off, so it synthesizes one here (see `postEventFrameOverWs`). * * `fn` runs inside the active span, so anything it injects trace context into - * is parented to this span rather than to the caller's — which is the contract + * is parented to this span rather than to the caller's, which is the contract * CLAUDE.md's trace-propagation rule describes. */ export async function withHttpClientSpan( @@ -427,7 +427,7 @@ export async function withHttpClientSpan( { kind: await getSpanKind('CLIENT') }, async (span) => { // Diagnostic (DEBUG only): named spans are created and recording here, - // yet never found in the backend — log the exact span identity so the + // yet never found in the backend, so log the exact span identity so the // export side can be checked for this specific span id. if (spanName && HTTP_DEBUG_ENABLED && span) { const ctx = span.spanContext(); @@ -453,7 +453,7 @@ export async function withHttpClientSpan( /** * Stamp a response status onto a client span, marking a non-2xx with the same * `error.type` the fetch path uses. Shared so a synthesized span reports a 409 - * identically to a real one — the status → error-type contract is what + * identically to a real one: the status → error-type contract is what * dashboards filter on, and it must not depend on which transport answered. */ export function recordClientSpanStatus( @@ -504,7 +504,7 @@ export interface InstrumentedFetchOptions extends HttpClientSpanOptions { /** * Notified about the transport-level outcome of the `fetch()` call: the thrown * error when no response arrived, `undefined` when one did. An HTTP error - * status is *not* reported as a failure — the origin answered, so the transport + * status is *not* reported as a failure: the origin answered, so the transport * worked. Lets a caller that owns a shared dispatcher retire it when its * connections stop delivering (see noteEventsTransportOutcome). */ @@ -516,7 +516,7 @@ export interface InstrumentedFetchOptions extends HttpClientSpanOptions { * observability "outgoing requests" view picks it up) with a caller-supplied * undici dispatcher. * - * Handles the shared envelope — OTEL client span + attributes, trace-context + * Handles the shared envelope: OTEL client span + attributes, trace-context * injection, cache-bust header, timeout (mapping TimeoutError/AbortError to * WorkflowWorldError), `DEBUG` logging, and the non-2xx error path (span error * attribute + curl-repro + typed error). Returns the raw `Response` on success @@ -549,8 +549,9 @@ export async function instrumentedFetch( { method, url, peerService, spanName, attributes }, async (span) => { // Explicitly propagate trace context so the receiving server can parent - // its spans to this client span — the custom undici dispatcher bypasses - // ambient auto-instrumentation. No-ops when no OTEL SDK is registered. + // its spans to this client span, since the custom undici dispatcher + // bypasses ambient auto-instrumentation. No-ops when no OTEL SDK is + // registered. if (injectTraceContext) await injectTraceContextIntoHeaders(headers); // Unique header per attempt to bypass RSC/Next fetch memoization (and to @@ -568,7 +569,7 @@ export async function instrumentedFetch( const start = Date.now(); let response: Response; try { - // With no dispatcher to honour, `WORKFLOW_NODE_HTTP` takes the request + // With no dispatcher to honor, `WORKFLOW_NODE_HTTP` takes the request // off undici altogether rather than leaving it on the undici behind // `fetch`. A dispatcher the caller supplied is an instruction to use // undici, so it keeps the request on `fetch`. diff --git a/packages/world-vercel/src/index.ts b/packages/world-vercel/src/index.ts index 7ac12ec34e..beb0dde9bd 100644 --- a/packages/world-vercel/src/index.ts +++ b/packages/world-vercel/src/index.ts @@ -49,8 +49,8 @@ export function createWorld(config?: APIConfig): World { // Vercel deployments are atomic and immutable, so a deployment id names // one fixed build for its whole lifetime. deploymentAffinity: true, - // NOTE: the backend half of resumeHook()'s parallel fast path — that - // the server enforces the `(runId, resumeId)` dedup constraint — is + // NOTE: the backend half of resumeHook()'s parallel fast path (that + // the server enforces the `(runId, resumeId)` dedup constraint) is // NO LONGER a static world capability here. It is attested per-lookup by // the server via `Hook.resumeCapabilities.hookResumeDedupVersion` // (response-only, recomputed every by-token read). This lets a server diff --git a/packages/world-vercel/src/instrumentObject.ts b/packages/world-vercel/src/instrumentObject.ts index 1758d79b87..0162d9071c 100644 --- a/packages/world-vercel/src/instrumentObject.ts +++ b/packages/world-vercel/src/instrumentObject.ts @@ -135,7 +135,7 @@ export function instrumentObject(prefix: string, o: T): T { } } - // Batch writes describe themselves by size and per-type shape — + // Batch writes describe themselves by size and per-type shape, // deliberately NOT workflow.event.type, which names a single event // write and would misleadingly tag the whole batch with its first // event. These live here (the world.events.createBatch span), not on @@ -156,8 +156,8 @@ export function instrumentObject(prefix: string, o: T): T { 'workflow.batch.size': events.length, // Sorted by type, so the same batch composition always renders // the same string. Map iteration is first-seen order, which - // depends on frame order — a pre-claimed fold leads with a - // pair while a pure eager fold leads with its creates — and an + // depends on frame order (a pre-claimed fold leads with a + // pair while a pure eager fold leads with its creates), and an // unstable string is not groupable as a telemetry dimension. 'workflow.batch.shape': [...counts] .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) diff --git a/packages/world-vercel/src/queue.ts b/packages/world-vercel/src/queue.ts index cb662f6bdd..655c1906dd 100644 --- a/packages/world-vercel/src/queue.ts +++ b/packages/world-vercel/src/queue.ts @@ -143,13 +143,13 @@ const HANDLER_ERROR_RETRY_AFTER_SECONDS = 1; // Ceiling for the per-redelivery backoff. This value is the `retry-after` we // hand to VQS, which clamps it into [5s, MAX_SQS_DELAY_SECONDS=900s] for the // first 32 deliveries and then applies its own exponential growth (also capped -// at 900s) — see vqs-server `calculateBackoffDelay`. Capping our base at 60s +// at 900s); see vqs-server `calculateBackoffDelay`. Capping our base at 60s // (the old value) wasted that headroom: a run stuck behind a sustained backend // outage exhausted its delivery budget in ~3.7h. Ramping to the 900s ceiling // instead stretches survival to ~9–10h (across `MAX_QUEUE_DELIVERIES` = 48 // attempts), so transient outages don't fail otherwise-healthy runs. Spanning // the full ~24h message-visibility window would require a higher delivery cap, -// not a higher ceiling — VQS clamps every hop at 900s, so going above it here +// not a higher ceiling: VQS clamps every hop at 900s, so going above it here // is pointless. const HANDLER_ERROR_MAX_RETRY_AFTER_SECONDS = 900; const HANDLER_ERROR_RETRY_JITTER_RATIO = 0.25; @@ -172,7 +172,7 @@ function getHandlerErrorRetryAfterSeconds(deliveryCount: number): number { /** * Default region used when no explicit override, no tagged run ID, and no * `VERCEL_REGION` env var are available. `iad1` preserves the historical - * behaviour from before per-message regional routing existed. + * behavior from before per-message regional routing existed. */ const FALLBACK_REGION = 'iad1'; @@ -195,8 +195,8 @@ function getRunIdFromPayload(payload: QueuePayload): string | undefined { /** * Bind this run's events channel to one invocation of the flow route. This is * the only pair of calls that opens one: nothing else in the SDK does, so every - * other writer — `start()` writing `run_created` from an arbitrary request - * handler, where a lone write would not repay a handshake — stays on HTTP. + * other writer (`start()` writing `run_created` from an arbitrary request + * handler, where a lone write would not repay a handshake) stays on HTTP. * * Both halves are no-ops on the HTTP default, and the gate is checked before the * import so a deployment on the default never loads `ws`. @@ -212,7 +212,7 @@ const wsEventsChannelForInvocation = ( /** * Unawaited and failure-proof: callers treat the handshake as free. The * refcount therefore rises a microtask late, so a write racing the import - * finds no channel and goes over HTTP — one frame, not the invocation, + * finds no channel and goes over HTTP: one frame, not the invocation, * since `close` awaits this same promise and so cannot release ahead of * the claim it is releasing. */ @@ -228,8 +228,8 @@ const wsEventsChannelForInvocation = ( * stops the process exiting and keeps a server invocation pinned. * * Releases the claim the open returned rather than re-resolving the run, - * which is what keeps a channel this invocation never opened — a later - * invocation's, registered under the same URL after ours was evicted — out + * which is what keeps a channel this invocation never opened (a later + * invocation's, registered under the same URL after ours was evicted) out * of reach of our release. */ async close(): Promise { @@ -269,12 +269,12 @@ function regionFromTaggedRunId(runId: string | undefined): string | undefined { * 1. Explicit `opts.region` override. * 2. Region embedded in the payload's tagged run ID. * 3. `VERCEL_REGION` environment variable. - * 4. {@link FALLBACK_REGION} (preserves pre-regional behaviour). + * 4. {@link FALLBACK_REGION} (preserves pre-regional behavior). * * The `opts.region` override and `VERCEL_REGION` are arbitrary strings, so * each is validated against the known region table and ignored (falling * through to the next source) when it isn't a routable region code. This keeps - * a bad override — e.g. `start({ region: 'xyz9' })` — from + * a bad override (e.g. `start({ region: 'xyz9' })`) from * clobbering the payload-derived region with an undeliverable destination. */ function resolveTargetRegion( @@ -311,15 +311,15 @@ function getHeadersFromPayload( /** * Resolves the physical VQS topic for a message. * - * Normally this is just the logical queue name. When + * Normally this is the logical queue name. When * `WORKFLOW_SEQUENTIAL_REPLAYS` is enabled, messages on flow (workflow) * topics get a payload-dependent physical topic. VQS scopes `maxConcurrency` * per concrete topic, so combined with `maxConcurrency: 1` on the flow * trigger: * * - Orchestrator replays (`WorkflowInvokePayload` without a `stepId`) get a - * per-run topic — at most one replay per run at a time. - * - Inline step executions (`WorkflowInvokePayload` WITH a `stepId` — they + * per-run topic: at most one replay per run at a time. + * - Inline step executions (`WorkflowInvokePayload` WITH a `stepId`; they * ride the flow topic in the combined handler model) get a per-step topic * so steps keep full parallelism across a run; only redeliveries of the * same step serialize. @@ -342,7 +342,7 @@ let loggedSequentialReplays = false; /** * Whether sequential replays are enabled (`WORKFLOW_SEQUENTIAL_REPLAYS=1`). - * Mirrors `isSequentialReplaysEnabled` in `@workflow/builders` — world-vercel + * Mirrors `isSequentialReplaysEnabled` in `@workflow/builders`; world-vercel * must not depend on the build-time package, so the check is duplicated. */ function isSequentialReplaysEnabled(): boolean { @@ -443,7 +443,7 @@ export function createQueue(config?: APIConfig): Queue { // we decode it from the payload's tagged run ID so messages produced by // `start()` land in the same region the run was created in. Falls back // to the `VERCEL_REGION` env var, then `iad1` to preserve historical - // behaviour for legacy / untagged run IDs. + // behavior for legacy / untagged run IDs. const region = resolveTargetRegion(payload, opts); const client = new QueueClient({ @@ -548,15 +548,15 @@ export function createQueue(config?: APIConfig): Queue { } } finally { // The only point in the SDK that knows an invocation has no writes - // left. In a `finally` so a failed handler closes too — the retry - // arrives as a new invocation and opens its own channel. + // left. In a `finally` so a failed handler closes too, since the + // retry arrives as a new invocation and opens its own channel. await wsEvents.close(); } }, { // Without an explicit retry directive, @vercel/queue leaves failed // handler messages invisible until the default 300s visibility timeout - // expires. Start retrying quickly, then back off by delivery count + // expires. Start retrying after 1s, then back off by delivery count // with jitter so an outage or poison message cannot hot-loop or // redrive in lockstep. Workflow handlers are event-sourced and must // remain idempotent because queue retries can happen close together. @@ -579,7 +579,7 @@ export function createQueue(config?: APIConfig): Queue { }; // `start()` resolves the current deployment before writing anything, so this - // is where a Vercel world running outside a deployment fails — ahead of any + // is where a Vercel world running outside a deployment fails: ahead of any // state write, and regardless of whether credentials happen to be valid. const getDeploymentId: Queue['getDeploymentId'] = async () => { const deploymentId = process.env.VERCEL_DEPLOYMENT_ID; diff --git a/packages/world-vercel/src/run-id/codec.ts b/packages/world-vercel/src/run-id/codec.ts index c1f34e6782..096bb0c686 100644 --- a/packages/world-vercel/src/run-id/codec.ts +++ b/packages/world-vercel/src/run-id/codec.ts @@ -2,8 +2,8 @@ * Low-level bit / Crockford-Base32 plumbing for tagged ULIDs. * * A ULID is a 128-bit value rendered as 26 Crockford-Base32 characters. Since - * 26 * 5 = 130 bits, the encoded representation has 2 leading zero pad bits - * — i.e. the top 2 bits of the first character must always be 0. This means + * 26 * 5 = 130 bits, the encoded representation has 2 leading zero pad bits, + * i.e. the top 2 bits of the first character must always be 0. This means * the first character of any valid ULID lies in the range `0`..`7`. * * The tagged-ULID layout (see ./regions.ts and ./index.ts for context): @@ -138,7 +138,7 @@ export function bytesToUlid(bytes: Uint8Array): string { // Emit 26 chars from 128 bits, MSB-first, with 2 leading zero pad bits // implicitly contributed by starting the bit buffer empty (bitCount = 0) // and producing the first 5-bit chunk only after we've shifted in 3 real - // bits — i.e. we encode by appending bytes and pulling 5-bit groups off + // bits; i.e. we encode by appending bytes and pulling 5-bit groups off // the top. let bitBuf = 0; let bitCount = 0; diff --git a/packages/world-vercel/src/run-id/index.ts b/packages/world-vercel/src/run-id/index.ts index 1e1111e587..f41ad05b15 100644 --- a/packages/world-vercel/src/run-id/index.ts +++ b/packages/world-vercel/src/run-id/index.ts @@ -14,7 +14,7 @@ * * Net effect: 80 bits of ULID randomness become 69 bits (still ~5.9 × 10²⁰ * distinct values per millisecond), and the maximum representable timestamp - * drops from year ~10895 down to year ~5429 — neither limit is practically + * drops from year ~10895 down to year ~5429; neither limit is practically * relevant. * * Tagged ULIDs remain valid ULIDs. Because the metadata sits at the **top** @@ -30,7 +30,7 @@ * * Changing the metadata mid-millisecond can still invert ordering relative * to a previous emission with different metadata; the {@link encode} - * function itself does not enforce any ordering invariants — that is the + * function itself does not enforce any ordering invariants: that is the * caller's responsibility (see the `createRunId` helper used by `start()`). * * @example @@ -79,7 +79,7 @@ export interface EncodeOptions { /** * Encoding format version to embed. Must be in the range 0..31. Defaults to * {@link CURRENT_VERSION} (1). Version 0 is reserved as a sentinel meaning - * "no metadata encoded" — callers should not normally emit it. + * "no metadata encoded", so callers should not normally emit it. */ version?: number; } @@ -98,7 +98,7 @@ interface DecodedRunIdBase { } /** - * Decode result for a ULID whose tag bit was set — the metadata fields + * Decode result for a ULID whose tag bit was set: the metadata fields * carry the values that `encode` wrote. */ export interface TaggedDecodedRunId extends DecodedRunIdBase { @@ -211,7 +211,7 @@ export function encode( * valid ULID; check {@link DecodedRunId.tagged} to determine whether the * input was actually tagged by this scheme. * - * The returned {@link DecodedRunId.ulid} has only the tag bit cleared — the + * The returned {@link DecodedRunId.ulid} has only the tag bit cleared: the * 11 metadata bits at the top of the randomness section remain in place, so * `decode(encode(u, r)).ulid` is *not* byte-identical to `u` (the top 11 * randomness bits of `u` were overwritten by `encode`), but diff --git a/packages/world-vercel/src/run-id/regions.ts b/packages/world-vercel/src/run-id/regions.ts index d3d0a7a7b7..dcd2d8b7ac 100644 --- a/packages/world-vercel/src/run-id/regions.ts +++ b/packages/world-vercel/src/run-id/regions.ts @@ -6,7 +6,7 @@ * ID is part of the on-the-wire encoding of every run ID ever issued for that * region. New regions must be appended with the next unused ID. * - * `0` is reserved for "unknown" — encode functions may emit it when the + * `0` is reserved for "unknown": encode functions may emit it when the * caller's region cannot be determined, and decode will surface it as * `region: null`. * @@ -43,7 +43,7 @@ export const REGION_IDS = { /** * Any key in {@link REGION_IDS}, including the `'unknown'` sentinel. Not - * usually what callers want — see {@link RegionCode} for the "known region" + * usually what callers want; see {@link RegionCode} for the "known region" * subset. */ export type RegionKey = keyof typeof REGION_IDS; @@ -58,7 +58,7 @@ export type RegionCode = Exclude; * Default region for run IDs minted without an explicit or environment-derived * region. Mirrors the server's `DEFAULT_VERCEL_REGION` (iad1): untagged/legacy * data and unknown-region runs both resolve to iad1 server-side, so minting a - * concrete `iad1` tag — rather than the `unknown`/0 sentinel — keeps every run + * concrete `iad1` tag (rather than the `unknown`/0 sentinel) keeps every run * ID self-describing and routable, and avoids the `tagged: true, region: null` * state entirely. */ @@ -88,8 +88,8 @@ export function lookupRegion(regionId: number): RegionCode | null { /** * Runtime guard for arbitrary strings crossing a JS/TS boundary (e.g. an * `opts.region` override or the `VERCEL_REGION` env var). Returns `true` only - * for a concrete, routable region code — the `unknown` sentinel and any - * unrecognised value both return `false`. + * for a concrete, routable region code: the `unknown` sentinel and any + * unrecognized value both return `false`. */ export function isKnownRegionCode( code: string | undefined diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 139349defe..d74f7421ab 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -269,7 +269,7 @@ const WAIT_TIMEOUT_HEADROOM_MS = 10_000; * attempts before the next one re-probes. * * A miss means the backend serving this base URL predates the route (or has it - * rolled back), which is a property of the *backend*, not of the run — so it is + * rolled back), which is a property of the *backend*, not of the run, so it is * cached rather than re-learned per call. It expires so a client that outlives * a server roll-forward picks the fast path back up on its own. */ @@ -277,7 +277,7 @@ const LONG_POLL_UNSUPPORTED_TTL_MS = 5 * 60 * 1000; /** * Suppression deadline per base URL, because one process can hold worlds - * pointed at different backends — the api.vercel.com proxy (with + * pointed at different backends: the api.vercel.com proxy (with * `projectConfig`) and workflow-server directly resolve to different hosts, * which can be on different versions. A miss against one must not disable the * fast path for the other. Bounded by construction: the key is the resolved @@ -297,9 +297,9 @@ export function _resetRunStatusLongPollSupportForTests(): void { * Two shapes, for the same reason: the answer we want is a plain read either * way, and the fast path should stand down until it re-probes. * - * - **The route is missing** (`404`/`405`/`501`). `404` is ambiguous — the run - * may not exist — which the plain read below disambiguates. - * - **The hold did not survive** — the request timed out client-side + * - **The route is missing** (`404`/`405`/`501`). `404` is ambiguous (the run + * may not exist), which the plain read below disambiguates. + * - **The hold did not survive**: the request timed out client-side * (`TIMEOUT`), the connection was severed mid-hold (`TRANSPORT`), or an * intermediary gave up on it (`504`). Holding a request open for ~25s is a * new shape for this client, and it crosses gateways and egress proxies that @@ -332,16 +332,16 @@ function isLongPollUnusable(error: WorkflowWorldError): boolean { * * Implements `Storage['runs'].waitForTerminalStatus`: resolves as soon as the * run is terminal, and otherwise with the latest snapshot once the budget - * expires. Never throws on a timeout — a still-running run is an answer. + * expires. Never throws on a timeout, since a still-running run is an answer. * * Degrades in two places, because the adapter can outlive the server version * it was built against: * * - **Budget.** Clamped to leave {@link WAIT_TIMEOUT_HEADROOM_MS} under the * adapter's per-request timeout, and the server clamps again to its own - * ceiling. A budget that clamps to zero is just a plain read. - * - **Missing route.** A `404` is ambiguous — the run may not exist, or this - * server may not have the route — so it is resolved by falling back to the + * ceiling. A budget that clamps to zero is a plain read. + * - **Missing route.** A `404` is ambiguous (the run may not exist, or this + * server may not have the route), so it is resolved by falling back to the * plain read, which is the answer we want either way: it raises * `WorkflowRunNotFoundError` for a missing run, and returns the run when the * *route* was what was missing. Only the latter (proof that the run exists @@ -397,7 +397,7 @@ export async function waitForWorkflowRunTerminalStatus( ); } catch (error) { // The caller cancelled on purpose. That is not a verdict on the route, so - // it propagates rather than degrading — otherwise an ordinary abort would + // it propagates rather than degrading; otherwise an ordinary abort would // both cost an extra read and switch the fast path off for every run in // this process. Checked first because an abort surfaces with the same // `TIMEOUT` code as a request that ran out of time on its own. @@ -568,11 +568,11 @@ const ExperimentalSetAttributesResponseSchema = z.object({ /** * Apply attribute changes to a workflow run. The body shape mirrors the * future `attr_set` event's `eventData.changes`, so the wire contract is - * forward-compatible with the full 5.0.0 attributes feature — only the + * forward-compatible with the full 5.0.0 attributes feature; only the * endpoint path changes. * * `options.allowReservedAttributes` opts the request into permitting - * `$`-prefixed keys (framework-only — see the SDK helper for details). + * `$`-prefixed keys (framework-only; see the SDK helper for details). * The flag is forwarded to the server via the request body. * * EXPERIMENTAL: tied to the MVP write-only attributes API. See diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index c347628010..09a3c2dd6a 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -49,7 +49,7 @@ const StepWireWithRefsSchema = StepWireSchema.omit({ * Transform step from wire format to Step interface format. * * The `error` field on Step is SerializedData (Uint8Array) from the - * serialization pipeline — we pass through the wire-format `error` (or + * serialization pipeline. We pass through the wire-format `error` (or * the resolved `errorRef`) as-is. Consumers hydrate via `hydrateStepError`. * * Wire→shape only: this does NOT decompress. The runtime write paths @@ -82,7 +82,7 @@ function filterStepData( // // This is the read/display entry point, so it decompresses gzip/zstd // payload wrappers via `normalizeStepData` (the runtime write paths use -// `deserializeStep` directly and skip this — see its doc comment). +// `deserializeStep` directly and skip this (see its doc comment). function filterStepData( step: any, resolveData: 'none' | 'all' diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index 59538ea6d2..55a368c760 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -36,7 +36,7 @@ export const MAX_CHUNKS_PER_REQUEST = 1000; /** * Effective max chunks per write request. Override via - * `WORKFLOW_MAX_CHUNKS_PER_REQUEST` — lower it (paired with the server's + * `WORKFLOW_MAX_CHUNKS_PER_REQUEST`. Lower it (paired with the server's * `MAX_CHUNKS_PER_BATCH` override) to exercise the batch-splitting path. */ const getMaxChunksPerRequest = (): number => @@ -47,7 +47,8 @@ const getMaxChunksPerRequest = (): number => // All stream requests share the instrumented envelope (`instrumentedFetch`): // an OTEL client span, trace-context injection, `DEBUG` logging, and the -// x-vercel diagnostic headers — the same coverage the v3/v4 paths have. +// x-vercel diagnostic headers, which provide the same coverage the v3/v4 paths +// have. // // Writes (the PUT write/close path) go through the H2 stream dispatcher (see // getStreamDispatcher): they send a fully-buffered body (or none), so they @@ -55,11 +56,11 @@ const getMaxChunksPerRequest = (): number => // long-lived live-read (GET) on the global dispatcher. Because stream appends // aren't idempotent, that stream dispatcher uses a deliberately narrowed retry // policy (see STREAM_RETRY_OPTIONS): it retries only on transient connection -// errors and HTTP 429 — both of which guarantee the chunk was never persisted — +// errors and HTTP 429 (both of which guarantee the chunk was never persisted) // and never on 5xx, so a retry can't duplicate an already-applied write. // Snapshot reads (chunks/info) go through makeRequest (default H1 dispatcher); // the live-read (GET) and list keep the global dispatcher (no custom retry) and -// no request timeout — the live read is long-lived and a whole-request deadline +// no request timeout. The live read is long-lived and a whole-request deadline // would truncate it. // Writes (PUT) and stream completion use the v2 stream endpoint. @@ -75,7 +76,7 @@ function getStreamUrl(name: string, runId: string, httpConfig: HttpConfig) { // (`createReconnectingFramedStream`) resume from the next chunk rather than // treating the timeout as end-of-stream. Reading from v2 would silently // truncate long-lived streams at the server's 2-minute limit. Only the live -// read is affected by the timeout — writes, completion, and snapshot reads +// read is affected by the timeout. Writes, completion, and snapshot reads // (chunks/info/list) stay on v2. function getStreamReadUrl(name: string, runId: string, httpConfig: HttpConfig) { return new URL( @@ -86,7 +87,7 @@ function getStreamReadUrl(name: string, runId: string, httpConfig: HttpConfig) { /** * Stream-operation attributes layered onto the shared HTTP client span (see * instrumentedFetch). These make stream writes/reads sliceable by run, stream - * name, and operation — beyond the generic `http PUT`/`http GET` verb — and + * name, and operation (beyond the generic `http PUT`/`http GET` verb) and * are no-ops when no OTEL SDK is registered (the span is undefined). */ function streamSpanAttributes(args: { @@ -186,7 +187,7 @@ const StreamInfoResponseSchema = z.object({ /** * Zod schema for the paginated stream chunks response from the server. * When using CBOR (the default for makeRequest), chunk data arrives as - * native Uint8Array byte strings — no base64 decoding required. + * native Uint8Array byte strings, so no base64 decoding is required. */ const StreamChunksResponseSchema = z.object({ data: z.array( @@ -253,7 +254,7 @@ export function createStreamer(config?: APIConfig): Streamer { // Send in pages of MAX_CHUNKS_PER_REQUEST to stay within the // server's per-batch limit (MAX_CHUNKS_PER_BATCH). - // Note: for batches spanning multiple pages, atomicity is relaxed — + // Note: for batches spanning multiple pages, atomicity is relaxed. // earlier pages may persist while a later page fails. The caller // retains the full buffer on error, so chunks from successful pages // will be re-sent on retry, producing duplicates. This is acceptable @@ -299,7 +300,7 @@ export function createStreamer(config?: APIConfig): Streamer { url: url.toString(), headers: httpConfig.headers, // Close is idempotent (unlike chunk appends), so its dispatcher - // retries 5xx — required by the server's close-barrier protocol, + // retries 5xx, as required by the server's close-barrier protocol, // which surfaces transient reconciliation states as retriable // 503s with the stream left durably closing. dispatcher: getStreamCloseDispatcher(config), diff --git a/packages/world-vercel/src/telemetry.ts b/packages/world-vercel/src/telemetry.ts index 8d0d2c3eea..438c12faff 100644 --- a/packages/world-vercel/src/telemetry.ts +++ b/packages/world-vercel/src/telemetry.ts @@ -24,14 +24,14 @@ async function getOtelApi(): Promise { // Static specifier is intentional: esbuild-bundled targets (the CLI's // `vercel-build-output-api` build, Nitro, Astro) ship a self-contained // bundle with no node_modules, so `@opentelemetry/api` (an optional peer) - // must be inlined at build time — a runtime-built specifier is opaque to + // must be inlined at build time. A runtime-built specifier is opaque to // esbuild and would silently disable tracing there. Bundlers that reject // an unresolvable static `import()` when the peer is absent (Rollup/Vite, // e.g. SvelteKit) externalize it in the framework integration instead. otelApiPromise = import('@opentelemetry/api').catch((error) => { - // A missing module is expected for apps without OTEL — but the same + // A missing module is expected for apps without OTEL, but the same // silent null also swallows bundler/resolution failures in apps that - // DO register a tracer, which then just lose every world-vercel span. + // DO register a tracer, which then lose every world-vercel span. // Surface the reason under DEBUG so that failure mode is diagnosable. if ( typeof process !== 'undefined' && @@ -63,10 +63,10 @@ let otelDiagLogged = false; /** * One-shot runtime diagnostic (DEBUG=workflow:* only): prints how THIS module - * instance of `@opentelemetry/api` sees the global registration — enough to - * tell a noop tracer from a registered provider, and a missing registration - * from an incompatible one. @workflow/core emits the same shape tagged - * `core`, so a single deployment's logs show both views side by side. + * instance of `@opentelemetry/api` sees the global registration, which is + * enough to tell a noop tracer from a registered provider, and a missing + * registration from an incompatible one. @workflow/core emits the same shape + * tagged `core`, so a single deployment's logs show both views side by side. */ function logOtelDiagnosticOnce(otel: typeof api, tracer: api.Tracer): void { if (otelDiagLogged || !workflowDebugEnabled()) return; @@ -214,7 +214,7 @@ export const ErrorType = SemanticConvention('error.type'); /** * Application-layer protocol the request was carried over (standard OTEL: * network.protocol.name). Only set on the WS events transport, whose client - * span is synthesized rather than produced by a real `fetch` — it is the + * span is synthesized rather than produced by a real `fetch`. It is the * attribute that keeps such a span honest about what actually went on the wire. */ export const NetworkProtocolName = SemanticConvention( @@ -282,7 +282,7 @@ export const WorkflowEventsTransport = SemanticConvention<'http' | 'ws'>( * (workflow.http.transport): `undici` | `node-http`. Set on BOTH paths, for * the same reason as {@link WorkflowEventsTransport}: the two emit the same * client span against the same `url.full`, so without this attribute a trace - * cannot say which transport carried the request — and `WORKFLOW_NODE_HTTP` + * cannot say which transport carried the request, and `WORKFLOW_NODE_HTTP` * is an opt-in whose whole point is being verified in a real deployment. */ export const WorkflowHttpTransport = SemanticConvention<'undici' | 'node-http'>( diff --git a/packages/world-vercel/src/utils.ts b/packages/world-vercel/src/utils.ts index 08c7445f92..f89873864b 100644 --- a/packages/world-vercel/src/utils.ts +++ b/packages/world-vercel/src/utils.ts @@ -36,7 +36,7 @@ import { version } from './version.js'; /** * Inline workflow-server URL override. Must remain an empty string on - * `main` — rewritten by external CI for branch-deployment testing. + * `main`. It is rewritten by external CI for branch-deployment testing. * Prefer `VERCEL_WORKFLOW_SERVER_URL` for deployment-time configuration. */ // biome-ignore format: External CI replaces only this line with a deployment URL that may exceed the formatter width. @@ -44,7 +44,7 @@ export const WORKFLOW_SERVER_URL_OVERRIDE = ''; /** * HTTP methods that are safe to transparently re-issue inside the adapter. - * A retry re-sends the request, so it is only safe for idempotent reads — a + * A retry re-sends the request, so it is only safe for idempotent reads. A * write could be applied twice. Writes rely on the workflow runtime's * idempotent replay (and server-side correlation-id de-duplication) instead. */ @@ -52,7 +52,7 @@ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD']); /** * How many extra times to re-issue an idempotent request when reading or - * decoding the response body fails transiently — a truncated/terminated + * decoding the response body fails transiently, such as a truncated/terminated * stream, a connection reset mid-body, or a gateway returning a non-CBOR/JSON * body. The shared `RetryAgent` (see `http-client.ts`) already retries * connection and 5xx failures, but body-consumption errors surface *after* it @@ -87,7 +87,7 @@ const BODY_PARSE_RETRY_BASE_MS = 100; * redrive, instead of crashing the invocation or failing the run. * * The set is consulted on both paths, so adding `ETIMEDOUT` for the node:http - * deadlines also classifies it on the undici path — where it is near + * deadlines also classifies it on the undici path, where it is near * unreachable, since undici's 10s `connectTimeout` fires long before the OS * raises `ETIMEDOUT` on a connect, and its own stalls surface as `UND_ERR_*`. * Deliberate either way: a socket that timed out is transient under any client. @@ -149,7 +149,7 @@ const getWorkflowServerUrlOverride = (): string => /** * Header the server reads to tighten its own limits (stream max-duration, - * chunk-batch size, …) per request — only ever to a stricter value than the + * chunk-batch size, …) per request, only ever to a stricter value than the * deployment default. See `world-vercel`'s server counterpart. */ const TEST_LIMIT_OVERRIDES_HEADER = 'x-workflow-test-limit-overrides'; @@ -160,9 +160,9 @@ const TEST_LIMIT_OVERRIDES_HEADER = 'x-workflow-test-limit-overrides'; * The value is a JSON object of server-constant name → value (e.g. * `{"STREAM_MAX_DURATION_MS":5000}`) sent verbatim as * `x-workflow-test-limit-overrides`. The server validates and clamps it - * (stricter-only), so a malformed value is harmlessly ignored there — we don't + * (stricter-only), so a malformed value is harmlessly ignored there. We don't * parse it here. Intended for a dedicated e2e deployment so the suite exercises - * edge paths (stream reconnect, batch splitting) quickly; unset in production. + * edge paths (stream reconnect, batch splitting) without long waits; unset in production. */ const getTestLimitOverridesHeader = (): string => process.env.WORKFLOW_TEST_LIMIT_OVERRIDES?.trim() || ''; @@ -180,7 +180,7 @@ export interface APIConfig { * dispatcher from a different undici version. Callers may pass any undici * version's dispatcher, or any object implementing the dispatcher contract. * - * Note: when provided, this dispatcher replaces *every* default — including + * Note: when provided, this dispatcher replaces *every* default, including * the one used for stream writes (the `PUT` write/close path). Stream appends * are not idempotent, and undici's `RetryAgent` retries `PUT` on 5xx by * default, which can duplicate a chunk the server already persisted. A custom @@ -231,7 +231,7 @@ export function serializeError( /** * Joins User-Agent product tokens with spaces per the RFC 9110 `User-Agent` * grammar (`product *( RWS ( product / comment ) )`), skipping empty parts. - * Never join UA products with `Headers.append()` — repeated header values + * Never join UA products with `Headers.append()`. Repeated header values * combine with `", "`, and a comma glued to a product token breaks * whitespace-delimited parsers on the receiving side. */ @@ -274,11 +274,11 @@ export const getHttpUrl = ( /** * The environment this client's writes will be attributed to by the backend. * - * Two distinct auth paths, two distinct sources — and this must mirror both + * Two distinct auth paths, two distinct sources. This must mirror both * exactly, because callers compare it against the environment a *different* * client reported to detect a cross-tenant fork (see `World.getEnvironment`): * - * - **With `projectConfig`** (CLI, CI, the observability dashboard — anything + * - **With `projectConfig`** (CLI, CI, the observability dashboard, or anything * going through the api.vercel.com proxy with a Vercel auth token): the proxy * attributes the write to the `x-vercel-environment` header, so the answer is * whatever {@link getHeaders} puts there. Both read this one function so they @@ -288,7 +288,7 @@ export const getHttpUrl = ( * - **Without `projectConfig`** (inside a Vercel deployment, authenticating * with the per-request OIDC token): the backend reads the token's * `environment` claim, which the platform mints as - * `customEnvironment?.slug ?? envTarget` — and `VERCEL_TARGET_ENV` is + * `customEnvironment?.slug ?? envTarget`, and `VERCEL_TARGET_ENV` is * populated from exactly the same pair (`customEnvironmentSlug || * envTarget`), so it matches the claim by construction. `VERCEL_ENV` alone * would be wrong for Vercel *custom* environments: it reports `preview` @@ -304,7 +304,7 @@ export const getHttpUrl = ( * * Returns `undefined` when neither source is available (e.g. a bare Node * process with no Vercel env vars). `undefined` is the honest answer there and - * callers skip their checks — guessing `'production'` would fabricate a + * callers skip their checks. Guessing `'production'` would fabricate a * mismatch against a genuine preview deployment. */ export const resolveClientEnvironment = ( @@ -333,7 +333,7 @@ export const getHeaders = ( } if (projectConfig) { // Derived from the same helper `getEnvironment` uses so the header and the - // value stamped into `runInput` can never disagree — a drift between them + // value stamped into `runInput` can never disagree. A drift between them // would make the cross-environment guard either miss a real fork or // reject a legitimate start. headers.set( @@ -443,7 +443,7 @@ export async function makeRequest({ // Explicitly propagate the active trace context (traceparent / // tracestate / baggage) onto the outgoing request so workflow-server - // can parent its spans to this client span — without relying on the + // can parent its spans to this client span without relying on the // customer app having undici auto-instrumentation. No-ops when no // OTEL SDK is registered. await injectTraceContextIntoHeaders(headers); @@ -456,7 +456,7 @@ export async function makeRequest({ } // Reading or decoding the response body can fail transiently even on a - // successful (2xx) response — a truncated/terminated stream, a + // successful (2xx) response, such as a truncated/terminated stream, a // connection reset mid-body, or a gateway returning a non-CBOR/JSON // body. The RetryAgent retries connection/5xx failures, but it has // already handed back the response by the time we consume the body, so diff --git a/packages/world-vercel/src/ws-transport-enabled.ts b/packages/world-vercel/src/ws-transport-enabled.ts index ed370ee56f..c07735c3e3 100644 --- a/packages/world-vercel/src/ws-transport-enabled.ts +++ b/packages/world-vercel/src/ws-transport-enabled.ts @@ -1,7 +1,7 @@ /** * The events-transport opt-in gate, deliberately alone in a module with no * imports. `events-v4.ts` and `queue.ts` read it on every invocation, so it has - * to be answerable without pulling in `ws-transport.js` — and with it `ws`, + * to be answerable without pulling in `ws-transport.js` and with it `ws`, * ~17 ms of module init that a deployment on the HTTP default never gets a * return on. Both call sites `await import('./ws-transport.js')` behind a true * result, so the cost lands only where the socket is actually used. @@ -9,7 +9,7 @@ /** * HTTP unless `WORKFLOW_EVENTS_TRANSPORT=ws`. Only `createWorkflowRunEventV4` - * (POST) is wired to it — GET/LIST aren't on the hot per-step path, and LIST's + * (POST) is wired to it. GET/LIST aren't on the hot per-step path, and LIST's * streamed, sentinel-terminated multi-frame response doesn't map onto a single * WS message. * @@ -18,7 +18,7 @@ * trace, but the HTTP branch's `instrumentedFetch` also opens an OTEL CLIENT * span per write and routes through the global `fetch` that Vercel's * outgoing-requests view instruments; the WS branch has neither, and individual - * frames carry no `traceparent` of their own. Acceptable behind a flag — + * frames carry no `traceparent` of their own. Acceptable behind a flag; * per-write instrumentation is a prerequisite for defaulting to it. */ export function isWsEventsTransportEnabled(): boolean { diff --git a/packages/world-vercel/src/ws-transport.ts b/packages/world-vercel/src/ws-transport.ts index 20527dffe1..78e780ebad 100644 --- a/packages/world-vercel/src/ws-transport.ts +++ b/packages/world-vercel/src/ws-transport.ts @@ -2,23 +2,23 @@ * Client half of the events WebSocket protocol. The normative wire-format and * lifecycle spec is the server's `docs/ws-protocol.md`. * - * One socket per `wsUrl` — which embeds the runId — is shared across concurrent + * One socket per `wsUrl` (which embeds the runId) is shared across concurrent * `createWorkflowRunEventV4` calls and multiplexed by `reqId`. Frame meta is * `{ reqId, type, ... }`, with the type-specific payload nested under a field * named after `type` (only `event` is sent today). The caller builds the meta; * this file owns the socket and the reqId<->promise bookkeeping. * - * Every failure mode has to reach the caller waiting on it — a swallowed error + * Every failure mode has to reach the caller waiting on it: a swallowed error * here is a hung invocation, not a lost log line. Whether a failed write is * re-sent is `event-retry.ts`'s decision, not this file's: `WsTransportError` * is mapped to the same `code: 'TRANSPORT'` shape a failed `fetch` produces. * * Uses the `ws` package rather than the WHATWG global `WebSocket`, which cannot - * set headers on the upgrade request — auth rides the handshake, once per + * set headers on the upgrade request; auth rides the handshake, once per * connection instead of once per message. * - * Transport *selection* lives at the bottom of the file — the opt-in flag, - * which Worlds can hold a socket at all, and the pre-warm entry point — so + * Transport *selection* lives at the bottom of the file (the opt-in flag, + * which Worlds can hold a socket at all, and the pre-warm entry point) so * `events-v4.ts` consumes one seam instead of assembling the transport. */ @@ -47,7 +47,7 @@ export interface WsFrameReply { /** * A transport-level failure: the socket closed, the frame could not be handed - * to it, or a reply arrived that cannot be correlated to a caller — always + * to it, or a reply arrived that cannot be correlated to a caller, always * before the frame was acked. Distinct from an application-level error (a reply * carrying a non-2xx status), which the events adapter raises as a typed * `@workflow/errors` error. Carries no retry policy of its own; @@ -89,7 +89,7 @@ const RECONNECT_BASE_DELAY_MS = 100; const RECONNECT_MAX_DELAY_MS = 5_000; /** Absent on servers predating the field, which only ever drained for - * maxDuration — so absent reads as `max_duration`. */ + * maxDuration, so absent reads as `max_duration`. */ type DrainReason = 'max_duration' | 'auth_expiry'; /** `getHeaders` is a caller-supplied thunk and shouldn't have to guarantee @@ -129,7 +129,7 @@ function describeError(err: unknown): string { } /** - * One multiplexed connection to the events WS endpoint. Not exported — callers + * One multiplexed connection to the events WS endpoint. Not exported: callers * go through `getWsEventsTransport`, which caches one instance per `wsUrl`. */ class WsEventsTransport { @@ -139,15 +139,15 @@ class WsEventsTransport { private reconnectTimer: ReturnType | null = null; /** Number of `open()` calls not yet matched by a `release()`. The socket goes * away at zero. A refcount rather than a flag because concurrent invocations - * for one run share this instance — see `open`. */ + * for one run share this instance; see `open`. */ private openCount = 0; /** Set by `close()`. Suppresses reconnects so an intentional teardown can't * be undone by the close handler it triggers. */ private closed = false; /** Reason from the most recent `drain`, consumed by the close that follows. */ private lastDrainReason: DrainReason | null = null; - /** A drain said the *token* expired, not that the socket aged out — the next - * connect must not reuse the same bearer. */ + /** A drain said the *token* expired, not that the socket aged out, so the + * next connect must not reuse the same bearer. */ private needsFreshToken = false; /** Authorization the current socket was opened with, so a forced refresh can * tell whether it actually produced a new one. */ @@ -186,7 +186,7 @@ class WsEventsTransport { // Deliberately the same knob the HTTP path uses. Without it, a reply // that never arrives for a reason the error/close handling doesn't // cover blocks the caller until the platform's `maxDuration` - // SIGTERM — including a server that accepts a frame and never + // SIGTERM, including a server that accepts a frame and never // answers it. deadline = setTimeout(() => { if (!conn.pending.delete(reqId)) return; @@ -207,7 +207,7 @@ class WsEventsTransport { deadline.unref?.(); conn.ws.send(frame, (err) => { if (!err) return; - // `ws.send()` does not throw when the socket isn't OPEN — it + // `ws.send()` does not throw when the socket isn't OPEN; it // reports here instead, so without this callback the request would // wait for a reply that is never coming. `delete` doubles as the // already-settled guard. @@ -228,7 +228,7 @@ class WsEventsTransport { /** * Claim the channel and start connecting. Called once per invocation that - * intends to write, at the point the run id is known — connecting lazily on + * intends to write, at the point the run id is known. Connecting lazily on * the first write instead bills the handshake (plus the OIDC mint riding it) * to whichever event happens to be written first; when that's a `step_started` * issued as the step body already runs, its server-recorded timestamp lands @@ -278,7 +278,7 @@ class WsEventsTransport { /** * Drop the socket and evict this transport from the cache. Terminal, not a * pause: the instance stays closed and a later `openWsChannel` for the same - * run constructs a fresh one. Idempotent. Safe with work in flight — those + * run constructs a fresh one. Idempotent. Safe with work in flight: those * requests fail through the socket's own `close` handler, same as any other * close. */ @@ -312,7 +312,7 @@ class WsEventsTransport { } /** - * Resolve the headers for one upgrade request — once per socket, since the + * Resolve the headers for one upgrade request, once per socket, since the * bearer only rides the upgrade. Re-resolving per socket is what lets a * reconnect pick up a fresh token rather than replaying an expired one. * @@ -331,7 +331,7 @@ class WsEventsTransport { // The server says the token is expiring and we can't produce a different // one. Inside a Vercel function the bearer comes from the invocation's own // `x-vercel-oidc-token` and is fixed for that invocation, so reconnecting - // now just earns a 401 and burns the attempt budget. Give up on the eager + // now earns a 401 and burns the attempt budget. Give up on the eager // reconnect; queue redelivery recovers this by landing in a new // invocation with a new token. throw new WsTransportError( @@ -437,7 +437,7 @@ class WsEventsTransport { ws.on('open', () => { opened = true; if (this.closed) { - // Released while this handshake was in flight — `close()` could + // Released while this handshake was in flight: `close()` could // only null out the connection it could see. Adopting this one now // would leave a live socket on a transport nothing will ever close. ws.close(1000, 'released while connecting'); @@ -492,7 +492,7 @@ class WsEventsTransport { // Unconditional because `pending` is per-connection: a superseded // socket's late close can only reach its own waiters. The frame was // in flight when the socket died, so the server either never saw it - // or never acked it — re-sending is safe, createEvent writes are + // or never acked it, so re-sending is safe, createEvent writes are // conditional on the entity server-side. this.failAllPending( conn, @@ -559,7 +559,7 @@ class WsEventsTransport { decoded = await decodeOneFrame(raw); } catch (err) { // Uncorrelatable, and it says the framing on this socket is no longer - // trustworthy — so the connection goes rather than leaving its waiters + // trustworthy, so the connection goes rather than leaving its waiters // unanswerable. const detail = `could not decode a ${raw.byteLength}-byte reply frame from ` + @@ -573,7 +573,7 @@ class WsEventsTransport { } if (decoded.meta.type === 'drain') { - // Unsolicited server push, no reqId. Informational on its own — the + // Unsolicited server push, no reqId. Informational on its own: the // `close` that follows is what triggers the reconnect and consumes the // reason recorded here. const reason: DrainReason = @@ -644,13 +644,13 @@ class WsEventsTransport { } /** - * Fail every waiter on `conn` and drop the socket — for a reply that can't be + * Fail every waiter on `conn` and drop the socket, for a reply that can't be * correlated to a caller, and for a request that outlived its deadline. The * whole connection goes because each case says the stream itself is no longer * understood; logging and moving on would leave the originating request in * `pending` with nothing able to answer it until the server drains (~680s), * well past the waiting invocation's `maxDuration`. The socket's own `close` - * handler does the teardown and schedules the reconnect — failing the waiters + * handler does the teardown and schedules the reconnect; failing the waiters * here is what makes callers see this diagnosis, not a bare close code. */ private failConnection(conn: Connection, message: string): void { @@ -666,11 +666,11 @@ const transports = new Map(); * Get (or lazily create) the shared WS transport for `wsUrl`. `getHeaders` runs * once per socket, at connect time, with `forceRefresh: true` when the previous * socket drained on an expiring token, so a caching token source knows not to - * serve the stale entry. Only the first caller's thunk for a `wsUrl` is kept — - * fine, since `wsUrl` embeds the runId and one run has one client. An entry - * exists only between `openWsChannel` and the matching `closeWsChannel`, so - * membership *is* the answer to "does this run have a channel" — which is what - * the write path asks, via `resolveWsTransport`. + * serve the stale entry. Only the first caller's thunk for a `wsUrl` is kept, + * which is fine, since `wsUrl` embeds the runId and one run has one client. An + * entry exists only between `openWsChannel` and the matching `closeWsChannel`, + * so membership *is* the answer to "does this run have a channel", which is + * what the write path asks, via `resolveWsTransport`. */ export function getWsEventsTransport( wsUrl: string, @@ -731,8 +731,9 @@ export { isWsEventsTransportEnabled }; * channel, so a code path that doesn't call this writes its events over HTTP * even with the transport enabled. * - * Call it as early in an invocation as the run id is known — the flow route does, - * before dispatching to the runtime. Only worth it for a caller that will write + * Call it as early in an invocation as the run id is known, as the flow route + * does before dispatching to the runtime. Only worth it for a caller that will + * write * several events: a single write does not repay a handshake, which is why * `start()` deliberately doesn't open one for `run_created`. * @@ -741,7 +742,7 @@ export { isWsEventsTransportEnabled }; * connect. Callers must be able to treat it as free. * * An open socket is not `unref`'d, so a caller that drops the release stops the - * process exiting — and keeps a server invocation pinned, one per connection — + * process exiting (and keeps a server invocation pinned, one per connection) * until the platform kills it. The socket drops once every concurrent holder * has released; see `WsEventsTransport.release`. * @@ -750,18 +751,18 @@ export { isWsEventsTransportEnabled }; * the connect path, so the next opener for the same run gets a *different* * instance under the same URL. A release that re-resolved the URL would * decrement whichever instance is registered by then rather than the one it - * claimed, dropping a socket a live invocation is still writing over — and for + * claimed, dropping a socket a live invocation is still writing over, and for * the event types `EVENT_RETRY_ELIGIBILITY` marks non-retryable there is no * second attempt to carry that write over HTTP. Idempotent for the same reason: * a doubled release must not consume another holder's claim. * * Synchronous matters beyond cost. Two invocations for one run share a channel, - * so an open races a release whenever the refcount stands at 1 — and because + * so an open races a release whenever the refcount stands at 1, and because * neither yields between its map lookup and its refcount write, the release * either observes the open's increment, and the socket survives for the new * holder, or lands first, and the opener misses the de-registered instance and - * builds a fresh channel. Adding an await ahead of the refcount write — minting - * a token to derive the URL, say — reopens that window, and an open landing + * builds a fresh channel. Adding an await ahead of the refcount write (minting + * a token to derive the URL, say) reopens that window, and an open landing * inside it hits an already-closed instance, returns early, and leaves that * invocation on HTTP for its whole duration with nothing to signal it. */ @@ -806,7 +807,7 @@ const OIDC_FORCE_REFRESH_BUFFER_MS = 24 * 60 * 60 * 1000; * Ask `@vercel/oidc` for a new token before the next `getHttpConfig()` reads * one, in response to a drain with `reason: 'auth_expiry'`. Swallows failures: * an unavailable refresh must not fail a write that `getHttpConfig()` can still - * produce a usable — if soon-to-expire — bearer for. + * produce a usable (if soon-to-expire) bearer for. * * Only effective outside a Vercel function. `getVercelOidcToken()` prefers * `getContext().headers['x-vercel-oidc-token']` over @@ -829,15 +830,15 @@ async function refreshOidcTokenBestEffort(): Promise { } } -// Each logged at most once per process — both branches below are expected -// to repeat (every event), and a per-request log would just be noise. +// Each logged at most once per process: both branches below are expected +// to repeat (every event), and a per-request log would add noise. let loggedWsProxyFallback = false; let loggedWsInUse = false; /** * Resolve this run's channel URL, or `null` when this World can't hold a socket * at all and every caller must use HTTP. Says nothing about whether a channel is - * *open* — that's `resolveWsTransport`. + * *open*; that's `resolveWsTransport`. */ function resolveChannelUrl( runId: string, @@ -850,7 +851,7 @@ function resolveChannelUrl( if (usingProxy) { // `usingProxy` resolves `baseUrl` to `api.vercel.com/v1/workflow`, an // HTTP-only REST gateway that does not forward a raw WebSocket upgrade to - // the workflow-server target — it either rejects the upgrade or hands the + // the workflow-server target: it either rejects the upgrade or hands the // route a plain forwarded request that never went through Vercel's // platform-level upgrade path, which is what surfaces as // "experimental_upgradeWebSocket is not available in the current runtime @@ -869,7 +870,7 @@ function resolveChannelUrl( /** * The write path's question: is there an open channel for this run? `null` means - * write over HTTP — because the transport is disabled, because this World can't + * write over HTTP, because the transport is disabled, because this World can't * hold a socket, or because nothing opened a channel for this invocation. * * A lookup, never a create. Lazily connecting here is what made the socket's diff --git a/packages/world/src/analytics.ts b/packages/world/src/analytics.ts index ac0f0a3cee..90753010c5 100644 --- a/packages/world/src/analytics.ts +++ b/packages/world/src/analytics.ts @@ -6,7 +6,7 @@ import { StepStatusSchema } from './steps.js'; import { WaitStatusSchema } from './waits.js'; /** - * Timezone-naive datetime string, e.g. `2026-07-13 17:09:11.593` — the + * Timezone-naive datetime string, e.g. `2026-07-13 17:09:11.593`: the * shape ClickHouse-backed analytics endpoints serialize `DateTime64` * values as. Such values are UTC by convention but carry no designator. */ @@ -18,7 +18,7 @@ const NAIVE_DATETIME = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/; * `z.coerce.date()` delegates to `new Date(value)`, which interprets a * naive string in the **process's local timezone**. That is only correct * when the process runs in UTC (e.g. the deployed observability web app's - * server actions) and is wrong by the local UTC offset everywhere else — + * server actions) and is wrong by the local UTC offset everywhere else: * the CLI on a laptop, `workflow web --localUi`, tests. Normalizing naive * strings to an explicit `Z` designator makes parsing timezone-independent. * Values that already carry timezone information (a `Z` or `±hh:mm` @@ -145,8 +145,8 @@ export interface AnalyticsListRunsParams { /** * Bound the listing to runs active between `startTime` and `endTime` * (ISO 8601 timestamps). Both must be provided together. A bounded window - * lets the backend prune its scan — the ClickHouse-backed Vercel - * implementation is significantly faster with one. Requesting a window + * lets the backend prune its scan, so the ClickHouse-backed Vercel + * implementation is faster with one. Requesting a window * older than the plan's observability lookback fails with * `observability-upgrade-required`. */ diff --git a/packages/world/src/attributes.ts b/packages/world/src/attributes.ts index af9aef4496..b222b2aba9 100644 --- a/packages/world/src/attributes.ts +++ b/packages/world/src/attributes.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; /** * Reserved key prefix for system-managed attributes. User code may not set - * keys starting with `$` — those are blocked at validation time so the + * keys starting with `$`: those are blocked at validation time so the * namespace remains available for future system use. */ export const RESERVED_ATTRIBUTE_KEY_PREFIX = '$'; @@ -42,7 +42,7 @@ export type AttributeChange = z.infer; export const AttributeChangesSchema = z.array(AttributeChangeSchema); /** - * Result returned by `runs.experimentalSetAttributes` — the post-merge + * Result returned by `runs.experimentalSetAttributes`: the post-merge * snapshot of all attributes on the run. Provided so callers (notably * `setAttributes` and observability emitters) do not need a follow-up read. */ @@ -53,7 +53,7 @@ export interface ExperimentalSetAttributesResult { export interface AttributeValidationContext { /** * Existing attribute keys on the run, used to enforce the per-run - * cap accurately against the post-merge total — an incoming change + * cap accurately against the post-merge total: an incoming change * that updates an already-present key contributes zero net adds. * * If omitted, the cap check assumes every non-null change is a fresh @@ -162,7 +162,7 @@ export function validateAttributeValue( /** * Validate a batch of attribute changes. Throws `AttributeValidationError` * on the first violation found. Pass `existingKeys` (in `context`) so - * the per-run cap check can use the real post-merge total — without it + * the per-run cap check can use the real post-merge total. Without it * the check is conservative and may reject an update to an * already-present key when the run is at the cap. */ diff --git a/packages/world/src/env-config.ts b/packages/world/src/env-config.ts index c12c48caef..ff75ed8b2b 100644 --- a/packages/world/src/env-config.ts +++ b/packages/world/src/env-config.ts @@ -3,14 +3,14 @@ * variables. * * Several SDK constants (timeouts, retry counts, stream buffering, …) are - * useful to tune per-deployment — most notably to dial them down on a + * useful to tune per-deployment, most notably to dial them down on a * dedicated e2e deployment so the test suite exercises edge paths (reconnects, * batch splitting, retries) that otherwise only trigger after long durations * or large payloads. * * `envNumber` reads `process.env[name]` lazily (so tests and deployments can * override per invocation), clamps to an optional `[min, max]` range, and - * never throws — an env override is an escape hatch, not a hard requirement, + * never throws: an env override is an escape hatch, not a hard requirement, * so an invalid value falls back to the constant's compiled-in default. A * misconfigured value warns once per process so the mistake is observable * without spamming logs. @@ -78,8 +78,8 @@ export function envNumber( * Matches the convention the runtime flags already use (`WORKFLOW_TURBO`, * `WORKFLOW_SLOT_IDENTITY`, …): unset or empty takes `fallback`, and the only * values that force a side are `0` / `false` and `1` / `true` - * (case-insensitive). Anything else falls back rather than throwing — a flag is - * an escape hatch, not a hard requirement — and warns once per process. + * (case-insensitive). Anything else falls back rather than throwing (a flag is + * an escape hatch, not a hard requirement) and warns once per process. */ export function envFlag( name: string, @@ -112,7 +112,7 @@ export function getMaxEventsPerRun(): number { } /** - * Reset the warn-once cache. Test-only — exported so unit tests can exercise + * Reset the warn-once cache. Test-only, exported so unit tests can exercise * the warning path repeatedly without sharing state across cases. * * @internal diff --git a/packages/world/src/events.ts b/packages/world/src/events.ts index 8d1cfd0ee2..4c5db40f02 100644 --- a/packages/world/src/events.ts +++ b/packages/world/src/events.ts @@ -114,7 +114,7 @@ export function isTerminalStepEventType( * lazily, `attr_set` is written on every attribute write, and `run_created` * precedes every replay. The terminal run types are absent for a different * reason: recording a class requires a consumer to take an event of it, and no - * consumer takes `run_completed` / `run_failed` / `run_cancelled` — the runtime + * consumer takes `run_completed` / `run_failed` / `run_cancelled`: the runtime * exits before replaying the body once the log holds one, so they never reach a * consumer at all. An entry for them could never match. */ @@ -201,8 +201,8 @@ export function isWaitEventType(eventType: string): eventType is WaitEventType { * whether it is walking positions (count it) or reconstructing what happened * (skip it). The two replay engines and the observability trace builder each * make that decision independently, and the one thing they must agree on is - * that a noop's `createdAt` — the sealer's wall clock, which can postdate - * every real event around it — never becomes a time the run observed. + * that a noop's `createdAt`, the sealer's wall clock, can postdate every real + * event around it but never becomes a time the run observed. */ export function isSealedNoopEvent( event: Pick | { eventType: string } @@ -253,7 +253,7 @@ export type EventDataPayloadField = /** * Fields within eventData that hold ref/payload data per event type. - * When resolveData is 'none', only these fields are stripped — all other + * When resolveData is 'none', only these fields are stripped, and all other * metadata (stepName, workflowName, etc.) is preserved. */ export const EVENT_DATA_REF_FIELDS = Object.fromEntries( @@ -313,7 +313,7 @@ export function stripEventDataRefs( // TODO: Event data on all specific event schemas can actually be undefined, // as the world may omit eventData when resolveData is set to 'none'. // Changing the type here will mainly improve type safety for o11y consumers. -// Note: specVersion is optional for backwards compatibility with legacy data in storage, +// Note: specVersion is optional for backward compatibility with legacy data in storage, // but is always sent by the runtime on new events. export const BaseEventSchema = z.object({ eventType: EventTypeSchema, @@ -353,7 +353,7 @@ const stepLatencyTelemetryFields = { rsfs: z.number().optional(), // Synchronous workflow-function replay duration of only the FINAL replay // pass within the rsfs window (the pass that scheduled the first step), - // excluding awaited network I/O — not accumulated across earlier + // excluding awaited network I/O. Not accumulated across earlier // pre-first-step passes, so it is not "the replay portion of rsfs". Only // present alongside rsfs, and only for the run's first step. finalSchedulingReplay: z.number().optional(), @@ -436,7 +436,7 @@ const StepStartedEventSchema = BaseEventSchema.extend({ // handler is executing this step's body inline. Stamped on the lazy // step_started (and re-stamped on an owner-recovery bare start) so // that a wake replay can tell "this attempt is in flight in a live - // invocation" apart from "this attempt died with its process" — the + // invocation" apart from "this attempt died with its process": the // owner's queue message doubles as the liveness lease (a crash means // the queue redelivers that same messageId, which is allowed to // re-execute). Ownership derives from the step's LATEST step_started: @@ -521,7 +521,7 @@ const HookConflictEventSchema = BaseEventSchema.extend({ * Sealed-log filler event (specVersion >= 7). Written ONLY by the World's * backend when it seals a slot whose writer allocated the position and died * before committing (see `SPEC_VERSION_SUPPORTS_SEALED_LOG`). It occupies its - * slot — so density arithmetic and cursors count it — but carries no workflow + * slot, so density arithmetic and cursors count it, but carries no workflow * meaning: replay steps over it without delivering it to any consumer and * without advancing the deterministic clock. NOT user-creatable, and absent * from `CreateEventSchema` for that reason. @@ -600,7 +600,7 @@ const RunCreatedEventSchema = BaseEventSchema.extend({ * The run's X25519 public key (base64), stamped by SDKs that support * sealed (`encp`) envelopes. Persisted onto the run entity so that * cross-run writers can seal payloads to this run without holding its - * symmetric key. Not secret — see `WorkflowRunBaseSchema`. + * symmetric key. Not secret. See `WorkflowRunBaseSchema`. */ encryptionPublicKey: z.string().optional(), }), @@ -734,7 +734,7 @@ const AllEventsSchema = z.discriminatedUnion('eventType', [ ]); // Server response includes runId, eventId, and createdAt -// specVersion is optional in database for backwards compatibility +// specVersion is optional in database for backward compatibility export const EventSchema = AllEventsSchema.and( z.object({ runId: z.string(), @@ -828,7 +828,7 @@ export interface CreateEventParams { resumePayloadDigest?: string; /** * Marks a `step_created` create as the queue consumer's re-ensure of a - * resilient step dispatch (a step message carrying `stepInput` — see + * resilient step dispatch (a step message carrying `stepInput`, see * `WorkflowInvokePayload.stepInput`): the producer's direct write was * parallelized with the queue publish and may have failed. Only meaningful * for `step_created`. @@ -840,9 +840,9 @@ export interface CreateEventParams { * this flag to narrow it: refuse the re-ensure (world-vercel surfaces the * backend's 410 as `RunExpiredError`, which the consumer treats as "nothing * left to execute" and acks the message) when it has recorded a refusal for - * this correlation id and no step entity exists. Best-effort by nature — a + * this correlation id and no step entity exists. Best-effort by nature (a * marker written at refusal time cannot be ordered before the redelivery it - * is meant to stop — so it hardens, and does not close, the window. Worlds + * is meant to stop), so it hardens, and does not close, the window. Worlds * may ignore this flag entirely. */ viaStepDispatch?: boolean; @@ -857,16 +857,16 @@ export interface CreateEventParams { computeInstanceId?: string; /** * How many events the writer held in its loaded log when it decided to write - * this one — equivalently, the slot it expects to land on minus one. Sent by + * this one: equivalently, the slot it expects to land on minus one. Sent by * every replay-context create; omitted by callers with no loaded log to be * stale against. * * A World's slots are dense and 1-based (see `Storage.events`), so a count * and a position are the same number. An id that is not a position does not - * produce a count here — it throws, since the runtime cannot state a + * produce a count here: it throws, since the runtime cannot state a * snapshot for a log it cannot place. Such a World attempts * `eventCount + 1`, and on contention **bumps** to the next free slot and - * commits there anyway — a stale count never rejects a write. What it does + * commits there anyway: a stale count never rejects a write. What it does * instead is report: when the committed slot is higher than the one asked * for, the events occupying the skipped slots come back on the success * response in {@link EventResult.events} / `cursor` / `hasMore`, so the @@ -904,7 +904,7 @@ export interface CreateEventParams { /** * Inline-delta optimization (opt-in). When set, the World MAY return, * on the resulting {@link EventResult}, the first page of events written - * strictly after this cursor (via `events`/`cursor`/`hasMore`) — the + * strictly after this cursor (via `events`/`cursor`/`hasMore`): the * same page an `events.list({ cursor: sinceCursor, sortOrder: 'asc' })` * call would return immediately after this write. Outside turbo mode the * runtime sets this on every write it makes from the orchestrator loop @@ -912,7 +912,7 @@ export interface CreateEventParams { * carries the log forward and the loop reads it back for free: instead of * re-reading its own just-written events (and any events interleaved * in-band, such as `hook_received`), it consumes the authoritative delta - * the write already had to compute. Turbo mode does not set it — the + * the write already had to compute. Turbo mode does not set it: the * point there is to keep the first invocation's writes as cheap as * possible, and it has no loaded log to extend. * @@ -923,7 +923,7 @@ export interface CreateEventParams { * `hasMore: true` rather than paginating to exhaustion. The runtime * consumes that page and continues from its cursor, so it never reads the * returned prefix again. - * Returning these fields at all is OPTIONAL — a World that omits them is + * Returning these fields at all is OPTIONAL: a World that omits them is * fully supported; the runtime falls back to `events.list`. This * preserves the same divergence guarantees as the fetch path because the * delta is computed atomically against the same log the fetch would read. @@ -935,7 +935,7 @@ export interface CreateEventParams { * (`events`/`cursor`/`hasMore`) so the runtime can skip its initial * `events.list`. The turbo first invocation backgrounds `run_started` * purely as a write barrier and never reads that preload, so it sets this - * to tell the World to skip the wasted list+resolve — trimming the + * to tell the World to skip the wasted list+resolve, trimming the * `run_started` round-trip that the chained first `step_started` waits on. * A World that ignores it (or doesn't preload) remains fully correct: the * runtime falls back to `events.list` whenever it actually needs the log. @@ -947,7 +947,7 @@ export interface CreateEventParams { */ skipPreload?: true; /** - * Replay-log preload opt-in (advisory) — the `hook_received` dual of + * Replay-log preload opt-in (advisory): the `hook_received` dual of * {@link skipPreload}. Set only by the queue consumer's idempotent * `hook_received` re-ensure on a lazy hook resume (alongside * {@link resumeId} + {@link resumePayloadDigest}). A World MAY return the @@ -957,15 +957,15 @@ export interface CreateEventParams { * `run_started` write and the initial `events.list`. * * The runtime trusts a returned preload as replay input ONLY when all of - * the following hold — a World that cannot guarantee them should return - * its normal {@link EventResult} instead: + * the following hold (a World that cannot guarantee them should return + * its normal {@link EventResult} instead): * * - `events` is the COMPLETE log with `hasMore: false` (the runtime has no * cursor-continuation machinery on this path; a bounded page is * rejected). * - `cursor` is a valid non-null resume point matching `events.list` * semantics (present even on the final page). - * - `run` (with `run.startedAt`) and `maxEvents` are present — this + * - `run` (with `run.startedAt`) and `maxEvents` are present: this * response plays `run_started`'s role, including the event-ceiling * handshake. * - The log contains `run_created`, `run_started`, and the canonical @@ -976,7 +976,7 @@ export interface CreateEventParams { * omitted from the replay input. * * Anything less and the runtime observes that no usable replay preload - * came back and falls back to the existing `run_started` setup — a World + * came back and falls back to the existing `run_started` setup. A World * that ignores the param entirely remains fully correct. Only meaningful * for `hook_received`; ignored for other event types. Producer-side * `resumeHook()` must not set it. @@ -1006,7 +1006,7 @@ export type EventResult = { * step-creation data atomically *created* the step on this call (the * caller won the create-claim), as opposed to transitioning a step that * already existed. The owned-inline runtime path uses this as the - * exactly-once ownership signal — it runs the step body inline only when + * exactly-once ownership signal: it runs the step body inline only when * it created the step, so a concurrent handler that lost the create race * (and gets `EntityConflictError`/skipped) never double-executes. Absent * (undefined) on the legacy path and from older servers/worlds, which is @@ -1034,7 +1034,7 @@ export type EventResult = { * - On any response whose committed slot came out higher than the one * {@link CreateEventParams.eventCount} asked for: * the events occupying the slots that were skipped over, in slot - * order. This is the "report" half of bump-and-report — the write + * order. This is the "report" half of bump-and-report: the write * succeeded, and these are the events the writer had not seen when it * decided to make it. */ @@ -1060,7 +1060,7 @@ export type EventResult = { /** * One event of a batch write ({@link Storage.events.createBatch}), in request - * order — which is the order the events land in the run's log. + * order, which is the order the events land in the run's log. */ export interface BatchEventRequest { /** The event, same discriminated union the single `create` takes. */ @@ -1068,7 +1068,7 @@ export interface BatchEventRequest { /** * Client event time for this event. Under slot identity this is the source * of the durable event's `createdAt` (a slot id carries no time), so the - * timestamp a replay observes is the one the writer chose — set it to the + * timestamp a replay observes is the one the writer chose: set it to the * instant the event logically occurred. */ occurredAt?: Date; @@ -1106,7 +1106,7 @@ export interface CreateEventBatchParams { * - rejection → the status code and error code the single create would have * failed with, so callers reuse their single-path conflict handling per * event. A `409`/`conflict` means the entity was not in the prior state - * the event requires — most commonly because an earlier delivery already + * the event requires, most commonly because an earlier delivery already * applied the same event, but possibly because the entity reached a * DIFFERENT state (e.g. `step_completed` conflicting because the step * failed). A 409 alone does not prove the equivalent effect was applied; @@ -1118,10 +1118,10 @@ export interface CreateEventBatchParams { * can return a mix of 200s and 409s from one call. * * Retry semantics: a transport retry of a committed batch converges to - * per-event 409s ONLY for entity-conditioned events — creates and terminal + * per-event 409s ONLY for entity-conditioned events: creates and terminal * transitions. A standalone bare `step_started` or a `step_retrying` * re-patches its step on every attempt and does NOT converge, and - * `hook_received` appends a new row per attempt — `world-vercel` rejects + * `hook_received` appends a new row per attempt, so `world-vercel` rejects * `hook_received` in a batch outright and only auto-retries batches whose * every event is retry-convergent. * @@ -1178,7 +1178,7 @@ export interface ListEventsByCorrelationIdParams { * run, not globally: a slot-numbered run counts its own steps and waits, so * `step_…001` names the first step of *every* such run. Naming the run is * what makes the answer that run's events, and it is what makes the - * pagination cursor unambiguous — `(runId, eventId)` is a key where an + * pagination cursor unambiguous: `(runId, eventId)` is a key where an * event id alone is not. */ runId: string; diff --git a/packages/world/src/hooks.ts b/packages/world/src/hooks.ts index 2ca7481a0c..136330e793 100644 --- a/packages/world/src/hooks.ts +++ b/packages/world/src/hooks.ts @@ -5,14 +5,14 @@ import { SerializedDataSchema } from './serialization.js'; import type { PaginationOptions, ResolveData } from './shared.js'; /** - * Minimal, immutable slice of a hook's owning run needed to resume it — + * Minimal, immutable slice of a hook's owning run needed to resume it: * enough for encryption-key resolution, serialization/compression capability * selection, queue routing, and trace linking, without fetching the full run. * * Persisted on new hook records (workflow-server) and also returned inline by * `getByToken`, so a resume can skip the separate `runs.get`. Deliberately * excludes the run's mutable state (e.g. status), inputs/outputs, attributes, - * and any secret — only fields that are fixed at hook-creation time. + * and any secret: only fields that are fixed at hook-creation time. */ export const HookResumeContextSchema = z.object({ deploymentId: z.string(), @@ -32,7 +32,7 @@ export const HookResumeContextSchema = z.object({ // `@workflow/core` re-ensures the `hook_received` event from the queue // message's `hookInput` on replay, so `resumeHook()`'s parallel fast path is // safe to use. Because a run is pinned to its creating deployment, this - // marker is a reliable per-run attestation — unlike inferring support from a + // marker is a reliable per-run attestation, unlike inferring support from a // version compare against a predicted release cutoff. Absent on runs created // before the marker existed (fall back to the sequential path). hookResumeInputVersion: z.number().optional(), @@ -66,8 +66,8 @@ export const HOOK_RESUME_DEDUP_VERSION = 1; * * Response-only and transient: NEVER persisted on the hook entity and NEVER * part of {@link HookResumeContextSchema}. Recomputing it per response is what - * makes a server rollback or kill switch take effect immediately — a rolled-back - * or kill-switched server simply stops emitting it, dropping new resumes to the + * makes a server rollback or kill switch take effect immediately: a rolled-back + * or kill-switched server stops emitting it, dropping new resumes to the * sequential path with no stranded hooks. (Contrast with the per-run, persisted * `hookResumeInputVersion`, which attests the *consumer* and is fixed at run * creation.) @@ -101,7 +101,7 @@ export const HookSchema = z.object({ environment: z.string(), metadata: SerializedDataSchema.optional(), createdAt: z.coerce.date(), - // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading + // Optional in database for backward compatibility, defaults to 1 (legacy) when reading specVersion: z.number().optional(), isWebhook: z.boolean().optional(), isSystem: z.boolean().optional(), @@ -113,9 +113,9 @@ export const HookSchema = z.object({ // falls back to `runs.get`. resumeContext: HookResumeContextSchema.optional(), // Backend dedup capability, computed FRESH by the server on every by-token - // lookup — RESPONSE-ONLY and TRANSIENT. Never persisted on the hook entity + // lookup: RESPONSE-ONLY and TRANSIENT. Never persisted on the hook entity // and never part of `resumeContext`, so a server rollback or kill switch - // takes effect on the very next lookup (the field simply stops appearing). + // takes effect on the next lookup (the field stops appearing). // `resumeHook()` gates its parallel fast path on this being present and // current. Absent against an older/rolled-back server or when the kill switch // is active. diff --git a/packages/world/src/interfaces.ts b/packages/world/src/interfaces.ts index 92596b4bc4..d2b0b396b9 100644 --- a/packages/world/src/interfaces.ts +++ b/packages/world/src/interfaces.ts @@ -162,8 +162,8 @@ export interface Storage { * Long poll for a run to reach a terminal status (`completed`, `failed`, * or `cancelled`), returning the same entity `get` returns. * - * This is how a caller awaiting a run's outcome — `await run.returnValue` - * — avoids paying interval-poll quantization for it: instead of asking + * This is how a caller awaiting a run's outcome (`await run.returnValue`) + * avoids paying interval-poll quantization for it: instead of asking * "is it done yet?" every second, it asks once and the World answers the * moment the run finishes. * @@ -175,28 +175,28 @@ export interface Storage { * snapshot, whatever its status. A timeout is a normal return, never an * error: a run that is still running is a legitimate answer. * - **`timeoutMs` is an upper bound, not a lower one.** An - * implementation MAY resolve earlier with a non-terminal snapshot — - * e.g. `@workflow/world-vercel` does when the backend it is talking to - * has no long-poll route and it degrades to a plain read. Callers must - * therefore pace their own retries rather than assume one call per + * implementation MAY resolve earlier with a non-terminal snapshot. For + * example, `@workflow/world-vercel` does when the backend it is talking + * to has no long-poll route and it degrades to a plain read. Callers + * must therefore pace their own retries rather than assume one call per * `timeoutMs` (the runtime's `Run#pollReturnValue` keeps consecutive * non-terminal observations at least one poll interval apart). * - **Fail exactly like `get`.** A missing run throws * `WorkflowRunNotFoundError`; transport failures surface as they would * on any other read. * - * OPTIONAL. Omit it entirely when the World has no way to wait — a - * deterministic simulator, a store with no change notification — and the + * OPTIONAL. Omit it entirely when the World has no way to wait (a + * deterministic simulator, a store with no change notification) and the * runtime keeps interval-polling `get` on * `WORKFLOW_RETURN_VALUE_POLL_INTERVAL_MS`. There is nothing to declare * beyond the method's presence, and no behavior degrades when it is * absent: the fast path is strictly additive. * - * Implementations are free to satisfy this however their backend allows — - * a server-side long poll (`world-vercel` holds + * Implementations are free to satisfy this however their backend allows, + * such as a server-side long poll (`world-vercel` holds * `GET /v2/runs/:runId/status` open), a change notification * (`world-postgres` uses `LISTEN`/`NOTIFY`, `world-local` an in-process - * emitter), or a tight internal poll — as long as a lost or missing + * emitter), or a tight internal poll, as long as a lost or missing * notification degrades to returning a snapshot rather than hanging past * the budget. */ @@ -327,7 +327,7 @@ export interface Storage { /** * The event log, and the one part of this interface with a requirement the * types cannot express: **the World allocates every event id, and every id - * is a slot** — `evnt_` followed by the event's dense, 1-based position in + * is a slot**: `evnt_` followed by the event's dense, 1-based position in * its run's log, zero-padded to 26 characters. Use `slotToEventId()` to * format one. * @@ -345,7 +345,7 @@ export interface Storage { * taken. The World advances to the next free slot, commits there, and * returns the events occupying the slots it skipped over on the success * response (see {@link EventResult.events}). The writer learns its - * snapshot was stale without the write being rejected — which is why no + * snapshot was stale without the write being rejected, which is why no * World needs a precondition guard. * * Allocating at the commit is what makes a reader's log a *prefix* of the @@ -385,7 +385,7 @@ export interface Storage { ): Promise>; /** - * OPTIONAL batch write — append an ordered list of events to the run's + * OPTIONAL batch write: append an ordered list of events to the run's * log in one durable, atomic-per-attempt write, with a per-event outcome * for each (see {@link BatchEventItemResult}). The events land in request * order at consecutive slots. A concurrent writer may push the whole @@ -393,14 +393,14 @@ export interface Storage { * report accompanies the result, so a position-tracking caller compares * the committed slots against its expectation and reloads the log to * observe what landed in between. Its local view stays a strict PREFIX - * of the log — never a hole — so replaying it stays correct and the + * of the log (never a hole), so replaying it stays correct and the * next reload self-corrects. * * Presence of the method IS the capability declaration: the core runtime * batches only when the World implements it (and the run's spec version * supports slot identity); absent, every write takes the single-event * `create` path unchanged. A World must implement it with real - * atomicity per attempt — a lost race must leave nothing behind — or not + * atomicity per attempt (a lost race must leave nothing behind) or not * implement it at all. * * Size limits are the caller's problem: Worlds enforce their own caps @@ -413,11 +413,11 @@ export interface Storage { * `hook_created`, `hook_disposed`, `attr_set`, and more events targeting * one entity than a single write can express (the one legal combination * is `step_created` followed by `step_started` for the same step, which - * creates the step born-running — the step's input MUST ride the + * creates the step born-running: the step's input MUST ride the * `step_created`; a `step_started` carrying a payload rejects the whole * batch). Events outside this list keep their own ordering requirements: * a caller mixing a batch with single writes (hook or attribute events) - * owns those barriers itself — the core runtime simply never batches a + * owns those barriers itself: the core runtime never batches a * suspension that carries hook or attribute writes. */ createBatch?( @@ -461,7 +461,7 @@ export interface Storage { * Optional feature capabilities a World implementation declares so the core * runtime can enable optimizations that depend on backend behavior, instead * of inferring support from environment variables alone. Every capability - * defaults to "unsupported" when absent — runtime fast paths that rely on + * defaults to "unsupported" when absent: runtime fast paths that rely on * one must fail closed (keep their conservative behavior) unless the World * explicitly declares it. */ @@ -475,7 +475,7 @@ export interface WorldCapabilities { }; /** - * The World's queue supports `maxConcurrency`-limited consumption — in + * The World's queue supports `maxConcurrency`-limited consumption, in * particular the per-run flow topics consumed with `maxConcurrency: 1` * that `WORKFLOW_SEQUENTIAL_REPLAYS=1` uses to serialize a run's * orchestrator invocations. Worlds whose queue has no concurrency-limit @@ -485,14 +485,14 @@ export interface WorldCapabilities { * serialization also requires the build-time half (a flow trigger emitted * with `maxConcurrency: 1`), which a runtime process cannot verify today. * The core runtime therefore does not yet take any fast path from this - * capability alone — it exists so a future build-verified signal can be + * capability alone: it exists so a future build-verified signal can be * combined with it (and so Worlds document the contract explicitly). */ maxConcurrency?: boolean; /** * The World's `events.create` deduplicates concurrent `hook_received` writes - * that carry the same `(runId, resumeId)` — collapsing them onto a single + * that carry the same `(runId, resumeId)`, collapsing them onto a single * committed event and returning the canonical one to every caller. This is * the backend half of `resumeHook()`'s parallel fast path: the producer's * direct write and the queue consumer's re-ensure both write the same @@ -565,7 +565,7 @@ export interface World extends Queue, Streamer, Storage { specVersion: number; /** - * Feature capabilities this World implementation supports — see + * Feature capabilities this World implementation supports. See * {@link WorldCapabilities}. Absent (or absent members) means * "unsupported": runtime optimizations gated on a capability fail closed. */ @@ -600,7 +600,7 @@ export interface World extends Queue, Streamer, Storage { * "production" target or same git branch for "preview" deployments) as the * current deployment. * - * Not all World implementations support this — it is only implemented by + * Not all World implementations support this: it is only implemented by * world-vercel where deployment routing is meaningful. */ resolveLatestDeploymentId?(): Promise; @@ -615,18 +615,18 @@ export interface World extends Queue, Streamer, Storage { * * Two overloads: * - * - `getEncryptionKeyForRun(run)` — Preferred. Pass a `WorkflowRun` when + * - `getEncryptionKeyForRun(run)`: Preferred. Pass a `WorkflowRun` when * the run entity already exists. The World reads any context it needs * (e.g., `deploymentId`) directly from the run. * - * - `getEncryptionKeyForRun(runId, context?)` — Used when the run entity + * - `getEncryptionKeyForRun(runId, context?)`: Used when the run entity * is not locally available, such as `start()` before run creation or a * forwarded writable stream carrying its owning deployment context. The * `context` parameter carries opaque world-specific data (e.g., * `{ deploymentId }` for world-vercel) needed to resolve the correct key. * When `context` is omitted, the World assumes the current deployment. * - * When not implemented, encryption is disabled — data is stored unencrypted. + * When not implemented, encryption is disabled: data is stored unencrypted. */ getEncryptionKeyForRun?(run: WorkflowRun): Promise; getEncryptionKeyForRun?( @@ -649,8 +649,8 @@ export interface World extends Queue, Streamer, Storage { * @param options - The full options bag passed to `start()` (typed as * `Record` here to avoid a circular dependency with * `@workflow/core`). Worlds should read only the fields they - * recognise — for example, `@workflow/world-vercel` reads - * `options.region` to embed a region identifier. Unrecognised keys + * recognize. For example, `@workflow/world-vercel` reads + * `options.region` to embed a region identifier. Unrecognized keys * must be ignored. `start()` always passes an object (an empty one * when it was called with no options), but implementations should * tolerate `undefined` for direct callers. @@ -666,7 +666,7 @@ export interface World extends Queue, Streamer, Storage { * network call. Return `undefined` when the environment can't be determined. * * The value MUST match the attribution the backend will actually apply to - * this client's writes — for `world-vercel` that means keeping it in lockstep + * this client's writes: for `world-vercel` that means keeping it in lockstep * with the `x-vercel-environment` header (proxy path) and the OIDC token's * `environment` claim (in-deployment path). A value that merely looks * plausible is worse than `undefined`, because callers use it to detect @@ -675,7 +675,7 @@ export interface World extends Queue, Streamer, Storage { * `start()` stamps this into the queue message's `runInput` so the consuming * deployment can tell that a message it was handed was created against a * different environment than its own. Not all Worlds have an environment - * dimension — local dev and Postgres have exactly one tenant, so they omit + * dimension: local dev and Postgres have exactly one tenant, so they omit * this and the check is skipped. */ getEnvironment?(): string | undefined; @@ -683,7 +683,7 @@ export interface World extends Queue, Streamer, Storage { /** * World-specific display fields for a run. * - * Tooling — e.g. the `workflow inspect` CLI — calls this to enrich a + * Tooling (e.g. the `workflow inspect` CLI) calls this to enrich a * run's listing row / detail output with fields only the world can * derive: a region decoded from the run ID, placement read off the * run's `executionContext`, a shard, a billing tier, etc. Consumers @@ -691,11 +691,11 @@ export interface World extends Queue, Streamer, Storage { * hook is absent, no extra fields appear at all. * * The contract: - * - **Cheap and pure.** Called once per displayed run, so avoid I/O — - * prefer deriving fields from the entity you are given. - * - **Read only what you recognise.** The argument is the run entity + * - **Cheap and pure.** Called once per displayed run, so avoid I/O. + * Prefer deriving fields from the entity you are given. + * - **Read only what you recognize.** The argument is the run entity * as the caller has it (a full storage run, or a leaner analytics - * row) — typed loosely for the same reason as {@link createRunId}. + * row), typed loosely for the same reason as {@link createRunId}. * Tolerate missing fields. * - **Must not throw.** * - A `null` field value means "applicable but undeterminable" and is diff --git a/packages/world/src/node-http.ts b/packages/world/src/node-http.ts index f00083b48c..c4d47340d6 100644 --- a/packages/world/src/node-http.ts +++ b/packages/world/src/node-http.ts @@ -405,7 +405,7 @@ export function nodeHttpFetch( // spent waiting for one is the pool being busy, not the origin being slow: // undici starts its `headersTimeout` at the equivalent point (once the // request is written, in `writeH1`), and a deadline that counted the queue - // wait would expire deliveries the origin never saw — a redelivery storm + // wait would expire deliveries the origin never saw: a redelivery storm // sourced entirely from local concurrency. The pool wait is left unbounded // for the same reason it is in undici; a caller that needs a ceiling on the // whole call passes `signal`. diff --git a/packages/world/src/queue.ts b/packages/world/src/queue.ts index 3e11d9ce00..76a4924319 100644 --- a/packages/world/src/queue.ts +++ b/packages/world/src/queue.ts @@ -93,7 +93,7 @@ export type TraceCarrier = z.infer; /** * Run creation data carried through the queue for resilient start. - * Only present on the first queue delivery — re-enqueues omit this. + * Only present on the first queue delivery: re-enqueues omit this. * When the runtime processes the message, it passes this data to the * run_started event so the server can create the run if it doesn't exist yet. */ @@ -122,12 +122,12 @@ export const RunInputSchema = z.object({ * two can disagree: if the message is consumed by a deployment in a * DIFFERENT environment, that consumer's `run_started` re-creates the run * under ITS tenant, so the same client-minted `wrun_` id ends up existing - * in two environments — one stuck pending forever, the other executing. + * in two environments: one stuck pending forever, the other executing. * Carrying the creator's environment lets the consumer compare it against * its own and refuse the delivery instead of forking the run. * * Absent for worlds with no environment dimension (local, Postgres), and - * for older SDKs — consumers must treat it as advisory and skip the check + * for older SDKs. Consumers must treat it as advisory and skip the check * when it is missing. */ environment: z.string().optional(), @@ -139,11 +139,11 @@ export type RunInput = z.infer; * invocation. Present only when `resumeHook()` takes the parallel fast path: * the producer persists the `hook_received` event and publishes this invocation * concurrently. On receipt, a consumer that understands `hookInput` idempotently - * ensures the `hook_received` event exists — keyed by `resumeId` — before + * ensures the `hook_received` event exists (keyed by `resumeId`) before * replaying, so the two concurrent writes converge on exactly one event. * * The `payload` is the already-serialized (and possibly encrypted) resume - * payload — the identical bytes the producer also sent on the direct + * payload: the identical bytes the producer also sent on the direct * `events.create`, so both server receipts hash to the same digest under the * `(runId, resumeId)` constraint. */ @@ -151,19 +151,19 @@ export type RunInput = z.infer; * Resilient step dispatch data carried through the queue alongside a * step-execution message ({@link WorkflowInvokePayload.stepId}). Present when * the producer (the suspension handler dispatching a newly created step) - * parallelized the `step_created` event write with the queue publish — the + * parallelized the `step_created` event write with the queue publish: the * same shape as resilient start (`runInput`) and the resilient hook resume * (`hookInput`). * * When the producer's `step_created` write fails transiently (429 / 5xx / * transport), the step entity may not exist when this message is consumed. A * consumer that understands `stepInput` idempotently re-ensures the - * `step_created` event — keyed by the message's `stepId` (the step's - * correlation id, unique per `(runId, correlationId)`) — before executing, so + * `step_created` event, keyed by the message's `stepId` (the step's + * correlation id, unique per `(runId, correlationId)`), before executing, so * the producer's write and the consumer's re-ensure converge on exactly one * event. * - * The `input` is the already-serialized (and possibly encrypted) step input — + * The `input` is the already-serialized (and possibly encrypted) step input: * the identical bytes the producer also sent on the direct `events.create`. */ export const StepDispatchInputSchema = z.object({ @@ -188,8 +188,8 @@ export const HookResumeInputSchema = z.object({ hookId: z.string(), /** * The hook's token, written into the `hook_received` event's `eventData` so - * the consumer's re-ensured event carries the same token the producer would - * — replay validates `eventData.token` against the `createHook` token. + * the consumer's re-ensured event carries the same token the producer + * would. Replay validates `eventData.token` against the `createHook` token. */ token: z.string(), /** The serialized resume payload, reused verbatim from the direct write. */ @@ -199,7 +199,7 @@ export const HookResumeInputSchema = z.object({ * serialized bytes and forwarded verbatim on both the direct `events.create` * and this queue message. The consumer forwards it back to the server so both * writers of the same `resumeId` record an identical digest on the - * `(runId, resumeId)` constraint — required because the v4 payload ref is not + * `(runId, resumeId)` constraint, required because the v4 payload ref is not * content-stable server-side. */ payloadDigest: z.string(), @@ -207,9 +207,9 @@ export const HookResumeInputSchema = z.object({ * The deployment the run is pinned to, from the producer's resume context. * Lets the consumer detect a misrouted delivery with a cheap ambient * deployment-id comparison BEFORE its hoisted `hook_received` replay-preload - * write — only a detected mismatch pays for the authoritative run fetch and + * write: only a detected mismatch pays for the authoritative run fetch and * the deployment-affinity guard. Optional for queued-message compatibility: - * messages from older producers omit it and simply skip the pre-write + * messages from older producers omit it and skip the pre-write * check (the authoritative guard before replay still protects them). */ deploymentId: z.string().optional(), @@ -218,8 +218,8 @@ export type HookResumeInput = z.infer; /** * Wall-clock boundaries of a hook-triggered resume, carried on the queue - * message so the SDK can report end-to-end time-to-resume (TTR) — entry into - * `resumeHook()` through to the first line of the next durable step — and its + * message so the SDK can report end-to-end time-to-resume (TTR, entry into + * `resumeHook()` through to the first line of the next durable step) and its * non-overlapping phase breakdown, as span attributes on that step's * `step.execute` span. See `runtime/resume-latency.ts` in `@workflow/core`. * @@ -237,16 +237,16 @@ export type HookResumeInput = z.infer; * * Every field is advisory and the whole object is optional, in all three * directions that matter for a rolling deploy: a new producer's timing is - * ignored by an old consumer, a new consumer simply reports no TTR for an old + * ignored by an old consumer, a new consumer reports no TTR for an old * message, and workflow-server never reads it at all. * * `strategy` and `setupSource` are deliberately typed as plain strings rather * than enums: an unrecognized value from a newer producer must not fail the - * parse of the whole invocation payload (which would wedge the run) — it is - * only ever forwarded to a span attribute. + * parse of the whole invocation payload (which would wedge the run), since it + * is only ever forwarded to a span attribute. */ export const HookResumeTimingSchema = z.object({ - /** Epoch ms at entry into `resumeHook()` — the start of the TTR window. */ + /** Epoch ms at entry into `resumeHook()`: the start of the TTR window. */ resumeRequestedAtMs: z.number(), /** Epoch ms immediately before the queue publish was requested. */ queuePublishRequestedAtMs: z.number(), @@ -289,7 +289,7 @@ export const WorkflowInvokePayloadSchema = z.object({ /** Step ID for inline step execution in combined handler. If provided, the flow execution * will jump directly to execute the step with the given ID before doing an event replay. */ stepId: z.string().optional(), - /** Step name, sent alongside stepId to avoid loading the event log just to resolve the name. */ + /** Step name, sent alongside stepId to avoid loading the event log to resolve the name. */ stepName: z.string().optional(), /** Run creation data, only present on the first queue delivery from start() */ runInput: RunInputSchema.optional(), @@ -310,8 +310,8 @@ export const WorkflowInvokePayloadSchema = z.object({ * Hook-resume TTR timing. Present on both `resumeHook()` dispatch paths * (unlike `hookInput`, which only rides the parallel fast path), and * forwarded onto a dispatched step message when the resuming invocation - * hands the next durable step to another invocation. Purely observational — - * see {@link HookResumeTimingSchema}. + * hands the next durable step to another invocation. Purely observational. + * See {@link HookResumeTimingSchema}. * * `.catch(undefined)` because this field must never be able to fail the * parse of the invocation payload: a malformed value (a NaN boundary, a @@ -353,8 +353,8 @@ export const HealthCheckPayloadSchema = z.object({ * an optional `runId`, so a probe payload also satisfies * `WorkflowInvokePayloadSchema` (whose only required field is `runId`). With * the invoke member first, parsing a runId-bearing probe silently dropped - * `__healthCheck` and `correlationId`, and the runtime — which dispatches on - * `__healthCheck` before falling through to the invoke schema — reinterpreted + * `__healthCheck` and `correlationId`, and the runtime (which dispatches on + * `__healthCheck` before falling through to the invoke schema) reinterpreted * the probe as "replay this run". That made the queue handler POST * `run_started` for a run that doesn't exist yet (404), fail, and retry * forever, so the probe never answered and `start()` timed out. @@ -385,7 +385,7 @@ export interface QueueOptions { * `@vercel/queue` client uses to route the message; when omitted, the * region is resolved from the payload's tagged run ID, then from the * `VERCEL_REGION` environment variable, and finally defaults to `'iad1'` - * (the pre-regional-routing behaviour). + * (the pre-regional-routing behavior). */ region?: string; } @@ -423,7 +423,7 @@ export interface Queue { * `step_started` records the handling invocation's messageId, and only a * delivery of that same message may re-execute the step before the * ownership lease expires (crash recovery via queue redelivery). A World - * whose queue mints a fresh ID per delivery degrades gracefully — owner + * whose queue mints a fresh ID per delivery degrades gracefully: owner * redeliveries fall back to the delayed-backstop path instead of executing * immediately, adding recovery latency but never wedging or duplicating. */ diff --git a/packages/world/src/runs.ts b/packages/world/src/runs.ts index 3012dc31dc..3412e1939e 100644 --- a/packages/world/src/runs.ts +++ b/packages/world/src/runs.ts @@ -63,7 +63,7 @@ export const WorkflowRunBaseSchema = z.object({ * ``` */ workflowName: z.string(), - // Optional in database for backwards compatibility, defaults to 1 (legacy) when reading + // Optional in database for backward compatibility, defaults to 1 (legacy) when reading specVersion: z.number().optional(), executionContext: z.record(z.string(), z.any()).optional(), input: SerializedDataSchema.optional(), @@ -91,12 +91,12 @@ export const WorkflowRunBaseSchema = z.object({ * * Defaults to `{}` after schema parsing so consumers always receive * a record regardless of world. World adapters need not initialize - * the field on disk — `world-local` JSON files written before this + * the field on disk: `world-local` JSON files written before this * field existed, and rows from any other adapter that omits the * column, both read as `{}` after Zod parses them. * * EXPERIMENTAL (MVP): the full Workflow Attributes feature replaces - * the direct-mutation MVP path with an event-sourced model — see + * the direct-mutation MVP path with an event-sourced model. See * the attributes-mvp changelog entry. */ attributes: z.record(z.string(), z.string()).default({}), @@ -104,12 +104,12 @@ export const WorkflowRunBaseSchema = z.object({ * The run's X25519 public key, base64-encoded (~44 chars). * * Lets any party that can read this run seal a payload *to* it without - * being able to read the run's data — used for cross-run writes such as a - * hook resumption from another deployment, or a child workflow writing into - * a forwarded stream. The matching private scalar is never stored: it is - * re-derived on demand from the deployment's own key material, so this - * field is not secret and its presence does not weaken the run's - * confidentiality. + * being able to read the run's data. This is used for cross-run writes + * such as a hook resumption from another deployment, or a child workflow + * writing into a forwarded stream. The matching private scalar is never + * stored: it is re-derived on demand from the deployment's own key + * material, so this field is not secret and its presence does not weaken + * the run's confidentiality. * * Stamped at run creation by SDKs that support sealed (`encp`) envelopes. * **Presence is the writer-side gate**: a run only carries a public key if diff --git a/packages/world/src/slot-identity.ts b/packages/world/src/slot-identity.ts index 29fdb98616..59b87a9794 100644 --- a/packages/world/src/slot-identity.ts +++ b/packages/world/src/slot-identity.ts @@ -14,7 +14,7 @@ * * The one thing that does *not* survive: a slot id's leading characters are * zeros, so decoding it as a ULID timestamp yields the Unix epoch. Nothing may - * derive a time from an event id without first ruling out a slot id — see + * derive a time from an event id without first ruling out a slot id. See * {@link isSlotBody} and the guard in `ulidToDate`. */ @@ -27,7 +27,7 @@ export const EVENT_ID_BODY_LENGTH = 26; * This is the discriminator against a ULID: a ULID's first 10 characters * encode milliseconds since the epoch, and `ulid()` never mints a zero * timestamp. Requiring the same 10 characters to be `0` therefore separates - * the two schemes with no ambiguity, and caps a slot at 10^16 - 1 — far above + * the two schemes with no ambiguity, and caps a slot at 10^16 - 1, far above * any run's event count, and reduced further below to stay in safe-integer * range. */ @@ -78,7 +78,7 @@ export function isSlotEventId(eventId: string): boolean { /** * Formats a slot as a prefixed event id. * - * @throws if the slot is outside the representable range — a caller that + * @throws if the slot is outside the representable range: a caller that * overflows must fail loudly rather than mint an id that sorts wrong. */ export function slotToEventId(slot: number): string { @@ -106,7 +106,7 @@ export function eventIdToSlot(eventId: string): number | null { * Separate from {@link eventIdToSlot} because the two failures are different * problems. A caller that can act on either scheme asks the question and takes * `null` as an answer; a caller whose whole computation is positional (a - * precondition snapshot, a density audit) has no correct behaviour to fall back + * precondition snapshot, a density audit) has no correct behavior to fall back * on, and silently skipping the id would make it report a position it never * verified. Throwing names the id instead. * diff --git a/packages/world/src/spec-version.ts b/packages/world/src/spec-version.ts index a4263a9a31..062ca9fb3b 100644 --- a/packages/world/src/spec-version.ts +++ b/packages/world/src/spec-version.ts @@ -1,5 +1,5 @@ /** - * Spec version utilities for backwards compatibility. + * Spec version utilities for backward compatibility. * * Uses a branded type to ensure packages import the version constants * from @workflow/world rather than using arbitrary numbers. @@ -19,7 +19,7 @@ export type SpecVersion = number & { /** * Legacy spec version (pre-event-sourcing). Also used for runs without specVersion. - * This is the only true legacy version — specVersion 2+ all use the event-sourced model. + * This is the only true legacy version: specVersion 2+ all use the event-sourced model. */ export const SPEC_VERSION_LEGACY = 1 as SpecVersion; @@ -55,7 +55,7 @@ export const SPEC_VERSION_SUPPORTS_SLOT_IDENTITY = 6 as SpecVersion; /** * Runs at this spec version or later live in a "sealed log": their slot * positions are pre-assigned by a per-run sequencer on the World's backend, - * so concurrent writers never race each other for a position — and a position + * so concurrent writers never race each other for a position. A position * whose writer died is filled ("sealed") by the backend with a `noop` event. * What the version gates is the READER contract that makes that safe: a * reader at this version knows a `noop` occupies its slot and carries no @@ -121,8 +121,8 @@ export const SEALED_LOG_ENV_VAR = 'WORKFLOW_SEALED_LOG'; * The fallback is a real fallback, not a formality. Turning this off has to * leave a World the runtime still admits, which is why * `assertWorldSupportsRuntimeProtocol` floors at the slot-identity version - * rather than at {@link SPEC_VERSION_CURRENT} — a kill switch that made the - * runtime reject its own World would be no kill switch at all. + * rather than at {@link SPEC_VERSION_CURRENT} because a kill switch that made + * the runtime reject its own World would be no kill switch at all. * * Every World reads runs up to {@link SPEC_VERSION_MAX_SUPPORTED} whatever * this returns, so switching it off here does not make runs another process @@ -144,7 +144,7 @@ export function mintedSpecVersion( * can we still read?", and they come apart in exactly the release order a spec * bump follows: a reader that can already handle the next version raises this * ceiling first, and stamping follows only once the version is safe to mint - * everywhere. Sealed-log support is at that first stage — every build reads + * everywhere. Sealed-log support is at that first stage. Every build reads * spec 7 and skips `noop`, while {@link mintedSpecVersion} still has to be * turned on before anything creates a spec-7 run. */ diff --git a/packages/world/src/steps.ts b/packages/world/src/steps.ts index 1aa8cb31fc..53e3d1b77a 100644 --- a/packages/world/src/steps.ts +++ b/packages/world/src/steps.ts @@ -18,7 +18,7 @@ export const StepStatusSchema = z.enum([ * - specVersion >= 2: Uint8Array (binary devalue format) * - specVersion 1: any (legacy JSON format) */ -// TODO: implement a discriminated union here just like the run schema +// TODO: implement a discriminated union here to match the run schema export const StepSchema = z.object({ runId: z.string(), stepId: z.string(), diff --git a/packages/world/src/ulid.ts b/packages/world/src/ulid.ts index 3ba1df2c99..9bd09c1cfe 100644 --- a/packages/world/src/ulid.ts +++ b/packages/world/src/ulid.ts @@ -6,8 +6,8 @@ const UlidSchema = z.string().ulid(); /** * A workflow run ID: the `wrun_` prefix followed by a 26-char ULID (minted - * client-side in core's `start()`). Validates the exact shape — prefix plus a - * well-formed ULID — rather than a loose length bound, so callers can't smuggle + * client-side in core's `start()`). Validates the exact shape (prefix plus a + * well-formed ULID) rather than a loose length bound, so callers can't smuggle * arbitrary strings through APIs that persist a run ID verbatim. */ export const workflowRunIdSchema = z.templateLiteral(['wrun_', z.ulid()]); diff --git a/skills/internal-dev-workbench/SKILL.md b/skills/internal-dev-workbench/SKILL.md index dba635540c..22fdf429b3 100644 --- a/skills/internal-dev-workbench/SKILL.md +++ b/skills/internal-dev-workbench/SKILL.md @@ -3,7 +3,7 @@ name: internal-dev-workbench description: Spin up a portless + tmux dev session for the Workflow SDK that gives each git worktree isolated `..localhost` URLs for the Next.js workbench and the observability UI, plus a Claude statusline that surfaces those URLs. Use only when the user asks for a "portless dev session", a "tmux dev layout for workflow", "worktree-isolated dev URLs", or wants to wire workflow dev URLs into the Claude statusline. Do not activate for the generic "start the dev server" / "run pnpm dev" task. metadata: author: Pranay Prakash - version: '0.1' + version: '0.2' --- # internal-dev-workbench @@ -16,14 +16,14 @@ This is **opt-in contributor tooling**. The repo's standard dev path (`pnpm dev` - `tmux` installed - `portless` installed globally (`npm i -g portless` or via Homebrew). Verify with `portless --version`. -- Repo bootstrapped: `pnpm install && pnpm build`. The first run on a fresh worktree must complete both before any dev server can start (the workbench apps depend on built workspace packages — without `pnpm build` you get `MODULE_NOT_FOUND` for `workflow`). +- Repo bootstrapped: `pnpm install && pnpm build`. The first run on a fresh worktree must complete both before any dev server can start (the workbench apps depend on built workspace packages; without `pnpm build` you get `MODULE_NOT_FOUND` for `workflow`). - `WORKFLOW_PUBLIC_MANIFEST=1` is required on the dev server when running e2e tests against it (otherwise `/.well-known/workflow/v1/manifest.json` is gated). ## Layout -`main-vertical` — the dev server takes the left column; the right column stacks the observability UI on top of a scratchpad shell: +With `main-vertical`, the dev server takes the left column; the right column stacks the observability UI on top of a scratchpad shell: -``` +```text +----------------------+--------------------------+ | | PANE_OBS: workflow web | | | (observability UI | @@ -31,14 +31,14 @@ This is **opt-in contributor tooling**. The repo's standard dev path (`pnpm dev` | (Next.js dev) | workbench app) | | +--------------------------+ | | PANE_SH: zsh scratchpad | -| | (repo root — for build, | +| | (repo root, for build, | | | tests, e2e, git, etc.) | +----------------------+--------------------------+ ``` ## Setup -The session name **must** match the worktree's portless prefix — the basename of the current branch — so the statusline (and any other tooling that derives the prefix from the branch) can locate it. Always run `tmux ls` first to confirm there's no pre-existing session with that name; never kill an existing one. +The session name **must** match the worktree's portless prefix (the basename of the current branch) so the statusline and any other tooling that derives the prefix from the branch can locate it. Always run `tmux ls` first to confirm there's no pre-existing session with that name; never kill an existing one. Pane indices in tmux depend on `pane-base-index` (0 by default, 1 with the common dotfile override). To stay correct under either, capture each pane's ID at split time with `-P -F '#{pane_id}'` and use those IDs as targets: @@ -84,13 +84,13 @@ Once both servers are ready, `portless list` shows the routes. With `portless ru The skill ships a statusline helper at `skills/internal-dev-workbench/statusline.sh` that derives the worktree prefix from the current branch and emits a compact line: -``` +```text dev · obs · tmux attach -t ``` -The dev / obs labels (Nerd Font rocket / graph glyphs) are OSC 8 hyperlinks — clickable in iTerm2, Kitty, WezTerm, Terminal.app, Ghostty — styled bold + underlined + bright cyan so they read unambiguously as links. The tmux fragment (Nerd Font copy glyph) is bold bright green, signaling "copy this" rather than "click this". It's shown only when a session named exactly the worktree prefix exists, and it's printed as a full ready-to-paste `tmux attach -t ` invocation. The font must include Nerd Font glyphs for the icons to render correctly; without them you'll see substitution boxes but the layout still works. Each piece is independent — if portless has no `.turbopack.localhost` route, the dev fragment is omitted, and so on. With nothing to show, the script prints nothing and the statusline stays silent. +The dev / obs labels (Nerd Font rocket / graph glyphs) are OSC 8 hyperlinks that are clickable in iTerm2, Kitty, WezTerm, Terminal.app, and Ghostty. Bold, underlined, bright cyan styling identifies them as links. The tmux fragment (Nerd Font copy glyph) is bold bright green, signaling "copy this" rather than "click this". It's shown only when a session named exactly the worktree prefix exists, and it's printed as a full ready-to-paste `tmux attach -t ` invocation. The font must include Nerd Font glyphs for the icons to render correctly; without them you'll see substitution boxes, but the layout still works. Each piece is independent. If portless has no `.turbopack.localhost` route, the dev fragment is omitted, and so on. With nothing to show, the script prints nothing and the statusline stays silent. -Wire it into `~/.claude/settings.json` so it works across all sessions and worktrees. **Point the path at your primary checkout, not at a worktree** — worktrees get deleted, so any path like `~/github/vercel/workflow--/...` will break the day you remove that worktree: +Wire it into `~/.claude/settings.json` so it works across all sessions and worktrees. **Point the path at your primary checkout, not at a worktree**. Worktrees get deleted, so any path like `~/github/vercel/workflow--/...` will break when you remove that worktree: ```json { @@ -105,14 +105,14 @@ Adjust the prefix if your main checkout lives elsewhere. The script itself is wo Output rules: - Nothing to show (no matching portless route, no matching tmux session) → empty output. -- Each piece appears independently — start a server but no tmux session and you'll see just the dev/obs fragments; the reverse shows just the tmux fragment. +- Each piece appears independently. Start a server but no tmux session, and you'll see only the dev/obs fragments; the reverse shows only the tmux fragment. - No git context but routes exist → falls back to the first matching `turbopack`/`workflow-obs` route, no tmux indicator. If you already use a statusline and want to append the internal-dev-workbench info, run the helper and concatenate in your existing wrapper script instead of replacing `command` outright. ## Restarting after editing workflow files -The workflow manifest is built at dev-server startup. New workflows or steps added to `workbench/example/workflows/*.ts` (and their symlinks in other workbenches) **do not appear at runtime** — even with HMR — until the dev server restarts. +The workflow manifest is built at dev-server startup. New workflows or steps added to `workbench/example/workflows/*.ts` (and their symlinks in other workbenches) **do not appear at runtime**, even with HMR, until the dev server restarts. ```bash tmux send-keys -t "$PANE_DEV" C-c @@ -158,9 +158,9 @@ Portless removes routes when each child process exits (Ctrl+C the panes first if ## Troubleshooting -- **`MODULE_NOT_FOUND: 'workflow'`** in the dev pane — workspace packages haven't been built. Run `pnpm build` from the repo root, then restart the pane. -- **Observability UI shows no runs** — verify the obs pane was started from inside `workbench/nextjs-turbopack` (or whichever workbench you want to inspect). The CLI reads the local World from the **current working directory**. -- **react-router on `:5173` instead of the portless port** — happens when the obs pane uses `pnpm dev` from `packages/web`. Switch to the `pnpm workflow web --webPort $PORT` form above. -- **Source-map warning on startup** (`failed to read input source map ... packages/serde/dist/index.js.map`) — benign; doesn't block dev. -- **Stale workflow registration** after editing `99_e2e.ts` — restart the dev pane; HMR doesn't rebuild the manifest. -- **Statusline shows nothing** — confirm `portless list` has at least one matching route, the path in `settings.json` is absolute, and the script is executable (`chmod +x`). +- **`MODULE_NOT_FOUND: 'workflow'`** in the dev pane: Workspace packages haven't been built. Run `pnpm build` from the repo root, then restart the pane. +- **Observability UI shows no runs**: Verify the obs pane was started from inside `workbench/nextjs-turbopack` (or whichever workbench you want to inspect). The CLI reads the local World from the **current working directory**. +- **react-router on `:5173` instead of the portless port**: This happens when the obs pane uses `pnpm dev` from `packages/web`. Switch to the `pnpm workflow web --webPort $PORT` form above. +- **Source-map warning on startup** (`failed to read input source map ... packages/serde/dist/index.js.map`): This warning is benign and doesn't block development. +- **Stale workflow registration** after editing `99_e2e.ts`: Restart the dev pane; HMR doesn't rebuild the manifest. +- **Statusline shows nothing**: Confirm `portless list` has at least one matching route, the path in `settings.json` is absolute, and the script is executable (`chmod +x`). diff --git a/skills/migrating-to-workflow-sdk/SKILL.md b/skills/migrating-to-workflow-sdk/SKILL.md index bb2a7100f6..6a2ddd2b90 100644 --- a/skills/migrating-to-workflow-sdk/SKILL.md +++ b/skills/migrating-to-workflow-sdk/SKILL.md @@ -3,7 +3,7 @@ name: migrating-to-workflow-sdk description: Migrates Temporal, Inngest, Trigger.dev, and AWS Step Functions workflows to the Workflow SDK. Use when porting Activities, Workers, Signals, step.run(), step.waitForEvent(), Trigger.dev tasks / wait.forToken / triggerAndWait, ASL JSON state machines, Task/Choice/Wait/Parallel states, task tokens, or child workflows. metadata: author: Vercel Inc. - version: '0.2.0' + version: '0.3.0' --- # Migrating to the Workflow SDK @@ -78,10 +78,10 @@ Before drafting `## Migrated Code`, write the selected route keys in `## Migrati ## Shared references -- `references/shared-patterns.md` — reusable code templates for hooks, child workflows, idempotency, streaming, and rollback. -- `references/runtime-targets.md` — Managed vs custom `World` guidance. -- `references/resume-routing.md` — route-key selection, obligations, and exact `## Migration Plan` shape. -- `references/retries.md` — canonical retry mechanics: `stepFn.maxRetries`, `RetryableError({ retryAfter })`, `FatalError`. +- `references/shared-patterns.md`: Reusable code templates for hooks, child workflows, idempotency, streaming, and rollback. +- `references/runtime-targets.md`: Managed vs. custom `World` guidance. +- `references/resume-routing.md`: Route-key selection, obligations, and exact `## Migration Plan` shape. +- `references/retries.md`: Canonical retry mechanics, including `stepFn.maxRetries`, `RetryableError({ retryAfter })`, and `FatalError`. ## Required output shape @@ -144,7 +144,7 @@ For concrete passing code, load: ## Sample prompt -``` +```text Migrate this Inngest workflow to the Workflow SDK. It uses step.waitForEvent() with a timeout and step.realtime.publish(). ``` diff --git a/skills/migrating-to-workflow-sdk/references/aws-step-functions.md b/skills/migrating-to-workflow-sdk/references/aws-step-functions.md index d12a4a5809..b4dc45445f 100644 --- a/skills/migrating-to-workflow-sdk/references/aws-step-functions.md +++ b/skills/migrating-to-workflow-sdk/references/aws-step-functions.md @@ -20,7 +20,7 @@ - Separate Lambda function stubs that only served as Task state handlers - Task-token plumbing (`SendTaskSuccess`, `SendTaskFailure`, SQS queue setup) after converting to hooks/webhooks - IAM roles and CloudFormation/CDK resources for orchestrator-to-Lambda wiring -- `"Next"` / `"End"` transition logic — replaced by `await` and `return` +- `"Next"` / `"End"` transition logic, replaced by `await` and `return` ## Add diff --git a/skills/migrating-to-workflow-sdk/references/inngest.md b/skills/migrating-to-workflow-sdk/references/inngest.md index 49113ce331..3a360bb9b9 100644 --- a/skills/migrating-to-workflow-sdk/references/inngest.md +++ b/skills/migrating-to-workflow-sdk/references/inngest.md @@ -20,7 +20,7 @@ - `serve()` handler and function registration - Event-schema dispatch layer and event type definitions used only for routing - Inline `step.run()` closures after extracting them into named `"use step"` functions -- `step.waitForEvent()` match expressions — hook tokens replace event matching +- `step.waitForEvent()` match expressions; hook tokens replace event matching ## Add diff --git a/skills/migrating-to-workflow-sdk/references/resume-routing.md b/skills/migrating-to-workflow-sdk/references/resume-routing.md index abb32d673d..bbca1168e3 100644 --- a/skills/migrating-to-workflow-sdk/references/resume-routing.md +++ b/skills/migrating-to-workflow-sdk/references/resume-routing.md @@ -2,6 +2,8 @@ Load this file when the source pauses for Signals, `step.waitForEvent()`, or `.waitForTaskToken`. + + ## Quick route recipes | Situation | Route keys | Must emit | Must not emit | diff --git a/skills/migrating-to-workflow-sdk/references/retries.md b/skills/migrating-to-workflow-sdk/references/retries.md index e6d5ba608b..4c05ad70d8 100644 --- a/skills/migrating-to-workflow-sdk/references/retries.md +++ b/skills/migrating-to-workflow-sdk/references/retries.md @@ -4,7 +4,7 @@ Canonical reference for how the Workflow SDK models step-level retries. Load thi The SDK exposes exactly three knobs. Nothing else is configurable at the step boundary. -## 1. Attempt count — `stepFn.maxRetries = N` +## 1. Attempt count: `stepFn.maxRetries = N` Set retry count as a property on the step function. It is a count only; it does not configure backoff. @@ -20,10 +20,10 @@ chargePayment.maxRetries = 5; ``` - Default is implementation-defined; pick an explicit value if the source framework specified one. -- No options object is accepted. `stepFn.maxRetries = N` is the only supported syntax. +- The API does not accept an options object. `stepFn.maxRetries = N` is the only supported syntax. - `maxRetries` controls *attempts*, not delay between attempts. -## 2. Delay between attempts — `new RetryableError(msg, { retryAfter })` +## 2. Delay between attempts: `new RetryableError(msg, { retryAfter })` Push the next retry into the future by throwing `RetryableError` with a `retryAfter` value (milliseconds, duration string, or Date). Use this when the source framework specified exponential backoff, a fixed delay, or a custom backoff policy. @@ -45,7 +45,7 @@ callRateLimitedApi.maxRetries = 10; - There is no built-in exponential-backoff helper. If the source used one, compute the delay in userland and pass it as `retryAfter`. - Automatic VQS scheduling handles the default retry cadence when `retryAfter` is not provided. -## 3. Give up — `throw new FatalError(msg)` +## 3. Give up: `throw new FatalError(msg)` Abort retries immediately. Use this for non-recoverable errors such as validation failures or 4xx responses that will never succeed. @@ -89,6 +89,6 @@ async function validatePayload(input: unknown) { ## Links -- `docs/content/docs/foundations/errors-and-retries.mdx` — the canonical user-facing docs page. -- `packages/core/src/private.ts:12-17` — `StepFunction.maxRetries` type definition. -- `packages/errors/src/index.ts` — `RetryableError` and `FatalError` implementations. +- `docs/content/docs/foundations/errors-and-retries.mdx`: The canonical user-facing docs page. +- `packages/core/src/private.ts:12-17`: The `StepFunction.maxRetries` type definition. +- `packages/errors/src/index.ts`: The `RetryableError` and `FatalError` implementations. diff --git a/skills/migrating-to-workflow-sdk/references/temporal.md b/skills/migrating-to-workflow-sdk/references/temporal.md index 43ad194f73..9fd04cb1ea 100644 --- a/skills/migrating-to-workflow-sdk/references/temporal.md +++ b/skills/migrating-to-workflow-sdk/references/temporal.md @@ -13,7 +13,7 @@ Userland imports come from `workflow` and `workflow/api`. Never import from `@wo | Worker + Task Queue | remove from app code | | Signal | `createHook()` or `createWebhook()` | | Query | `getWritable({ namespace: 'status' })` on the workflow side; clients read via `getRun(runId).getReadable()` | -| Update | `createHook()` + `resumeHook()` (one-way; no return-value parity — stream the result via `getWritable()` or keep a separate HTTP read route) | +| Update | `createHook()` + `resumeHook()` (one-way; no return-value parity, so stream the result via `getWritable()` or keep a separate HTTP read route) | | Child Workflow | step-wrapped `start()` / `getRun()` | | Activity timeouts (`startToCloseTimeout`, `scheduleToCloseTimeout`, `heartbeatTimeout`) | enforce inside steps with `AbortSignal.timeout()`, or `Promise.race(step(), sleep(...))` from the workflow | | Activity retry policy (`maximumAttempts`, `initialInterval`, etc.) | `maxRetries` + `RetryableError` / `FatalError` classification | @@ -41,6 +41,6 @@ Userland imports come from `workflow` and `workflow/api`. Never import from `@wo - Idempotency keys on external writes via `getStepMetadata().stepId` - Rollback stack for compensation-heavy flows (replaces nested try/catch around each Activity) - `getWritable()` for progress streaming (replaces custom progress Activities) -- Step-wrapped `start()` / `getRun()` for child workflows — return serializable `runId` values to the workflow +- Step-wrapped `start()` / `getRun()` for child workflows; return serializable `runId` values to the workflow diff --git a/skills/migrating-to-workflow-sdk/references/trigger-dev.md b/skills/migrating-to-workflow-sdk/references/trigger-dev.md index 8b17131fa7..66b0ec0aeb 100644 --- a/skills/migrating-to-workflow-sdk/references/trigger-dev.md +++ b/skills/migrating-to-workflow-sdk/references/trigger-dev.md @@ -25,7 +25,7 @@ - `@trigger.dev/sdk` task registration and `client.defineJob` / `task()` wiring - `trigger.config.ts` project config, queue config, and machine config -- `schemaTask()` zod wrapper layer — move validation to the app boundary +- `schemaTask()` Zod wrapper layer; move validation to the app boundary - `tasks.trigger()` / `runs.retrieve()` imports inside task bodies in favor of `start()` / `getRun()` - `wait.forToken()` token-issuance plumbing after converting to hooks/webhooks - `AbortTaskRunError` imports after converting to `FatalError` @@ -45,7 +45,7 @@ - See `references/shared-patterns.md` -> `## Generated callback URL (manual response)` - Durable progress writes with `getWritable()` (replaces `metadata.stream()`) - Idempotency keys on external writes via `getStepMetadata().stepId` -- Step-level `RetryableError` + `maxRetries` (replaces `retry.onThrow` and `retry.fetch`). Retry count lives on the step via `myStep.maxRetries = N` (default 3). Control delay between attempts by throwing `new RetryableError(msg, { retryAfter: '5s' })` — there is no built-in exponential helper; compute the delay yourself based on `getStepMetadata().attempt` if you need one. +- Step-level `RetryableError` + `maxRetries` (replaces `retry.onThrow` and `retry.fetch`). Retry count lives on the step via `myStep.maxRetries = N` (default 3). Control delay between attempts by throwing `new RetryableError(msg, { retryAfter: '5s' })`. There is no built-in exponential helper; compute the delay based on `getStepMetadata().attempt` if needed. - `FatalError` at step boundaries (replaces `AbortTaskRunError`) - Step-wrapped `start()` / `getRun()` for child runs (replaces `task.triggerAndWait()` and `batch.triggerAndWait()`) - Parallel fan-out via `Promise.all()` over step-wrapped `start()` calls (replaces `batch.triggerAndWait()`) diff --git a/skills/workflow-init/SKILL.md b/skills/workflow-init/SKILL.md index 2b043a0858..4bf391b5f2 100644 --- a/skills/workflow-init/SKILL.md +++ b/skills/workflow-init/SKILL.md @@ -3,14 +3,14 @@ name: workflow-init description: Install and configure Vercel Workflow SDK before it exists in node_modules. Use when the user asks to "install workflow", "set up workflow", "add durable workflows", "configure workflow sdk", or "init workflow" for Next.js, Express, Hono, Fastify, NestJS, Nitro, Nuxt, Astro, SvelteKit, or Vite. metadata: author: Vercel Inc. - version: '1.3' + version: '1.4' --- # workflow-init Initial setup of Vercel Workflow SDK **before** `workflow` is installed. Fetch the official getting-started guide for the user's framework. -## Decision Flow +## Decision flow ### 0) Sanity check Read `package.json`. If `workflow` is already a dependency, tell the user to use `/workflow` instead (it reads versioned docs from `node_modules/workflow/docs/`). Only continue if workflow is missing. diff --git a/skills/workflow/SKILL.md b/skills/workflow/SKILL.md index d5a0e5207c..0fc321746c 100644 --- a/skills/workflow/SKILL.md +++ b/skills/workflow/SKILL.md @@ -3,10 +3,10 @@ name: workflow description: Creates durable, resumable workflows using Vercel's Workflow SDK. Use when building workflows that need to survive restarts, pause for external events, retry on failure, or coordinate multi-step operations over time. Triggers on mentions of "workflow", "durable functions", "resumable", "workflow sdk", "queue", "event", "push", "subscribe", or step-based orchestration. metadata: author: Vercel Inc. - version: '1.10' + version: '1.11' --- -## *CRITICAL*: Always Use Correct `workflow` Documentation +## *Critical*: Always use correct `workflow` documentation Your knowledge of `workflow` is outdated. @@ -26,7 +26,7 @@ Documentation structure in `node_modules/workflow/docs/`: - `api-reference/workflow-api/` - Client API (start.mdx, get-run.mdx, resume-hook.mdx, etc.) - `api-reference/workflow-runtime/` - Runtime API (get-world.mdx) and `world/` World SDK (storage.mdx, streams.mdx, queue.mdx) - `api-reference/workflow-observability/` - Hydration and name parsing utilities (hydrate-resource-io.mdx, parse-workflow-name.mdx, etc.) -- `ai/` - AI SDK integration docs +- `ai/`: AI SDK integration docs - `errors/` - Error code documentation Related packages also include bundled docs: @@ -37,12 +37,12 @@ Related packages also include bundled docs: **When in doubt, update to the latest version of the Workflow SDK.** -### Official Resources +### Official resources - **Website**: https://workflow-sdk.dev - **GitHub**: https://github.com/vercel/workflow -### Quick Reference +### Quick reference **Directives:** @@ -75,7 +75,7 @@ import { workflow } from "workflow/astro"; import { DurableAgent } from "@workflow/ai/agent"; ``` -## Prefer Step Functions to Avoid Sandbox Errors +## Prefer step functions to avoid sandbox errors `"use workflow"` functions run in a sandboxed VM. `"use step"` functions have **full Node.js access**. Put your logic in steps and use the workflow function purely for orchestration. @@ -107,7 +107,7 @@ export async function dataProcessingWorkflow(userId: string) { **Benefits:** Steps have automatic retry, results are persisted for replay, and no sandbox restrictions. -## Workflow Sandbox Limitations +## Workflow sandbox limitations When you need logic directly in a workflow function (not in a step), these restrictions apply: @@ -131,7 +131,7 @@ export async function myWorkflow() { **Note:** `DurableAgent` from `@workflow/ai` handles the fetch assignment automatically. -## DurableAgent — AI Agents in Workflows +## DurableAgent: AI agents in workflows Use `DurableAgent` to build AI agents that maintain state and survive interruptions. It handles the workflow sandbox automatically (no manual `globalThis.fetch` needed). @@ -175,20 +175,20 @@ export async function myAgentWorkflow(userMessage: string) { **Key points:** - `getWritable()` streams output to the workflow run's default stream - Tool `execute` functions that need Node.js/npm access should use `"use step"` -- Tool `execute` functions that use workflow primitives (`sleep()`, `createHook()`) should **NOT** use `"use step"` — they run at the workflow level +- Tool `execute` functions that use workflow primitives (`sleep()`, `createHook()`) should **NOT** use `"use step"` because they run at the workflow level - `maxSteps` limits the number of LLM calls (default is unlimited) - Multi-turn: pass `result.messages` plus new user messages to subsequent `agent.stream()` calls **For more details on `DurableAgent`, check the AI docs in `node_modules/@workflow/ai/docs/`.** -## Starting Workflows & Child Workflows +## Starting workflows & child workflows -Use `start()` to launch workflows from API routes. **`start()` cannot be called directly in workflow context** — wrap it in a step function. +Use `start()` to launch workflows from API routes. **`start()` cannot be called directly in workflow context**, so wrap it in a step function. ```typescript import { start } from "workflow/api"; -// From an API route — works directly +// From an API route; works directly export async function POST() { const run = await start(myWorkflow, [arg1, arg2]); return Response.json({ runId: run.runId }); @@ -198,7 +198,7 @@ export async function POST() { const run = await start(noArgWorkflow); ``` -**Starting child workflows from inside a workflow — must use a step:** +**Starting child workflows from inside a workflow requires a step:** ```typescript import { start } from "workflow/api"; @@ -217,11 +217,11 @@ export async function parentWorkflow() { } ``` -`start()` returns immediately — it doesn't wait for the workflow to complete. Use `run.returnValue` to await completion. +`start()` returns immediately and doesn't wait for the workflow to complete. Use `run.returnValue` to await completion. -## Hooks — Pause & Resume with External Events +## Hooks: pause & resume with external events -Hooks let workflows wait for external data. Use `createHook()` inside a workflow and `resumeHook()` from API routes. Deterministic tokens are for `createHook()` + `resumeHook()` (server-side) only. `createWebhook()` always generates random tokens — do not pass a `token` option to `createWebhook()`. +Hooks let workflows wait for external data. Use `createHook()` inside a workflow and `resumeHook()` from API routes. Deterministic tokens are for `createHook()` + `resumeHook()` (server-side) only. `createWebhook()` always generates random tokens, so do not pass a `token` option to `createWebhook()`. ### Single event @@ -242,7 +242,7 @@ export async function approvalWorkflow() { ### Multiple events (iterable hooks) -Hooks implement `AsyncIterable` — use `for await...of` to receive multiple events: +Hooks implement `AsyncIterable`. Use `for await...of` to receive multiple events: ```typescript import { createHook } from "workflow"; @@ -275,7 +275,7 @@ export async function POST(req: Request) { } ``` -## Error Handling +## Error handling Use `FatalError` for permanent failures (no retry), `RetryableError` for transient failures: @@ -298,7 +298,7 @@ All data passed to/from workflows and steps must be serializable. **Not supported:** Functions, Symbols, WeakMap/WeakSet. Pass data, not callbacks. -### Custom Class Serialization +### Custom class serialization Class instances **can** be serialized across workflow/step boundaries by implementing the `@workflow/serde` protocol. This is essential when a class has instance methods with `"use step"` or when you want to pass class instances between steps. @@ -346,7 +346,7 @@ export class Point { **When to avoid serde:** If a class is fundamentally inseparable from Node.js APIs (every method needs `fs`, `net`, etc.) and cannot meaningfully exist as a shell in the workflow sandbox, keep it entirely in step functions and pass plain data objects across boundaries instead. -### Validating Serde Compliance +### Validating serde compliance Use these tools to verify classes are correctly set up: @@ -395,7 +395,7 @@ async function streamData(chunk: string) { } ``` -### Namespaced Streams +### Namespaced streams Use `getWritable({ namespace: 'name' })` to create multiple independent streams for different types of data. This is useful for separating logs from primary output, different log levels, agent outputs, metrics, or any distinct data channels. Long-running workflows benefit from namespaced streams because you can replay only the important events (e.g., final results) while keeping verbose logs in a separate stream. @@ -438,7 +438,7 @@ async function emitAgentThought(thought: string) { async function emitAgentResult(result: string) { "use step"; - // Important results go to the default stream for easy replay + // Important results go to the default stream for replay const writer = getWritable().getWriter(); try { await writer.write({ type: "result", content: result }); @@ -493,7 +493,7 @@ export async function GET(request: Request) { } ``` -**Pro tip:** For very long-running sessions (50+ minutes), namespaced streams help manage replay performance. Put verbose/debug output in separate namespaces so you can replay just the important events quickly. +For long-running sessions (50+ minutes), namespaced streams help manage replay performance. Put verbose/debug output in separate namespaces so you can replay only the important events. ## Debugging @@ -526,17 +526,17 @@ npx workflow cancel --backend vercel --project --team --backend vercel --project --team --url npx workflow web --backend vercel --project --team --env preview --url -# Local run — prints the local web UI deep link +# Local run: prints the local web UI deep link npx workflow inspect run --url # Machine-readable: --url --json prints { "url": "..." } to stdout @@ -551,20 +551,20 @@ URL formats produced: - **Local:** `http://localhost:?resource=run&id=` (port defaults to `3456`; the link works while the `npx workflow web` server is running). -stdout contains **only** the URL (or the JSON object) — all other output goes to -stderr — so you can capture it directly, e.g. `URL=$(npx workflow web --backend vercel --url)`. +stdout contains **only** the URL (or the JSON object). All other output goes to +stderr, so you can capture it directly, for example, `URL=$(npx workflow web --backend vercel --url)`. **Debugging tips:** - Use `--json` (`-j`) on any command for machine-readable output -- Use `--web` to open the Vercel Observability dashboard in your browser, or `--url` to just print the deep link +- Use `--web` to open the Vercel Observability dashboard in your browser or `--url` to print the deep link - Use `--help` on any command for full usage details - Only import workflow APIs you actually use. Unused imports can cause 500 errors. -## Testing Workflows +## Testing workflows -Workflow SDK provides a Vitest plugin for testing workflows in-process — no running server required. +Workflow SDK provides a Vitest plugin for testing workflows in-process without a running server. -**Unit testing steps:** Steps are just functions; without the compiler, `"use step"` is a no-op. Test them directly: +**Unit testing steps:** Steps are functions; without the compiler, `"use step"` is a no-op. Test them directly: ```typescript import { describe, it, expect } from "vitest"; @@ -619,7 +619,7 @@ describe("approvalWorkflow", () => { }); ``` -**Testing webhooks:** Use `resumeWebhook()` with a `Request` object — no HTTP server needed: +**Testing webhooks:** Use `resumeWebhook()` with a `Request` object. No HTTP server is needed: ```typescript import { start, resumeWebhook } from "workflow/api"; @@ -634,17 +634,17 @@ await resumeWebhook(hook.token, new Request("https://example.com/webhook", { ``` **Key APIs:** -- `start()` — trigger a workflow -- `run.returnValue` — await workflow completion -- `waitForHook(run, { token? })` / `waitForSleep(run)` — wait for workflow to reach a pause point -- `resumeHook(token, data)` / `resumeWebhook(token, request)` — resume paused workflows -- `getRun(runId).wakeUp({ correlationIds })` — skip `sleep()` calls +- `start()`: Trigger a workflow +- `run.returnValue`: Await workflow completion +- `waitForHook(run, { token? })` / `waitForSleep(run)`: Wait for workflow to reach a pause point +- `resumeHook(token, data)` / `resumeWebhook(token, request)`: Resume paused workflows +- `getRun(runId).wakeUp({ correlationIds })`: Skip `sleep()` calls **Best practices:** - Keep unit tests (no plugin) and integration tests (`workflow()` plugin) in separate configs - Use deterministic hook tokens based on test data for easier resumption -- Set generous `testTimeout` — workflows may run longer than typical unit tests -- `vi.mock()` does **not** work in integration tests — step dependencies are bundled by esbuild +- Set generous `testTimeout` values because workflows may run longer than typical unit tests +- `vi.mock()` does **not** work in integration tests because step dependencies are bundled by esbuild ## Observability & World SDK @@ -657,12 +657,12 @@ import { hydrateResourceIO, observabilityRevivers, parseStepName, parseWorkflowN ``` **Key docs** (grep `node_modules/workflow/docs/` for full details): -- `api-reference/workflow-runtime/world/storage.mdx` — events, runs, steps, hooks (events are source of truth; others are materialized views) -- `api-reference/workflow-observability/` — hydration and name parsing +- `api-reference/workflow-runtime/world/storage.mdx`: Events, runs, steps, and hooks (events are the source of truth; others are materialized views) +- `api-reference/workflow-observability/`: Hydration and name parsing -### World SDK Method Signatures +### World SDK method signatures -⚠️ Pagination is nested: `{ pagination: { cursor } }` — NOT `{ cursor }` directly. +⚠️ Pagination is nested: `{ pagination: { cursor } }`, NOT `{ cursor }` directly. ```typescript const world = await getWorld(); @@ -673,7 +673,7 @@ const run = await world.runs.get(runId, { resolveData: 'all' | 'none' }); // Cancel via event creation (no cancel() method on runs) await world.events.create(runId, { eventType: 'run_cancelled' }); -// Steps — runId is top-level, NOT inside pagination +// Steps: runId is top-level, NOT inside pagination const { data, cursor } = await world.steps.list({ runId, pagination: { cursor }, resolveData: 'all' | 'none' }); const step = await world.steps.get(runId, stepId, { resolveData: 'all' | 'none' }); @@ -694,37 +694,37 @@ const streamNames = await world.streams.list(runId); const chunks = await world.streams.getChunks(runId, name, { limit, cursor }); const info = await world.streams.getInfo(runId, name); -// Queue (methods live directly on world — internal SDK infrastructure) +// Queue (methods live directly on world as internal SDK infrastructure) await world.queue(queueName, payload, opts); const deploymentId = await world.getDeploymentId(); ``` -### `resolveData` Parameter +### `resolveData` parameter Controls whether input/output data is **included** in the response. Accepts `'all'` (default) or `'none'`. **IMPORTANT**: Even with `'all'`, data is still devalue-serialized. You MUST call `hydrateResourceIO()` to get usable JS values. - **Use `'none'`** for status polling, progress dashboards, run listings -- **Use `'all'`** (or omit) when you need to inspect actual step I/O data — then **always hydrate** +- **Use `'all'`** (or omit) when you need to inspect actual step I/O data, then **always hydrate** ```typescript -// Lightweight status check — no I/O loaded +// Lightweight status check with no I/O loaded const run = await world.runs.get(runId, { resolveData: 'none' }); console.log(run.status); // 'running' | 'completed' | 'failed' | 'cancelled' -// Full inspection — resolveData includes data, hydrateResourceIO deserializes it +// Full inspection: resolveData includes data, hydrateResourceIO deserializes it const step = await world.steps.get(runId, stepId); // defaults to 'all' const hydrated = hydrateResourceIO(step, observabilityRevivers); ``` > **Common mistake**: Checking `step.input !== undefined` after `resolveData: 'all'` and assuming -> the data is ready to use. The data exists but is serialized — always hydrate first. +> the data is ready to use. The data exists but is serialized, so always hydrate first. -### Data Hydration (Devalue Format) +### Data hydration (devalue format) Step I/O is serialized via [devalue](https://github.com/Rich-Harris/devalue) with a 4-byte format prefix (`devl`). Without hydration, `input`/`output` are Uint8Array-like objects with numeric keys: -`{"0":100,"1":101,"2":118,"3":108,...}` — these are NOT usable values. +`{"0":100,"1":101,"2":118,"3":108,...}` contains values that are NOT usable without hydration. **Always hydrate before using I/O data:** @@ -739,7 +739,7 @@ const hydrated = steps.map(s => hydrateResourceIO(s, observabilityRevivers)); `hydrateResourceIO` works on both `Step` and `WorkflowRun` objects. For encrypted workflows, use `getEncryptionKeyForRun()` + `hydrateResourceIOWithKey()`. -### Name Parsing +### Name parsing `parseWorkflowName()`, `parseStepName()`, and `parseClassName()` return `{ shortName: string, moduleSpecifier: string } | null`. Always use optional chaining: @@ -750,7 +750,7 @@ const parsed = parseWorkflowName("workflow//./src/workflows/order//processOrder" // ⚠️ Returns null if format doesn't match ``` -### Event Types +### Event types Events are the append-only source of truth. Runs/Steps/Hooks are materialized views. @@ -761,7 +761,7 @@ Events are the append-only source of truth. Runs/Steps/Hooks are materialized vi | Hook | `hook_created`, `hook_received`, `hook_disposed`, `hook_conflict` | | Wait | `wait_created`, `wait_completed` | -## Error Handling Patterns +## Error handling patterns Three error strategies for different failure modes: @@ -774,10 +774,10 @@ Three error strategies for different failure modes: ```typescript import { FatalError, RetryableError } from "workflow"; -// Permanent failure — workflow terminates +// Permanent failure, so the workflow terminates throw new FatalError("Invalid input: missing required field"); -// Transient failure — will retry +// Transient failure, so it will retry throw new RetryableError("API rate limited", { retryAfter: "5m" }); // Mixed criticality parallel execution diff --git a/tarballs/README.md b/tarballs/README.md index c3ce2eb90c..c56382e0a0 100644 --- a/tarballs/README.md +++ b/tarballs/README.md @@ -10,7 +10,7 @@ For each public package, `scripts/pack.ts`: It also generates a `public/index.html` that lists every published package alongside a copyable `pnpm i …` command, so the bare deployment URL is itself useful when shared. -The deployment serves the resulting `*.tgz` files at the root of the project URL — e.g. `https://.vercel.sh/workflow.tgz`. +The deployment serves the resulting `*.tgz` files at the root of the project URL. For example, `https://.vercel.sh/workflow.tgz`. This is used for pre-release testing of `vercel/workflow` PRs by installing tarballs directly: diff --git a/workbench/python/README.md b/workbench/python/README.md index d7ba8975ad..e41ad5447b 100644 --- a/workbench/python/README.md +++ b/workbench/python/README.md @@ -2,19 +2,19 @@ A Python implementation of the workflow app side, so `packages/core/e2e/e2e.test.ts` has something other than JavaScript to run against. The test driver stays in -TypeScript — one source of truth for what the protocol is — and this app is the +TypeScript as the source of truth for the protocol, and this app is the second implementation it drives. Built on [vercel-py](https://github.com/vercel/vercel-py), pinned by commit in `pyproject.toml`: one `[tool.uv.sources]` entry for the umbrella `vercel` package. The only code this app imports is `vercel.workflow`, which since -vercel-py#299 is its own `vercel-workflow` distribution — but depending on that +vercel-py#299 is its own `vercel-workflow` distribution. However, depending on that name directly puts `@vercel/python` in workers mode and silently unsubscribes the deployment from the queue, so the umbrella stays until the builder learns -the split — the note above the dependency has the detail. Everything but +the split. The note above the dependency has the detail. Everything but `vercel` itself therefore resolves from PyPI, the runtime included; the note above the source entry says how to check that what you got -matches the rev you asked for. Bump the rev deliberately, and re-run the suite +matches the rev you asked for. Bump the rev deliberately, and rerun the suite when you do. ## Running it @@ -29,7 +29,7 @@ Both of those guard the lock against your personal `~/.config/uv/uv.toml` (`--locked` refuses to rewrite it, and `pnpm dev` passes `--no-config` to `uv run`, which re-locks on startup otherwise). If you find an `[options]` block at the top of `uv.lock`, something ran uv without one of them and CI will reject -the result — the note above `[tool.uv.sources]` in `pyproject.toml` has the rest, +the result. The note above `[tool.uv.sources]` in `pyproject.toml` has the rest, including how to bump the pin. Then, from the repo root: @@ -56,23 +56,23 @@ Both sides are wired: the `workbench-python-workflow` project is rooted at with no Python meaning). What makes the build work: - `vercel.json` declares `pyproject.toml` as the build src. That is what puts - `@vercel/python` in "declared-only" mode — without it the `[tool.vercel]` - keys are ignored, because the builder only attaches workflows to recognised + `@vercel/python` in "declared-only" mode. Without it, the `[tool.vercel]` + keys are ignored because the builder only attaches workflows to recognized Python frameworks or to declared builds, and a bare ASGI app is neither. - That `use` carries an **explicit builder version**, which the other workbench projects do not need. An unpinned `use` resolves to whatever `@vercel/python` ships inside the Vercel CLI the build platform happens to run, and that lags - npm — builds were picking up 6.53.0 (CLI 58.1.0) well after 6.55.x was out. + npm. Builds were picking up 6.53.0 (CLI 58.1.0) well after 6.55.x was out. 6.53.0 predates queue mode, so the builder silently fell back to "workers" mode and pointed the workflow function straight at `app:registry`. That is a `Workflows` object, not an ASGI callable, so every queue delivery 500'd with - `Could not determine the application interface for 'app:registry'` — the run + `Could not determine the application interface for 'app:registry'`. The run was created, the message was delivered, and nothing ever executed. Everything below about consumer groups depends on queue mode, so the pin is what makes it reachable at all. Bump it deliberately. - `[tool.vercel] entrypoint = "app:app"` builds the web function. On Vercel it serves exactly one useful route, `manifest.json`. Runs arrive over the queue, - so the hand-written `POST /flow` adapter is dead code there — it is how the + so the hand-written `POST /flow` adapter is dead code there. It is how the *local* world delivers, and only that. - `[[tool.vercel.workflows]] entrypoint = "app:registry"` builds the workflow function. At build time the builder imports `app`, reads @@ -100,8 +100,8 @@ with no Python meaning). What makes the build work: Reaching queue mode at all has a second, blunter condition: the builder looks for the literal name `vercel` in `[project].dependencies` and then reads `importlib.metadata.version("vercel")`, requiring >= 0.8.0. Both halves name - the umbrella distribution, so declaring only `vercel-workflow` — the package - that actually holds the runtime since vercel-py#299 — drops the build back to + the umbrella distribution, so declaring only `vercel-workflow` (the package + that actually holds the runtime since vercel-py#299) drops the build back to workers mode. That failure is quieter than the 6.53.0 one above: the trigger is written for consumer `app_registry` on `__wkf_*` rather than `default` on `__wkf_workflow_*`, so deliveries do not 500, they never arrive. Every run @@ -128,18 +128,18 @@ The *driver* clears it with the workbench project's own identity, so `workbench-python-workflow` had to be listed in **that** project's Trusted Sources alongside the JS workbench projects. It now is: driver writes land, and runs are created. Before that, every write failed with `v4 createEvent: response -missing required x-wf-* headers` and a `SyntaxError: Unexpected token '<'` — the -HTML SSO page, not a workflow-server response. +missing required x-wf-* headers` and a `SyntaxError: Unexpected token '<'`. +The response was an HTML SSO page, not a workflow-server response. The *app* clears it with a header, and until `vercel-py#278` vercel-py did not send that header. `getHttpConfig` (`packages/world-vercel/src/utils.ts:366`) sets both `Authorization: Bearer ` **and** `x-vercel-trusted-oidc-idp-token: `; vercel-py's `_cbor_request` set only -the first. Trusted Sources reads the second, so the very first call the workflow -handler made — `runs_get`, before any replay — came back `302` to the SSO page, +the first. Trusted Sources reads the second, so the first call the workflow +handler made (`runs_get`, before any replay) came back `302` to the SSO page, and every delivery 500'd: -``` +```text HTTP Request: GET https://e2e.vercel-workflow.com/api/v2/runs/wrun_... "HTTP/1.1 302 Found" File ".../worlds/vercel.py", line 219, in _cbor_request result = resp.json() @@ -149,7 +149,7 @@ cbor2.CBORDecodeEOF: premature end of stream That the redirect surfaced as a CBOR decode error rather than as "you were redirected to a login page" was a second bug in the same function: it parsed the -body before checking the status. Both are fixed in the pinned rev — the auth +body before checking the status. Both are fixed in the pinned rev. The auth headers now split proxy from direct the way `getHttpConfig` does, and a non-2xx derives its error from the status before the body is touched. @@ -162,8 +162,8 @@ A `trustedSources.projects` entry would additionally let a locally pulled `VERCEL_OIDC_TOKEN` in; the other workbench projects have one, this project does not need it for CI. -The divergences that bite are catalogued in vercel-py's queue notes — region -routing, no delivery cap, and a one-second floor on immediate re-enqueues. +The divergences that cause failures are cataloged in vercel-py's queue notes: +region routing, no delivery cap, and a one-second floor on immediate re-enqueues. The one that used to block this lane outright was the queue transport. Once queue mode was reached, every delivery 500'd on `UnicodeDecodeError: 'utf-8' @@ -180,29 +180,29 @@ the current protocol the one lane not testing it. vercel-py fixed it in two parts, spanning two packages: `vercel-py#265` attaches a CBOR-with-JSON-fallback transport to the workflow topic, and `vercel-py#266` taught `Topic` / `TopicPattern` to carry a codec in the first place. That is why -the pin briefly needed a second `[tool.uv.sources]` entry — `vercel-queue` 0.8.0 +the pin briefly needed a second `[tool.uv.sources]` entry. `vercel-queue` 0.8.0 carries #266, so PyPI is enough again. Attaching it to the *subscription* rather than to a -client is what makes it work when deployed — the function that receives a push +client is what makes it work when deployed. The function that receives a push delivery is the builder-generated `_vc_queue_handlers/.py`, whose `vercel.queue.asgi_app()` constructs its own `QueueClient` with no arguments, so a transport set on the world's client would never have been consulted. Confirmed fixed against a real deployment: on `36d5763a` the invoke payload decodes and the workflow handler runs, which is how the protection-header gap -above became visible at all — it is the next call the handler makes. +above became visible at all. It is the next call the handler makes. -`world-local` was never affected — Python is producer and consumer there, so +`world-local` was never affected. Python is producer and consumer there, so JSON on both ends is self-consistent. Behind that sat one more, and it is why `cryptography` has to be installed. On Vercel the run input is written by the *driver*, and `@workflow/world-vercel` encrypts every payload it -can resolve a per-run key for — which on a deployment is all of them, with no +can resolve a per-run key for, which on a deployment is all of them, with no opt-out env var or config flag. Each run therefore arrived as an `encr` envelope and died at the first hydration of its input, before any fixture code ran: -``` +```text SerializationError: the input of run wrun_... uses the 'encr' format, which this SDK cannot read ``` @@ -223,24 +223,24 @@ two sides interoperate without Python ever encrypting anything. `encp`, the X25519 sealed-box format one run uses to write to another, became readable in `vercel-py#297`; nothing in the suite produces one either way. -Again `world-local` is exempt — no deployment key, nothing to derive from, so +Again, `world-local` is exempt. It has no deployment key to derive from, so the local lane could never have caught this. It is the clearest case so far of why the Vercel lane earns its keep. The last one arrived from this repo rather than being found here. #3389 gave -`world-vercel` `specVersion: 6` (slot-numbered event ids), so every run the +`world-vercel` `specVersion: 6` (slot-numbered event IDs), so every run the driver creates is stamped 6, while vercel-py typed the field as -`Literal[1, 2, 3, 4, 5]` on the shared event base. The write itself succeeded — -it was parsing the `200` that raised. vercel-py now splits the two meanings the +`Literal[1, 2, 3, 4, 5]` on the shared event base. The write itself succeeded. +Parsing the `200` response raised. vercel-py now splits the two meanings the way TypeScript does: `SPEC_VERSION_CURRENT` stays 2 (what Python stamps) and `SPEC_VERSION_MAX_SUPPORTED` is 6 (what Python will read), which is what keeps version 7 from being the same outage. `world-local` is on 5, so once more only the deployed lane saw it. -Slot ids themselves need nothing from Python: a slot body is 26 zero-padded +Slot IDs themselves need nothing from Python: a slot body is 26 zero-padded decimal digits, which every ULID validator already accepts and which sorts the same way. The one thing that breaks is decoding a timestamp out of an event id, -and vercel-py never does — it mints ids only for `world-local`. +and vercel-py never does. It mints IDs only for `world-local`. The last one only ever showed up here, and it read as flakiness for two rounds before it read as a bug: one arbitrary run per suite died with `WorkflowWorldError: @@ -248,17 +248,17 @@ workflow run wrun_… not found` about 400ms after `start()`, the queue did not redeliver, and the driver sat out its 60-second timeout. A different fixture each time, which is what made it look random. It is not: `start()` (`packages/core/src/runtime/start.ts:577`) issues `run_created` and the queue push -**in parallel**, deliberately — "if events.create fails with 429/5xx, the run was -still accepted via the queue" — so a consumer that finds no run row has found a +**in parallel**, deliberately. "If events.create fails with 429/5xx, the run was +still accepted via the queue," so a consumer that finds no run row has found a normal state, not an error one. The TypeScript consumer never reads the row at all: it writes `run_started`, which is idempotent and creates the run when `run_created` was never seen, and takes the run entity out of that response. vercel-py had it the other way round (`runs_get`, then `run_started` only if `status == "pending"`) and 500'd on the 404, and it dropped the `runInput` the -message carries for exactly this purpose — input, deployment id, workflow name, +message carries for exactly this purpose: input, deployment ID, workflow name, spec version, attribute seed. `vercel-py#282` carries `runInput` through, `#284` bootstraps from `run_started`, and `#283` gives `world-local` the same -resilient start. Which is also what retired this app's last `unsupported` entry, +resilient start. This also retired the app's last `unsupported` entry, the deterministic version of the same defect: *resilient start: addTenWorkflow completes when run_created returns 500* deletes the row on purpose. @@ -269,14 +269,14 @@ files in one process while the queue delivery takes an HTTP round trip, so ## Conformance baseline What the suite runs here is declared in `e2e-conformance.json`: the ported -fixtures, and — when there is one — an `unsupported` map naming individual tests +fixtures and, when there is one, an `unsupported` map naming individual tests whose failure is a runtime gap rather than a missing fixture. There is none right now. Both axes are ratchets: a claim that stops being true fails the run instead of quietly skipping, so growing the file is the only way to move. `ConformanceConfig` in `packages/core/e2e/utils.ts` spells out each direction. Current baseline: **9 passing, 128 skipped, of 137** on `world-local`, and -**8 of 156** on Vercel. It is one baseline, not two — the extra 19 collected on +**8 of 156** on Vercel. It is one baseline, not two. The extra 19 collected on Vercel are `e2e-agent.test.ts`, which that lane also picks up and skips whole, and the ninth pass is `deploymentId: 'latest' is a no-op in non-Vercel worlds`, which is local by definition. @@ -285,13 +285,13 @@ which is local by definition. This app is honest about being early. In rough order of how much it costs: -- **Most fixtures are simply not ported yet** — 66 tests across 52 fixtures. +- **Most fixtures are not ported yet.** This includes 66 tests across 52 fixtures. They are not blocked on one thing anymore: the largest blocks are hooks (19 tests, where vercel-py's `BaseHook.wait()` has a different shape than the async-iterable hook the fixtures use), streams (11, where vercel-py now has `read_stream` / `get_writable` and nothing here uses them yet), `setAttributes` (9, no Python equivalent), and `FatalError` / - `RetryableError` (7 — `FatalError` is exported now, `RetryableError` has no + `RetryableError` (7; `FatalError` is exported now, and `RetryableError` has no Python counterpart at all). - **The `.well-known/workflow/v1` surface lives in `app.py`, not the SDK**, and reaching it needs three `vercel.workflow._internal` imports @@ -299,7 +299,7 @@ This app is honest about being early. In rough order of how much it costs: which has a public equivalent. The module docstring explains why that surface belongs in the app. - **The health-check tests stay JS-only, but no longer for want of an - implementation.** vercel-py answers both probes as of `vercel-py#292` — the + implementation.** vercel-py answers both probes as of `vercel-py#292`: the `?__health` one inside `workflow_entrypoint` (so `app.py` only routes to it) and the queue-based one in `workflow_handler`. What the driver additionally asserts is a `workflowCoreVersion` string, which Python deliberately omits @@ -309,6 +309,6 @@ This app is honest about being early. In rough order of how much it costs: - **No webhook route and no app-specific API routes**, so the webhook and direct-step-call tests are JS-only too. -Two things in `workflows/99_e2e.py` look like mistakes and are not — a module +Two things in `workflows/99_e2e.py` look like mistakes but are intentional: a module name starting with a digit, and Python functions in camelCase. Its docstring says why both are load-bearing; don't "fix" either without reading it. diff --git a/workbench/sim-world/README.md b/workbench/sim-world/README.md index 3501c6572b..451ccbda13 100644 --- a/workbench/sim-world/README.md +++ b/workbench/sim-world/README.md @@ -13,8 +13,8 @@ pnpm sim in-flight-after-decision # one scenario, by id Exits non-zero if any scenario misses an expectation or trips a consistency check, so it doubles as the package's integration test. -This README is about **adding a scenario**. The API a script is written in — -writers, advances, withholdings — is the +This README is about **adding a scenario**. The API for writing a script, +including writers, advances, and withholdings, is in the [API reference](../../packages/world-sim/README.md#api-reference); how the simulator works and how to change it is the rest of [`packages/world-sim/README.md`](../../packages/world-sim/README.md), and the @@ -23,8 +23,8 @@ internals are [`DESIGN.md`](../../packages/world-sim/DESIGN.md). ## Adding a scenario One scenario, one file in [`scenarios/`](./scenarios), named after its id. -Copy the file next door and change what differs — that is the whole workflow, -and the book is split this way so that it is. +Copy the neighboring file and change what differs. Each file contains the full +scenario workflow. ```ts // scenarios/hook-at-step-started.ts @@ -50,18 +50,18 @@ export const scenario: ScenarioSpec = { ``` Then import it in [`scenarios/index.ts`](./scenarios/index.ts) and place it in -the `scenarios` array. Order is the only thing that file decides: simplest +the `scenarios` array. Order is the only thing that file decides: least complex first, and each pair of near-identical scenarios adjacent, so a reader meets a distinction right after the thing it is a distinction from. Put yours next to the one it is a variation of. The id is stable and hyphenated; it is what a commit message or a bug report cites and what the command-line filter matches first. The `name` beside it is -prose and free to be reworded. +prose, and you can reword it. -The workflow named by `workflow` must be exported from -[`workflows/index.ts`](./workflows/index.ts) — all of them live in that one -file because a scenario is read together with the branch it steers. Prefer +Export the workflow named by `workflow` from +[`workflows/index.ts`](./workflows/index.ts). They all live in that one +file so readers can compare a scenario with the branch it steers. Prefer reusing one; a new workflow is only worth it when the shape you need to steer does not exist yet. @@ -85,9 +85,9 @@ Every advance and everything a script can do while one is held is in the [API reference](../../packages/world-sim/README.md#api-reference). Four things from it come up on the first scenario you write: -- **Name the right writer.** `step_started`, `wait_created`, `hook_created` and +- **Name the right writer.** `step_started`, `wait_created`, `hook_created`, and the run's own decisions belong to `sim.writer.orchestrator()`. A step's - `step_completed` / `step_failed` belongs to that step body — + `step_completed` / `step_failed` belongs to that step body: `sim.writer.step('reserveInventory')`, or `sim.writer.anyStep()` for whichever gets there first. Naming the wrong one is a wait that times out, so the failure is loud, but knowing the rule saves the trip. @@ -106,7 +106,7 @@ And one thing the advances cannot do at all: a held writer stops the scheduler, so virtual time stops with it and no timer can fire while anything is held. If the interleaving you need is *a timer firing while a step result is outstanding*, no arrangement of holds will reach it. `sim.deliverQueued` is the -way out — it delivers a queued message from inside the script, concurrently with +way out. It delivers a queued message from inside the script, concurrently with the hold. See [the API reference](../../packages/world-sim/README.md#deliverqueued-and-why-it-is-not-an-advance) for the shape, and @@ -117,7 +117,7 @@ for it in use. Two different instruments, for two different things: -- **`sim.check`** asserts a *sentence about the middle of the run* — "the live +- **`sim.check`** asserts a *sentence about the middle of the run*: "the live pass decided the fork without the hook". It is the only way to pin down a fact that exists at one instant and is gone by the end. - **`expect`** asserts the run's outcome: `status`, and `output` when the @@ -137,18 +137,18 @@ scenario. The command-line fence flags below override them for a whole run. ## Flags -| flag | effect | +| Flag | Effect | | --- | --- | -| `--verbose` | include queue deliveries in the trace | -| `--color` / `--no-color` | force colour on through a pipe / off. Default: on for a terminal, off otherwise, so `pnpm sim > out.txt` is already diffable | -| `--fence` / `--no-fence` | force the optimistic-concurrency fence on or off for every scenario | -| `--report-only` | print every failure, exit 0 anyway | -| `--summary-file ` | one collapsed `
` — the count on the visible line, the table behind it — for a PR comment or `$GITHUB_STEP_SUMMARY` | -| `--detail-file ` | the full trace, colour forced off, as a CI artifact | -| `--title ` | heading for the summary file | +| `--verbose` | Include queue deliveries in the trace | +| `--color` / `--no-color` | Force color on through a pipe or off. Default: on for a terminal, off otherwise, so `pnpm sim > out.txt` is already diffable | +| `--fence` / `--no-fence` | Force the optimistic-concurrency fence on or off for every scenario | +| `--report-only` | Print every failure, but exit 0 | +| `--summary-file ` | Create one collapsed `
` with the count on the visible line and the table behind it for a PR comment or `$GITHUB_STEP_SUMMARY` | +| `--detail-file ` | Write the full trace with color forced off as a CI artifact | +| `--title ` | Set the heading for the summary file | **`--no-fence`** turns the fence off everywhere, asking whether anything relies -on it. It is a diagnostic — **read the violation count, not the +on it. It is a diagnostic. **Read the violation count, not the pass count**, because a scenario whose whole point is that the guard fired asserts exactly that and fails by design when you disarm it (`in-flight-before-decision-counted` is the one that does this today). @@ -158,7 +158,7 @@ asserts exactly that and fails by design when you disarm it [`.github/workflows/world-sim.yml`](../../.github/workflows/world-sim.yml) plays the book on every pull request and posts its summary as one sticky comment: -``` +```text ## Sim World Simulated world deterministic testing for races. [Traces](…) @@ -170,20 +170,20 @@ Simulated world deterministic testing for races. [Traces](…) the published count is the thing to look at rather than the check mark. That is also why `pnpm test` in this package is `--report-only` while `pnpm sim` -stays strict — a recursive `pnpm -r test` should not go red for the known reds, +stays strict. A recursive `pnpm -r test` should not go red for the known reds, but someone running the book deliberately wants the exit code. ## Reading the output -Events in the printed stream are referred to by **log position** — `#12` is the -twelfth event in the durable log, `@7` the resource created at position 7 — and -the trace prints in commit order. See +The printed stream identifies events by **log position**. `#12` is the +twelfth event in the durable log, and `@7` is the resource created at position 7. The +trace prints in commit order. See [`packages/world-sim/README.md`](../../packages/world-sim/README.md#reading-the-output). ## What the scenarios show The first three run the **same workflow with the same input** and differ only in -when the approval hook is delivered — inside the `step_started` commit, inside +when the approval hook is delivered: inside the `step_started` commit, inside the `step_completed` commit, or inside the `hook_created` commit. Same result, three different event logs. Diff them against each other; that difference is what a real deployment leaves to chance. @@ -195,33 +195,32 @@ holding one does not freeze the other. The rest cover the properties that make scenarios usable as tests: a hook racing a deadline (both branches, on demand), a thirty-day sleep that costs microseconds, a step that retries twice, cancellation landing mid-step, and a -hook that never arrives — which is reported as a stall naming the undelivered +hook that never arrives, which the runner reports as a stall naming the undelivered token rather than hanging the run. ## Red scenarios Some scenarios fail, on purpose and by construction, and `pnpm sim` exits non-zero because of them. They are reproductions of corruptions the runtime can -still produce: each states the outcome the run should have reached — the branch -its own durable log implies — and fails until the runtime gets there. The +still produce. Each states the outcome the run should have reached (the branch +its own durable log implies) and fails until the runtime gets there. The failure line names both sides, e.g. `expected "afterSlow:doc-26", got "afterFast:doc-26"`. So a red is an open bug, not a recorded observation, and it goes green when the -bug is fixed rather than when the bug is seen once more. Which means the count -is the thing to watch, in either direction: one more is a regression, one fewer +bug is fixed rather than when the bug is seen once more. Watch the count in +either direction: one more is a regression, while one fewer means a scenario is ready to retire. -Run the book to see the current set — this file deliberately does not keep a +Run the book to see the current set. This file deliberately does not keep a list, because a list here is a second copy of something the book already says -exactly, and it is the copy that goes stale. The analysis that is *not* -re-derivable from a run — which guard closes which shape, which of those guards -is armed in production and which is dark — is in -[`DESIGN.md`](../../packages/world-sim/DESIGN.md#the-six). +exactly, and it is the copy that goes stale. [`DESIGN.md`](../../packages/world-sim/DESIGN.md#the-six) +contains the analysis that you cannot derive from a run: which guard closes +which shape, which guards are armed in production, and which are dark. ## Requirements `run.ts` and the scenario book are TypeScript executed directly by Node's type stripping, which needs Node >= 22.18 (the version pinned in `.node-version`). -Every workflow under test is compiled by the normal SDK build pipeline, exactly +The normal SDK build pipeline compiles every workflow under test, exactly as a deployment would compile it. diff --git a/workbench/vitest/MOCKING.md b/workbench/vitest/MOCKING.md index adcf4be5d8..075b17a9c8 100644 --- a/workbench/vitest/MOCKING.md +++ b/workbench/vitest/MOCKING.md @@ -2,9 +2,9 @@ ## Summary -`vi.mock()` cannot intercept imports inside step functions when using the `workflow()` Vitest plugin. This applies to both first-party code (your own modules) and third-party npm packages. Step dependencies are inlined into a pre-built bundle at build time, completely bypassing Vitest's module system. To mock step dependencies, use unit tests instead. +`vi.mock()` cannot intercept imports inside step functions when using the `workflow()` Vitest plugin. This limitation applies to both first-party code (your own modules) and third-party npm packages. Step dependencies are inlined into a prebuilt bundle at build time, bypassing Vitest's module system. To mock step dependencies, use unit tests instead. -**Confirmed:** A test in `test/mock.test.ts` verifies that mocking the `ms` npm package via `vi.mock('ms', ...)` does not take effect — the real `ms()` function is called inside the step, not the mock. +**Confirmed:** A test in `test/mock.test.ts` verifies that mocking the `ms` npm package via `vi.mock('ms', ...)` does not take effect. The step calls the real `ms()` function, not the mock. ## Root causes @@ -14,7 +14,7 @@ Three layers of isolation prevent `vi.mock()` from working: The step bundle (`steps.mjs`) is built by esbuild with `bundle: true`. Even though `externalizeNonSteps: true` is set, user code imported by step files is inlined directly into the bundle. -This happens because the builder's enhanced-resolve (`packages/builders/src/swc-esbuild-plugin.ts`) silently falls back to bundling when it can't resolve `.js` → `.ts` imports. The `onResolve` hook's catch block (line 135) returns `null` on resolution failure, letting esbuild handle it — which bundles the file. +This happens because the builder's enhanced-resolve (`packages/builders/src/swc-esbuild-plugin.ts`) silently falls back to bundling when it can't resolve `.js` to `.ts` imports. The `onResolve` hook's catch block (line 135) returns `null` on resolution failure, letting esbuild handle and bundle the file. For example, if `workflows/notification.ts` (a step file) imports `../lib/email.ts`, the enhanced resolver tries to resolve `../lib/email.js` and fails (because the actual file is `email.ts`). The error is swallowed, esbuild resolves it via its own `resolveExtensions` config, and the dependency gets inlined. @@ -40,7 +40,7 @@ A single workflow file can contain both `"use workflow"` and `"use step"` functi - **step mode** (esbuild bundle): step functions retain their real implementations. The SWC transform emits an inline IIFE that stores each function in a `globalThis` registry; workflow functions become stubs. - **workflow mode** (esbuild bundle): workflow functions are bundled as code strings for the VM. Step functions become stubs. -The same source file must be compiled twice in different modes — once for the test file (client mode) and once for execution (step mode). This requires separate build artifacts that can't share Vitest's module graph. +The same source file must be compiled twice in different modes: once for the test file (client mode) and once for execution (step mode). This requires separate build artifacts that can't share Vitest's module graph. ## What would need to change @@ -64,12 +64,12 @@ Remove `@vite-ignore` from the step bundle import so Vitest processes it and can Move step bundle loading from `setupFiles` (which runs before test files) to lazy initialization on first step invocation. This way, `vi.mock()` from the test file will have already been registered when the step bundle and its dependencies are first imported. -The workflow bundle does **not** need these changes — it runs inside a sandboxed VM where module mocking is architecturally impossible, and workflow functions should not have side effects that need mocking. +The workflow bundle does **not** need these changes. It runs inside a sandboxed VM where module mocking is architecturally impossible, and workflow functions should not have side effects that need mocking. ## Workarounds Until these changes are made: -1. **Unit test steps directly** — Import step functions without the workflow plugin. `vi.mock()` works normally since there's no pre-built bundle. `"use step"` is a no-op without the compiler. -2. **Dependency injection** — Pass dependencies as step arguments instead of importing them at the module level. -3. **Hook-based patterns** — Use hooks to inject test data into the workflow at runtime, rather than mocking the source of that data. +1. **Unit test steps directly**: Import step functions without the workflow plugin. `vi.mock()` works normally because there's no prebuilt bundle. `"use step"` is a no-op without the compiler. +2. **Dependency injection**: Pass dependencies as step arguments instead of importing them at the module level. +3. **Hook-based patterns**: Use hooks to inject test data into the workflow at runtime rather than mocking the source of that data. diff --git a/workbench/vitest/README.md b/workbench/vitest/README.md index 20201ef0ea..d0fca3edd9 100644 --- a/workbench/vitest/README.md +++ b/workbench/vitest/README.md @@ -2,10 +2,10 @@ This workbench demonstrates how to test workflows with Vitest using the `@workflow/vitest` plugin. -## How It Works +## How it works -1. **Vitest Plugin**: The `workflow()` plugin from `@workflow/vitest` handles SWC transforms, bundle building, and in-process handler registration automatically. -2. **No Server Required**: Workflows execute entirely in-process using a [Local World](/docs/worlds/local) instance — no HTTP server needed. +1. **Vitest plugin**: The `workflow()` plugin from `@workflow/vitest` handles SWC transforms, bundle building, and in-process handler registration automatically. +2. **No server required**: Workflows execute entirely in-process using a [Local World](/docs/worlds/local) instance, so they don't need an HTTP server. 3. **Tests**: Use `start(workflow, args)` and await `run.returnValue`, plus helpers like `waitForSleep()` and `waitForHook()`. ## Usage @@ -14,9 +14,9 @@ This workbench demonstrates how to test workflows with Vitest using the `@workfl pnpm test ``` -## Project Structure +## Project structure -``` +```text workbench/vitest/ ├── workflows/ │ ├── simple.ts # Basic workflow with arithmetic steps @@ -30,9 +30,9 @@ workbench/vitest/ └── package.json ``` -## Test Coverage +## Test coverage -- **Simple workflow**: Start and await return value +- **Basic workflow**: Start and await return value - **Sleep workflow**: `waitForSleep()` → `wakeUp()` to skip sleep - **Multi-sleep workflow**: Targeted `wakeUp()` with correlation IDs - **Hook workflow**: `waitForHook()` → `resumeHook()` with approval/rejection From 5a59bb82e8984a6818d62118aeaccc0efd13d4fb Mon Sep 17 00:00:00 2001 From: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:35:55 -0700 Subject: [PATCH 08/10] Add disabled state to decrypt controls (#3715) --- .../disable-web-shared-decrypt-controls.md | 5 ++ .../src/components/event-list-view.tsx | 19 ++++++- .../components/sidebar/attribute-panel.tsx | 8 +++ .../sidebar/copyable-data-block.tsx | 9 +++- .../sidebar/entity-detail-panel.tsx | 18 ++++++- .../sidebar/sidebar-data-context.tsx | 2 + .../trace-viewer/components/detail-panel.tsx | 2 + .../src/components/ui/data-inspector.tsx | 20 ++++++- .../src/components/ui/decrypt-button.tsx | 11 +++- .../test/copyable-data-block.test.ts | 53 +++++++++++++++++++ 10 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 .changeset/disable-web-shared-decrypt-controls.md diff --git a/.changeset/disable-web-shared-decrypt-controls.md b/.changeset/disable-web-shared-decrypt-controls.md new file mode 100644 index 0000000000..50d5a99603 --- /dev/null +++ b/.changeset/disable-web-shared-decrypt-controls.md @@ -0,0 +1,5 @@ +--- +'@workflow/web-shared': patch +--- + +Allow consumers to disable decrypt controls and explain why decryption is unavailable. diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index edb5aa596f..f8b90fb40e 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -821,6 +821,10 @@ interface EventsListProps { onDecrypt?: () => void; /** Whether the encryption key is currently being fetched. */ isDecrypting?: boolean; + /** Whether decryption is unavailable. */ + isDecryptDisabled?: boolean; + /** Explains why decryption is unavailable. */ + decryptDisabledReason?: string; /** Run-level hint: the run contains encrypted data (from probe). */ hasEncryptedData?: boolean; /** Fetch events for an exact correlation or event ID. */ @@ -1304,6 +1308,8 @@ function EventListViewInner({ onSortOrderChange, onDecrypt, isDecrypting = false, + isDecryptDisabled = false, + decryptDisabledReason, hasEncryptedData: hasEncryptedDataProp = false, onExactIdSearch, showSeparateEventOccurrenceTimestamps = false, @@ -1663,7 +1669,16 @@ function EventListViewInner({ return (
@@ -1759,6 +1774,8 @@ function EventListViewInner({ )} diff --git a/packages/web-shared/src/components/sidebar/attribute-panel.tsx b/packages/web-shared/src/components/sidebar/attribute-panel.tsx index 10611d4fb3..4c0d905f4b 100644 --- a/packages/web-shared/src/components/sidebar/attribute-panel.tsx +++ b/packages/web-shared/src/components/sidebar/attribute-panel.tsx @@ -836,6 +836,8 @@ export const AttributePanel = ({ onRunClick, onDecrypt, isDecrypting = false, + isDecryptDisabled = false, + decryptDisabledReason, resource, }: { data: Record; @@ -852,6 +854,10 @@ export const AttributePanel = ({ onDecrypt?: () => void; /** Whether decryption is currently in progress */ isDecrypting?: boolean; + /** Whether decryption is unavailable */ + isDecryptDisabled?: boolean; + /** Explains why decryption is unavailable */ + decryptDisabledReason?: string; /** Resource type of the selected span, used to show targeted loading skeletons. */ resource?: string; }) => { @@ -943,6 +949,8 @@ export const AttributePanel = ({ ? { onDecrypt, isDecrypting, + isDecryptDisabled, + decryptDisabledReason, hasEncryptedData: outerDecryptCtx?.hasEncryptedData, } : outerDecryptCtx; diff --git a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx index 8061b74636..5c0a0cf082 100644 --- a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx +++ b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx @@ -25,7 +25,14 @@ export function EncryptedDataBlock() {
{ctx ? ( -