关于 migrating-workflow-v4-to-v5
这是一份供应用团队将 Workflow SDK 从 4.x 升级到 5.0 的迁移指南。它梳理破坏性变更并提供针对性改写,同时指出工作流和步骤主体通常无需重写。
主要功能
- 01依赖升级指引
- 02运行时 API 迁移规则
- 03World 接口变更说明
- 04流与事件 API 改写
- 05框架集成导入路径更新
适用场景
- 01评估应用从 4.x 迁移到 5.0
- 02定位已移除的运行时导出项
- 03更新自定义 World 和流调用
- 04检查框架及编译器集成变更
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ vercel/ workflow --skill migrating-workflow-v4-to-v5安装后在 Claude Code 中输入/migrating-workflow-v4-to-v5确认已加载。
详细介绍
将 Workflow SDK 4.x 迁移到 5.0
Workflow SDK 5.0 保留了 4.x 的编程模型。"use workflow" / "use step"、start()、getRun()、钩子、webhook、流、sleep()、重试和事件日志均未改变,因此大多数应用代码可以直接编译。
破坏性变更集中在三个方面:
- 运行时入口点(
workflow/api、workflow/runtime)——移除了两个导出。 World接口——仅当应用实现自定义 World 或直接调用getWorld()时才会涉及。- 构建集成——
@workflow/nest子路径,以及从未公开的私有编译器子路径。
不要重写工作流或步骤的主体。如果你发现自己在重构业务逻辑,那就超出了此次迁移的范围。
前期检查
编辑之前,请确认:
- 安装了哪些软件包。 检查
package.json中的workflow和所有@workflow/*依赖项。 - 应用是否使用运行时。 搜索
getWorld、createWorld、getWorldHandlers、writeToStream、readFromStream、closeStream、listStreamsByRunId、getStreamChunks、world.steps、listByCorrelationId、runStep、stepEntrypoint、internal/private、core/private。 - 应用是否实现自定义 World。 搜索
implements World、: World、createLocalWorld、createVercelWorld、startWorkflowWorld。 - 使用了哪种框架集成。
@workflow/next、@workflow/nest、@workflow/nitro、@workflow/sveltekit、@workflow/vite、@workflow/nuxt、@workflow/astro或 CLI。 - 是否使用了
hook.getConflict()。 搜索getConflict。 - 是否在工作流外读取钩子元数据。 搜索
getHookByToken和resumeHook,然后搜索其返回值上的.metadata。 - 应用是否直接调用编译器。 搜索
mode: 'client'、transformSync、swc-plugin-workflow。只有自定义构建集成才会这样做。 - 是否使用了
experimental_setAttributes。 搜索experimental_setAttributes。
对于第 2–8 项中应用未使用的内容,请报告为“不适用”,而不要直接跳过。
步骤 1 — 升级依赖项
将每个 workflow 和 @workflow/* 依赖项升级到 ^5.0.0。这些软件包会同时发布,不能混用不同主版本——使用 5.x workflow 搭配 4.x @workflow/next 会导致构建失败。
{
"dependencies": {
"workflow": "^5.0.0",
"@workflow/next": "^5.0.0"
}
}
然后重新安装并重新构建,以便编译器重新生成工作流 / 步骤包以及 .well-known/workflow/v1/ 下的生成路由。切勿手动编辑生成的输出。
Node 要求不变:^18 || ^20 || ^22 || ^24。
步骤 2 — 应用机械式改写
仅在实际出现相应模式时应用每条规则。
getWorld() 和 createWorld() 变为异步
// v4
const world = getWorld();
// v5
const world = await getWorld();
这同样适用于 getWorldHandlers()。在 4.x 中使用 await 就已经是正确做法,因此可以在升级依赖项之前安全地进行此项修改。沿调用链向上添加 async,不要用 .then() 链包装。
移除 createLocalWorld() 和 createVercelWorld()
第一方 World 软件包现在只提供一个 createWorld() 工厂函数。参数保持不变——这只是重命名。
// v4
import { createLocalWorld } from '@workflow/world-local';
const world = createLocalWorld({ dataDir });
// v5
import { createWorld } from '@workflow/world-local';
const world = createWorld({ dataDir });
@workflow/world-vercel 中的 createVercelWorld 也同样适用。
从 workflow/api 中移除 runStep
直接调用步骤函数。编译器会将调用路由到步骤运行时。
// v4
import { runStep } from 'workflow/api';
const result = await runStep(chargeCard, [orderId]);
// v5
const result = await chargeCard(orderId);
从 workflow/runtime 中移除 stepEntrypoint
框架集成会自行生成步骤路由——删除仅用于调用 stepEntrypoint 的手写步骤路由。对于自定义主机,请改为通过 getWorldHandlers() 提供处理程序。
移除 workflow/internal/private 和 @workflow/core/private
这些子路径从未属于公开 API。移除相关导入;如果生成的构建输出仍引用它们,说明输出已过期——请重新安装并重新构建,而不是恢复这些导入。
流方法移至 world.streams.*,并将 runId 移到首位
| v4 | v5 |
|---|---|
world.writeToStream(name, runId, chunk) | world.streams.write(runId, name, chunk) |
world.writeToStreamMulti(name, runId, chunks) | world.streams.writeMulti(runId, name, chunks) |
world.closeStream(name, runId) | world.streams.close(runId, name) |
world.readFromStream(name, startIndex?) | world.streams.get(runId, name, startIndex?) |
world.getStreamChunks(name, runId, options?) | world.streams.getChunks(runId, name, options?) |
world.listStreamsByRunId(runId) | world.streams.list(runId) |
参数顺序已调换,因此只重命名会在不报错的情况下将流名称传到本应传入运行 ID 的位置。请在每个调用处交换参数。readFromStream 原本没有 runId 参数,而 streams.get 要求传入该参数,因此请将所属运行 ID 一路传递到调用处。
在工作流中使用 getWritable() 或读取 run.readable 的应用代码不受影响;这条规则仅适用于直接访问 World。
world.steps.get() 需要 runId
第一个参数原本是 string | undefined,现在是 string。请传入拥有该步骤的运行 ID。
// v4
const step = await world.steps.get(undefined, stepId);
// v5
const step = await world.steps.get(runId, stepId);
events.listByCorrelationId() 需要 runId
关联 ID 用于标识其所属运行中的步骤、钩子或等待项,而不是跨运行标识。查询限定在单次运行内,因此请传入拥有该 ID 的运行。
analytics.events.listByCorrelationId() 也接收相同的 runId,并且已弃用:要求传入 runId 后,它就是 analytics.events.list({ runId, correlationId }) 的特例,并将在下一个主版本中移除。请先添加 runId,然后趁着修改该文件,将调用改为 list。存储层的 events.listByCorrelationId() 并未弃用。
// v4
const events = await world.events.listByCorrelationId({ correlationId });
// v5
const events = await world.events.listByCorrelationId({ runId, correlationId });
hook.getConflict() 返回一个 Run
返回值现在是冲突运行的句柄,而不是 { runId }。conflict.runId 的读取结果仍然相同,因此现有代码可以继续工作,但可以删掉通过步骤查询运行的往返操作。
// v4
const conflict = await hook.getConflict();
if (conflict) {
const run = await fetchRun(conflict.runId); // "use step" wrapper around getRun()
return { dedupedTo: await run.returnValue };
}
// v5
const conflict = await hook.getConflict();
if (conflict) {
return { dedupedTo: await conflict.returnValue };
}
同一个句柄上还可以使用 await conflict.status 和 await conflict.cancel()。请勿删除 if (conflict) 空值检查——如果令牌已顺利认领,getConflict() 仍会返回 null。
hook.metadata 是一个 Promise
对于 getHookByToken() 和 resumeHook() 返回的钩子,metadata 现在是一个 getter,会返回经过记忆化的 Promise,与 run.returnValue 的形式相同。按令牌查找钩子只需读取一次;加载元数据可能会产生额外的网络往返,而只有等待它的代码才会承担这些开销。
// v4
const hook = await getHookByToken(token);
if (hook.metadata?.allowedUserId !== userId) {
return new Response('Forbidden', { status: 403 });
}
// v5
const hook = await getHookByToken(token);
const metadata = (await hook.metadata) as { allowedUserId?: string } | undefined;
if (metadata?.allowedUserId !== userId) {
return new Response('Forbidden', { status: 403 });
}
对于未存储元数据的钩子,等待它会返回 undefined,且不会产生 I/O,因此无需为 await 添加保护条件。{ ...hook } 和 JSON.stringify(hook) 不再包含 metadata,因此转发整个钩子对象的处理程序必须显式转发等待后获取的值。hook.runId、hook.token 和其他字段均未改变,而 world.hooks.getByToken() 仍返回原始 Hook 记录。在 TypeScript 中,旧形式会导致构建失败;在纯 JavaScript 中,hook.metadata.allowedUserId 会从 Promise 上读取到 undefined,因此上述检查会在不报错的情况下拒绝每个请求。
experimental_setAttributes 更名为 setAttributes
// v4
import { experimental_setAttributes } from 'workflow';
// v5
import { setAttributes } from 'workflow';
v5 已移除旧名称,因此必须进行此改写。ExperimentalSetAttributesOptions 同样已更名为 SetAttributesOptions。start() 上的 attributes 选项保持不变。
从 SWC 转换中移除 mode: 'client'
这只与自行调用编译器的自定义构建集成相关。client 模式已合并到 step 中;现在 step 会处理提升的变量引用和死代码消除。请传入 mode: 'step'。
NestLocalBuilder 已从 @workflow/nest 根目录移出
// v4
import { NestLocalBuilder } from '@workflow/nest';
// v5
import { NestLocalBuilder } from 'workflow/nest/builder';
NestVercelBuilder 位于 workflow/nest/vercel-builder。WorkflowModule 仍从 @workflow/nest 导入;此拆分可避免将构建工具链打包进运行时,因此不要从运行时代码会导入的模块重新导出该构建器。
步骤 3 — 标出需要决策的行为变更
这些不是代码修改。请报告所有适用项,不要默默“修复”它们。
- 追踪默认使用
linked模式。 每次工作流和步骤调用都有自己的追踪根,并通过 span 链接到入队位置和运行来源,而不是每次运行只有一条追踪。如果应用有按每次运行的单个追踪 ID 建立的仪表板、已保存查询或告警,请改用workflow.run.id属性,或设置WORKFLOW_TRACE_MODE=continuous以恢复 4.x 的结构。 - 事件创建前置条件守卫已移除。
WORKFLOW_PRECONDITION_GUARD已不存在;如果应用的环境配置和部署配置中设置了该变量,请将其移除。SDK 中不再有任何 World 会因快照过期而拒绝写入——落后的重放会从下一次写入中了解自己遗漏了什么。 - Turbo 模式默认开启。 运行的首次调用会在后台执行
run_started,并跳过初始事件日志加载。设置WORKFLOW_TURBO=0可禁用此模式。 - 错误会保留其类型。
WorkflowRunFailedError.cause现在会保留原始类身份和原因链。由于 4.x 会将类信息扁平化,曾通过模式匹配error.message的代码现在可以使用instanceof——但请先标出此项,不要主动改写错误处理。 - 事件 ID 是槽位编号,不是 ULID。 事件 ID 现在是它在运行日志中的 1 起始位置(
evnt_00000000000000000000000042),仅在单次运行内唯一,且不包含时间戳。请报告所有将事件 ID 用作全局键的应用代码(应将其与runId配对),或从中解码时间的代码(应读取事件的createdAt)。其他实体 ID 均未改变。 - 现在会强制执行每次运行的事件数量上限。 World 会提供上限(Local 和 Vercel World 为 25,000 个事件),达到上限的运行会失败,并返回
MAX_EVENTS_EXCEEDED。请标出任何具有无界循环的工作流;修复方式是每批使用一个子运行,这属于设计变更,而非迁移修改。 - 流式写入会立即刷新首个数据块。 默认刷新窗口从 10ms 变为 0。如果应用依赖该窗口合并一连串很小的数据块,可设置
streamFlushIntervalMs或WORKFLOW_STREAM_FLUSH_INTERVAL_MS。 - 生成的步骤、工作流和 webhook 包均为 ESM(由 VM 执行的工作流包仍为 CJS)。这只影响会对构建输出进行后处理的宿主。
- 重复的步骤或工作流 ID 现在会导致构建失败。 在 4.x 中,工作区文件里两个同名的非导出函数会发生冲突,后写入者覆盖先写入者。如果构建因此失败,请重命名其中一个——不要禁用检查。
- 升级前写入的
world-postgres行。 由 4.x 存储的失败运行读回时会显示error: undefined,因为负载位于旧版error文本列,而非errorJson。没有数据迁移;近期历史记录仪表板可能会对升级前的失败显示空白错误。 world-local流数据块已移至streams/chunks/<streamName>/。旧的扁平目录结构中的文件不会被读回,遗留文件会保留在原处——这是本地开发状态,因此可以删除数据目录。- 工作流沙箱对非确定性的限制更严格。 工作流函数内部不再支持
WeakRef、FinalizationRegistry、Atomics.waitAsync和异步WebAssembly编译;crypto.subtle.digest则会同步计算(结果相同,时序确定)。请在"use workflow"文件中搜索这些 API;修复方式是将相关代码移至步骤中,这属于设计变更——请标出,不要主动重构。 - 正在执行的运行不会迁移。 在 4.x 部署上创建的运行会继续在该部署上执行。让它们在创建时所在的部署上完成;不要添加用于“排空”或重新定向它们的代码。
步骤 4 — 自定义 World 实现
仅当应用自行实现了 World 时才需要。此部分请使用 migrating-world-v4-to-v5 技能(npx skills add https://github.com/vercel/workflow --skill migrating-world-v4-to-v5);该技能涵盖完整的 World 迁移,包括下文概述的事件 ID 分配工作。以下内容足以帮助确定工作范围,并判断这项工作是否尚未完成。
除上述流和步骤签名外,接口还新增了:
streams命名空间(参见步骤 2 中的表格)。analytics— 仅包含元数据的运行、步骤、事件、钩子、等待项和属性列表。- 对运行的属性支持,包括
experimentalSetAttributes。 - 能力声明,让运行时能够控制是否启用优化。未声明的能力默认关闭,这意味着不完整的 World 仍然正确,但速度较慢 — 只有在确实实现某项能力后,才应声明该能力。事件 ID 分配不属于能力:它是必需条件,而非能力。曾通过旧的
preconditionGuard标志拒绝过期写入的 World 可以删除这段代码,因为该标志已移除,且没有替代项。 - 运行读取时可返回可选的单次运行事件上限,运行时会强制执行该上限。
- 队列选项中可选的
createRunId()和region,适用于按区域放置运行状态的 World。
四项契约变更会影响现有实现。第一项是必需的,且无法从类型签名中看出:
-
事件 ID 是槽位,分配事件 ID 是强制要求。 事件 ID 不再是由 World 自行生成的 ULID,而是
evnt_后跟该事件在该运行日志中的从 1 开始的位置,并补零至 26 个字符(@workflow/world中的slotToEventId()会执行格式化)。位置必须唯一且连续无缺口,因此必须在存储层解决竞争 — 例如为(runId, eventId)设置唯一约束,或使用条件写入 — 不能在进程中读取最大值再加一。events.create()参数中包含用于确定写入位置的eventCount;若该位置已被占用,World 不得拒绝创建,而应前进到下一个空闲位置,在该位置提交,并在成功响应中返回占用被跳过位置的事件。必须在提交时确定位置,并在追加事件的同一操作中完成,因为这样才能避免事件落在读取器已经越过的位置之后。此处没有可声明的能力,也没有回退方案:运行时会读取所加载的每个 ID 中的位置,若无法读取,就会使运行失败。因此,ID 不按位置编号的 World 虽然能通过类型检查并启动运行,却会在首次重放时失败,并报错Event id is not slot-numbered。请告知用户,存储中已有的运行无法由新代码重放 — ULID 编号的运行无法按位置读取,运行时会拒绝处理,因此这些运行必须先在 4.x 上完成,除非平台会将运行固定到创建它的部署版本(Vercel 会这样做,因此可解决此问题)。请引导他们使用
@workflow/world-testing中的numbers events by position,以便在一致性测试套件中而非重放时发现 ID 方案错误;并引导他们阅读《Upgrading a World to v5》指南,了解完整规则。 -
挂起与调度。 等待操作不再使用非对称的
{ timeoutSeconds }返回约定。等待操作是携带delaySeconds的普通队列续接;每次挂起时,等待与步骤调度会统一到一个并行批次中。若某个 World 对旧的等待返回值做了特殊处理,则需要按照当前接口重写。 -
步骤队列主题已弃用。
'step'队列类型不再存在:排队的步骤会发送到工作流主题(在负载中携带stepId/stepName),并在合并后的流程处理程序中执行。若某个 World 配置或路由了单独的__wkf_step_*主题,可以将其移除。 -
World 在构建时解析。 World 会静态注入到宿主程序包中,而不是在运行时动态选择;一方 World 程序包会公开
createWorld()工厂。自定义或社区 World 必须能在构建时解析;请验证应用仍能通过它启动,而不要假定运行时会进行查找。
可省略可选方法;运行时会使用备用实现。请引导用户参阅 migrating-world-v4-to-v5 技能和《将 World 升级到 v5》指南,其中包含完整的接口差异以及上述约定变更,而不要自行编写方法实现。
必需的输出格式
请按以下结构返回迁移结果:
## Summary
## Dependency Changes
## Code Changes
## Behavior Changes To Review
## Verification
## Open Questions
## Code Changes列出每条已应用规则各自对应的一项,并注明修改的文件路径。未适用的规则需列为不适用,且只列一次——不要默默省略。## Behavior Changes To Review列出适用于此应用的第 3 步事项,并注明具体后续操作(涉及哪个控制面板、哪个环境变量)。## Open Questions列出无法从代码中判断的事项,尤其是需要进行接口改造的自定义 World。
验证
按以下顺序运行并报告实际输出:
- 安装并构建。构建必须无错误地重新生成工作流/步骤程序包。
- 类型检查。异步
getWorld()变更和steps.get签名会在此处显现。 - 应用的测试套件。
- 启动应用并端到端执行一次真实运行,确认第一步成功运行且该次运行进入终止状态。
若存在以下任一情况,则迁移失败:
-
workflow和@workflow/*版本同时包含 4.x 与 5.x - 调用
getWorld()/createWorld()/getWorldHandlers()时未使用await - 仍导入
runStep或stepEntrypoint - 仍导入
createLocalWorld或createVercelWorld - 仍导入
workflow/internal/private或@workflow/core/private -
world.streams.*调用仍采用 v4 参数顺序(名称在 runId 前) - 调用
streams.get时未提供runId - 调用
world.steps.get时将undefined作为第一个参数 - 从
@workflow/nest而不是workflow/nest/builder导入NestLocalBuilder - 编译器调用仍传入
mode: 'client' - 从
getHookByToken()或resumeHook()结果读取metadata时未使用await - 第 3 步中的行为变更被悄悄“修复”而未报告
- 对工作流或步骤主体所做的重构超出上述规则
- 手动编辑了
.well-known/workflow/v1/下的生成输出 - 未实际运行并报告构建、类型检查或测试结果
参考
- v5 新特性:https://workflow.dev/docs/whats-new
- 配置与运行时调优:https://workflow.dev/docs/configuration
- 将 World 升级到 v5:https://workflow.dev/worlds/upgrading-to-v5
- 构建 World:https://workflow.dev/worlds/building-a-world
- v4 文档:https://workflow.dev/v4/docs