From 76d786efa1ab0bc2eba5d0f92a08096d07c8211a Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 22 May 2026 13:35:05 +0200 Subject: [PATCH 1/7] [tests] Fix abort-fetch e2e flake (#2081) --- .changeset/upset-ghosts-rush.md | 4 ++ packages/core/e2e/e2e.test.ts | 19 ++++-- workbench/example/workflows/99_e2e.ts | 95 ++++++++++++++++++++------- 3 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 .changeset/upset-ghosts-rush.md diff --git a/.changeset/upset-ghosts-rush.md b/.changeset/upset-ghosts-rush.md new file mode 100644 index 0000000000..864621d664 --- /dev/null +++ b/.changeset/upset-ghosts-rush.md @@ -0,0 +1,4 @@ +--- +--- + +chore(tests): surface HTTP status and elapsed time in abort-fetch e2e diagnostics so flaky failures of `abortFetchInFlightWorkflow` and `abortVoidSleepTimeoutWorkflow` reveal why the slow upstream returned early. diff --git a/packages/core/e2e/e2e.test.ts b/packages/core/e2e/e2e.test.ts index 23021b4107..5f00f28ae1 100644 --- a/packages/core/e2e/e2e.test.ts +++ b/packages/core/e2e/e2e.test.ts @@ -3016,11 +3016,16 @@ describe('e2e', () => { const run = await start(await e2e('abortFetchInFlightWorkflow'), []); const returnValue = await run.returnValue; - expect(returnValue.winner).toBe('timeout'); + // Include the full returnValue (status + elapsedMs from the step) in + // the assertion message so a flaky failure surfaces *why* fetch won + // the race — e.g. httpbin returning a 5xx in <1s — instead of just + // "expected 'fetch' to be 'timeout'". + const summary = JSON.stringify(returnValue); + expect(returnValue.winner, summary).toBe('timeout'); // The step's catch path returned aborted=true (fetch threw AbortError), // not the natural-completion path (which would set ok=true,aborted=false). - expect(returnValue.fetchResult.aborted).toBe(true); - expect(returnValue.fetchResult.ok).toBe(false); + expect(returnValue.fetchResult.aborted, summary).toBe(true); + expect(returnValue.fetchResult.ok, summary).toBe(false); } ); @@ -3041,8 +3046,12 @@ describe('e2e', () => { const run = await start(await e2e('abortVoidSleepTimeoutWorkflow'), []); const returnValue = await run.returnValue; - expect(returnValue.aborted).toBe(true); - expect(returnValue.ok).toBe(false); + // Same diagnostic treatment as abortFetchInFlightWorkflow: when the + // slow upstream returns early the step result includes status and + // elapsedMs, which are what we'll need to triage the next flake. + const summary = JSON.stringify(returnValue); + expect(returnValue.aborted, summary).toBe(true); + expect(returnValue.ok, summary).toBe(false); } ); diff --git a/workbench/example/workflows/99_e2e.ts b/workbench/example/workflows/99_e2e.ts index af4d52af0f..41ed11de25 100644 --- a/workbench/example/workflows/99_e2e.ts +++ b/workbench/example/workflows/99_e2e.ts @@ -1727,23 +1727,78 @@ async function abortFromStep( /** * Step that uses fetch with an AbortSignal. * Uses a URL that intentionally delays, so the abort cancels it. + * + * Accepts a list of URLs and tries them in order, falling back to the + * next on 5xx (or non-AbortError network failure) so a single bad upstream + * doesn't flake the abort-fetch tests. Empirically, httpbin.org returns + * 502 from GH Actions runners often enough to dominate CI flakiness; + * pairing it with a second slow endpoint gives both belt and suspenders. + * + * Reports `status`, `elapsedMs`, and the `url` that resolved so that when + * the abort-fetch tests do fail, the assertion message shows exactly what + * the upstream(s) returned instead of leaving us guessing why the race + * winner was `fetch` instead of `timeout`. */ async function fetchWithSignal( - url: string, + urls: readonly string[], signal: AbortSignal -): Promise<{ ok: boolean; aborted: boolean }> { +): Promise<{ + ok: boolean; + aborted: boolean; + status?: number; + url?: string; + elapsedMs: number; + attempts: { url: string; status?: number; error?: string }[]; +}> { 'use step'; - try { - const response = await globalThis.fetch(url, { signal }); - return { ok: response.ok, aborted: false }; - } catch (err: any) { - if (err.name === 'AbortError') { - return { ok: false, aborted: true }; + const startedAt = Date.now(); + const attempts: { url: string; status?: number; error?: string }[] = []; + for (const url of urls) { + try { + const response = await globalThis.fetch(url, { signal }); + attempts.push({ url, status: response.status }); + if (response.ok) { + return { + ok: true, + aborted: false, + status: response.status, + url, + elapsedMs: Date.now() - startedAt, + attempts, + }; + } + // Non-2xx — fall through and try the next URL. + } catch (err: any) { + if (err.name === 'AbortError') { + attempts.push({ url, error: 'AbortError' }); + return { + ok: false, + aborted: true, + elapsedMs: Date.now() - startedAt, + attempts, + }; + } + attempts.push({ url, error: err?.message ?? String(err) }); + // Network error — fall through and try the next URL. } - throw err; } + return { + ok: false, + aborted: false, + elapsedMs: Date.now() - startedAt, + attempts, + }; } +// Slow endpoints used by the abort-fetch e2e tests. Tried in order; postman- +// echo first because httpbin.org has historically returned 502s from GH +// Actions. Both cap at /delay/10 in practice, which is comfortably longer +// than the 2s race threshold these tests use. +const SLOW_FETCH_URLS = [ + 'https://postman-echo.com/delay/10', + 'https://httpbin.org/delay/10', +] as const; + /** * E2E: Basic timeout cancellation. * Creates controller in workflow, races step vs sleep, aborts on timeout. @@ -2194,15 +2249,14 @@ export async function abortFetchInFlightWorkflow() { 'use workflow'; const controller = new AbortController(); - // httpbin.org/delay/N holds the response open for N seconds — used here - // as a slow endpoint that the abort can cancel mid-flight. Same external- - // service pattern as other e2e workflows in this file (jsonplaceholder, - // example.com). Avoids needing a per-workbench /api/delay route, which - // would only exist on the one workbench it was added to. - const fetchPromise = fetchWithSignal( - 'https://httpbin.org/delay/30', - controller.signal - ); + // SLOW_FETCH_URLS holds the response open for ~10s — used here as a slow + // endpoint that the abort can cancel mid-flight. Same external-service + // pattern as other e2e workflows in this file (jsonplaceholder, example.com). + // Avoids needing a per-workbench /api/delay route, which would only exist + // on the one workbench it was added to. The step falls back to the second + // URL only if the first returns a 5xx or non-AbortError network failure, + // so a transient outage on one upstream doesn't flake the test. + const fetchPromise = fetchWithSignal(SLOW_FETCH_URLS, controller.signal); // Race the fetch against a 2s sleep. Sleep wins; abort fires. const winner = await Promise.race([ @@ -2245,10 +2299,7 @@ export async function abortVoidSleepTimeoutWorkflow() { const controller = new AbortController(); void sleep('2s').then(() => controller.abort()); - return await fetchWithSignal( - 'https://httpbin.org/delay/30', - controller.signal - ); + return await fetchWithSignal(SLOW_FETCH_URLS, controller.signal); } /** From 0d0bb013d7073f964bb3aea7869e84ed762bf7a9 Mon Sep 17 00:00:00 2001 From: Pranay Prakash Date: Fri, 22 May 2026 06:22:25 -0700 Subject: [PATCH 2/7] Generate local gitignore when using public workflow manifests (#1683) Co-authored-by: Peter Wielander Co-authored-by: Peter Wielander --- .changeset/ignore-generated-manifest.md | 7 +++++++ packages/builders/src/vercel-build-output-api.ts | 3 +++ packages/next/src/builder-deferred.ts | 6 ++++++ packages/next/src/builder-eager.ts | 3 +++ packages/sveltekit/src/builder.ts | 3 +++ workbench/example/.gitignore | 2 +- 6 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 .changeset/ignore-generated-manifest.md diff --git a/.changeset/ignore-generated-manifest.md b/.changeset/ignore-generated-manifest.md new file mode 100644 index 0000000000..d00263a47d --- /dev/null +++ b/.changeset/ignore-generated-manifest.md @@ -0,0 +1,7 @@ +--- +"@workflow/builders": patch +"@workflow/next": patch +"@workflow/sveltekit": patch +--- + +Write colocated `.gitignore` files for public workflow manifests generated by `WORKFLOW_PUBLIC_MANIFEST=1` diff --git a/packages/builders/src/vercel-build-output-api.ts b/packages/builders/src/vercel-build-output-api.ts index 91c428255d..03fb55ffdf 100644 --- a/packages/builders/src/vercel-build-output-api.ts +++ b/packages/builders/src/vercel-build-output-api.ts @@ -62,6 +62,9 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder { 'static/.well-known/workflow/v1' ); await mkdir(staticManifestDir, { recursive: true }); + if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { + await writeFile(join(staticManifestDir, '.gitignore'), '*'); + } await copyFile( join(workflowGeneratedDir, 'manifest.json'), join(staticManifestDir, 'manifest.json') diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index b768c6c271..f4170ca966 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -589,6 +589,12 @@ export async function getNextBuilderDeferred() { 'public/.well-known/workflow/v1' ); await mkdir(publicManifestDir, { recursive: true }); + if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { + await this.writeFileIfChanged( + join(publicManifestDir, '.gitignore'), + '*' + ); + } await this.copyFileIfChanged( manifestFilePath, join(publicManifestDir, 'manifest.json') diff --git a/packages/next/src/builder-eager.ts b/packages/next/src/builder-eager.ts index cfc0ed63f3..93d587d951 100644 --- a/packages/next/src/builder-eager.ts +++ b/packages/next/src/builder-eager.ts @@ -79,6 +79,9 @@ export async function getNextBuilderEager() { 'public/.well-known/workflow/v1' ); await mkdir(publicManifestDir, { recursive: true }); + if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { + await writeFile(join(publicManifestDir, '.gitignore'), '*'); + } await copyFile( join(workflowGeneratedDir, 'manifest.json'), join(publicManifestDir, 'manifest.json') diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 39afe31d4d..ffb130c32b 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -109,6 +109,9 @@ export const POST = async ({request}) => { 'static/.well-known/workflow/v1' ); await mkdir(staticManifestDir, { recursive: true }); + if (process.env.VERCEL_DEPLOYMENT_ID === undefined) { + await writeFile(join(staticManifestDir, '.gitignore'), '*'); + } await copyFile( join(workflowGeneratedDir, 'manifest.json'), join(staticManifestDir, 'manifest.json') diff --git a/workbench/example/.gitignore b/workbench/example/.gitignore index 821a5ee075..2cc3c7bc8d 100644 --- a/workbench/example/.gitignore +++ b/workbench/example/.gitignore @@ -1 +1 @@ -manifest.js \ No newline at end of file +manifest.js From c5023646d16c68fabab9ef258144a8eb283c3a66 Mon Sep 17 00:00:00 2001 From: Karthik Kalyan <105607645+karthikscale3@users.noreply.github.com> Date: Fri, 22 May 2026 06:35:09 -0700 Subject: [PATCH 3/7] [docs] Add cookbook entry on upgrading workflows (#1874) Co-authored-by: Peter Wielander --- .changeset/upgrading-workflows-cookbook.md | 2 + .../docs/v4/cookbook/advanced/meta.json | 1 + .../cookbook/advanced/upgrading-workflows.mdx | 212 ++++++++++++++++++ docs/content/docs/v4/cookbook/index.mdx | 1 + .../docs/v5/cookbook/advanced/meta.json | 7 +- .../cookbook/advanced/upgrading-workflows.mdx | 195 ++++++++++++++++ docs/content/docs/v5/cookbook/index.mdx | 1 + docs/lib/cookbook-tree.ts | 8 + packages/world-local/src/queue.test.ts | 4 +- 9 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 .changeset/upgrading-workflows-cookbook.md create mode 100644 docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx create mode 100644 docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx diff --git a/.changeset/upgrading-workflows-cookbook.md b/.changeset/upgrading-workflows-cookbook.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/upgrading-workflows-cookbook.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/content/docs/v4/cookbook/advanced/meta.json b/docs/content/docs/v4/cookbook/advanced/meta.json index 1b8ea39d44..48610b096d 100644 --- a/docs/content/docs/v4/cookbook/advanced/meta.json +++ b/docs/content/docs/v4/cookbook/advanced/meta.json @@ -3,6 +3,7 @@ "pages": [ "child-workflows", "distributed-abort-controller", + "upgrading-workflows", "serializable-steps", "publishing-libraries" ] diff --git a/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx b/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx new file mode 100644 index 0000000000..45ffdbff1c --- /dev/null +++ b/docs/content/docs/v4/cookbook/advanced/upgrading-workflows.mdx @@ -0,0 +1,212 @@ +--- +title: Upgrading Workflows +description: Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward. +type: guide +summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })` — either automatically on every iteration, or on demand via a dedicated upgrade hook.' +related: + - /docs/foundations/versioning + - /cookbook/common-patterns/workflow-composition + - /docs/api-reference/workflow-api/start + - /docs/foundations/hooks +--- + +Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** — a moment where it's safe to checkpoint state and start fresh — and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run. + + +For the underlying model — why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary — see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments. + + +A clean upgrade point is any spot in the workflow where: + +- All in-progress side effects have completed (or aren't needed by the next iteration) +- The relevant state can be serialized into the workflow's input arguments +- It's natural for the workflow to "checkpoint" — typically right after handling an external event, completing a batch, or finishing a logical phase + +There are two ways to apply this: + +1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)). Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. Simple — no extra triggers — but every event pays the respawn cost. +2. **Upgrade on demand via a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)). A single long-lived run handles many events in a loop and only respawns when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane (e.g. after a deploy). More control and fewer respawns, at the cost of an explicit trigger. + +### When to use each + +- **Method 1** when iterations are short and frequent, the work is cheap to checkpoint, and you want shipped fixes to apply on the very next event. Long-lived "session" workflows (subscriptions, queues, FSMs) that already process events one at a time fit this naturally. +- **Method 2** when iterations are infrequent or expensive (you don't want to respawn on every event), or when you need to roll out a fix to a fleet of in-flight runs after a deploy by fanning out to a control-plane endpoint. Also fits when "upgrade" should be an explicit operation rather than a side effect of handling each event. + +## Method 1: Upgrade on every iteration + +Each run inherits state via its argument, blocks on a hook, processes the resume, then unconditionally hands off to its successor. The `start()` call is wrapped in a `"use step"` function (required) and passes `deploymentId: "latest"` so the new run lands on the freshest code. + +```typescript lineNumbers +import { defineHook, getWorkflowMetadata } from "workflow"; +import { start } from "workflow/api"; + +declare function processItem(itemId: string): Promise; // @setup + +interface QueueState { + processed: number; + cursor: string | null; +} + +export const nextItemHook = defineHook<{ itemId: string }>(); + +async function spawnSelfOnLatest(state: QueueState): Promise { + "use step"; // [!code highlight] + + // `deploymentId: "latest"` resolves to whichever deployment is current + // when this spawn lands — NOT the deployment running this code. + const next = await start(longRunningQueue, [state], { // [!code highlight] + deploymentId: "latest", // [!code highlight] + }); // [!code highlight] + return next.runId; +} + +export async function longRunningQueue( + state: QueueState = { processed: 0, cursor: null }, +): Promise { + "use workflow"; + + const { workflowRunId } = getWorkflowMetadata(); + + // Block until something fires the hook — could be hours, days, or longer. + // Per-run hook tokens (workflowRunId) keep concurrent chains isolated. + const { itemId } = await nextItemHook.create({ token: workflowRunId }); // [!code highlight] + + await processItem(itemId); + + // Hand off to a fresh run on the latest deployment. THIS run ends here. + await spawnSelfOnLatest({ // [!code highlight] + processed: state.processed + 1, // [!code highlight] + cursor: itemId, // [!code highlight] + }); // [!code highlight] +} +``` + +### Resuming the hook + +Any server-side code can resume the currently-active iteration by calling `.resume()` with the run ID: + +```typescript +import { nextItemHook } from "@/workflows/long-running-queue"; + +export async function POST(req: Request) { + const { runId, itemId } = await req.json(); + + await nextItemHook.resume(runId, { itemId }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + +The caller tracks the active `runId` (e.g. in a database, KV, or returned from the previous iteration) and updates it whenever the chain advances. + +## Method 2: Upgrade on demand via a dedicated hook + +Use a single long-running workflow that handles events in a loop. Define a second hook — `upgradeHook` — alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits. + +```typescript lineNumbers +import { defineHook, getWorkflowMetadata } from "workflow"; +import { start } from "workflow/api"; + +declare function processItem(itemId: string): Promise; // @setup + +interface QueueState { + processed: number; + cursor: string | null; +} + +export const nextItemHook = defineHook<{ itemId: string }>(); +export const upgradeHook = defineHook<{ reason?: string }>(); // [!code highlight] + +async function spawnSelfOnLatest(state: QueueState): Promise { + "use step"; + + const next = await start(longRunningQueue, [state], { + deploymentId: "latest", + }); + return next.runId; +} + +export async function longRunningQueue( + state: QueueState = { processed: 0, cursor: null }, +): Promise { + "use workflow"; + + const { workflowRunId } = getWorkflowMetadata(); + + while (true) { + // Race a normal work event against the upgrade signal. + const event = await Promise.race([ // [!code highlight] + nextItemHook + .create({ token: workflowRunId }) + .then((payload) => ({ kind: "work" as const, payload })), + upgradeHook // [!code highlight] + .create({ token: workflowRunId }) // [!code highlight] + .then(() => ({ kind: "upgrade" as const })), // [!code highlight] + ]); + + if (event.kind === "upgrade") { // [!code highlight] + // Checkpoint current state and hand off to a fresh run + // on whatever deployment is live now. THIS run ends here. + await spawnSelfOnLatest(state); // [!code highlight] + return; // [!code highlight] + } + + await processItem(event.payload.itemId); + state = { + processed: state.processed + 1, + cursor: event.payload.itemId, + }; + } +} +``` + +### Triggering the upgrade + +Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deploy pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix. + +```typescript +import { upgradeHook } from "@/workflows/long-running-queue"; + +export async function POST(req: Request) { + const { runId, reason } = await req.json(); + + // The workflow exits its loop, captures state, and respawns + // on the latest deployment. + await upgradeHook.resume(runId, { reason }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + +To upgrade a fleet of runs after a deploy, list active runs (e.g. from a tracking store) and call this endpoint for each. + +## How it works + +1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up — so any shipped fix applies starting from that respawn. Both methods rely on this. +2. **`start()` from a step.** [`start()`](/docs/api-reference/workflow-api/start) is not allowed directly inside `"use workflow"` functions — wrap it in a `"use step"` helper to keep the spawn deterministic across replays. +3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself. +4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering. +5. **Method 1 vs Method 2 is just where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting — there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run. + +## Adapting to your use case + +- **Combine with a sleep.** Race the hook against `sleep()` so iterations also tick on a timer: `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives. +- **Stateless successors.** If the next iteration doesn't need the previous state (e.g. a pure event router), call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing. +- **Persist state externally.** If state needs to be readable from outside the workflow (dashboards, debugging, recovery), write it to a database in a step before spawning the next run. +- **Track the active runId externally.** Whatever resumes the hook needs to know the current run. Have the spawn step write the new `runId` to a KV/database keyed by a stable session identifier so resumers always look up the latest one. + +## Caveats + +- **Backward compatibility matters.** Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest). +- **Workflow identity is the function name + file path.** Renaming the function or moving the file across a deployment changes the workflow ID — the next iteration will fail to resolve. Treat the workflow's name and location as stable interfaces. +- **There is a tiny gap between iterations.** The current run ends as soon as `start()` returns; the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready. +- **Method 2: track active runs externally.** Because Method 2's runs are long-lived, the set of in-flight runs only changes when one starts, completes, or upgrades. Persist run IDs (and clean them up on completion or upgrade) so a rollout script can fan out reliably. After resuming `upgradeHook`, also update the tracked run ID once the new run reports back, the same way you would in Method 1. +- **`start()` must be called from a step**, never directly from the workflow body. + +## Key APIs + +- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function +- [`"use step"`](/docs/foundations/workflows-and-steps) — required wrapper for `start()` calls +- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) — spawn the successor on the newest deployment +- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspend the workflow until an external event resumes it +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — exposes `workflowRunId` for per-run hook tokens diff --git a/docs/content/docs/v4/cookbook/index.mdx b/docs/content/docs/v4/cookbook/index.mdx index 94cb6d9d18..ea1ecbb724 100644 --- a/docs/content/docs/v4/cookbook/index.mdx +++ b/docs/content/docs/v4/cookbook/index.mdx @@ -34,5 +34,6 @@ A curated collection of workflow patterns with clean, copy-paste code examples f - [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent - [**Distributed Abort Controller**](/cookbook/advanced/distributed-abort-controller) — Build a cross-process abort controller using workflow streams and hooks +- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward - [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary - [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions diff --git a/docs/content/docs/v5/cookbook/advanced/meta.json b/docs/content/docs/v5/cookbook/advanced/meta.json index 3f4a6be9ae..443536ea64 100644 --- a/docs/content/docs/v5/cookbook/advanced/meta.json +++ b/docs/content/docs/v5/cookbook/advanced/meta.json @@ -1,4 +1,9 @@ { "title": "Advanced", - "pages": ["child-workflows", "serializable-steps", "publishing-libraries"] + "pages": [ + "child-workflows", + "upgrading-workflows", + "serializable-steps", + "publishing-libraries" + ] } diff --git a/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx b/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx new file mode 100644 index 0000000000..0b9b556c8e --- /dev/null +++ b/docs/content/docs/v5/cookbook/advanced/upgrading-workflows.mdx @@ -0,0 +1,195 @@ +--- +title: Upgrading Workflows +description: Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward. +type: guide +summary: 'Identify a clean upgrade point and hand off to a fresh run via `start(self, [state], { deploymentId: "latest" })` — either automatically on every iteration, or on demand via a dedicated upgrade hook.' +related: + - /docs/foundations/versioning + - /cookbook/common-patterns/workflow-composition + - /docs/api-reference/workflow-api/start + - /docs/foundations/hooks +--- + +Workflows that block on external events for days, weeks, or months can outlive many deployments. **The key is to identify a clean upgrade point in the workflow** — a moment where it's safe to checkpoint state and start fresh — and then call [`start()`](/docs/api-reference/workflow-api/start) with `deploymentId: "latest"` to spawn a new run carrying that state forward. The current run ends; the next run begins on whatever deployment is live at that moment, so shipped fixes apply immediately without ever migrating an in-flight run. + + +For the underlying model — why runs pin to a deployment by default, how cancel-and-rerun works, and how state crosses the version boundary — see [Versioning](/docs/foundations/versioning). This recipe focuses on event-driven workflows that need to keep advancing across deployments. + + +A clean upgrade point is any spot in the workflow where: + +- All in-progress side effects have completed (or aren't needed by the next iteration) +- The relevant state can be serialized into the workflow's input arguments +- It's natural for the workflow to "checkpoint" — typically right after handling an external event, completing a batch, or finishing a logical phase + +There are two ways to apply this: + +1. **Upgrade on every iteration** ([Method 1](#method-1-upgrade-on-every-iteration)). Each run handles a single event and unconditionally hands off to a fresh run on the latest deployment before exiting. Simple — no extra triggers — but every event pays the respawn cost. +2. **Upgrade on demand via a dedicated hook** ([Method 2](#method-2-upgrade-on-demand-via-a-dedicated-hook)). A single long-lived run handles many events in a loop and only respawns when an `upgradeHook` fires. A separate endpoint resumes that hook from your control plane (e.g. after a deploy). More control and fewer respawns, at the cost of an explicit trigger. + +### When to use each + +- **Method 1** when iterations are short and frequent, the work is cheap to checkpoint, and you want shipped fixes to apply on the very next event. Long-lived "session" workflows (subscriptions, queues, FSMs) that already process events one at a time fit this naturally. +- **Method 2** when iterations are infrequent or expensive (you don't want to respawn on every event), or when you need to roll out a fix to a fleet of in-flight runs after a deploy by fanning out to a control-plane endpoint. Also fits when "upgrade" should be an explicit operation rather than a side effect of handling each event. + +## Method 1: Upgrade on every iteration + +Each run inherits state via its argument, blocks on a hook, processes the resume, then unconditionally hands off to its successor by calling `start()` directly from the workflow body with `deploymentId: "latest"`. + +```typescript lineNumbers +import { defineHook, getWorkflowMetadata } from "workflow"; +import { start } from "workflow/api"; + +declare function processItem(itemId: string): Promise; // @setup + +interface QueueState { + processed: number; + cursor: string | null; +} + +export const nextItemHook = defineHook<{ itemId: string }>(); + +export async function longRunningQueue( + state: QueueState = { processed: 0, cursor: null }, +): Promise { + "use workflow"; + + const { workflowRunId } = getWorkflowMetadata(); + + // Block until something fires the hook — could be hours, days, or longer. + // Per-run hook tokens (workflowRunId) keep concurrent chains isolated. + const { itemId } = await nextItemHook.create({ token: workflowRunId }); // [!code highlight] + + await processItem(itemId); + + // Hand off to a fresh run on the latest deployment. THIS run ends here. + // `deploymentId: "latest"` resolves to whichever deployment is current + // when this spawn lands — NOT the deployment running this code. + await start( // [!code highlight] + longRunningQueue, // [!code highlight] + [{ processed: state.processed + 1, cursor: itemId }], // [!code highlight] + { deploymentId: "latest" }, // [!code highlight] + ); +} +``` + +### Resuming the hook + +Any server-side code can resume the currently-active iteration by calling `.resume()` with the run ID: + +```typescript +import { nextItemHook } from "@/workflows/long-running-queue"; + +export async function POST(req: Request) { + const { runId, itemId } = await req.json(); + + await nextItemHook.resume(runId, { itemId }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + +The caller tracks the active `runId` (e.g. in a database, KV, or returned from the previous iteration) and updates it whenever the chain advances. + +## Method 2: Upgrade on demand via a dedicated hook + +Use a single long-running workflow that handles events in a loop. Define a second hook — `upgradeHook` — alongside the work hook, and race them. While only the work hook fires, the run keeps handling events on its current deployment. When `upgradeHook` resumes, the workflow captures current state and respawns on the latest deployment, then exits. + +```typescript lineNumbers +import { defineHook, getWorkflowMetadata } from "workflow"; +import { start } from "workflow/api"; + +declare function processItem(itemId: string): Promise; // @setup + +interface QueueState { + processed: number; + cursor: string | null; +} + +export const nextItemHook = defineHook<{ itemId: string }>(); +export const upgradeHook = defineHook<{ reason?: string }>(); // [!code highlight] + +export async function longRunningQueue( + state: QueueState = { processed: 0, cursor: null }, +): Promise { + "use workflow"; + + const { workflowRunId } = getWorkflowMetadata(); + + while (true) { + // Race a normal work event against the upgrade signal. + const event = await Promise.race([ // [!code highlight] + nextItemHook + .create({ token: workflowRunId }) + .then((payload) => ({ kind: "work" as const, payload })), + upgradeHook // [!code highlight] + .create({ token: workflowRunId }) // [!code highlight] + .then(() => ({ kind: "upgrade" as const })), // [!code highlight] + ]); + + if (event.kind === "upgrade") { // [!code highlight] + // Checkpoint current state and hand off to a fresh run + // on whatever deployment is live now. THIS run ends here. + await start(longRunningQueue, [state], { // [!code highlight] + deploymentId: "latest", // [!code highlight] + }); // [!code highlight] + return; // [!code highlight] + } + + await processItem(event.payload.itemId); + state = { + processed: state.processed + 1, + cursor: event.payload.itemId, + }; + } +} +``` + +### Triggering the upgrade + +Expose a separate endpoint that resumes `upgradeHook` for a given run. Call it from your deploy pipeline, an admin UI, or a fan-out script that iterates over every active run after shipping a fix. + +```typescript +import { upgradeHook } from "@/workflows/long-running-queue"; + +export async function POST(req: Request) { + const { runId, reason } = await req.json(); + + // The workflow exits its loop, captures state, and respawns + // on the latest deployment. + await upgradeHook.resume(runId, { reason }); // [!code highlight] + + return Response.json({ success: true }); +} +``` + +To upgrade a fleet of runs after a deploy, list active runs (e.g. from a tracking store) and call this endpoint for each. + +## How it works + +1. **`deploymentId: "latest"` is the upgrade knob.** Without it, the spawn pins to the current deployment. With it, the new run resolves to whatever deployment is current when the runtime picks it up — so any shipped fix applies starting from that respawn. Both methods rely on this. +2. **`start()` runs directly from the workflow body.** In v5, [`start()`](/docs/api-reference/workflow-api/start) is step-backed, so it can be called from a workflow function and still records a deterministic step boundary in the event log — no manual `"use step"` wrapper is required. +3. **State carries through the function argument.** The accumulating context flows from run N to run N+1 as a serialized argument. No external store is required for the state itself. +4. **Per-run hook tokens.** Using `workflowRunId` as the hook token scopes each iteration's wait to its own run, so multiple chains can run concurrently without interfering. +5. **Method 1 vs Method 2 is just where the spawn happens.** In Method 1 every run spawns its successor unconditionally before exiting — there is no long-lived process to migrate. In Method 2 the spawn happens only when the upgrade hook fires; otherwise the loop keeps handling events on the same run. + +## Adapting to your use case + +- **Combine with a sleep.** Race the hook against `sleep()` so iterations also tick on a timer: `Promise.race([hook, sleep("1d")])` lets the workflow advance even if no external event arrives. +- **Stateless successors.** If the next iteration doesn't need the previous state (e.g. a pure event router), call `start(longRunningQueue, [], { deploymentId: "latest" })` and skip the argument plumbing. +- **Persist state externally.** If state needs to be readable from outside the workflow (dashboards, debugging, recovery), write it to a database in a step before spawning the next run. +- **Track the active runId externally.** Whatever resumes the hook needs to know the current run. Capture the `runId` returned by `start()` and write it to a KV/database keyed by a stable session identifier (in a step) so resumers always look up the latest one. + +## Caveats + +- **Backward compatibility matters.** Because the next run executes on a different deployment, the workflow's input arguments and return type must remain compatible across deployments. Adding required fields, removing fields, or changing types can cause serialization failures. See the [`deploymentId: "latest"` callout](/docs/api-reference/workflow-api/start#using-deploymentid-latest). +- **Workflow identity is the function name + file path.** Renaming the function or moving the file across a deployment changes the workflow ID — the next iteration will fail to resolve. Treat the workflow's name and location as stable interfaces. +- **There is a tiny gap between iterations.** The current run ends as soon as `start()` returns; the next run starts asynchronously. A resume that arrives in that window can fail with "hook not found." Make resumers retry, or have the API persist pending payloads and apply them once the next iteration is ready. +- **Method 2: track active runs externally.** Because Method 2's runs are long-lived, the set of in-flight runs only changes when one starts, completes, or upgrades. Persist run IDs (and clean them up on completion or upgrade) so a rollout script can fan out reliably. After resuming `upgradeHook`, also update the tracked run ID once the new run reports back, the same way you would in Method 1. + +## Key APIs + +- [`"use workflow"`](/docs/foundations/workflows-and-steps) — marks the orchestrator function +- [`start()`](/docs/api-reference/workflow-api/start) with [`deploymentId: "latest"`](/docs/api-reference/workflow-api/start#using-deploymentid-latest) — spawn the successor on the newest deployment +- [`defineHook()`](/docs/api-reference/workflow/define-hook) — suspend the workflow until an external event resumes it +- [`getWorkflowMetadata()`](/docs/api-reference/workflow/get-workflow-metadata) — exposes `workflowRunId` for per-run hook tokens diff --git a/docs/content/docs/v5/cookbook/index.mdx b/docs/content/docs/v5/cookbook/index.mdx index 0dc1ce62ab..309f087c21 100644 --- a/docs/content/docs/v5/cookbook/index.mdx +++ b/docs/content/docs/v5/cookbook/index.mdx @@ -33,5 +33,6 @@ A curated collection of workflow patterns with clean, copy-paste code examples f ## Advanced - [**Child Workflows**](/cookbook/advanced/child-workflows) — Spawn and orchestrate child workflows from a parent +- [**Upgrading Workflows**](/cookbook/advanced/upgrading-workflows) — Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward - [**Serializable Steps**](/cookbook/advanced/serializable-steps) — Wrap non-serializable third-party objects so they cross the workflow boundary - [**Publishing Libraries**](/cookbook/advanced/publishing-libraries) — Ship npm packages that export reusable workflow functions diff --git a/docs/lib/cookbook-tree.ts b/docs/lib/cookbook-tree.ts index 2189235df8..25e6748bd5 100644 --- a/docs/lib/cookbook-tree.ts +++ b/docs/lib/cookbook-tree.ts @@ -56,6 +56,7 @@ export const slugToCategory: Record = { // Advanced 'child-workflows': 'advanced', 'distributed-abort-controller': 'advanced', + 'upgrading-workflows': 'advanced', 'serializable-steps': 'advanced', 'publishing-libraries': 'advanced', }; @@ -189,6 +190,13 @@ export const recipes: Record = { category: 'advanced', skipVersions: ['v5'], }, + 'upgrading-workflows': { + slug: 'upgrading-workflows', + title: 'Upgrading Workflows', + description: + 'Identify a clean upgrade point in a long-running workflow and spawn a fresh run on the latest deployment carrying state forward.', + category: 'advanced', + }, 'serializable-steps': { slug: 'serializable-steps', title: 'Serializable Steps', diff --git a/packages/world-local/src/queue.test.ts b/packages/world-local/src/queue.test.ts index 8b9d2d1e8c..3874f63648 100644 --- a/packages/world-local/src/queue.test.ts +++ b/packages/world-local/src/queue.test.ts @@ -170,7 +170,9 @@ describe('queue timeout re-enqueue', () => { }); it('logs actionable guidance for detached ArrayBuffer proxy failures', async () => { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); const fetchError = new TypeError('fetch failed'); (fetchError as TypeError & { cause?: unknown }).cause = new TypeError( 'Cannot perform ArrayBuffer.prototype.slice on a detached ArrayBuffer' From cf256b56f1f88375e53b5698254a808312255d0a Mon Sep 17 00:00:00 2001 From: Rich Haines Date: Fri, 22 May 2026 15:43:43 +0200 Subject: [PATCH 4/7] [docs] Replace local ai-agent-detection with @vercel/agent-readability (#1580) --- docs/app/[lang]/llms.mdx/[[...slug]]/route.ts | 10 +- docs/lib/ai-agent-detection.ts | 168 ----------------- docs/package.json | 1 + docs/proxy.ts | 34 ++-- pnpm-lock.yaml | 171 +++++++++++++++++- 5 files changed, 188 insertions(+), 196 deletions(-) delete mode 100644 docs/lib/ai-agent-detection.ts diff --git a/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts b/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts index 3134f7d1f7..dd3abd0c4c 100644 --- a/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts +++ b/docs/app/[lang]/llms.mdx/[[...slug]]/route.ts @@ -1,10 +1,12 @@ -import { notFound } from 'next/navigation'; +import { generateNotFoundMarkdown } from '@vercel/agent-readability'; import { rewriteCookbookUrlsInText } from '@/lib/geistdocs/cookbook-source'; import { getLLMText, source } from '@/lib/geistdocs/source'; import { i18n } from '@/lib/geistdocs/i18n'; export const revalidate = false; +const MARKDOWN_HEADERS = { 'Content-Type': 'text/markdown; charset=utf-8' }; + export async function GET( _req: Request, { params }: RouteContext<'/[lang]/llms.mdx/[[...slug]]'> @@ -13,7 +15,11 @@ export async function GET( const page = source.getPage(slug, lang); if (!page) { - notFound(); + // Status 200 (not 404): agents commonly discard 404 response bodies. + const requestedPath = slug?.length ? `/${slug.join('/')}` : '/'; + return new Response(generateNotFoundMarkdown(requestedPath), { + headers: MARKDOWN_HEADERS, + }); } const sitemapPath = diff --git a/docs/lib/ai-agent-detection.ts b/docs/lib/ai-agent-detection.ts deleted file mode 100644 index e4184e8ecf..0000000000 --- a/docs/lib/ai-agent-detection.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * AI Agent Detection Utility - * - * Multi-signal detection for AI agents/bots. Used to serve markdown - * responses when agents request docs pages. - * - * Three detection layers: - * 1. Known UA patterns (definitive) — curated from https://bots.fyi/?tags=ai_assistant - * 2. Signature-Agent header (definitive) — catches ChatGPT agent (RFC 9421) - * 3. Missing browser fingerprint heuristic — catches unknown bots - * - * Optimizes for recall over precision: serving markdown to a non-AI bot - * is low-harm; missing an AI agent means a worse experience. - * - * Last reviewed: 2026-03-20 against bots.fyi + official vendor docs - */ - -// Layer 1: Known AI agent UA substrings (lowercase). -const AI_AGENT_UA_PATTERNS = [ - // Anthropic — https://support.claude.com/en/articles/8896518 - 'claudebot', - 'claude-searchbot', - 'claude-user', - 'anthropic-ai', - 'claude-web', - - // OpenAI — https://platform.openai.com/docs/bots - 'chatgpt', - 'gptbot', - 'oai-searchbot', - 'openai', - - // Google AI - 'gemini', - 'bard', - 'google-cloudvertexbot', - 'google-extended', - - // Meta - 'meta-externalagent', - 'meta-externalfetcher', - 'meta-webindexer', - - // Search/Research AI - 'perplexity', - 'youbot', - 'you.com', - 'deepseekbot', - - // Coding assistants - 'cursor', - 'github-copilot', - 'codeium', - 'tabnine', - 'sourcegraph', - - // Other AI agents / data scrapers (low-harm to serve markdown) - 'cohere-ai', - 'bytespider', - 'amazonbot', - 'ai2bot', - 'diffbot', - 'omgili', - 'omgilibot', -]; - -// Layer 2: Known AI service URLs in Signature-Agent header (RFC 9421). -const SIGNATURE_AGENT_DOMAINS = ['chatgpt.com']; - -// Layer 3: Traditional bot exclusion list — bots that should NOT trigger -// the heuristic layer (they're search engine crawlers, social previews, or -// monitoring tools, not AI agents). -const TRADITIONAL_BOT_PATTERNS = [ - 'googlebot', - 'bingbot', - 'yandexbot', - 'baiduspider', - 'duckduckbot', - 'slurp', - 'msnbot', - 'facebot', - 'twitterbot', - 'linkedinbot', - 'whatsapp', - 'telegrambot', - 'pingdom', - 'uptimerobot', - 'newrelic', - 'datadog', - 'statuspage', - 'site24x7', - 'applebot', -]; - -// Broad regex for bot-like UA strings (used only in Layer 3 heuristic). -const BOT_LIKE_REGEX = /bot|agent|fetch|crawl|spider|search/i; - -export type DetectionMethod = 'ua-match' | 'signature-agent' | 'heuristic'; - -export interface DetectionResult { - detected: boolean; - method: DetectionMethod | null; -} - -/** - * Detects AI agents from HTTP request headers. - * - * Returns both whether the agent was detected and which signal triggered, - * so callers can log the detection method for accuracy tracking. - */ -export function isAIAgent(request: { - headers: { get(name: string): string | null }; -}): DetectionResult { - const userAgent = request.headers.get('user-agent'); - - // Layer 1: Known UA pattern match - if (userAgent) { - const lowerUA = userAgent.toLowerCase(); - if (AI_AGENT_UA_PATTERNS.some((pattern) => lowerUA.includes(pattern))) { - return { detected: true, method: 'ua-match' }; - } - } - - // Layer 2: Signature-Agent header (RFC 9421, used by ChatGPT agent) - const signatureAgent = request.headers.get('signature-agent'); - if (signatureAgent) { - const lowerSig = signatureAgent.toLowerCase(); - if (SIGNATURE_AGENT_DOMAINS.some((domain) => lowerSig.includes(domain))) { - return { detected: true, method: 'signature-agent' }; - } - } - - // Layer 3: Missing browser fingerprint heuristic - // Real browsers (Chrome 76+, Firefox 90+, Safari 16.4+) send sec-fetch-mode - // on navigation requests. Its absence signals a programmatic client. - const secFetchMode = request.headers.get('sec-fetch-mode'); - if (!secFetchMode && userAgent && BOT_LIKE_REGEX.test(userAgent)) { - const lowerUA = userAgent.toLowerCase(); - const isTraditionalBot = TRADITIONAL_BOT_PATTERNS.some((pattern) => - lowerUA.includes(pattern) - ); - if (!isTraditionalBot) { - return { detected: true, method: 'heuristic' }; - } - } - - return { detected: false, method: null }; -} - -/** - * Generates a markdown response for AI agents that hit non-existent URLs. - */ -export function generateAgentNotFoundResponse(requestedPath: string): string { - return `# Page Not Found - -The URL \`${requestedPath}\` does not exist in the documentation. - -## How to find the correct page - -1. **Browse the sitemap**: [/sitemap.md](/sitemap.md) — A structured index of all pages with URLs, content types, and descriptions -2. **Browse the full index**: [/llms.txt](/llms.txt) — Complete documentation index - -## Tips for requesting documentation - -- For markdown responses, append \`.md\` to URLs (e.g., \`/docs/getting-started.md\`) -- Use \`Accept: text/markdown\` header for content negotiation -`; -} diff --git a/docs/package.json b/docs/package.json index 5310247f99..f4b0ce4a27 100644 --- a/docs/package.json +++ b/docs/package.json @@ -31,6 +31,7 @@ "@types/node": "catalog:", "@types/react": "^19.1.12", "@types/react-dom": "^19.1.9", + "@vercel/agent-readability": "^0.2.1", "@vercel/analytics": "^1.6.1", "@vercel/edge-config": "^1.4.0", "@vercel/speed-insights": "1.3.1", diff --git a/docs/proxy.ts b/docs/proxy.ts index b6be88c65d..c26e559e76 100644 --- a/docs/proxy.ts +++ b/docs/proxy.ts @@ -1,3 +1,4 @@ +import { isAIAgent } from '@vercel/agent-readability'; import { createI18nMiddleware } from 'fumadocs-core/i18n/middleware'; import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation'; import { @@ -5,7 +6,6 @@ import { type NextRequest, NextResponse, } from 'next/server'; -import { isAIAgent } from '@/lib/ai-agent-detection'; import { i18n } from '@/lib/geistdocs/i18n'; import { trackMdRequest } from '@/lib/md-tracking'; @@ -92,22 +92,22 @@ const proxy = (request: NextRequest, context: NextFetchEvent) => { // so they always get structured content without needing .md URLs or Accept headers if (isDocsOrCookbookPath(pathname) && !pathname.includes('/llms.mdx/')) { const agentResult = isAIAgent(request); - if (agentResult.detected && !isMarkdownPreferred(request)) { - const result = markdownRewrite; - - if (result) { - context.waitUntil( - trackMdRequest({ - path: pathname, - userAgent: request.headers.get('user-agent'), - referer: request.headers.get('referer'), - acceptHeader: request.headers.get('accept'), - requestType: 'agent-rewrite', - detectionMethod: agentResult.method, - }) - ); - return NextResponse.rewrite(new URL(result, request.nextUrl)); - } + if ( + agentResult.detected && + !isMarkdownPreferred(request) && + markdownRewrite + ) { + context.waitUntil( + trackMdRequest({ + path: pathname, + userAgent: request.headers.get('user-agent'), + referer: request.headers.get('referer'), + acceptHeader: request.headers.get('accept'), + requestType: 'agent-rewrite', + detectionMethod: agentResult.method, + }) + ); + return NextResponse.rewrite(new URL(markdownRewrite, request.nextUrl)); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5819ef6263..06e6d40da3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@types/react-dom': specifier: ^19.1.9 version: 19.1.9(@types/react@19.1.13) + '@vercel/agent-readability': + specifier: ^0.2.1 + version: 0.2.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.55.0)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)))(svelte@5.55.0)(typescript@5.9.3)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)))(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(svelte@5.55.0)(vue-router@4.6.3(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3)) @@ -809,7 +812,7 @@ importers: version: link:../tsconfig nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) vite: specifier: 7.3.2 version: 7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3) @@ -1688,7 +1691,7 @@ importers: version: 5.2.1 nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) devDependencies: '@types/express': specifier: ^5.0.5 @@ -1722,7 +1725,7 @@ importers: version: 5.8.4 nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) devDependencies: '@types/node': specifier: 'catalog:' @@ -1771,7 +1774,7 @@ importers: version: 4.2.0 nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) openai: specifier: ^6.6.0 version: 6.6.0(ws@8.20.0)(zod@4.3.6) @@ -2144,7 +2147,7 @@ importers: version: 4.2.0 nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) openai: specifier: ^6.1.0 version: 6.6.0(ws@8.20.0)(zod@4.3.6) @@ -2394,7 +2397,7 @@ importers: version: 4.2.0 nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) openai: specifier: ^6.1.0 version: 6.9.1(ws@8.20.0)(zod@4.3.6) @@ -2428,7 +2431,7 @@ importers: version: 4.2.0 nitro: specifier: 'catalog:' - version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) + version: 3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) openai: specifier: ^6.6.0 version: 6.6.0(ws@8.20.0)(zod@4.3.6) @@ -2935,24 +2938,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.4': resolution: {integrity: sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.4': resolution: {integrity: sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.4': resolution: {integrity: sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.4': resolution: {integrity: sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==} @@ -3773,166 +3780,196 @@ packages: resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.3': resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.3': resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.3': resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.3': resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.3': resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.3': resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.4': resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.4': resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.4': resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.4': resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.4': resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.4': resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.4': resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.4': resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} @@ -4270,42 +4307,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -4438,24 +4482,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.1': resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.1': resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.1': resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.1': resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==} @@ -4510,24 +4558,28 @@ packages: engines: {node: '>= 12'} cpu: [arm64] os: [linux] + libc: [glibc] '@node-rs/xxhash-linux-arm64-musl@1.7.6': resolution: {integrity: sha512-AB5m6crGYSllM9F/xZNOQSPImotR5lOa9e4arW99Bv82S+gcpphI8fGMDOVTTCXY/RLRhvvhwzLDxmLB2O8VDg==} engines: {node: '>= 12'} cpu: [arm64] os: [linux] + libc: [musl] '@node-rs/xxhash-linux-x64-gnu@1.7.6': resolution: {integrity: sha512-a2A6M+5tc0PVlJlE/nl0XsLEzMpKkwg7Y1lR5urFUbW9uVQnKjJYQDrUojhlXk0Uv3VnYQPa6ThmwlacZA5mvQ==} engines: {node: '>= 12'} cpu: [x64] os: [linux] + libc: [glibc] '@node-rs/xxhash-linux-x64-musl@1.7.6': resolution: {integrity: sha512-WioGJSC1GoxQpmdQrG5l/uddSBAS4XCWczHNwXe895J5xadGQzyvmr0r17BNfihvbBUDH1H9jwouNYzDDeA6+A==} engines: {node: '>= 12'} cpu: [x64] os: [linux] + libc: [musl] '@node-rs/xxhash-wasm32-wasi@1.7.6': resolution: {integrity: sha512-WDXXKMMFMrez+esm2DzMPHFNPFYf+wQUtaXrXwtxXeQMFEzleOLwEaqV0+bbXGJTwhPouL3zY1Qo2xmIH4kkTg==} @@ -4854,48 +4906,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-C3zapJconWpl2Y7LR3GkRkH6jxpuV2iVUfkFcHT5Ffn4Zu7l88mZa2dhcfdULZDybN1Phka/P34YUzuskUUrXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-minify/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-2T/Bm+3/qTfuNS4gKSzL8qbiYk+ErHW2122CtDx+ilZAzvWcJ8IbqdZIbEWOlwwe03lESTxPwTBLFqVgQU2OeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-MKLjpldYkeoB4T+yAi4aIAb0waifxUjLcKkCUDmYAY3RqBJTvWK34KtfaKZL0IBMIXfD92CbKkcxQirDUS9Xcg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-UFVcbPvKUStry6JffriobBp8BHtjmLLPl4bCY+JMxIn/Q3pykCpZzRwFTcDurG/kY8tm+uSNfKKdRNa5Nh9A7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-minify/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-B9GyPQ1NKbvpETVAMyJMfRlD3c6UJ7kiuFUAlx9LTYiQL+YIyT6vpuRlq1zgsXxavZluVrfeJv6x0owV4KDx4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-fXfhtr+WWBGNy4M5GjAF5vu/lpulR4Me34FjTyaK9nDrTZs7LM595UDsP1wliksqp4hD/KdoqHGmbCrC+6d4vA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-minify/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-jFBgGbx1oLadb83ntJmy1dWlAHSQanXTS21G4PgkxyONmxZdZ/UMKr7KsADzMuoPsd2YhJHxzRpwJd9U+4BFBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-minify/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-nxPd9vx1vYz8IlIMdl9HFdOK/ood1H5hzbSFsyO8JU55tkcJoBL8TLCbuFf9pHpOy27l2gcPyV6z3p4eAcTH5Q==} @@ -4973,48 +5033,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-QagKTDF4lrz8bCXbUi39Uq5xs7C7itAseKm51f33U+Dyar9eJY/zGKqfME9mKLOiahX7Fc1J3xMWVS0AdDXLPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-RPddpcE/0xxWaommWy0c5i/JdrXcXAkxBS2GOrAUh5LKmyCh03hpJedOAWszG4ADsKQwoUQQ1/tZVGRhZIWtKA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-ur/WVZF9FSOiZGxyP+nfxZzuv6r5OJDYoVxJnUR7fM/hhXLh4V/be6rjbzm9KLCDBRwYCEKJtt+XXNccwd06IA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-ujGcAx8xAMvhy7X5sBFi3GXML1EtyORuJZ5z2T6UV3U416WgDX/4OCi3GnoteeenvxIf6JgP45B+YTHpt71vpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-hbsfKjUwRjcMZZvvmpZSc+qS0bHcHRu8aV/I3Ikn9BzOA0ZAgUE7ctPtce5zCU7bM8dnTLi4sJ1Pi9YHdx6Urw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-1QrTrf8rige7UPJrYuDKJLQOuJlgkt+nRSJLBMHWNm9TdivzP48HaK3f4q18EjNlglKtn03lgjMu4fryDm8X4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-gRvK6HPzF5ITRL68fqb2WYYs/hGviPIbkV84HWCgiJX+LkaOpp+HIHQl3zVZdyKHwopXToTbXbtx/oFjDjl8pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-QPJvFbnnDZZY7xc+xpbIBWLThcGBakwaYA9vKV8b3+oS5MGfAZUoTFJcix5+Zg2Ri46sOfrUim6Y6jsKNcssAQ==} @@ -5098,48 +5166,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-arm64-musl@0.117.0': resolution: {integrity: sha512-ykxpPQp0eAcSmhy0Y3qKvdanHY4d8THPonDfmCoktUXb6r0X6qnjpJB3V+taN1wevW55bOEZd97kxtjTKjqhmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-ppc64-gnu@0.117.0': resolution: {integrity: sha512-Rvspti4Kr7eq6zSrURK5WjscfWQPvmy/KjJZV45neRKW8RLonE3r9+NgrwSLGoHvQ3F24fbqlkplox1RtlhH5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-gnu@0.117.0': resolution: {integrity: sha512-Dr2ZW9ZZ4l1eQ5JUEUY3smBh4JFPCPuybWaDZTLn3ADZjyd8ZtNXEjeMT8rQbbhbgSL9hEgbwaqraole3FNThQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-riscv64-musl@0.117.0': resolution: {integrity: sha512-oD1Bnes1bIC3LVBSrWEoSUBj6fvatESPwAVWfJVGVQlqWuOs/ZBn1e4Nmbipo3KGPHK7DJY75r/j7CQCxhrOFQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxc-transform/binding-linux-s390x-gnu@0.117.0': resolution: {integrity: sha512-qT//IAPLvse844t99Kff5j055qEbXfwzWgvCMb0FyjisnB8foy25iHZxZIocNBe6qwrCYWUP1M8rNrB/WyfS1Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-gnu@0.117.0': resolution: {integrity: sha512-2YEO5X+KgNzFqRVO5dAkhjcI5gwxus4NSWVl/+cs2sI6P0MNPjqE3VWPawl4RTC11LvetiiZdHcujUCPM8aaUw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxc-transform/binding-linux-x64-musl@0.117.0': resolution: {integrity: sha512-3wqWbTSaIFZvDr1aqmTul4cg8PRWYh6VC52E8bLI7ytgS/BwJLW+sDUU2YaGIds4sAf/1yKeJRmudRCDPW9INg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxc-transform/binding-openharmony-arm64@0.117.0': resolution: {integrity: sha512-Ebxx6NPqhzlrjvx4+PdSqbOq+li0f7X59XtJljDghkbJsbnkHvhLmPR09ifHt5X32UlZN63ekjwcg/nbmHLLlA==} @@ -5199,36 +5275,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.5.1': resolution: {integrity: sha512-RJxlQQLkaMMIuWRozy+z2vEqbaQlCuaCgVZIUCzQLYggY22LZbP5Y1+ia+FD724Ids9e+XIyOLXLrLgQSHIthw==} @@ -6491,36 +6573,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} @@ -6727,121 +6815,145 @@ packages: resolution: {integrity: sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.60.0': resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.53.2': resolution: {integrity: sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.53.2': resolution: {integrity: sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.53.2': resolution: {integrity: sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.53.2': resolution: {integrity: sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.53.2': resolution: {integrity: sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.53.2': resolution: {integrity: sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.53.2': resolution: {integrity: sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.53.2': resolution: {integrity: sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.53.2': resolution: {integrity: sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.53.2': resolution: {integrity: sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -7281,24 +7393,28 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.15.3': resolution: {integrity: sha512-j4SJniZ/qaZ5g8op+p1G9K1z22s/EYGg1UXIb3+Cg4nsxEpF5uSIGEE4mHUfA70L0BR9wKT2QF/zv3vkhfpX4g==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.15.3': resolution: {integrity: sha512-aKttAZnz8YB1VJwPQZtyU8Uk0BfMP63iDMkvjhJzRZVgySmqt/apWSdnoIcZlUoGheBrcqbMC17GGUmur7OT5A==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.15.3': resolution: {integrity: sha512-oe8FctPu1gnUsdtGJRO2rvOUIkkIIaHqsO9xxN0bTR7dFTlPTGi2Fhk1tnvXeyAvCPxLIcwD8phzKg6wLv9yug==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.15.3': resolution: {integrity: sha512-L9AjzP2ZQ/Xh58e0lTRMLvEDrcJpR7GwZqAtIeNLcTK7JVE+QineSyHp0kLkO1rttCHyCy0U74kDTj0dRz6raA==} @@ -7411,48 +7527,56 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.13': resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.13': resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.13': resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.13': resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==} @@ -7995,6 +8119,16 @@ packages: peerDependencies: vue: '>=3.5.18' + '@vercel/agent-readability@0.2.1': + resolution: {integrity: sha512-ShT7BzIS/dwKompii8tm5do+NR1g4xL5M3wM7S01xsH6yuYQ7wiTPZEcmHMFLHCsAQg45/mD0hgrufpS3NVunw==} + engines: {node: '>=20.0.0'} + hasBin: true + peerDependencies: + next: '>=14' + peerDependenciesMeta: + next: + optional: true + '@vercel/analytics@1.6.1': resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==} peerDependencies: @@ -11483,72 +11617,84 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.30.2: resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -15024,6 +15170,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -22787,6 +22934,10 @@ snapshots: unhead: 2.1.12 vue: 3.5.30(typescript@5.9.3) + '@vercel/agent-readability@0.2.1(next@16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': + optionalDependencies: + next: 16.2.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@vercel/analytics@1.6.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.55.0)(vite@6.4.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)))(svelte@5.55.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)))(next@16.2.1(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.55.0)(vue-router@4.6.3(vue@3.5.30(typescript@5.9.3)))(vue@3.5.30(typescript@5.9.3))': optionalDependencies: '@sveltejs/kit': 2.55.0(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.55.0)(vite@6.4.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)))(svelte@5.55.0)(typescript@5.9.3)(vite@6.4.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)) @@ -28147,7 +28298,7 @@ snapshots: nf3@0.3.16: {} - nitro@3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)): + nitro@3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(rollup@4.53.2)(vite@7.3.2(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) @@ -28164,6 +28315,7 @@ snapshots: unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.7(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(chokidar@5.0.0)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1)(ofetch@2.0.0-alpha.3) optionalDependencies: + '@vercel/queue': 0.1.7 dotenv: 17.3.1 giget: 3.1.2 jiti: 2.6.1 @@ -28200,7 +28352,7 @@ snapshots: - sqlite3 - uploadthing - nitro@3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)): + nitro@3.0.260429-beta(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(@vercel/queue@0.1.7)(better-sqlite3@11.10.0)(chokidar@5.0.0)(dotenv@17.3.1)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8))(giget@3.1.2)(ioredis@5.10.1)(jiti@2.6.1)(vite@7.3.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.3)): dependencies: consola: 3.4.2 crossws: 0.4.5(srvx@0.11.15) @@ -28217,6 +28369,7 @@ snapshots: unenv: 2.0.0-rc.24 unstorage: 2.0.0-alpha.7(@netlify/blobs@9.1.2)(@vercel/blob@2.0.0)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.13))(chokidar@5.0.0)(db0@0.3.4(better-sqlite3@11.10.0)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(better-sqlite3@11.10.0)(pg@8.20.0)(postgres@3.4.8)))(ioredis@5.10.1)(ofetch@2.0.0-alpha.3) optionalDependencies: + '@vercel/queue': 0.1.7 dotenv: 17.3.1 giget: 3.1.2 jiti: 2.6.1 From 070bd0cea960a0d56d7812a6147455f75a06d859 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Fri, 22 May 2026 07:11:34 -0700 Subject: [PATCH 5/7] [next] make lazyDiscovery the default in withWorkflow (#1805) * [next] make lazyDiscovery the default in withWorkflow Flips the default for `workflows.lazyDiscovery` from `false` to `true` so new projects get deferred workflow discovery automatically on Next.js versions that support deferred entries (>= 16.2.0-canary.48). Older versions continue to fall back to eager discovery. Users can still opt back into eager discovery explicitly by passing `workflows: { lazyDiscovery: false }`. Also: - Remove the now-redundant `lazyDiscovery: true` from the Next.js workbench apps. - Reword the fallback warning for clarity when lazy is the default. - Update the local-build e2e assertion to match the new warning text. - Update the withWorkflow docs with the new default. * [workbench] remove commented 'export default nextConfig' lines --- .changeset/lazy-discovery-default.md | 10 ++++++++++ .../api-reference/workflow-next/with-workflow.mdx | 4 ++-- packages/core/e2e/local-build.test.ts | 3 +-- packages/next/src/builder.ts | 2 +- packages/next/src/index.test.ts | 15 +++++++++++++++ packages/next/src/index.ts | 8 +++++++- workbench/nextjs-turbopack/next.config.ts | 7 ++----- workbench/nextjs-webpack/next.config.ts | 3 +-- 8 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 .changeset/lazy-discovery-default.md diff --git a/.changeset/lazy-discovery-default.md b/.changeset/lazy-discovery-default.md new file mode 100644 index 0000000000..9490c888c9 --- /dev/null +++ b/.changeset/lazy-discovery-default.md @@ -0,0 +1,10 @@ +--- +'@workflow/next': minor +--- + +Change `lazyDiscovery` default to `true` for `withWorkflow`. Workflow +discovery is now deferred until files are requested instead of scanning +eagerly at startup on Next.js versions that support deferred entries +(>= 16.2.0-canary.48). Older versions automatically fall back to eager +discovery. Pass `workflows: { lazyDiscovery: false }` to opt back into +eager discovery on supported Next.js versions. diff --git a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx index 413cb55d00..84562002fa 100644 --- a/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx +++ b/docs/content/docs/v5/api-reference/workflow-next/with-workflow.mdx @@ -68,7 +68,7 @@ const nextConfig: NextConfig = {}; export default withWorkflow(nextConfig, { workflows: { - lazyDiscovery: true, + lazyDiscovery: false, local: { port: 4000, }, @@ -79,7 +79,7 @@ export default withWorkflow(nextConfig, { | Option | Type | Default | Description | | --- | --- | --- | --- | -| `workflows.lazyDiscovery` | `boolean` | `false` | When `true`, defers workflow discovery until files are requested instead of scanning eagerly at startup. Useful for large projects where startup time matters. | +| `workflows.lazyDiscovery` | `boolean` | `true` | Defers workflow discovery until files are requested instead of scanning eagerly at startup. Set to `false` to force eager discovery (scanning the project up front). Requires a Next.js version that supports deferred entries; older versions fall back to eager discovery automatically. | | `workflows.local.port` | `number` | — | Overrides the `PORT` environment variable for local development. Has no effect when deployed to Vercel. | | `workflows.sourcemap` | `boolean \| 'inline' \| 'linked' \| 'external' \| 'both'` | `'inline'` | Controls source maps on generated workflow bundles. See [Source maps](#source-maps) below. | diff --git a/packages/core/e2e/local-build.test.ts b/packages/core/e2e/local-build.test.ts index 5be9d63cf9..f734c32fcc 100644 --- a/packages/core/e2e/local-build.test.ts +++ b/packages/core/e2e/local-build.test.ts @@ -96,8 +96,7 @@ const DEFERRED_BUILD_MODE_PROJECTS = new Set([ 'nextjs-webpack', 'nextjs-turbopack', ]); -const DEFERRED_BUILD_UNSUPPORTED_WARNING = - 'Enabled lazyDiscovery but Next.js version is not compatible'; +const DEFERRED_BUILD_UNSUPPORTED_WARNING = 'lazyDiscovery requires Next.js >='; const EAGER_DISCOVERY_LOG = 'Discovering workflow directives'; describe.each([ diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 43390403fa..e4a4e9ec56 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -23,7 +23,7 @@ export function shouldUseDeferredBuilder(nextVersion: string): boolean { if (flagEnabled && !versionCompatible && !warnedAboutFlagAndVersion) { warnedAboutFlagAndVersion = true; console.warn( - `Enabled lazyDiscovery but Next.js version is not compatible, needs ${DEFERRED_BUILDER_MIN_VERSION} have ${nextVersion}` + `lazyDiscovery requires Next.js >= ${DEFERRED_BUILDER_MIN_VERSION} (found ${nextVersion}); falling back to eager workflow discovery.` ); } diff --git a/packages/next/src/index.test.ts b/packages/next/src/index.test.ts index 4a3e0adec0..ed68e38a1e 100644 --- a/packages/next/src/index.test.ts +++ b/packages/next/src/index.test.ts @@ -129,6 +129,21 @@ describe('withWorkflow builder config', () => { }); }); + it('enables lazyDiscovery by default', async () => { + withWorkflow({}); + expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBe('1'); + }); + + it('enables lazyDiscovery when explicitly set to true', async () => { + withWorkflow({}, { workflows: { lazyDiscovery: true } }); + expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBe('1'); + }); + + it('disables lazyDiscovery when explicitly set to false', async () => { + withWorkflow({}, { workflows: { lazyDiscovery: false } }); + expect(process.env.WORKFLOW_NEXT_LAZY_DISCOVERY).toBeUndefined(); + }); + it('configures diagnostics inside the default Next.js dist dir', async () => { const config = withWorkflow({}); diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index cdb9dbbd47..3efa9a40a5 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -341,11 +341,17 @@ export function withWorkflow( }; } = {} ) { + // lazyDiscovery defaults to true; pass `lazyDiscovery: false` to force eager + // discovery (scanning the project at startup) instead of deferring workflow + // discovery until files are requested. The `WORKFLOW_NEXT_LAZY_DISCOVERY` + // environment variable, if set, takes precedence over the option. const lazyDiscoveryOverride = parseEnvironmentFlag( process.env.WORKFLOW_NEXT_LAZY_DISCOVERY ); if (lazyDiscoveryOverride === undefined) { - if (workflows?.lazyDiscovery) { + if (workflows?.lazyDiscovery === false) { + delete process.env.WORKFLOW_NEXT_LAZY_DISCOVERY; + } else { process.env.WORKFLOW_NEXT_LAZY_DISCOVERY = '1'; } } else { diff --git a/workbench/nextjs-turbopack/next.config.ts b/workbench/nextjs-turbopack/next.config.ts index 24b6181926..e2af03af80 100644 --- a/workbench/nextjs-turbopack/next.config.ts +++ b/workbench/nextjs-turbopack/next.config.ts @@ -1,5 +1,5 @@ -import type { NextConfig } from 'next'; import path from 'node:path'; +import type { NextConfig } from 'next'; import { withWorkflow } from 'workflow/next'; const turbopackRoot = path.resolve(process.cwd(), '../..'); @@ -18,7 +18,4 @@ const nextConfig: NextConfig = { }, }; -// export default nextConfig; -export default withWorkflow(nextConfig, { - workflows: { lazyDiscovery: true }, -}); +export default withWorkflow(nextConfig); diff --git a/workbench/nextjs-webpack/next.config.ts b/workbench/nextjs-webpack/next.config.ts index 69ef25c947..6e66cb37cd 100644 --- a/workbench/nextjs-webpack/next.config.ts +++ b/workbench/nextjs-webpack/next.config.ts @@ -10,5 +10,4 @@ const nextConfig: NextConfig = { serverExternalPackages: ['@node-rs/xxhash'], }; -// export default nextConfig; -export default withWorkflow(nextConfig, { workflows: { lazyDiscovery: true } }); +export default withWorkflow(nextConfig); From 34481af4b6c5b321275f874f93012e639d7971c6 Mon Sep 17 00:00:00 2001 From: Mitul Shah Date: Fri, 22 May 2026 11:30:38 -0400 Subject: [PATCH 6/7] Detail Pane Cleanup (#1973) * cleanup items * Update events-list.tsx * nice * Update attribute-panel.tsx * woo * remove toast * Fix: Unused variable `selectedResource` causes TypeScript build failure (TS6133) due to `noUnusedLocals: true` in tsconfig. This commit fixes the issue reported at packages/web-shared/src/components/new-trace-viewer/trace-viewer.tsx:462 **Bug explanation:** The variable `selectedResource` is declared on line 462 of `trace-viewer.tsx`: ```ts const selectedResource = selectedSpan?.resource as string | undefined; ``` However, all references to this variable were removed in the PR (the colored resource badge in the panel header was removed), leaving behind the unused declaration. The project's TypeScript configuration has `noUnusedLocals: true`, which causes TypeScript to emit error TS6133 for any declared-but-unused local variables. This is confirmed directly in the Vercel build logs: ``` @workflow/web-shared:build: src/components/new-trace-viewer/trace-viewer.tsx(462,9): error TS6133: 'selectedResource' is declared but its value is never read. ``` This caused the `@workflow/web-shared#build` task to fail with exit code 2, which in turn caused the entire Vercel deployment to fail. **Fix explanation:** Removed the unused `const selectedResource = selectedSpan?.resource as string | undefined;` declaration on line 462. The nearby `selectedResourceId` variable (which was NOT removed) remains in place and is still actively used in the JSX below. This is a minimal one-line deletion that resolves the build failure. Co-authored-by: Vercel Co-authored-by: mitul-s * tweak * Update events-list.tsx * Update events-list.tsx * cleanup * Update attribute-panel.tsx * copy button * Create button.tsx * cleanup * rename `this` to Context * cleanup * Update button.tsx * avoid decrypt flashign * cleanup * Update attribute-panel.tsx * nav on detail card * polish * Update detail-card.tsx * Update trace-viewer.tsx * colors * Update attribute-panel.tsx * Update detail-card.tsx * Update copyable-data-block.tsx * Detail Pane + Other cleanup items (#2020) * polish * Update attribute-panel.tsx * Create wise-frogs-thank.md Signed-off-by: Mitul Shah --------- Signed-off-by: Mitul Shah --------- Signed-off-by: Mitul Shah Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com> Co-authored-by: Vercel Co-authored-by: Cursor Agent --- .changeset/wise-frogs-thank.md | 5 + .../components/copy-button.tsx | 2 +- .../components/event-list.tsx | 2 +- .../new-trace-viewer/detail-panel.tsx | 2 +- .../new-trace-viewer/trace-viewer.tsx | 139 +++--- .../components/sidebar/attribute-panel.tsx | 440 ++++++++---------- .../sidebar/copyable-data-block.tsx | 76 +-- .../src/components/sidebar/detail-card.tsx | 160 +++++-- .../sidebar/entity-detail-panel.tsx | 56 +-- .../src/components/sidebar/events-list.tsx | 130 ++---- .../web-shared/src/components/ui/button.tsx | 61 +++ .../src/components/ui/data-inspector.tsx | 27 +- .../src/components/ui/error-stack-block.tsx | 33 +- 13 files changed, 620 insertions(+), 513 deletions(-) create mode 100644 .changeset/wise-frogs-thank.md create mode 100644 packages/web-shared/src/components/ui/button.tsx diff --git a/.changeset/wise-frogs-thank.md b/.changeset/wise-frogs-thank.md new file mode 100644 index 0000000000..4b62fe9ff7 --- /dev/null +++ b/.changeset/wise-frogs-thank.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +adjusted spacing on trace viewer and detail pane diff --git a/packages/web-shared/src/components/new-trace-viewer/components/copy-button.tsx b/packages/web-shared/src/components/new-trace-viewer/components/copy-button.tsx index 2b3df3bd9b..59e38159ef 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/copy-button.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/copy-button.tsx @@ -31,7 +31,7 @@ export function CopyButton({ type="button" aria-label={ariaLabel} className={cn( - 'cursor-pointer text-gray-800 hover:text-gray-1000 bg-transparent border-none p-1 m-0', + 'cursor-pointer text-gray-800 hover:text-gray-1000 bg-transparent border-0 p-1 m-0', className )} onClick={(e) => { diff --git a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx index faf504a1e6..7952baf2b6 100644 --- a/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/components/event-list.tsx @@ -68,7 +68,7 @@ const EventRow = ({ onClick={() => onSelectSpan(span.spanId)} >
-
+
diff --git a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx index 64956d78fc..739105742c 100644 --- a/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx +++ b/packages/web-shared/src/components/new-trace-viewer/detail-panel.tsx @@ -29,7 +29,7 @@ export function DetailPanel({ + +
- ); - } - return ( -
- - Encrypted -
- ); + return ; } /** @@ -269,7 +226,9 @@ type AttributeKey = | 'workflowCoreVersion' | 'receivedCount' | 'lastReceivedAt' - | 'disposedAt'; + | 'disposedAt' + | 'isSystem' + | 'errorCode'; const attributeOrder: AttributeKey[] = [ 'workflowName', @@ -326,6 +285,7 @@ const attributeDisplayNames: Partial> = { attempt: 'Attempts', eventId: 'Event ID', runId: 'Run ID', + token: 'Token', eventType: 'Event Type', correlationId: 'Correlation ID', deploymentId: 'Deployment ID', @@ -432,7 +392,7 @@ const attributeToDisplayFn: Record< stepName: (_value: unknown) => null, // IDs runId: (_value: unknown) => null, - stepId: (_value: unknown) => null, + stepId: (value: unknown) => String(value), hookId: (value: unknown) => String(value), eventId: (value: unknown) => String(value), // Run/step details @@ -468,12 +428,18 @@ const attributeToDisplayFn: Record< // Resolved attributes, won't actually use this function metadata: (value: unknown) => { if (!hasDisplayContent(value)) return null; - if (isEncryptedMarker(value)) return ; + if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; return JsonBlock(value); }, input: (value: unknown, context?: DisplayContext) => { - if (isEncryptedMarker(value)) return ; + if (isEncryptedMarker(value)) { + return ( + + + + ); + } if (isExpiredMarker(value)) return ; // Check if input has args + closure vars structure if (value && typeof value === 'object' && 'args' in value) { @@ -482,8 +448,6 @@ const attributeToDisplayFn: Record< closureVars?: Record; thisVal?: unknown; }; - const argCount = Array.isArray(args) ? args.length : 0; - const argLabel = argCount === 1 ? 'argument' : 'arguments'; const hasClosureVars = hasDisplayContent(closureVars); const hasThisVal = hasDisplayContent(thisVal); const hasArgs = hasDisplayContent(args); @@ -512,22 +476,12 @@ const attributeToDisplayFn: Record< // Don't render an empty "Input (0 arguments)" card when no input exists. if (!hasArgs && !hasClosureVars && !hasThisVal) { - return ( - - ); + return ; } return ( <> - + {Array.isArray(args) ? args.map((v, i) => (
@@ -542,30 +496,18 @@ const attributeToDisplayFn: Record< )} {hasThisVal && ( - {JsonBlock(thisVal)} + {JsonBlock(thisVal)} )} ); } // Fallback: treat as plain array or object - const argCount = Array.isArray(value) ? value.length : 0; - const argLabel = argCount === 1 ? 'argument' : 'arguments'; if (!hasDisplayContent(value)) { - return ( - - ); + return ; } return ( - + {Array.isArray(value) ? value.map((v, i) => (
@@ -577,21 +519,25 @@ const attributeToDisplayFn: Record< ); }, output: (value: unknown) => { + if (isEncryptedMarker(value)) { + return ( + + + + ); + } if (!hasDisplayContent(value)) return null; - if (isEncryptedMarker(value)) return ; if (isExpiredMarker(value)) return ; - return ( - - {JsonBlock(value)} - - ); + return {JsonBlock(value)}; }, error: (value: unknown) => { - if (isEncryptedMarker(value)) return ; + if (isEncryptedMarker(value)) { + return ( + + + + ); + } if (isExpiredMarker(value)) return ; if (!hasDisplayContent(value)) return null; @@ -599,31 +545,33 @@ const attributeToDisplayFn: Record< // pre-formatted text. Otherwise fall back to the raw JSON viewer. if (isStructuredErrorWithStack(value)) { return ( - + ); } return ( - + {JsonBlock(value)} ); }, eventData: (value: unknown) => { - if (isEncryptedMarker(value)) return ; + if (isEncryptedMarker(value)) { + return ( + + + + ); + } if (isExpiredMarker(value)) return ; if (!hasDisplayContent(value)) return null; - return {JsonBlock(value)}; + return ( + + {JsonBlock(value)} + + ); }, errorCode: (value: unknown) => { if (typeof value !== 'string' || value.length === 0) return null; @@ -639,6 +587,15 @@ const resolvableAttributes = [ 'eventData', ]; +// Attributes whose displayFn renders its own section header via DetailCard, +// so the outer AttributeBlock should not duplicate the label. +const selfHeaderedAttributes = new Set([ + 'input', + 'output', + 'error', + 'eventData', +]); + const ExpiredDataMessage = () => (
(
); +const copyableBasicAttributes = new Set([ + 'stepId', + 'hookId', + 'eventId', + 'deploymentId', +]); + export const AttributeBlock = ({ attribute, value, @@ -665,19 +629,26 @@ export const AttributeBlock = ({ inline?: boolean; context?: DisplayContext; }) => { + const decryptCtx = useContext(DecryptClickContext); const isExpandableLoadingTarget = - attribute === 'input' || attribute === 'eventData'; + attribute === 'input' || + attribute === 'output' || + attribute === 'eventData'; if (isLoading && isExpandableLoadingTarget && !hasDisplayContent(value)) { - return ( -
- - {attribute} - - -
- ); + const label = + attribute === 'eventData' + ? 'Event Data' + : attribute === 'output' + ? 'Output' + : 'Input'; + if (decryptCtx?.hasEncryptedData) { + return ( + + + + ); + } + return ; } const displayFn = @@ -706,6 +677,10 @@ export const AttributeBlock = ({ ); } + if (selfHeaderedAttributes.has(attribute)) { + return <>{displayValue}; + } + return (
{typeof isLoading === 'boolean' && isLoading && ( @@ -716,10 +691,7 @@ export const AttributeBlock = ({ />
)} -
+
{attribute} @@ -789,16 +761,18 @@ export const AttributePanel = ({ if (!isLoading) return present; - // During loading, ensure input appears so its skeleton renders + if (resource === 'sleep') return present; + + // During loading, ensure sections appear so their skeletons render // in the correct position (above the events section). - const loadingDefaults = ['input']; + const loadingDefaults = ['input', 'output']; for (const key of loadingDefaults) { if (!present.includes(key)) { present.push(key); } } return present.sort(sortByAttributeOrder); - }, [displayData, isLoading]); + }, [displayData, isLoading, resource]); // Filter out attributes that return null const visibleBasicAttributes = basicAttributes.filter((attribute) => { @@ -854,125 +828,121 @@ export const AttributePanel = ({ }); }, []); + const outerDecryptCtx = useContext(DecryptClickContext); + const decryptValue = onDecrypt + ? { + onDecrypt, + isDecrypting, + hasEncryptedData: outerDecryptCtx?.hasEncryptedData, + } + : outerDecryptCtx; + return ( - -
- {/* Basic attributes in a vertical layout with border */} - {visibleBasicAttributes.length > 0 && ( -
- {orderedBasicAttributes.map((attribute, index) => { - const displayValue = attributeToDisplayFn[ - attribute as keyof typeof attributeToDisplayFn - ]?.(displayData[attribute as keyof typeof displayData]); - const isModuleSpecifier = attribute === 'moduleSpecifier'; - const moduleSpecifierValue = - typeof displayValue === 'string' - ? displayValue - : String( - displayValue ?? displayData.moduleSpecifier ?? '' - ); - const shouldCapitalizeLabel = - attribute !== 'workflowCoreVersion'; - const showResumeAtSkeleton = - isLoading && resource === 'sleep' && !displayData.resumeAt; - const showDivider = - index < orderedBasicAttributes.length - 1 || - showResumeAtSkeleton; - - return ( -
-
- - {getAttributeDisplayName(attribute)} - - {isModuleSpecifier ? ( - - ) : ( - - {displayValue} - - )} -
- {showDivider ? ( -
- ) : null} -
- ); - })} - {isLoading && resource === 'sleep' && !displayData.resumeAt && ( -
-
- + {visibleBasicAttributes.length > 0 && ( +
+ {orderedBasicAttributes.map((attribute) => { + const displayValue = attributeToDisplayFn[ + attribute as keyof typeof attributeToDisplayFn + ]?.(displayData[attribute as keyof typeof displayData]); + const isModuleSpecifier = attribute === 'moduleSpecifier'; + const isCopyableBasicAttribute = + copyableBasicAttributes.has(attribute as AttributeKey) && + typeof displayValue === 'string'; + const moduleSpecifierValue = + typeof displayValue === 'string' + ? displayValue + : String(displayValue ?? displayData.moduleSpecifier ?? ''); + + return ( +
+ + {getAttributeDisplayName(attribute)} + + {isModuleSpecifier ? ( + + ) : isCopyableBasicAttribute ? ( +
+ + +
+ ) : ( + + {displayValue} - -
+ )}
- )} -
- )} - {error ? ( - - ) : hasExpired ? ( - - ) : ( - <> - {resolvedAttributes.map((attribute) => ( - - ))} - - )} -
+ ); + })} + {isLoading && resource === 'sleep' && !displayData.resumeAt && ( +
+
+ + resumeAt + + +
+
+ )} +
+ )} + {error ? ( + + ) : hasExpired ? ( + + ) : resolvedAttributes.length > 0 ? ( + <> + {resolvedAttributes.map((attribute) => ( + + ))} + + ) : null} diff --git a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx index d77f4376f9..eb85678777 100644 --- a/packages/web-shared/src/components/sidebar/copyable-data-block.tsx +++ b/packages/web-shared/src/components/sidebar/copyable-data-block.tsx @@ -1,8 +1,48 @@ 'use client'; -import { Copy } from 'lucide-react'; -import { useToast } from '../../lib/toast'; -import { DataInspector } from '../ui/data-inspector'; +import { Lock } from 'lucide-react'; +import { useContext } from 'react'; +import { CopyButton } from '../new-trace-viewer/components/copy-button'; +import { Button } from '../ui/button'; +import { DataInspector, DecryptClickContext } from '../ui/data-inspector'; +import { Spinner } from '../ui/spinner'; + +const encryptedPlaceholderPreview = `{ + "input": "[encrypted]", + "result": "[encrypted]" +}`; + +export function EncryptedDataBlock() { + const ctx = useContext(DecryptClickContext); + + return ( +
+ +
+ {ctx ? ( + + ) : ( + + + Encrypted + + )} +
+
+ ); +} const serializeForClipboard = (value: unknown): string => { if (typeof value === 'string') return value; @@ -21,31 +61,13 @@ const serializeForClipboard = (value: unknown): string => { }; export function CopyableDataBlock({ data }: { data: unknown }) { - const toast = useToast(); return ( -
- +
+
); diff --git a/packages/web-shared/src/components/sidebar/detail-card.tsx b/packages/web-shared/src/components/sidebar/detail-card.tsx index 8d817f8cce..18f89a765b 100644 --- a/packages/web-shared/src/components/sidebar/detail-card.tsx +++ b/packages/web-shared/src/components/sidebar/detail-card.tsx @@ -1,65 +1,143 @@ -import { ChevronRight } from 'lucide-react'; -import type { ReactNode } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { type ReactNode, useState } from 'react'; +import { cn } from '../../lib/utils'; export function DetailCard({ summary, children, onToggle, disabled = false, + defaultOpen = false, + variant = 'section', + trailing, summaryClassName, contentClassName, }: { summary: ReactNode; children?: ReactNode; - /** Called when the detail card is expanded/collapsed */ onToggle?: (open: boolean) => void; - /** Renders a non-expandable summary card when true. */ disabled?: boolean; - /** Extra classes for the summary row. */ + defaultOpen?: boolean; + variant?: 'section' | 'card'; + trailing?: ReactNode; summaryClassName?: string; - /** Extra classes for expanded content wrapper. */ contentClassName?: string; }) { - if (disabled) { + const [open, setOpen] = useState(defaultOpen); + + const handleToggle = (e: React.SyntheticEvent) => { + // React surfaces `toggle` as a bubbling synthetic event even though the + // native event doesn't bubble. Without this guard, a nested
+ // (e.g. an event card inside the Events section) collapsing would flip + // the outer DetailCard's state. Only react to direct toggles. + if (e.target !== e.currentTarget) return; + const next = e.currentTarget.open; + setOpen(next); + onToggle?.(next); + }; + + if (variant === 'card') { + if (disabled) { + return ( +
+ {summary} +
+ ); + } return ( -
- {summary} -
+ + + + {summary} + + +
{children}
+
+ ); + } + + // Shared height with the expandable summary row. Keeps the trailing / + // disabled / chevron variants visually identical in height regardless of + // what's in the trailing slot. + const rowClasses = + 'flex h-9 items-center gap-2 px-2 -mx-2 text-heading-14 font-medium my-2'; + + if (trailing) { + return ( +
+
+
+ +
+ {summary} +
{trailing}
+
+
+ ); + } + + if (disabled) { + return ( +
+
+ {summary} +
+
); } return ( -
onToggle?.((e.target as HTMLDetailsElement).open)} - > - - - - {summary} - - -
{children}
-
+
+
+ +
+ + +
+ {summary} +
+
{children}
+
+
); } diff --git a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx index bf1f7682f3..483b4e6dd5 100644 --- a/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx +++ b/packages/web-shared/src/components/sidebar/entity-detail-panel.tsx @@ -9,6 +9,7 @@ import { DecryptClickContext } from '../ui/data-inspector'; import { AttributePanel } from './attribute-panel'; import { EventsList } from './events-list'; import { ResolveHookModal } from './resolve-hook-modal'; +import { useSidebarDataOptional } from './sidebar-data-context'; // Type guards for runtime validation of span attribute data function isStep(data: unknown): data is Step { @@ -116,6 +117,9 @@ export function EntityDetailPanel({ new Set() ); + const sidebar = useSidebarDataOptional(); + const hasEncryptedData = Boolean(sidebar?.hasEncryptedData && !encryptionKey); + const data = selectedSpan?.data; const rawEvents = selectedSpan?.rawEvents; const rawEventsLength = rawEvents?.length ?? 0; @@ -345,9 +349,11 @@ export function EntityDetailPanel({ return (
-
+
{hasPendingActions && (
)} -
-
- -
- - {resource !== 'run' && rawEvents && ( -
- -
- )} -
+ + + {resource !== 'run' && rawEvents && ( + + )}
diff --git a/packages/web-shared/src/components/sidebar/events-list.tsx b/packages/web-shared/src/components/sidebar/events-list.tsx index af31f533a1..cf34ea8e58 100644 --- a/packages/web-shared/src/components/sidebar/events-list.tsx +++ b/packages/web-shared/src/components/sidebar/events-list.tsx @@ -1,16 +1,15 @@ 'use client'; import { EVENT_DATA_REF_FIELDS, type Event } from '@workflow/world'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { isExpiredMarker } from '../../lib/hydration'; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { hasEncryptedFields, isExpiredMarker } from '../../lib/hydration'; import { ErrorCard } from '../ui/error-card'; import { ErrorStackBlock, isStructuredErrorWithStack, } from '../ui/error-stack-block'; import { Skeleton } from '../ui/skeleton'; -import { localMillisecondTime } from './attribute-panel'; -import { CopyableDataBlock } from './copyable-data-block'; +import { CopyableDataBlock, EncryptedDataBlock } from './copyable-data-block'; import { DetailCard } from './detail-card'; /** @@ -94,7 +93,7 @@ function EventItem({ // When the encryption key changes and this event was previously expanded, // re-load the data so it gets decrypted - useEffect(() => { + useLayoutEffect(() => { if (!encryptionKey || !wasExpandedRef.current) return; loadedDataRef.current = null; setLoadedData(null); @@ -102,25 +101,27 @@ function EventItem({ }, [encryptionKey, loadEventData]); const createdAt = new Date(event.createdAt); + const createdAtTime = createdAt.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); const displayPayload = isLoading ? loadedData : mergedDisplay; return ( - +
+ {event.eventType} - {' '} - -{' '} - - {localMillisecondTime(createdAt.getTime())} - + + {createdAtTime} + +
} onToggle={ canHaveData @@ -131,47 +132,17 @@ function EventItem({ } > {/* Event attributes */} -
-
- - Event ID - - +
+
+ Event ID + {event.eventId}
{event.correlationId && ( -
- - Correlation ID - - +
+ Correlation ID + {event.correlationId}
@@ -180,12 +151,7 @@ function EventItem({ {/* Loading state */} {isLoading && ( -
+
@@ -203,7 +169,7 @@ function EventItem({ {/* Event data */} {displayPayload != null && ( -
+
)} @@ -258,6 +224,10 @@ function EventDataBlock({ ); } + if (hasEncryptedFields({ eventType, eventData: data })) { + return ; + } + // For error events (step_failed, step_retrying), the eventData has the shape // { error: StructuredError, stack?: string, ... }. Check both the top-level // value and the nested `error` field for a stack trace. @@ -311,26 +281,28 @@ export function EventsList({ [events] ); + const hasEvents = sortedEvents.length > 0 && !error; + + if (!hasEvents && !isLoading) { + return ; + } + return ( -
-

- Events -

+ {isLoading ? ( -
- - - +
+ {[0, 1, 2].map((i) => ( +
+ + +
+ ))}
- ) : null} - {!isLoading && !error && sortedEvents.length === 0 && ( -
No events found
- )} - {sortedEvents.length > 0 && !error ? ( -
+ ) : ( +
{sortedEvents.map((event) => ( ))}
- ) : null} -
+ )} + ); } diff --git a/packages/web-shared/src/components/ui/button.tsx b/packages/web-shared/src/components/ui/button.tsx new file mode 100644 index 0000000000..a54c084d83 --- /dev/null +++ b/packages/web-shared/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; + +const buttonVariants = cva( + [ + 'outline-none m-0 border-0 align-baseline no-underline group/trigger', + 'relative cursor-pointer select-none transform translate-z-0', + 'inline-flex max-w-full items-center justify-center gap-x-1 whitespace-nowrap rounded-md font-medium', + '!text-gray-100', + 'bg-gray-1000', + 'transition-[border-color,background,color,transform,box-shadow] duration-150 ease-in-out', + 'hover:bg-[var(--themed-hover-bg,_hsl(0,_0%,_22%))] dark-theme:hover:bg-[var(--themed-hover-bg,_hsl(0,_0%,_80%))]', + // disabled styles + 'disabled:cursor-not-allowed aria-disabled:cursor-not-allowed', + 'disabled:bg-gray-100 disabled:!text-gray-700 disabled:hover:bg-gray-100', + 'aria-disabled:bg-gray-100 aria-disabled:text-gray-700 aria-disabled:hover:bg-gray-100', + ], + { + variants: { + variant: { + default: '', + secondary: + 'border border-gray-alpha-400 [--themed-bg:_var(--ds-background-100)] [--themed-fg:_var(--ds-gray-1000)] [--themed-hover-bg:_var(--ds-gray-alpha-200)]', + ghost: + '[--themed-bg:_transparent] [--themed-fg:_var(--ds-gray-1000)] [--themed-hover-bg:_var(--ds-gray-alpha-100)]', + }, + size: { + default: 'h-10 px-4 text-[14px]', + sm: 'h-8 px-3 text-[14px]', + xs: 'h-6 px-1.5 py-0.5 text-button-12', + icon: 'h-8 w-8', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +); + +export type ButtonProps = React.ButtonHTMLAttributes & + VariantProps; + +export function Button({ + className, + variant, + size, + type = 'button', + ...props +}: ButtonProps) { + return ( + + Decrypt + ); } return ( diff --git a/packages/web-shared/src/components/ui/error-stack-block.tsx b/packages/web-shared/src/components/ui/error-stack-block.tsx index f6e618d001..d80c043255 100644 --- a/packages/web-shared/src/components/ui/error-stack-block.tsx +++ b/packages/web-shared/src/components/ui/error-stack-block.tsx @@ -1,7 +1,7 @@ 'use client'; -import { AlertCircle, Copy } from 'lucide-react'; -import { useToast } from '../../lib/toast'; +import { AlertCircle } from 'lucide-react'; +import { CopyButton } from '../new-trace-viewer/components/copy-button'; /** * Check whether `value` looks like a structured error object with a `stack` @@ -43,7 +43,6 @@ export function ErrorStackBlock({ }: { value: Record & { stack: string }; }) { - const toast = useToast(); const stack = value.stack; const message = typeof value.message === 'string' ? value.message : undefined; // V8's `Error.stack` already starts with `Name: message`, so when the @@ -60,29 +59,11 @@ export function ErrorStackBlock({ background: 'var(--ds-red-100)', }} > - + {title && (
Date: Fri, 22 May 2026 18:51:59 +0200 Subject: [PATCH 7/7] [web-shared] Fix "Queued for" duration for retried steps (#2087) --- .../queued-for-uses-first-step-started.md | 5 ++ .../src/components/event-list-view.tsx | 42 ++++++---- .../test/event-list-duration.test.ts | 77 +++++++++++++++++++ 3 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 .changeset/queued-for-uses-first-step-started.md create mode 100644 packages/web-shared/test/event-list-duration.test.ts diff --git a/.changeset/queued-for-uses-first-step-started.md b/.changeset/queued-for-uses-first-step-started.md new file mode 100644 index 0000000000..d469643bf3 --- /dev/null +++ b/.changeset/queued-for-uses-first-step-started.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Fix the "Queued for" duration shown in the events list for retried steps. It now measures from `step_created` to the first `step_started` instead of the last, so the displayed value reflects actual queue time rather than queue time plus all retry waits. diff --git a/packages/web-shared/src/components/event-list-view.tsx b/packages/web-shared/src/components/event-list-view.tsx index 5415f94778..db83be83f5 100644 --- a/packages/web-shared/src/components/event-list-view.tsx +++ b/packages/web-shared/src/components/event-list-view.tsx @@ -142,7 +142,7 @@ function buildNameMaps( return { correlationNameMap, workflowName }; } -interface DurationInfo { +export interface DurationInfo { /** Time from created → started (ms) */ queued?: number; /** Time from started → completed/failed/cancelled (ms) */ @@ -154,19 +154,30 @@ interface DurationInfo { * created ↔ started (queued) and started ↔ completed/failed/cancelled (ran). * Also computes run-level durations under the key '__run__'. */ -function buildDurationMap(events: Event[]): Map { +export function buildDurationMap(events: Event[]): Map { + // Process events in chronological order so the result doesn't depend on + // the caller's sort direction. Retried steps emit multiple `step_started` + // events for the same correlationId; the queued duration must be measured + // against the first one, not the last. + const chronological = [...events].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + const createdTimes = new Map(); + const firstStartedTimes = new Map(); const startedTimes = new Map(); const durations = new Map(); - for (const event of events) { + for (const event of chronological) { const ts = new Date(event.createdAt).getTime(); const key = event.correlationId ?? '__run__'; const type: string = event.eventType; // Track created times (first event for each correlation) if (type === 'step_created' || type === 'run_created') { - createdTimes.set(key, ts); + if (!createdTimes.has(key)) { + createdTimes.set(key, ts); + } } // Track started times & compute queued duration @@ -176,16 +187,21 @@ function buildDurationMap(events: Event[]): Map { type === 'workflow_started' ) { startedTimes.set(key, ts); - // If no explicit created event was seen, use the started time as created - if (!createdTimes.has(key)) { - createdTimes.set(key, ts); - } - const createdAt = createdTimes.get(key); - const info = durations.get(key) ?? {}; - if (createdAt !== undefined) { - info.queued = ts - createdAt; + // The queued duration is anchored on the first start event only — + // subsequent step_started events come from retries. + if (!firstStartedTimes.has(key)) { + firstStartedTimes.set(key, ts); + // If no explicit created event was seen, use the started time as created + if (!createdTimes.has(key)) { + createdTimes.set(key, ts); + } + const createdAt = createdTimes.get(key); + const info = durations.get(key) ?? {}; + if (createdAt !== undefined) { + info.queued = ts - createdAt; + } + durations.set(key, info); } - durations.set(key, info); } // Compute ran duration on terminal events diff --git a/packages/web-shared/test/event-list-duration.test.ts b/packages/web-shared/test/event-list-duration.test.ts new file mode 100644 index 0000000000..d791f3f4ee --- /dev/null +++ b/packages/web-shared/test/event-list-duration.test.ts @@ -0,0 +1,77 @@ +import type { Event } from '@workflow/world'; +import { describe, expect, it } from 'vitest'; +import { buildDurationMap } from '../src/components/event-list-view.js'; + +/** + * Regression tests for the "Queued for" duration shown in the Events tab. + * + * A retried step emits multiple `step_started` events for the same + * correlationId. The queued duration must be anchored on the FIRST + * `step_started` (time from `step_created` to first attempt), not the last, + * so the displayed value reflects how long the step waited before any work + * began. + */ + +function ev( + eventType: string, + correlationId: string | null, + createdAt: string +): Event { + // Only the fields buildDurationMap reads are required; the rest of Event + // is opaque to it. + return { + eventType, + correlationId, + createdAt, + } as unknown as Event; +} + +describe('buildDurationMap → queued duration', () => { + it('uses the first step_started, not the last, for steps with retries', () => { + const events: Event[] = [ + ev('step_created', 'step-1', '2026-01-01T00:00:00.000Z'), + ev('step_started', 'step-1', '2026-01-01T00:00:01.000Z'), + ev('step_failed', 'step-1', '2026-01-01T00:00:02.000Z'), + ev('step_retrying', 'step-1', '2026-01-01T00:00:03.000Z'), + ev('step_started', 'step-1', '2026-01-01T00:00:10.000Z'), + ev('step_completed', 'step-1', '2026-01-01T00:00:11.000Z'), + ]; + + const map = buildDurationMap(events); + // 1s between step_created and the first step_started. + expect(map.get('step-1')?.queued).toBe(1000); + }); + + it('handles events in descending order (newest first)', () => { + const ascending: Event[] = [ + ev('step_created', 'step-1', '2026-01-01T00:00:00.000Z'), + ev('step_started', 'step-1', '2026-01-01T00:00:01.000Z'), + ev('step_failed', 'step-1', '2026-01-01T00:00:02.000Z'), + ev('step_started', 'step-1', '2026-01-01T00:00:10.000Z'), + ev('step_completed', 'step-1', '2026-01-01T00:00:11.000Z'), + ]; + const descending = [...ascending].reverse(); + + expect(buildDurationMap(ascending).get('step-1')?.queued).toBe(1000); + expect(buildDurationMap(descending).get('step-1')?.queued).toBe(1000); + }); + + it('still works for a step with a single start (no retry)', () => { + const events: Event[] = [ + ev('step_created', 'step-2', '2026-01-01T00:00:00.000Z'), + ev('step_started', 'step-2', '2026-01-01T00:00:00.500Z'), + ev('step_completed', 'step-2', '2026-01-01T00:00:02.000Z'), + ]; + + expect(buildDurationMap(events).get('step-2')?.queued).toBe(500); + }); + + it('falls back to the started time when no created event is seen', () => { + const events: Event[] = [ + ev('step_started', 'step-3', '2026-01-01T00:00:05.000Z'), + ev('step_completed', 'step-3', '2026-01-01T00:00:06.000Z'), + ]; + + expect(buildDurationMap(events).get('step-3')?.queued).toBe(0); + }); +});