Karthik Kalyan
90d · built 2026-09-08
Performance
What Karthik Kalyan shipped in the selected window, measured in ETV, and how it compares with the 90 days before it.
Effective capacity
+2.5engineers
delivers like 3.5 (3.5x pre-AI)
Output (ETV)
21.1ETV
+179.4% vs 7.5 prior
Features share
35.3%
−6.2 pp vs prior window
Fixes share
10.2%
−11.7 pp vs prior window
Work mix
35.3% Features5.2% Maintenance40.2% Tests9.1% Docs10.2% Fixes
58 commits over 90 days, ending 2026-09-08.
Daily performance
Daily ETV, stacked by Features, Maintenance, Tests, Docs and Fixes.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 10 by ETV in the last 90 days.
- 1.3ETVLazy hook resumption: parallel event write + queue publish (#3230) * feat(core): lazy hook resumption via parallel event write + queue publish (rebased onto #1834 + #3145) Rebase of #3230 onto current main (267765375 + #1834 resilient resumeHook + #3145 event-count-gated replay restart). Reconstructed as a single commit since `git rebase -i` is unavailable in this environment. Reconciliation vs the pre-rebase branch: - Replaces #1834's version-prediction (`supportsQueueHookInput`, `QUEUE_HOOK_INPUT_MIN_VERSION`) with #3230's capability protocol (persisted `hookResumeInputVersion` + static `hookResumeDedup`). - One idempotency protocol: a single `resumeId` + SHA-256 payload digest per resume, sent to both the direct event write and the queue `hookInput`. - Two execution tiers: backend+consumer attest dedup -> parallel `Promise.allSettled(event write, queue publish)`; otherwise plain sequential (no hookInput/resumeId, event-write errors propagate). - Consumer re-ensures the `hook_received` event (keyed by resumeId/digest) after event loading, before replay; skips when already preloaded. - Preserves #3145: event-count guard, `preconditionReinvocations`, in-process replay restart, `insertEventByEventId`. - Removes #1834's resumeId-only test (never released); adds parallel + consumer-preload + world-local dedup/producer-consumer suites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(core): read top-level event.resumeId in replay dedup; reconcile unreleased #1834 docs/changeset - hook.ts: dedup hook_received replay on top-level event.resumeId (the backend now hoists it to a first-class column), with the legacy nested eventData.resumeId retained as a deprecated parse-only fallback. - workflow.test.ts: cover dedup across both top-level and legacy nested forms. - resume-hook.ts: emit producer recovery telemetry when a transient event-write failure is swallowed on the parallel path. - resume-hook.consumer-preload.test.ts: add terminal-run (consume) and transient-conflict (rethrow/redeliver) re-ensure cases. - Consolidate the two overlapping changesets into resilient-resume-hook.md and delete the redundant lazy-hook-resumption.md. - Docs: return type back to Promise<Hook> (resume-hook.mdx), rewrite the resilience changelog to the final parallel/deduplicated design, and correct the WORKFLOW_DISABLE_LAZY_HOOK_RESUME resilience wording. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs,core: rename "Resilient hook resume" → "Lazy hook resume" for consistency - changelog/index.mdx: update the changelog entry title. - hook.ts: update the dedup comment label to "Lazy-resume dedup". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: give #3230 its own changeset instead of repurposing #1834's The lazy-hook-resume work had been folded into #1834's pre-existing `resilient-resume-hook.md` changeset. Give this PR its own changeset and delete the superseded #1834 one, whose `resilientResume: true` flag promise no longer holds (resumeHook() returns plain Promise<Hook>). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: add #3230's own lazy-hook-resumption changeset Follow-up to 63d877178, which deleted #1834's superseded changeset but did not stage the replacement. Adds this PR's own changeset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: tighten lazy-hook-resumption changeset Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: leave #1834's resilient-resume changeset/changelog/docs untouched Restore #1834's own artifacts that #3230 had rewritten: - .changeset/resilient-resume-hook.md (restored verbatim) - docs/.../changelog/resilient-resume.mdx (restored verbatim) - docs/.../changelog/index.mdx (restored verbatim) #3230 keeps only its own changeset plus the two docs its code/config genuinely require: the resumeHook() Promise<Hook> return type (ResumedHook is removed from the code) and the new WORKFLOW_DISABLE_LAZY_HOOK_RESUME env var. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Reconcile #1834 ResumedHook contract with #3230 parallel resume Preserve the resilient-resume contract from #1834 on the parallelized resumeHook() fast path instead of dropping it: - Restore the `ResumedHook` type (Hook + optional `resilientResume`) and its exports (`@workflow/core/runtime`, `workflow/api`); resumeHook/resumeHookImpl return `Promise<ResumedHook>`. - Set `resilientResume: true` on the swallow-recover branch (transient direct write failure + successful queue dispatch), absent on the happy/sequential paths. - Restore the producer OTEL convention `workflow.hook.resilient_resume` and the consumer `workflow.hook.resilient_resume_materialized`, wired where the consumer re-ensures the event. - Restore the consumer `occurredAt` derivation from the resume ULID so the materialized hook_received is dated to resume time, not queue-round-trip time. - Fix the #3230 changeset's contradictory "Still returns Promise<Hook>" line and update the resilient-resume changelog + resume-hook API reference to the shipped parallel/dedup behavior. - Port the #1834 failure-path coverage into resume-hook.parallel.test.ts (non-retryable event-write rethrow, both-fail prioritizes the queue error, resilientResume flag + payload delivery on the recovered path). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address review: drop dead nested resumeId fallback, remove server PR link - Drop the legacy nested `eventData.resumeId` fallback in the hook consumer. The nested form was only ever written by unreleased preview builds and is stripped by `EventSchema` parsing (the `hook_received` eventData schema does not declare it), so the fallback was dead code. Dedup now keys solely off the top-level `event.resumeId` column. Repoint the replay dedup test to the surviving top-level path (it previously exercised the nested form only by building unparsed Event objects in memory). - Remove the internal workflow-server PR reference from world-vercel's capability note (the link 404s outside the org); the note keeps the same information without the dead link. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>github.com-vercel-workflow · 31f92df1 · 2026-08-03
- 1.3ETVAdd attribute inspection to the CLI (#3950) * Add attribute inspection to the CLI and probe the cancel window once `wf inspect attributes` lists the distinct attribute keys on a project's runs with their run counts and first/last seen times, and `wf inspect runs --attribute key=value` filters by them. Between them they turn attributes from something you can only write into something you can discover and query. Both are analytics-only — storage has no cross-run attribute index — so the listing says so rather than falling back, and the filter warns and is ignored the way --since/--until already do. The flag is parsed and bounded in lib/inspect so the error names --attribute rather than the parameter it becomes, and so it is testable next to the other inspect flag helpers. It splits on the first `=` only, since a value may contain one, and keeps an empty value, which matches runs whose attribute was set to the empty string. `wf cancel` also probed the plan's listing window inside its per-status fan-out, so a four-status cancel issued four identical probes. The window is a property of the plan rather than of a status, so the probe is hoisted above the fan-out: eight requests become five. The harness only ever modelled the storage path, so that probe logic had no coverage; the new test fails with two probes before the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Do not depend on an unreleased world export for the flag cap The --attribute cap was imported from @workflow/world, where the constant is added by a different branch, so on main it resolved to undefined and `values.length > undefined` was always false: the flag accepted any number of pairs and the test for it never threw. Declare the cap in the CLI instead. The World and the backend enforce the same bound independently, and this copy exists only so the error can name the flag the user typed rather than the parameter it becomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Degrade sleeps to the event log and bound the inspect flags `wf inspect sleeps` was the only list path that could not degrade: it branched on analytics being present and either returned or exited, so on any backend providing analytics the storage branch below it was unreachable and an analytics failure ended the command. It now warns and falls through, like the run, step, and event listings. An argument the World rejected is not retried — the same argument fails either path, so falling back would trade a precise message for a slower failure. handleApiError also only recognised errors carrying an HTTP status. A client-side argument rejection has none, because no request was made, so it fell past every branch and was rethrown as an unhandled error. It is now reported as given: the message already names the method, the parameter, and what it received. --limit and --runId are checked before any backend setup so a mistyped value names the flag and costs no round trip. The limit bound is deliberately looser than the per-endpoint caps, which differ by resource and stay with the World; this one catches a typo'd digit or a negative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Scope --attribute to inspect and document the inspect flags --attribute was added to the shared cliFlags, which cancel, health, start, and web all spread — so `workflow health --attribute k=v` parsed and was silently ignored. It belongs with the other inspect-only filters in the command's own flags, next to --runId and --since. The configuration reference documented every shared flag but none of the inspect-only ones, so --runId, --stepId, --hookId, --since/--until, --withData and --decrypt had no entries at all. They now do, in an Inspect filtering section, alongside --attribute. --status and --workflowName were documented under bulk cancel only; both also filter inspect listings, which is now noted where they are. --limit's entry described a default with no bound and is now rejected outside 1 to 1000, so it says so, and points out that individual listings cap lower. The attributes guide claimed filtering was available "through the Analytics API", which is no longer the whole story: the CLI can now discover keys and filter by them, so that section splits into a CLI half and an API half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop dropping inspect flags silently Three flags the caller typed were being discarded without saying so — the same failure the World argument guards were added to remove, reintroduced one layer up. --attribute and --since/--until warned that the backend has no analytics read path, but that condition is also false when --withData asks for payloads, which only storage carries. Blaming the backend for the caller's own flag sends them looking in the wrong place, so the warning now names whichever applies. inspect attributes dropped --sort entirely, explained only by a code comment. It is forwarded now, and still left unset when absent so the backend's alphabetical key order stands rather than the `desc` the time-ordered listings impose. A repeated --attribute key silently kept the last value, and a test asserted that as if it were intended. Matching is per-key, so resolving it means discarding a filter the caller typed: it is rejected instead. The shared --limit entry also stated the 1-to-1000 bound that only inspect enforces, which is wrong for cancel's own 1-to-500. The bound moves to an inspect entry and the shared one points at both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Reject --attribute on listings that cannot use it Only the runs listing filters by attributes, but the flag was parsed for every inspect resource: `inspect steps --attribute tenant=acme` returned a normal, unfiltered step list with no warning, as did events, hooks, attributes, and `inspect run <id>`, which already names one run. That is the silent drop the preceding commit set out to remove, missed one layer up in the command itself. Validated alongside the other flag bounds, before any backend setup, so a flag on the wrong subcommand costs no round trip. Covered at the command level as well as in the unit, since the defect was not in the validator but in nothing calling it: the tests drive `Inspect.run` with a mocked setup module and assert the backend is never reached. Five of them fail without this change. Reported in review; verified against a real project rather than found by the suite, which is why the command-level coverage goes in with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Resolve the test's oclif root without a URL pathname `new URL('../..', import.meta.url).pathname` yields `/D:/a/...` on Windows — a leading slash before the drive letter — so `Config.load` could not find package.json and every command-level test failed there while passing on Linux. `fileURLToPath` handles both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review on the attribute flag Attribute keys naming an Object.prototype member were rejected as duplicates before anything was stored, because the duplicate check used `in`, which walks the prototype. `--attribute toString=v` failed on first sight, and `__proto__=v` would have set the prototype rather than stored a value had it got that far. The map is null-prototype now and the check uses Object.hasOwn. --url and --web return before the filter is parsed, and neither forwards it, so `inspect runs --attribute k=v --url` opened an unfiltered view and a malformed pair skipped validation entirely. Both are rejected: the dashboard takes no attribute filter. --sort carried an oclif default of desc, so the "forward only when asked" check in the attribute listing was always true and overrode the backend's alphabetical key order. Every time-ordered listing already falls back to desc itself, so the parser-level default is gone and the flag now means what it says. The docs claimed --since and --until must be supplied together, but the CLI resolves the pair before the World sees it: --since alone is valid and --until defaults to now. Only --until alone is rejected. The vercel[bot] comment about ANALYTICS_MAX_ATTRIBUTE_FILTERS not being exported was already addressed in 1811e4f0e, before #3943 landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review: hoist the attribute parse, cap the limit, merge main The INVALID_ARGUMENT handling was dead because this branch was cut from main before #3943 landed: nothing in the tree threw that code and WorkflowWorldError had no `field`, so both new arms were unreachable and the field assertion in output.test.ts described an API that did not exist here. Merging main makes all of it live, and makes the two comments claiming the World enforces these bounds true — world-vercel asserts them now. parseAttributeFilters ran inside toInspectOptions, after setupCliWorld, so a malformed pair paid for auth and a project lookup before failing. It is parsed in the same block as the other bounds now, and toInspectOptions receives the result. The gap was untested because the only malformed-pair case paired it with --url, which returns before the parse either way; there are now command-level cases for a missing separator, an empty key, too many pairs, and a duplicate key. --limit allowed up to 1000, but the cross-run listings cap at 100 and so does the storage step listing a run-scoped read falls back to, so 101-1000 produced an opaque backend 400 — and on steps it depended on whether analytics had rows for that run. Capped at 100, the smallest any reachable listing accepts. The docs claim that listings "report the limit they accept" was false and is gone. --attribute with --withData warned and returned every row, which is the failure the scope guard exists to prevent and is knowable at validation time. It is a hard error now. listSleeps degraded on any failure, including a plan-window 402 whose message tells the caller to upgrade. Access, plan, and invalid-argument failures are reported; only availability failures degrade. The comment claiming the sibling listings degrade was wrong — none of them do — and now says why sleeps is the exception. Also: --sort/--since/--until/--workflowName help text and the options type no longer say "runs only"; examples and the unknown-resource text list attributes; listAttributes and listRuns' filter forwarding have coverage, including both warning strings; the unreachable 'web' case is out of the scope test; the ineffective biome suppression is gone; and the cancel arithmetic is two statuses, so one probe is saved, not three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): warn on flags inspect attributes cannot apply; bound cancel --limit `inspect attributes` accepted --status, --runId, --stepId, --hookId and --withData, dropped all five, and printed the full key table. --status is the likely one: filtering runs by attribute and status together is documented, so reaching for it on the key listing is natural and the answer looks narrowed. Warn per flag, as the sibling listings do. `cancel --limit` advertised 1-500. Both read paths cap at 100 — the analytics runs listing rejects more locally, the storage listing it falls back to caps server-side — so 101-500 always failed, and cancel's catch handled only the plan gate and rethrew the rest with nothing printed. Bound it to 100 and route the catch through the shared reporter. Collapse the three actionable-error checks listSleeps had inlined into `reportActionableApiError`, shared with `handleApiError` and cancel, so the set cannot drift between the three callers. Fold inspect's bounds chain and --attribute parse into `validateInspectFlags` (run() 45 -> 39). Correct two comments: the MAX_LIMIT rationale (cross-run listings now reject locally rather than returning an opaque 400), and listSleeps' --interactive note, which described a partial-table reprint that cannot happen — pages after the first are fetched inside the keypress listener, whose rejection never reaches that catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): forward --hookId to the listings `--hookId` was declared as a flag, declared on `InspectCLIOptions`, and read by `listEvents` (`correlationIdFilter = opts.hookId || opts.stepId`), but `toInspectOptions` never copied it across. So `inspect events --hookId` parsed, cleared every check, sent no correlationId, and returned the run's whole event list. Pre-existing on main, but this PR both documents the flag and adds a `listAttributes` warning that depends on it, so the branch was unreachable from the CLI and its unit test only passed by calling `listAttributes` directly. That is the gap: a listing's own tests pass options in, so they cannot see a drop in the projection. `inspect-flag-forwarding.test.ts` goes through `Inspect.run` instead, and pins the whole mapping key by key. Three of its four cases fail without the one-line fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>github.com-vercel-workflow · f9073d07 · 2026-09-03
- 1.2ETVFix compressed workflow error display (#2680) * Fix compressed workflow data display * Add OSS web no-key hydration regression * Scope compression normalization to read paths; tidy hydration Address review feedback on the compressed-data fix: - world-vercel: keep gzip/zstd decompression on the o11y/display read paths (getStep/getRun/getEvent/getWorkflowRunEvents/getHook) but not on the runtime event-append path (world.events.create, createStep, updateStep). That path is runtime-only and re-hydrates every payload via the decompress-aware helpers, so decompressing at the adapter was redundant work on the TTFB-sensitive run_started/inline-delta path and skewed the runtime's deserialize compression telemetry to `codec: none`. deserializeStep is now shape-only; normalizeStepData runs in the read filter. Adds a regression test pinning the write-path pass-through. - serialized-data: drop dead `errorRef`/`metadataRef` normalization (refs are descriptor objects, never compressed byte payloads). - web: in the wait-entity path, filter events by correlationId before hydrating so an encryption key doesn't decrypt the whole event page. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Peter Wielander <peter.wielander@vercel.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>github.com-vercel-workflow · 89f4726b · 2026-06-29
- 1.2ETVperf(core): path-independent stream write batching (group commit in the server writable) (#3078) * perf(core): move stream write batching into WorkflowServerWritableStream (group commit) Batching previously lived in flushablePipe's coalescing loop, so it only engaged on paths that used flushablePipe (getWritable). A raw ReadableStream crossing a workflow/step boundary is piped with native pipeTo(), which does not pull chunk N+1 until write(chunk N) resolves — and write() resolved only after the flush timer AND the server round trip, so the buffer never held more than one chunk and every token became its own server request. The sink now group-commits: - write() resolves when the chunk enters a bounded client buffer; the bound counts buffered AND in-request chunks (WORKFLOW_STREAM_MAX_INFLIGHT_CHUNKS, preserving its documented meaning) plus a byte bound (WORKFLOW_STREAM_MAX_BUFFERED_BYTES, new, default 8 MiB, documented in runtime-tuning). A full buffer applies backpressure until a group lands durably. - The flush interval is a real group-commit window; chunks arriving while a request is in flight accumulate and form the next writeMulti group. One request in flight at a time preserves chunk order. - Per-request wire limits (1,000 chunks / 1 MiB) split groups exactly as the coalescing pipe did; an oversized single chunk goes alone. - Durability moved to an explicit barrier (STREAM_DRAIN_SYMBOL): close() drains before closing; flushablePipe adopts the barrier so lock-release completion (step completion) still means 'everything written is durable'; abort() DRAINS the accepted prefix (never closing) so a producer error after acked writes cannot lose data — native pipeTo aborts the sink on source failure; and a failed pipe drains before settling so a step failure is not persisted ahead of the emitted prefix. A dispatch failure retains the group, poisons the sink, and surfaces at the next write/close/drain. flushablePipe is now a plain per-chunk pump responsible only for lock-release completion and durability tracking; its coalescing machinery and STREAM_WRITE_BATCH_SYMBOL are removed. Covered: native-pipeTo batching (the regression), awaited per-chunk loops coalescing into one writeMulti, in-flight accumulation, wire-cap splits (count/byte/oversized), in-flight-inclusive backpressure for both bounds, sequential fallback without writeMulti, source-error prefix delivery through abort, failed-pipe drain-before-reject, early-ack sticky errors, turbo run-ready barrier gating (incl. dwell telemetry), drain-barrier adoption/rejection, and group-level flush spans. 1,572 core unit tests pass; e2e tier requires a deployment and was not run here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(core): re-dispatch a chunk buffered in the request settle gap Review (bot): a write landing between the dispatch loop's empty-buffer exit and the reaction clearing the in-flight marker armed no timer (scheduleGroupCommit saw the marker set) and was never dispatched on an open stream — only a later write/close/drain would pick it up. The settle reaction now re-dispatches when the buffer is non-empty, treating the chunk as an in-request arrival; drain waiters settle with the new chain. Regression test aims a write at the settle gap and asserts both chunks flush without a close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(core): document abort-drain boundedness and terminal-run conflict handling Review note: the abort-path drain is deliberately un-timeboxed (a bound would drop acked chunks); its worst case is owned by the World transport's finite timeout/retry budget, and a teardown-driven drain into an already-terminal run rejects into the existing catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(core): poll instead of fixed sleeps for dispatch assertions The native-pipeTo batching test flaked on a slow CI runner: a fixed 25ms wait raced the 10ms commit window plus scheduler jitter. All 'dispatch has happened' assertions now poll the expectation (bounded); intentional negatives keep their fixed windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>github.com-vercel-workflow · b610c46f · 2026-07-24
- 1.1ETVcli: read list views from world.analytics when available (#2648) * Add workflow analytics world APIs * cli: read list views from world.analytics when available inspect list views (runs, steps, events, hooks, sleeps) now read from the optional world.analytics namespace when the active backend provides one, falling back to the runtime storage APIs otherwise. Payload and detail views are unchanged. Deprecate --with-data for list views; payloads are viewable per-resource via 'inspect <resource> <id>'. * cli: keep hook listing on the runtime storage API The analytics read path omits ownerId (and the secret hook token), so routing hook listing through it silently drops the ownerId column. Keep inspect hooks on the runtime APIs, consistent with the web observability UI. Runs, steps, events, and sleeps continue to use the analytics read path when available. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Handle analytics access metadata in CLI * test(cli): preserve analytics pageInfo in json output * fix(cli): paginate analytics sleeps output * fix(cli): correct deprecation message flag name to --withData The list-view deprecation warning referenced '--with-data', but the actual oclif flag is '--withData' (with '-d' alias); '--with-data' errors with "Nonexistent flag". Fix the warning text, the doc comment, and the changeset to reference the real flag name. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): preserve inspect json array output * fix(cli): fall back when analytics lists are empty --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>github.com-vercel-workflow · 17d4ce22 · 2026-07-07
- 1.0ETV[world][web][cli] o11y: window-aware runs listing (#2812) * web: infinite scroll for the runs table Replace Previous/Next cursor paging with front-style infinite scroll: a useInfiniteList hook accumulates cursor pages with per-run dedup and generation-guarded resets, and useLoadMoreOnScroll drives loadMore from an IntersectionObserver sentinel (400px prefetch margin, guarded against double-fetch, observed against the table's scroll container). Footer now shows the loaded count and the analytics lookback window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: back the runs infinite list with SWR so tab switches serve from cache Rewrite useInfiniteList on useSWRInfinite: pages are keyed by [cacheKey, cursor] in SWR's global cache, so unmount/remount (switching tabs) restores fetched pages instantly instead of refetching. Revalidation is conservative because analytics list queries are expensive: revalidateFirstPage and revalidateIfStale are off; freshness comes from the Refresh button and the visibility-change auto-reload, which map to reload() (reset to first page + revalidate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * world: expose startTime/endTime on analytics runs listing The workflow-server /v2/analytics/runs endpoint has accepted a bounded startTime/endTime window since it shipped, and is significantly faster with one (the window prunes the ClickHouse scan: ~2s for 12h vs ~8s for the default 30-day entitlement window). The world client never exposed the params, so the CLI and web UI could only issue windowless requests. Pass them through so clients can send bounded windows (e.g. a period picker like front's workflows o11y). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: front-style period picker for the runs list Add a time-window picker (1h/6h/24h/3d/7d/30d, default 24h, URL-backed via ?period=) that sends an explicit startTime/endTime window through fetchRuns -> world.analytics.runs.list, keeping the ClickHouse scan bounded. The window is frozen per selection/refresh so all cursor pages share the same bounds, and it participates in the SWR cache key. Plan tiers are honored data-driven from the server's pageInfo: presets longer than the plan's observability lookback are disabled in the picker (labeled Observability Plus when an upgrade is available), and a 402 observability-upgrade-required response renders through the existing upgrade-required error handling. The footer now labels the selected window instead of the plan lookback. The runtime (local) fallback path ignores the window since the storage API has no time filter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: allow status filtering without a workflow name filter The status dropdown was disabled on Vercel backends until a workflow was selected — a limitation of the runtime DynamoDB API's index design. The runs list now reads via world.analytics, whose ClickHouse query filters derived status independently of workflowName, so drop the guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * cli: time-window flags for runs listing; widen name lookups past the default window The analytics backend now defaults windowless runs listings to the trailing 24h. Replicate the web's window support in the CLI: - 'workflow inspect runs' gains --since/--until (relative durations like 30m/12h/7d/2w, or timestamps) which are sent as an explicit startTime/endTime window. Out-of-plan windows surface through the existing observability-upgrade-required handling; non-analytics backends warn that the flags are ignored. - 'workflow start <name>' resolves the workflow's latest run via a windowless (default-window) listing and now retries across the plan's whole observability window on a miss, so names idle for more than a day keep resolving. - Bulk 'workflow cancel' matches across the plan window up front — a run can sleep or wait on a hook for days without recent events, so the default recent window must not bound cancellation matching. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: tighten changeset descriptions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: persist frozen listing windows across remounts; minimize lockfile diff Address review findings: - The frozen startTime/endTime lived in component state, but RunsTable fully remounts on tab switches, so every remount minted a new SWR cache key — the cached-pages restore never hit and cache entries grew unboundedly (one per key, including every 5s local-backend poll tick). Move the frozen windows to a module-scope store keyed by period: a remount reuses the stored window (same cache key, instant restore), and the window only advances on explicit refresh/reload. Non-analytics backends now send no window at all (the runtime APIs ignore it anyway), which also hides the period picker and window label there. - Regenerate pnpm-lock.yaml from main so the diff contains only the swr addition (plus its own use-sync-external-store dependency), dropping the unrelated docs-importer radix-ui re-resolutions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>github.com-vercel-workflow · fe327e69 · 2026-07-08
- 1.0ETVDurable hook resume: write, then wake (#3841) * test(core): reproduce lazy resume disposal race * Fix durable hook resume race * Fail closed on unknown hook wakes * Improve unsupported hook wake diagnostics * Address durable hook resume review feedback * Harden producer-committed wake handling * Serialize durable hook resume: write, then wake resumeHook() now dispatches strictly serially: the hook_received event is made durable first, and the workflow wake is published only after the write is acknowledged. The wake is a plain runId message (the shape the sequential path always published), so the producer-committed wake barrier, its queue-message field, and the HOOK_RESUME_INPUT_VERSION bump are all removed — no consumer or backend coordination is needed, and either side rolls back independently to today's behavior. The pre-write ops flush now partitions serialization ops: producer-push uploads are awaited before the event commits (the payload must not point at bytes still in flight), while consumer-settled reader ops — a dehydrated WritableStream, e.g. a manual webhook's responseWritable — are backgrounded. Awaiting those deadlocked the resume against its own wake (webhookWorkflow failing across the whole e2e matrix). Also: wake retries stop on definitive 4xx errors instead of burning the retry budget; WORKFLOW_DISABLE_LAZY_HOOK_RESUME no longer gates anything and is ignored; the internal resumeHookDurable alias is removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Address review: retry classification, wake dedup, 409 passthrough - Wake retry classification now actually fires against @vercel/queue: its errors carry no status field, so classify by the World's deployment-unavailable hook, then numeric status, then the queue client's definitive-4xx error names. - The wake publish carries idempotencyKey `hook-<resumeId>` on the claim path, so a retried publish whose response was lost dedups instead of costing a duplicate full replay. - EntityConflictError (HTTP 409) from the durable write is no longer re-keyed to HookNotFoundError: every 409 the backend emits on this write today is transient (slot conflict past the server's retry budget, claim race) and committed nothing, so it surfaces retryable instead of presenting as a permanent 404. - Stamp workflow.hook.resume_committed / wake_published span attributes after each leg resolves, making stranded resumes (committed event, no wake) queryable from traces. - Document on the public resumeHook signature that passing the token (not a cached Hook) is what makes the write idempotent-on-retry. - Changeset/changelog: note the ended-run behavior change (late webhook deliveries to finished runs now 404 instead of 202) and the 409 passthrough. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Pranay Prakash <pranay.gp@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>github.com-vercel-workflow · 2668e332 · 2026-08-31
- 1.0ETVperf(core): initialize lazy hook replay from hook_received stream (#3345) * perf(core): initialize lazy hook replay from hook_received stream On a lazy hook queue delivery, the consumer's idempotent hook_received re-ensure is hoisted above run_started and doubles as the invocation's setup request: it asks the World to return the current replay log with the write (new advisory CreateEventParams.preloadEvents), so one HTTP request yields the canonical event, the reconstructed run, and the complete replay log — skipping both the run_started POST and the initial events.list. - world: optional `preloadEvents?: true` on CreateEventParams, the hook_received dual of skipPreload; Worlds may ignore it - world-vercel: createHookReceivedPreloadEventV4 sends the frame Accept on eligible hook_received posts and decodes either response mode — frames via the response decoder extracted from the LIST consumer (GET behavior unchanged), CBOR via the shared materialized-response mapping. The run is reconstructed from run_created/run_started (plus attr_set folds), the canonical event found by x-wf-event-id, and resumeId now survives frame decoding so the runtime can match it - core: new fast path before the generic run-state setup, guarded on hookInput.resumeId + payloadDigest; a validated COMPLETE preload (hasMore false — this path has no cursor-continuation machinery) initializes workflowRun/preloadedEvents/maxEventsLimit directly, anything else falls back to the run_started setup without re-posting the hook; error classification matches the existing re-ensure (terminal → consume, transient → redeliver); setup source reported via workflow.resume_setup_source (never workflow.hook.resilient_resume_materialized, which stays a recovery-only signal) - producer resumeHook() is unchanged and never sets preloadEvents Based directly on main (no dependency on #3124/#3191); pairs with workflow-server's streamed hook_received replay-log response, which deploys first — the SDK negotiates per request and falls back safely against older servers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * address review: lazy fallback, retryable resume, terminal telemetry - world-vercel: the preload request keeps hook_received's lazy remoteRefBehavior — a supporting server owns frame-body resolution, while an older server now answers the CBOR fallback without resolving an S3-backed payload the runtime would discard - world-vercel: the atomic lazy-resume shape (resumeId + digest) opts into withEventPostRetry via idempotentHookResume — the (runId, resumeId) claim makes the POST idempotent-on-retry; legacy/partial hook_received shapes stay single-attempt, definitive 4xx stays non-retryable (unit + adapter + trace-propagation coverage) - core: a terminal event found in the preload records workflow.resume_setup_source=hook_received_stream and the run's actual terminal status on the span before consuming the delivery - core: document resilient_resume_materialized as the legacy/non-atomic re-ensure signal (claim ownership is not observable client-side, so the hoisted path deliberately never emits it) and resume_setup_source as a latency signal, not proof of event creation; note the Option A skip is now unreachable for atomic resumes - world: spell out the full preload usability contract on preloadEvents (complete hasMore-false log, non-null cursor, run/startedAt/maxEvents, lifecycle events, matching resumeId, list ordering, read-after-write consistency); bump @workflow/world to minor - new QuickJS sourcing tests (VM mocked): an attested complete preload is used verbatim with no events.list, a non-attested hook-containing preload is refetched, and an attested empty preload is not trusted Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>github.com-vercel-workflow · 9c1b3c86 · 2026-08-06
- 0.9ETVfeat(core): measure hook-triggered time to resume (#3437) Report end-to-end TTR for a hook resumption — entry into the public resume API through to the first line of the next durable step — on that step's `step.execute` span, decomposed into non-overlapping phases that sum exactly to the total: workflow.resume.total_ms workflow.resume.phase.{producer_prep,queue_delivery,resume_setup, replay,step_dispatch,step_claim,step_prepare}_ms dimensioned by trigger, dispatch strategy, setup source, and whether the step ran inline or was dispatched to another invocation. T0 is stamped by whichever public entry point the caller used, so `resumeWebhook` — which does its own by-token lookup and key resolution before reaching the shared implementation — measures the same window as `resumeHook` rather than a systematically shorter one. T7 is taken inside `contextStorage.run`, immediately before `stepFn.apply()`, so the `step_prepare` phase covers the step-context setup it is defined to cover. `resumeHook()` puts the producer boundaries on an optional `hookResumeTiming` field on the queue message (both dispatch paths); the consuming invocation adds its own and hands them to the execution that will actually ATTEMPT the next durable step. That decision is made against the dispatch loop's own classification, so an owned-recovery step keeps the measurement here instead of it riding off on a queued sibling, and a step converted into a delayed backstop wake — which this delivery does not attempt — never takes it. Within an inline batch the tracking is shared and a one-shot latch picks the single step that reaches user code, so the sample survives the batch's first step losing its create-claim. A deployment-affinity re-route forwards the timing untouched, keeping the wasted hop inside `queue_delivery`. The field is optional in every direction (new producer/old consumer, new consumer/old message, no workflow-server change) and parses with `.catch(undefined)` so a malformed value can never fail a delivery. A sample is emitted only when every required boundary is present, finite, and monotonic — a skewed or incomplete set is dropped rather than reported as a negative phase. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>github.com-vercel-workflow · a0ccfe0f · 2026-08-12
- 0.8ETVotel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces (#2363) * otel: explicit traceparent injection + linked-trace mode for bounded per-invocation traces - Add WORKFLOW_TRACE_MODE ('linked' default, 'continuous' legacy) to the workflow and step queue handlers. In linked mode, WORKFLOW_V2/STEP spans start a new trace root with span links to the incoming delivery context and the run-origin context, and re-enqueued messages forward the ORIGINAL run-origin trace carrier unchanged. - world-vercel now explicitly injects W3C traceparent/tracestate/baggage headers on outgoing workflow-server HTTP requests from inside the client span (no-op without an OTEL SDK registered). - New workflow.trace.mode span attribute; unit tests for both modes and for header injection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * changeset: call out behavioral telemetry changes of the linked default Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: add v5 observability tracing page Documents OTEL spans/attributes, linked trace mode and WORKFLOW_TRACE_MODE, span links, context propagation, and the v4 behavior-change callout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * otel: human-friendly span names for workflow and step spans WORKFLOW_V2/STEP prefixes with full machine names (workflow//./src/...//fn) become workflow.execute / step.execute / workflow.start with the short function name. New workflowDisplayName/stepDisplayName helpers in @workflow/utils handle both raw and queue-sanitized name forms; full names remain in the workflow.name/step.name attributes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * changeset: merge span-name and linked-trace notes into one changeset Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update trace-shape prose to renamed span names Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: replace ascii trace diagram with mermaid Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * address review: empty carriers, shared trace helpers, mode warning, name edge cases, consumer span kind - Treat an empty ({}) trace carrier as absent everywhere the trace-mode logic branches, so linked mode falls back to a fresh origin instead of forwarding a useless {} forever; workflow.trace.propagated now reports whether a usable carrier arrived. - Extract the duplicated linked-mode logic into shared telemetry helpers getNextTraceCarrier() and buildInvocationSpanLinks(), used by both the workflow and step queue handlers; resume-hook now uses linkToTraceCarrier (gaining the isSpanContextValid guard). - Warn once per distinct unrecognized WORKFLOW_TRACE_MODE value instead of silently selecting linked. - shortNameFromSanitized: map default/__default to the module short name (mirroring parseName) and document the `$`-sanitization limitation. - Queue-delivered workflow.execute spans now use the CONSUMER span kind, matching queue-delivered step.execute spans; docs span table and changeset updated accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>github.com-vercel-workflow · 926a5e7c · 2026-06-15