mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
workflow@5.0.0-beta.16
1248 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5711c1e9d6 | Version Packages (beta) (#2390) workflow@5.0.0-beta.16 | ||
|
|
b3cc513220 | [ci] Increase dev.test.ts cleanup hook timeout (#2416) | ||
|
|
0178fa5730 | [world-vercel] Switch event endpoints to v4 wire format (#2055) | ||
|
|
5dbeecbb82 |
docs: document run idempotency (#2011)
* docs: document run idempotency * docs: address idempotency review feedback * docs: make hook tokens the idempotency pattern * docs: address toolbar idempotency feedback * docs: clarify idempotency page description * docs: scope idempotency descriptions * docs: move step idempotency example under section * docs: simplify idempotency guidance * docs: simplify idempotency cookbook * docs: add empty changeset Signed-off-by: Nathan Rajlich <n@n8.io> * docs: address idempotency review feedback * feat: add hook ready promise * docs: mention conflicting hook run id * test: cover hook ready continuation scheduling * feat: replace hook.ready with hook.hasConflict (Promise<boolean>) - hook.hasConflict resolves true when the token is owned by another active hook, false once registration is committed — no throw, so workflows can branch on conflicts early. Awaiting it suspends the workflow to commit the hook registration (createHook alone does not). - Chain the already-created fast-path through promiseQueue so resolution order matches event-log order (review feedback). - Skip inline step execution when a suspension has an awaited hook creation so the hasConflict continuation can advance independently of step execution (review feedback). - Update unit tests, e2e tests, workbench workflows, and v4/v5 docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix inconsistent hasConflict bullet in create-webhook reference State both resolution values explicitly (true = token already owned, false = registered) instead of a parenthetical that only described the false case. * docs: require docs preview links in PR descriptions for docs changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: restore SWC Plugin heading in AGENTS.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: adopt hook.hasConflict in run idempotency docs - Primary claim pattern is now `if (await hook.hasConflict)` instead of try/catch on HookConflictError; payload awaits still reject with HookConflictError (with conflictingRunId) when the owner's run ID is needed. - Route example returns the active owner via resumeHook()'s runId instead of threading conflictingRunId through the workflow result. - Update claim-pattern prose across start(), getHookByToken(), world storage, scheduling, workflow composition, and cookbook idempotency pages (v4 + v5). - Add @skip-typecheck marker to the cross-block route sample, fixing a pre-existing docs typecheck failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: move resume-or-start guidance into a dedicated resumeHook example The early callout was too vague and out of place at the top of the API reference. Replace it with a 'Resume or Start' example section that explains the flow, shows the resume-first/start-then-retry route, and links to the run idempotency pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: detect the concurrent-start race via runId comparison instead of awaiting returnValue The 'Resume or Start' example returned the just-started run's runId with reused: false even when a concurrent request's run won the token race — the payload had reached the actual owner, so the response pointed callers at a run that exits as a duplicate. The foundations route handled the race correctly but by awaiting run.returnValue, blocking the HTTP response on full workflow completion. resumeHook() always resolves against the actual active owner, so comparing the resumed hook's runId with the started run's runId detects the race in both examples — race-correct and non-blocking. * feat: replace hook.hasConflict with hook.getConflict (Promise<Run | null>) hasConflict's boolean didn't expose WHICH run owns the token, so the duplicate run couldn't act on the conflict. getConflict resolves with null once registration commits, or with a Run handle for the conflicting run — letting the workflow return/log the owner's runId, inspect its status, await its result, or cancel it and continue, all in code. The workflow-mode create-hook module exposes the bundle's compiled Run class (durable step-proxy methods) on a well-known symbol so the host- side hook consumer can construct the conflicting run inside the VM. Contexts without the class (plain unit tests) fall back to a { runId } object, which is also the documented v4 shape (no native Run serialization in v4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: adopt hook.getConflict and add conflict-handling strategy guide Run idempotency docs now use getConflict (resolves with the conflicting Run in v5, { runId } in v4) and document code-driven conflict strategies in place of static ID-reuse policies: reject the duplicate, adopt the owner's result, inspect before deciding, signal the owner via resumeHook, and supersede via cancel-and-reclaim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: never resolve getConflict with a non-Run fallback shape getConflict's contract is Promise<Run | null>. In the degenerate cases where a real Run cannot be constructed — a hook_conflict event persisted by an old world without conflictingRunId, or a context that never loaded the workflow-mode create-hook module — reject with HookConflictError instead of resolving with a { runId }-shaped impostor. Test harnesses now register the Run class on the (VM) globalThis like real bundles do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: make getConflict a method — hook.getConflict() A property getter that triggers registration/suspension reads as passive state; a method makes the side effect explicit. Update implementation, types, tests, e2e workflows, docs, and changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: getConflict is a method — hook.getConflict() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: typecheck every sample — drop skip-typecheck escape hatches Route examples typecheck as-is since the runId-comparison rewrite; strategy fragments are now complete self-contained workflows; the publishing-libraries cross-block dependency uses the declare @setup convention. 934 samples typechecked, none skipped by this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review: guard Run class registration, fix anchors, clarify changeset - Only register WORKFLOW_RUN_CLASS when the workflow runtime is present (WORKFLOW_CREATE_HOOK installed on globalThis), so host imports of the workflow-mode module neither mutate the host global nor expose the non-step-proxy host Run. - Drop #run-idempotency link fragments — that section lands in the stacked docs PR (#2011), which restores the anchored links. - Note in docs that getConflict() rejects with HookConflictError for legacy hook_conflict events lacking the owner's run ID. - Changeset now calls out the hasConflict -> getConflict() replacement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: restore run-idempotency anchors now that the section exists here Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: describe fixed conflict policies generically, without naming other systems Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: Nathan Rajlich <n@n8.io> Co-authored-by: Nathan Rajlich <n@n8.io> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
dde689a056 |
Render attr_set events and run attributes in observability UI (#2393)
- Teal diamond markers for attr_set events on the trace timeline with time tooltips (new trace viewer) - attr_set payloads render changed/removed keys and the writer (workflow vs step + attempt) in the run sidebar and Events tab - Run root span selection now shows run-level events (run lifecycle + attr_set) in the sidebar - Attributes card on run details renders key-value rows with reserved $-prefixed keys badged and sorted after user keys - attr_set added to MARKER_EVENT_TYPES, BOUNDARY_LABELS, event colors (teal), and the flat events list run-level grouping - Docs: screenshots on the attributes page, served from docs/public Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5ad57e8270 | [ci] Fix backport job model slug (#2403) | ||
|
|
4a5a23088d | [ci] Comment on PR when backport fails, revert to use opus 4.8 (#2400) | ||
|
|
af859c3a6d | Update queue client to 0.3.1 (#2399) | ||
|
|
011d482808 |
fix(deps): upgrade esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr) (#2395)
* fix(deps): upgrade esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr) Bump the esbuild catalog from ^0.27.3 (resolving 0.27.7) to ^0.28.1 to resolve the High-severity advisory GHSA-gv7w-rqvm-qjhr (missing binary integrity verification before executing downloaded binaries). All workspace consumers reference esbuild via `catalog:` (@workflow/builders, @workflow/cli, workbench/example, and the root devDependency), so the single catalog bump propagates everywhere. Adds a patch changeset for the two publishable consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): exclude esbuild from minimumReleaseAge gate The canary E2E jobs run `pnpm install --no-frozen-lockfile` (they mutate the next dependency), which re-resolves the catalog and hits the 48h `minimumReleaseAge` gate on the freshly-published esbuild@0.28.1, failing setup with ERR_PNPM_NO_MATCHING_VERSION. Add esbuild and @esbuild/* to minimumReleaseAgeExclude (pnpm's recommended fix, consistent with the existing @vercel/*, @workflow/*, turbo exclusions) so the intended, catalog-pinned security upgrade resolves under non-frozen installs. Re-resolving also drops the redundant esbuild@0.28.0 (nitropack@2.13.4 consolidates onto 0.28.1 within its ^0.28.0 range). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4763a760bf |
test: e2e coverage for run-idempotency conflict-handling strategies (#2387)
* test: e2e coverage for run-idempotency conflict-handling strategies Covers the patterns documented in foundations/idempotency: - claim-only hook mutex: token claimed and held with no payload data, duplicate identifies the owner, token released after completion - adopt the owner's result via conflict.returnValue - signal the owner: duplicate forwards its payload via resumeHook - supersede: duplicate cancels the owner and reclaims the token - route-side resume-or-start retry pattern reaching the started run Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: fix adopt-owner-result race — gate owner completion on observed conflict On slow runtimes the duplicate's first invocation could land after the owner completed and released the token, making the duplicate a fresh owner that waits forever for a payload (90s timeout across CI matrices). Poll the duplicate's event log for hook_conflict before resuming the owner, and widen the test timeout for the added gate budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review: assert superseded owner's returnValue rejection; empty changeset - Await run1.returnValue and assert WorkflowRunCancelledError so the cancellation is verified end-to-end and no rejection leaks from the supersede test. - Test-only PR: use an empty changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retrigger preview deployments (turbopack deployment for |
||
|
|
3229d20614 |
fix(docs): repair broken links, fix the link linter, and version-correct v5 Card + edit links (#2391)
* fix(docs): repair broken links and make the docs link linter actually validate The docs link linter (docs/scripts/lint.ts) had been silently passing everything since the app moved under app/[lang]/ (#552): the next-validate-link populate key 'docs/[[...slug]]' no longer matched the real route, and the unpopulated [lang] homepage route produced a fallback regex (^\/(.+)$) that matched every href. It also only scanned v4 content. - Rewrite lint.ts to build explicit v4/v5 URL spaces from both fumadocs sources (including cookbook URL variants, app routes, worlds pages, public/ assets, and next.config.ts redirects) and validate each version's content against version-correct render semantics. Also validate frontmatter related/prerequisites references (version-relative) and heading fragments. - Rewrite Card hrefs on v5 pages: the v5 routes rewrote inline markdown links from /docs/... to /v5/docs/... but Card renders its own Link, so Card hrefs escaped to the v4 routes and 404'd for v5-only pages (e.g. /v5/docs/observability linking to /docs/observability/attributes). - Fix all dead content links surfaced by the working linter (56 across v4+v5): nonexistent use-workflow/use-step/start API pages now point at foundations/workflows-and-steps and workflow-api/start, getStepMetadata path corrected, /docs/worlds/local → /worlds/local, dead changelog/ internal references removed or unlinked, retired common-patterns links point at the cookbook, and a dead #returnvalue anchor now targets #returns. - Add an index page for api-reference/workflow-errors (both versions), which was linked from the API reference landing page but had no page. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(docs): add version prefix to 'Edit this page on GitHub' links All "Edit this page on GitHub" links 404'd since the v4/v5 content split (#1948): page.path is relative to the per-version content dir, but EditSource built URLs against docs/content/docs/ without the v4/ or v5/ segment. Add a required version prop, passed from each page route. Incorporates #2120 by Luke Howard (@gldkhoward), rebased onto the v5 route changes from this branch. Fixes #2119. Co-authored-by: Luke Howard <dev@lukehoward.com.au> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
56dcf5f2e1 |
feat(web-shared): RelativeTimeCard with shared ContextCard provider (#2328)
* feat(web-shared): RelativeTimeCard with shared ContextCard provider
Add a ContextCard provider/trigger and rebuild the timestamp tooltip as a
RelativeTimeCard, giving animated, collision-aware morphing hover cards.
Mount the shared provider in EventListView and AttributePanel.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Match vercel/front timestamp format for run/activity fields
Render absolute Created/Started/Completed (and sibling) timestamps using
date-fns in vercel/front's request/activity format (e.g.
"JUN 10 10:16:02.69 GMT-4") via the shared formatLocalMillisecondTime helper.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): register dark-theme/light-theme Tailwind variants
The context-card arrow tip stroke uses `dark-theme:[--context-card-tip-stroke:#252525]`,
but Tailwind v4 has no built-in `dark-theme` variant, so the utility was silently
dropped and the stroke fell back to its light `#DBDBDB` value — rendering as a white
caret in dark mode. Register the `dark-theme`/`light-theme` custom variants in
styles.css (mirroring vercel/front's geistcn tailwind.css, extended to match the
`.dark`/`[data-theme="dark"]` selectors this package and next-themes use).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): match context card shadow to vercel/front
The --ds-shadow-tooltip token was guessed when added standalone, producing
an oversized/heavy drop shadow. Reproduce front's exact resolved value for
both light and dark themes (including the background-border layer).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(web-shared): bridge context card hover gap to stop flicker
The card is positioned `sideOffset` away from the trigger, leaving a
transparent un-hoverable gap that caused the hover card to flicker
(open → close → open) when moving the cursor onto it. Add a transparent
hover bridge inside the floating wrapper that extends the hover surface
by `sideOffset` to meet the trigger edge, keeping the visual spacing
while making the hover surface continuous.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Revert "fix(web-shared): bridge context card hover gap to stop flicker"
This reverts commit
|
||
|
|
628795aa87 |
Add allowReservedAttributes option to start() (#2385)
* Add allowReservedAttributes option to start() experimental_setAttributes already exposes allowReservedAttributes for framework-level callers that own a $-prefixed sub-namespace, and the run_created / run_started event schemas plus the local and Postgres worlds already accept and validate the flag. start() was the one gap: it always validated initial attributes with the reserved prefix disallowed and had no way to opt out, so framework code could not seed reserved attributes at run creation. Thread the option through start(): - StartOptions.allowReservedAttributes, passed to client-side validation and forwarded on the run_created eventData - carried in the queue runInput (new RunInputSchema field) and forwarded to run_started so the resilient/lazy run creation path validates identically Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add e2e coverage for reserved initial attributes via allowReservedAttributes Verified locally against the nextjs-turbopack dev server: the reserved key passes client and server validation, lands on the run at creation, and survives the workflow's own attr_set writes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
58ddc62d02 |
Version Packages (beta) (#2364)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>workflow@5.0.0-beta.15 |
||
|
|
d81b929fa9 |
Animate in-progress segments in the timeline (#2383)
* Animate in-progress segments in the timeline Add an animated diagonal "barber-pole" stripe overlay to in-progress (running/received) segments in the new trace viewer timeline, so it's obvious at a glance which work is still live. The animation lives in a colocated CSS module (timeline.module.css), imported by the component — web-shared is in consumers' transpilePackages, so the keyframes ship with the component rather than relying on the global styles.css (which Geist-using hosts don't import). Also fixes a latent status bug this surfaced: the run-segment builders collapsed every non-failed run to "running", so completed runs rendered as "running" (and, with the new animation, kept animating). They now map to a new terminal `completed` status (blue, static) via a fail-closed runSegmentStatus helper — only genuinely in-progress runs animate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Create chatty-walls-appear.md Signed-off-by: Mitul Shah <mitulxshah@gmail.com> --------- Signed-off-by: Mitul Shah <mitulxshah@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8262c2d292 |
Render queued span time as a lead-in connector on the trace timeline. (#2381)
Replace the filled gray queued box with a tick and horizontal line into the active bar so wait time reads as "waited, then ran." Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
3cf0e33562 |
fix(docs): right-align sidebar folder carets consistently (#2377)
SidebarFolderTrigger renders a <button>, which shrink-to-fits its content, so the ms-auto chevron sat directly next to the folder name for folders without an index link (e.g. How it works, AI Agents, Testing). SidebarFolderLink renders an <a> that spans the full sidebar width, so its chevron was pushed to the right edge. Add w-full to both so every folder caret is right-aligned. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
303b6da28a |
[core] Add wire-level framing for byte streams (#1853)
Co-authored-by: Peter Wielander <peter.wielander@vercel.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> |
||
|
|
055b66649a | docs: move World SDK and getWorld under workflow/runtime, split out workflow/observability (#2375) | ||
|
|
01c8c0878a |
Replace hook.hasConflict with hook.getConflict() returning the conflicting Run (#2373)
* feat: replace hook.hasConflict with hook.getConflict (Promise<Run | null>)
hasConflict's boolean didn't expose WHICH run owns the token, so the
duplicate run couldn't act on the conflict. getConflict resolves with
null once registration commits, or with a Run handle for the conflicting
run — letting the workflow return/log the owner's runId, inspect its
status, await its result, or cancel it and continue, all in code.
The workflow-mode create-hook module exposes the bundle's compiled Run
class (durable step-proxy methods) on a well-known symbol so the host-
side hook consumer can construct the conflicting run inside the VM.
Contexts without the class (plain unit tests) fall back to a { runId }
object, which is also the documented v4 shape (no native Run
serialization in v4).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: never resolve getConflict with a non-Run fallback shape
getConflict's contract is Promise<Run | null>. In the degenerate cases
where a real Run cannot be constructed — a hook_conflict event persisted
by an old world without conflictingRunId, or a context that never loaded
the workflow-mode create-hook module — reject with HookConflictError
instead of resolving with a { runId }-shaped impostor.
Test harnesses now register the Run class on the (VM) globalThis like
real bundles do.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: make getConflict a method — hook.getConflict()
A property getter that triggers registration/suspension reads as passive
state; a method makes the side effect explicit. Update implementation,
types, tests, e2e workflows, docs, and changeset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* review: guard Run class registration, fix anchors, clarify changeset
- Only register WORKFLOW_RUN_CLASS when the workflow runtime is present
(WORKFLOW_CREATE_HOOK installed on globalThis), so host imports of the
workflow-mode module neither mutate the host global nor expose the
non-step-proxy host Run.
- Drop #run-idempotency link fragments — that section lands in the
stacked docs PR (#2011), which restores the anchored links.
- Note in docs that getConflict() rejects with HookConflictError for
legacy hook_conflict events lacking the owner's run ID.
- Changeset now calls out the hasConflict -> getConflict() replacement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor: resolve the conflicting Run through the serialization class registry
Replace the bespoke WORKFLOW_RUN_CLASS global with the registry the
serialization pipeline already uses to revive Run instances:
- The SWC plugin already auto-registers the workflow bundle's compiled
Run in globalThis[workflow-class-registry], but under a path-derived
classId the host cannot know statically. The workflow-mode create-hook
module now aliases it under a stable id (class//workflow//Run) via a
new aliasSerializationClass() helper (a plain registry entry —
registerSerializationClass cannot be reused since the plugin's IIFE
already defined the non-configurable classId property).
- createConflictingRun() looks the class up with
getSerializationClass(RUN_CLASS_ID, ctx.globalThis) and constructs
through its WORKFLOW_DESERIALIZE hook, exactly as the Instance reviver
would for a serialized Run crossing from a step into the workflow.
- Because the registry is keyed per-global, no environment guard is
needed: a stray host-side import registers the host Run on the host
registry, which is the correct class for that context. The
WORKFLOW_CREATE_HOOK guard, the ??=, and the WORKFLOW_RUN_CLASS symbol
are all deleted.
Verified: 1156 core unit tests; compiled workbench bundle contains the
stable alias alongside the plugin's path-derived registration with zero
WORKFLOW_RUN_CLASS references; all 5 hookGetConflict e2e tests pass
against a local nextjs-turbopack dev server, including conflict
resolution reading conflict.status through a durable step.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Nathan Rajlich <n@n8.io>
|
||
|
|
79f0d4bc7d | ci: use claude-fable-5 for backport AI model (#2370) | ||
|
|
e163422551 |
Add hook.hasConflict for early hook conflict detection (#2015)
* feat: add hook ready promise * test: cover hook ready continuation scheduling * feat: replace hook.ready with hook.hasConflict (Promise<boolean>) - hook.hasConflict resolves true when the token is owned by another active hook, false once registration is committed — no throw, so workflows can branch on conflicts early. Awaiting it suspends the workflow to commit the hook registration (createHook alone does not). - Chain the already-created fast-path through promiseQueue so resolution order matches event-log order (review feedback). - Skip inline step execution when a suspension has an awaited hook creation so the hasConflict continuation can advance independently of step execution (review feedback). - Update unit tests, e2e tests, workbench workflows, and v4/v5 docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: fix inconsistent hasConflict bullet in create-webhook reference State both resolution values explicitly (true = token already owned, false = registered) instead of a parenthetical that only described the false case. * docs: require docs preview links in PR descriptions for docs changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: restore SWC Plugin heading in AGENTS.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Nathan Rajlich <n@n8.io> |
||
|
|
b3279f8b17 |
[core] V2: unify wait+step queue dispatch in suspension processing (#1925)
* [core] V2: pre-schedule the wait timer before inline-executing a step Fix `Promise.race(step, sleep)` semantics in V2 mixed suspensions without losing inline step execution. Inline `await executeStep(...)` blocks the V2 handler for the full step duration, but `wait_completed` events are only created on the *next* loop iteration's "complete elapsed waits" pass. So if the sleep is shorter than the step, replay always picked the step because the wait_completed event hadn't been written yet — `sleepWinsRaceWorkflow` returned `'step'` instead of `'sleep'`. Fix: when a suspension contains both an owned inline step and at least one pending wait, queue a delayed self-message with `delaySeconds = suspensionResult.timeoutSeconds` *before* starting inline execution. The queued continuation fires in a separate function invocation while the step is still running. That parallel invocation's "complete elapsed waits" pass writes wait_completed, replay observes the elapsed wait, and `Promise.race` resolves with the sleep correctly. The original (still-running) inline invocation finishes its step, sees `run_completed` on the next loop iteration, and exits. This preserves inline-step execution speed for the step-wins case: the step finishes inline and the workflow returns directly. The eagerly-queued wait continuation fires after the step has won and just no-ops on the terminal run. Test plan: - New e2e tests `sleepWinsRaceWorkflow` and `stepWinsRaceWorkflow` exercising `Promise.race` between a step function and `sleep()`, in both directions. - Verified locally against `nextjs-turbopack` workbench: both pass. Event log confirms `wait_completed` is created at t≈1s after `wait_created` (1s sleep) instead of at t≈11s after the inline step finishes. Eager-processing changelog updated with a "Mixed Suspensions" section describing the pre-scheduled wait approach and its rationale. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [world-local] Honor delaySeconds before message delivery The local queue's `queue()` enqueue path ignored the `delaySeconds` option entirely — every message was delivered immediately, regardless of the requested delay. VQS-side queues (used by world-vercel and world-postgres) honor delaySeconds at the broker, so this brings world-local in line with production semantics. The runtime needs this to land before the wait-as-continuation unification in the next commit: that change starts queueing wait timers as fresh delayed continuations instead of returning `{ timeoutSeconds }`. Without delaySeconds support, those wait continuations would fire instantly in dev and trigger spurious replays. Sleep happens outside the queue's worker semaphore so a delayed message doesn't tie up a worker slot during its delay window — other immediate messages are free to dispatch in parallel. New tests in queue.test.ts cover: - delaySeconds > 0 → setTimeout called with the right ms value - delaySeconds === 0 → no setTimeout (immediate dispatch) - delaySeconds omitted → no setTimeout (immediate dispatch) * [core] V2: unify wait+step queue dispatch in suspension processing Replace the asymmetric "steps go to the queue, waits become a { timeoutSeconds } return value" pattern with a single Promise.all batch that queues every pending operation we are not running inline. Before this change, suspension processing had three branches that all needed to keep the wait/step asymmetry consistent: - pendingSteps.length === 0 returned { timeoutSeconds } - inlineStep + waits eagerly queued a delayed self-message AND set inlineStep to undefined (Option A) AND returned { timeoutSeconds } - inlineStep retry path returned { timeoutSeconds } if there were waits After this change, every suspension goes through one path: for non-inline pendingSteps: queue stepId message if timeoutSeconds defined: queue delayed continuation await Promise.all(dispatches) if !inlineStep: return await executeStep(inlineStep) Behaviorally, this restores inline step execution even when the suspension also has a wait (Option A's carve-out is no longer necessary): the wait timer fires in a separate function invocation on the queue, in parallel with the inline step. If the sleep wins the race, that parallel invocation observes wait_completed via the "complete elapsed waits" pass and finishes the run; if the step wins, the wait continuation fires later and no-ops on the terminal run via the existing terminal-event check. Other cleanups: - The inline-step retry path no longer needs to forward suspensionResult.timeoutSeconds — the wait timer was already enqueued as part of the unified dispatch above. - A dead post-step `if (timeoutSeconds && pendingSteps.length === 1)` block (just a comment, no body) is removed; the loop's "complete elapsed waits" pass handles the same case correctly. - Step queueing now uses a shared `traceCarrier` rather than re-serializing per step. Retry/throttle and hook-conflict paths still return { timeoutSeconds } since their semantics are "redeliver THIS message after a delay" rather than "schedule a fresh wait timer." Those can be unified in a follow-up. Test plan: - New e2e tests `sleepWinsRaceWorkflow` and `stepWinsRaceWorkflow` pass against the `nextjs-turbopack` workbench. - Event log inspection confirms wait_completed fires at t≈1s (after wait_created at t≈0s) for the sleep-wins case, and that the inline step runs only once (no duplicate step_started events that the earlier eager-queue approach produced in dev). - All 842 @workflow/core unit tests pass. - All 346 @workflow/world-local unit tests pass (with the delaySeconds support added in the previous commit). Requires the world-local delaySeconds fix in the prior commit; without it, wait continuations would fire instantly in dev and the parallel replay would re-enter handleSuspension before the wait elapsed (recoverable via existing redelivery, but inefficient). * [docs] V2 unified suspension dispatch + changeset Update the "Mixed Suspensions" section in eager-processing.mdx to describe the unified parallel-dispatch model: - All non-inline pendingSteps are queued with stepId - The wait timer (if any) is queued as a delayed continuation - All dispatched in one Promise.all batch - One owned step is then inline-executed (if any) The doc previously described Option A (the carve-out where waits forced all steps to be queued); the unified model removes that carve-out and explains why the wait continuation works in parallel with the inline step. Also notes the dependency on world-local's new delaySeconds support (landed earlier in the same PR series). Changeset bumps both @workflow/core and @workflow/world-local since both packages have user-observable behavior changes. * [core] Dedupe wait continuations on the wait's correlationId While a wait is pending, every replay pass over the run re-observes it (once per step completion in Promise.all([steps..., sleep()]), etc.) and would enqueue another delayed continuation — each a spurious replay when the wait elapses, and each a fresh message that resets the delivery-attempt runaway guard. Key the continuation on the wait's correlationId so the worlds' idempotency dedupe collapses them. Near-elapsed waits (<= 2s) are enqueued without the key: a continuation delivered marginally early (clock skew; the ceil() on the delay can leave a ~0 margin) re-observes its wait as pending and must be able to enqueue a fresh short-delay retry. VQS idempotency records persist until message-retention TTL — reusing the key there would drop the retry and stall the run permanently. Also adapts wait-completion-replay tests (from #2038) to the unified dispatch model: the hook-branch step now executes inline (registered in the test world, which now returns a step entity from step_started), so each scenario performs one extra loop-iteration event fetch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [core] Always key wait continuations; bucket the key for near-elapsed waits CI caught sleepWinsRaceWorkflow failing across the world-postgres lanes: world-postgres serializes KEY-LESS workflow messages per run (inflightWorkflowRuns), so a key-less wait continuation parks behind the flow message that is inline-executing the racing step — wait_completed lands after step_completed and the race resolves to the step. Keyed messages take the concurrent dedupe path, so the continuation must always carry an idempotency key. The near-elapsed exception (<= 2s) now uses a second-bucketed suffix instead of omitting the key: an early-delivered continuation re-observes its wait as pending and re-enqueues with >= 1s delay, which guarantees a later bucket — a fresh key that dedupe windows cannot drop — while same-instant duplicates still collapse. Verified against a local world-postgres setup (express workbench, Graphile worker): sleepWins/stepWins pass 3/3 with wait_completed at t+1s; the event log confirms the continuation fires in parallel with the in-flight inline step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [core] Clamp wait-continuation delays; chain long waits with hop-keyed dedupe Addresses PR review: the unified dispatch passed delaySeconds to the queue unclamped while keying the continuation on the bare wait correlationId. On world-vercel (23h max delay, 24h VQS message retention) a sleep() longer than the max either failed the dispatch or was delivered early with its re-enqueue silently dropped by the still-live idempotency record - stalling the run permanently. - New runtime/wait-continuation.ts owns delay + idempotency-key selection: delays clamp to 23h and longer waits chain across hops, with the hop index suffixed to the key so re-observations within a hop window dedupe while each hop delivery gets a fresh key. Near- elapsed threshold and max delay are named constants; full rationale moved out of the runtime.ts comment block. Unit tests pin the key selection including chain advancement. - SuspensionHandlerResult: timeoutSeconds/timeoutWaitCorrelationId collapsed into waitTimeout?: { seconds, correlationId } so the pairing can't drift (review nit). - runtime.test.ts ack-ordering harness adapted to the unified model: step_created now answers EntityConflictError so the handler observes the step without owning it and must queue it (the carve-out the tests relied on - "pending wait disables inline execution" - is exactly what this branch removes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [world-local] Abort pending queue sleeps on close() Addresses PR review: a pending delayed message kept the dev process's event loop alive for its full delay, and close() only closed the HTTP agent - a sleep that fired afterwards attempted delivery against the closed agent and logged a spurious "[local world] Queue operation failed" error during test/CLI shutdown. One AbortController owned by the queue now cancels the delaySeconds sleep, the timeoutSeconds re-delivery sleep, and the retry backoff on close(); the resulting AbortError is already swallowed by the existing isAbortError check. close() is idempotent since shutdown paths may invoke it twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [docs] Wait-continuation clamping + hop chaining; changeset eager-processing.mdx pseudocode now shows the continuation's idempotency key and clamped delay (PR review nit); the dedupe prose covers the two key variations (hop suffix for chained long waits, second bucket for near-elapsed waits). Changeset mentions long-sleep chaining and world-local's abort-on-close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Pranay Prakash <pranay.gp@gmail.com> |
||
|
|
f2a7bdeb0a |
fix(world-local,world-postgres): make duplicate hook_created idempotent (#2295)
* fix(world-local): make duplicate hook_created idempotent Duplicate processing of the same hook_created — same runId, hookId, and token, e.g. cross-process replay or queue redelivery — was being recorded as a hook_conflict in the event log, which then replayed as a self- conflict HookConflictError. The fix mirrors the existing step_created duplicate-correlation path: when the exclusive token claim fails and the existing claim has the same (runId, hookId), throw EntityConflictError so the runtime's existing concurrent-replay catch path swallows it. Different runId or hookId reusing the same token still produces a real hook_conflict. The persisted token claim already carried hookId; only the read schema was dropping it. The schema now preserves hookId (marked optional for backward compatibility with older claim files). Fixes #2283 * fix(world-postgres): make duplicate hook_created idempotent world-postgres has the same gap as world-local was just fixed for: the duplicate-token check in events.create unconditionally writes a hook_conflict event when an existing hook with the same token is found, even when the existing hook has the same (runId, hookId) as the incoming event. The unique partial index on workflow_events does not catch this because the duplicate path inserts hook_conflict, not hook_created. Mirror the world-local fix: when the existing hook's (runId, hookId) matches the incoming event, throw EntityConflictError so the runtime's existing concurrent-replay catch path swallows it. Different runId or hookId reusing the same token still produces a real hook_conflict. Refs #2283 * test(e2e): add regression test for hook_conflict from same-tick replay race Regression test for #1665 / #2283. A parent workflow awaits 6 child workflows with Promise.all; each child does a tiny step and creates one webhook. Awaited children flatten into the parent run, so all webhook creations land on the same workflow body. When their step resolutions align in the same tick the workflow body is re-walked and each pass submits hook_created with the same deterministic (correlationId, token). Before the world-side idempotency fix, the world wrote hook_conflict events for the duplicates and the workflow failed with HookConflictError. With the fix, duplicates throw EntityConflictError (swallowed by the suspension handler), no hook_conflict events appear in the log, and the webhooks resolve normally. Verified locally against world-local: the test fails reliably (3/3) on the unfixed code and passes reliably (5/5) on the fixed code. * test(e2e): rewrite parallelStepsThenWebhookWorkflow to match the actual #1665 repro The earlier version invoked another 'use workflow' function directly from inside the parent workflow, which is not a valid child-workflow invocation (child workflows must be spawned via start()) and didn't mirror the bug shape on #1665 anyway. Rewrite the workflow as a single 'use workflow' function that exactly mirrors Paolo's minimal repro: await Promise.all([stepA(), stepB()]); using webhook = createWebhook(); await webhook; The for-loop runs N independent iterations of that sequence in series, each disposing its webhook via 'using' before the next, to give the timing-sensitive race multiple chances to fire. The race is hard to force deterministically on fast local dev — but the same (runId, hookId) idempotency invariant is covered deterministically by the new unit tests in world-local and world-postgres. This e2e test serves as a higher-level regression net: its assertions (no hook_conflict event in the log, no HookConflictError-failed run) are correct whether the race fires or not, and will catch any future regression on a run that does hit it. * fix(world-local,world-postgres): recover crash-orphaned hook claims/rows instead of suppressing the retry Addresses review feedback on PR #2295. The original idempotency fix made duplicate same-(runId, hookId) hook_created submissions throw EntityConflictError so the suspension handler's concurrent-replay catch path swallows them. But the claim file (world-local) and hook row (world-postgres) are written before the durable hook_created event, and the writes are not atomic. A process / DB interruption between the claim/hook write and the event write leaves an orphaned claim/hook row; the retry then matched the same (runId, hookId), threw EntityConflictError, got swallowed, and the run was permanently left with no hook_created event in the log. world-local: - Add a per-(runId, hookId) in-process mutex (withHookLock) mirroring the existing withStepLock, so two same-tick concurrent calls serialize on the entity write and the dedup branch never observes an in-flight winner mid-write. - In the dedup branch, when the existing claim is for the same (runId, hookId) we are trying to create, check whether the durable hook entity actually exists on disk: - exists → real duplicate: throw EntityConflictError as before. - missing → orphaned claim from a prior crash: fall through and complete the partial write (write the hook entity with overwrite, then emit hook_created via the outer code path). world-postgres: - In the dedup branch, when the existing hook row matches the incoming (runId, hookId), check whether a hook_created event for this (runId, correlationId) already exists in the event log: - exists → real duplicate: throw EntityConflictError as before. - missing → orphaned hook row from a prior crash between hook INSERT and events INSERT: skip the hook insert (the row is already there) and let the outer code path emit hook_created, completing the partial write. Tests: - world-local: pre-seed an orphaned token claim with no matching hook entity, retry hook_created, assert hook entity and hook_created event both land (no hook_conflict, no EntityConflictError). - world-postgres: pre-seed an orphaned hook row with no matching hook_created event, retry, assert hook_created event lands (no hook_conflict, no EntityConflictError). Both tests fail on the prior implementation (EntityConflictError thrown on retry, exact symptom from the review). * fix(world-local): probe the event log (not the hook entity) to detect duplicate hook_created Addresses follow-up review on PR #2295. The previous dedup branch checked whether the durable hook entity existed on disk. But the hook entity is written before the `hook_created` event, and the two writes are not atomic, so a crash between them leaves both the claim file and the hook entity on disk with no event in the log. The dedup branch then matched on `(runId, hookId)`, found the hook entity, threw EntityConflictError, and the suspension handler swallowed the retry — permanently losing `hook_created` from the event log. The fix mirrors what the world-postgres branch already does: probe the run's event log for an existing `hook_created` event for the same `(runId, correlationId)`. The event is the durable record of a successful hook creation; the claim file and hook entity are partial- write artifacts that may exist without the event. - exists → real duplicate: throw EntityConflictError so the runtime's concurrent-replay catch path swallows it. - missing → orphaned partial write (crash at any point before the event landed): re-write the hook entity (with overwrite: true, in case a stale partial copy exists) and let the outer code path emit the hook_created event. Added a new helper findHookCreatedEvent that runs a filtered paginatedFileSystemQuery with limit:1 over the run's events. Regression test "should recover an orphaned hook entity with no matching hook_created event" added — pre-creates a hook, deletes just the hook_created event from disk to simulate a crash between the entity write and the event write, asserts the retry emits a fresh hook_created event (no hook_conflict, no swallowed EntityConflictError). I verified this test fails on the prior fix (throws `EntityConflictError: Hook "hook_orphan_entity_1" already created`, exactly as pranaygp reported) and passes on this commit. The previous test ("should recover an orphaned hook token claim with no matching hook entity") continues to pass — the event-log probe is a strict superset of the entity probe, since a missing entity always also implies a missing event. * fix(world-local): converge same-hook creation across workers via canonical eventId Addresses follow-up review on PR #2295. The previous fix made the dedup branch probe the event log to decide real-duplicate vs orphan-recovery, but the probe and the recovery write are not a single atomic operation. Two workers sharing a data directory (or two retries that lose `writeExclusive(constraintPath)` back to back) could both pass the probe (each observing no hook_created event yet), both fall through to the recovery write, and both append a hook_created event with a different eventId — producing two events in the log for the same (runId, hookId). The in-process `withHookLock` mutex does not help here because it is process-local and tag-specific. The fix persists `eventId` in the durable token claim file (written by the original `writeExclusive(constraintPath)`). On a same-(runId, hookId) dedup match, retries adopt that canonical eventId and rebuild the event with a deterministic createdAt derived from the eventId (a ULID). The outer event write switches from `writeJSON` (check-then-write, TOCTOU) to `writeExclusive` (O_CREAT|O_EXCL via temp-file + hard-link, atomic across processes). Either worker may win the publish; the other throws EntityConflictError which the runtime's existing concurrent-replay catch path swallows. Net result: exactly one hook_created event per logical creation. Backward compatibility: a claim file written before this commit lacks `eventId`. Retries that read such a claim fall back to the event-log probe + fresh-eventId recovery — the legacy behavior that does not converge across workers but cannot regress for freshly- written claims after upgrade. world-postgres already converges across workers via the partial unique index on workflow_events_entity_creation_unique (runId+correlationId+eventType for hook/step/wait_created): the loser's INSERT raises 23505 which is already translated to EntityConflictError. Regression tests: - world-local: `converges same-hook creation across workers to one event` uses two tagged storage instances sharing one data directory and fires 25 paired Promise.allSettled hook_created calls. Expected 25 hook_created events total; before this fix yielded 50. - world-postgres: `converges same-hook creation across concurrent calls to one event` exercises the same shape against the real Postgres unique index. Already converges; the test is a guard against future regressions to the catch path. Verified the world-local test fails on c7b23e1b5 with exactly the shape pranaygp reported (50 events for 25 logical creations) and passes on this commit. The earlier orphaned-claim and orphaned- entity recovery tests also continue to pass. * fix(world-local): converge legacy hook claims via recovery-marker sidecar; replace tag-proxy test with real subprocess workers Addresses follow-up review on PR #2295. Two distinct issues, both flagged by pranaygp as P1: 1. The fallback path for token claims written by versions before eventId was persisted inline (legacy claims after upgrade) still permitted the same cross-process corruption the inline fast path was fixed to prevent. Two processes both reading a legacy claim each generated their own eventId, landed their writeExclusive(eventPath) calls at different paths, and appended two hook_created events for the same (runId, hookId). Existing persisted claims after a real upgrade are exactly the state the crash-recovery branch needs to repair, so leaving the legacy path non-convergent is silent corruption, not backward compatibility. 2. The committed cross-worker convergence test used two tagged storage instances sharing one directory as a proxy for separate processes. But tags change the destination filename (events/wrun_X-evnt_Y.worker-a.json vs ...worker-b.json), so two tagged workers can each writeExclusive their own event at different paths and both fulfill. The Map-by-eventId deduplication in the assertion then masked the duplicate publication, so the test passed for the wrong reason. Implementation: - New HookRecoveryMarkerSchema (`{ eventId, hookId, runId }`) and HookRecoveryMarkerPath helper. The marker is a sidecar at hooks/tokens/<hash>.recovery.json, written via writeExclusive so the first cross-process retry pins its candidate eventId as canonical; subsequent retries read the marker and adopt that eventId. Together with the existing writeExclusive(eventPath) in the outer publish, this gives the legacy-fallback path the same single-event convergence guarantee as the inline-eventId fast path. - pinCanonicalEventIdForLegacyClaim() encapsulates the marker write-or-read. A stale marker for a different (runId, hookId) (token-reuse with leaked state) is overwritten best-effort — the common cross-worker race for the same hook still converges; only the narrow stale-token-reuse case loses convergence. - hook_disposed now also deletes the recovery marker when it deletes the token constraint file, preventing a future legacy recovery for a recycled token from latching onto a stale eventId. - The dedup branch unified: existingClaim.eventId for new claims, pinCanonicalEventIdForLegacyClaim() for legacy ones. Removed the now-redundant findHookCreatedEvent helper — the writeExclusive(eventPath) in the outer publish is the authoritative duplicate-vs-orphan detector. Tests: - New test fixture test-fixtures/hook-race-worker.ts (TypeScript, run via child_process.fork with tsx as execPath — tsx is a transitive dev dep via vitest). Each subprocess gets its own createStorage(testDir) so the in-process hookLocks Map cannot serialize across workers. - Replaced the tag-proxy test with "converges same-hook creation across separate OS processes to one event". Spawns workerCount subprocesses, releases them from a barrier into the same hook_created, asserts exactly one fulfilled + (N-1) rejected with EntityConflictError, and asserts directly on the raw events.list() result (no Map dedup) that the number of hook_created entries equals the number of logical creations. - Added "converges same-hook creation across processes when only a legacy token claim exists". Same shape, but pre-seeds the legacy claim format (`{ token, hookId, runId }` with no eventId) before each race. Verified to FAIL on 7ce66551b (both subprocesses fulfill, no convergence) and pass on this commit. - Also verified the new-eventId subprocess test FAILS when the event write is reverted to writeJSON (TOCTOU), confirming it exercises the writeExclusive-based cross-process arbitration. Both prior orphaned-claim / orphaned-entity recovery tests also continue to pass. * fix(world-local): per-lifetime recovery markers, restore event-log probe, fix CI tsx resolution Addresses three P1 review comments on PR #2295. 1. Stale recovery marker leaking across token-reuse lifetimes (pranaygp): The previous marker path used `hashToken(token)` so a stale marker for run A could leak into run B's recovery when the same token was reused after run A terminated through normal lifecycle. `deleteAllHooksForRun()` and tagged `world.clear()` deleted the token constraint and hook entity but NOT the marker sidecar, so the next legacy claim on the same token entered the stale-marker overwrite branch and the workers overwrote it non-atomically, yielding divergent publication. Fix: - Marker path now hashes `(token, runId, hookId)` together (`hookRecoveryMarkerPath` in storage/helpers.ts). Different lifetimes can never share a marker, so the stale-marker overwrite branch is removed entirely. - `hookRecoveryMarkerPath` is moved to helpers.ts and shared across events-storage.ts, hooks-storage.ts, and index.ts. - `deleteAllHooksForRun()` and tagged `world.clear()` now also delete the recovery marker for each hook (disk hygiene; per- lifetime identity makes leaks no longer corrupting). - `hook_disposed` now uses the new per-lifetime marker path too. 2. Duplicate `hook_created` event when a legacy claim's event was already published (VADE bot, also implied by pranaygp's analysis): Removing the event-log probe from the legacy fallback let a post- upgrade retry pin a new canonical eventId via the marker and publish a duplicate event at that path, even when the original pre-upgrade writer had already successfully published the event with its own (different) eventId. Fix: - Restore `findExistingHookCreatedEventId()` (renamed and made to return the eventId for clearer semantics). - Legacy fallback now probes the event log BEFORE pinning the marker; if a matching `hook_created` event already exists, throw `EntityConflictError` so the runtime's concurrent-replay catch path swallows the retry. - Inline-`eventId` fast path does NOT need the probe — the claim itself is the durable convergence key. 3. CI failure: tsx not resolvable under pnpm isolated linking (pranaygp; confirmed by ubuntu/windows unit test 60s timeouts): The previous test hard-coded `node_modules/.bin/tsx` assuming tsx would be hoisted there. But tsx was only a transitive peer dep via vitest, and pnpm's isolated linking does NOT link transitive peer deps into the workspace bin after a fresh install — so neither root nor package-local `.bin/tsx` existed in CI, the subprocess fork never started, and the barrier hung until vitest killed the test. Fix: - Add `tsx` as a direct `devDependency` of `@workflow/world- local` (pinned to 4.20.6 to match the existing transitive resolution). - Resolve via `import.meta.resolve('tsx/package.json')` and read the `bin` field dynamically, so we adapt to wherever pnpm links tsx for this package — not a hard-coded layout. - Lazy-init the resolver (no module-load IIFE) so an absent tsx fails only the convergence tests, not all 376 tests in the file. - Surface a clear error message if resolution fails, calling out the cause (transitive vs direct deps) for future readers. Also: harden the barrier helper so `error` events and pre-ready exits resolve BOTH `readyPromises` and `donePromises`, then `SIGKILL` siblings. Previously a broken child only resolved `donePromises`, leaving `Promise.all(readyPromises)` pending until the per-test timeout (60s in CI). Regression tests added: - `legacy claim whose hook_created event was already published does not append a duplicate event` — pre-seeds a legacy claim AND a pre-existing `hook_created` event with a different eventId, asserts the retry throws EntityConflictError and the log still has exactly the original event. - `converges legacy claim recovery across run lifetimes after token reuse` — runs pranaygp's full lifecycle path: race subprocess workers on run A's legacy claim, terminate run A via `run_completed` (triggers `deleteAllHooksForRun`), reuse the token in a legacy claim for run B, race subprocess workers again, asserts exactly one fulfillment + one `EntityConflictError` per race and exactly one `hook_created` event per run. Both new tests verified to fail on 2c673e436 (after rebuilding): the published-event test throws via duplicate publish instead of EntityConflictError, the token-reuse test sees both run B workers fulfill (2 events instead of 1). The existing orphaned-claim and orphaned-entity recovery tests also continue to pass. CI loop confirmed to be repaired locally by spawning subprocesses via the new resolver and intentionally breaking the worker fixture to verify the helper fails fast (~500ms) instead of hanging at the barrier. * fix(world-local): defer hook entity write until event publish commits Addresses karthikscale3's P1 review comment on PR #2295. The dedup-recovery path used to write the hook entity BEFORE the outer event publish proved whether the attempt was repairing a missing event or just colliding with an already-published `hook_created`. For already-committed duplicates, the event write then throws `EntityConflictError`, but the hook entity had already been overwritten with the retry's payload — leaving the durable hook entity and the event log inconsistent (e.g. the entity reflects the retry's metadata while the event still carries the original). karthikscale3 reproduced this on the prior head by creating `hook_created` with metadata `{ v: "a" }`, then retrying the same `(runId, hookId, token)` with metadata `{ v: "b" }` and `isWebhook: false`: the retry threw `EntityConflictError` but `hooks.get()` returned the retry's payload. Fix: defer the hook entity write until AFTER the outer `writeExclusive(eventPath)` commits. The branch now only captures the entity-to-write and its overwrite options; the actual write happens immediately after the event publish in the shared trailing block. A retry that ends in `EntityConflictError` (the event was already published) now leaves the entity untouched. The first-writer happy path and all recovery paths (orphaned- claim, orphaned-entity, cross-worker convergence, legacy claim, token-reuse across lifetimes) are unaffected — they all reach the event publish successfully, then the entity write runs as before. Regression test `does not mutate an already-committed hook entity when a duplicate hook_created retry collides` added to world-local: runs karthikscale3's exact scenario and asserts the persisted entity still carries the original metadata and isWebhook. Verified to fail on the prior commit (persisted metadata = 0xbb instead of 0xaa) and pass on this commit after rebuilding. Parallel guard test `does not mutate an already-committed hook entity when a duplicate hook_created retry collides` added to world-postgres. Postgres already protected this via `onConflictDoNothing()` on the hook INSERT, but the test guards against a future regression that adds an UPDATE/UPSERT to the dedup path. * refactor(world-local): per-instance in-process locks; drop tsx subprocess test plumbing You were right that the tsx subprocess machinery was overkill for a storage-level convergence test. Replaced with a simple two-instance in-process test that exercises the same cross-process semantics without spawning anything. The trick: `stepLocks` and `hookLocks` were module-level Maps shared by all `createEventsStorage` calls in the same process. Move them inside the function so each `createStorage(dir)` call gets its own lock map. Two storage instances sharing one data directory then behave exactly like two separate OS processes: - independent in-process `hookLocks` Maps (no in-process serialization between them), and - a shared filesystem (so the on-disk `writeExclusive` claim / marker / event publish primitives are the only thing arbitrating convergence). This is also a real architectural improvement — the global lock map was always a leaky abstraction that made unit-test simulation of the cross-process path awkward. Changes: - `stepLocks` and `hookLocks` moved from module scope into `createEventsStorage`. `withStepLock` and `withHookLock` wrappers collapsed into direct `withInProcessLock(map, key, fn)` calls at the two call sites that need them. - The three convergence regression tests in `storage.test.ts` now use `const workerA = createStorage(testDir); const workerB = createStorage(testDir);` and race `Promise.allSettled` of `events.create` from both — no subprocess, no IPC, no barrier helper, no `raceHookCreatedAcrossProcesses`. Same assertions (exactly one fulfillment + N-1 `EntityConflictError` per race, raw `events.list()` shows exactly one `hook_created` per logical creation — no Map dedup) so the regression catches are identical. - Removed: `tsx` devDep, `test-fixtures/hook-race-worker.ts`, `HOOK_RACE_WORKER` / `resolveTsxLoaderUrl` / `TSX_BIN` / `raceHookCreatedAcrossProcesses` and the `fork`/`fileURLToPath` imports they pulled in. Verified (after rebuilding world-local): - All 379 tests pass on macOS in ~1s (was ~6.7s with subprocesses). - Convergence tests confirmed to still catch the bugs: temporarily reverted the `eventId = canonicalEventId` adoption → both workers fulfilled (2 events instead of 1). Temporarily reverted the legacy-claim marker pin → same: both workers fulfilled. - No subprocess machinery means no Windows-specific quirks (cli.mjs shebang, .cmd wrappers, .bin hoisting under pnpm isolated linking, etc.) that produced the Windows CI 60s timeouts. - World-postgres still has its own parallel guard test for the karthikscale3 "no-mutate-on-duplicate" regression; that one exercises real DB concurrency and is unaffected by this change. Full repo `pnpm test` (43 packages) and the `parallelStepsThenWebhookWorkflow` e2e test against world-local both green. * fix(world-local): repair event-first hook orphans from the persisted event; skip #1665 e2e on world-postgres - A crash between the hook_created event publish and the deferred hook entity write left the event committed with the entity missing and unrepairable (retries threw EntityConflictError without materializing the entity). Retries now rebuild the entity from the PERSISTED event's payload — never the retry's eventData — via a race-safe writeExclusive, on both the canonical-eventId collision path and the legacy-claim probe path. - Skip parallelStepsThenWebhookWorkflow e2e on world-postgres: the same-tick replay pattern surfaces a separate pre-existing step_started ordering bug there (#2331). --------- Co-authored-by: Peter Wielander <peter.wielander@vercel.com> |
||
|
|
ce5dedca79 |
docs(observability): remove MVP implementation detail bullet (#2367)
Co-authored-by: v0agent <it+v0agent@vercel.com> |
||
|
|
564a47c504 |
fix: settle aborted parallel steps before completing abortParallelWorkflow (#2244)
* fix: wait for aborted parallel steps to settle * test: assert aborted results for parallel abort workflow |
||
|
|
ae8d6feeda |
Add native v4 workflow attribute events (#2226)
* Add native workflow attribute events * Fix abbreviated attributes docs sample * Document attribute replay ordering for step races * Address native attribute review feedback * Validate before claiming attr_set dedup lock; clearer start() attribute errors - world-local: claim the attr_set correlation lock only after validation, so a validation failure does not permanently mark the correlationId as written and wedge the run in a re-invoke loop on retry - world-postgres: distinguish a concurrently-deleted run from a cap violation when the guarded attributes update matches no rows - core: reject non-string initial attribute values in start() with a clear error instead of a downstream schema failure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add attribute edge-case tests across all layers - core: normalizeAttributeChanges unit tests (non-object inputs, FatalError wrapping, key/value/batch limits, boundary lengths, UTF-8 byte counting) - core: start() rejects reserved keys, oversized keys/values, and over-cap initial attribute batches before any write - world-local + world-postgres: per-run cap enforced against existing attributes (upsert-at-cap allowed, removal frees room), oversized values rejected on attr_set, invalid initial attributes rejected on run_created - e2e: validation DX workflow asserting every invalid write throws a catchable FatalError naming the violated rule and limit, with the run staying healthy; start() rejects invalid initial attributes client-side Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove accidentally committed local e2e diagnostics artifact Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bump world-vercel to spec version 4 for native attributes The deployed workflow-server (vercel/workflow-server#469) materializes native attr_set events and accepts initial run attributes, but world-vercel still advertised spec v3 — so start(..., { attributes }) rejected itself client-side ('requires spec version 4') on every Vercel deployment, failing the new e2e seeding test across the prod matrix. New runs are now stamped v4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Reject duplicate correlated attr_set before materializing in Postgres A redelivered duplicate — including one carrying different changes for the same correlationId — previously re-applied the run attributes update and only then failed the event insert, leaving the snapshot out of sync with the event log. Pre-check the event log for the correlationId before mutating; the unique index still guards the truly-concurrent race, which is idempotent (deterministic replay carries identical changes). Also apply the suggested docs wording for initial attributes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Apply suggestion from @VaguelySerious Signed-off-by: Peter Wielander <mittgfu@gmail.com> * Fail the run on World-rejected attribute writes; un-nest runtime test Two fixes from review: - runtime.test.ts: the pre-existing test "propagates transient step_created failures..." was accidentally nested inside the new attribute-race test, failing the new test ("Calling the test function inside another test function is not allowed") and preventing the old test from running. Restored it verbatim at describe level. - A workflow-body attr_set the World rejects as invalid (e.g. the cumulative per-run attribute cap, which only the World can check) is deterministic: redelivering the orchestrator message replays the same write into the same rejection, wedging the run in redelivery with no terminal event. handleSuspension now wraps such rejections in FatalError, and workflowEntrypoint fails the run with the validation error instead of rejecting the delivery. Transient storage errors still propagate and retry via redelivery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: Peter Wielander <peter.wielander@vercel.com> |
||
|
|
05e46fa3f6 |
Version Packages (beta) (#2326)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>workflow@5.0.0-beta.14 |
||
|
|
884bd8db76 | [ci] Fix flaky windows unit tests (#2359) | ||
|
|
c000462502 | Capture Vercel runtime logs when e2e Vercel Prod lanes fail (#2356) | ||
|
|
4e8a9657c9 | Fix e2e failure reporting under vitest 4 and preserve fetch error causes (#2355) | ||
|
|
a813382216 |
[core] Fix process crash from rejected waitUntil promises (#2336)
Co-authored-by: Peter Wielander <mittgfu@gmail.com> |
||
|
|
bf44d4dd0a |
[core] Remove duplicate waitUntil for suspension handler async operations (#2345)
Co-authored-by: Peter Wielander <mittgfu@gmail.com> |
||
|
|
95d7009e8a |
Prevent local tests from hanging (#2338)
* Prevent local tests from hanging This commit allows manually running pnpm test: - use vitest run to stop world-vercel and world-testing when complete - disable Nuxt telemetry to prevent interactive telemetry prompt Signed-off-by: Justin Xu <xu.justin.j@gmail.com> * Disable Nuxt telemetry via workspace .nuxtrc instead of inline env vars The inline NUXT_TELEMETRY_DISABLED=1 in package.json scripts doesn't work on Windows (pnpm runs scripts through cmd.exe). A workspace-root .nuxtrc is cross-platform, and @nuxt/kit resolves it from the pnpm workspace root, so it also covers workbench/nuxt's build/dev, which hit the same first-run consent prompt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Signed-off-by: Justin Xu <xu.justin.j@gmail.com> Co-authored-by: Pranay Prakash <pranay.gp@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4670c4b92d |
feat(core): add optional namespace for queue topic prefix (#2305)
* feat(core): add optional namespace for queue prefix * fix(world-postgres): job queue prefix validation * fix: changeset description Co-authored-by: Peter Wielander <mittgfu@gmail.com> Signed-off-by: Will Sather <56037657+willsather@users.noreply.github.com> * fix: add world-postgres to changeset * fix: world-postgres handle namespaced job queue names * fix: resolve namespace via env var in core runtime * fix: world-postgres job queue name task handler * fix(world-postgres): honor namespace on consumer side * Fix namespaced queue routing reliability (#2340) * Fix namespaced queue routing reliability * Inline queue namespace in generated routes * Avoid loading Vercel functions during runtime import --------- Signed-off-by: Will Sather <56037657+willsather@users.noreply.github.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> Co-authored-by: JJ Kasper <jj@jjsweb.site> |
||
|
|
eb2b8c988d |
[web-shared] Show precise durations in the new trace viewer (#2335)
* [web-shared] Show precise durations in the new trace viewer The events list and timeline bar labels now render two-decimal seconds for durations over 1s instead of rounding to whole seconds. Co-authored-by: Cursor <cursoragent@cursor.com> * [web-shared] Fix duration rounding at unit boundaries formatDurationPrecise bucketed durations on the raw ms value but only rounded at display time, so inputs just below a unit boundary carried into the next unit without re-bucketing (e.g. 59999ms -> "60.00s"). Round to centisecond precision FIRST, then decompose in integer centisecond space so the seconds component stays in [0.00, 59.99] and carries re-bucket into the next unit (59999ms -> "1m 0.00s"). Co-authored-by: Cursor <cursoragent@cursor.com> * [web-shared] Simplify precise duration formatting Reuse the existing MS_IN_* constants instead of the centisecond decomposition; behavior is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
f5f6d0ede6 |
Validate unique workflow step IDs at build time (#2018)
* Validate unique step ids at build time
* Fall back to file-path IDs for non-exported package files
Instead of synthesizing a 'name/dist/<path>@version' specifier (which
hardcoded the dist/ output convention), non-exported workspace/node_modules
files now return moduleSpecifier: undefined and let the SWC plugin's
'./{filepath}' fallback produce per-file IDs. This is the same path local
app files have always taken and avoids the dist/ assumption flagged in
review. The build-time duplicate-ID check stays as the safety net.
* Dedupe virtual-entry imports by canonical module identity
When both the source and the compiled-dist copies of the same workspace
package export end up in discoveredSteps/discoveredWorkflows (e.g. the
'workflow' package's internal/builtins in monorepo dev), they resolve to
the same module via esbuild's package resolution. The virtual entry was
emitting BOTH 'import "workflow/internal/builtins";' (the built-in
preamble) and 'import "../../packages/workflow/src/internal/builtins.ts";'
(via the isWorkspaceSourceBackedPackageFile carve-out in createImport),
which made the swc plugin transform both copies and generate duplicate
step IDs.
Track a per-bundle set of emitted module identities (package specifier
when reachable, otherwise the file path) and skip files whose identity
has already been imported. The steps bundle pre-seeds the set with the
built-in steps specifier so workspace step files at that path don't
emit a competing relative-path import.
* Stop rewriting workspace package /dist/ -> /src/ during Next.js discovery
The Next.js deferred builder's `resolveSourceBackedPackagePath` rewrote
any discovered `/dist/` path to its `/src/` sibling for workspace
packages and for `workflow`/`@workflow/*` tarballs. That made the
discovered step file list point at source files while base-builder's
esbuild bundle (which builds the workflow VM and step registrations)
resolved the same package imports through `pkg.exports` to
`/dist/`. The workflow proxy ID — generated from the dist path —
didn't match the step bundle's registration ID — generated from the
src path — producing "Step function not registered" failures at
runtime, most visibly with @workflow/ai's doStreamStep on Vercel and
Windows Next.js deployments.
App code that imports a package by name should resolve naturally
through pkg.exports; the loader has no business reaching into the
package's source tree. Drop the rewrite (and the now-unused
`resolveCopiedStepImportTargetPath` helper that supported it).
Workspace packages are still discovered — that's a separate predicate
(`shouldPreferSourceBackedPackagePath`) which only gates inclusion,
not path translation.
Verified locally with the nextjs-turbopack workbench: agent e2e suite
(19 tests, including the failing `agentBasicE2e`) and the
addTenWorkflow duplicate-name suite all pass.
* Address review nits: extract stripPackageVersion, expand duplicate-ID hint, note new build-time check in changeset
---------
Co-authored-by: Nathan Rajlich <n@n8.io>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
|
||
|
|
b5396bc932 |
Move run attributes into their own detail card (#2327)
* Move run attributes into their own detail card Render the run's `attributes` field as a dedicated collapsible DetailCard in the detail panel instead of as a cramped JSON value inside the top metadata key/value list. Co-authored-by: Cursor <cursoragent@cursor.com> * Create empty-worlds-throw.md Signed-off-by: Mitul Shah <mitulxshah@gmail.com> * Render attributes card after Input/Output Co-authored-by: Cursor <cursoragent@cursor.com> --------- Signed-off-by: Mitul Shah <mitulxshah@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
eb976db35b | [core] Forward-port stream reconnect to getReadable level (#2318) | ||
|
|
b549342c5c | [docs] Add "Step executed multiple times" error page (#2310) | ||
|
|
3e49c6ebf4 |
Fix flickering on the detail panel when navigating the trace viewer (#2325)
* ok * Update events-list.tsx * Apply suggestion from @VaguelySerious Co-authored-by: Peter Wielander <mittgfu@gmail.com> Signed-off-by: Mitul Shah <mitulxshah@gmail.com> --------- Signed-off-by: Mitul Shah <mitulxshah@gmail.com> Co-authored-by: Peter Wielander <mittgfu@gmail.com> |
||
|
|
73e64bba03 |
Version Packages (beta) (#2254)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>workflow@5.0.0-beta.13 |
||
|
|
bb6ff9ac99 |
Patch vulnerable package dependencies (#2301)
* chore: patch package dependency vulnerabilities Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> * Prefer direct dependency upgrades for security fixes --------- Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> |
||
|
|
3ae0ae2917 |
Deprecate DurableAgent and update it with WorkflowAgent in v5 docs (#2285)
* docs: deprecate DurableAgent in v5 * docs: fix workflow docs CI failures * docs: trim deprecated DurableAgent guidance * docs: point v5 cookbook to WorkflowAgent * docs: fix v5 cookbook navigation * docs: align WorkflowAgent guide * docs: add WorkflowAgent cookbook heading * docs: expand WorkflowAgent cookbook handoff |
||
|
|
867e33903d |
[codex] Fix partial world-local exclusive writes (#2296)
* fix(world-local): publish exclusive files atomically * fix(world-local): guard temp cleanup |
||
|
|
c19f38d907 |
[world-vercel] Validate ref resolve responses before use (#2035)
* [world-vercel] Validate ref resolve responses before use
When workflow-server returns a ref body to the SDK, the bytes are
fed into the workflow runtime's event log and deserialized via
`decodeFormatPrefix`. The SDK always writes ref payloads with at
least a 4-byte format prefix (see `encodeWithFormatPrefix` in
`@workflow/core`), so a zero-byte response — or one whose length
disagrees with `Content-Length` — is never a valid stored value.
Before this change, `resolveRefDescriptor` had no validation: a
200 with an empty body would be passed downstream as a zero-length
Uint8Array, which then failed deep inside replay with:
Data too short to contain format prefix: expected at least 4 bytes, got 0
By that point the workflow's in-memory event snapshot is already
poisoned with the empty payload, so every subsequent replay
deterministically reproduces the same failure, downstream
`resumeHook()` calls surface as `Hook not found`, and the run
only unsticks when stale-run cleanup terminates the sandbox.
This catches the failure at the transport boundary instead, where
it can be retried as a `WorkflowWorldError`. Both an empty body
and a length mismatch (truncated streaming response) are rejected.
This is the SDK-side companion to vercel/workflow-server#432, which
adds the same validation on the server side.
* Address review: reject <4-byte bodies, handle malformed Content-Length
Three review changes:
1. Reject any body shorter than the 4-byte format-prefix length, not
just zero-byte bodies. The SDK guarantees every stored ref payload
starts with a 4-byte format prefix (FORMAT_PREFIX_LENGTH in
@workflow/core), so a 1-3 byte body would also fail downstream
replay with the same 'Data too short to contain format prefix'
error this PR exists to prevent.
2. Parse Content-Length safely with parseInt + Number.isFinite +
non-negative checks instead of bare Number(). A non-numeric value
like 'abc' would otherwise produce NaN and silently surface as a
'truncated' error, masking the real cause. Malformed values are
treated as absent; the minimum-length check still defends against
actual truncation in that case.
3. Add tests for the truncated-body-without-Content-Length case
(chunked transfer where Content-Length validation can't see the
truncation), and for a malformed Content-Length header that should
be ignored rather than misreported as truncation.
The validation logic also moves into a small assertValidRefBody
helper to keep the inner trace function under the noExcessiveCognitiveComplexity limit.
* Address review: scope 4-byte minimum to binary refs, strict Content-Length parsing
- Only apply the 4-byte format-prefix minimum to application/octet-stream
payloads; CBOR refs can legitimately be 1-byte primitives (true/0/null).
- Require Content-Length to be a plain run of digits before comparing;
parseInt would otherwise accept numeric-prefixed garbage ('12junk' -> 12).
- Make the changeset succinct.
* Address review: skip Content-Length check for compressed responses
fetch/undici transparently decompresses gzip/br bodies but leaves
Content-Length describing the encoded (compressed) size, so comparing it
against the decompressed byteLength would reject valid compressed refs as
a phantom 'ref-body-length-mismatch'. Skip the comparison when a
non-identity Content-Encoding is present; an absent or 'identity' encoding
is still validated. Adds regression tests for both cases.
|
||
|
|
aa628b7a8f | fix: bump devalue to 5.8.1 (#2292) | ||
|
|
ccd37e9a59 |
Handle lazy stream key request failures (#2257)
Signed-off-by: Pranay Prakash <pranay.gp@gmail.com> |
||
|
|
0e39f3b657 |
fix(docs): declare Nitro auto-import globals for code samples (#2290)
The nitro-native-build changelog sample calls useStorage() (a Nitro server-side auto-import) from a step, which the docs type-checker couldn't resolve and failed with TS2552. Add liberal global declarations for useStorage/useDatabase/useRuntimeConfig so Nitro auto-imports type-check in docs samples. |
||
|
|
0fd0891cc4 |
[core] Preserve event-log order in hook-vs-sleep replay races (#2171) (#2185)
Co-authored-by: Nathan Rajlich <n@n8.io> |