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. Use the version picker to see the old layout on the v4 version of this page. + **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 adbe3c1e45..f18a215de2 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 @@ -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. @@ -146,7 +150,8 @@ All three first-party Worlds now implement it: Vercel accepts up to 30 days, and - **A misrouted delivery no longer fails a run.** Runs are pinned to the deployment that created them. A delivery that arrives at a different deployment is now re-routed to the pinned one with backoff instead of failing, and only gives up with the new [`DEPLOYMENT_MISMATCH`](/docs/errors/deployment-mismatch) error once the recovery budget is spent. Nothing executes on the wrong deployment while this happens. In 4.x the same situation surfaced as an unexplained decryption failure. - **A run cannot be forked across environments.** `start()` stamps the environment it was called from onto the queue message, and a deployment refuses a delivery whose run was created in a different environment. Previously a preview client and a production deployment could each hold half of one run ID. - **An experimental QuickJS VM engine.** Set [`WORKFLOW_VM=quickjs`](/docs/configuration/runtime-tuning#workflow_vm) to run workflow functions in a QuickJS VM compiled to WebAssembly instead of `node:vm`, for platforms that do not provide `node:vm`. Replay semantics are identical, but the available globals are not: check the differences before switching an existing deployment. -- **An opt-in WebSocket transport for event writes** on the Vercel World, via [`WORKFLOW_EVENTS_TRANSPORT=ws`](/docs/configuration/worlds). HTTP remains the default. +- **Event writes ship over a WebSocket** on the Vercel World, instead of one HTTP request each. This is the default; set [`WORKFLOW_EVENTS_TRANSPORT=http`](/worlds/vercel#workflow_events_transport) to opt out, which only that exact value does, so a typo fails toward the socket rather than pinning a deployment to HTTP. Tracing is unaffected: each write still emits an `http POST` client span, synthesized around the frame, with the transport on `workflow.events.transport`. +- **`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 e71cdb66ee..3648c107f7 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; - + + // 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; + list(params: ListEventsParams): Promise>; listByCorrelationId(params: ListEventsByCorrelationIdParams): Promise>; }; @@ -149,6 +154,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 2ddadc5cab..146e418726 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, and the floor rises 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. 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, such as the Python runtime, has to catch 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 @@ -84,6 +93,14 @@ These do not change any signature, so an implementation ported by types alone wi **A stale replay no longer has to be refused.** v5 shipped with a `preconditionGuard` capability for a World that rejected an event creation whose snapshot was behind the log. It is gone, and nothing replaced it: allocating positions at the commit means a reader's log is a prefix rather than a prefix with a hole, replay is deterministic on a prefix, and a write reports the events it was pushed past. As a result, a stale replay costs a merge instead of a rejection. If you implemented the guard, you can delete it. `PreconditionFailedError` and the runtime's handling of it remain for a World that allocates positions away from the commit (see [Event ID allocation](#event-id-allocation)); no World in the SDK throws it. +**Process-wide state has to live on `globalThis`.** A module's top-level `const` or `let` is one instance per *module instance*, not per process, and a host server routinely holds several. Next.js compiles its server output into independent module graphs, and a bundled module is compiled into each one with its own module-scope bindings. Since `@workflow/world-vercel` moved from external to bundled, every module-scope singleton in it quietly became one per layer. The visible casualty was the WebSocket events transport: the queue consumer registered its channel in the route copy's registry while the write path looked it up in the instrumentation copy's empty one, so every event silently fell back to HTTP for the life of the process. + +This bites rather than merely wasting memory because the runtime caches the *World object* process-wide while any module state that World closes over stays layer-local. Anything your World reaches at request time therefore has to be process-wide too: connection pools, transport registries, ID factories, caches, and log-once latches. Hold them in one object behind [`globalSingleton()`](https://github.com/vercel/workflow/blob/main/packages/utils/src/global-singleton.ts) from `@workflow/utils`, which keys the object off a `Symbol.for` on `globalThis`. A `let` cannot be shared by reference, so a latch becomes a field on that object. + +**One World per process.** The workflow entrypoint's queue handler is now built from the runtime World that `getWorld()` returns, rather than from `getWorldHandlers()`. A stateful World is no longer instantiated twice in one process, so it stops getting duplicate connection pools and duplicate queue workers. If you added your own de-duplication to work around that, it is now redundant, though harmless if it keys on process-wide state. + +**Your own transport is your own business, except for the tracing.** How a World ships events to its backend is unconstrained: `@workflow/world-vercel` defaults to a WebSocket and falls back to HTTP. What is constrained is what a reader of a trace sees. A non-HTTP transport still has to emit the per-event client span that an HTTP write would, or the per-event view of a run silently disappears. See [`WORKFLOW_EVENTS_TRANSPORT`](/worlds/vercel#workflow_events_transport) for the span shape and attributes the Vercel World uses, including a separate span for the handshake. + **Event creation can return a delta.** `events.create()` may return events alongside the one it created, in `events` with a matching `cursor` and `hasMore`. The runtime uses this to skip a follow-up `events.list` round trip on `run_started`, on step-terminal writes that carried a `sinceCursor`, and on `hook_received` writes that carried `preloadEvents`. All three are advisory: a World that returns only the created event stays correct and pays one more round trip. ## Event ID allocation @@ -101,6 +118,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 +135,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, so a lost race leaves 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. | @@ -137,7 +158,7 @@ Compiling workflow files changed independently of the storage contract. | Change | What to do | | --- | --- | | The `client` SWC transform mode was removed | It merged into `step` mode. Integrations passing `mode: 'client'` pass `mode: 'step'`. | -| `stepEntrypoint` removed from `workflow/runtime` | Steps execute through the combined workflow handler the framework integrations generate. Custom hosts use `getWorldHandlers()`. | +| `stepEntrypoint` removed from `workflow/runtime` | Steps execute through the combined workflow handler the framework integrations generate. A custom host builds that handler from the World `getWorld()` returns. `getWorldHandlers()` still exists for the build-time view of a World, which is what a build integration wants; it is no longer how a request-time handler is assembled. | | Step, workflow and webhook bundles are ESM | Generated output moved from CJS to ESM, with a `createRequire` banner for CJS dependencies. The VM-executed workflow bundle stays CJS. The CLI's standalone output is renamed to match: `flow.mjs`, `webhook.mjs`, and `__step_registrations.mjs` in place of `flow.js`, `webhook.js`, and `step.js`. Consumers import the namespace rather than a default. | | `workflow/internal/private` and `@workflow/core/private` removed | These were never public API. The compiler no longer emits imports from them, so regenerate build output rather than importing them yourself. | | Duplicate step or workflow IDs fail the build | 4.x resolved collisions across non-exported workspace files last-write-wins. A build integration that derived IDs from a partial path may now produce build failures. | 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({ diff --git a/skills/migrating-world-v4-to-v5/SKILL.md b/skills/migrating-world-v4-to-v5/SKILL.md index 63eb338539..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.1.0' + version: '0.3.1' --- # Migrating a World from the v4 spec to v5 @@ -25,8 +26,9 @@ Before editing, establish and report each of these: 5. **Which optional members exist.** Grep for `capabilities`, `analytics`, `getRuntimeDeadline`, `getEnvironment`, `createRunId`, `describeRun`, `getEncryptionKeyForRun`, `resolveLatestDeploymentId`, `cancelMany`, `experimentalSetAttributes`. 6. **Whether it provisions step topics.** Grep for `'step'`, `__wkf_step`, `stepQueue`. 7. **Whether it rejects stale writes.** Grep for `PreconditionFailedError`, `preconditionGuard`, `stateUpdatedAt`, `stateEventCount`, `stateCursor`, `412`. -8. **How it is tested.** Grep for `@workflow/world-testing` and `createTestSuite`. A World without the conformance suite wired up gets it in this migration. -9. **Where its runs live.** Ask, or determine from the deployment model, whether a single deployment serves every run or a run is pinned to the deployment that created it. This decides the rollout in step 6 and cannot be read out of the code. +8. **Where its process-wide state lives.** Grep the World's modules for top-level `const`/`let` holding a pool, client, socket, registry, cache, ULID factory, or a log-once boolean. Note every one: these are correct in a `require()`d package and wrong in a bundled one. +9. **How it is tested.** Grep for `@workflow/world-testing` and `createTestSuite`. A World without the conformance suite wired up gets it in this migration. +10. **Where its runs live.** Ask, or determine from the deployment model, whether a single deployment serves every run or a run is pinned to the deployment that created it. This decides the rollout in step 7 and cannot be read out of the code. Report anything not applicable rather than skipping it silently. @@ -75,24 +77,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 @@ -129,7 +143,28 @@ A correlation ID identifies a step, hook or wait within its run, not across runs World selection is static, resolved into host bundles by the build rather than looked up dynamically at runtime. Verify the World still resolves after the upgrade and that its module graph survives bundling. A World that relied on a runtime `require` of a path computed from an environment variable will not be found. -## Step 4 — the contract changes +## Step 4 — move process-wide state onto `globalThis` + +This one is silent. It type-checks, it passes tests in isolation, and it fails only in a host that bundles the World. + +A module's top-level `const` or `let` is one instance per *module instance*, not per process. Next.js compiles its server output into independent module graphs, and a bundled module is compiled into each with its own module-scope bindings. The runtime caches the *World object* process-wide, but module state that World closes over stays layer-local, so anything the World reaches at request time has to be process-wide too. + +Take every finding from intake item 8 and hold it in one object: + +```ts +import { globalSingleton } from '@workflow/utils'; + +const state = globalSingleton('@my-org/world-foo//connections', 1, () => ({ + pool: undefined as Pool | undefined, + warnedOnce: false, +})); +``` + +`globalSingleton` keys the object off a `Symbol.for` on `globalThis`, so every copy of the module gets the same one. The second argument is a shape version: bump it when the object's shape changes incompatibly, so an older copy of the package sharing the process keeps its own state instead of misreading yours. A `let` cannot be shared by reference, which is why a log-once latch becomes a field rather than staying a variable. + +Report this even when nothing needed changing, and name the failure it prevents. In `@workflow/world-vercel` the casualty was the WebSocket events transport: the queue consumer registered its channel in one layer's registry and the write path looked it up in another layer's empty one, so every event fell back to HTTP for the life of the process, with nothing logged and no test failing. + +## Step 5 — the contract changes These change no signature. A World ported by types alone compiles and then behaves incorrectly. @@ -139,21 +174,26 @@ These change no signature. A World ported by types alone compiles and then behav - **Capabilities fail closed.** An unadvertised capability costs performance, never correctness, so a partial World stays correct while it catches up. The reverse is not true: advertising something not enforced removes a guard the runtime was relying on. Set a flag only once the behavior is implemented. - **Event creation may return a delta.** `events.create()` may return events alongside the one it created, in `events` / `cursor` / `hasMore`. Beyond the bump-and-report case in step 1, the runtime uses this to skip a follow-up `events.list` on `run_started`, on step-terminal writes carrying `sinceCursor`, and on `hook_received` writes carrying `preloadEvents`. All three are advisory: returning only the created event stays correct and pays one more round trip. -## Step 5 — optional surface worth adopting +## Step 6 — optional surface worth adopting None of this is required, and the runtime routes around each absence. Report what the World is missing rather than implementing everything unprompted. -`capabilities` (`hookRetention.active`, `hookResumeDedup`, `deploymentAffinity`, `maxConcurrency`), `analytics`, `runs.experimentalSetAttributes`, `runs.cancelMany`, `getRuntimeDeadline()`, `getEnvironment()`, `createRunId()`, `describeRun()`, `getEncryptionKeyForRun()`, `resolveLatestDeploymentId()`, `close()`. +`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 +## Step 7 — the rollout Warn the user, in the migration report, before they deploy: **Runs already in the store cannot be replayed by the new code.** A ULID-numbered run is not readable as positions, and the runtime refuses it rather than guessing. There is no mixed-scheme mode and no per-run fallback. -Which follows depends on intake item 9. Where a run executes on the deployment that created it, this resolves itself: those runs finish on the build that started them and never meet the new code. Where a single deployment serves every run, the in-flight ones must be drained on the 4.x build before the v5 World is deployed, or they will fail. +Which follows depends on intake item 10. Where a run executes on the deployment that created it, this resolves itself: those runs finish on the build that started them and never meet the new code. Where a single deployment serves every run, the in-flight ones must be drained on the 4.x build before the v5 World is deployed, or they will fail. ## Verification @@ -188,8 +228,9 @@ Fail the migration if any of these are true: - [ ] a `streams.*` call kept the v4 argument order (name before runId) - [ ] `steps.get` or `listByCorrelationId` is reachable without a run ID - [ ] a capability is advertised whose behavior is not implemented +- [ ] a pool, client, socket, registry, cache, ID factory, or log-once latch is still held at module scope - [ ] `@workflow/world-testing` is not wired up, or its results were not reported -- [ ] the rollout warning in step 6 was not given +- [ ] the rollout warning in step 7 was not given - [ ] the build, typecheck or test results were not actually run and reported ## Required output shape @@ -198,6 +239,7 @@ Fail the migration if any of these are true: ## Summary ## Event ID Allocation ## Interface Changes +## Process-Wide State ## Contract Changes ## Optional Surface Not Implemented ## Rollout @@ -206,8 +248,9 @@ Fail the migration if any of these are true: ``` - `## Event ID Allocation` states where the slot is now computed, what settles a race for it, and how a bumped write reports the span it skipped. Quote the code. +- `## Process-Wide State` lists every module-scope singleton found and where it moved, or states that there were none. - `## Optional Surface Not Implemented` lists what was left out and what each absence costs, so the user can decide. -- `## Rollout` carries the step 6 warning and which of its two cases applies to this deployment. +- `## Rollout` carries the step 7 warning and which of its two cases applies to this deployment. ## Reference