From 5188f5b0033695644dd4ff24cb0d0d84e45aba6a Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 21 Aug 2026 12:50:56 -0700 Subject: [PATCH 1/3] docs: make v5 the default documentation version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serves `content/docs/v5` and `content/worlds/v5` unprefixed at /docs, /worlds and /cookbook, moves v4 under /v4, and puts What's new first in the v5 sidebar. The versioned source config drives both route trees (the root tree always renders `versionedSources.current`), so this is a `routePrefix` move plus source re-binding rather than a restructure. The only file moves are `app/[lang]/v5/**` → `app/[lang]/v4/**`. - Version switcher: `v5 (Latest)` / `v4 (Maintenance)`, `current: 'v5'`. - `pre-release-banner.tsx` becomes `maintenance-banner.tsx`. v4 pages carry an amber notice whose "Go to Workflow 5 (Latest)" link deep-links to the same page on the current version, falling back to the nearest section index for v4-only pages, and keep `robots: noindex, follow`. - Redirects: `/v5/*` to the unprefixed equivalent (bare `/v5` needs its own rule, since `:path*` expands to an empty Location). The world-docs and api-reference restructure rules are mirrored onto `/v4/docs/*`, and every page existing in only one tree gets a version-switcher fallback. - Both worlds route trees pass an explicit version into the shared page components, whose semantics flipped with the switch, so the smoke checks now assert the pairing: a " · v4" title marker and noindex on the maintenance routes, neither on the canonical ones, and a community world serving directly rather than self-redirecting. - The link lint's two URL spaces swap with the prefixes. Redirect destinations resolve against the real HTTP space, since redirects are matched before render-time href rewriting. - The two `/v4/...` links this makes expressible are restored: a v5 page cannot link to a v4 page while v4 is the unprefixed version. Co-Authored-By: Claude Opus 5 --- .changeset/docs-v5-default-version.md | 2 + docs/app/[lang]/cookbook/[[...slug]]/page.tsx | 2 +- docs/app/[lang]/docs/[[...slug]]/page.tsx | 2 +- .../{v5 => v4}/cookbook/[[...slug]]/page.tsx | 34 ++-- .../app/[lang]/{v5 => v4}/cookbook/layout.tsx | 12 +- .../{v5 => v4}/docs/[[...slug]]/layout.tsx | 17 +- .../{v5 => v4}/docs/[[...slug]]/page.tsx | 42 +++-- .../[lang]/{v5 => v4}/worlds/[id]/page.tsx | 4 +- .../worlds/building-a-world/page.tsx | 4 +- docs/app/[lang]/{v5 => v4}/worlds/layout.tsx | 2 +- docs/app/[lang]/worlds/[id]/page.tsx | 4 +- .../[lang]/worlds/building-a-world/page.tsx | 4 +- .../{v5 => }/worlds/upgrading-to-v5/page.tsx | 0 docs/components/geistdocs/docs-layout.tsx | 2 +- .../geistdocs/maintenance-banner.tsx | 59 ++++++ .../geistdocs/pre-release-banner.tsx | 49 ----- docs/components/worlds/WorldVersionSelect.tsx | 6 +- docs/components/worlds/world-detail-page.tsx | 20 +- docs/components/worlds/worlds-guide-page.tsx | 23 +-- .../docs/v5/how-it-works/code-transform.mdx | 2 +- docs/content/docs/v5/meta.json | 1 + docs/content/docs/v5/whats-new.mdx | 2 +- docs/lib/geistdocs/config.tsx | 38 ++-- docs/lib/geistdocs/cookbook-source.ts | 24 +-- docs/lib/geistdocs/source.ts | 94 +++++----- docs/lib/geistdocs/version-href.ts | 2 +- docs/lib/geistdocs/version-source.ts | 22 +-- docs/lib/geistdocs/version-switch-paths.ts | 18 +- docs/lib/geistdocs/versions.ts | 59 ++++-- docs/next.config.ts | 172 +++++++++++------- docs/proxy.ts | 10 +- docs/scripts/check-docs-smoke.mjs | 75 +++++++- docs/scripts/lint.ts | 114 ++++++------ docs/source.config.ts | 2 +- 34 files changed, 535 insertions(+), 388 deletions(-) create mode 100644 .changeset/docs-v5-default-version.md rename docs/app/[lang]/{v5 => v4}/cookbook/[[...slug]]/page.tsx (63%) rename docs/app/[lang]/{v5 => v4}/cookbook/layout.tsx (52%) rename docs/app/[lang]/{v5 => v4}/docs/[[...slug]]/layout.tsx (50%) rename docs/app/[lang]/{v5 => v4}/docs/[[...slug]]/page.tsx (71%) rename docs/app/[lang]/{v5 => v4}/worlds/[id]/page.tsx (87%) rename docs/app/[lang]/{v5 => v4}/worlds/building-a-world/page.tsx (65%) rename docs/app/[lang]/{v5 => v4}/worlds/layout.tsx (89%) rename docs/app/[lang]/{v5 => }/worlds/upgrading-to-v5/page.tsx (100%) create mode 100644 docs/components/geistdocs/maintenance-banner.tsx delete mode 100644 docs/components/geistdocs/pre-release-banner.tsx diff --git a/.changeset/docs-v5-default-version.md b/.changeset/docs-v5-default-version.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/docs-v5-default-version.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/app/[lang]/cookbook/[[...slug]]/page.tsx b/docs/app/[lang]/cookbook/[[...slug]]/page.tsx index 1c2b03d7ac..f4362e5574 100644 --- a/docs/app/[lang]/cookbook/[[...slug]]/page.tsx +++ b/docs/app/[lang]/cookbook/[[...slug]]/page.tsx @@ -11,7 +11,7 @@ const docsPage = createDocsPage({ ...config, github: config.github && { ...config.github, - editPath: 'docs/content/docs/v4/{path}', + editPath: 'docs/content/docs/v5/{path}', }, }, source: cookbookSource, diff --git a/docs/app/[lang]/docs/[[...slug]]/page.tsx b/docs/app/[lang]/docs/[[...slug]]/page.tsx index 53798c3c5b..c3338f549e 100644 --- a/docs/app/[lang]/docs/[[...slug]]/page.tsx +++ b/docs/app/[lang]/docs/[[...slug]]/page.tsx @@ -17,7 +17,7 @@ const docsPage = createDocsPage({ ...config, github: config.github && { ...config.github, - editPath: 'docs/content/docs/v4/{path}', + editPath: 'docs/content/docs/v5/{path}', }, }, source: geistdocsSource, diff --git a/docs/app/[lang]/v5/cookbook/[[...slug]]/page.tsx b/docs/app/[lang]/v4/cookbook/[[...slug]]/page.tsx similarity index 63% rename from docs/app/[lang]/v5/cookbook/[[...slug]]/page.tsx rename to docs/app/[lang]/v4/cookbook/[[...slug]]/page.tsx index 4b4192d5d9..952182df53 100644 --- a/docs/app/[lang]/v5/cookbook/[[...slug]]/page.tsx +++ b/docs/app/[lang]/v4/cookbook/[[...slug]]/page.tsx @@ -4,21 +4,21 @@ import { Card, type CardProps } from 'fumadocs-ui/components/card'; import type { ComponentProps, ComponentType } from 'react'; import { getMDXComponents } from '@/components/geistdocs/mdx-components'; import { config } from '@/lib/geistdocs/config'; -import { v5CookbookSource } from '@/lib/geistdocs/source'; +import { v4CookbookSource } from '@/lib/geistdocs/source'; import { rewriteHrefForVersion } from '@/lib/geistdocs/version-href'; -const VERSION_PREFIX = '/v5'; +const VERSION_PREFIX = '/v4'; // Content links are authored against the raw `/docs/...` and `/worlds/...` -// URL spaces; rewrite them into the v5 view so navigation doesn't escape to -// the v4 route. Card renders its own Link (not the `a` component), so it -// needs the same rewrite applied separately. -function v5Href(href: T): T { +// URL spaces; rewrite them into the v4 view so navigation doesn't escape to +// the current-version route. Card renders its own Link (not the `a` +// component), so it needs the same rewrite applied separately. +function v4Href(href: T): T { return rewriteHrefForVersion(href, VERSION_PREFIX); } -function V5CookbookCard(props: CardProps) { - return ; +function V4CookbookCard(props: CardProps) { + return ; } const docsPage = createDocsPage({ @@ -26,18 +26,18 @@ const docsPage = createDocsPage({ ...config, github: config.github && { ...config.github, - editPath: 'docs/content/docs/v5/{path}', + editPath: 'docs/content/docs/v4/{path}', }, }, - source: v5CookbookSource, - mdx: ({ link }) => getMDXComponents({ a: link, Card: V5CookbookCard }), + source: v4CookbookSource, + mdx: ({ link }) => getMDXComponents({ a: link, Card: V4CookbookCard }), resolveLink: ({ link }) => { const Link = link as ComponentType>; - const V5CookbookLink = (props: ComponentProps<'a'>) => ( - + const V4CookbookLink = (props: ComponentProps<'a'>) => ( + ); - return V5CookbookLink; + return V4CookbookLink; }, openGraph: { images: true, @@ -47,14 +47,14 @@ const docsPage = createDocsPage({ }, renderTop: ({ data }) => , metadata: ({ metadata, page }) => { - const stableUrl = page.url.replace(/^\/v5(?=\/cookbook(?:\/|$))/, ''); + const currentUrl = page.url.replace(/^\/v4(?=\/cookbook(?:\/|$))/, ''); return { ...metadata, - title: `${page.data.title} · Pre-release`, + title: `${page.data.title} · v4`, alternates: { ...metadata.alternates, - canonical: stableUrl, + canonical: currentUrl, types: { ...metadata.alternates?.types, 'text/markdown': `${page.url}.md`, diff --git a/docs/app/[lang]/v5/cookbook/layout.tsx b/docs/app/[lang]/v4/cookbook/layout.tsx similarity index 52% rename from docs/app/[lang]/v5/cookbook/layout.tsx rename to docs/app/[lang]/v4/cookbook/layout.tsx index dee8f752c1..478f29a7aa 100644 --- a/docs/app/[lang]/v5/cookbook/layout.tsx +++ b/docs/app/[lang]/v4/cookbook/layout.tsx @@ -1,20 +1,20 @@ import { DocsLayout } from '@/components/geistdocs/docs-layout'; -import { PreReleaseBanner } from '@/components/geistdocs/pre-release-banner'; +import { MaintenanceBanner } from '@/components/geistdocs/maintenance-banner'; import { getCookbookTree } from '@/lib/geistdocs/cookbook-source'; -import { PRE_RELEASE_VERSION } from '@/lib/geistdocs/versions'; +import { MAINTENANCE_VERSION } from '@/lib/geistdocs/versions'; const Layout = async ({ children, params, -}: LayoutProps<'/[lang]/v5/cookbook'>) => { +}: LayoutProps<'/[lang]/v4/cookbook'>) => { const { lang } = await params; return (
- + {children} diff --git a/docs/app/[lang]/v5/docs/[[...slug]]/layout.tsx b/docs/app/[lang]/v4/docs/[[...slug]]/layout.tsx similarity index 50% rename from docs/app/[lang]/v5/docs/[[...slug]]/layout.tsx rename to docs/app/[lang]/v4/docs/[[...slug]]/layout.tsx index 7beefa9f2a..b520d1147b 100644 --- a/docs/app/[lang]/v5/docs/[[...slug]]/layout.tsx +++ b/docs/app/[lang]/v4/docs/[[...slug]]/layout.tsx @@ -1,7 +1,7 @@ import { DocsLayout } from '@/components/geistdocs/docs-layout'; -import { PreReleaseBanner } from '@/components/geistdocs/pre-release-banner'; +import { MaintenanceBanner } from '@/components/geistdocs/maintenance-banner'; import { getDocsTreeForVersion } from '@/lib/geistdocs/version-source'; -import { PRE_RELEASE_VERSION } from '@/lib/geistdocs/versions'; +import { MAINTENANCE_VERSION } from '@/lib/geistdocs/versions'; // This layout lives inside `[[...slug]]` rather than next to it so that // `params.slug` is available: the sidebar needs the active page to decide @@ -9,16 +9,21 @@ import { PRE_RELEASE_VERSION } from '@/lib/geistdocs/versions'; const Layout = async ({ children, params, -}: LayoutProps<'/[lang]/v5/docs/[[...slug]]'>) => { +}: LayoutProps<'/[lang]/v4/docs/[[...slug]]'>) => { const { lang, slug } = await params; return (
- + {/* Deep-link the banner to the same page on the latest version; pages + that only exist in v4 are caught by the version-switcher fallback + redirects in next.config.ts. */} + {children} diff --git a/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx b/docs/app/[lang]/v4/docs/[[...slug]]/page.tsx similarity index 71% rename from docs/app/[lang]/v5/docs/[[...slug]]/page.tsx rename to docs/app/[lang]/v4/docs/[[...slug]]/page.tsx index 27a0af1087..e1a1eeeafe 100644 --- a/docs/app/[lang]/v5/docs/[[...slug]]/page.tsx +++ b/docs/app/[lang]/v4/docs/[[...slug]]/page.tsx @@ -8,27 +8,27 @@ import { getMDXComponents } from '@/components/geistdocs/mdx-components'; import { config } from '@/lib/geistdocs/config'; import { rewriteCookbookUrl } from '@/lib/geistdocs/cookbook-source'; import { resolveSectionChildren } from '@/lib/geistdocs/section-children'; -import { source, v5GeistdocsSource } from '@/lib/geistdocs/source'; +import { source, v4GeistdocsSource } from '@/lib/geistdocs/source'; import { rewriteHrefForVersion } from '@/lib/geistdocs/version-href'; import { getDocsTreeForVersion } from '@/lib/geistdocs/version-source'; -import { PRE_RELEASE_VERSION } from '@/lib/geistdocs/versions'; +import { MAINTENANCE_VERSION } from '@/lib/geistdocs/versions'; -const VERSION_PREFIX = '/v5'; +const VERSION_PREFIX = '/v4'; const DEFAULT_LANG = config.defaultLanguage ?? 'en'; const getPageUrl = ({ page }: { page: { url: string } }) => `${VERSION_PREFIX}${page.url}`; // Content links are authored against the raw `/docs/...` and `/worlds/...` -// URL spaces; rewrite them into the v5 view so navigation doesn't escape to -// the v4 route. Card renders its own Link (not the `a` component), so it -// needs the same rewrite applied separately. -function v5Href(href: T): T { +// URL spaces; rewrite them into the v4 view so navigation doesn't escape to +// the current-version route. Card renders its own Link (not the `a` +// component), so it needs the same rewrite applied separately. +function v4Href(href: T): T { return rewriteHrefForVersion(href, VERSION_PREFIX); } -function V5Card(props: CardProps) { - return ; +function V4Card(props: CardProps) { + return ; } const docsPage = createDocsPage({ @@ -36,21 +36,21 @@ const docsPage = createDocsPage({ ...config, github: config.github && { ...config.github, - editPath: 'docs/content/docs/v5/{path}', + editPath: 'docs/content/docs/v4/{path}', }, }, - source: v5GeistdocsSource, + source: v4GeistdocsSource, getPageUrl, mdx: ({ link, page }) => getMDXComponents({ a: link, - Card: V5Card, - // Cards render in the v5 URL space (`/v5/docs/...`), matching the - // sidebar tree so hrefs don't escape to the v4 route. + Card: V4Card, + // Cards render in the v4 URL space (`/v4/docs/...`), matching the + // sidebar tree so hrefs don't escape to the current-version route. AutoCards: () => ( @@ -58,11 +58,11 @@ const docsPage = createDocsPage({ }), resolveLink: ({ link }) => { const Link = link as ComponentType>; - const V5Link = (props: ComponentProps<'a'>) => ( - + const V4Link = (props: ComponentProps<'a'>) => ( + ); - return V5Link; + return V4Link; }, openGraph: { images: true, @@ -76,9 +76,11 @@ const docsPage = createDocsPage({ return { ...metadata, - title: `${page.data.title} · Pre-release`, + title: `${page.data.title} · v4`, alternates: { ...metadata.alternates, + // Prefer the current-version page when the same path exists there so + // search engines consolidate on the latest docs. canonical: source.getPage(params.slug, params.lang) ? page.url : pageUrl, @@ -95,7 +97,7 @@ const docsPage = createDocsPage({ }, }); -const Page = async (props: PageProps<'/[lang]/v5/docs/[[...slug]]'>) => { +const Page = async (props: PageProps<'/[lang]/v4/docs/[[...slug]]'>) => { const { slug, lang } = await props.params; // Cookbook recipes moved out of `/docs/cookbook/...` into their own diff --git a/docs/app/[lang]/v5/worlds/[id]/page.tsx b/docs/app/[lang]/v4/worlds/[id]/page.tsx similarity index 87% rename from docs/app/[lang]/v5/worlds/[id]/page.tsx rename to docs/app/[lang]/v4/worlds/[id]/page.tsx index 8ffc6e84a1..360df8883f 100644 --- a/docs/app/[lang]/v5/worlds/[id]/page.tsx +++ b/docs/app/[lang]/v4/worlds/[id]/page.tsx @@ -19,10 +19,10 @@ export async function generateMetadata({ params, }: PageProps): Promise { const { id } = await params; - return generateWorldMetadata(id, 'v5'); + return generateWorldMetadata(id, 'v4'); } export default async function Page({ params }: PageProps) { const { id } = await params; - return ; + return ; } diff --git a/docs/app/[lang]/v5/worlds/building-a-world/page.tsx b/docs/app/[lang]/v4/worlds/building-a-world/page.tsx similarity index 65% rename from docs/app/[lang]/v5/worlds/building-a-world/page.tsx rename to docs/app/[lang]/v4/worlds/building-a-world/page.tsx index 7175b26fff..c078387c20 100644 --- a/docs/app/[lang]/v5/worlds/building-a-world/page.tsx +++ b/docs/app/[lang]/v4/worlds/building-a-world/page.tsx @@ -5,9 +5,9 @@ import { } from '@/components/worlds/worlds-guide-page'; export function generateMetadata(): Promise { - return generateWorldsGuideMetadata('building-a-world', 'v5'); + return generateWorldsGuideMetadata('building-a-world', 'v4'); } export default function Page() { - return ; + return ; } diff --git a/docs/app/[lang]/v5/worlds/layout.tsx b/docs/app/[lang]/v4/worlds/layout.tsx similarity index 89% rename from docs/app/[lang]/v5/worlds/layout.tsx rename to docs/app/[lang]/v4/worlds/layout.tsx index 04784763e0..b77c0019fe 100644 --- a/docs/app/[lang]/v5/worlds/layout.tsx +++ b/docs/app/[lang]/v4/worlds/layout.tsx @@ -4,7 +4,7 @@ import { source } from '@/lib/geistdocs/source'; const Layout = async ({ children, params, -}: LayoutProps<'/[lang]/v5/worlds'>) => { +}: LayoutProps<'/[lang]/v4/worlds'>) => { const { lang } = await params; return ( diff --git a/docs/app/[lang]/worlds/[id]/page.tsx b/docs/app/[lang]/worlds/[id]/page.tsx index 871f123016..0ff8d64a55 100644 --- a/docs/app/[lang]/worlds/[id]/page.tsx +++ b/docs/app/[lang]/worlds/[id]/page.tsx @@ -18,10 +18,10 @@ export async function generateMetadata({ params, }: PageProps): Promise { const { id } = await params; - return generateWorldMetadata(id, 'v4'); + return generateWorldMetadata(id, 'v5'); } export default async function Page({ params }: PageProps) { const { id } = await params; - return ; + return ; } diff --git a/docs/app/[lang]/worlds/building-a-world/page.tsx b/docs/app/[lang]/worlds/building-a-world/page.tsx index c078387c20..7175b26fff 100644 --- a/docs/app/[lang]/worlds/building-a-world/page.tsx +++ b/docs/app/[lang]/worlds/building-a-world/page.tsx @@ -5,9 +5,9 @@ import { } from '@/components/worlds/worlds-guide-page'; export function generateMetadata(): Promise { - return generateWorldsGuideMetadata('building-a-world', 'v4'); + return generateWorldsGuideMetadata('building-a-world', 'v5'); } export default function Page() { - return ; + return ; } diff --git a/docs/app/[lang]/v5/worlds/upgrading-to-v5/page.tsx b/docs/app/[lang]/worlds/upgrading-to-v5/page.tsx similarity index 100% rename from docs/app/[lang]/v5/worlds/upgrading-to-v5/page.tsx rename to docs/app/[lang]/worlds/upgrading-to-v5/page.tsx diff --git a/docs/components/geistdocs/docs-layout.tsx b/docs/components/geistdocs/docs-layout.tsx index bcd1c85530..154042c306 100644 --- a/docs/components/geistdocs/docs-layout.tsx +++ b/docs/components/geistdocs/docs-layout.tsx @@ -9,7 +9,7 @@ type DocsTreeNode = DocsTree['children'][number]; const SIDEBAR_ITEM_BADGES: Array<{ suffix: string; label: string }> = [ { suffix: '/docs/getting-started/python', label: 'Beta' }, - { suffix: '/v5/docs/getting-started/python', label: 'Beta' }, + { suffix: '/v4/docs/getting-started/python', label: 'Beta' }, ]; const getSidebarBadge = (url?: string) => diff --git a/docs/components/geistdocs/maintenance-banner.tsx b/docs/components/geistdocs/maintenance-banner.tsx new file mode 100644 index 0000000000..f0d69ea0d7 --- /dev/null +++ b/docs/components/geistdocs/maintenance-banner.tsx @@ -0,0 +1,59 @@ +import Link from 'next/link'; +import { + buildVersionUrl, + LATEST_VERSION, + MAINTENANCE_VERSION, +} from '@/lib/geistdocs/versions'; + +interface MaintenanceBannerProps { + pathname: string; +} + +const ClockRewind = ({ className }: { className?: string }) => ( + +); + +/** + * Shown on the maintenance-version docs routes (`/v4/...`) so readers who + * landed there from an old link know they aren't on the current docs, with a + * one-click path to the same page on the latest version. + */ +export const MaintenanceBanner = ({ pathname }: MaintenanceBannerProps) => { + const latestHref = buildVersionUrl(pathname, LATEST_VERSION); + return ( +
+
+
+ + + Viewing Workflow {MAINTENANCE_VERSION.id.replace(/^v/, '')}.x + documentation. This version only receives stability fixes. + +
+ + Go to Workflow {LATEST_VERSION.id.replace(/^v/, '')} (Latest) + +
+
+ ); +}; diff --git a/docs/components/geistdocs/pre-release-banner.tsx b/docs/components/geistdocs/pre-release-banner.tsx deleted file mode 100644 index a7c7579a6b..0000000000 --- a/docs/components/geistdocs/pre-release-banner.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import Link from 'next/link'; -import { - buildVersionUrl, - LATEST_VERSION, - PRE_RELEASE_VERSION, -} from '@/lib/geistdocs/versions'; - -interface PreReleaseBannerProps { - pathname: string; -} - -const SparklesFilled = ({ className }: { className?: string }) => ( - -); - -export const PreReleaseBanner = ({ pathname }: PreReleaseBannerProps) => { - const latestHref = buildVersionUrl(pathname, LATEST_VERSION); - return ( -
-
-
- - - Viewing Workflow {PRE_RELEASE_VERSION.id.replace(/^v/, '')}{' '} - (Pre-release) Documentation. - -
- - Go to Workflow {LATEST_VERSION.id.replace(/^v/, '')} (Latest) - -
-
- ); -}; diff --git a/docs/components/worlds/WorldVersionSelect.tsx b/docs/components/worlds/WorldVersionSelect.tsx index f1dcdfa34b..6aebea4c0c 100644 --- a/docs/components/worlds/WorldVersionSelect.tsx +++ b/docs/components/worlds/WorldVersionSelect.tsx @@ -9,9 +9,9 @@ interface WorldVersionSelectProps { /** * Version switcher for the world detail pages. World docs are versioned like - * the docs trees (/worlds/* for the current version, /v5/worlds/* for the - * pre-release), but the worlds listing page has no natural home for the docs - * sidebar switcher — so each world page renders its own. + * the docs trees (/worlds/* for the current version, /v4/worlds/* for the + * maintenance version), but the worlds listing page has no natural home for + * the docs sidebar switcher — so each world page renders its own. */ export function WorldVersionSelect({ current, diff --git a/docs/components/worlds/world-detail-page.tsx b/docs/components/worlds/world-detail-page.tsx index b535965e2e..910b3a66ea 100644 --- a/docs/components/worlds/world-detail-page.tsx +++ b/docs/components/worlds/world-detail-page.tsx @@ -7,7 +7,7 @@ import { notFound, redirect } from 'next/navigation'; import type { ComponentProps, ComponentType, ReactNode } from 'react'; import { FluidComputeCallout } from '@/components/custom/fluid-compute-callout'; import { getMDXComponents } from '@/components/geistdocs/mdx-components'; -import { v5WorldsSource, worldsSource } from '@/lib/geistdocs/source'; +import { v4WorldsSource, worldsSource } from '@/lib/geistdocs/source'; import { rewriteHrefForVersion } from '@/lib/geistdocs/version-href'; import type { DocsVersionId } from '@/lib/geistdocs/versions'; import { getWorldData } from '@/lib/worlds-data'; @@ -34,13 +34,13 @@ const officialWorldMdxSlugs: Record = { }; const VERSION_SOURCES = { - v4: worldsSource, - v5: v5WorldsSource, + v5: worldsSource, + v4: v4WorldsSource, } as const; const VERSION_PREFIXES = { - v4: '', - v5: '/v5', + v5: '', + v4: '/v4', } as const; export const officialWorldIds = Object.keys(officialWorldMdxSlugs); @@ -58,10 +58,10 @@ export async function generateWorldMetadata( } const versionPrefix = VERSION_PREFIXES[version]; - const isPreRelease = version === 'v5'; + const isMaintenance = version === 'v4'; return { - title: `${data.world.name} World${isPreRelease ? ' · Pre-release' : ''} | Workflow SDK`, + title: `${data.world.name} World${isMaintenance ? ' · v4' : ''} | Workflow SDK`, description: data.world.description, openGraph: { images: [`/og/worlds/${id}`], @@ -72,7 +72,7 @@ export async function generateWorldMetadata( 'text/markdown': `${versionPrefix}/worlds/${id}.md`, }, }, - ...(isPreRelease + ...(isMaintenance ? { robots: { index: false, @@ -103,7 +103,7 @@ export async function WorldDetailPage({ // Community worlds have no versioned content — their canonical page lives // at /worlds/ only. - if (version !== 'v4' && !isOfficial) { + if (version !== 'v5' && !isOfficial) { redirect(`/worlds/${id}`); } @@ -133,7 +133,7 @@ export async function WorldDetailPage({ })); // Content links are authored against the raw /docs/... and /worlds/... - // URL spaces; on the pre-release route they are rewritten into the /v5 + // URL spaces; on the maintenance route they are rewritten into the /v4 // view so navigation doesn't escape to the current-version pages. const RelativeLink = createRelativeLink(source, page); const VersionedLink = (props: ComponentProps<'a'>) => ( diff --git a/docs/components/worlds/worlds-guide-page.tsx b/docs/components/worlds/worlds-guide-page.tsx index 79398496df..2d9b4a3bd2 100644 --- a/docs/components/worlds/worlds-guide-page.tsx +++ b/docs/components/worlds/worlds-guide-page.tsx @@ -4,27 +4,22 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import type { ComponentProps, ComponentType, ReactNode } from 'react'; import { getMDXComponents } from '@/components/geistdocs/mdx-components'; -import { v5WorldsSource, worldsSource } from '@/lib/geistdocs/source'; +import { v4WorldsSource, worldsSource } from '@/lib/geistdocs/source'; import { rewriteHrefForVersion } from '@/lib/geistdocs/version-href'; import type { DocsVersionId } from '@/lib/geistdocs/versions'; import { WorldDetailToc } from './WorldDetailToc'; import { WorldVersionSelect } from './WorldVersionSelect'; const VERSION_SOURCES = { - v4: worldsSource, - v5: v5WorldsSource, + v5: worldsSource, + v4: v4WorldsSource, } as const; const VERSION_PREFIXES = { - v4: '', - v5: '/v5', + v5: '', + v4: '/v4', } as const; -/** - * Standalone guide pages in the worlds tree — the ones that are not a world - * detail page. They render outside the docs sidebar, so each is a bespoke - * route passing its own slug. - */ export async function generateWorldsGuideMetadata( slug: string, version: DocsVersionId @@ -36,10 +31,10 @@ export async function generateWorldsGuideMetadata( } const versionPrefix = VERSION_PREFIXES[version]; - const isPreRelease = version === 'v5'; + const isMaintenance = version === 'v4'; return { - title: `${page.data.title}${isPreRelease ? ' · Pre-release' : ''} | Workflow SDK`, + title: `${page.data.title}${isMaintenance ? ' · v4' : ''} | Workflow SDK`, description: page.data.description, openGraph: { images: ['/og/worlds'], @@ -50,7 +45,7 @@ export async function generateWorldsGuideMetadata( 'text/markdown': `${versionPrefix}/worlds/${slug}.md`, }, }, - ...(isPreRelease + ...(isMaintenance ? { robots: { index: false, @@ -90,7 +85,7 @@ export async function WorldsGuidePage({ })); // Content links are authored against the raw /docs/... and /worlds/... URL - // spaces; on the pre-release route they are rewritten into the /v5 view. + // spaces; on the maintenance route they are rewritten into the /v4 view. const RelativeLink = createRelativeLink(source, page); const VersionedLink = (props: ComponentProps<'a'>) => ( - **Changed in 5.0:** in 4.x the step bundle was served as its own HTTP route at `POST /.well-known/workflow/v1/step`, with step messages delivered on a separate `__wkf_step_*` queue topic. v5 merged both into the combined flow handler — the step bundle became a registration module imported by `flow.js`, and step messages arrive on the shared workflow queue. See the v4 version of this page, reachable from the version picker, for the old layout. + **Changed in 5.0:** in 4.x the step bundle was served as its own HTTP route at `POST /.well-known/workflow/v1/step`, with step messages delivered on a separate `__wkf_step_*` queue topic. v5 merged both into the combined flow handler — the step bundle became a registration module imported by `flow.js`, and step messages arrive on the shared workflow queue. See the [v4 version of this page](/v4/docs/how-it-works/code-transform) for the old layout. ### `webhook.js` diff --git a/docs/content/docs/v5/meta.json b/docs/content/docs/v5/meta.json index ba87e4448d..030a3db919 100644 --- a/docs/content/docs/v5/meta.json +++ b/docs/content/docs/v5/meta.json @@ -1,6 +1,7 @@ { "pages": [ "---", + "whats-new", "getting-started", "foundations", "how-it-works", diff --git a/docs/content/docs/v5/whats-new.mdx b/docs/content/docs/v5/whats-new.mdx index 617dfb203b..62a4f5cbf2 100644 --- a/docs/content/docs/v5/whats-new.mdx +++ b/docs/content/docs/v5/whats-new.mdx @@ -20,7 +20,7 @@ npx skills add https://github.com/vercel/workflow --skill migrating-workflow-v4- Workflow SDK v4 remains installable as `workflow@4` and receives stability - fixes. Switch to its documentation with the version picker in the sidebar. + fixes. Its documentation lives at [/v4/docs](/v4/docs). ## Highlights diff --git a/docs/lib/geistdocs/config.tsx b/docs/lib/geistdocs/config.tsx index f43b572dba..5fd18c4812 100644 --- a/docs/lib/geistdocs/config.tsx +++ b/docs/lib/geistdocs/config.tsx @@ -35,52 +35,52 @@ export const config = defineConfig({ siteId, translations, content: [ - { id: 'docs', label: 'Docs', dir: 'content/docs/v4', route: '/docs' }, + { id: 'docs', label: 'Docs', dir: 'content/docs/v5', route: '/docs' }, { id: 'cookbook', label: 'Cookbook', - dir: 'content/docs/v4/cookbook', + dir: 'content/docs/v5/cookbook', route: '/cookbook', }, { - id: 'v5-docs', - label: 'v5 Docs', - dir: 'content/docs/v5', - route: '/v5/docs', + id: 'v4-docs', + label: 'v4 Docs', + dir: 'content/docs/v4', + route: '/v4/docs', }, { - id: 'v5-cookbook', - label: 'v5 Cookbook', - dir: 'content/docs/v5/cookbook', - route: '/v5/cookbook', + id: 'v4-cookbook', + label: 'v4 Cookbook', + dir: 'content/docs/v4/cookbook', + route: '/v4/cookbook', }, { id: 'worlds', label: 'Worlds', - dir: 'content/worlds/v4', + dir: 'content/worlds/v5', route: '/worlds', }, { - id: 'v5-worlds', - label: 'v5 Worlds', - dir: 'content/worlds/v5', - route: '/v5/worlds', + id: 'v4-worlds', + label: 'v4 Worlds', + dir: 'content/worlds/v4', + route: '/v4/worlds', }, ], versions: { - current: 'v4', + current: 'v5', items: [ { id: 'v5', - label: 'v5 (Pre-release)', + label: 'v5 (Latest)', description: 'Workflow 5.x', - routePrefix: '/v5', icon: , }, { id: 'v4', - label: 'v4 (Latest)', + label: 'v4 (Maintenance)', description: 'Workflow 4.x', + routePrefix: '/v4', icon: , }, ], diff --git a/docs/lib/geistdocs/cookbook-source.ts b/docs/lib/geistdocs/cookbook-source.ts index aa22ad3b08..7691192ca7 100644 --- a/docs/lib/geistdocs/cookbook-source.ts +++ b/docs/lib/geistdocs/cookbook-source.ts @@ -5,7 +5,7 @@ import { type RecipeCategory, recipes, } from '../cookbook-tree'; -import { source, v5Source } from './source'; +import { source, v4Source } from './source'; const COOKBOOK_DOCS_PREFIX_RE = /\/docs\/cookbook(?=\/|$)/g; @@ -18,8 +18,8 @@ export function rewriteCookbookUrl(url: string): string { /** * Rewrite a fumadocs source URL (`/docs/cookbook/...`) to the public cookbook - * URL for a given version prefix. Pass '' for v4 (`/cookbook/...`) or '/v5' - * for v5 (`/v5/cookbook/...`). + * URL for a given version prefix. Pass '' for v5 (`/cookbook/...`) or '/v4' + * for v4 (`/v4/cookbook/...`). */ export function rewriteCookbookUrlForVersion( url: string, @@ -42,13 +42,13 @@ function isCookbookFolder(node: Node): boolean { /** * Return the docs page tree with cookbook nodes removed. * Used by the docs layout so the sidebar never shows cookbook entries. - * Pass 'v5' to use the v5 source tree; defaults to 'v4'. + * Pass 'v4' to use the v4 source tree; defaults to 'v5' (the current version). */ export function getDocsTreeWithoutCookbook( lang: string, - version: 'v4' | 'v5' = 'v4' + version: 'v4' | 'v5' = 'v5' ): Root { - const src = version === 'v5' ? v5Source : source; + const src = version === 'v4' ? v4Source : source; const fullTree = src.pageTree[lang]; return { ...fullTree, @@ -70,7 +70,7 @@ function createRecipePage( slug: string, versionPrefix: string ): PageNode { - const versionId = versionPrefix ? versionPrefix.replace(/^\//, '') : 'v4'; + const versionId = versionPrefix ? versionPrefix.replace(/^\//, '') : 'v5'; const recipe = recipes[slug]; const versionedRecipe = { ...recipe, @@ -89,8 +89,8 @@ function createCategoryFolder( category: RecipeCategory, versionPrefix: string ): FolderNode { - // Derive version ID from prefix: '/v5' → 'v5', '' → 'v4' - const versionId = versionPrefix ? versionPrefix.replace(/^\//, '') : 'v4'; + // Derive version ID from prefix: '/v4' → 'v4', '' → 'v5' + const versionId = versionPrefix ? versionPrefix.replace(/^\//, '') : 'v5'; const categoryRecipes = Object.values(recipes).filter( (recipe) => recipe.category === category && !recipe.skipVersions?.includes(versionId) @@ -107,15 +107,15 @@ function createCategoryFolder( /** * Build a standalone cookbook sidebar tree from cookbook-tree metadata. - * Pass a versionPrefix (e.g. '/v5') to produce version-prefixed sidebar URLs. + * Pass a versionPrefix (e.g. '/v4') to produce version-prefixed sidebar URLs. */ export function getCookbookTree(lang: string, versionPrefix = ''): Root { - const src = versionPrefix ? v5Source : source; + const src = versionPrefix ? v4Source : source; const fullTree = src.pageTree[lang]; return { ...fullTree, - $id: `cookbook__root__${versionPrefix ? versionPrefix.replace(/^\//, '') : 'v4'}`, + $id: `cookbook__root__${versionPrefix ? versionPrefix.replace(/^\//, '') : 'v5'}`, name: 'Cookbook', children: [ createOverviewPage(versionPrefix), diff --git a/docs/lib/geistdocs/source.ts b/docs/lib/geistdocs/source.ts index 18f67fb5d7..0aa5a6e589 100644 --- a/docs/lib/geistdocs/source.ts +++ b/docs/lib/geistdocs/source.ts @@ -31,7 +31,7 @@ const rewriteLocalDocsUrlForVersion = (url: string, versionPrefix: string) => { return replacePathPrefix(url, DOCS_PREFIX, `${versionPrefix}/docs`); } - // World docs are versioned like the docs trees (/worlds vs /v5/worlds), so + // World docs are versioned like the docs trees (/worlds vs /v4/worlds), so // links authored against the raw /worlds/... space get the same treatment. if (versionPrefix && hasPathPrefix(url, WORLDS_PREFIX)) { return replacePathPrefix(url, WORLDS_PREFIX, `${versionPrefix}/worlds`); @@ -112,32 +112,32 @@ const expandAutoCards = ( const versionedSources = createVersionedSources({ config, - current: 'v4', + current: 'v5', versions: [ { - id: 'v4', - label: 'v4 (Latest)', - docs: v4docs, + id: 'v5', + label: 'v5 (Latest)', + docs: v5docs, baseUrl: '/docs', markdown: { transform: (markdown, { page }) => rewriteDocsUrlsForVersion( - expandAutoCards(markdown, 'v4', page.url), + expandAutoCards(markdown, 'v5', page.url), '' ), }, }, { - id: 'v5', - label: 'v5 (Pre-release)', - docs: v5docs, + id: 'v4', + label: 'v4 (Maintenance)', + docs: v4docs, baseUrl: '/docs', - routePrefix: '/v5', + routePrefix: '/v4', markdown: { transform: (markdown, { page }) => rewriteDocsUrlsForVersion( - expandAutoCards(markdown, 'v5', page.url), - '/v5' + expandAutoCards(markdown, 'v4', page.url), + '/v4' ), }, }, @@ -249,71 +249,71 @@ export const cookbookSource = createCookbookRouteSource( } ); -export const v5GeistdocsSource = createDocsRouteSource( - versionedSources.byId.v5, +export const v4GeistdocsSource = createDocsRouteSource( + versionedSources.byId.v4, { - id: 'v5-docs', - label: 'v5 Docs', - versionPrefix: '/v5', + id: 'v4-docs', + label: 'v4 Docs', + versionPrefix: '/v4', } ); -export const v5CookbookSource = createCookbookRouteSource( - versionedSources.byId.v5, +export const v4CookbookSource = createCookbookRouteSource( + versionedSources.byId.v4, { - id: 'v5-cookbook', - label: 'v5 Cookbook', - versionPrefix: '/v5', + id: 'v4-cookbook', + label: 'v4 Cookbook', + versionPrefix: '/v4', } ); -// Canonical World docs, versioned like the docs trees: v4 (current) is served -// at /worlds/*, v5 at /v5/worlds/*. These pages are rendered by the worlds app +// Canonical World docs, versioned like the docs trees: v5 (current) is served +// at /worlds/*, v4 at /v4/worlds/*. These pages are rendered by the worlds app // routes (not the docs layout), but the bundles are included in the source // lists so they stay covered by search, llms.txt, sitemap(.md), and the // markdown export routes. export const worldsSourceBundle = createSource({ config, - docs: worldsV4Docs, + docs: worldsV5Docs, baseUrl: '/worlds', id: 'worlds', label: 'Worlds', }); -const v5WorldsBundleRaw = createSource({ +const v4WorldsBundleRaw = createSource({ config, - docs: worldsV5Docs, + docs: worldsV4Docs, baseUrl: '/worlds', - id: 'v5-worlds', - label: 'v5 Worlds', + id: 'v4-worlds', + label: 'v4 Worlds', markdown: { - // Match the v5 docs markdown export: links authored against the raw - // /docs/... and /worlds/... spaces are rewritten into the /v5 view. - transform: (markdown) => rewriteDocsUrlsForVersion(markdown, '/v5'), + // Match the v4 docs markdown export: links authored against the raw + // /docs/... and /worlds/... spaces are rewritten into the /v4 view. + transform: (markdown) => rewriteDocsUrlsForVersion(markdown, '/v4'), }, }); -// Route/list surfaces see the v5 worlds pages in their public /v5/worlds/... -// URL space (the raw loader keeps /worlds/... URLs, mirroring how the v5 docs +// Route/list surfaces see the v4 worlds pages in their public /v4/worlds/... +// URL space (the raw loader keeps /worlds/... URLs, mirroring how the v4 docs // source is wrapped by createDocsRouteSource). -export const v5WorldsSourceBundle: GeistdocsSourceBundle = { - ...v5WorldsBundleRaw, - baseUrl: '/v5/worlds', +export const v4WorldsSourceBundle: GeistdocsSourceBundle = { + ...v4WorldsBundleRaw, + baseUrl: '/v4/worlds', source: { - ...v5WorldsBundleRaw.source, + ...v4WorldsBundleRaw.source, getPage: ((slug?: string[], lang?: string) => { - const page = v5WorldsBundleRaw.source.getPage(slug, lang); - return page ? withUrl(page, `/v5${page.url}`) : undefined; + const page = v4WorldsBundleRaw.source.getPage(slug, lang); + return page ? withUrl(page, `/v4${page.url}`) : undefined; }) as Source['getPage'], getPages: ((lang?: string) => - v5WorldsBundleRaw.source + v4WorldsBundleRaw.source .getPages(lang) - .map((page) => withUrl(page, `/v5${page.url}`))) as Source['getPages'], + .map((page) => withUrl(page, `/v4${page.url}`))) as Source['getPages'], }, }; export const worldsSource = worldsSourceBundle.source; -export const v5WorldsSource = v5WorldsBundleRaw.source; +export const v4WorldsSource = v4WorldsBundleRaw.source; export const currentSources = [ geistdocsSource, @@ -323,13 +323,13 @@ export const currentSources = [ export const allSources = [ geistdocsSource, cookbookSource, - v5GeistdocsSource, - v5CookbookSource, + v4GeistdocsSource, + v4CookbookSource, worldsSourceBundle, - v5WorldsSourceBundle, + v4WorldsSourceBundle, ]; export const source = versionedSources.current.source; -export const v5Source = versionedSources.byId.v5.source; +export const v4Source = versionedSources.byId.v4.source; export const getPageImage = versionedSources.current.getPageImage; export const getLLMText = versionedSources.current.getPageMarkdown; diff --git a/docs/lib/geistdocs/version-href.ts b/docs/lib/geistdocs/version-href.ts index 2dfa7b3095..1942b17247 100644 --- a/docs/lib/geistdocs/version-href.ts +++ b/docs/lib/geistdocs/version-href.ts @@ -4,7 +4,7 @@ import { hasPathPrefix } from './path-prefix'; /** * Rewrite an href authored against the raw unversioned URL spaces * (`/docs/...`, `/docs/cookbook/...`, `/worlds/...`) into a version's public - * view (e.g. `/v5/docs/...`, `/v5/cookbook/...`, `/v5/worlds/...`) so + * view (e.g. `/v4/docs/...`, `/v4/cookbook/...`, `/v4/worlds/...`) so * navigation from a versioned page doesn't escape to the current-version * route. Non-string and external hrefs pass through untouched. */ diff --git a/docs/lib/geistdocs/version-source.ts b/docs/lib/geistdocs/version-source.ts index 20dbb6b23a..d97c7813b0 100644 --- a/docs/lib/geistdocs/version-source.ts +++ b/docs/lib/geistdocs/version-source.ts @@ -1,7 +1,7 @@ import type { Root } from 'fumadocs-core/page-tree'; import { getDocsTreeWithoutCookbook } from './cookbook-source'; import type { DocsVersion } from './versions'; -import { PRE_RELEASE_VERSION } from './versions'; +import { MAINTENANCE_VERSION } from './versions'; function rewriteUrl(url: string, prefix: string): string; function rewriteUrl(url: undefined, prefix: string): undefined; @@ -41,25 +41,25 @@ function rewriteNodeUrls( /** * Build the sidebar tree for a given docs version. * - * - v4 (latest): returns the v4 source tree (content/docs/v4) with cookbook - * nodes stripped. No filtering needed — v4 simply doesn't contain v5-only - * pages. - * - v5 (pre-release): returns the v5 source tree (content/docs/v5) with - * cookbook nodes stripped and URLs rewritten to the `/v5/docs/...` namespace - * so sidebar links stay inside the v5 view. + * - v5 (latest): returns the v5 source tree (content/docs/v5) with cookbook + * nodes stripped. No URL rewriting needed — the current version is served + * unprefixed. + * - v4 (maintenance): returns the v4 source tree (content/docs/v4) with + * cookbook nodes stripped and URLs rewritten to the `/v4/docs/...` namespace + * so sidebar links stay inside the v4 view. */ export function getDocsTreeForVersion( lang: string, version: DocsVersion ): Root { - if (version.preRelease) { - const base = getDocsTreeWithoutCookbook(lang, 'v5'); + if (version.maintenance) { + const base = getDocsTreeWithoutCookbook(lang, 'v4'); return { ...base, children: rewriteNodeUrls(base.children, version.prefix), }; } - return getDocsTreeWithoutCookbook(lang, 'v4'); + return getDocsTreeWithoutCookbook(lang, 'v5'); } -export { PRE_RELEASE_VERSION }; +export { MAINTENANCE_VERSION }; diff --git a/docs/lib/geistdocs/version-switch-paths.ts b/docs/lib/geistdocs/version-switch-paths.ts index 2156d40cef..7865faf1ac 100644 --- a/docs/lib/geistdocs/version-switch-paths.ts +++ b/docs/lib/geistdocs/version-switch-paths.ts @@ -5,9 +5,9 @@ import { import { cookbookSource, geistdocsSource, - v5CookbookSource, - v5GeistdocsSource, - v5WorldsSourceBundle, + v4CookbookSource, + v4GeistdocsSource, + v4WorldsSourceBundle, worldsSourceBundle, } from './source'; @@ -17,25 +17,25 @@ import { * switcher lands on the nearest existing ancestor (or `/docs`, which the app * redirects to getting-started) instead of a 404. * - * The route sources already expose public URLs (the v5 ones prefixed with - * `/v5`), so the v5 entry strips that prefix to get prefix-relative paths. + * The route sources already expose public URLs (the v4 ones prefixed with + * `/v4`), so the v4 entry strips that prefix to get prefix-relative paths. */ export const getVersionSwitchPaths = ( lang: string ): Record => ({ - v4: { + v5: { fallbackPath: '/docs', paths: collectVersionPaths({ lang, sources: [geistdocsSource, cookbookSource, worldsSourceBundle], }), }, - v5: { + v4: { fallbackPath: '/docs', paths: collectVersionPaths({ lang, - routePrefix: '/v5', - sources: [v5GeistdocsSource, v5CookbookSource, v5WorldsSourceBundle], + routePrefix: '/v4', + sources: [v4GeistdocsSource, v4CookbookSource, v4WorldsSourceBundle], }), }, }); diff --git a/docs/lib/geistdocs/versions.ts b/docs/lib/geistdocs/versions.ts index d8dc8b1a60..2b55a12912 100644 --- a/docs/lib/geistdocs/versions.ts +++ b/docs/lib/geistdocs/versions.ts @@ -5,41 +5,57 @@ export interface DocsVersion { label: string; subtitle: string; prefix: string; - preRelease: boolean; + /** + * True for a version that is no longer the current release line. Maintenance + * docs are served under a route prefix and excluded from search indexing. + */ + maintenance: boolean; } export const VERSIONS: DocsVersion[] = [ { id: 'v5', - label: 'v5 (Pre-release)', + label: 'v5 (Latest)', subtitle: 'Workflow 5.x', - prefix: '/v5', - preRelease: true, + prefix: '', + maintenance: false, }, { id: 'v4', - label: 'v4 (Latest)', + label: 'v4 (Maintenance)', subtitle: 'Workflow 4.x', - prefix: '', - preRelease: false, + prefix: '/v4', + maintenance: true, }, ]; -export const PRE_RELEASE_VERSION: DocsVersion = VERSIONS[0]; -export const LATEST_VERSION: DocsVersion = VERSIONS[1]; +export const LATEST_VERSION: DocsVersion = VERSIONS[0]; +export const MAINTENANCE_VERSION: DocsVersion = VERSIONS[1]; + +/** + * Route segment the maintenance version is served under (`v4`). The latest + * version has no prefix, so this is the only version segment in the URL space. + */ +export const MAINTENANCE_SEGMENT = MAINTENANCE_VERSION.prefix.replace( + /^\//, + '' +); /** - * Derive the active docs version from a pathname. Matches `/v5/...` (or - * `//v5/...` once locale prefix is applied) against the pre-release - * prefix; everything else is v4. + * Derive the active docs version from a pathname. Matches `/v4/...` (or + * `//v4/...` once locale prefix is applied) against the maintenance + * prefix; everything else is the latest version. */ export function getVersionFromPathname(pathname: string): DocsVersion { - // The v5 segment sits either at the root (default locale hidden) or right - // after a locale segment — both cases are covered by checking positions - // 0 and 1. + // The version segment sits either at the root (default locale hidden) or + // right after a locale segment — both cases are covered by checking + // positions 0 and 1. const segments = pathname.split('/').filter(Boolean); - if (segments[0] === 'v5' || segments[1] === 'v5') { - return PRE_RELEASE_VERSION; + if ( + segments[0] === MAINTENANCE_SEGMENT || + segments[1] === MAINTENANCE_SEGMENT + ) { + return MAINTENANCE_VERSION; } return LATEST_VERSION; } @@ -54,7 +70,7 @@ export function getVersionFromPathname(pathname: string): DocsVersion { * `usePathname()` can return either `/docs/...` (default locale hidden by * the i18n middleware) or `//docs/...` (non-default locale shown). * We detect the locale segment by checking whether segment 0 is a - * structural path token (`docs` or `v5`) rather than assuming position. + * structural path token (`docs` or `v4`) rather than assuming position. */ export function buildVersionUrl( pathname: string, @@ -63,7 +79,10 @@ export function buildVersionUrl( const segments = pathname.split('/').filter(Boolean); // Structural segments are path tokens that are never locale prefixes. const isStructural = (s: string | undefined) => - s === 'docs' || s === 'v5' || s === 'cookbook' || s === 'worlds'; + s === 'docs' || + s === MAINTENANCE_SEGMENT || + s === 'cookbook' || + s === 'worlds'; // Versioned routes carry a structural token at the root or right after a // locale segment; everything else is shared and returned unchanged. @@ -74,7 +93,7 @@ export function buildVersionUrl( const localeSegments = segments[0] && !isStructural(segments[0]) ? segments.slice(0, 1) : []; let rest = segments.slice(localeSegments.length); - if (rest[0] === 'v5') rest = rest.slice(1); + if (rest[0] === MAINTENANCE_SEGMENT) rest = rest.slice(1); const prefixSegments = targetVersion.prefix ? [targetVersion.prefix.replace(/^\//, '')] : []; diff --git a/docs/next.config.ts b/docs/next.config.ts index 27b8473a74..64afd47aac 100644 --- a/docs/next.config.ts +++ b/docs/next.config.ts @@ -25,10 +25,32 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs', - destination: '/v5/docs/getting-started', + source: '/v4/docs', + destination: '/v4/docs/getting-started', permanent: false, }, + // v5 is the current version and is served unprefixed, so the whole /v5 + // URL space (used while v5 was a pre-release) maps onto its unprefixed + // equivalent. Rules further down that move an unprefixed path (the + // api-reference restructure, the world docs) apply on the following hop. + // + // v4 content also links here on purpose: hrefs on a /v4 page are + // rewritten into the /v4 view at render time, so a /v5/... href is the + // only way for v4 content to point at the current version's page. + // + // Bare /v5 needs its own rule: `:path*` matches zero segments, but the + // expanded destination is then the empty string, which Next.js emits as + // an empty Location header. + { + source: '/v5', + destination: '/', + permanent: true, + }, + { + source: '/v5/:path*', + destination: '/:path*', + permanent: true, + }, { source: '/docs/cookbook', destination: '/cookbook', @@ -57,7 +79,7 @@ const config: NextConfig = { // Redirect old world docs to the /worlds routes. The world pages // (and Building a World) were removed from the versioned docs trees; // content/worlds/{v4,v5} is the canonical source, served at /worlds/* - // (current) and /v5/worlds/* (pre-release). + // (current) and /v4/worlds/* (maintenance). { source: '/docs/deploying/world/local-world', destination: '/worlds/local', @@ -74,18 +96,18 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs/deploying/world/local-world', - destination: '/v5/worlds/local', + source: '/v4/docs/deploying/world/local-world', + destination: '/v4/worlds/local', permanent: true, }, { - source: '/v5/docs/deploying/world/postgres-world', - destination: '/v5/worlds/postgres', + source: '/v4/docs/deploying/world/postgres-world', + destination: '/v4/worlds/postgres', permanent: true, }, { - source: '/v5/docs/deploying/world/vercel-world', - destination: '/v5/worlds/vercel', + source: '/v4/docs/deploying/world/vercel-world', + destination: '/v4/worlds/vercel', permanent: true, }, { @@ -94,20 +116,20 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs/deploying/building-a-world', - destination: '/v5/worlds/building-a-world', + source: '/v4/docs/deploying/building-a-world', + destination: '/v4/worlds/building-a-world', permanent: true, }, // The worlds listing and compare pages are unversioned; send the - // version-prefixed URLs (reachable via the render-time /v5 link - // rewrite on pre-release pages) to the canonical routes. + // version-prefixed URLs (reachable via the render-time /v4 link + // rewrite on maintenance pages) to the canonical routes. { - source: '/v5/worlds', + source: '/v4/worlds', destination: '/worlds', permanent: false, }, { - source: '/v5/worlds/compare', + source: '/v4/worlds/compare', destination: '/worlds/compare', permanent: false, }, @@ -226,18 +248,16 @@ const config: NextConfig = { // setAttributes graduated from experimental_setAttributes; the API // reference page moved with it. { - source: '/v5/docs/api-reference/workflow/experimental-set-attributes', - destination: '/v5/docs/api-reference/workflow/set-attributes', + source: '/docs/api-reference/workflow/experimental-set-attributes', + destination: '/docs/api-reference/workflow/set-attributes', permanent: true, }, - // setAttributes is v5-only, so the unversioned path has no page yet. - // Land on the section index directly (no redirect chain through the - // /docs/api-reference/workflow/set-attributes fallback below). Point - // this at /docs/api-reference/workflow/set-attributes once v5 becomes - // the default version. + // setAttributes is v5-only, so neither the graduated nor the + // experimental path has a page in the v4 tree; both land on the + // section index. { - source: '/docs/api-reference/workflow/experimental-set-attributes', - destination: '/docs/api-reference/workflow', + source: '/v4/docs/api-reference/workflow/experimental-set-attributes', + destination: '/v4/docs/api-reference/workflow', permanent: false, }, { @@ -257,8 +277,8 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs/api-reference/workflow-api/world/observability', - destination: '/v5/docs/api-reference/workflow-observability', + source: '/v4/docs/api-reference/workflow-api/world/observability', + destination: '/v4/docs/api-reference/workflow-observability', permanent: true, }, { @@ -267,8 +287,8 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs/api-reference/workflow-api/get-world', - destination: '/v5/docs/api-reference/workflow-runtime/get-world', + source: '/v4/docs/api-reference/workflow-api/get-world', + destination: '/v4/docs/api-reference/workflow-runtime/get-world', permanent: true, }, { @@ -277,8 +297,8 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs/api-reference/workflow-api/world', - destination: '/v5/docs/api-reference/workflow-runtime/world', + source: '/v4/docs/api-reference/workflow-api/world', + destination: '/v4/docs/api-reference/workflow-runtime/world', permanent: true, }, { @@ -287,99 +307,113 @@ const config: NextConfig = { permanent: true, }, { - source: '/v5/docs/api-reference/workflow-api/world/:path*', - destination: '/v5/docs/api-reference/workflow-runtime/world/:path*', + source: '/v4/docs/api-reference/workflow-api/world/:path*', + destination: '/v4/docs/api-reference/workflow-runtime/world/:path*', permanent: true, }, // --- Version-switcher fallbacks --- - // The version switcher swaps the /v5 route prefix without checking - // that the page exists in the target version, so pages that exist in - // only one docs tree 404 on switch. Each rule below covers a page - // missing from one version and lands on the nearest equivalent + // The version switcher adds or drops the /v4 route prefix without + // checking that the page exists in the target version, so pages that + // live in only one docs tree 404 on switch. Each rule below covers a + // page missing from one version and lands on the nearest equivalent // (usually the section index). All are temporary redirects: they must - // be revisited when content is backported or when v5 becomes the - // default version (which swaps the trees served at /docs). + // be revisited when content is backported, and the /v4 ones can be + // dropped wholesale once the v4 docs are retired. // // Pages that exist only in v5 (v5 -> v4 switch): { - source: '/docs/api-reference/workflow/set-attributes', - destination: '/docs/api-reference/workflow', + source: '/v4/docs/whats-new', + destination: '/v4/docs', permanent: false, }, { - source: '/docs/api-reference/workflow-errors/precondition-failed-error', - destination: '/docs/api-reference/workflow-errors', + source: '/v4/docs/api-reference/workflow/set-attributes', + destination: '/v4/docs/api-reference/workflow', permanent: false, }, { - source: '/docs/api-reference/workflow-runtime/world/analytics', - destination: '/docs/api-reference/workflow-runtime/world', + source: + '/v4/docs/api-reference/workflow-errors/precondition-failed-error', + destination: '/v4/docs/api-reference/workflow-errors', + permanent: false, + }, + { + source: '/v4/docs/api-reference/workflow-runtime/world/analytics', + destination: '/v4/docs/api-reference/workflow-runtime/world', permanent: false, }, { source: - '/docs/changelog/(attributes-mvp|eager-processing|step-message-ownership)', - destination: '/docs/changelog', + '/v4/docs/changelog/(attributes-mvp|eager-processing|step-message-ownership)', + destination: '/v4/docs/changelog', permanent: false, }, { - source: '/docs/configuration', - destination: '/docs/deploying', + source: '/v4/docs/configuration', + destination: '/v4/docs/deploying', permanent: false, }, { - source: '/docs/configuration/:path*', - destination: '/docs/deploying', + source: '/v4/docs/configuration/:path*', + destination: '/v4/docs/deploying', permanent: false, }, { - source: '/docs/errors/abort-signal-timeout-in-workflow', - destination: '/docs/errors', + source: '/v4/docs/errors/abort-signal-timeout-in-workflow', + destination: '/v4/docs/errors', permanent: false, }, { - source: '/docs/foundations/cancellation', - destination: '/docs/foundations', + source: '/v4/docs/foundations/cancellation', + destination: '/v4/docs/foundations', permanent: false, }, // v4 has no how-it-works index page; foundations is the closest // conceptual landing for the v5 cancellation internals page. { - source: '/docs/how-it-works/cancellation', - destination: '/docs/foundations', + source: '/v4/docs/how-it-works/cancellation', + destination: '/v4/docs/foundations', permanent: false, }, { - source: '/docs/getting-started/react-router', - destination: '/docs/getting-started', + source: '/v4/docs/getting-started/react-router', + destination: '/v4/docs/getting-started', permanent: false, }, { - source: '/docs/getting-started/react-router/:path*', - destination: '/docs/getting-started', + source: '/v4/docs/getting-started/react-router/:path*', + destination: '/v4/docs/getting-started', permanent: false, }, { source: - '/docs/internal/(nitro-native-build|nitro-web-ui|serializable-abort-controller)', - destination: '/docs/internal', + '/v4/docs/internal/(nitro-native-build|nitro-web-ui|serializable-abort-controller)', + destination: '/v4/docs/internal', permanent: false, }, { - source: '/docs/observability/(attributes|tracing)', - destination: '/docs/observability', + source: '/v4/docs/observability/(attributes|tracing)', + destination: '/v4/docs/observability', + permanent: false, + }, + // The World upgrade guide describes the v4 -> v5 spec move, so it has no + // v4 counterpart; the World spec it starts from is documented in the v4 + // Building a World page. + { + source: '/v4/worlds/upgrading-to-v5', + destination: '/v4/worlds/building-a-world', permanent: false, }, // Pages that exist only in v4 (v4 -> v5 switch): { - source: '/v5/docs/api-reference/workflow-runtime/step-entrypoint', - destination: '/v5/docs/api-reference/workflow-runtime', + source: '/docs/api-reference/workflow-runtime/step-entrypoint', + destination: '/docs/api-reference/workflow-runtime', permanent: false, }, - // /v5/cookbook/advanced has no index page; fall back to the root. + // /cookbook/advanced has no index page; fall back to the root. { - source: '/v5/cookbook/advanced/distributed-abort-controller', - destination: '/v5/cookbook', + source: '/cookbook/advanced/distributed-abort-controller', + destination: '/cookbook', permanent: false, }, ]; diff --git a/docs/proxy.ts b/docs/proxy.ts index 4c598e0bec..cd75a15b0b 100644 --- a/docs/proxy.ts +++ b/docs/proxy.ts @@ -8,15 +8,15 @@ const proxy = createProxy({ markdownRoutes: [ { from: '/docs/*path', to: '/[lang]/llms.mdx/docs/*path' }, { from: '/cookbook/*path', to: '/[lang]/llms.mdx/cookbook/*path' }, - { from: '/v5/docs/*path', to: '/[lang]/llms.mdx/v5/docs/*path' }, + { from: '/v4/docs/*path', to: '/[lang]/llms.mdx/v4/docs/*path' }, { - from: '/v5/cookbook/*path', - to: '/[lang]/llms.mdx/v5/cookbook/*path', + from: '/v4/cookbook/*path', + to: '/[lang]/llms.mdx/v4/cookbook/*path', }, { from: '/worlds/*path', to: '/[lang]/llms.mdx/worlds/*path' }, { - from: '/v5/worlds/*path', - to: '/[lang]/llms.mdx/v5/worlds/*path', + from: '/v4/worlds/*path', + to: '/[lang]/llms.mdx/v4/worlds/*path', }, ], }); diff --git a/docs/scripts/check-docs-smoke.mjs b/docs/scripts/check-docs-smoke.mjs index 32291f445f..b2ed8be522 100644 --- a/docs/scripts/check-docs-smoke.mjs +++ b/docs/scripts/check-docs-smoke.mjs @@ -153,6 +153,59 @@ const assertHtmlMeta = async (path, expectedOgImagePath) => { } }; +/** + * The unprefixed world routes must serve the current version (no " · v4" + * title marker, indexable) and the /v4 routes the maintenance version + * (" · v4" marker, noindex). Guards against the version passed by the route + * files drifting out of sync with the version semantics in + * components/worlds/world-detail-page.tsx. + */ +const assertWorldVersionMarkers = async (path, { maintenance }) => { + const res = await fetch(`${BASE_URL}${path}`, { + headers: await getTrustedSourcesHeaders(), + }); + if (!res.ok) { + throw new Error(`${path} returned ${res.status}`); + } + const html = await res.text(); + const title = html.match(/([^<]*)<\/title>/i)?.[1] ?? ''; + const hasV4Marker = title.includes('· v4'); + if (maintenance && !hasV4Marker) { + throw new Error(`${path} title was "${title}", expected a " · v4" marker`); + } + if (!maintenance && hasV4Marker) { + throw new Error( + `${path} title was "${title}", expected the current version (no " · v4" marker)` + ); + } + const hasNoindex = + /<meta[^>]+name=["']robots["'][^>]+content=["'][^"']*noindex/i.test(html); + if (maintenance && !hasNoindex) { + throw new Error(`${path} is missing the robots noindex meta tag`); + } + if (!maintenance && hasNoindex) { + throw new Error(`${path} is unexpectedly noindexed`); + } +}; + +/** + * Community worlds have no versioned content; their canonical page must serve + * directly. A version mismatch in the world routes turns them into + * self-redirect loops, so assert a plain 200 with no redirect. + */ +const assertServesDirectly = async (path) => { + const res = await fetch(`${BASE_URL}${path}`, { + redirect: 'manual', + headers: await getTrustedSourcesHeaders(), + }); + if (res.status !== 200) { + const location = res.headers.get('location'); + throw new Error( + `${path} returned ${res.status}${location ? ` -> ${location}` : ''}` + ); + } +}; + const checks = [ { name: 'Deployment protection', @@ -211,12 +264,26 @@ const checks = [ run: () => assertHtmlMeta('/worlds/building-a-world', '/og/worlds'), }, { - name: 'HTML meta - worlds upgrading-to-v5 (v5)', - run: () => assertHtmlMeta('/v5/worlds/upgrading-to-v5', '/og/worlds'), + name: 'HTML meta - worlds upgrading-to-v5', + run: () => assertHtmlMeta('/worlds/upgrading-to-v5', '/og/worlds'), + }, + { + name: 'HTML meta - world vercel (v4)', + run: () => assertHtmlMeta('/v4/worlds/vercel', '/og/worlds/vercel'), + }, + { + name: 'World version markers - vercel (current)', + run: () => + assertWorldVersionMarkers('/worlds/vercel', { maintenance: false }), + }, + { + name: 'World version markers - vercel (v4)', + run: () => + assertWorldVersionMarkers('/v4/worlds/vercel', { maintenance: true }), }, { - name: 'HTML meta - world vercel (v5)', - run: () => assertHtmlMeta('/v5/worlds/vercel', '/og/worlds/vercel'), + name: 'Community world serves directly - turso', + run: () => assertServesDirectly('/worlds/turso'), }, { name: 'OG docs page image', diff --git a/docs/scripts/lint.ts b/docs/scripts/lint.ts index 0337cf16ff..158baf6355 100755 --- a/docs/scripts/lint.ts +++ b/docs/scripts/lint.ts @@ -15,8 +15,8 @@ import { import { resolveSectionChildren } from '../lib/geistdocs/section-children'; import { source, - v5Source, - v5WorldsSource, + v4Source, + v4WorldsSource, worldsSource, } from '../lib/geistdocs/source'; import { getWorldIds } from '../lib/worlds-data'; @@ -70,9 +70,9 @@ async function getSharedUrls(): Promise<Map<string, UrlMeta>> { for (const path of [ '/', '/docs', - '/v5/docs', + '/v4/docs', '/cookbook', - '/v5/cookbook', + '/v4/cookbook', '/worlds', '/worlds/compare', '/llms.txt', @@ -105,20 +105,21 @@ async function listFilesRecursive(dir: string, prefix = ''): Promise<string[]> { /** * Build the two URL spaces links are resolved against. * - * v4 space — how hrefs resolve when rendered on a v4 (unversioned) page: - * /docs/X → v4 page X - * /cookbook/X → v4 cookbook page - * /v5/docs/X → v5 page X (explicit cross-version link) - * /v5/cookbook/X → v5 cookbook page + * v5 space — how hrefs resolve when rendered on a v5 (unversioned, current) + * page: + * /docs/X → v5 page X + * /cookbook/X → v5 cookbook page + * /v4/docs/X → v4 page X (explicit cross-version link) + * /v4/cookbook/X → v4 cookbook page * - * v5 space — how hrefs resolve when rendered on a /v5 page. The v5 routes - * rewrite /docs/... hrefs (inline links and Card hrefs) to /v5/docs/... at - * render time, so an unversioned /docs/X href on a v5 page resolves to the - * v5 page X — it is broken unless X exists in the v5 content tree: - * /docs/X → v5 page X (rewritten at render time) - * /cookbook/X → v4 cookbook page (not rewritten) - * /v5/docs/X → v5 page X - * /v5/cookbook/X → v5 cookbook page + * v4 space — how hrefs resolve when rendered on a /v4 page. The v4 routes + * rewrite /docs/... hrefs (inline links and Card hrefs) to /v4/docs/... at + * render time, so an unversioned /docs/X href on a v4 page resolves to the + * v4 page X — it is broken unless X exists in the v4 content tree: + * /docs/X → v4 page X (rewritten at render time) + * /cookbook/X → v5 cookbook page (not rewritten) + * /v4/docs/X → v4 page X + * /v4/cookbook/X → v4 cookbook page */ function buildSpaces( v4Pages: LoadedPage[], @@ -131,43 +132,43 @@ function buildSpaces( const v5Space: Scanned = { urls: new Map(shared), fallbackUrls: [] }; // World docs are versioned like the docs trees and rendered at /worlds/* - // (v4/current) and /v5/worlds/* (pre-release). They follow the same URL - // resolution model: on v5 pages, unversioned /worlds/... hrefs are - // rewritten to /v5/worlds/... at render time. Unlike the manifest-derived + // (v5/current) and /v4/worlds/* (maintenance). They follow the same URL + // resolution model: on v4 pages, unversioned /worlds/... hrefs are + // rewritten to /v4/worlds/... at render time. Unlike the manifest-derived // entries in getSharedUrls, these carry heading hashes. - for (const { page, hashes } of worldsV4Pages) { - v4Space.urls.set(page.url, { hashes }); - } for (const { page, hashes } of worldsV5Pages) { + v5Space.urls.set(page.url, { hashes }); + } + for (const { page, hashes } of worldsV4Pages) { const meta = { hashes }; - v4Space.urls.set(`/v5${page.url}`, meta); - v5Space.urls.set(`/v5${page.url}`, meta); - v5Space.urls.set(page.url, meta); + v5Space.urls.set(`/v4${page.url}`, meta); + v4Space.urls.set(`/v4${page.url}`, meta); + v4Space.urls.set(page.url, meta); } - for (const { page, hashes } of v4Pages) { + for (const { page, hashes } of v5Pages) { const meta = { hashes }; - v4Space.urls.set(page.url, meta); + v5Space.urls.set(page.url, meta); const cookbookUrl = rewriteCookbookUrl(page.url); if (cookbookUrl !== page.url) { // /docs/cookbook/X is served at /cookbook/X — valid in both spaces - // (cookbook links are not version-rewritten on v5 pages). - v4Space.urls.set(cookbookUrl, meta); + // (cookbook links are not version-rewritten on v4 pages). v5Space.urls.set(cookbookUrl, meta); + v4Space.urls.set(cookbookUrl, meta); } } - for (const { page, hashes } of v5Pages) { + for (const { page, hashes } of v4Pages) { const meta = { hashes }; - v4Space.urls.set(`/v5${page.url}`, meta); - v5Space.urls.set(`/v5${page.url}`, meta); - // On v5 pages, unversioned /docs/... hrefs are rewritten to /v5/docs/... - // at render time, so they resolve to the v5 page. - v5Space.urls.set(page.url, meta); + v5Space.urls.set(`/v4${page.url}`, meta); + v4Space.urls.set(`/v4${page.url}`, meta); + // On v4 pages, unversioned /docs/... hrefs are rewritten to /v4/docs/... + // at render time, so they resolve to the v4 page. + v4Space.urls.set(page.url, meta); const cookbookUrl = rewriteCookbookUrl(page.url); if (cookbookUrl !== page.url) { - v4Space.urls.set(`/v5${cookbookUrl}`, meta); - v5Space.urls.set(`/v5${cookbookUrl}`, meta); + v5Space.urls.set(`/v4${cookbookUrl}`, meta); + v4Space.urls.set(`/v4${cookbookUrl}`, meta); } } @@ -180,9 +181,15 @@ function buildSpaces( * concrete URLs already in the space, so a redirect never blanket-validates * URLs whose destination doesn't exist. * - * Sources under /docs are only reachable from v4 pages (on v5 pages the - * render-time rewrite turns /docs/... into /v5/docs/..., which skips the - * redirect), so they are only added to the v4 space. + * Destinations are resolved against the v5 space, which is the real HTTP URL + * space (unprefixed URLs are served by the current version, /v4 ones by the + * maintenance version). Redirects are matched by the server before a page + * renders, so the render-time /docs → /v4/docs href rewriting never applies + * to them — a /v5/... link on a v4 page still lands on the v5 page. + * + * Sources under /docs are only reachable from v5 pages (on v4 pages the + * render-time rewrite turns /docs/... into /v4/docs/..., which skips the + * redirect), so they are only added to the v5 space. */ async function applyRedirects(v4Space: Scanned, v5Space: Scanned) { const redirects = (await nextConfig.redirects?.()) ?? []; @@ -192,18 +199,23 @@ async function applyRedirects(v4Space: Scanned, v5Space: Scanned) { const spaces = src.startsWith('/docs') && !src.startsWith('/docs/cookbook') - ? [v4Space] + ? [v5Space] : [v4Space, v5Space]; for (const space of spaces) { - applyRedirectToSpace(space, src, dest); + applyRedirectToSpace(space, src, dest, v5Space); } } } -function applyRedirectToSpace(space: Scanned, src: string, dest: string) { +function applyRedirectToSpace( + space: Scanned, + src: string, + dest: string, + httpSpace: Scanned +) { if (!src.includes(':')) { - const meta = space.urls.get(dest); + const meta = httpSpace.urls.get(dest); if (meta) space.urls.set(src, meta); return; } @@ -213,7 +225,7 @@ function applyRedirectToSpace(space: Scanned, src: string, dest: string) { // Expand by swapping prefixes against known URLs. const srcPrefix = src.slice(0, src.indexOf('/:')); const destPrefix = dest.slice(0, dest.indexOf('/:')); - for (const [url, meta] of [...space.urls]) { + for (const [url, meta] of [...httpSpace.urls]) { if (url.startsWith(`${destPrefix}/`)) { space.urls.set(srcPrefix + url.slice(destPrefix.length), meta); } @@ -271,7 +283,7 @@ function getFrontmatterRefs(raw: string): string[] { /** * Validate frontmatter `related` and `prerequisites` references. These are - * version-relative: a /docs/... reference on a v5 page must exist in the v5 + * version-relative: a /docs/... reference on a v4 page must exist in the v4 * content tree (matching how the page's links resolve when rendered). */ function checkFrontmatterRefs( @@ -303,10 +315,10 @@ function checkFrontmatterRefs( async function checkLinks() { const [v4Pages, v5Pages, worldsV4Pages, worldsV5Pages, shared] = await Promise.all([ + loadPages(v4Source), loadPages(source), - loadPages(v5Source), + loadPages(v4WorldsSource), loadPages(worldsSource), - loadPages(v5WorldsSource), getSharedUrls(), ]); @@ -338,9 +350,9 @@ async function checkLinks() { checkRelativePaths: 'as-url', }), // World pages resolve links version-relative, exactly like docs pages: - // v4 world pages against the v4 space, v5 world pages against the v5 + // v5 world pages against the v5 space, v4 world pages against the v4 // space (where unversioned /docs and /worlds hrefs are render-rewritten - // into the /v5 view). + // into the /v4 view). validateFiles(toFileObjects(worldsV4Pages), { scanned: v4Space, markdown, diff --git a/docs/source.config.ts b/docs/source.config.ts index d1245665da..be6b784dfb 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -42,7 +42,7 @@ export const v5docs = defineDocs({ }, }); -// Canonical World docs rendered at /worlds/* (v4/current) and /v5/worlds/* +// Canonical World docs rendered at /worlds/* (v5/current) and /v4/worlds/* // (the docs trees only keep the Deploying overview; world pages live outside // the docs, versioned with the same v4/v5 strategy). export const worldsV4Docs = defineDocs({ From a08bcedd7e8bd2131be405f2907d2959c499e3d1 Mon Sep 17 00:00:00 2001 From: Peter Wielander <peter.wielander@vercel.com> Date: Fri, 21 Aug 2026 13:01:50 -0700 Subject: [PATCH 2/3] docs: catch v5 up with main, and with the sealed log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep of everything that landed on main since the last content refresh, plus the spec-7 sealed log from #3634, which is not merged but is the default for v5. Spec 7 changes what a World declares, so the upgrade guide's Spec versions section is rewritten around `mintedSpecVersion()`. The accepted range widens back to two versions, because a deployment with `WORKFLOW_SEALED_LOG=0` stamps the one below, and both `SPEC_VERSION_CURRENT` and `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` become literals by another name for the purpose of declaring it: neither follows the setting. The new section says what a sealed log costs an implementer, which for most is nothing — a World allocating each position at the commit cannot leave a hole, so it never seals and never writes a `noop`. The obligation belongs to a World that pre-assigns positions, and the half that binds everyone is the reader's. `WORKFLOW_SEALED_LOG` is documented in runtime-tuning, taken verbatim from #3634 so whichever lands second is a no-op merge. The deep reference sections that PR writes, in event-sourcing and the World allocation contract, are left to it. Two optional World members shipped after the guide was written and were missing everywhere: - `events.createBatch` — in the World reference (interface and contract note), the upgrade guide's optional surface, and the skill. Implementing it is the declaration, so the note leads with atomicity per attempt and what a World must reject. - `runs.waitForTerminalStatus` — same three places. It is also what makes `await run.returnValue` a long poll rather than an interval, which is user-facing, so What's new gets it too. What's new also gains the batched suspension write and the pre-assigned positions behind it, both under faster-and-cheaper runs where they belong: neither has any API surface. Not addressed: `workflow.step.execute.duration`, the OpenTelemetry histogram added in #3526, has nowhere to go. The docs have no metrics reference at all, and inventing one for a single metric is a bigger call than this sweep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- .../docs/v5/configuration/runtime-tuning.mdx | 10 ++++++ docs/content/docs/v5/whats-new.mdx | 5 +++ docs/content/worlds/v5/building-a-world.mdx | 9 ++++- docs/content/worlds/v5/upgrading-to-v5.mdx | 27 ++++++++++---- skills/migrating-world-v4-to-v5/SKILL.md | 35 ++++++++++++++----- 5 files changed, 69 insertions(+), 17 deletions(-) diff --git a/docs/content/docs/v5/configuration/runtime-tuning.mdx b/docs/content/docs/v5/configuration/runtime-tuning.mdx index 2c03c5155b..afa02e0d9e 100644 --- a/docs/content/docs/v5/configuration/runtime-tuning.mdx +++ b/docs/content/docs/v5/configuration/runtime-tuning.mdx @@ -122,6 +122,16 @@ For example, a workflow can run a 10-minute inline step even with `WORKFLOW_REPL - The check trades one failure for another. Most holes stand for an event that never happened, and replaying past those is correct. A hole standing for an event that did happen looks identical, and replaying past that one produces a run whose result is silently wrong. Failing is the recoverable side of that trade. - Set `0` to replay across holes instead. +### `WORKFLOW_SEALED_LOG` + +- Default: enabled +- New runs are created at the sealed-log spec version, in which the World's backend assigns each event its position *before* the write commits rather than letting concurrent writers race for one. Concurrent writes then never contend for a position, which is what makes a wide fan-out cheap. +- The price of assigning positions in advance is that a writer which claims one and then dies leaves a position no writer will ever fill. The backend closes such a position by writing a `noop` event into it once it can prove the position was abandoned, so a reader still sees the dense log it needs. Replay steps over a `noop` without delivering it to the workflow and without advancing the deterministic clock — its timestamp belongs to whichever reader sealed it, not to the run. +- Set `0` to put a deployment back on the previous scheme, where each position is allocated by the write that occupies it. Use this as the kill switch if position assignment turns out to be at fault for event-log problems. +- Existing runs are unaffected either way. A run's spec version is stamped once, at creation, and read from the run for the rest of its life, so flipping this changes only what *new* runs get, and a run in flight keeps the scheme it started on. Every build reads sealed logs regardless of the setting. +- A run created at the sealed-log version can only be replayed by a reader that knows to skip `noop` events. That is every runtime on this release train, but a runtime that pins its own accepted spec range separately — the Python runtime, for one — has to have caught up before it can read these runs. Switch this off in an environment where it has not. +- Only the Vercel World seals. The Local and Postgres Worlds allocate each position at the commit that occupies it, so they cannot leave a hole and never write a `noop`; the setting still moves the version they stamp, so the fleet stays on one spec. + ### `WORKFLOW_PRECONDITION_MAX_INPROCESS_RESTARTS` - Default: `3` diff --git a/docs/content/docs/v5/whats-new.mdx b/docs/content/docs/v5/whats-new.mdx index 62a4f5cbf2..2cd8a785bb 100644 --- a/docs/content/docs/v5/whats-new.mdx +++ b/docs/content/docs/v5/whats-new.mdx @@ -37,6 +37,10 @@ The largest change in v5 has no API surface: the runtime does far less work per **Resuming a hook takes one round trip instead of two.** `resumeHook()` writes the `hook_received` event and dispatches the queue message concurrently, with a `(runId, resumeId)` dedup constraint keeping the two writers converging on exactly one event. See [Resilient hook resumption](/docs/changelog/resilient-resume). +**A suspension's writes go out as one batch.** The `step_created` and `wait_created` events a suspension produces are folded into a single durable write with a per-event outcome, instead of one request each, and a fan-out's inline step bodies start straight off that commit rather than each claiming its step first. This engages on Worlds that implement the batch API and can be turned off with [`WORKFLOW_BATCH_TRANSITIONS=0`](/docs/configuration/worlds#workflow_batch_transitions). See [Batched event writes](/docs/changelog/batched-event-writes). + +**Concurrent writers no longer compete for a position in the log.** Each event's position used to be claimed by the write that filled it, so a wide fan-out serialized on that claim. Positions are now handed out ahead of the commit, which is what makes the fan-out above cheap. The cost is a position whose writer dies, which the backend closes with a `noop` event that replay steps over. Nothing about this is visible from workflow code; [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) is the kill switch, and existing runs keep the scheme they were created on. + **Payloads are compressed** before they are encrypted and sent to the API. Repetitive payloads compress heavily; AI token streams average around 80% smaller. That is less stored data and less to move over the network. On [Vercel Workflows](/worlds/vercel) these benefits compound to reduce compute costs by up to 80% for workflows made of many small steps, and storage cost by up to 70%, depending on the workload. Depending on your setup, you may see similar gains using self-hosted or third-party Worlds. @@ -147,6 +151,7 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and - **A run cannot be forked across environments.** `start()` stamps the environment it was called from onto the queue message, and a deployment refuses a delivery whose run was created in a different environment. Previously a preview client and a production deployment could each hold half of one run ID. - **An experimental QuickJS VM engine.** Set [`WORKFLOW_VM=quickjs`](/docs/configuration/runtime-tuning#workflow_vm) to run workflow functions in a QuickJS VM compiled to WebAssembly instead of `node:vm`, for platforms that do not provide `node:vm`. Replay semantics are identical, but the available globals are not: check the differences before switching an existing deployment. - **An opt-in WebSocket transport for event writes** on the Vercel World, via [`WORKFLOW_EVENTS_TRANSPORT=ws`](/docs/configuration/worlds). HTTP remains the default. +- **`await run.returnValue` waits instead of polling.** Reading a run's result long-polls the World until the run reaches a terminal status, rather than asking again on a fixed interval. A result is observed as soon as it exists, and an idle wait costs one open request instead of a request per tick. Worlds that do not implement the long poll keep the interval. - **An event arriving mid-replay no longer fails the run.** A hook resume or step completion landing while a replay is in flight used to be able to fail it with [`CORRUPTED_EVENT_LOG`](/docs/errors/corrupted-event-log). Writes now come back with the events the replay had not seen, and the event is held for whichever part of the workflow awaits it. A run only fails when the log is genuinely missing a position. ## Breaking changes diff --git a/docs/content/worlds/v5/building-a-world.mdx b/docs/content/worlds/v5/building-a-world.mdx index 398c0425ca..e7f0fe3b88 100644 --- a/docs/content/worlds/v5/building-a-world.mdx +++ b/docs/content/worlds/v5/building-a-world.mdx @@ -128,7 +128,12 @@ interface Storage { // Create an event for an existing run create(runId: string, data: CreateEventRequest, params?: CreateEventParams): Promise<EventResult>; - + + // Optional: append an ordered list of events in one durable write, with a + // per-event outcome for each. Implementing it is the declaration; there is + // no capability flag. + createBatch?(runId: string, events: BatchEventRequest[], params?: CreateEventBatchParams): Promise<EventBatchResult>; + list(params: ListEventsParams): Promise<PaginatedResponse<Event>>; listByCorrelationId(params: ListEventsByCorrelationIdParams): Promise<PaginatedResponse<Event>>; }; @@ -148,6 +153,8 @@ interface Storage { 2. Atomically update the affected entity (run, step, or hook) 3. Return both the created event and the updated entity +**Batch Writes:** `events.createBatch()` is optional, and implementing it is what declares it — the runtime folds a suspension's `step_created` and `wait_created` writes into batches only when the method exists, and otherwise takes the single-event path unchanged. Implement it with real atomicity per attempt, so a lost race leaves nothing behind, or do not implement it at all. The events land in request order at consecutive positions, and a concurrent writer may push the whole batch above the caller's view of the log; no skipped-event report accompanies the result, so a position-tracking caller compares the committed positions against what it expected and reloads. Reject the whole batch, with a request-level error, for `run_created`, `run_started`, `run_cancelled`, `hook_created`, `hook_disposed`, `attr_set`, for more events targeting one entity than a single write can express, and for a batch over your own size caps. The one legal same-entity pair is `step_created` followed by `step_started`, which creates the step born-running, and there the input must ride the `step_created`. + **Run Creation:** For `run_created` events, the `runId` parameter may be a client-provided string or `null`. When `null`, your World generates and returns a new `runId`. **Hook Tokens:** Hook tokens must be unique. If a `hook_created` event conflicts with an active Hook or a token still reserved after its run ended, return a `hook_conflict` event and include the owner's run ID as `eventData.conflictingRunId`. diff --git a/docs/content/worlds/v5/upgrading-to-v5.mdx b/docs/content/worlds/v5/upgrading-to-v5.mdx index 3ce2d8b0e8..efda40e93d 100644 --- a/docs/content/worlds/v5/upgrading-to-v5.mdx +++ b/docs/content/worlds/v5/upgrading-to-v5.mdx @@ -36,27 +36,36 @@ We're working on bringing back World compatibility tests and reporting on the [W ## Spec versions -A World declares the protocol version it speaks on `specVersion`, and that number is stamped on every run it creates. Declare `SPEC_VERSION_CURRENT` from `@workflow/world`, not a literal: +A World declares the protocol version it speaks on `specVersion`, and that number is stamped on every run it creates. Declare `mintedSpecVersion()` from `@workflow/world`, not a literal: {/* @skip-typecheck - partial World, the other members are elided */} ```typescript -import { SPEC_VERSION_CURRENT } from '@workflow/world'; +import { mintedSpecVersion } from '@workflow/world'; export function createWorld(): World { return { - specVersion: SPEC_VERSION_CURRENT, + specVersion: mintedSpecVersion(), // ... }; } ``` -In v4 the runtime required that number to equal its own current version exactly. In v5 it checks the declaration against a range, `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]`, before it creates or replays anything, and refuses a World outside it with an error naming both the range and what your World declared. +In v4 the runtime required that number to equal its own current version exactly. In v5 it checks the declaration against a range before it creates or replays anything, and refuses a World outside it with an error naming both the range and what your World declared. The floor is the version that introduced [slot-numbered event IDs](#event-id-allocation), because a World below it allocates IDs the runtime cannot read positions out of, and admitting one would only move the failure from startup into the middle of a run. The ceiling is the highest version this runtime can read. -The two bounds are the same version today, so exactly one is accepted. That is a consequence of [event ID allocation](#event-id-allocation) being a requirement rather than an option: a World declaring anything lower allocates IDs the runtime cannot read positions out of, and admitting it would only move the failure from startup into the middle of a run. The check is written as a range because the constants answer different questions and come apart while a version bump is staged — the ceiling rises when the runtime learns to read the next version, the floor when that version becomes the one Worlds stamp. +`mintedSpecVersion()` is a function rather than a constant because the version a World stamps is a deployment-level choice: it answers with the sealed-log version by default, and with the slot-identity version when [`WORKFLOW_SEALED_LOG=0`](/docs/configuration/runtime-tuning#workflow_sealed_log) opts new runs out. Both sit inside the accepted range, so either answer is a valid declaration. Reading it per `createWorld()` call rather than once at module load is what lets a single process create Worlds in both modes. -Using the constant is what keeps the check passing across upgrades. It moves with the `@workflow/world` version your package resolves, so a bump raises your declaration and the runtime's floor together, while a hard-coded number leaves your World a version behind the next bump and gets it rejected by the runtime it ships alongside. This is worth re-checking if you followed earlier guidance: `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` names the version that introduced slot-numbered IDs and is equal to `SPEC_VERSION_CURRENT` today, but declaring it pins you to a literal by another name. `@workflow/world-vercel` declared it and now declares the current version instead. Keep `@workflow/world` in the same release channel as the `workflow` version your users install. +Calling it is also what keeps the check passing across upgrades, since it moves with the `@workflow/world` version your package resolves. A hard-coded number leaves your World a version behind the next bump and gets it rejected by the runtime it ships alongside — and that includes the constants: `SPEC_VERSION_CURRENT` and `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` are literals by another name for this purpose, since neither follows the sealed-log setting. Keep `@workflow/world` in the same release channel as the `workflow` version your users install. -Runs carry a spec version too, and a run keeps the version it was created under for its whole life. Read the stamped version off the run rather than assuming every run matches what your World declares today. Bumping the constant does not reach runs already in your store: their version is persisted, every version test in the runtime is a lower bound, and a run's event ID scheme is resolved from what is stored. +### Sealed logs and `noop` events + +The sealed-log version exists for a World whose store makes allocating a position at the commit a contention bottleneck. Such a World may hand positions out from a per-run counter *before* the commit, so concurrent writers never race for one, and then restore density at read time by writing a `noop` event into any position it can prove was abandoned. A `noop` occupies its position and means nothing: replay steps over it without delivering it and without advancing the deterministic clock. + +Two consequences for an implementation: + +- **If you allocate at the commit, you are already compliant** and have nothing to build. No write can leave a position empty, so you have no holes to seal and will never emit a `noop`. `@workflow/world-local` and `@workflow/world-postgres` are in this position. +- **What the version actually gates is the reader.** A run stamped at the sealed-log version can only be replayed by a reader that knows to skip `noop`. That is every runtime on this release train, but a runtime pinning its own accepted range separately — the Python runtime, for one — has to have caught up first. `WORKFLOW_SEALED_LOG=0` is the switch for an environment where it has not. + +Runs carry a spec version too, and a run keeps the version it was created under for its whole life. Read the stamped version off the run rather than assuming every run matches what your World declares today. Changing what you stamp does not reach runs already in your store: their version is persisted, every version test in the runtime is a lower bound, and a run's event ID scheme is resolved from what is stored. ## Interface changes @@ -101,6 +110,8 @@ The scheme exists for what a reader can conclude from a log it just fetched: pos - **Bump and report.** `events.create()` params carry `eventCount`, so the expected position is `eventCount + 1`. When it is taken, do not reject the write: commit at the next free position and return the events you skipped on the success response. A stale count is the normal case for a parallel fan-out, and rejecting it would serialize writes the runtime deliberately issues concurrently. - **Allocate at the commit.** Take the position in the same operation that appends the event, not earlier. This is what makes a reader's log a prefix of the run's log rather than a prefix with a hole in it: nothing can land behind a position a reader has already passed. A World that mints a position in a request handler and commits later breaks the property every replay depends on, and is the only kind that still has a use for a stale-write rejection. + The one sanctioned exception is the sealed log, which is what the [sealed-log spec version](#sealed-logs-and-noop-events) is for: a World may pre-assign positions if it also seals the holes that leaves. Everything below assumes you allocate at the commit, which is the simpler contract and the one both first-party non-Vercel Worlds keep. + [Event ID Allocation](/worlds/building-a-world#event-id-allocation) carries the full rules, and [Event IDs](/docs/how-it-works/event-sourcing#event-ids) covers what the format means for anything that reads an ID back. One consequence is specific to an upgrade, and it is the thing to plan around. @@ -116,6 +127,8 @@ None of this is required. Each entry is a hook the runtime uses if your World pr | Member | What it buys | | --- | --- | | `capabilities` | Advertises `hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, and `maxConcurrency`. See the contract note above about failing closed. Event ID allocation is *not* in here: it is a requirement, not a capability. | +| `events.createBatch` | Appends an ordered list of events in one durable write, with a per-event outcome for each. Implementing the method *is* the declaration: the runtime folds a suspension's `step_created` / `wait_created` writes into batches only when it exists, and otherwise takes the single-event path unchanged. Implement it with real atomicity per attempt — a lost race must leave nothing behind — or leave it out. See [Batched event writes](/docs/changelog/batched-event-writes). | +| `runs.waitForTerminalStatus` | Long-polls until a run reaches a terminal status. `await run.returnValue` uses it when present, instead of polling on an interval. | | `analytics` | A metadata-only read namespace for observability surfaces. Payload-bearing reads stay on `runs`, `steps`, `events`, and `hooks`. | | `runs.experimentalSetAttributes` | Backs `setAttributes()` from application code. Without it, run attributes are unavailable. | | `runs.cancelMany` | Bulk cancellation: up to 500 unique run IDs per request (`BULK_CANCEL_MAX_RUN_IDS`), an optional `cancelReason` of at most 512 characters, and a per-run outcome for every ID. Without it, the runtime falls back to bounded-concurrency individual cancels. | diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md index 63eb338539..99ee013c8a 100644 --- a/skills/migrating-world-v4-to-v5/SKILL.md +++ b/skills/migrating-world-v4-to-v5/SKILL.md @@ -3,7 +3,7 @@ name: migrating-world-v4-to-v5 description: Upgrades a custom Workflow SDK World implementation from the v4 spec to v5. Use when a package implements the `World` interface from `@workflow/world` and is moving to 5.x — event IDs that are ULIDs rather than slot positions, `Event id is not slot-numbered` at replay time, a `specVersion` the runtime refuses, `writeToStream` / `closeStream` / `readFromStream` as top-level World methods, `steps.get` or `events.listByCorrelationId` without a `runId`, a `'step'` queue kind or `__wkf_step_*` topics, a `preconditionGuard` capability, or a `createLocalWorld` / `createVercelWorld` factory. metadata: author: Vercel Inc. - version: '0.1.0' + version: '0.2.0' --- # Migrating a World from the v4 spec to v5 @@ -75,24 +75,36 @@ Then return the skipped span whenever the committed slot exceeds `eventCount + 1 ## Step 2 — declare the spec version -`specVersion` is the protocol version the World implements, and the number stamped on every run it creates. Import the constant: +`specVersion` is the protocol version the World implements, and the number stamped on every run it creates. Call the helper rather than importing a constant: ```ts -import { SPEC_VERSION_CURRENT } from '@workflow/world'; +import { mintedSpecVersion } from '@workflow/world'; export function createWorld(): World { return { - specVersion: SPEC_VERSION_CURRENT, + specVersion: mintedSpecVersion(), // ... }; } ``` -The runtime checks this against `[SPEC_VERSION_CURRENT, SPEC_VERSION_MAX_SUPPORTED]` before it creates or replays anything and refuses a World outside that range, naming both the range and what the World declared. The floor sits where it does because slot-numbered IDs are required: a World declaring less allocates IDs the runtime cannot read positions out of. +The runtime checks the declaration against a supported range before it creates or replays anything, and refuses a World outside it, naming both the range and what the World declared. The floor is the version that introduced slot-numbered IDs, because a World below it allocates IDs the runtime cannot read positions out of. The ceiling is the highest version the runtime can read. -Replace a literal with the constant even when the numbers currently agree. A literal leaves the World a version behind the next bump and gets it rejected by the runtime it ships alongside. `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` is a literal by another name for this purpose: it names the version that introduced slots rather than the version to declare. +`mintedSpecVersion()` is a function because the version a World stamps is a deployment choice: it answers with the sealed-log version by default, and one below it when `WORKFLOW_SEALED_LOG=0` opts new runs out. Both are inside the accepted range. Call it inside `createWorld()` rather than caching it at module load, so one process can create Worlds in both modes. -Runs carry their own spec version, persisted at creation, and keep it for life. Read it off the run rather than assuming every run matches what the World declares today. +Report any of these as findings rather than leaving them: + +- A hard-coded number. It leaves the World a version behind the next bump and gets it rejected by the runtime it ships alongside. +- `SPEC_VERSION_CURRENT` or `SPEC_VERSION_SUPPORTS_SLOT_IDENTITY` used as the declaration. Both are literals by another name here, since neither follows the sealed-log setting. +- Code that assumes every run matches what the World declares today. A run's version is persisted at creation and kept for life, so read it off the run. + +### The sealed log and `noop` + +The sealed-log version permits one alternative to allocating a position at the commit (step 1): hand positions out from a per-run counter *before* the commit, so concurrent writers never race for one, then restore density at read time by writing a `noop` event into any position provably abandoned. A `noop` occupies its position and means nothing — replay steps over it without delivering it and without advancing the deterministic clock. + +For most migrations this is a no-op, and say so rather than skipping it: **a World that allocates at the commit is already compliant** and will never emit a `noop`, because no write can leave a position empty. Only build the sealing half if the World pre-assigns positions, and then it must also never return a page with an interior hole — return the dense prefix below the hole and let the next page pick up once the position resolves. + +The half that always applies is the reader's: `noop` is not user-creatable, never sent to `events.create()`, and only the World's own read path may write one. If the World validates event types on read, make sure `noop` parses. ## Step 3 — apply the mechanical rewrites @@ -143,9 +155,14 @@ These change no signature. A World ported by types alone compiles and then behav None of this is required, and the runtime routes around each absence. Report what the World is missing rather than implementing everything unprompted. -`capabilities` (`hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, `maxConcurrency`), `analytics`, `runs.experimentalSetAttributes`, `runs.cancelMany`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. +`capabilities` (`hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, `maxConcurrency`), `analytics`, `runs.experimentalSetAttributes`, `runs.cancelMany`, `runs.waitForTerminalStatus()`, `events.createBatch()`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. + +Four are worth raising unprompted because their absence is felt rather than reported: -Two are worth raising unprompted because their absence is felt rather than reported. Without `getRuntimeDeadline()` the inline replay budget is a flat two minutes, so a host with a long function timeout does less work per invocation than it could. Without `close()`, CLI commands and short-lived processes cannot exit cleanly without `process.exit()`. +- Without `getRuntimeDeadline()` the inline replay budget is a flat two minutes, so a host with a long function timeout does less work per invocation than it could. +- Without `close()`, CLI commands and short-lived processes cannot exit cleanly without `process.exit()`. +- Without `events.createBatch()`, a suspension's `step_created` and `wait_created` writes each take their own round trip. Implementing the method *is* the declaration — there is no flag — so it must be atomic per attempt, leaving nothing behind on a lost race, or be left out entirely. It cannot express `run_created`, `run_started`, `run_cancelled`, `hook_created`, `hook_disposed` or `attr_set`, and a World rejects the whole batch when one arrives. +- Without `runs.waitForTerminalStatus()`, `await run.returnValue` falls back to polling on an interval instead of long-polling. ## Step 6 — the rollout From 8d9081bacba3160215bc38b2e54fd0c039522520 Mon Sep 17 00:00:00 2001 From: Peter Wielander <peter.wielander@vercel.com> Date: Tue, 25 Aug 2026 13:38:00 -0700 Subject: [PATCH 3/3] docs: fold the World migration skill description into a block scalar #3779 fixed the app migration skill, whose description ended with `mode: 'client'`. The colon-space made the plain scalar parse as a nested mapping, and the skills CLI dropped the file: `Found 5 skills`, and `--skill migrating-workflow-v4-to-v5` answered `No matching skills found`, which is the command whats-new.mdx tells readers to run. The World skill's description has no colon-space today, so it parses. It is also 590 characters of backticked identifiers, and any future edit naming a field with its value reintroduces the break. Fold it the same way now, while the value is known good. Verified with the yaml package that the folded scalar is byte-identical, and with `skills add . --skill migrating-world-v4-to-v5` that the CLI still finds all six. Signed-off-by: Peter Wielander <peter.wielander@vercel.com> --- skills/migrating-world-v4-to-v5/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md index 3c1aa683a1..fbc2d61f2d 100644 --- a/skills/migrating-world-v4-to-v5/SKILL.md +++ b/skills/migrating-world-v4-to-v5/SKILL.md @@ -1,9 +1,10 @@ --- name: migrating-world-v4-to-v5 -description: Upgrades a custom Workflow SDK World implementation from the v4 spec to v5. Use when a package implements the `World` interface from `@workflow/world` and is moving to 5.x — event IDs that are ULIDs rather than slot positions, `Event id is not slot-numbered` at replay time, a `specVersion` the runtime refuses, `writeToStream` / `closeStream` / `readFromStream` as top-level World methods, `steps.get` or `events.listByCorrelationId` without a `runId`, a `'step'` queue kind or `__wkf_step_*` topics, a `preconditionGuard` capability, or a `createLocalWorld` / `createVercelWorld` factory. +description: >- + Upgrades a custom Workflow SDK World implementation from the v4 spec to v5. Use when a package implements the `World` interface from `@workflow/world` and is moving to 5.x — event IDs that are ULIDs rather than slot positions, `Event id is not slot-numbered` at replay time, a `specVersion` the runtime refuses, `writeToStream` / `closeStream` / `readFromStream` as top-level World methods, `steps.get` or `events.listByCorrelationId` without a `runId`, a `'step'` queue kind or `__wkf_step_*` topics, a `preconditionGuard` capability, or a `createLocalWorld` / `createVercelWorld` factory. metadata: author: Vercel Inc. - version: '0.3.0' + version: '0.3.1' --- # Migrating a World from the v4 spec to v5