opencode 的 Effect 架构迁移实战:移除 makeRuntime 服务门面(Facade)的完整清单与模板
本文以 opencode 仓库中的 Facade removal checklist 为核心,讲清 opencode 基于 Effect-TS 的依赖注入体系里"服务门面"是什么、为什么必须移除,以及项目是如何按"低风险批次 + 重调用方批次"两波次完成迁移的。读完本文,你将掌握:门面模式的识别特征与危害、AppRuntime.runPromise(Effect.gen(...)) 等标准调用模板、测试改写的 effect 风格写法,以及一套可验证的"完成判定标准(Done means)"与剩余工作清单。
一、什么是"门面":makeRuntime 与双轨 API 问题
opencode 的核心服务都构建在 Effect-TS 的 Context.Service + Layer 之上,正确用法是在 Effect.gen 体内 yield* 出服务实例再调用方法。但在迁移期间,许多服务文件除了 Service 之外,还额外导出一组基于 Promise 的 async 函数:它们内部调用 makeRuntime(Service, layer) 构建一个"服务私有运行时",再用 runPromise((svc) => svc.method(...)) 把每次调用都跑一遍完整的运行时。这类导出函数就是本清单所称的"门面(facade)"。
门面带来的问题是双轨 API:同一能力既可以 yield* Npm.Service 拿到,也可以直接 await npm.install(...)。后者绕过了调用方所在 Effect 的作用域与依赖组合,测试难以注入替身,且服务文件被永久绑定到一个私有运行时上。
当前仓库中的活例证是 packages/core/src/npm.ts:Npm 服务既导出 Service,又在文件尾部保留了完整的门面尾部:
const { runPromise } = makeRuntime(Service, LayerNode.compile(node))
export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}
export async function add(...args: Parameters<Interface["add"]>) {
return runPromise((svc) => svc.add(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
return runPromise((svc) => svc.which(...args))
}
这就是清单中"Priority hotspots"指出的目标:install()、add()、which() 等 async 门面帮助函数最终要删除,只保留 Npm.Service 这一条访问路径。
二、门面盘点与迁移进度
清单给出的当前状态是(截至该分支):
packages/opencode/src/中共有 5 处makeRuntime(...)调用点;- 其中 2 处被有意排除在迁移目标之外:
src/bus/index.ts与src/effect/cross-spawn-spawner.ts; - 剩余 2 个仍在服役的运行时门面:
Npm与TuiConfig。
迁移按两波次推进,且均已合并:
- Wave 1(低风险批次,已合并):
src/pty/index.ts(Pty)、src/skill/index.ts(Skill)、src/project/vcs.ts(Vcs)、src/tool/registry.ts(ToolRegistry)、src/auth/index.ts(Auth)。 - Wave 2(重调用方批次,已合并):
src/config/config.ts(Config)、src/provider/provider.ts(Provider)、../core/src/filesystem.ts(FileSystem)、src/lsp/index.ts(LSP)、src/mcp/index.ts(MCP)。
此外清单还记录了 session 系列(session.ts / prompt.ts / revert.ts / summary.ts)、Agent、Permission、Worktree、Plugin、Snapshot 等已完成"service-local facades removed"的文件。
清单中所有被迁移服务遵循同一共享模式,识别一个待迁移服务时可按此特征排查:
- 一个服务文件仍导出
makeRuntime(...)+ async 门面; - 一两个路由或 CLI 入口直接调用这些门面;
- 测试直接调用门面,需切换为
yield* svc.method(...); - 待调用方全部消失后,删除
makeRuntime(...)、移除 async 门面导出、去掉makeRuntime的 import。
排除项的边界
清单明确了两处不参与门面移除的 makeRuntime(...),以及原因:
- src/bus/index.ts —— 核心事件总线管道,不属于普通服务门面;
- src/effect/cross-spawn-spawner.ts —— 这是
ChildProcessSpawner的运行时辅助设施,而非服务命名空间门面。
这一边界说明很重要:门面移除只针对"服务命名空间"的私有运行时,不代表仓库里所有 makeRuntime 调用都要消灭。从当前仓库看,packages/opencode/src/ 下仍存在的 makeRuntime 调用点还包括 installation/index.ts 及 src/cli/cmd/run/ 下的若干运行时启动文件,这些属于各自模块的运行时组织方式,不在本清单的完成判定范围内。
三、完成判定标准(Done means)
清单为每个服务定义了严格的五条件"完成"标准,任何一条不满足都不算迁移完成:
- 所有生产代码调用方停止使用
Namespace.method(...)门面调用; - 所有直接调用门面的测试停止走门面,改为从上下文
yield*出服务; - 服务文件中不再存在
makeRuntime(...); - 服务文件不再导出基于运行时的门面帮助函数;
grep被迁移的门面方法名,只能找到服务实现本身或无关同名。
第 5 条是关键的可验证依据:它把"是否完成"从主观判断变成了仓库内的可执行检查。
四、调用方改造模板
门面移除后,原本 await Namespace.method(...) 的调用方需要按场景套用统一模板。以下四套模板直接继承自清单原文。
4.1 路由处理器
用一段 AppRuntime.runPromise(Effect.gen(...)) 作为处理器主体,在体内 yield* 出所需服务:
const value = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
)
若两次服务调用相互独立,保持在同一个 effect 体内并使用 Effect.all(...) 并发执行。
opencode 的 AppRuntime 本体见 packages/opencode/src/effect/app-runtime.ts:它用 AppNodeBuilderV1.build 把 Npm、Auth、Config、Provider、Session、LSP、MCP 等数十个服务的 Layer 节点组合成单一 AppLayer,再以 ManagedRuntime.make(AppLayer, { memoMap }) 建出托管运行时,对外暴露 runSync / runPromise / runPromiseExit / runFork / runCallback / dispose。模板里"一段连续 body、内部自由 yield 多个服务"的写法,正是这个共享运行时设计的直接体现——所有服务在同一 Layer 图中解析,无需调用方关心依赖拼装。
4.2 普通 async CLI / 脚本入口
即使调用方本身还不是 Effect 服务,也优先用一整块连续的 AppRuntime.runPromise(Effect.gen(...)) 覆盖整个工作单元:
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
const skill = yield* Skill.Service
yield* auth.set(key, info)
return yield* skill.all()
}),
)
仅在真正的孤立单次调用或别扭的回调边界,才退回 AppRuntime.runPromise(Service.use(...))。不要在同一个连续工作流里堆叠多个小 runPromise(...) 调用。清单还强调:这是合理的中间状态,不要为了移除门面而去"effect 化"整个 CLI 文件。
4.3 启动引导 / 即发即忘代码
若旧门面调用只是为了触发初始化,直接通过该文件已有的运行时调用服务:
void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
不要为了给 bootstrap 用而重新在服务里引入一个专用运行时。
4.4 测试
门面测试统一改写为完整 effect 风格:
it.effect("does the thing", () =>
Effect.gen(function* () {
const svc = yield* Pty.Service
const info = yield* svc.create({ command: "cat", title: "a" })
yield* svc.remove(info.id)
}).pipe(Effect.provide(Pty.defaultLayer)),
)
三条配套规则:
- 若仓库测试已使用
testEffect(...),优先testEffect(Service.defaultLayer),测试体内yield* Service.Service; - 不要让测试走
AppRuntime,除非测试明确在验证应用运行时本身。门面移除场景下,测试通常只需提供它所需的具体服务 Layer; - 若测试使用
provideTmpdirInstance(...),该 fixture 需要活的ChildProcessSpawnerLayer。对于defaultLayer未内置该基础设施的服务,优先补充仓库标准的跨平台 spawner Layer:
const infra = CrossSpawnSpawner.defaultLayer
const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
缺少这一层时,测试会在运行时以 Service not found: effect/process/ChildProcessSpawner 失败。CrossSpawnSpawner 正是第二节提到的排除项之一,它作为共享基础设施 Layer 被测试复用,而不是被当作门面移除。
五、清单已回答的六个问题
这部分是迁移过程中的决策记录,对同类 Effect 项目迁移有直接参考价值:
- 是否要先 effect 化整个调用方? 不需要。路由文件用
AppRuntime.runPromise(Effect.gen(...))组合处理器;CLI 和脚本用AppRuntime.runPromise(Service.use(...));bootstrap 用既有 bootstrap 运行时。门面移除不需要比这更大的重构。 - 测试是否继续从 async 测试体调用命名空间门面? 否,现在就转换。终态是
yield* svc.method(...),而不是async测试里的await Namespace.method(...)。 - 是否保留导出的
runPromise作为便利函数? 否。本批次的目标是彻底删除服务私有运行时。 - 路由里有 websocket 回调或嵌套 async 处理器怎么办? 保持路由形状不变,把每个门面调用替换为
AppRuntime.runPromise(Service.use(...)),或在可行时用一个Effect.gen(...)包裹周边 async 段。不能因为路由含回调形状代码就保留服务门面。 - 是否每次服务调用都套一个
runPromise? 否。默认每个 handler / 命令 / 工作流只写一个连续的AppRuntime.runPromise(Effect.gen(...))块,块内 yield 出所有需要的服务。多个小runPromise只在调用方结构强制要求时接受,如 websocket 生命周期回调、外部回调 API 或确实互不相干的孤立操作。 - 单个服务表达式是否要包
Effect.gen(...)? 通常不。只有一个表达式时优先直写:
await Effect.runPromise(FileSystem.Service.use((svc) => svc.read({ path })))
只有工作流确实需要多个 yield 值或分支时才用 Effect.gen(...)。
六、来自前两批次的经验教训
清单沉淀了六条反复出现的错误与纠正,值得在动手前通读:
- 测试通常应提供具体服务 Layer,而不是
AppRuntime; - 使用
provideTmpdirInstance(...)且需要子进程的测试,优先补CrossSpawnSpawner.defaultLayer; - 位置作用域(location-scoped)服务可能需要服务 Layer + 对应位置 fixture 两者齐全。例如
FileSystem测试要同时提供Location.Service与FileSystem.locationLayer; - 不要为了返回结果就把单个
Service.use(...)调用包进Effect.gen(...),用直写形式; - 为保证 CLI 可读性,当 handler 开始内联"加载配置 + 加载服务 + 批量 effect 扇出"时,抽出文件内的 preload 辅助函数;
- 在门面分支上 rebase 邻近合并时,优先选择已经清理过的服务/测试版本,而不是旧的内联门面时代代码。
七、剩余工作与可验证的现状
清单把剩余工作收敛为两项:
- 移除
Npm的运行时门面(install()/add()/which()); - 移除
TuiConfig的运行时门面(get()/waitForDependencies()等)。
对照当前仓库,这两项与清单描述一一对应、可直接核验:
Npm:packages/core/src/npm.ts 中 Npm.Service(Context.Service<...>()("@opencode/Npm"))提供 add / install / which 三个方法,底层通过 @npmcli/arborist 做 reify、EffectFlock 做安装锁、FSUtil 做目录探测;而文件尾部 L257-L269 仍保留 makeRuntime(Service, LayerNode.compile(node)) 与三个 async 门面导出——正符合"在 Npm.Service 之上仍导出运行时门面帮助函数"的未勾选状态。
TuiConfig:清单记录的路径为 src/cli/cmd/tui/config/tui.ts;从当前仓库结构看,该服务现位于 packages/opencode/src/config/tui.ts。其服务主体已完全 effect 化(TuiConfig.get / TuiConfig.pluginOrigins / TuiConfig.waitForDependencies 均为 Effect.fn,通过 Npm.Service 的 yield* 安装 TUI 插件依赖),但 L262-L276 仍保留门面尾部:
export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] })
const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node))
export async function waitForDependencies() {
await runPromise((svc) => svc.waitForDependencies())
}
export async function get() {
return runPromise((svc) => svc.get())
}
export async function pluginOrigins() {
return runPromise((svc) => svc.pluginOrigins())
}
按"Done means"标准,完成这两个文件即意味着:调用方(CLI / TUI 启动路径)改走 AppRuntime 或自身运行时 yield 服务,测试改为 testEffect(...) + 具体 Layer 的形式(Npm 的测试见 packages/core/test/npm.test.ts),随后删除 makeRuntime 行与三个 async 导出,并用 grep 确认被迁移方法名只剩服务实现本身。
八、完整清单快照
以下是清单中的最终状态表,便于检索引用:
| 服务 | 状态 | 备注 |
|---|---|---|
src/npm/index.ts(Npm) |
未完成 | 在 Npm.Service 之上仍导出运行时门面帮助函数(现位于 packages/core/src/npm.ts) |
src/cli/cmd/tui/config/tui.ts(TuiConfig) |
未完成 | 在 TuiConfig.Service 之上仍导出运行时门面帮助函数(现位于 packages/opencode/src/config/tui.ts) |
session 系列(session.ts / prompt.ts / revert.ts / summary.ts) |
完成 | service-local facades 已移除 |
src/agent/agent.ts(Agent) |
完成 | service-local facades 已移除 |
src/permission/index.ts(Permission) |
完成 | service-local facades 已移除 |
src/worktree/index.ts(Worktree) |
完成 | service-local facades 已移除 |
src/plugin/index.ts(Plugin) |
完成 | service-local facades 已移除 |
src/snapshot/index.ts(Snapshot) |
完成 | service-local facades 已移除 |
../core/src/filesystem.ts(FileSystem) |
完成 | 遗留 opencode 服务已移除 |
src/lsp/index.ts(LSP) |
完成 | 门面已移除并合并 |
src/mcp/index.ts(MCP) |
完成 | 门面已移除并合并 |
src/config/config.ts(Config) |
完成 | 门面已移除并合并 |
src/provider/provider.ts(Provider) |
完成 | 门面已移除并合并 |
src/pty/index.ts(Pty) |
完成 | 门面已移除并合并 |
src/skill/index.ts(Skill) |
完成 | 门面已移除并合并 |
src/project/vcs.ts(Vcs) |
完成 | 门面已移除并合并 |
src/tool/registry.ts(ToolRegistry) |
完成 | 门面已移除并合并 |
src/auth/index.ts(Auth) |
完成 | 门面已移除并合并 |
九、小结
这份清单的价值不仅在于它盘点了 opencode 的 Effect 迁移进度,更在于它沉淀了一套可复用的服务门面消除方法论:
- 识别:
makeRuntime(...)+ async 导出 = 待移除门面; - 改造:路由 / CLI / bootstrap / 测试四类调用方各有一套标准模板,默认"一个连续
runPromise(Effect.gen(...))块、块内多服务 yield"; - 验收:五条件 Done means + grep 可验证;
- 收敛:两波批次完成后,剩余工作从"几十个文件"收敛为
Npm与TuiConfig两个文件,且两个文件在源码中的门面尾部都清晰可见、可直接对号入座。
对正在做 Effect 化或类似 DI 架构迁移的项目,这份文档中的模板与经验教训(尤其是测试 Layer 组装与 CrossSpawnSpawner.defaultLayer 的坑)是可直接搬走的实战参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00