mcpField
M
migrating-workflow-v4-to-v5

migrating-workflow-v4-to-v5

github.com/vercel开发2.5k320 次安装今天更新
代码仓库

关于 migrating-workflow-v4-to-v5

这是一份供应用团队将 Workflow SDK 从 4.x 升级到 5.0 的迁移指南。它梳理破坏性变更并提供针对性改写,同时指出工作流和步骤主体通常无需重写。

主要功能

  1. 01依赖升级指引
  2. 02运行时 API 迁移规则
  3. 03World 接口变更说明
  4. 04流与事件 API 改写
  5. 05框架集成导入路径更新

适用场景

  1. 01评估应用从 4.x 迁移到 5.0
  2. 02定位已移除的运行时导出项
  3. 03更新自定义 World 和流调用
  4. 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()、重试和事件日志均未改变,因此大多数应用代码可以直接编译。

破坏性变更集中在三个方面:

  1. 运行时入口点(workflow/api、workflow/runtime)——移除了两个导出。
  2. World 接口——仅当应用实现自定义 World 或直接调用 getWorld() 时才会涉及。
  3. 构建集成——@workflow/nest 子路径,以及从未公开的私有编译器子路径。

不要重写工作流或步骤的主体。如果你发现自己在重构业务逻辑,那就超出了此次迁移的范围。

前期检查

编辑之前,请确认:

  1. 安装了哪些软件包。 检查 package.json 中的 workflow 和所有 @workflow/* 依赖项。
  2. 应用是否使用运行时。 搜索 getWorld、createWorld、getWorldHandlers、writeToStream、readFromStream、closeStream、listStreamsByRunId、getStreamChunks、world.steps、listByCorrelationId、runStep、stepEntrypoint、internal/private、core/private。
  3. 应用是否实现自定义 World。 搜索 implements World、: World、createLocalWorld、createVercelWorld、startWorkflowWorld。
  4. 使用了哪种框架集成。 @workflow/next、@workflow/nest、@workflow/nitro、@workflow/sveltekit、@workflow/vite、@workflow/nuxt、@workflow/astro 或 CLI。
  5. 是否使用了 hook.getConflict()。 搜索 getConflict。
  6. 是否在工作流外读取钩子元数据。 搜索 getHookByToken 和 resumeHook,然后搜索其返回值上的 .metadata。
  7. 应用是否直接调用编译器。 搜索 mode: 'client'、transformSync、swc-plugin-workflow。只有自定义构建集成才会这样做。
  8. 是否使用了 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 移到首位
v4v5
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。

验证

按以下顺序运行并报告实际输出:

  1. 安装并构建。构建必须无错误地重新生成工作流/步骤程序包。
  2. 类型检查。异步 getWorld() 变更和 steps.get 签名会在此处显现。
  3. 应用的测试套件。
  4. 启动应用并端到端执行一次真实运行,确认第一步成功运行且该次运行进入终止状态。

若存在以下任一情况,则迁移失败:

  • 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/ 下的生成输出
  • 未实际运行并报告构建、类型检查或测试结果

参考

评论

支持 Markdown