拓冰建站拓冰建站
首页 / 资讯中心 / 正文

OpenCode v2 中 Catalog / Config / Plugin 生命周期设计:Location 作用域的可重放变换如何实现

OpenCode v2 中 Catalog / Config / Plugin 生命周期设计Location 作用域的可重放变换如何实现【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode本文基于 opencode 仓库中的设计文档 specs/v2/catalog-config-plugin-lifecycle.md完整梳理 opencode v2 核心在provider/model 数据归属与可见目录状态变更这一问题上评估过的两套方案Config 变换 全量服务重载 vs Catalog 变换并对照 packages/core/src/state.ts、packages/core/src/catalog.ts 等源码说明当前实际选中的 Location 作用域 Catalog 变换是如何落地的包括插件如何注册可重放变换、插件禁用时如何自动撤销影响、models.dev 远程数据与 Policy 策略如何在目录重放中生效。问题背景目录状态从哪里来又因什么而变在 opencode v2 中前端需要一份可见的 provider/model 目录Catalog而这份目录的输入来自多个来源且每个来源都可能在 location一个已打开的工作位置保持开放期间发生变化。设计文档首先列出了必须同时满足的八类场景Initial load初始加载location 打开时内置/已配置的插件被激活构建第一份可见目录Config配置用户手工编写的 provider/model 定义与覆盖项models.dev远程模型数据按定时器刷新的远程 provider/model 数据Auth凭据活跃凭据会启用/配置某些 provider之后也可能消失Plugin activation插件激活插件在 location 打开期间开始贡献数据Plugin disablement插件禁用插件停止贡献其影响必须彻底消失Config edit配置编辑location 打开期间配置内容发生变化Policy策略provider 已经存在之后允许/拒绝的 provider 选择发生变化。围绕这些场景文档比较了两套设计。文档开头的状态声明明确指出当前核心已选定可重放的 Location 作用域 Catalog 变换即方案 B而 reload/watch 行为与延迟的外部插件激活仍属于设计中的工作以下方案 A 的对比作为历史上下文保留。方案 AConfig 变换 服务重载历史对照方案 A 的思路是Config先合并其有序的配置文件然后执行有序、可重放的插件变换。每个变换是一个接收DraftConfig.Info的回调可以修改任意配置字段type ConfigTransform (config: DraftConfig.Info) void const transform yield * Config.transform() yield * transform((config) { config.providers ?? {} config.providers.acme { /* ... */ } config.model acme/code config.permissions [ /* ... */ ] })关键约束在于由于一个变换可以修改配置的任何部分变换变化无法安全地只触发Catalog.reload()或任何更细的子集所有由 config 派生的服务都必须从新变换后的配置原地重载const transform yield* Config.transform() yield* transform((draft) mutateAnyConfigField(draft)) → Reload.all() → Policy.reload() → Catalog.reload() → Agent.reload() → MCP.reload() → other config-consuming services reload方案 A 的初始加载已配置插件的安装/更新不应阻塞 location 的就绪。先基于手工配置与快速内置项构建初始快照然后在后台激活慢插件并将它们引发的重载请求合并coalesceLocationServiceMap.get(ref) → build location layer → Config.layer reads authored documents → merge authored documents → run currently active Config transforms → Policy.layer reads transformed Config → Catalog.layer reads transformed Config → materialize baseline provider/model catalog → PluginBoot baseline ready → Frontend.fetchCatalog() PluginBoot background fiber → install/update plugin packages concurrently → activate completed plugins → Config.transform() → transform(updateConfig) → ReloadScheduler.request() → debounce short burst of completed activations → Reload.all() → Config.get() → run newly active Config transforms → Catalog.reload() → Catalog.Event.Updated → Frontend.refetchCatalog()文档特别强调初始 layer 构建不是 reload。Reload.all()只在 live location 真正变化后运行例如某个后台插件变为活跃、某个配置源变化。防抖debounce减少了多个插件几乎同时完成时的重复全量重载但由于一个 config 变换可以改任意字段每批仍然要重载所有消费 config 的服务。方案 A 下其余场景的重放链Config配置文件加载config file loaded → config source/watch trigger records new documents → Reload.all() → Policy.reload() → Catalog.reload() → Catalog.Event.Updated → Frontend.refetchCatalog()models.dev定时器触发timer fires → ModelsDevPlugin.refresh() → ModelsDev.get() → transform(applyModelsDevToConfig) → Reload.all() → Policy.reload() → Catalog.reload() → Catalog.Event.Updated → Frontend.refetchCatalog()这里Catalog并不知晓ModelsDev插件在 catalog 读取配置之前先把远程数据变换进 config。Auth账户切换Account.switched(providerID) → AuthPlugin.refresh(providerID) → Account.active(providerID) → transform(applyAuthToConfig) → Reload.all() → Policy.reload() → Catalog.reload() → Catalog.Event.Updated → Frontend.refetchCatalog()插件激活Plugin.activate(acme-models) → Config.transform() → transform(applyAcmeConfig) → Reload.all() → Policy.reload() → Catalog.reload() → Catalog.Event.Updated → Frontend.refetchCatalog()插件禁用Plugin.disable(company-naming) → close plugin scope → Config internally unregisters transform in finalizer → Reload.all() → Policy.reload() → Catalog.reload() → sonnet.name Sonnet → Catalog.Event.Updated → Frontend.refetchCatalog()配置编辑文件监听触发file watcher sees edit → config source/watch trigger records updated documents → Reload.all() → Policy.reload() → Catalog.reload() → Catalog.Event.Updated → Frontend.refetchCatalog()Policy策略变化policy config changes → config source/watch trigger records updated documents → Reload.all() → Policy.reload() → Catalog.reload() → apply updated policy → Catalog.Event.Updated → Frontend.refetchCatalog()方案 A 的权衡文档为方案 A 总结的利弊如下插件拿到DraftConfig.Info可以检查前置配置状态并通过可重放变换修改任意配置字段插件禁用会移除其 config 变换让各服务无需手工撤销即可重新物化models.dev 和 auth 变成 config 变换而不是 catalog 依赖Config拥有对变换可见字段的合并/排序语义因为 config 变换可以修改任何内容细粒度服务重载并不安全任何变换变化后所有消费 config 的服务都要重载Catalog依赖 provider/model 配置语义是这整轮服务重载的一部分一次重载最多产生一条Catalog.Event.Updated通知延迟插件激活避免了阻塞就绪但插件完成可能造成启动阶段反复的全量服务重载批次。方案 BCatalog 变换当前选中方案方案 B 把变换点从配置层下移到目录层插件注册可重放的catalog 变换每个变换接收一个Catalog.Editor其辅助方法修改私有的 catalog 草稿Catalog从活跃的变换集合重新物化可见记录。文档给出的接口签名是interface Catalog { transform(): Effect.Effect(update: (catalog: Catalog.Editor) void) Effect.Effectvoid, never, Scope.Scope }变换注册的完整语义const transform yield* Catalog.transform() yield* transform(update) → replace this transform callback → apply active transforms in registration order → apply policy → commit diff → Event.publish(Catalog.Event.Updated) → Frontend.refetchCatalog()方案 B 的初始加载与方案 A 相同的原则插件安装/更新不阻塞就绪。先用立即可得的源构建初始目录再在后台激活慢插件并合并刷新请求LocationServiceMap.get(ref) → build location layer → Catalog.layer creates empty catalog state → PluginBoot.layer activates immediately available plugins → ConfigProviderPlugin installs Catalog.transform() → ModelsDevPlugin installs Catalog.transform() → AuthPlugin installs Catalog.transform() → Catalog.layer applies active transforms during boot → apply policy → materialize baseline provider/model catalog → PluginBoot baseline ready → Frontend.fetchCatalog() PluginBoot background fiber → install/update plugin packages concurrently → activate completed plugins → Catalog.transform() → transform(updateCatalog) → Catalog internally rebuilds → Catalog.Event.Updated → Frontend.refetchCatalog()注意与方案 A 的差异每个完成的插件激活调用其变换时都会重建 catalog如果要对插件完成做防抖需要额外引入显式的批量/挂起重建机制——它并不由变换接口本身产生。方案 B 下其余场景的重放链Config配置文件加载config file loaded → ConfigProviderAdapter.load() → transform(applyConfigToCatalog) → Catalog internally rebuildsmodels.dev定时器触发timer fires → ModelsDevPlugin.refresh() → ModelsDev.get() → transform(applyModelsDevToCatalog) → Catalog internally rebuilds → commit diffAuth账户切换Account.switched(providerID) → AuthPlugin.refresh() → transform(applyAuthToCatalog) → Catalog internally rebuilds → replay active transforms including current auth → apply policy → commit diff插件激活Plugin.activate(acme-models) → Catalog.transform() → transform(applyAcmeToCatalog) → Catalog internally rebuilds → commit diff插件禁用Plugin.disable(company-naming) → close plugin scope → Catalog internally unregisters transform in finalizer → Catalog internally rebuilds → sonnet.name Sonnet → commit diff配置编辑file watcher sees edit → ConfigProviderAdapter.load() → transform(applyUpdatedConfigToCatalog) → Catalog internally rebuildsPolicy策略变化policy changes → Catalog rebuild trigger → replay all active transforms → apply updated policy last → commit diff方案 B 的权衡文档总结的方案 B 利弊禁用、源刷新与策略重评估都是变换重放操作语义统一Auth 不需要被表示成配置的一部分Config 保持为 catalog 的一个数据来源而不是 catalog 的依赖API 形态与方案 A 一致但可修改草稿是 catalog 状态而非配置状态Catalog 除了读取外还需要变换排序与内部重建行为需要额外指定重算顺序、序列化与 diff 事件一次内部重建最多产生一条Catalog.Event.Updated通知延迟插件激活避免阻塞就绪且 catalog 变换变化只重建 catalog对这些重建做防抖需要额外的批量接口或一个在暴露更新前安装多个变换的激活协调器。当前实现Location 作用域的可重放 Catalog 变换文档声明当前核心已选中方案 B。下面对照源码看这套设计实际落在哪些文件里。State可重放变换的通用基座两个方案共有的变换注册 重放机制在核心中抽象为通用的 State 模块。State.create接受三个选项initial创建初始状态以及每次带作用域变换重载时的基底值draft把可变状态包装成领域特定的草稿 API即方案 B 中的 Editorfinalize在所有活跃变换执行之后、重建状态变为可见之前运行。其内部实现揭示了三个关键机制见 packages/core/src/state.ts重放materialize每次都从initial()全新开始把当前transforms数组按注册顺序逐个应用到草稿上最后commit。这意味着插件禁用不需要反向操作——下一次重放时该插件的变换已不在列表中其影响自然消失正好对应文档中close plugin scope → unregisters transform in finalizer → rebuild的流程作用域终值器transform把回调登记后通过Scope.addFinalizer(scope, dispose)绑定到调用方的 Scope。Scope 关闭时dispose从transforms中过滤掉该变换并触发一次materialize。这就是文档Plugin Disablement场景在实现层的对应物串行化与批处理每次reload/注册/注销都经过同一把SemaphoreSemaphore.makeUnsafe(1)串行执行State.batch提供了在当前批处理内把多个重载延迟合并执行的机制CurrentBatchReference为文档权衡中提到的批量/防抖留下了接口位置。CatalogDraft 结构与 Policy 收尾Catalog 服务 的Interface直接继承State.TransformableDraft即文档方案 B 中transform()签名的实现形态export interface Interface extends State.TransformableDraft { readonly provider: { ... } readonly model: { ... } }文档中的Catalog.Editor对应这里的Draft类型见 catalog.ts 中的 Draft 定义provider.list/get/update/remove列出、读取、修改、删除 provider 记录update时若记录不存在会自动创建空记录并把request.body.baseURL归一化为api.urlnormalizeApimodel.get/update/remove修改指定 provider 下的模型update会强制回填model.id与model.providerID保证草稿记录自洽model.default.get/set设置默认providerID modelID组合。Catalog 把 Policy 放在finalize钩子里精确实现了方案 B 重放链中apply policy last的顺序见 catalog.ts 的 finalizefinalize: Effect.fn(CatalogV2.finalize)(function* (catalog) { if (policy.hasStatements()) { for (const record of [...catalog.provider.list()]) { if ((yield* policy.evaluate(provider.use, record.provider.id, allow)) deny) { catalog.provider.remove(record.provider.id) } } } yield* events.publish(Event.Updated, {}) })即所有插件变换重放完毕后按策略把被deny的 provider 从目录中剔除然后发布至多一条Event.Updated——对应文档一次重建最多产生一条Catalog.Event.Updated通知的约束。Policy 本身在 packages/core/src/policy.ts 中实现evaluate使用通配符匹配Wildcard.match找到最后一条匹配语句返回allow/deny无匹配时回退到传入的 fallback。这解释了方案 A/B 文档中 Policy 场景策略变化只需触发重建的原因——策略在每次重放末端被重新求值。插件侧通过 PluginHost 注册变换插件并不直接接触 Catalog 服务。PluginHost.make 把Catalog.Service包装成插件上下文ctx.catalog暴露reload与transform两个入口transform内部把核心Draft转成带mutable类型标注的 Editor 形态交给插件回调。仓库中的内置插件都按此模式注册可重放变换覆盖文档中列出的各个来源Config 来源config/plugin/provider.ts 中ctx.catalog.transform回调读取config.entries()有序配置文档把配置的 provider 写入目录并应用Config.latest(entries, model)指定的默认模型models.dev 来源plugin/models-dev.ts 中变换回调调用modelsDev.get()把远程 provider/model 数据逐条catalog.provider.update进草稿——对应文档中transform(applyModelsDevToCatalog)的落点provider 特化如 plugin/provider/anthropic.ts、plugin/provider/azure.ts、plugin/provider/amazon-bedrock.ts各自遍历evt.provider.list()按 API 包名识别目标 provider 并改写其请求细节Auth 来源集成/凭据相关的 provider 使能同样以变换方式参与重放Auth 无需被表示成配置字段——这正是文档为方案 B 记录的权衡之一。models.dev 的定时刷新链路文档 scenarios 中的models.devremote provider/model data refreshed on a timer对应 packages/core/src/models-dev.ts 的实现事实数据源默认为https://models.opencode.ai可被OPENCODE_MODELS_URL覆盖拉取结果原子写入全局缓存文件临时文件 rename并用跨进程文件锁Flock.effect防止多个 opencode CLI 竞争写同一缓存refresh以 5 分钟 mtime TTL 判断缓存新鲜度Duration.minutes(5)锁内二次检查后重新拉取并invalidate进程内缓存最后发布Event.Refreshed除非禁用layer 启动时通过Schedule.spaced(60 minutes)fork 一个后台 fiber 周期调用refresh——这就是文档所说的定时器来源。Location 作用域与服务装配文档标题中的Location-scoped体现在Catalog 不是全局单例而是随每个 location 构建。location-services.ts 中locationServices节点组同时包含Policy.node、Config.node、Catalog.node、PluginV2.node、Integration.node等buildLocationServiceMap用LayerMap.make按Location.Ref惰性构建整组服务idle 60 分钟后回收并在编译时记录booting location services日志。这对应方案 B 初始加载流程中LocationServiceMap.get(ref) → build location layer的第一步同时Catalog.node声明了对Policy.node与Integration.node的依赖见 catalog.ts 末尾说明目录在策略求值与可用性判断provider.available会结合 Integration 的连接状态上都与 location 内的其他服务同层装配。结论从设计对比到实现语义这篇设计文档的价值在于把目录状态如何随来源变化的问题收敛为一条统一规则任何来源配置、远程数据、凭据、插件都不直接改写可见目录而是注册一个可重放变换可见目录 initial() 按注册顺序重放所有活跃变换 finalize 阶段应用 Policy 至多一条 Updated 事件。禁用一个来源等价于注销其变换下一次重放即完成撤销。从源码结构看当前仓库实现完整继承了方案 B 的重放、作用域注销、策略收尾与事件语义packages/core/src/state.ts、packages/core/src/catalog.ts而文档也明确保留了两个仍属设计中的部分reload/watch 的触发行为以及延迟外部插件激活的批量协调方案 B 权衡中需要额外批量接口或激活协调器一条。相关的行为验证集中在 packages/core/test/catalog.test.ts配合 packages/core/test/policy.test.ts、packages/core/test/plugin.test.ts 可以追踪具体断言。延伸阅读同目录下的配套规格specs/v2/provider-model.mdprovider/model 数据模型、specs/v2/provider-policy.md策略语义、specs/v2/config.md配置加载、specs/v2/session.md会话。【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门