跳至正文
Applications — Cloudflare OS:产品形态、Gadgets、Gatekeepers 与 Workers 架构

Cloudflare OS:产品形态、Gadgets、Gatekeepers 与 Workers 架构

AI 参与说明(Agent:Codex):本文基于公开源码与官方资料协助整理,围绕项目功能、应用制作流程,以及状态、执行、隔离、授权和恢复原理展开。源码快照与实际验证范围见文末。

先说结论

Cloudflare OS 是一套可部署到自有 Cloudflare 账户的 AI productivity environment:用户在一个公司工作区中与 Agent 对话、生成或修改小型应用,并在最小授权下把这些应用连接到 GitHub、Google、Slack 等外部系统。

它的核心主张可以压缩为三件事:

  • Agent chat:让 Agent 在公司知识与连接的系统上下文中完成任务。
  • Gadgets:每个用户运行自己的小型应用实例;用户可以让 Agent 生成、测试和改写该应用,再安全地分享给协作者。
  • Gatekeepers:将外部资源包装成带授权、最小资源范围、审计和延迟审批的 capability,而不是把一组全局 MCP 凭据直接交给所有 Agent。

这套产品将 Agent、用户身份、应用生成、协作、外部服务授权与 Workers 运行环境组合在一起。官方将其标为 Early Access,实际采用时仍需核验所需功能与运行边界。Cloudflare OS README

阅读前先看这几个词

先对照名称,再沿“用户入口 → Workspace 状态 → Gadget 执行 → 外部资源授权”阅读。中文名称只用于导读;后文固定使用左栏名称。

英文术语中文名称简要解释
Workspace工作区一组 Gadget、聊天、代码版本与外部连接的协调边界
Gadget小型应用可由 Agent 修改代码、具有独立应用存储的运行实例
Blueprint蓝图可发布、复制和合并更新的应用代码与连接需求
Gatekeeper访问守门器代表具体外部资源,管理授权、读取记录和操作审批
Durable Object持久对象按对象身份协调请求、持久状态与实时连接的运行单元
Durable Object Facet持久对象分面在父对象监督下运行动态代码,并拥有独立 SQLite 的子实例
Git VFSGit 虚拟文件系统将真实 Git 对象映射到 Workspace 存储,而非本机目录
Worktree工作树在本项目中指归属于某个 chat 的 Git 文件编辑对象,具有特定产品语义
Code Mode代码执行模式Agent 编写 JavaScript,通过获得的能力完成工具调用
Observer观察者由 Gatekeeper 核验其能否读取共享 Workspace 中相关数据的协作者
Cap’n Web保留原名连接浏览器、Gadget 与 Agent 所调用接口的 RPC 库
capability能力一个可调用的对象引用,拥有它才得到相应方法权限
pin固定基准chat 记录自己正在编辑的代码基底及已纳入的 mainline commit
operational transformation(OT)操作转换将同一基底上的并发编辑调整为可顺序应用、结果收敛的操作
three-way merge三方合并根据共同基准、mainline 与 chat 三份内容计算合并结果
epoch纪元一段以固定基底重建代码提案的历史区间
persistence barrier持久化屏障下一步继续前必须等待本地状态和对应日志写入完成的边界

怎样阅读这篇源码分析

先看用户能完成什么,再看这些能力如何组合。第 1 节用功能地图和一条应用制作流程建立整体认知;第 2–6 节解释运行结构、隔离、授权、部署与使用方法;第 7–11 节沿真实调用链追踪状态、执行与失败恢复。源码引用和复现环境见对应证据及文末。

读源码时始终分清三种状态:chat 的代码提案、Gadget 的已接受代码、Facet 的应用数据。前两种都与源码有关,但有不同的存储和提交边界;第三种不会因为前两种发生 revert 就自动恢复。Agent、Blueprint 和协作权限都围绕这三者工作。

想解决的问题对应深读
本地已经打开,接下来怎样注册、配模型和定位源码?第 5.5 节:本地入口;第 11 节:按状态不变量阅读
一次点击怎样穿过浏览器、Workspace 和外部服务?第 7 节:RPC、Loader/Facet、OAuth capability 与 Observer
模型怎样获得上下文,工具如何执行,失败后从哪里继续?第 8 节:Agent step、输入投影、compaction 与 callback
两个 chat 同时改代码怎样合并,Accept 究竟接受什么?第 9 节:CodeChange、OT、Git、Worktree 与 Blueprint
定时任务注册后为何未运行,重试会不会重复写?第 10 节:Scheduler admission、计数与投递状态机
怎样自己复现、检查并判断已经理解?第 11 节:源码阅读顺序、不变量与纯函数实验

1. 产品形态:不是“一个聊天框”,而是 Agent 驱动的应用工作区

Cloudflare OS 希望部署者把它改造成自己的“Company OS”。用户可以先通过聊天完成一个任务,再把需要反复使用的流程做成 Gadget,连接必要的外部资源,最后与他人协作或分享应用设计。文档、白板和仪表盘是可以制作的应用形态,具体功能取决于 Gadget 的代码及获得的能力。

1.1 功能地图:输入任务后,能得到什么

用户要做什么输入与产物实现入口与原理
持续处理一个任务在 Workspace 新建 Chat,提问、追问,保留消息与执行状态;一次对话不必创建应用newChat() 创建 Chat,Agent 从持久日志重建上下文
制作或修改小应用描述检查表、看板等需求,得到 Gadget 文件与待检查的代码提案createGadget() 建立应用对象;文件工具将编辑写入 Chat 的提案状态
试用修改后再接受打开提案预览,检查效果;Accept 后推进应用的已接受代码mergeChanges() 检查代码基准,再推进 Git head;预览与正式代码共用该 Gadget 的应用存储
选择模型配置 provider、模型与必要凭据,或连接本机 Ollama,再在 Chat 选用模型配置表单 处理用户模型;平台 AI Gateway 模型由管理员另行管理
读取资料或操作外部资源连接账号,选定仓库、文档等资源,再将其交给 Chat 或 Gadget 使用connectAccount() 建立账号连接,Gatekeeper 将资源包装成可调用的 capability;读写与审批规则由具体实现决定
与他人维护或使用应用分享 Workspace,授予 build 或 use,分别提供维护能力或受限应用接口Overseer.open() 结合角色和 Observer 核验决定准入;持有链接不必然能进入
复用应用设计从 Blueprint 创建自己的实例、连接自己的资源,并按需接受后续更新newGadgetFromBlueprint() 与 applyBlueprint() 导入代码和连接需求,更新进入可合并的提案
观察执行并在断线后继续查看接收实时输出、消息与代码变更,重新连接后补齐持久记录先订阅再读取,由 后端补发记录;临时 stream 与持久消息有不同恢复边界

这里的 Gadget 不是“所有人共用一套 SaaS 后端中的一条记录”。README 的描述是:用户创建应用时,会得到自己的私有实例;Blueprint 共享的是公开 release 的代码及所需 binding 的形状,而不是原 Gadget 的私有 commit history、聊天记录、SQLite 数据或凭据。由 Blueprint 创建的每个实例都拥有独立的 bindings、storage 和 chat history。Blueprints

这项设计的价值在于把“可修改性”和“隔离性”一起放进产品模型:用户可以要求 Agent 给自己的应用新增功能,同时不让这次改动天然触及其他用户的实例或第三方账号。

1.2 一条完整工作流:制作 GitHub Issue 看板

下面用一个示例串起这些能力。它描述接口支持的工作流,不代表已经实测生成了这个看板。

  1. 建立任务。 在 Workspace 中新建 Chat,选择可用模型,说明看板要展示的 Issue 字段及交互。Workspace 负责协调,Chat 保存这次任务的消息与提案。
  2. 提供资源。 连接自己的 GitHub 账号,选择目标仓库,将对应 capability 提供给 Chat 或 Gadget。连接账号、选定资源和交给应用使用是三个动作;模型不直接取得账号 token。
  3. 生成应用。 Agent 读取获准的仓库数据,通过文件工具创建或编辑 Gadget。编辑先成为 Chat 的代码提案;跨过 step persistence barrier 后,executeCode 才能使用这份已记录的修改。同一 step 的后续文件工具可以读取内存中更新的内容。
  4. 预览并接受。 打开提案预览,检查看板,再 Accept 当前 Chat 的有效提案。Accept 推进已接受代码,后续请求加载相应代码;它不证明业务逻辑正确,也不会回滚预览时写入的应用数据。
  5. 发起外部操作。 如果看板提供“关闭 Issue”,Gadget 要通过绑定的 Gatekeeper 发起操作。Gatekeeper 先生成待处理 Action,满足人工审批或已配置的合格自动审批规则后,才向 GitHub 提交写入。
  6. 分享或复用。 让协作者共同维护时授予 build,让其他人使用成品时授予 use,并经过所需的 Observer 核验。若希望别人拥有自己的应用实例,则发布 Blueprint,由对方配置自己的资源连接。

这条流程中有三种不同的产物:Agent 的任务记录、应用的代码、应用及外部系统的数据。看懂它们分别在哪里保存、何时生效、由谁授权,就能理解后面的架构,而不会把“Agent 已回复”“代码已接受”和“外部操作已完成”混为一谈。

2. 运行原理:Workers 运行时承载的多租户工作区

上述工作流由六组机制共同支撑。阅读时先确认每组机制管理什么状态,再追踪它与其他模块交换的对象。

架构主题实现原理与阅读重点源码入口
代码状态已接受代码进入真实 Git commit DAG;chat 内未接受修改使用基于 commit 的 CodeChange / operational transformationGitStore、Overseer code flow
编辑对象一个 Workspace 可以包含多个 Gadget;Worktree 是归属于特定 chat 的另一类 workpiece,不创建 Gadget Facetworkpiece registry
Blueprint 分发用 Git release commit / packfile 分发应用设计,更新形成可预览、接受的提案Blueprint releases
Agent 运行每个完成的模型 step 将消息与缓冲编辑一起持久化;按上下文预算进行 compaction,并重试特定临时模型错误Agent loop and step barrier
共享分享角色、Observer 核验、restricted mode 与 ownerInvitesOnly 共同约束访问;范围扩大时隔离新增能力并重启会话Observer admission
构建以每个 Worker 的 cloudflare.config.ts 生成 Wrangler 配置;bundled Blueprints 源码包提供内建应用config generator、bundled-blueprints

2.0 先分清 Worker、Workspace 与 Facet

A Workspace is supervised by an Overseer Durable Object; its Gadgets run as separate Facets. 这里的 Workspace 是协调边界,Gadget 是其中的应用实例。不能把每次聊天、每个 Gadget、每个 Gatekeeper 都画成一个独立的顶层 Workspace。Overseer implementation

flowchart TD
    UI["Workshop Frontend"] --> R["Router Worker"]
    R --> S["Workshop backend<br/>HTTP / WebSocket / RPC"]
    S --> O["OverseerDurableObject<br/>一个 Workspace"]
    O -->|"读写协调状态"| DB["Supervisor SQLite<br/>chat、权限、workpieces<br/>Git objects"]
    O -->|"运行与恢复"| A["Agent loop / Code Mode"]
    A -->|"调用应用"| GF["Gadget Facets<br/>每个 Gadget 独立 SQLite"]
    GF -->|"受控 binding"| KF["Gatekeeper Facets<br/>每个连接的受控状态"]
    KF --> E["外部服务"]

图中展示主要运行对象与一条典型调用路径:Agent 也能直接调用获得的 Gatekeeper capability;Gadget client 通过 parent RPC 调用 Gadget,浏览器边界见第 3 节。UserDurableObject 保存用户索引、账户与列表;AdminSettings、PendingLogin、UserDirectoryDurableObject 分别服务管理、登录衔接和用户目录,不能替代 Workspace 的 Overseer。User 与 Overseer 等顶层对象通过 ctx.exports 获取;当前配置因此没有为这些类逐个声明显式 namespace binding。Blueprint 的 KV / R2 分发路径见第 2.4 节。backend config and migrations

请求先通过 /api 建立 Cap’n Web WebSocket 或 HTTP batch session,以 PublicApi 为初始 capability;认证后返回 AuthenticatedApi,再通过历史命名的 openGadget(workspaceId) 进入 Workspace Overseer。newGadget() 现在实际创建的是 Workspace,源码中的 API 名称不能直接当作当前产品对象数量。API routing and authentication、Workspace creation

Overseer 同时保留两种隔离:自己的 SQLite 保存可信协调状态;Gadget Facet 的 SQLite 保存应用数据。Gatekeeper Facet 由相应 Vendor 提供动态类,再由 Overseer 安装。默认 Gadget 为兼容历史使用 gadget 作为 Facet key,后续 Gadget 使用 gadget<id>,连接使用 gatekeeper<id>。这是对象命名约定,不是对外 HTTP 路径。Facet identities、Gatekeeper Facet

平台层的 Durable Object Facets 让动态类得到自己的 SQLite,并由父对象控制入口;Facet 是父对象之内的动态执行边界,不需要把父对象的 namespace 交给生成代码。Durable Object Facets

2.1 Agent 如何真正执行工作

Agent 使用 @earendil-works/pi-agent-core / @earendil-works/pi-ai 驱动模型循环,完整产品行为由 Workshop 的 harness 实现。库、Pi Coding Agent 和 Cloudflare OS 不能视为同一产品。

Agent 的两条路径应分开理解:编辑工具生成待接受的代码修改;executeCode 运行一个完整 JavaScript module,通过 env 中的 RPC 对象调用 Gadget、Gatekeeper、Git 或 Worktree。Code Mode 的 Loader 同时装入 harness.js 和 agent.js,使用 globalOutbound: null,并按当前 chat 构造能力集合。Code Mode execution

env 并非只包含手工附加的外部连接:当前还包含内建 GIT,以及产品提供的 ambient bindings;用户为这个 chat 获得的 workpiece / connection 能力再叠加其中。这仍与把所有第三方 token 交给生成代码不同,但权限审计需要核对实际 getEnvForAgent(),不能只读 Prompt。Agent env

flowchart TD
    H["读取持久 chat history"] --> M["模型生成一个 step"]
    M --> T["按顺序执行 tools"]
    T --> E["编辑:缓冲 CodeChange"]
    T --> X["executeCode:受控 Dynamic Worker"]
    E --> P["commitAgentStep<br/>消息与缓冲编辑一起持久化"]
    X --> P
    P --> D{"下一步条件"}
    D -->|"有 tool results<br/>按需 compaction 后继续"| M
    D -->|"无工具、取消或等待用户"| F["结束本 turn"]

图中的编辑与执行是两种工具路径,不能先在同一个 step 缓冲代码编辑,再让 executeCode 使用尚未持久化的编辑结果。当前实现会报错,要求在下一步执行;否则崩溃后可能只留下执行副作用,却丢失解释它的代码变更。step guard

这个 barrier 保证的是本地消息与编辑的一致持久化,不构成第三方写操作的跨系统事务。取消 Agent 后,已经完成 step 的工作仍然保留;要撤回代码提案,需要显式 revert。step persistence

Agent loop 依据工具结果、取消、connection request、awaitDecision 与 compaction 条件继续或结束;临时模型请求错误最多重试两次。这不能推导为无限执行、exactly-once execution 或任意故障后自动恢复。runAgent、loop conditions

当前还支持 Agent Spawner:Gadget 或代码通过受限 env 创建子 Agent,子 Agent 对应的 chat 可持久化并被后续交互继续使用。它是产品内部的执行能力,不是获得 Workspace 所有权限的捷径;共享 use 权限的核验还必须计算可到达 Spawner 的 env。Spawner API、Spawner implementation

Agent 生命周期还由 Overseer 的持久 activeAgents 与 Alarm 协调:对象初始化时恢复被中断的 turn,处理待执行的 callable Agent calls;同一个 Alarm 安排 Agent 工作、pending calls 和外部消息投递等事项。每个 Agent 不需要一个永久常驻 Node process,也不是各自拥有一个独立 Alarm。这个恢复机制与前述逐 step 持久化配合,但不消除远端副作用的重试窗口。Agent scheduling and recovery、resume on initialization

2.2 Git VFS:已接受代码、提案与 Worktree

Committed code and proposed edits have different storage models. 已接受代码以真实 Git blobs、trees、commits 存入 Overseer 的 gitObjects collection;当前 GitStore 没有普通仓库的 branches、tags、index 或 HEAD 文件。每个 Gadget record 的 commit 指针、chat 的 pinned commit 和 Blueprint record 充当产品工作流中的引用。GitStore

chat 中的编辑先落在 CodeChange 流,以 pinned commit 为基底,通过 CodeMirror 的 operational transformation 处理实时编辑。接受只允许 fast-forward:如果 Gadget head 已被另一 chat 推进,会返回 stale;需要先 update from mainline,执行三方合并、处理冲突,再接受并推进 head。它不会静默覆盖另一 chat 的已接受代码。code flow、threeWayMerge

flowchart TD
    G["Gadget record<br/>已接受 commit"] --> BASE["chat pinned commit"]
    BASE --> OT["CodeChange / OT<br/>未接受编辑"]
    OT --> PRE["预览提案代码"]
    OT --> ACCEPT["接受全部 proposed changes"]
    ACCEPT --> FRESH{"待提交 Gadget head<br/>等于 pin.mergedCommit?"}
    FRESH -->|"否:stale"| MERGE["Update from mainline<br/>三方合并 / 处理冲突"]
    MERGE --> OT
    FRESH -->|"是"| NEXT["推进 Gadget head<br/>复用或新写 commit"]
    NEXT --> INVALID["codeVersion 变化<br/>失效 Loader / 重启 Facet"]
    AUTH["可访问的 commit tree"] --> WT["Worktree<br/>归属于特定 chat"]
    WT --> GC["GIT capability<br/>commit tree 读写"]

Worktree 与 Gadget 共用 workpiece registry,但产品行为不同:Worktree 以某个 commit tree 为基底,读取可以延迟从基底解析;修改后形成 chat 中的提案。它不运行 Gadget UI、不拥有 Gadget Facet、不挂 Gadget bindings,即使已经接受,仍归属于那个 chat。use 接口不枚举 Worktree,build 接口可以枚举;“归属于 chat”不是对共同维护者隐藏代码的独立隐私保证。这里的 Worktree 是源码中的产品对象,不能等同于本机执行一次 git worktree add。Worktree creation and visibility、workpiece subscriptions

env.GIT 的 commit 读取也不是“知道 hash 就能读取任意外部仓库”。Git cache 与 Gatekeeper 协调可观察对象的权限;要访问远端资源仍需要相应连接。Worktree 的 Code Mode stub 还绑定到当前 execution,不能保存一个 stub 后在任意后续时刻绕过本次编辑范围。Git capability and Worktree session、Worktree session

2.3 代码变化怎样进入 Gadget Facet

Gadget Loader 从当前代码内容构造 module map,以 server.js 为入口,并提供兼容 self binding GADGET、内建 GIT 与可见 named bindings;默认禁止网络出口。Loader 缓存身份包含 Workspace ID、codeVersion、Gadget ID,预览代码还加 chat ID 与 change sequence,使不同代码版本不会混用同一个 Worker。Gadget Loader

flowchart TD
    Q["请求 Gadget / 预览代码"] --> V["选定 mainline 或 chat 提案"]
    V --> L["LOADER.get<br/>包含代码版本的缓存 key"]
    L --> C["getDurableObjectClass<br/>Gadget"]
    C --> F["ctx.facets.get<br/>同一个 Gadget Facet key"]
    F --> SQL["保留该 Facet SQLite"]
    CH["切换预览 / 接受新代码"] --> AB["abort 旧 Facet"]
    AB --> L

预览代码不会自动得到一份隔离的应用数据库。 当前 #getGadgetFacetRaw() 在 mainline 与 chat 预览间切换时,会 abort 然后重用同一个 Gadget Facet key。因此,代码提案与已接受代码可以分开,预览产生的应用状态仍可能写入这个 Gadget 的 SQLite。需要隔离试验数据时,应创建独立 Gadget / Workspace,不能把“尚未接受代码”理解成“尚未影响应用数据”。preview Facet switching

这与平台的 facets.abort() 保留存储、facets.delete() 删除存储的语义一致。代码更新会失效 Loader 并 abort 相应 Facet,但更新代码本身不负责迁移应用 schema;Gadget 作者仍需考虑旧数据兼容。code invalidation、Facet lifecycle

2.4 Blueprint:从代码副本到可合并的 release

新发布的 Blueprint 使用独立 release commit,其 tree 来自 Gadget 当前已接受代码,author / committer 是公开的 Blueprint owner,message 采用 release 标识。它不会把 Gadget 的私有 commit history 一起发布。继承其他 Blueprint 时可以保留公开 release 的 lineage,用于今后的三方合并;这个 lineage 与私有 Gadget 历史必须分开。release commits and lineage

flowchart TD
    GH["Gadget 已接受代码"] --> RC["独立 Blueprint release commit"]
    RC --> PACK["Git packfile<br/>公开 release lineage + 必要 tree"]
    PACK --> R2["R2<br/>blueprintId / commitId"]
    RC --> META["metadata<br/>version / commitId / binding requirements"]
    META --> U["User DO 列表副本"]
    U --> KV["BLUEPRINTS KV<br/>公开查找"]
    R2 --> INST["实例化:独立 Gadget"]
    KV --> INST
    INST --> UP["主动检查新 release<br/>预览并接受合并提案"]

权威 Blueprint record 位于 Workspace Overseer;User DO 为列表保留副本,KV 提供公开 metadata 查找,R2 保存代码 pack。传播是单向的,并以 dirty 标记和 retry 处理失败,不是 DO、KV、R2 之间的原子事务。新 release 使用 <blueprintId>/<commitId> 键且保留旧 release;旧 Yjs snapshot 与 version 1 .gadget archive 仍可读取,按 snapshot release 解释。storage architecture

创建自 Blueprint 的 Gadget 可以跟随、更新或切换 Blueprint,更新不会自动覆盖用户代码,而是生成可预览、接受的提案。pack 在对象进入 Workspace 时校验,限制格式、对象、文件与 ancestry;“上传 archive 成功”不等于“已经能实例化”。Blueprint update proposals、validation

内置 Docs、Sheets、Slides 的源码位于 packages/bundled-blueprints/blueprints/,以 blueprint.json、client.ts、server.ts 和局部模块维护。构建把 TypeScript 与允许的 ui / sync libraries 打包成自包含 client.js / server.js,再由 backend 安装到 KV / R2;这个源码包不单独部署为 Worker。构建期能 import 的包也不等于 Gadget 运行时拥有任意 npm loader。bundled-blueprints layout and libraries

2.5 当前工程入口与复核方式

当前 Worker 配置的事实来源是各包的 cloudflare.config.ts;scripts/generate-worker-configs.ts 生成保留在仓库里的 wrangler.jsonc,CI 用配置一致性检查防止两者偏离。compatibility_date 固定 Workers 兼容行为,是实际运行参数,不能用文档修订日期替代。configuration generation、generated backend config

GitLab Gatekeeper 支持 Project、Issue、Merge Request 和受控 Git transport;@gadgets/observability 提供日志和可选活动指标。bundled-blueprints、observability 与 gatekeeper-kit 是源码 / 构建库,不能都画成需要部署的 Worker;Gatekeeper Kit 的 Layer 2 基类仍是提案,当前不能把它当作所有连接的统一运行基类。GitLab Gatekeeper、Observability、Gatekeeper Kit scope

配置核对的可复跑命令见第 11.5 节。

阅读源码时可按 server.ts → overseer.ts → storage-schema/overseer-storage.ts → git-store.ts / agent.ts → gatekeeper.ts 进入;遇到文档或注释与调用实现不一致,按固定版本的实现核对,不把历史计划当成已实现能力。

3. Gadget 的两层隔离:Worker server 与浏览器 client

server.js 由 Dynamic Worker Loader 装载,其 Gadget class 作为 Overseer 的 Facet 运行;globalOutbound: null 阻止直接 fetch() / connect();被授予的 binding 仍然可以到达对应服务。网络禁用限制的是默认出口,不能代替对 binding 的授权检查。Egress control

Client 运行于 sandboxed iframe,使用 parent 提供的 postMessage() Cap’n Web RPC session 与 Gadget server 通信。CSP 与 iframe sandbox 限制浏览器网络与页面能力。浏览器 client、Gadget server、Code Mode 和 Gatekeeper 是不同信任边界:Gatekeeper 自己可以调用外部服务,不代表生成代码获得了相同的网络权限。sandbox model

Agent 内建 webFetch 是另一条受控读取路径:只对公共 HTTPS 发起 GET,拒绝 URL 内的用户名 / 密码,不发送 cookie 或 Authorization;默认读取上限 1 MiB,硬上限 5 MiB,超时 30 秒。它支持文档转 Markdown,并检查 Content-Signal: ai-input=no;最终工具输出另有 32K 字符预算。它不是 executeCode 的通用网络出口。Workspace 进入 restricted mode 后会禁止 webFetch。webFetch implementation、Agent output budget

隔离正确性仍依赖运行时、浏览器、Gadget API、Gatekeeper 与部署配置。browser-export 源码仍记录 WebRTC/STUN 通道限制的 TODO;Observer admission 也有并发注册方面的 TODO。本文据源码说明已实现的边界,不据 README 的产品措辞推导形式化无泄漏保证。browser export boundary、Observer concurrency TODO

4. Gatekeepers:把外部访问从环境变量变成 capability

Gatekeeper 将外部服务 API 包装为 Cap’n Web 能力,管理授权、Observation 与 Action。用户连接账号、将资源引入 Workspace、授予 chat / Gadget binding 是不同步骤;已配置 OAuth 账号不等于所有 Agent 自动拥有该账号的全部权限。Gatekeeper interfaces

4.1 模拟、审批与真正提交

支持模拟的 Gatekeeper 可以返回本地模拟结果,让 Agent 继续积累 Action,用户稍后批准实际执行;不支持模拟的实现使用 awaitDecision,Agent 在这个边界结束 turn,等待用户决定。模拟读回不能证明第三方系统已经变更。

当前 MCP Gatekeeper 是具体反例:它支持 tools/*,没有通用模拟机制;按服务端 readOnlyHint 分类,标为只读的工具可以立即调用,其他工具进入审批。这意味着只读标注本身仍是对 endpoint 的信任,不能保证一个误标工具实际无副作用。它的主要范围是工具名,不会推断任意工具参数的资源权限;MCP binding 当前仅供 owner 使用,addObserver 会拒绝协作者。因此,“接入 MCP”与“所有连接都有同样的资源隔离、模拟与协作能力”不能画等号。MCP Gatekeeper limits

自动审批也不是全局开关:Action 必须声明 autoApprovable,还要命中用户为对应 Gatekeeper / action kind 启用的规则。restricted mode 下即使已有规则,也转为人工审批。队列按顺序执行,遇到需人工决定或错误的 Action 会停止。auto-approval policy

GitHub / GitLab 为各类写操作声明稳定的 action kind,供用户针对单个 connection 开启规则。合并 Pull Request / Merge Request 与 approving review 始终需要人工批准;关闭或重开、request-changes review、push 等其他已实现操作可以按规则自动审批。force push 使用独立 kind,启用普通 push 不会同时放开改写历史。这里的“支持自动审批”不表示默认自动写入;前述 restricted mode 与队列限制仍然有效。GitHub kinds 与准入判定、GitLab kinds 与准入判定

远端操作已执行、而本地 Action 尚未标记完成时仍可能崩溃;实现明确将 applyAction() 的幂等处理交给 Gatekeeper。因此,持久 Action queue 与审批记录不能单独证明外部写入 exactly-once。Action crash window

4.2 build / use 之外还有 Observer 核验

角色控制功能面:build 可以编辑代码、使用 chat 和管理 bindings;use 得到使用已接受 Gadget 的受限接口,use 接口不提供 Worktree 或其他未接受提案的读取。但用户有分享链接或 use 角色,仍不一定能打开应用。role-specific interfaces

flowchart TD
    OPEN["协作者打开 Workspace"] --> ROLE{"sharing graph 允许?"}
    ROLE -->|"否"| DENY["拒绝"]
    ROLE -->|"是"| SCOPE["计算角色可到达的 Gatekeepers"]
    SCOPE -->|"需要账号的连接"| ACCOUNT["协作者自己的账户<br/>提供 verifier"]
    ACCOUNT --> CHECK{"Gatekeeper.addObserver<br/>允许读取历史 Observation?"}
    CHECK -->|"否 / 无法验证"| DENY
    CHECK -->|"是"| SESSION["注册 Observer<br/>返回 build / use interface"]
    SESSION --> READ{"未来读取会排除<br/>仍获授权且在范围内的 Observer?"}
    READ -->|"是"| BLOCK["阻止这次 Observation"]
    READ -->|"否"| OK["允许读取并记录"]

build 需要核验全部相关 Gatekeeper;use 核验可从 Gadget bindings、enabled hooks 和可到达 Agent Spawner env 取得数据的连接。Gatekeeper 根据协作者自己的外部账号及 verifier 判断其能否读取相关历史 Observation。未来一次读取若排除了仍在授权范围内的 Observer,Overseer 会阻止这次读取;检查依据是授权关系,不是此人此刻是否在线。authorizeCollaborator / ensureObserver、observation exclusion

这条机制尝试解决“Gadget 已存下敏感数据,再通过共享 UI 重放”的问题。当前主要是 Workspace 范围的准入或阻止读取,不能理解成已有逐条 chat 消息脱敏或逐字段行级过滤。

4.3 Restricted mode、范围扩大与撤权

Observation 携带 containsRestrictedData 时,Workspace 进入 restricted mode:禁止 webFetch,Action 转人工审批,并拒绝 Git push。Action 描述不完整时仍可能交给人工并明确标记,不能声称所有外发内容都已被自动检查。ownerInvitesOnly 则进一步限制分享图,只认可 owner 的直接授权,使分享链接与协作者转授不能继续扩大访问。restricted web access and push、ownerInvitesOnly

连接核验范围扩大且存在受影响角色的活跃协作者会话时,Overseer 会暂时隔离新增 / 扩大的 Gatekeeper,并安排 Workspace restart,让会话重新核验;仅 owner 在线或协作者均离线时,不需要同样的会话重置。当前撤权代码在授权图改变后立即安排 restart,再做尽力而为的 Observer 清理与列表刷新;scheduleAccessRestart() 会先同步存储、等待约 100 ms 再 abort,不能理解成瞬时撤权。scope quarantine、revocation order

restart 是能力失效与重新准入机制,不是外部系统操作的撤销,也不是任意已泄露数据的召回。这个区别与 Facet 的代码重启、Git 提案 revert、Gatekeeper Action reject 一样,属于不同层面的状态变化。

5. 在 Cloudflare Workers 部署到自有账户

前文只说明了“可部署到自己的 Cloudflare account”,这还不足以构成可上线的方案。Cloudflare OS 主仓库的本地入口和 Workers 云端部署是两回事:pnpm run-local 用于试用,官方明确不把它作为生产路径;当前独立 workerd server 的部署说明仍是 “Coming soon”。Cloudflare OS Quick Start

5.1 两条官方路径

路径适用场景关键边界
hosted deploy flow希望在自有 Cloudflare account 中快速获得实例,主要调整品牌、登录和管理员官方托管流程不需要本地构建;如只做基础定制,这是最低运维面的入口。
Cloudflare OS starter需要自定义域名、Cloudflare Access、复用既有 KV/R2、增加 Gatekeeper、保留日志与明确控制升级starter 在本地生成生产 Wrangler 配置并部署一组 Worker,适合把部署配置纳入自己的版本控制与发布流程。

不要在 Cloudflare OS 主仓库根目录把裸 wrangler deploy 当成完整生产方案:官方的生产入口是上面的 deploy flow 或 starter。以下拓扑与命令针对 starter 的固定快照 3d211477,不是对任意 Cloudflare OS commit 的无条件承诺。

部署说明按文末列明的 starter 快照核验;其 submodule 与正文源码引用分别锁定,不能把源码分析当成生产部署验收。

5.2 starter 的六个 Worker 拓扑

starter 默认部署 六个 Worker。Router 是唯一的公共入口;其余 Worker 不配公网 route,只通过 Service Bindings 被 Router 或 Workshop 调用。这样,身份验证和对外 HTTP 入口不会分散到每个后端组件。starter architecture

flowchart TD
    user["用户"] --> access["Cloudflare Access"]
    access --> router["Router Worker<br/>唯一公开 route;提供前端"]
    router -->|Service Bindings| backend["Private backend plane<br/>Workshop、Context、Scheduler、custom Gatekeeper、Error Reporter<br/>Durable Objects · KV · R2 · Loader · Browser · AI"]

在此拓扑中,Workshop 仍是 Gadget 与状态的核心:它需要 Durable Objects、BLUEPRINTS / AVATARS KV、BLUEPRINT_CONTENT R2、Dynamic Worker Loader、Browser binding 和模型相关 binding。上游 workshop-backend 的 Wrangler 配置也能看到对应的 DO migration、KV、R2、LOADER 和 BROWSER 声明;它们不是可有可无的外围服务。starter generated bindings upstream Workshop config

这里有一个容易遗漏的点:starter 的六个 Worker 不等于已经覆盖 GitHub、Google、Slack 等所有 OAuth Gatekeeper。要加入这类 Gatekeeper,通常还要为它同时配置 Router 的 HTTP Service Binding、Workshop 的 GatekeeperVendor RPC binding,以及该 Gatekeeper 自己的 BASE_URL、CLIENT_ID / CLIENT_SECRET secret;不是“多发布一个 Worker”就够了。Gatekeeper binding manifest

5.3 以 starter 为基线的部署顺序

以下是 starter README 派生的命令流程,本文未实际执行部署。在其锁定快照中,前置条件是 Node.js >=24.19、pnpm 11.17,以及已开通所需的 Workers、KV、R2、Browser Rendering、Dynamic Worker Loaders;默认模型目录还会使用 Workers AI 与 AI Gateway。starter deployment prerequisites

bash
# 位于 cloudflare-os-starter checkout
git submodule update --init
pnpm install
pnpm --dir cloudflare-os install
pnpm exec wrangler login

# 编辑 deployment.jsonc 后,先预检再部署
pnpm check
pnpm deploy

deployment.jsonc 应保存可公开审阅的部署数据,而非密码。至少要确定 account ID、六个稳定且唯一的 Worker 名、Router 的 Custom Domain 或 workers.dev 路由、publicBaseUrl、Cloudflare Access 的 issuer / audience / 管理员列表、AI Gateway、KV/R2 复用或自动创建设置,以及 observability。默认值为 null 时,Wrangler 可以自动 provision 所需 KV namespace 和 R2 bucket;接管已有数据时则应填入已有资源标识。starter configuration

第三方 OAuth 凭据、跨 account AI Gateway token 等敏感值应使用对应 Worker 的 secret,不应写入 deployment.jsonc、Gadget 或 Blueprint。Cloudflare 提供 wrangler secret put 管理 Worker secret;同 account 的默认 Workers AI / AI Gateway binding 不需要把 API token 写进项目。Workers secrets starter AI configuration

pnpm check 不只是类型检查:starter 会生成临时生产 Wrangler 配置、构建,并进行 deploy dry run。真实发布依赖顺序是 Error Reporter(启用时)→ Context → Scheduler → custom Gatekeeper → Workshop → Router;Router 最后发布,因为它绑定前面的私有 Worker。生成文件会在结束时清理。deployment sequence

5.4 上线前后必须核验的边界

  1. 先确认只有 Router 可公开访问。 Custom Domain / workers.dev route 只属于 Router;starter 同时关闭所有 Worker 的 preview URL,避免形成绕过 Router 与 Access 的另一条入口。发布后应从外部验证 Access 是否先于应用生效。route and preview URL configuration
  2. 把 Worker 名和状态资源当成持久身份。 Workshop 的 Durable Object 数据按 Worker script identity 划分;把既有 Workshop 改成新 Worker 名会得到新的、看似空白的 DO namespace。迁移已有实例还必须复用相应 KV/R2 标识,并保持 publicBaseUrl / Context sharing boundary 一致,否则 Context 数据和 OAuth redirect 可能落到另一套边界。migrating from hosted deploy
  3. 用低敏感度资源做端到端演练。 验证管理员身份、Gatekeeper 的最小 scope 与撤销、Context / Scheduler、AI、Error Reporter 和日志;确认私有 Worker 没有独立公网地址,再把团队数据接入。
  4. 锁定并审查上游版本。 部署前记录 starter 的 submodule gitlink,审查 Wrangler 基础配置与 Gatekeeper contract,再执行检查、部署与验证;不要无审查地跟随 upstream。starter upgrade guidance

5.5 本地试用:启动、注册与模型配置

在 Cloudflare OS checkout 根目录执行:

bash
node --version
pnpm --version
pnpm run-local

仓库固定使用 pnpm 11.17.0。run-local 会先安装依赖,再构建 typed-storage 和 Workshop 前端,最后生成开发配置并启动多 Worker 栈,由后端在 http://localhost:8787 提供前端资源。它不是只启动静态页面;首次仍需下载依赖和 workerd,不能据此宣称首次完全离线。启动脚本、package manager

默认本地模式不需要 Cloudflare 登录、OAuth app 或模型 key。打开正常注册页,自行注册用户名 admin 并设置密码,即可使用本地管理员功能:开发脚本只是将这个用户名加入 ADMINS,没有预设的 admin 密码。只有显式传入 --use-workers-ai-binding 才启用需要远端账号的 Workers AI。本地管理员、AI binding 的 opt-in

没有配置平台 AI Gateway 时,在首次向导或 /providers 添加自己的模型;/admin 的 Models 面板管理的是平台 Gateway 模型。也可以使用已在本机运行的 Ollama,默认地址为 http://localhost:11434,token 可空。向导允许先跳过模型,所以登录和本地界面可先体验;AI 对话仍需一个可用模型。Providers 与平台模型的分工、Ollama 配置、向导完成条件

需要修改源码并即时查看结果时,用两个终端分别执行:

bash
# Terminal 1: backend and gatekeepers
pnpm dev-server

# Terminal 2: frontend with Vite
pnpm dev-client

开发模式访问 http://localhost:3000,数据仍保存在本地 .wrangler。根 .dev.vars 可补充本地配置,shell 环境优先;不要手改每次启动都会生成的 wrangler.dev.jsonc。本地与开发入口、开发变量读取

启动成功后,可从第 7 节的浏览器认证与 Workspace 准入开始追源码。实际启动环境和验证范围见第 11.2 节。

6. 已部署后如何用好:适用场景、工作法与边界

本节是依据前述公开架构给部署者的实践建议,不是 Cloudflare 给出的官方行业方案或安全保证。尤其在 v2 仍为 Early Access 的阶段,应把每次接入外部数据和写操作当作独立的风险决策。

6.1 先把产品定位对

最合适的定位是:受控的内部 AI 工作区,加上一座为个人或小团队制作专属小工具的工厂。它的价值不在于替代所有聊天产品,也不在于一开始就承接无人值守自动化;而在于把下面这条链路放在同一个权限模型中:

一次性任务 → 可反复使用的 Gadget → 可分发的 Blueprint → 在必要时才连接一个受控的外部资源。

建议按“持续主题或敏感边界”划分 Workspace,例如一个仓库、一个客户项目或一个内部流程各用一个 Workspace,而不是把所有工作混在同一个长期对话中。每个 Workspace 对应独立的 Durable Object,且拥有自己的 conversation、Gatekeeper 与 outputs;这种划分能让后续的撤权、审计和归档更清楚。Cloudflare OS README

优先级适合从这里开始首个可交付物为什么适合
1个人或小团队的低敏生产力工具发布前检查表、会议/周报模板、项目风险看板、工时或预算计算器不接外部资源也能验证 Gadget 的生成、交互和迭代体验。
2重复出现的团队小流程可复用的需求收集页、只读质量检查面板、交付物模板稳定后可发布为 Blueprint;使用者得到独立实例,而不是共享原始数据和聊天记录。
3有明确负责人、可复核的外部数据分析单个仓库的 issue/PR 状态摘要、单个项目的变更报告、文档检索后的草稿支持资源范围的原生 Gatekeeper 可以限制到具体对象;MCP 仅到工具名,需单独核验。先只读,再放开有限写入。
4低风险、可幂等的定时任务每日晨报草稿、周期巡检、待办汇总仅在部署中实际启用 Scheduler 后使用;任务应允许重试或重复投递,且不把“触发成功”当成外部写入已经完成。

启用 Context Gatekeeper 后,可将项目说明、术语表和 Runbook 放入 Context Library,供 Agent 读取。要分清两个入口:/context 仍是 Context & Skills 预告页,真实管理应用在 /gatekeepers/context。管理应用提供 collection、成员角色、分享、退出与 everyoneCanView;不能用预告页判断整个 Context Library 尚未实现。预告路由、Gatekeeper 管理路由、Context 管理应用

6.2 一个稳妥的采用阶梯

flowchart TD
    scope["明确一个低敏任务"] --> private["无外部连接的私有 Gadget"]
    private --> verify["人工验收输出和边界"]
    verify --> blueprint["重复使用后做成 Blueprint"]
    blueprint --> readonly["接入单一只读资源"]
    readonly --> approval["小范围写入 + 人工审批"]
    approval --> review["定期复核与撤权"]

这不是必须逐项走完的产品流程,而是很适合首次部署的风险递增顺序。

  1. 先写清任务契约。 为第一个 Workspace 定义输入、输出、禁止动作和验收标准。例如“输入文章标题、链接和发布平台;输出可勾选检查项与风险提示;不保存 token、不连接服务、不执行写操作”。这样可以把 Agent 的成功标准从“看起来回答得不错”变成可验证的交付物。
  2. 先做无连接 Gadget。 让 Agent 用示例数据生成一个小界面,然后亲自测试异常输入、移动端布局和结果是否可复现。一次性的总结或提纲不必强行做成 Gadget;只有重复三次以上、需要交互界面或持续状态的工作,再把它产品化。
  3. 稳定后再沉淀成 Blueprint。 Blueprint 会复制代码、binding 需求和元数据,但不复制聊天历史、SQLite 数据、凭据或已连接资源。因此它适合把验证过的工作法交给团队,而不是把原 Workspace 当作共享模板。Blueprints
  4. 外部连接一次只引入一个窄资源,并从只读开始。 例如只连接一个测试仓库、一个项目目录或一份资料,而非整个组织、邮箱或云盘。要求 Agent 在执行前复述“能访问什么、不能做什么、会不会产生副作用”;得到输出后再核对目标系统中的真实状态。
  5. 将写操作当作单独的发布环节。 首次写入应落在低风险目标(如测试仓库的一条草稿评论),并要求人工批准。支持模拟的 Gatekeeper 可以让 Agent 继续规划,但模拟结果不是外部系统已经变更的证据;批准后仍应独立验证。Gatekeeper session semantics
  6. 再考虑协作和定时。 希望同事只使用成品时授予 use;只有需要修改代码、使用 AI 或管理 binding 的可信共同维护者才授予 build。Scheduler 只用于可幂等、低风险的草稿和巡检;上游实现默认新任务未启用,漏掉的执行不会补跑,回调也可能被重复投递。Sharing Scheduler semantics

下面这个提示词适合作为第一天的起点:

text
创建一个“发布前检查表”Gadget:输入为文章标题、链接、发布平台;
输出为可勾选的检查项和风险提示。先只使用示例数据,不连接外部服务,
不保存任何 token,也不要执行写操作。完成后列出:功能、测试用例、
已知限制,以及把它制成团队 Blueprint 前应人工检查的事项。

接入资源时,改用更明确的约束,而不是仅说“帮我看看这个仓库”:

text
只使用我在本 Workspace 中附加的这个 GitHub 仓库,先进行只读巡检:
总结最近变更、失败测试、风险文件和建议行动。开始前复述你能访问的
具体资源与不能做的操作。不要创建或关闭 issue、评论、push 或修改设置;
请列出数据时间点与无法确认的部分,等待我确认下一步。

6.3 运行和治理的最小清单

范围建议可验证方式
入口只让 Router 成为公开入口,并让 Cloudflare Access 位于应用之前;管理权限名单应小于普通可进入应用的用户范围。使用无权限账号或无痕窗口测试访问路径,并确认没有 workers.dev 或 preview URL 绕过入口。对于 starter,参见前文的 Router / 私有 Worker 拓扑。
Gatekeeper每个连接登记负责人、用途、允许资源、允许动作和复核日期;默认单资源、只读、最小 OAuth scope。在目标服务侧复核 OAuth scope,并撤销一次授权验证 revoke 是否真的生效。
共享默认给 use;build 只给能共同维护代码和连接的人员。用两个测试账号验证二者实际看到的 UI、AI 和 binding 管理权限。
Blueprint不把链接、标题、描述、代码或 binding 说明当作机密边界。上游可通过持有链接查询 Blueprint metadata;外层 Access 是否遮挡该路径则取决于自己的部署,须实测。用无权限会话测试分享链接;代码和元数据中不出现 token、客户详情、内部 URL 或真实资源标识。
模型与成本用一组脱敏的固定任务比较正确率、返工次数、耗时和成本,再确定默认模型与更强模型的回退条件。若启用了 AI Gateway,使用其请求分析、缓存和 rate limiting 观察真实流量;日志中不要保留完整 prompt、文档、token 或 cookie。
升级对 starter 和上游 gitlink 锁版本;新增 Gatekeeper、OAuth scope 或自动写入前重新审查。在测试 Workspace 用低敏样本执行 pnpm check、部署演练和端到端回归,再升级生产实例。

Cloudflare Access 的保护不能只凭部署配置推断。Cloudflare 的自托管应用文档明确建议关闭能绕过 Access 的 workers.dev 与 preview URL;应把无权限访问测试纳入每次变更后的验收。Protect self-hosted applications

6.4 不宜一开始就交给 Cloudflare OS 的工作

目标为什么不宜直接采用更稳妥的处理
面向公众的多租户 SaaS 或系统记录库Cloudflare OS 的核心是受控 Workspace 与 Gadget 实例,不是现成的客户隔离、计费、SLA 与数据主系统。让它做内部运营台或原型;业务数据仍以专门的产品后端为准。
无人值守的生产变更、资金操作、批量发信或权限管理Agent 可能因审批、取消或模型请求失败而停止,Scheduler 不保证 exactly-once execution,模拟也不等于真实提交。采用显式审批、幂等设计、审计和独立的业务工作流;先在测试目标演练。
高敏、受监管或全量企业账户数据这需要额外完成数据分类、保留策略、访问审计、第三方 OAuth 审核和事故响应,而 Early Access 不应代替这些控制。先只接公开或低敏样本,并由安全/合规负责人决定是否扩大范围。
重计算、长时间任务或完整 IDE 替代动态执行、模型上下文和故障恢复各有边界;取消也不会自动撤销已完成步骤。将重任务交给适合的 Container、Workflow 或本地开发环境;Cloudflare OS 保留为界面、协作与受控调度层。

只需要让 Agent 在一个本地代码库里执行工具时,DeepSeek Harness(dsh)、Pi 等本地 harness 通常更轻;只需要持久 Agent workspace 而不需要完整办公产品时,Cloudflare Computer 技术解析:持久 Workspace、执行后端与 Agent 集成 更接近底层能力。

Cloudflare OS 的设计同时保留 Gadget 的实例边界、Gatekeeper 的资源 capability 边界,以及 Durable Object 的状态协调边界。把它用作“小步试验、验证、复制、再逐步授权”的工作台,通常比把它直接变成全权限自动化中枢更能发挥这套设计的价值。

7. 一次真实调用中的 runtime 与 capability

本节用一个假设的 IssueBoard 输入场景追踪平台真实调用链;应用方法名为教学示例,平台函数、字段与时序均来自固定源码。

7.1 一次点击如何穿过三层 RPC,最后读到一条 Issue

先设定可追踪的例子:Workspace 中有 Gadget IssueBoard,其 server.js 导出 Gadget Durable Object class,Gadget 绑定了名为 ISSUE 的 GitHub issue connection。用户点击 UI 的“刷新”按钮,client 调用 gadget.refresh();refresh() 中调用 env.ISSUE.getDetails(),再把结果保存进自己的 SQLite。这个例子的应用方法名 refresh 是示意;下面的平台函数名来自源码。

这条调用不是浏览器带着 OAuth token 直接调用 GitHub,实际路径为:

  1. Router 根据 URL 选择 HTTP service binding。
  2. 浏览器与 backend 的 /api 建立 Cap’n Web session,先取得 PublicApi。
  3. PublicApi.authenticate(token) 在 User DO 验证 token,返回绑定该 userId 的 AuthenticatedApiImpl。
  4. AuthenticatedApi.openGadget(workspaceId) 打开 Workspace Overseer;角色与 Observer 核验在这里发生。
  5. Workspace 返回 GadgetClient,UI 调用 getUiBundle() 取 client.js,再调用 connectToGadget() 取得 Gadget server capability。
  6. parent 把这一 capability 作为 MessagePort RPC session 的本地 target,交给 opaque-origin iframe。
  7. iframe 中的 gadget.refresh() 沿 MessagePort → browser/server WebSocket → native Workers RPC 到 Gadget Facet。
  8. env.ISSUE.getDetails() 通过 Overseer 创建的 binding loopback 延迟打开对应 Gatekeeper session。
  9. GitHub Gatekeeper 用自己的 account capability 调 GitHub;读到内容后,先等待 approvalQueue.authorizeObservation(),成功才向 Gadget 返回数据。

Router 自己不计算 Workspace 权限。它扫描 GATEKEEPER_* binding,把 suffix 变为小写并将下划线换成连字符,例如 GATEKEEPER_GITHUB 对应 /gatekeeper/github;/api、/api/* 和 Blueprint screenshot 走 backend,其他生产请求走 ASSETS。OAuth callback 因此先到相应 Gatekeeper,不到 backend 的某个统一 /auth endpoint。Router 源码

PublicApi 和 AuthenticatedApi 是对象授权的两个阶段。公开入口中,authenticate 将 username:secret 拆成两部分,按 username 定位 User DO,再由它验证 secret;只有验证成功,才构造 AuthenticatedApiImpl。后续方法从这个对象封闭保存的 #userId 定位用户,调用方不能在每次调用中自行替换身份。PublicApi.authenticate、AuthenticatedApi identity

从浏览器补全这段链路:main.tsx 先连接 /api;密码登录页在浏览器中做 Argon2id,再调用 login(),取得 Workshop 自己的 opaque session token。useAuth 从 localStorage.authToken 恢复 token,并通过 authenticate() 的 RPC promise 继续调用。服务端仍会核验 User DO 中 session secret 的 hash、过期时间与激活状态;拿到本地 stub 不代表已经通过认证。这是普通密码模式,不能与另一分支的 Cloudflare Access JWT 混为一谈。WebSocket bootstrap、认证恢复与 promise pipelining、User DO session 校验

标识怎样产生或定位阅读时不要混淆
profile ID用户名或邮箱等产品身份不是 Workspace ID
User DO ID由 users.idFromName() 定位认证后由服务端固定
Workspace DO ID创建时用 overseers.newUniqueId() 分配RPC 仍沿用 newGadget / openGadget 旧名
Chat IDWorkspace 内递增整数同属 Overseer,不新建一个 Chat DO

创建 Workspace、Chat ID 与记录

openGadget 的参数仍叫 gadget id,当前这层对象却是 Workspace。它把字符串解析成 Overseer DO id,调用 overseer.open(userId, profileId, notifyClosed, shareKey, configureObservers)。不要把“知道 DO id”当作已获授权;open 内部另有共享图与 Observer gate。openGadget

Overseer.open 对 owner 直接采用 build;非 owner 先处理 share-key redemption,再等待 ambient capsules 完成,最后调用 authorizeCollaborator。只有 role 解析成功且 Observer 核验通过,才返回 capability:use 返回 UseOverseerInterface,build 返回 OverseerClientInterface。因此 UI 隐藏并非角色控制的全部,服务端真的返回不同的 target。Overseer.open 角色与返回对象

use 的 getUiBundle(chatId) 与 connectToGadget(chatId) 拒绝任何 chatId,只允许 mainline。它仍能获得 Gadget 的原生 API stub,因为 UI 本来就靠它工作。因此“use 只能看 UI”不能扩展解释为“use 不能触发 server 方法”或“Gadget 所有方法天然只读”。应用作者决定了自己导出的方法能做什么。UseGadgetClientInterface

最后一次 GitHub read 体现了 Observation gate 的准确位置。下面是省略展示字段的结构伪代码,用于解释顺序,不能直接运行:

ts
async getDetails(): Promise<GitHubIssueDetails> {
  const details = await this.gatekeeper.openIssue(this.logicalId);
  await this.approvalQueue.authorizeObservation({
    title: ...,
    description: ...,
  });
  return details;
}

远端服务可能已经把数据发给受信任的 Gatekeeper,授权点控制的是数据是否继续进入 Gadget/Agent 这个低信任域。不能把“授权前不返回数据”写成“授权前完全不调用外部 API”。GitHubIssueImpl.getDetails

7.2 Cap’n Web 不是又一份 REST URL 清单

Cap’n Web 的关键在于方法可以返回新的 RPC 对象,也可以接收 callback。PublicApi → AuthenticatedApi → Overseer → GadgetClient → Gadget 是逐步发放 capability 的过程;拿到父对象不意味着自动拿到所有后代对象,但一旦某个对象真的被返回,持有者就可以调用其公开方法。这里混合使用了浏览器侧 Cap’n Web 与服务端原生 Workers RPC,并通过 wrapper 互通,并非整个系统只依赖一种 transport。

/api 对 POST 调 newHttpBatchRpcResponse,对 WebSocket upgrade 调 newWebSocketRpcSession。WebSocket session 可以长期承载 capability、订阅和回调;HTTP batch 是另一条入口。abortSignal 仅对 WebSocket 有效,代码类型注释明确说对 HTTP batch 无效,不能把这两种连接的生命周期写成完全相同。RPC transports 与 abortSignal

长连接还解释了为什么撤权不能只修改共享表。已经返回的 capability 不会因为表中某行变化自动消失。这里用 notifyClosed 做断链联动:server 把 callback 给 Overseer;Overseer abort 时,callback 未经正常 close 就被 dispose;server 检测到这一状态,触发 abortSignal,把浏览器 WebSocket session dispose,迫使它重新连接并重新跑 open。notifyClosed 断链桥

这种重新准入是粗粒度的 Workspace restart,不是每次远程方法调用都重新计算完整共享图。理解这一点,才能正确评估后面的即时性、恢复与撤权残余。

前端还有另一半恢复逻辑:PublicApi 重连后重新认证,Workspace 页面重新取得能力对象。ChatInterface 先 subscribeToChat() 再读取列表与历史,后端补发上次时间点之后的持久记录,并返回可 dispose 的订阅 handle。后续 metadata、message、stream、changeApplied 是 callback 调用,不是轮询或 SSE。前端订阅顺序、后端补发与退订

streamGeneration 用来区分当前内存流与重启前的临时流;DO 实例变化后,应丢弃旧的 provisional 内容并从持久历史重建。退订只停止向这个浏览器推送,不调用 Stop,也不承诺补发掉线期间的全部临时 delta。Agent 自己的运行和恢复由第 8 节的持久记录协调。

7.3 Loader 选择代码版本,Facet 固定应用实例

Dynamic Worker Loader 与 Facet 的职责不同:

层身份依据决定什么
Loader workerWorkspace id、codeVersion、Gadget id;preview 再加 chatId 与 sequence本次运行使用哪份 JS modules 与 env
Gadget Facetgadget 或 gadget<id>这个 Gadget 的应用实例及持久存储
Gatekeeper Facetgatekeeper<id>这个 connection 的 Gatekeeper 实例及状态
Overseer DOWorkspace DO id可信协调状态、权限、对话、提案与生命周期

loadGadgetWorker 先同步读取 codeVersion。如果处于 chat preview,额外捕获 chat metadata 与 nextSequence,并把它们放进缓存身份;异步 loader callback 重建截至 sequence - 1 的 chat content,避免加载过程中刚加入的后续 batch 混进较早的缓存 key。mainline 则读 Gadget 的 head commit files。这样“缓存身份指向哪一个代码快照”与“实际装入哪一份内容”才能一致。Loader 快照与 cache key

当前代码构建 module map 时只选以 .js 结尾的文件,mainModule 固定为 server.js,compatibilityDate 固定为 2026-02-01,并启用 allow_irrevocable_stub_storage。getEnvForLoader 注入 self binding GADGET、内建 GIT 和该 Gadget 可见的 named bindings;chat 只看到本 chat 的 provisional edge。globalOutbound: null 切断默认网络出口。Gadget worker 配置、env 构造

这里没有一个自动安装 npm、编译任意 TS/TSX、打包 React 的通用 build pipeline。Gadget server 是直接装入 JS module;UI 的 getGadgetUiBundle 甚至保留了 “TODO: Bundle the UI? For now we just return client.js.”,实际取的就是 client.js 文本。Loader 平台可能在内部编译模块,但这不等于仓库实现了完整前端构建器。getGadgetUiBundle

Loader 返回的是 WorkerStub,不是应用数据库。#getGadgetFacetRaw 再从它取 getDurableObjectClass("Gadget"),交给 ctx.facets.get(facetName, ...) 运行。如果 mainline 与 preview 切换,代码会 ctx.facets.abort(facetName, ...),但仍使用同一个 facetName。预览因此不是独立数据库沙盒:未接受的代码可以向这个 Gadget 既有持久存储写数据;返回 mainline 只改变运行代码,不会自动撤销数据库变化。Facet 装载与 preview 切换

同样,bumpVersion 的作用是增长 codeVersion、abort 受影响的 Gadget,令下次加载取新代码;它不迁移 Gadget SQLite schema。bumpVersion

还要区分“暂停一个 JS stub 的生命周期”与“保存一个可恢复 capability”。所有正常 Gadget Facet stub 都通过父对象 ctx.restore({type:"gadget", gadgetId, chatId?}) 铸造;裸 facets.get 仅留给父对象的 restore dispatcher。原因是 runtime 需要父 stub 携带重建路径,子 Facet 才能调用自己的 ctx.restore,把持久 callback 交给 Agent 或 Hook。恢复链约束

OverseerRestoreParams.chatId 也会封进持久 stub。只要那个 chat 仍对同一个 Gadget 提议变化,恢复会回到其 preview;chat 已提交、丢弃或删除后,同一参数会解析回 mainline。恢复时 Gadget/connection 的 existence 会重新检查,删除 connection 后其旧 restore 路径会拒绝,而不是静默指向别的 connection。restore dispatcher

7.4 OAuth grant 怎样缩成一个 Issue capability

需要同时区分四层 authority:

层示例缩小的对象
Provider grantGitHub OAuth grantProvider 最终允许 Gatekeeper 做什么
Connected accountGatekeeperUser哪个外部身份及它的凭据
Resource connectionissue #42 对应 Gatekeeper Facet哪个 resource
Session / application envenv.ISSUE哪个调用者能取得哪组方法及审批队列

GatekeeperVendor.connectAccount 负责账号授权,不直接成为 Gadget 的 env。正常 connect 由 User DO 创建 callback、保存 Workshop flow nonce;GitHub 等供应商的 UserAccount 再保存 callback 与自己的 initiation nonce,OAuth 完成先生成 handoff ticket,只有发起用户的 authenticated session 携同一 flow nonce 兑换后,才写进 connectedAccounts。技术意义是:OAuth callback 完成与 Workshop account 激活不是同一个事件。User.connectAccount、handoff stage/redeem

为什么不能只依赖 OAuth state?connect URL 本身是 bearer capability,攻击者可以在自己的 Workshop account 发起 connect,再让别人打开 URL。如果系统在 callback 时直接激活,别人授权的 Provider grant 可能落进攻击者 account。源码 contract 要求最终 page 把 ticket 带回 Workshop origin,再由原发起 session 兑换;ticket 与 nonce 都以 hash 储存在该 User DO,限定到同一 accountId、有效期,并在后续 await 前读出删除,错误 nonce 也消耗 ticket。威胁与 handoff contract、single-use handoff 代码

资源缩小发生在另外一步。GitHub getGatekeeperClassFor(url) 解析 owner/repo;URL 为 /issues/42 时,将 resourceKind:"issue" 与 issueNumber:42 封进 ctx.props 返回动态 DO class。startSession 对这个 resourceKind 返回 GitHubIssueImpl,而 repo URL 才返回 GitHubRepoSessionImpl。Issue capability 自身持有固定 logicalId,getDetails、setTitle、postComment 使用它,不开放任意 repo 参数。畸形 /issues/new 或 /pull/12abc 直接拒绝,不退回更宽的 repo。资源解析、session shape、Issue 方法

例如 Alice 的 GitHub grant 能访问 A、B 两个仓库,但她只为 Gadget 建立 A/issues/42 connection,Gadget env 只放 ISSUE。生成代码不能通过这一个对象调用 listRepositories() 或换成 B/issues/99,因为这组方法和参数没有在这个 session 中授出。相反,如果她绑定 repo session,capability 就更宽,可以从 repo 获取该仓库内的多个 issue。

这不是把 Provider token 变成了 GitHub 自己签发的“issue #42 token”。token 仍由 Gatekeeper account 持有,隔离依赖 Gatekeeper 正确实现资源范围以及 runtime 不暴露 account authority。也不能因 Issue 连接窄,就说 OAuth scopes 一样窄;当前 GitHub connect 只区分 auth-only 与 full scopes,并未根据每条 Issue URL 向 Provider 申请 issue 级 OAuth grant。GitHub connect scopes

getEnvForLoader 不把 account token 或 User DO 交给 Gadget,而是放入固定 {target, caller} 的 GatekeeperLoopback。第一次调用经 startGatekeeperSession 定位对应 Facet,openGatekeeperSession 再注入绑定 gatekeeperId 与 caller 的 ApprovalQueueImpl。caller 是追踪 Agent/chat/Gadget 来源的上下文,不是低信任代码可以随意提交的 identity 字符串。loopback env、session routing、session chokepoint

注意共享 contract 中 getGatekeeperClassFor 注释还提及 setPermissions(),但引用的 Gatekeeper interface 没有该方法,GitHub implementation 也没有。解释运行机制应依实际类与 session,不把这段历史注释当成当前必经步骤。

7.5 Observer 在回答另一个问题:协作者可否看到已读取的数据

resource capability 限制“程序可以读什么”;Observer 检查“谁可以看到程序读到的内容”。后者不能通过 OAuth scope 代替。

假设 Alice 的 Workspace 已读取私有 repo A。她把 Workspace 分享给 Bob,Bob 即便拿到有效 share link,也还需要用自己的 GitHub connected account 提供 verifier。User.getVerifier(accountId, expectedVendorId) 从 Bob 自己的 connectedAccounts 取对象,并核对 vendor,不能把 Google verifier 冒充 GitHub verifier。verifier 归属核验

GitHub verifier 带的是 Bob 自己的 userObjectId,hasRepoAccess(A) 用 Bob token 调 GET /repos/{owner}/{repo};403、404 转成 false,其他异常继续抛出。GitHubGatekeeperImpl.addObserver 要求这个 check 成功。因此 owner 的 token 能读 A,并不自动证明 Bob 也能读 A。GitHub verifier、GitHub addObserver

ensureObserver 的步骤是:计算 role scope → 读取既有 observer accountChoices → 缺少时经 callback 请用户选择 → 平行取得各 account verifier 并调用 addObserver → 所有成功后才保存 {profileId, observerId, accountChoices}。已有选择在每次 open 都重新验证;失败可再提示一次,最终失败拒绝本次 open。ensureObserver

角色决定核验的生产者范围:

  • build:所有需要外部 account 的 Gatekeeper;因为它能通过 connection list 打开 session。
  • use:所有非 provisional Gadget 的绑定目标、enabled Hook 的来源,以及这些路径能到达的 Agent Spawner env targets 的传递闭包。
  • vendorless AI model/Agent Spawner 自身无需 account check,但可到达的外部生产者仍进入闭包。
  • provisional Gadget / binding 没有接受前不属于 use 可见对象,不应提前扩大 use scope。

这一 scope 不是当前 UI 是否显示某个 tab、某个协作者是否在线的判断。use scope closure、role scope

宽范围服务需要记住“实际读过什么”。Context Library 用 trackedCollectionObservers,新 Observer 需能读取已经观察过的 collection,未来读取其他 collection 时再计算 exclusions。private collection 的授权由 collection DO 自己的 viewer / editor / owner grants 与 everyoneCanView 标记决定;账号 library 只是发现索引,列表、读取与 Observer verifier 仍检查 collection 的当前权限。public collection 在 sharing domain 内可读,仅 deployment admin 可编辑和删除。Context Observer 策略、唯一权限解释器、实时 verifier

分享接收者通过 Workshop 用户选择器的一次性 ticket 确定,Context 管理 API 兑换 ticket 后,从所选用户的 Context verifier 取得同一 sharing domain 下的 account IDs,再写入 grants;浏览器不能直接指定另一个人的 accountId 作为授权依据。everyoneCanView 允许域内其他账号读取,但不会自动加入其 library,也不会把 private collection 变成 admin 管理的 public collection。ticket 与 verifier 分享链、library 索引与 grant 的写入顺序

于是能解释一个具体的分享变化案例:

  1. Workspace 只读 public collection P,Bob 能访问 P,addObserver 成功。
  2. Alice 以后让 Agent 读 private collection S;Bob 无权看 S,Gatekeeper 的 Observation 带 excludeObservers:[bobObserverId]。
  3. Overseer 查 observerId → profileId,再查 Bob 仍获授权且来源仍在 Bob role scope。当前没有按 chat 隐藏数据的实现,因此拒绝整个 Observation,不能“先返回内容再隐藏一条消息”。
  4. 若 Bob 已在共享图中失去授权,Overseer 清理其 observer record 与 Gatekeeper registration,这次 Observation 可以继续。
  5. 若 Bob 为 use 且该 connection 已离开它的实际 scope,只去掉他在这条 connection 的 registration,保留其整体协作者身份;以后 rebind 再 open 时重新验证。

这里的阻断依据是“已授权且在范围”,离线 Bob 也会阻断;关闭浏览器不等于放弃以后看 Workspace 的权限。excludeObservers 分类与清理

7.6 restricted-data flag、owner-only invitation 与权限图

containsRestrictedData 与 ownerInvitesOnly 是两个独立 latch,不能统称“敏感模式权限”。

containsRestrictedData 被一次 Observation 设置后,Workspace 进入 restricted mode:公开网页 fetch 受阻、auto-approval 被挂起,Action 需要人工复核。它没有按字节追踪 data provenance,也不自动阻断对其他已连接服务的所有写入。contract 明确让 approver 检查 Action 完整描述是否包含敏感数据;不完整描述会被标识,git pushes 有更严格拒绝条件。不能宣传为全自动 DLP 或对任意跨服务泄漏的证明。restricted-data contract

ownerInvitesOnly 改变的是共享图:share links 停止授权,只保留 owner 直接授予的 edge。例子中 owner O 给 Alice build,Alice 再给 Bob build;正常状态 Bob 可达 O,因此获权。flag 设置后 Bob 的间接路径失效;O 直接邀请 Bob 后才恢复。通过 link 入场的人同样不再依赖那条 link 获权。这个 flag 不代替 external account Observer checks。ownerInvitesOnly contract

共享图的撤销不是递归删除所有下游记录。移除 Alice 相当于切断其上游路径,Bob 的下游 edge 仍留在 storage;若 Bob 没有其他到 owner 的路径,它暂时失去有效 role。重新添加 Alice,Bob 可以随路径重新可达;ownerInvitesOnly 时只有直接 owner grant 算数。这就是“保留历史 edge”与“当前有效权限”必须分开看的原因。SharingManager 撤权模型

新的 account connection 一加入 Workspace,build scope 就扩大;当它经 Gadget binding、enabled Hook 或可达 Agent Spawner env 进入 use 可达范围时,才扩大相应 use scope。为免已有 session 持有较小核验范围却立即访问新 connection,Overseer 同步保存新 record、判定受影响 session、将 connection id 放入 #gatekeepersPendingRestart。在真正 reset 到来前,openGatekeeperSession 等入口检查这个 quarantine set。owner 独用或受影响角色无活跃 session 时,无需为了无现存 capability 的人重启。新增连接发布顺序、quarantine 与 restart

scheduleAccessRestart 先 ctx.storage.sync(),再等待约 100 ms,最后 ctx.abort(reason)。先 flush 是为避免权限修改还没落盘就被 abort;延迟是为让发起修改的 RPC 结果先返回。它不构成零延迟撤权、无法撤销已完成外部写入,也不能收回已经传给用户的数据。restart 顺序

7.7 浏览器和服务端的网络边界需要分别阅读

Gadget server 的 globalOutbound:null 只禁止默认出口,被授予的 service binding 仍可间接访问外部资源。Gatekeeper 代码则属于可信外围,需要自己的外部 API 网络权限。backend 的内建 WebFetch 又是第三条路径:仅 public HTTPS GET、不支持 forwarding credentials,生产依赖 global_fetch_strictly_public 做 DNS 解析后的 IP 过滤;源码特意说明 wrangler dev 会放宽 outbound,因此 dev 与 production 条件不同。WebFetch 边界

浏览器侧,GadgetUI 将 Cap’n Web JS 作为嵌套 data URL 注入 client.js。iframe 创建 MessageChannel,把一个 port 用 window.parent.postMessage("handshake","*",[port2]) 交给 parent。parent 不凭 "null" origin 独自信任消息,它同时核对 event.source === iframe.contentWindow 与 event.origin === "null",再取得当前 Gadget stub,作为 newMessagePortRpcSession 的 local target。bootstrap、handshake source check

这个 iframe 使用 srcDoc、没有 allow-same-origin,CSP 含 default-src 'none'、frame-src 'none'、connect-src 'none'、form-action 'none',常规外部图片/脚本/连接未放行,脚本样式等主要来自 data URL。parent 交给它的是 Gadget server capability,不是整个 AuthenticatedApi 或 Workspace 管理对象。CSP

但不要把它写成“绝无外网能力”。实际 sandbox 是 allow-scripts allow-popups allow-popups-to-escape-sandbox;注入脚本 monkey-patch window.open,并对 target=_blank links 加 noopener,意图允许用户点击打开新窗口。这个行为与 iframe 内禁止 fetch 是不同的能力。源码还在 browser-export 中明确记录 CSP/request interception 不覆盖 WebRTC/STUN,且普通 Gadget iframe 也存在同样缺口。因此仓库自己没有给出全面无网络泄漏的结论。popup 策略、sandbox flags、WebRTC/STUN TODO

CSRF 也不能用“系统用了 WebSocket”概括。在非 Access 模式,/api 设计为 in-band authorization,HTTP batch 回包主动设置 Access-Control-Allow-Origin:*;公开入口不因浏览器碰到某个 ambient auth cookie 就自动拥有 user authority。若配置 CF_ACCESS_AUD,情况不同:server 在处理 /api 前强制 Origin 等于当前 URL origin,然后验证 Access JWT,再允许 authenticateFromCfAccess。对 cookie/Access 这种 ambient identity,额外的 Origin check 是关键区别。Access Origin/JWT gate、cross-origin batch

Gatekeeper connect forms 另有自己的 mutation guard:expected Origin 来自配置的 base URL,检查 Content-Type 的 media type 精确匹配。connect page 响应禁缓存、禁 iframe embedding、禁 Referrer;nonce 分 initiation / oauth 两阶段并轮换,OAuth callback claim 后消费。这些是某个 HTTP flow 的局部控制,不能从 kit 存在就推断所有 Gatekeeper 的所有 route 都无遗漏。connectMutationError、nonce lifecycle

7.8 必须和设计一起记住的残留

这份 commit 已有不少精细的 fail-closed 修补,例如 observer/gatekeeper pair 的 add/remove 串行锁、scope widening quarantine、正在等待 Observer prompt 的 session 也计入 role session。但仍不能讲成完备的动态信息流安全模型。

边界源码中的实际残余
首次 Observer 注册addObserver 成功至 ObserverRecord 写入之间,Gatekeeper 已认识 opaque id,Overseer 查不到该 id,forward exclusion 当前按 unknown/inactive 忽略;源码提出 pending-id map 修复 TODO
同一 profile 同时 openensureObserver 尚未 per-profile serialization,两次首次打开可能各生成 observerId,后写 record 遗忘另一份 registration
外部 ACL 被撤销每次 reopen 会重新核验;本次核验失败只拒绝本次 open,已有 sessions 保留原核验取得的 access,直到重连
never-bound produceruse 未核验不在其 scope 的 producer;build Agent 从该 producer 读出 restricted data 后写入 Gadget state,use 可能看到,系统不追踪 observation 后的 provenance,源码接受为 v1 risk
Hook 返回二级 capabilityper-firing callback wrapper 每次检查 Hook live record;callback 方法返回的独立 capability 不受这层 per-call revocation wrapper 递归保护
浏览器网络CSP/iframe 对常规网络有效,但已有 WebRTC/STUN TODO,popup 行为还需真实浏览器验证

证据:首次注册窗口、同 profile 并发、existing session 残余、never-bound producer、Hook wrapper 限制

7.9 状态账本:控制面为什么不能和应用数据库混在一起

读 Cloudflare OS 的数据层时,先问“这条状态由谁解释”,再问“它存在哪里”。同一份代码文本、一个 Gadget 的业务计数器、一次待批准的 push,虽然都需要持久化,恢复方式却不同。

状态所有者与入口输入变化后的结果与恢复代价
Workspace 协调状态Overseer 的 makeOverseerStorage(ctx.storage)chat、proposal、角色、Action、Hook、workpiece head 等决定可信协调;重启后按持久记录重建运行态
Gadget 应用数据动态 Gadget class 的独立 Facet storage应用 RPC 写自己的 SQLite;更新 server.js 不会自动回滚或迁移应用 schema
Gatekeeper 实例状态每条 connection 的 gatekeeper<id> Facet保存该资源的观察、模拟及供应商状态;不是把整个账号的 token 直接交给 Gadget
代码对象Overseer 的 gitObjectscommit/tree/blob 按 oid 存放;移动 head 是改引用,原对象仍可供其他历史使用
Git provenance / push marksOverseer 的 gitObjectMetadata拉取来源与 Action 生命周期会改变它;不能单靠代码 bytes 重建“谁有它、谁正获准接收它”
派生索引与运行缓存typed-storage indexes、内存 map索引可由主记录 rebuild;内存缓存可丢弃;二者均不能替代业务主记录

父对象存储和 Facet 存储分离的目的,是让低信任应用代码只能操作自己的应用状态,不能通过自己的 SQL 直接改 owner、审批和共享关系。源码把 Overseer 的持久 shape 集中声明在 overseer-storage.ts;Gadget class 则在 Loader 装载后由 ctx.facets.get 实例化。这是状态拥有权的分离,不能误读为“一个 Workspace 的所有事务横跨父对象和全部 Facet”。跨 DO 或 Provider 的写入仍有外部失败窗口。Overseer schema、Gadget Facet 构造

typed-storage 是带索引与订阅的同步 KV 视图

@gadgets/typed-storage 把 DO 的同步 storage.kv 包装成三种主要对象:

  • singleton 是单个槽位,如 ownerId、codeVersion、schema version。缺失时 get() 返回 schema default,不代表已把这个 default 写入磁盘。
  • collection<T>() 是按 primary key 读取、列举和写入的 record 集合;gadgets 的 key 是 workpiece id,actions 的 key 是 Action id。
  • uniqueIndexes / nonUniqueIndexes 由 record 计算索引键。例如 gadgets.byBindingName 从 record 得出 bindingName,null 表示不加入索引;同名 Gadget 的 put 会因唯一性冲突而失败。provisional record 也是真记录,所以在提案接受前就保留名称。

这里的 singleton 指存储槽位,和“一个账号提供的 ambient Agent singleton capability”不是同一个概念。schema slots、workpiece unique index

collection 的 index 是内部 subscriber:put 读取旧 record,按新旧 record 的索引键增删派生条目,再写主记录;这些同步操作放进 storage.transactionSync。typedStorage.transaction 同样直接委托这个本地同步事务。它不是跨 Workspace、Facet 与外部 API 的分布式事务。collection.put、索引键维护、transaction 委托

这也决定 schema 改动的代价。把 TypeScript 属性重命名,如果没指定旧 storageKey / storageName,就是在读另一个物理键,并非自动迁移。新增 index 也不会自动扫描历史记录:必须在迁移阶段先 rebuild(),再触碰旧 record。已有 migration version 与 backfill 是工程机制;泛型提供编译时约束,并不自动验证历史磁盘数据的每个字段。稳定存储键、index rebuild 契约、Overseer schema version

“还有文件”与“对象仍存在”是不同事实

gadgets 是 Gadget 与 Worktree 共用的 workpiece registry;它的历史名称不能让人误以为所有 row 都有 Gadget UI 或 Facet。每个 row 的 type 才区分二者,registry 才是当前对象的枚举与存在性依据。WorkpieceRecord

例如删除 Gadget 7:removeWorkpiece 先删除指向它的 Hook,再删除 registry row、清除 running-chat map,最后 ctx.facets.delete(facetName) 删除应用实例存储。但历史 chat pin、Git commit 或遗留 Yjs root 可以仍在;它们不能凭文件存在就重新枚举、执行或渲染 Gadget 7。删除后想恢复应用,代价也不只是“把旧 server.js 找出来”:还需要恢复对象身份、head、binding,以及已被 delete 的应用数据;历史代码本身不是数据库备份。删除次序与残留说明、registry 是事实源

反过来,代码更新调用 facets.abort 保留实例存储,删除对象调用 facets.delete;因此“重启 Gadget”“回退代码”“删除 Gadget”拥有不同的数据后果。preview 切换同样沿用一个 Facet identity,不能当成自动复制应用数据库。

为什么 GitObjectMetadata 不能直接塞进 blob,或当成可随时重算的缓存

gitObjects 的 record 是 {oid,data}:data 为 deflated loose object,同一个 oid 的内容不可变。gitObjectMetadata 却按 oid 保存可变关系:

  • onRemote:哪些 Gatekeeper 对应 remote 已经按当前系统的证据规则拥有这个 object。
  • pullableFrom:哪些 Gatekeeper 声称可取到它,用于 pull routing;这是提示,不等于授予读取。
  • pendingPush:[{gatekeeperId,actionId}]:哪些已排队 Action 将它推给某个 remote,使目标 Gatekeeper 在 simulation 中可以读到待推对象。
  • type 与可选 size:type 可以只是引用上下文的声明;只有实际测过 bytes 才有 size,不能因对象没取回来就臆测其大小。

metadata 经常先于内容存在:远端可以 advertise 一个 commit,tree 可以引用尚未取回的 blob,超大对象也可能只记录实测 size 而拒绝保存内容。分开存储还避免每次查 provenance 都读入整个大 blob。两种 record、独立 collection 的理由

设 object X 从 remote A 进入 Workspace,Agent 生成 commit C 准备推向 B。记录 pendingPush(B, action42) 后,B 的 Gatekeeper 才能在 scoped view 中看这些待推对象;这不是证明 B 已经收到了它们。Action 真正 apply 成功后,convertPushMarksToOnRemote(42) 把 mark 转成 onRemote;reject 或删除 connection 则清除 mark。已 apply 的 push 即使后来 revert ref,也不删除 onRemote,因为回退 branch pointer 没有让 remote 忘记已经接收的对象。scoped read、push mark 生命周期

byPendingPushAction 是从 pendingPush 数组派生的多值索引,apply/reject 由 actionId 找到精确标记集合,不需要重新遍历整个 Git DAG。这个 index 丢了可以按 record 重建;metadata 主记录丢了,就不能仅从 object hash 推断其 remote 来源和尚未完成的授权。因此名称虽叫 cache,里面仍包含需要认真恢复的协调账本。action 索引

安全表述也要停在源码的边界:git-cache 自己明确说 scoped view 与 ancestry rule 是防误操作和 simulation 的机制,不是针对恶意 Gatekeeper 的 confinement boundary;这里的 Gatekeeper 处于可信侧。不能把 onRemote 的证据等级进一步宣传成独立验证远端实际全部内容的通用安全协议。git-cache trust model

8. Agent 执行:从一条消息到可恢复的工作步骤

8.0 先分清 turn、pass、step 与流式进度

假设用户提出:“给待办 Gadget 增加优先级,再测试保存行为。”系统需要把自然语言转成模型上下文,执行读写和 RPC 调用,向浏览器流式显示进度,同时保证服务重启后能分清哪些修改真正保存了。Cloudflare OS 的核心办法是将长任务拆成可持久化的模型 step。

  • 用户 turn:用户消息或 callback 启动的一整轮工作,可能包含多次模型请求和多次工具调用。
  • pass:从持久日志重建一份可供模型使用的上下文,再进入 loop 的一次过程。compaction 或模型请求重试可以让同一个用户 turn 经历多个 pass。
  • 模型 step:模型输出一条 AssistantMessage,执行其中的工具,再把这条消息和本 step 的本地编辑一起提交。pi 的事件将这个边界称为 turn_end;它不等于用户 turn 已经结束。
  • 流式进度:文本片段、工具输入和 edit preview 提前送到客户端。它们帮助用户观察过程,尚不能证明这一 step 已持久化。

源码用 runAgent() 管理整个用户 turn,用 runAgentPass() 重建一次上下文并运行 pi loop,用 commitAgentStep() 提交模型 step。compaction 或临时模型失败会让一个用户 turn 经过多个 pass。外层循环、step 提交事件

flowchart TD
    U[用户消息或 callback] --> S[持久化输入与 activeAgents]
    S --> R[加载 checkpoint 和 chat log]
    R --> P[重建模型消息、bindings、文件状态]
    P --> C{上下文需要压缩?}
    C -->|需要且存在切点| K[生成摘要与结构化 checkpoint]
    K --> R
    C -->|继续| M[模型生成一个 step]
    M -->|完整响应| T[顺序执行工具<br/>取消时跳过尚未执行的调用]
    M -->|临时错误:预算内重试| R
    M -->|Stop 或永久错误:本 step 不提交| E[收尾执行登记<br/>已完成 step 保留]
    T --> O[更新内存 overlay 或执行 capability]
    O --> B[await turn_end barrier]
    B --> D[同事务保存 transcript 与缓冲代码变更]
    D -->|成功| F{下一步如何继续?}
    D -->|提交失败| E
    F -->|仍有工具结果且预算充足| M
    F -->|需要 compaction| R
    F -->|等待决定| W[清 busy 与 running<br/>保留 ActiveAgentRecord]
    W --> Q{相关决定是否全部通过?}
    Q -->|通过:写 nudge 并重新领取执行| R
    Q -->|仍 pending| W
    Q -->|denied:删除执行记录| E
    F -->|最终回答或工具阶段已取消| E

图中的 busy 指 chatMeta.activeAgent,保留下来的执行记录则位于 activeAgents 表。二者名字接近,职责不同:前者表示这个 Chat 有正在执行或准备中的任务,后者保存恢复所需的发起者和模型身份。等待审批时可以没有 busy 标记,同时仍有持久执行记录;最终回答、失败与 Stop 的收尾规则见第 8.7 节。暂停与收尾

从源码分工看,这条链有四个接点:

接点接收什么,产生什么为什么要分开
Overseer 的消息入口和启动逻辑用户输入或 callback → 持久日志、执行身份和本次 AbortController浏览器断开后,任务仍有可恢复的身份与输入
runAgent() / runAgentPass()checkpoint 与日志 → 模型消息、文件视图和可用 bindingsUI 消息、模型历史与工具需要的精确状态各有表示
Pi loop 与工具实现完整模型响应 → 顺序工具调用、结果和继续/结束决定模型生成、工具效果与下一次请求有明确边界
commitAgentStep()本 step 的 transcript 与缓冲提案 → 同一事务中的持久记录后续请求和重启都能解释已经保存的代码状态

消息与启动入口、上下文入口、step 提交

8.1 输入先成为日志,再成为模型上下文

sendChatMessage() 不是直接把字符串传给模型。RPC 入口先尝试 steering:chat 正在运行且尚未取消时,非空纯文本、不带 capsule、附件或 format 的输入写入 queuedSteers。它沿用当前 turn 的模型,不因本次调用另传 model ID 就切换模型。Agent 在下一次模型请求前消费队列,将消息以 steered: true 写入日志;这不是另起一个 turn,也不会立即中断正在执行的工具。steer 准入、RPC 先 steer 再解析模型、消费队列并写日志

其余输入走启动新 turn 的路径:规范化附件,确认 chat 没有运行中的 Agent,再取得 message preparation reservation,等待 slash command 或消息预处理完成。这个 reservation 防止 callback drain 在准备期间抢先启动同一个 chat。预处理包含异步边界,因此返回后再次检查 chat 状态;随后把此前的用户编辑 materialize 成日志,事务写入用户消息及 activeAgent,最后调用 startAgent()。新 turn 消息入口

未消费的 steer 也有收尾规则:没有审批等待时可启动同模型后续 turn;错误时先记下它们,再记录错误供 Retry 读取。Stop 则清空待消费 steer,并交回停止方客户端恢复草稿,不能假定每个发送者都会自动拿回自己的文字。turn 收尾、Stop 与队列交接

startAgent() 同步登记内存中的 running set 和持久 ActiveAgentRecord。record 只记录 chat、发起用户、模型 ID、发起身份及是否 callback 发起;不把包含 secret API token 的完整模型配置重复存进 Workspace。恢复时重新向发起者的 User DO 解析模型。启动登记、持久记录字段

运行前还会回收上次失败 step 留下、没有日志支持的 provisional Gadget,materialize 之前的编辑,检查 usage / balance,选择模型和 Gateway routing,再进入 runAgent()。因此,聊天页上的“开始运行”与第一次发出模型 HTTP 请求之间,还存在状态清理、额度检查和模型解析。turn setup

模型看到的 history 是一份由持久日志投影出来的输入,不等于 UI 中的消息数组原样复制:

持久输入模型侧转换这样做的原因
用户文字中的 resource capsule改写为指向 env.NAME 的引用,并恢复对应 chat binding将用户选中的资源与实际 capability 对应起来
图片、文本附件、PDF图片 block、文本内容或 provider 可接受的 document 表达不同模型有不同输入能力;切换模型后不支持的类型退化为说明标记
Agent 历史消息优先恢复 server-only model snapshot;缺失时从显示记录重建保留 reasoning 签名、model provenance 和工具调用结构
已保存工具调用重建 tool call / tool result 配对让模型知道成功、失败及文件状态,而不是再次执行已完成的外部调用
用户编辑、revert、catalog change合成 observeUserChanges 调用及文本结果用已有工具消息结构表示模型之外发生的变化
compaction 前的历史带来源提示的摘要 + 结构化 checkpoint缩短输入,同时恢复机器必须精确保留的状态

capsule 改写、附件转换、模型快照恢复、合成 Observation

这里有一项值得借鉴的存储决策:显示记录和模型快照分开。makeStoredAssistantMessage() 使用“完整复制,只删已重复保存的 tool-call arguments”的方式创建快照;arguments 已在 AiToolCall.input 保存,重放时按 toolCall ID 补回。这样 reasoning、签名、provider / API / model 来源和未来新增字段不容易被白名单遗漏。快照保存在 server-only 的 chatModelData,不会因为 UI 不显示 reasoning 的某些字段而丢失模型重放所需的信息。快照创建、重组工具参数、存储设计说明

同一条输入为什么有三种表示

例如用户在消息里附上一个 Gadget,要求“读取它的代码,再增加优先级”。Chat log 保留原始文字、resource capsule 的位置和目标 workpiece;模型看到的对应片段则是指向 env.NAME 的引用。执行工具时,chatBindings.NAME 才被解析成该对象的 loopback RPC capability。消息里提到文件,不代表文件正文已经进入模型上下文;prompt 里出现资源名称,也不等于已经获得调用权限。 capsule 投影、实际 env 的建立

一次 pass 的重建可分成下面几层。这里没有一个独立的 prepareContext() API;实际工作发生在 loadChatHistory() 与 runAgentPass() 内部。

flowchart TD
    CP[active checkpoint] --> BASE[恢复 bindings 和 pins<br/>建立代码 base trees]
    BASE --> PATCH[叠加 checkpoint 的 proposedChange]
    LOG[从 compactedTo 起的 chat records<br/>含该条边界记录] --> REPLAY[按 sequence 重放记录]
    PATCH --> REPLAY
    REPLAY --> MSG[modelMessages<br/>用户、assistant、tool results、Observation]
    REPLAY --> STATE[机器状态<br/>sessionContent、bindings、filesRead]
    REPLAY --> SOURCES[modelMessageSources<br/>记录投影来自哪个 sequence]
    MSG --> INPUT[稳定 system prompt + tools<br/>组成下一次模型输入]
    STATE --> TOOLS[为后续工具提供实际状态]
    SOURCES --> CUT[按完整 record 选择 compaction 切点]

checkpoint 中先恢复 pin 的 Git 基准,再叠加尚未接受的修改,最后重放保留日志;顺序决定了同一条 CodeChange 应用在哪份文本上。一条持久 record 可以投影成 assistant tool call 加多条 tool result,因此 modelMessageSources 还要把这些消息映回同一个 sequence,防止 compaction 从它们中间切开。历史加载、恢复 bindings、pins 和提案、来源映射

这里的 replay 是重建输入和本地视图:保存过的 executeCode 输出成为 tool result,历史 createGadget 恢复已记录的 ID,历史 describeBinding 使用保存的输出。它们不会因为重建上下文就再调用一次外部服务。下一次模型请求仍是新的请求,后续是否再次调用某个工具,由模型和当前规则决定。历史工具结果重放

model snapshot 保留的是原消息结构及来源,随后仍要经过 Pi 的 provider/model 转换。同模型的 reasoning 保留与切换模型后的转换,是两种不同情况;“原样存储”不能推导为任意模型都能原样接收这些 reasoning blocks。快照恢复与模型转换测试

8.2 System prompt 是带稳定前缀的环境快照

普通 coding Agent 的 prompt 分为通用行为规则与 Workspace 环境信息。后者包括 Gadget 文件列表、bindings、standard formats、可连接的 vendor,以及 always-available resources。文件列表刻意表示“session 开始时”的状态,并保存在 workspacePrompt;compaction 后才使用新的 Workspace 快照。这样每次请求不必因为工作区变化重写前缀。Workspace prompt

ambient resource 的 catalog 也遵循相同思路:稳定列表进入 system prompt,变化通过追加 catalogChange 日志及合成 Observation 通知模型。模型通过“稳定快照 + 后续事件”获知环境变化。资源已经不可达时会从列表滤去,旧 prompt 不能凭空保留实际权限。Workspace 随机 salt 放在项目专属 section 开头,用于阻止同 provider account 下不了解该 salt 的参与者探测这段私有 prompt 的 cache prefix。catalog 快照与重放、salt 与 system sections、追加 catalog 变化

这是一项上下文与缓存策略,不是授权层。实际 env 仍由 Overseer 按 chat binding 构造;prompt 中有资源介绍,并不替代 Gatekeeper 的能力检查。

因此要分清“冻结的环境描述”和“工具实际使用的状态”。其他 Chat 新建或重命名文件后,旧 system 文件列表可能暂时没有变化;工具读取什么由本 Chat 的文件视图和能力状态决定,冻结的 prompt 列表不决定读取内容。已 pin 的文件使用 pin 基准加本 Chat overlay,未 pin Gadget 的 head 也会在本 pass 首次观察后固定。catalog 的变化通过后续 Observation 告知模型,compaction 刷新下一阶段的 Workspace 描述。稳定前缀减少了不必要的输入变化,但不充当文件锁、权限快照或数据库快照。Workspace 快照的保存语义、文件视图与 observed head

8.3 Pi 提供 loop,Workshop 定义产品语义

核心集成可以压缩为下面这段真实源码节选:

ts
let context: AgentContext = {
  messages: [
    {...systemMessage, toolsAdded: toolList.map(toToolDeclaration)}, ...modelMessages,
  ],
  tools: toolList,
};

await runAgentLoopContinue(context, {
  model: handle.model,
  convertToLlm: (messages) => messages as Message[],
  toolExecution: "sequential",
  getSteeringMessages: async () => hooks.takeSteers(chatId).map(
    text => ({role: "user" as const, content: text, timestamp: Date.now()})),
  maxTokens: maxOutputTokens,
  finishTurn: ({message, toolResults}) => {
    // Workshop decides whether to wait for approval, end, or reload for compaction.
  },
}, emit, abortSignal, stream);

节选省略了 finishTurn 函数体,不能作为独立程序运行。getSteeringMessages 消费前述输入队列,stream 使用当前 pass 选定的模型输出路径。loop 接线

Pi 提供模型消息和事件协议、工具参数校验及循环执行。Workshop 负责模型输入投影、真实工具实现、stream fan-out、step transaction、审批暂停、预算检查和恢复。尤其要注意 emit 是异步事件 sink:loop 会等待 turn_end 的持久化完成,再发下一次模型请求。工具执行方式明确为 sequential,因此同一 step 里的“先写再读”能使用前一个工具刚更新的内存状态;它没有在本地并行修改同一份 session overlay。事件 sink

从模型输出到下一次请求,中间发生什么

模型 streaming 时,Pi 先在内存中累积 AssistantMessage,Workshop 把文本、reasoning 和工具参数片段转成临时显示。工具参数收齐、工具执行返回、step 持久化是三个完成点。 普通工具的前端 toolCallFinished 在参数收齐时就可能发出,executeCode 则延后到执行结束;不能从这个事件名判断代码已经保存。流事件转换

sequenceDiagram
    participant M as Model
    participant P as Pi + Workshop
    participant O as Overseer
    P->>M: 请求一个 step
    M-->>P: 完整 AssistantMessage
    loop 按 toolCall 顺序
        P->>P: 校验并执行工具<br/>收集 result 或 error
    end
    P->>P: finishTurn 计算决定
    P->>O: await emit(turn_end)<br/>调用 commitAgentStep
    Note over O: 预取后重新校验<br/>本地日志与提案一起保存
    O-->>P: 提交完成,barrier 返回
    alt 决定结束或等待审批
        P->>P: 退出本 pass
    else 仍有后续工作
        P->>M: 下一次请求<br/>带上 tool results
    end

finishTurn() 先计算决定,Pi 随后仍要等待 turn_end,最后才执行这个决定。因此“决定暂停”不会绕过本 step 的保存,“还有工具结果”也不会让下一次请求抢在提交之前。这里的 await 是模型循环的顺序屏障;SQLite transaction 提供本地写入的原子性,两者各自解决一个问题。Workshop 的结束条件、Pi 已发布的 loop 实现

Pi 在调用工具实现之前先查找工具并校验参数:未知工具或 schema 错误产生 error result,不进入 execute;运行时抛错同样转成 error result,供模型修正。普通工具失败不会自动丢弃同批其他成功结果。若模型因输出长度限制截断响应,Pi 会拒绝这条响应中的全部 tool calls,避免执行可能残缺的参数。Pi 工具执行与截断处理、参数、未知工具与运行错误测试

8.4 文件工具维护的是提案状态,不是直接覆盖已接受代码

文件工具共享 sessionContent overlay、已读文件知识、pin 集合和 step buffer。一次成功写入会立即更新 overlay,使同 step 后续文件工具读到新内容;但真正持久化和广播 change row,要等 step barrier。

readFile 返回有行号窗口说明的内容,并记录文件知识及可用的 blob oid;editFile 要求先读过文件,并在首次修改未 pin 的已提交文件时核对读到的 oid 是否仍等于当前 base 的 oid。若另一 chat 已接受了新版本,旧知识不能作为编辑依据,模型必须重读。writeFile 是整文件 set,不要求先读,但首次写入仍会 pin 当前 head,并在 barrier 再验证该 head 没有移动。readFile、writeFile、editFile 知识新鲜度检查

例如模型在同一 step 依次写入 client.js 并读取该文件,读取看到的是新文本;若随后调用 executeCode,会因本 step 已有 buffered edit 被拒绝。原因在于 Code Mode 能产生真实 RPC 副作用,不能让它依赖尚未写入 durable history 的代码。模型应先结束这个 step,再在下一个 step 执行测试。这道 guard 是整个 step 级别的粗粒度限制,连先前改的是其他 Gadget / Worktree 也会触发。缓冲应用过程、executeCode guard

每个工具编辑在 buffer 中保留独立顺序,不提前合成成一个 row。客户端把后续 changeApplied 与同文件最早等待的 edit preview 配对;如果合并 row,就可能失去“哪个 preview 被哪个真实变更替代”的对应关系。最终 transcript 的 changes 消息可以合成,但直播 row 仍逐调用提交。AgentStepChange 约定

buffer 还按 codeChangeSerializedSize() 累加,与共享 MAX_CODE_CHANGE_SIZE 比较。超过预算的这次调用不加入 buffer,之前成功的调用仍可在 barrier 保存。错误会告诉模型拆分大文件或结束当前 step 后继续。这是持久记录大小约束驱动的背压,而不只是减少内存。预算检查

Worktree binding 与文件工具共用同一 turn 状态。commit() 可以先生成 Git commit object,但 HEAD advancement 在当前 step 的 pendingWorktreeCommits 中缓冲,barrier 成功后才推进注册记录;若中途失败,可能剩下不可达 commit object,而不会把未记录的 HEAD 当成已完成工作。WorktreeTurnAccess、Agent 的 Worktree 状态

文件读取知识也需要重建。历史 readFile 若带有读取已提交代码时的 observedOid / observedCommit,系统按记录取回当时的 blob,不能拿当前 HEAD 的文本替代;session 读取则按 pin、overlay 和此前编辑重建。filesRead 刻意不跨 compaction 保存,保留日志中的读取及成功写入、编辑可以重新建立它。如果旧读取已被摘要替代,保留记录又没有重建有效知识,模型仍须重新读取才能编辑。这避免把“摘要说我读过”当成文件新鲜度证明。历史读取的两个分支、写入与编辑恢复知识、不保留 filesRead 的原因

8.5 Persistence barrier 保证什么,又不保证什么

在成功的 turn_end 中,Workshop 汇总文本、可显示 reasoning、每个 tool call 的 input / result / error、server-only model snapshot、captured Action 的引用与 connection request,再 drain 本 step 的文件编辑、资源创建和 binding 的记录,以及 Worktree HEAD advancement。一次 commitAgentStep() 将 transcript 和缓冲的本地提案状态放进同一个 storage transaction。事件汇总

Action 本身与 Chat 中的 Action 引用有不同的提交边界。 submitAction() 早在工具执行时就可独立保存 ActionRecord,并按规则启动自动审批;step 末尾保存的是 {type: "action", actionId} 这类关联。因此不能把 barrier 画成“模型消息、所有 Action 和外部效果第一次一起落盘”。Gadget 的应用存储、Gatekeeper 队列及第三方系统都可能已有变化。Action 的独立保存

事务前先异步预取文件树和 Worktree base 文本,事务内重新读取 metadata,核对缓存 generation / revision,验证新 pin 的 head,应用每一条变更,保存 AssistantMessage,随后 materialize 出这个 step 的 changes 消息。预取与提交之间再次验证,正是为了处理异步 I/O 释放执行权后状态可能变化的问题。barrier 的预取与同步尾部

更具体地说,事务内依次追加 change rows、写入 assistant/tool 记录及 model snapshot,再将变更物化为 changes 消息;创建/绑定的 stamp 和 Worktree HEAD 在这个过程更新。直播通知可以在事务内部的写入过程中发出,不能把它改画成“提交成功后才统一广播”。下一次模型请求等待的是整个 commitAgentStep() 成功返回。事务顺序、change row 广播、消息与 model snapshot 同步保存

平台的 transactionSync() 保证同步事务中的写入一起提交或回滚;output gate 则约束受保护的出站消息何时可发送。它们都不会撤销事务之前已经完成的另一个对象或第三方请求。Workshop 的 step barrier、SQLite 的原子性和外部操作的幂等,是三个需要分别核对的保证。SQLite transactionSync、output gate

这个设计能支撑的表述是:已完成 step 的本地代码提案与其解释日志一起 durable;未完成 step 的内存 buffer 不会被当成完成结果。 它不能推出外部写入的 exactly-once execution。

故障位置源码可确定的恢复行为边界
模型仍在 streaming 时失败失败 AssistantMessage 不持久化;符合 transient 分类时重新读取最后保存 step,并发送 streamReset已显示片段是 provisional
文件工具已更新 buffer,但 step 未到 barrier本 step 的 buffer 丢失;从持久日志重新建立输入provisional resource 注册需要后续清理
barrier 完成,进程未标记整个 turn 结束恢复入口先从日志判断最终回答、审批等待及未处理 steer;没有新输入时不重复生成已完成结果不能只看最后一条显示消息是否为 assistant
外部 RPC 已产生效果,但工具结果尚未入日志仅靠 step transaction 不能消除重放窗口依赖 Gatekeeper / 目标接口的幂等语义及其 Action 记录
storage transaction 中途抛错durable rows 回滚,内存 caches 删除后重建事务内已发出的订阅广播不能被 storage rollback 收回

失败请求不写日志、末尾完成则跳过、广播不随事务回滚

最后一项是源码明确承认的设计边界:中途 exception 本身应视为 bug,但订阅者可能已经看见后来回滚的 rows。不能把“SQLite 事务原子性”扩大成“客户端从未见过临时不一致”。

恢复入口先分类 durable 日志,runAgentPass() 仍保留“没有新输入、末尾为 assistant 则跳过”的第二层检查。持久化阶段的 agent.step.persist.started/completed/failed 日志与 runtime span,按 execution ID 和 sequence 关联;它们没有引入另一张持久 step 状态表,也不是磁盘 flush ACK。DO 重启后的新执行有新的 execution ID。恢复入口分类、末尾防御检查、持久化进度

还有一个容易漏掉的错误协议:Code Mode 的 entrypoint.verify() 启动失败会直接抛出工具错误;但 entrypoint.run() 的未捕获异常会被 Overseer 捕获,拼入返回日志中的 Uncaught exception。外层 executeCode 随后仍可能生成普通成功 tool result。因此 工具 transport 成功不代表生成程序的业务执行成功;判断运行是否正常必须检查输出中的异常及真实状态。若执行后 tail log 未到,还可能抛出“等待执行日志超时”,这同样不能证明程序没有产生副作用。verify 与运行异常、外层工具结果

8.6 Compaction 同时处理语言记忆和机器状态

预算来自模型配置或内置模型目录,并将 output reservation 从可用 input window 扣除;未知模型默认 context window 为 128,000。自动 compaction 的触发点是 input budget 的 85%;保留尾部的目标为 min(inputBudget × 30%, 20,000 tokens)。切点按完整 record 移动,pending request 也可能限制切点,所以尾部可能超过目标。20k 不是最终 prompt 或保留历史的硬上限,system prompt 与摘要还会增加总量。token limits、尾部目标与记录边界

估算优先使用 provider 上一次 step 的 usage.totalTokens,再补上此后新增消息与工具结果的估计;没有 usage 时,按序列化长度约四个字符一个 token,并单独计入 system prompt。附件数据从权重中替换为短标记,因为 base64 大小不等于 provider 处理附件的 token 成本。因此这里只能视为调度估算,不能当成精确 tokenizer。上下文计量、估计权重

meta.totalTokens 保存的是最近一个模型 step 的 prompt + response 测量,不是整个会话的累计消费。重建时只补估这次测量之后的记录,以及同 sequence 下尚未计入 usage 的工具结果;compaction 发布后会清除旧测量,下一 pass 重新估算缩短后的输入。用累计计费 token 代替这个值,会把“总共用过多少”误当成“下一次请求有多大”。估算公式、usage 保存语义

切点以持久 chat record 为单位:同一 record 转成的 assistant tool call 与 tool result 不会被分开。modelMessageSources.canCut 标记每个原始 record 贡献的第一个模型消息,因此不能笼统写成“总会保留完整用户 turn”;历史可以在模型 step 的完整记录边界截断。pending connection request 还会把切点约束在发起请求的 turn 之前,避免仍等待用户决定的状态只剩摘要。canCut 产生位置、pending request 保护

checkpoint 不能只存一段自然语言。摘要负责目标、决定、错误和下一步;机器字段恢复以下状态:

  • chatBindings:摘要与保留日志中的 env.NAME 仍有对应对象。
  • pins、epoch、proposedChange:重建未接受的代码状态,而不是请模型从摘要猜出文件。
  • nextChangeId:后续工具结果中的 change ID 仍连续。
  • workspacePrompt、alwaysAvailableResources:固定下一阶段的环境基线。

Checkpoint 字段

原 chat log 不删除,UI 仍可向前分页。通用文本摘要路径使用标明 machine-generated、要求视为记录的 <prior_conversation>,并去掉摘要内部同名边界标签,避免摘要逃出来源提示。revert 会 refold 被影响 checkpoint 的代码字段,保留其摘要和边界。文本摘要来源提示、revert refold

同时匹配 anthropic-messages API、Anthropic provider 与源码模型 allowlist 的配置使用 Anthropic on-demand compaction。请求使用这段会话的 system prompt、tools 和相关选项,取得 compaction block;checkpoint 同时保存摘要文本与原始 block。后续兼容模型的请求重发未修改的 block,其他模型仍使用文本摘要。即便走 native 路径,切点或 system prompt 变化仍可能使 provider 丢弃历史 thinking,不能据此承诺 reasoning 永久可重放。支持条件与 native 请求、原始 block 重发

两条路径仍有相同的失败边界:显式 /compact 只压缩并结束,失败会上报;自动 compaction 失败则保留原 history 继续。这不等于 native 请求失败后自动换成另一套文本摘要请求。两条摘要分支及失败处理

8.7 Stop、等待审批与 restart 是三种不同状态变化

Stop 在取消内存执行前,会清除 queued steers、删除持久执行记录,并写入 stopped nudge,再调用当前 Chat 的 AbortController.abort()。先删除记录,是为了避免对象恰好在异步收尾之前消失,重启后又恢复用户已经停止的工作。模型在 streaming 时被取消,失败请求不写入 step;若模型已经完整输出、取消发生在工具执行阶段,barrier 仍会保存已经完成的工具效果,尚未执行的工具调用记为 Operation aborted,随后结束 loop。取消不是回滚,已经完成 step 的提案仍存在,需要用户显式 revert。Stop 的持久边界、中途取消的 transcript

也不能把 Stop 描述为所有远端调用的立即强制终止。executeCodeMode() 参数没有接收这个 AbortSignal,内部等待动态 Worker 的 entrypoint.run();工具 loop 的取消与正在执行的 RPC 有不同传播边界。此实现中 5 秒 timeout 是执行后等待 tail log 的超时,不能误写成 Code Mode 程序最多运行 5 秒。Code Mode 的执行与日志等待、run 与 timeout 顺序

等待外部授权分两条路径。requestConnection 只有真正创建请求成功才设置 connectionRequested,在 barrier 保存卡片后结束本 turn;参数错误或资源无法解析时,模型还能修正重试。createExternalResource 则立即返回模拟资源 Gatekeeper 并加入 chatBindings,真实 provider 创建等用户批准;Agent 可先围绕模拟对象继续组织操作。二者不能用同一个“请求后都暂停”规则概括。requestConnection、createExternalResource

批准后的继续执行通过持久记录协调。暂停时清除 chat 的 busy 标记,但保留原发起者与模型的 ActiveAgentRecord;#decisionWait() 从日志一起检查 connection request 与 awaitDecision Action,仍有 pending 就等待,有 denied 则删除执行记录,不自动恢复。全部通过后,resumeAgentAfterDecision() 在异步解析模型前同步记录 agentNudge、设置 busy 状态并登记执行,再走重启也使用的 #resumeAgent()。批准页面关闭不会单独取消这份恢复记录,也不会仅因批准者不同而切换原 turn 的模型与计费身份。兼容旧暂停 chat、缺少原记录时是例外:此时可能采用显式批准者身份。保留暂停记录、决定分类与 durable resume

DO restart 根据 activeAgents 恢复未结束的用户 turn。构造阶段先登记 running set 和 LiveChatContext,使重启后立即到达的 Stop 也有对应 controller,再解析模型并重放 durable history。模型查询失败分为两类:模型被删除等永久失败进入可见错误;DO reset 等暂时失败可留待 alarm 重试,resumeAttempts 的持久预算最多 4 次。这个预算仅约束恢复准备时的模型查询,不是推理或远端效果的重试次数。恢复的是最后持久 step 之后的执行,不保存 JavaScript 调用栈、Promise 或未完成 stream。恢复入口与模型查询预算

恢复记录、忙碌标记与内存对象各管什么

状态保存位置恢复时的作用
ActiveAgentRecord持久 activeAgents 表找回原发起者、model ID、发起方式与恢复准备预算;等待审批也可保留
chatMeta.activeAgent持久 Chat metadata表示本 Chat 有执行或准备中的工作;缺失时仍可能在等决定
#runningAgents当前 DO 的内存防止本实例为同一 Chat 重复领取执行,不充当持久任务列表
LiveChatContext当前 DO 的内存保存 AbortController,并用对象身份识别异步工作是否仍拥有本次执行
queuedSteers持久 Chat metadata保存尚未被模型消费的追加输入,消费后转成有序用户日志
pendingAgentCalls持久队列保存尚未转成 callback 日志的调用与可持久参数
agentNudge持久内部日志表示审批后继续、Stop 或 Retry 的输入边界,供下一次重建解释

执行记录字段、同步领取执行、审批 nudge

这些状态配合起来,才能回答“还应该继续吗”和“谁有权继续”。恢复过程先读日志,区分未结束 step、已保存的最终回答、待审批事项,以及最终回答之后的新 steer。只有确实需要继续时,才按原发起者查询当前模型;协作者发起的执行还要重新核验 build 权限。审批者可以让等待条件满足,却不会因此接管原 turn 的模型身份。恢复前分类及身份核验

异步模型查询期间,Stop、新消息准备或另一份执行上下文都可能改变局面。因此查询返回后必须再次检查 LiveChatContext 的 identity、取消信号、busy 标记与 message preparation。旧 Promise 即使后来成功,也不能把已经停止的 turn 重新启动。这是内存执行归属与持久恢复身份一起工作,而不是靠一个 isRunning 布尔值处理所有情况。查询后的归属检查、等待准备完成再检查

恢复准备的每个 attempt 在查询前就递增并持久化,成功准备后清零,避免反复 eviction 让预算从头开始。一次 attempt 内,replay-safe User DO read 还可能使用 fresh stub 重试一次;所以“4 个恢复 attempt”不能写成“最多 4 次网络请求”。这一预算与模型请求本身的两次 transient retry 也是两套独立机制。恢复预算落盘、DO reset 读取重试

源码没有给主 Agent 暴露一个通用 sleep 工具。运行中的 Agent 或 pending callbacks 会持有一个固定的 keep-alive alarm 时间(首次 outstanding work 后 60 秒);alarm handler 等所有 Agent、callback drain 和响应投递完成,期间新工作产生时继续等待。系统空闲才清除相应 alarm。因此应区分“没有新消息时 Agent 不运行”“DO 自身被回收后重新实例化”和“应用通过 Scheduler 等能力安排未来事件”,不能统称为模型挂起后原地苏醒。alarm 调度

仅等待审批、没有其他 runnable 工作时,不需要维持这条 keepalive;Workspace 的 Alarm 是协调器,不是每个模型 step 的独立定时器。后续审批事件重新领取执行,回到相同的上下文重建与 step loop。共享 Alarm 的准入与等待

临时模型失败是另一条有界恢复路径:isRetryableAssistantError() 判定可重试后,外层最多追加两次尝试,等待分别为 1 秒、2 秒,每次重读 durable history 并发送 streamReset。这不是所有异常自动重试;工具错误通常成为模型可读取的 tool result,由模型修正下一步,永久 provider 错误进入可见 error。重试计数保存在这一轮 runAgent 调用内,不是跨任意多次 DO restart 的全局上限。retry loop、错误分类

8.8 Callback 把 Agent 从 request-response 变成事件驱动执行者

Code Mode 收到的 self 指向当前 chat。await self.foo(args) 的完成含义是 callback 已记录,不是 Agent 已经处理完成、更不是等到模型返回业务结果。否则当前 Agent 等自己产生的下一轮 Agent,容易形成自等待。spawnCallable() 也是这种持久投递模型。self 语义

deliverAgentCallback() 先把方法、实参、摘要和模型发起者写进 pendingAgentCalls,然后安排 alarm。参数必须可持久存储;普通 transient RPC stub 会在同步序列化时被拒绝,只有 persistent stub 能安全进入未来消息。若 chat 正在运行或 preparation 尚未结束,callback 继续留在 storage,不干扰当前 prompt。持久入队

drain 按 chat single-flight:同一 chat 的多个并发 drain 请求共享同一个 Promise,避免同时解析模型或重复投递。查询 User DO 后重新检查 message preparation 和 activeAgent;从最终检查到“把全部排队 callback 写进日志、删除 pending record、启动 turn”之间没有 await。arguments 放到不发给客户端的独立表,聊天只存摘要及 foo_ARGS 一类 binding name,完整数据在下一轮 executeCode 的 env 中取得。single-flight、投递同步尾部

多个待投递 callback 可以合在同一个 turn;选择模型与计费归属时使用第一条 call 的发起者。原模型已消失时仍把 callbacks 写进 chat 并说明无法启动,避免 alarm 永久重试;User DO 暂时不可达时保留 pending record,alarm 负责再投递。turn 完成、失败、无法恢复等结束路径统一进入 #finishAgentTurn(),丢弃旧 LiveChatContext,再 drain 等待队列,新 turn 获得新的 AbortController。结束移交、永久与暂时失败区分

callback 的“待投递”记录一旦转成 agentCallback 日志、独立参数记录和执行身份,重启就走正常 Agent 恢复,不再从 pending 队列递送同一行。但上游如果重新调用接口,会获得新的 call ID;队列内不重复搬运一行,并不能推出整个调用传输具有 exactly-once execution。callback 投递的同步尾部

这里的串行边界是一个 chat,不是整个 Workspace。不同 chat 的 Agent 可以同时存在,由各自运行登记和 durable queue 协调;共享 Gadget 的并发接受则由代码 pin、head 校验与后续 merge/accept 规则处理。spawned Agent 的工具列表更窄,并且文件写工具额外拒绝修改 Gadget,只允许修改 Worktree;它可以经绑定执行任务,不等于获得有人值守 coding chat 的全部修改能力。spawned 工具集、运行时写入检查

8.9 沿源码验证这套机制

阅读时可以带着以下输入变化核对状态,而不只浏览函数名:

  1. 读后有其他 chat 接受新版本:追踪 observedOid → editFile 首写检查 → pin → commitAgentStep head 检查,确认过期读取为什么被拒绝。
  2. 同 step 写文件再执行:追踪 stepBuffer.changes.length → executeCode guard,确认何时 overlay 已变、何时 durable code 才变。
  3. 模型输出后用户取消:分清取消发生在 request streaming 还是工具执行,检查 transcript 中缺失 tool result 为什么记作 Operation aborted。
  4. barrier 后 DO 重启:检查 activeAgents → decisionWait / steer / final-answer 分类 → loadChatHistory,确认完成、等待批准与仍有新输入时为什么走不同分支。
  5. 长聊中 callback 到达:检查 callback 先 durable 入队、当前 turn 后 drain、下轮 env.foo_ARGS 的形成过程。
  6. compaction 后撤回早期提案:检查原 log 仍在、checkpoint 代码字段 refold、摘要保留,区分“曾经做过什么”和“当前还提议哪些代码”。

上游测试用预置模型响应与故障注入,检查消息、持久日志与恢复之间的关系;steering harness 还会拒绝未 mock 的外发请求。覆盖范围、实际结果和复跑命令见第 11.4 节。真实 provider、OAuth scope、生产重启与远端副作用的幂等需要分别验证。

9. Git、实时编辑与 Blueprint:从文件操作到公开 release

9.1 代码究竟放在哪里:Git object database 与 chat 的修改流

Cloudflare OS 没有为每次对话创建一个磁盘目录,再运行 git checkout -b。一个 Workspace 的 Overseer 保存共享的 Git object database;每个 Gadget record 指向自己的 mainline commit;一个 chat 只为实际修改过的 workpiece 固定 commit,并在其上叠加有版本号的 CodeChange。这里的“chat 分支”是产品语义:pinned commit + 当前 epoch 的变更,不是 .git/refs/heads/ 中的一条 Git branch。GitStore 的边界、commit-backed chat code

需要先分清两种“接受”。submitCodeChange() 接受一条编辑,表示服务器已将其排入这个 chat 的修改流;用户点击 Accept changes 调用 mergeChanges(),才把整个 chat 的提案提升为 Gadget mainline。第二种接受也不是 Publish as blueprint:后者还要生成公开 release,再向 R2/KV 分发。修改流记录、接受实现、release 生成

Git VFS 虚拟的只有文件系统,Git 对象是真的

makeGitObjectsFs() 是 isomorphic-git 与 Durable Object storage 之间的窄适配层。它只认 /git/objects/xx/yyyy…,把路径中的 40 位 oid 映射成 gitObjects collection 的记录。读取返回压缩字节;写入复制 Uint8Array 后存储;不支持的写路径报 EPERM。mkdir() 是空操作,因为存储层没有真的目录;objects/pack 返回空数组,因为此对象库保存 loose objects。VFS 实现

每条记录的 data 是 zlib 压缩后的真实 Git object,oid 为 SHA-1 内容地址。相同 blob/tree 可被多个 Gadget、多个历史复用,但这不等于存储 Git delta:每条 loose object 记录保存整个对象的压缩结果。对象库没有 refs、tags、index 或 HEAD;产品层的 GadgetRecord.commitId、Blueprint records 与 chat pins 承担引用职责。这里只使用 isomorphic-git 的 plumbing;commit 由项目自己的 encoder 写,以便保留 blueprint-release 扩展 header。对象结构与无 refs 设计、GitObjectRecord

因此,“文件保存了”可以对应不同层次:blob 已写入只说明对象存在;record 指向该 commit 才说明它成为当前代码。mergeChanges() 和 Blueprint 更新会先写内容寻址对象,再核验状态并移动 record 指针。若后面的检查发现 stale,前面写入的对象可能成为 dangling objects,却不会部分推进用户可见的 head。当前没有 GC,不能把“删除 chat/Gadget”理解成“它碰过的所有 blob 都物理删除”。写入与 GC 约束、接受前最终核验

CodeChange 为什么不是一份源码

真实类型只有这一层映射:

ts
export type FileChange =
  | { edit: TextChange }
  | { set: string }
  | { remove: true };

export type CodeChange = {
  [gadgetId: number]: [path: string, change: FileChange][];
};

这是按 Gadget 分组的文件变更。未出现的文件保留原状;edit 只带文本操作,不带原文;set 才携带某个文件的完整新内容;remove 删除一个路径。它甚至不能区分“一个空 Gadget”和“不存在的 Gadget”:Gadget 是否存在由 registry 决定,不在文件 diff 里。wire types、diffFiles 的边界

文件项使用 [path, change][],没有使用 {[path]: change},也不是随手选的序列化风格。Git 合法文件名可以是 __proto__、constructor 或 toJSON;Cap’n Web 反序列化会删除对象上遮蔽原型成员的 key,用路径作为对象 key 会静默丢文件。外层 Gadget key 则限定为 canonical decimal,不会产生这种碰撞。设计解释

TextChange 覆盖整份旧文本的坐标范围,但不重传被保留的旧文字:数字表示保留多少 UTF-16 code units,数组的第一个数表示删除长度,后续字符串按 \n 连接作为插入内容。它必须与特定 revision 的旧文本长度一致。下面是直接调用该版本 applyCodeChange() 已核验的教学输入:

ts
const before = new Map([
  [7, new Map([
    ["server.js", "const limit = 10;\n"],
    ["README.md", "stable\n"],
  ])],
]);

const change = {
  7: [["server.js", { edit: [14, [2, "25"], 2] }]],
};

const after = applyCodeChange(before, change);
// server.js -> "const limit = 25;\n"
// README.md -> "stable\n"
// before 本身仍保留 limit = 10。

14 跳过 const limit = ,[2, "25"] 替换原来的 10,最后的 2 保留分号和换行。若把同一操作施加于不存在的 server.js,源码抛 edit of absent file;若旧文长度不符,ChangeSet 也会拒绝。即使长度恰巧相同,也不能据此认为旧内容正确:revision 与 pin 的校验负责保证它落在应有的基底上。applyCodeChange() 自己只负责应用,既不从 Git 读取 commit,也不猜测原文。TextChange 的坐标含义、实际应用逻辑

所以还原 chat 代码至少需要三样东西:其最后有效 pin 对应的 commit tree、该 pin 之后的有效 changes messages,以及尚未 materialize 的 live rows。只把一次 CodeChange 打印出来当“生成的完整代码”,会漏掉未改文件和被保留文本;只把 changes message 都顺序拼上去,也会忽略 epoch、re-root 与 revert。内容重建实现

一个 chat 的可重建状态:pin、revision、generation 与 epoch

字段它回答的问题不要混淆的对象
pin.baseCommit后面的未提交变更叠在哪棵 tree 上?不一定等于当前 mainline head
pin.mergedCommit这个 chat 已纳入了哪个 mainline commit?update 后可与 baseCommit 不同
generation当前是哪一条仍有效的编辑流?不是 Git commit counter
revision此 generation 已接受了多少条流变更?每次 generation 变化后重置为 0
epoch当前代码历史从哪个 boundary message 后开始重建?不是聊天消息总数

永久 Gadget 在一个 epoch 的第一次修改时 pin;没有被 pin 的 Gadget 跟随当前 mainline。pin 因此既是“修改基准”,也是“这个 chat 涉及哪些代码”的记录。一次 chat 可以 pin 多个 Gadget,每个 Gadget 有自己的 commit 与历史,共享的是 chat 的 revisioned stream,而不是所有 Gadget 共用一个 head。ChatCodeBase 与 pins

一条新 pins 声明会 re-root 所指的 Gadget:先从声明的新 baseCommit tree 开始,此 Gadget 在同一 epoch 更早的修改不再重复应用。composeEpochChanges() 遇到声明时,会删除此前已 compose 的该 Gadget 部分,再 compose 本条 message 的 change。别的 Gadget 的修改保持不动。这避免把已经写进 merge commit 的编辑又重放一次。re-root 规则、composeEpochChanges

materializeChatChanges() 也不等于 Accept。它将短期 rows 合成一条持久 changes message,附 watermark,旧 rows 退为供迟到提交 transform 的窗口;当前内容不变。Accept 才会关闭 epoch、清空 pins、把 revision 归零并增加 generation。客户端不能在重建时把 message 里已经 materialize 的 change 和旧 row 各应用一次。row 生命周期、epoch reset

9.2 TextChange:同一段修改为什么需要正确原文

workshop-shared/src/code-change.ts 的 applyCodeChange() 将 TextChange 应用到由 revision/pin 确定的文件内容。结果进入 chat 的提案状态,尚未成为 Gadget mainline。

输入原文为 const limit = 10;\n:保留 14 个 UTF-16 code units,删除接下来的 2 个,插入 25,最后保留分号与换行这 2 个。结果为 const limit = 25;\n。把数值换成 125,输出长度增加 1,后缀仍从旧文本的第 16 个 code unit 起读取;不是按新文本坐标倒推删除范围。

旧文本 const limit = 10;\n 长度为 14 + 2 + 2 = 18。如果原文在 const 后多出一个空格,变成 const limit = 10;\n,长度就变为 19;原来的 [14, [2, "25"], 2] 仍只覆盖 18 个旧 code units,因此会被拒绝。按这份新原文生成的变更应为 [15, [2, "25"], 2],保留前缀的长度也要随之增加。

长度检查有必要,但还不充分:两份原文长度相同,仍可能内容不同。服务器还要校验 generation / revision,将迟到编辑 transform 到当前状态,并核验 pins。这些协议检查在下一节展开;一次纯函数输入的结果不能证明整个 OT 服务或 Git 接受流程正确。

9.3 实时 OT 与 Git 三方合并解决的是两种竞争

同一个 chat 内,先把并发编辑排成一条流

客户端提交的不只是 change,还包括 generation、已看见的 revision、随机 clientId、该客户端逐条增加的 seq,首次触碰已有 workpiece 时另带 pin。服务器先验证 schema,再把迟到提交 transform 到当前 revision,最后对实际内容验证长度与 Unicode 边界并落 row。校验顺序不能颠倒:变换前的坐标不属于当前文本。submission 格式、服务端 transform 与内容验证

transformCodeChange(a, b) 的约定是:a 先被服务器排序,返回的 a 可应用于 b 后面,返回的 b 可应用于 a 后面。两条路径应收敛。在同一空文件位置同时插入 A 和 B,a 先排序时,两条路径结果均为 AB;这个顺序由服务端固定,不能让各个客户端自行选择。OT priority convention、transformCodeChange

同 revision 对同路径的并发操作transform 后的规则
edit 对 editCodeMirror ChangeSet OT;同坐标插入时先排序者的文字在前
set 对 edit整文件 set 保留,旧基底上的 edit 被丢弃
remove 对 edit删除保留,旧文件上的 edit 被丢弃
set/remove 对 set/remove服务器后排序者胜出
不同 Gadget 或不同文件两边各自保留

这里的“删除胜过 edit”是明确的协议选择:旧文本被整体替换/移除后,其局部坐标已无从解释;不是代码语义层面的智能消解。file-level transform rules

重试也不能简单再发送一次“同样的文本编辑”。服务端用 (userId, clientId, seq) 记录最后一次已接受提交与 SHA-256 digest:同 seq、同 payload 返回原落点;同 seq、不同 payload 拒绝。这避免“服务端已经写入,但响应在网络中丢了”的重试把插入执行两次。此 dedupe record 活得和 chat 一样久,不随 materialization 或 generation reset 删除。dedupe schema、先 dedupe 再校验基底

不同 chat 推进 mainline,用显式基准做三方合并

假设 C0 的 server.js 是 const limit = 10;。chat A 和 B 首次编辑时都 pin 在 C0。A 改成 30 并 Accept,mainline 变成 C1;B 自己看到的 25 不会凭空变成 30,因为它仍 pin 在 C0。B 直接 Accept 时,record.commitId = C1 与 pin.mergedCommit = C0 不同,返回 {outcome: "stale"}。这一步没有偷偷拿 B 覆盖 A。fast-forward gate

B 调用 updateChatFromMainline(),传给 threeWayMerge() 的三侧分别是:

text
base   = tree(pin.mergedCommit) = C0 的 limit 10
ours   = tree(mainline head)    = C1 的 limit 30
theirs = 当前 chat 的文件       = B 的 limit 25

这是同一行的竞争,当前真实函数产生:

text
<<<<<<< mainline
const limit = 30;
||||||| merged base
const limit = 10;
=======
const limit = 25;
>>>>>>> this chat

随后写 S 保存更新前的 chat 文件,以旧 pin.baseCommit 为 parent;写 M = [C1, S] 保存合并结果;把 pin.baseCommit re-root 到 M,pin.mergedCommit 改成 C1。Gadget mainline 此时仍是 C1,用户可在 chat 预览和修改 M 的结果。若后面没有再编辑,Accept 可以直接把 head 前进到 M,无需再造一个同内容 commit;若修改了解冲突,则在 M 上提交最终文件。如此同时保留 mainline 和 chat 的来源。三侧输入与 S/M 写入、复用 merge parent

同 chat OT 与三方合并有一个容易漏掉的差别:三方合并的 delete-vs-modify 不会直接删除文件。 它保留改动一方的全文,同时把路径列进 conflictPaths,但没有 inline markers。不能通过“全文没有 <<<<<<<”判定一切无冲突。threeWayMerge 的真实分支

base 与两侧状态三方合并结果
只有一侧变更,含单侧删除取变更一侧,clean
两侧完全相同,含都删除取相同结果,clean
一侧删除、另一侧修改保留修改,路径进入 conflictPaths,无 markers
两侧修改,diff3 区段互不重叠合并两个区段,可能 clean
同区段冲突,或同路径新增不同内容产生三方 markers,并记录路径

“不同的行”不保证 diff3 一定判断无冲突。在用于核验的输入中,连续两行分别被两侧修改仍被当成同冲突区段;在两处修改间保留一行共同上下文后才 clean。它是文本合并,不理解 TypeScript AST、API 兼容性或业务含义。

同样,当前 mergeChanges() 没有“扫描 conflict marker 并拒绝 Accept”的 gate。解决冲突、检查语法与执行效果是正确的审查步骤,但不能声称源码已经强制保证这些步骤通过。 Accept 强制的是 head、pin、chat 状态的一致性。applyBlueprint() 在 kind: "merge" 且配置了 reviewing model 时启动 reviewer Agent,也是因为文本 clean 不能证明逻辑兼容。reviewer 的适用条件、Accept 的最终检查

Accept 为什么接受全部,以及 Accept 后正在输入的文字怎么办

mergeChanges() 先 materialize live rows,再接受 chat 当前所有未 revert 提案。它不支持任意挑几条 changes message 接受:epoch reset 会丢弃旧 stream,若排除其中一部分,留下来的变更将失去原来的根。对多个 Gadget,先计算各自最终内容、检查 stale、写对象,再在无 await 的同步尾部提升 provisional records、bindings 并推进 heads;一个相关 Gadget stale 就不会部分推进其余 Gadget。接受全部的原因、最终核验与同步推进

接受结束后写带 epochBoundary: true 的 merge message,清空 pins,revision 回到 0,generation 加 1。代码此时已经在 commits 中;下次编辑按新 head 重新 pin。为处理“用户在点击 Accept 的同时,另一个标签页还有一笔请求在途中”,服务器保存最近一次内容保持边界,并临时保留旧 rows 供 OT:先 transform 到旧 generation 的末尾,再通过边界 commit,最后 transform 当前 generation 的 rows,这叫 straggler bridge。reset 与 boundary、跨 generation transform

这个 bridge 不是无条件保留任何旧请求。revert、discard、update-from-mainline 属于 destructive generation bump,没有可继续的旧基底,客户端要丢弃旧编辑状态并重建;只有最近一次内容保持边界、还留着完整 transform window 且涉及 Gadget 内容连续时才可 bridge。Accept 计算期间若新 row 已被服务端确认,但尚未被本次快照覆盖,Accept 会要求 retry,保留那笔 row;不能把它当“还在途中”然后忽略。generation 两类变化、接受期间新增编辑

9.4 多 Gadget 与 Worktree:共享对象库,不共享生命周期

永久 Gadget 的 commitId 是 mainline head。一个还没有文件的永久 Gadget 也有 empty-tree 初始 commit;只有当前 chat 创建、尚未接受的 provisional Gadget 可以没有 head。它的文件从本 chat 的 proposed changes 构建;创建和 binding additions 与代码一样有 pending: {chatId, sequence},只有该 chat 能预览它们,Accept 后才对 mainline 和其他 chat 可见。pending binding 仍占住名字,避免两个 chat 同时声明同名不同连接。GadgetRecord 与 pending

Worktree 不是能执行的 Gadget,也不是本机 checkout。它是从某个 commit 起步、归属于特定 chat 的文件树,没有 output、bindings 或 facet,不会成为应用;文件工具和 env.REPO 的 Worktree API 共同读写这一棵树。大型仓库不会整体展开到 CodeContent:读时从 Git cache 按路径取,写过或为 edit 读取的文件才进入 overlay,删除用 tombstone 路径集合表示。WorktreeRecord、惰性重建、同一 session 入口

Worktree 字段何时改变意义
baseCommit创建后不变最初从哪里取出的仓库
headCommit显式 commit()、revert 显式 advancementAgent API 看到的 HEAD,后续显式 commit 的 parent
pinBaseAccept 关闭 epoch 时已接受文件内容,新 epoch overlay 的基底

例如初始三个字段都是 R0。Agent 修改后 commit() 得到 R1,headCommit 前进到 R1,但 pinBase 仍是 R0,本 epoch 的 OT rows 也不清除;否则重建会把同一修改应用两遍。若此后又改文件并 Accept,服务器可能写一个 auto-commit A1 保存全部接受内容,只让 pinBase = A1,显式 HEAD 仍为 R1。下次显式 commit() 的 tree 由 A1 + overlay 构造,parent 仍是 R1,auto-commit 因此从显式历史中 squash 掉,不会带着接受用户的自动提交身份一起 push。commit() 的 treeBase/parent 分工、Accept 的 Worktree 自动提交

Worktree 创建本身不算待接受提案,首次修改或显式 commit 才 pin。创建后仅 revert/discard edits 会保留 Worktree,回退其内容与显式 HEAD;删除 chat 才删除它名下全部 Worktrees。接受使文件内容持久化,但 Worktree 仍归属于原 chat,不会自动加入其他 chat 的 env,其他 chat 不能直接把它作为文件工具或 env.REPO 目标。

build 订阅仍可枚举带 chatId 的 Worktree summary,已提交 Git objects 也在 Workspace 共享对象库中,知道完整 oid 的授权调用可读取它们。这里的 chat 归属描述编辑与生命周期,不能当作保密边界。生命周期与回滚、回滚显式 HEAD、chat 删除

还有另一种入口 env.GIT.newWorktree(commitId):它复用同一个 WorktreeSessionImpl,但以 session 内存对象代替 chat registry 与 turn state。未提交 overlay 跟随这次 session 消失;commit() 写出的 Git object 仍留在 Workspace,返回 commit id 是持久 handle。别把它和 Agent 为 chat 创建的 Worktree record 混为同一个生命周期。Git binding、newWorktree 实现

9.5 Blueprint 为什么能更新,却不复制私有历史

从私有 Gadget tree 构造另一条公开 release graph

Blueprint 分享当前已接受文件、binding requirements 和公开 metadata,既不分享原 Workspace 的数据库与连接,也不分享原 Gadget 的 commit history。原因不仅是 chat history 另有表:Gadget commits 的 message 会出现 chat title,author 包含协作者,直接把源 commit 当 release parent 就会把这些历史一并带出去。公开范围、release encoder 的隐私边界

mintBlueprintRelease() 因而使用源 Gadget head 的 tree,重新编码一个 release commit:author/committer 为已公开的 Blueprint owner,message 为 Release <version>: <title>;第一 parent 是此 Blueprint 上一个 release,其余 parents 只取源 Gadget 新合入的公开 releases。derived Blueprint 没有自己的前代时,先写空 tree 的 Release 0 保住本 lineage 的第一 parent。source commit id 留在私有 Overseer record 中供下次发布比较,不作为私有 ancestry 输出。mintBlueprintRelease、BlueprintGadgetRecord

text
公开 Blueprint A:A1 ─ A2 ─ A3
私有 Gadget B:   e ─ i ─ b1 ─ b2     i = [e, A2]
公开 Blueprint B:B0 ─ B1             B1 = [B0, A2],tree取自b2

B1 可以保留“基于 A2”的公开关系,因此日后能三方合并 A/B;它的 parents 中没有 b2、b1、i,也就没有沿 parents 追出 B 的私有开发历史的路径。复用相同 tree/blob 是按内容共享,不等于公开私有 commit graph。lineage 示例与原则

不能把“第二 parent”一律视为 release。update-from-mainline 的 M=[H,S] 中,S 是私有 chat snapshot,公开它同样会泄露标题和作者。因此三个真正合入 release 的路径会写 blueprint-release <R> header,releasesMergedBy() 只承认“被此 header 标记、且确实是非第一 parent”的对象。普通输入不能用 commit message 注入该 header;导入 pack 的 canonical validation 也不允许这种额外 header。release 标记与读取

这条隔离只保护未选择发布的历史,不是源码的自动脱敏器。当前 tree 里若已经写入 token、内部 URL 或私人内容,它们仍会进入 Blueprint;发布者仍须检查当前文件与 binding annotations。另一处细节是 release commit 不写 Blueprint id,因为 id 本身是 bearer share link,derived pack 会转发 release commits。所属关系写在 metadata 与 GadgetRecord.upstream,而非公开 commit header。不在 release 写分享 id、官方发布范围

Pack 为什么带祖先 commits,却不带所有历史 files

release pack 带当前 release 完整 tree、所有祖先 release commits,以及每条其他 lineage 最新 fork point 的完整 tree。祖先 commits 让消费者能找 common ancestry;fork-point tree 则让消费者真正有三方 merge 所需旧文件。它不是一个完整 Git clone:同 lineage 较早版本往往只有 commit metadata,没有 tree,因此“知道共同 commit”与“持有可用 merge base 文件”是两个条件。buildReleasePack

fork point 随 pack 传送,不在需要时远程取,有三重原因:commit id 本身定位不了按 Blueprint id 键控的 R2;原 Blueprint 被删后 derived Blueprint 仍应可用;导入别的 deployment 的 .gadget 文件必须自包含足够的合并资料。对象内容寻址又能复用相同 blobs,携带一棵 tree 不必重复写它未改变的所有文件。fork-point 取舍

导入 Workspace 前,readReleasePack() 重新计算 object ids,并执行 validateReleaseObjects():必须有 release 与闭合 ancestry,当前 tree 完整,其余 tree 完整或缺席;只容纳允许的 commits/trees/UTF-8 regular blobs,不允许额外对象;pack 及单对象解压上限 32 MiB,最多 10,000 commits。上传 .gadget 先检查 archive envelope,实际内容在加载入 Workspace 时校验,因此“上传成功”不等于“代码 pack 已可实例化”。pack 校验、读取 pack、archive 校验时机

应用 release 仍是提案,直到 Accept 才生效

applyBlueprint() 从 public metadata 找当前 release,加载校验对象后,以 Gadget head 与 release 的 common base 执行 threeWayMerge(),写 M=[H,R] 并标记 R,创建一个新 chat,pin 在 M、mergedCommit=H。Gadget head 和 upstream 此时均未变。Accept 才将 head 前进、记录 Gadget 跟随的 Blueprint 与 release。提案构造、接受 upstream

kind对文件的含义历史/跟随关系是否仍可能变化
follow合并结果等于 Gadget 当前文件且无冲突,或 release 已在历史中仍可能需要记录已合入该 release、改 follow id
fastForwardGadget 相对 merge base 没有自己的文件变化,结果取 release 文件实现仍可写 [H,R] merge commit,不等于直接让 head=R
merge两边都有需协调的变化,或存在冲突写提案;可配置 reviewer Agent 读取和修复结果

这张表还有一个重要反例:Gadget 修改的文件被 release 删除,threeWayMerge() 会保留原修改,所以结果可能完全等于 Gadget 当前文件;因为仍有 delete-vs-modify conflict,它必须是 merge,不能误标为 follow。kind 的实际分类

common ancestry 不存在时,默认不假装能安全合并。服务器尝试 Gadget 已记录的 upstream release,或本 lineage 最早带文件的 commit;若候选 base 不是 Gadget 从当前 Blueprint 接受过的已知 release,且 base 文件集非空、新 release 的 history 也不持有相同 tree,则标为 unverified,缺少 allowUnrelated 时返回 unrelated。若存在共同 commits 却一个 base tree 都没持有,返回 baseUnavailable;显式同意 unrelated 也不能凭空补出缺失文件。merge-base 选择

9.6 状态表:排查时先确认哪一层才是权威

数据权威位置 / key用途
已提交源码对象Overseer gitObjects,oid 为主键blobs/trees/commits,共享去重
Gadget mainline 与 workpiece 是否存在Overseer gadgets,workpiece idtype 区分 Gadget/Worktree;registry 决定列出、执行与展示
chat pins 与流位置Overseer chatMeta,chat idcodeBase 保存 generation/revision/pins
chat 持久内容记录Overseer chats,chat id + sequencechanges/pins/merge/revert;可按历史位置重建
尚未 materialize 的编辑Overseer chatChanges,chat id + generation + revision当前流;retired rows 只供 transform
重试去重chatChangeClients,chat + user + client上次 seq、digest 与原落点
最近可 bridge 边界chatChangeBoundaries,chat id关闭 generation 与各 Gadget 内容连续 commit
压缩对话的代码状态chatCompactions,chat + compactedTopins、epoch、composed proposedChange;原 message 仍保留
Blueprint 作者端记录Overseer blueprints,Blueprint idsource commit、release 列表、metadata、dirty
Blueprint 对外查找与分发User DO 列表副本 → Workers KV metadata;R2 packmetadata 的 commitId 指向 <blueprintId>/<commitId>
Git remote 来源与 push 授权Overseer gitObjectMetadata,oidonRemote 是证明、pullableFrom 只是路由提示、pendingPush 是 action 绑定的临时读权限

这些 collections 是 typed-storage 对 Durable Object 自己的 storage.kv 的封装,用 collectionName:key 前缀和派生 index 组织记录;不能因为看到 kv 就把它们误认为公共 Workers KV BLUEPRINTS namespace。写 record 与维护 indexes 通过 transactionSync() 配套完成。Blueprint 的 DO→User DO→Workers KV/R2 则是跨服务单向传播,以 dirty 与 retry 修复,不能跨这些层宣称原子事务。schema 的对象与来源集合、chat 集合、typed-storage prefix、Blueprint 存储传播

删除也要按层理解。删除 Gadget 会删除 registry entry、相关 hooks 与 facet,Gatekeepers 保留;旧 commits/可能仍被旧 chat pin 的对象留在共享 store,失去 registry 的 Gadget 内容不会继续被执行。删除 chat 清理它的提案、该 chat 名下的 Worktrees、rows、checkpoints 与 dedupe,而不是反向删除已经 Accept 到永久 Gadget 的 mainline。删除 Blueprint 则移除其 R2 prefix 与公开索引;消费者已经导入的 Git objects 是另一个 Workspace 的数据,不会因上游删除而远程清空。删除 workpiece、删除 chat、R2 删除范围

10. Scheduler:把持久 capability 送回 Workspace,而不是定时重放一段 Prompt

Scheduler 在完整系统中的作用,是让 Gadget 注册一个将来仍能恢复的回调,再由 Workshop 在每次投递前重新检查授权。输入是 cadence、callback 与展示信息;输出是一次携带 runId 的 onSchedule(firing) 调用。它不是给模型进程加一个永远不退出的定时器。Scheduler contract

10.1 注册、启用、准入、执行是四件事

调用 every()、calendarAt() 或 runAt() 只注册 disabled hook。用户从 Connections 启用时,先调用 controller 的 enable(),让 ScheduleDriver 保存 schedule state 和可持久恢复的 initiator;成功返回后,Overseer 才将 Hook 标记为 enabled。跨对象的这两个写入并不原子,投递时仍由 startHook() 检查实时准入。启用顺序。

因此,取得 schedule ID 只证明注册完成,不能据此认定任务已经启用并开始投递。calendarAt() 使用显式 IANA timezone,every() 按 elapsed UTC time;把一天写成 24 小时间隔,与按当地每天某时执行,在夏令时切换时会产生不同结果。注册与 cadence

到点后,真正的投递顺序如下。图中的 admission 与 authorization 是两个独立检查点;前者拒绝与后者失败,对重试计数的影响不同。

sequenceDiagram
    participant D as ScheduleDriver DO
    participant S as 持久 schedule state
    participant W as Workshop HookInitiator
    participant Q as ApprovalQueue
    participant G as Gadget callback
    D->>S: beginDueRun:pending/admission,记录 runId 与 lease
    D->>W: await startHook()
    alt Hook / account / Gatekeeper 已失效
        W-->>D: reject
        D->>S: rejectRun:跳过本次,不消耗 callback attempt
    else 通过准入
        W-->>D: callback + approvalQueue
        D->>S: admitRun:pending/delivery,attempts + 1
        D->>Q: authorizeObservation()
        Q-->>D: 允许
        D->>G: onSchedule(firing)
        G-->>D: 返回
        D->>S: completeRun:下个未来 slot 或 completed
    end

这条顺序可以直接在 #deliverPrepared() 核对。startHook() 先 await,不是先 pipeline 使用返回的 capability 再处理拒绝;授权失败或 callback 抛错会走 failRun()。pending 的 stage、runId 和 lease 都写入持久 state,回调结果到达后再核对当前记录,防止旧请求完成后改写已经重新启用或撤销的任务。恢复时还区分阶段:admission lease 到期会继续同一个 runId 的准入;delivery lease 到期先把本次已消耗的 attempt 记为失败,再进入有界重试,而不是另建一次 occurrence。恢复入口

10.2 为什么 count: 10 不等于十次成功

A schedule occurrence and a callback attempt are different counters. beginDueRun() 在一个 slot 到期、取得 runId 时就增加 occurrenceCount;admitRun() 才增加 attempts。重试保留原 runId,不再消耗 occurrence。准入拒绝不消耗 callback attempt,但这个 due slot 已经消耗 occurrence。状态转换实现

例如一个配置 occurrences: { count: 3 } 的每日任务:第一天成功;第二天 startHook() 暂时拒绝,但 Hook 未被 disable、driver row 仍保留,因此跳过这个 occurrence;第三天 authorization 成功,callback 首次调用抛错、重试调用成功。它用了三个 occurrence、三次 callback 调用,只有两次逻辑运行最终成功。第三天两次调用属于同一个 runId,attempts 从 1 到 2;第一天的 attempt 不累计到第三天。这里不能把第二天写成“用户 disable 了任务”,因为 disable 会删除 driver state。实现不逐个补跑已经越过的历史 slots:推进 recurrence 时从当前时间选择下个未来 slot,被跳过的 slots 不计数;这不等于保证已排队的一次 due callback 绝不会延迟执行。禁用后重新启用会重新计数,所以 count 约束的是这次 enablement,不是永久累计上限;而对仍有 driver row 的重复 enable(),实现保留原有进度,不会顺手归零。enable/disable 数据处理 次数边界

每个逻辑 occurrence 最多消耗八次 attempt;authorization 失败也会消耗 attempt,即使尚未真正调用 callback。前七次失败后的等待为 1、2、4、8、16、32、60 分钟;第八次失败进入 dead,UI 展示 Needs attention。state machine 同时以 runId 和 stage 防止迟到结果推进错误的一轮。retryDelay() 与终态

10.3 为什么仍需要业务幂等

如果 onSchedule() 已向外部系统写入成功,但网络返回丢失,driver 只看见失败,仍可能用相同 runId 再投递。应用应以 runId 识别同一逻辑 occurrence;但仅在回调结束后记录一个“已执行”标记,仍然有“外部写入成功、标记未保存”的窗口。要避免外部重复写入,目标 API 支持时也应传相同的幂等键;没有该能力时,就必须接受、检测或人工处理重复,不能把 DO 本地事务当成跨服务事务。

实现还有明确的调度边界:一个 account 的 alarm batch 最多取 20 项,普通投递并发为 4;代码注释指出,挂起 callback 仍会拖住这个 account 的 batch,恢复 watchdog 不会让同一个 live alarm handler 与自己并行。这是源码承认的实现取舍,不是通过重试次数能消除的问题。batch 与恢复边界 alarm handler

11. 用状态不变量读源码,再做可复现实验

源码规模很大,逐文件通读容易把细节记成零散名词。更有效的阅读单位是一个不变量:谁拥有状态、谁能改变它、改变前检查什么、失败后保留什么。下面是本文建议的阅读顺序,并非上游强制流程。

顺序要回答的问题先读的实现阅读后应能预测的结果
1请求怎样获得一个 Workspace 的权限?frontend main.tsx → useAuth.ts → backend server.ts → user.ts → Overseer.open() / sharing.ts区分 session、User DO、Workspace 与 Chat;解释只有 workspace ID 或 share link 时还缺什么
2什么算真实存在的 Gadget?storage-schema/overseer-storage.ts → createGadget() → getGadgetRecord() / readGadgetFiles()文件残留不会让已删除 Gadget 自动重新运行;workpiece registry 才是枚举入口
3一次代码修改何时持久化、何时被接受?code-change.ts → chat fold → commitAgentStep() → mergeChanges()stop、revert、accept、stale 分别改变哪层状态
4哪段代码正在用哪个数据库?loadGadgetWorker() → #getGadgetFacetRaw()切换 preview 会重启代码,但不克隆应用 SQLite
5模型为什么获得这些工具和上下文?prepareChatBindings() → runAgentPass() → compaction换 binding、停机恢复、历史被摘要后,哪些信息仍需结构化保留
6一次外部写入怎样到达 provider?Gatekeeper session → ApprovalQueue → ActionRecord → applyAction()simulation、approval、applied 之间的区别,以及崩溃后的重复窗口
7新协作者会怎样影响今后的读取?authorizeCollaborator() → addObserver() → authorizeObservation()已授权 Observer 被排除时,为什么旧 session 的一次新读取也可能失败
8一份设计怎样复制并升级?Blueprint release → pack validation → applyBlueprint()新实例得到代码和 binding requirement,原实例私有历史、SQLite 和 OAuth 不随之复制
9浏览器断线后怎样恢复显示?useWorkspaceOpen.ts → ChatInterface.tsx → subscribeToChat()持久消息补发、临时 stream generation 与 Agent 是否停止,是三个不同问题
10运行中追加文字怎样进入下一次模型请求?steerRunningTurn() → queuedSteers → takeSteers() → getSteeringMessages当前模型不变;带附件的新输入与普通 steer 有不同准入

从本地页面开始,可先在 main.tsx、PublicApi.authenticate()、Overseer.open() 和 OverseerClientInterface.sendChatMessage() 定位入口,再追 runAgent(),最后进入 executeCodeMode() 或 commitAgentStep()。前一条路径授予能力并执行调用,后一条保存模型输出与代码提案;不要把一次 tool result、一次 step 提交和用户 Accept 当成同一个完成点。浏览器入口、Workspace 准入、消息 RPC

11.1 先做五个预测,再打开实现核对

  1. 两个 chat 修改同一个 Gadget,都基于它的 commit A,chat 1 已接受到 B;chat 2 即使只改这个 Gadget 的另一个文件,也不能直接 accept。先检查的是这个 Gadget 的 head 与 mergedCommit,不会因为两份 diff 看起来不冲突就略过 stale。两个 chat 修改不同 Gadget 时,不能直接套用这个结论。accept stale gate
  2. 同 chat 的两次并发修改落在同一文本位置,不能直接按客户端到达时的旧 offset 依次 apply。先做 schema validation,再 transform 到当前 revision,最后按当前内容做 content validation。
  3. Agent 修改 server.js 后,紧接着执行同一 step 的 executeCode,不会读到一份“已经安全落盘的新代码”。实现会直接拒绝这次 executeCode,要求跨越 step persistence barrier 后,在下一 step 执行;不是偷偷回退执行旧版本。step guard
  4. Gadget preview 调用一个 increment() 方法,即使之后 revert 代码提案,也不能据此预测计数回到原值。代码状态与 Facet 应用状态由不同操作管理。
  5. 一个已获准的 Observer 不在线,不意味着 owner 可以读取他无权看的数据并留在同一 Workspace。检查的是仍有效的授权与范围,不是在线人数。

这些预测把产品对象、代码版本、实时编辑、权限和存储连在一起。能解释“为什么会这样”,比记住某个文件有多少行更接近掌握项目。

11.2 验证层次与适用边界

针对 ef34d8e 执行的纯函数验证覆盖:真实 applyCodeChange() 的 UTF-16 替换、文件创建/删除、epoch re-root、并发插入收敛、remove/edit;以及真实 threeWayMerge() 的非重叠 diff3 区段、同区段冲突、delete/modify。前者使用原模块的 CodeMirror 依赖,未调用的 fast-diff 入口用抛错 stub 隔离;后者执行从固定源码抽取的原合并实现及实际 diff3 依赖。另外,将正文教学模型在两种原文上对全部 1..999 替换数字逐一与真实 applyCodeChange() 对照,1,998 组输出与长度一致;额外空格配旧长度的输入也同样拒绝。这些结果验证指定输入下的算法行为,不等于完整 RPC、DO、浏览器或云端端到端测试。

选定的 Agent 相关测试分批执行,共 103 项通过,覆盖范围和命令见第 11.4 节;未运行完整上游测试套件。复现完整系统时,应在匹配该 commit 与 lockfile 的独立环境安装依赖,再使用根目录的 build、test 和各包 Vite+ task 入口。当前不少包的 task 定义在 vite.config.ts,不能因 package.json 没有 test script 就判断它没有测试。上游构建说明 上游测试分层

2026-10-20 已在 macOS、Node.js 26.0.0、pnpm 11.17.0、Wrangler 4.147.0 下完成第 5.5 节的启动链,主页返回 HTTP 200,浏览器登录页与注册页可正常打开。Node.js 26 是此次实际环境,不表示仓库要求的最低版本;未填写模型凭据,未验证 AI 对话、OAuth 与协作。wrangler dev 允许 localhost,因此本地运行也不能替代生产中 global_fetch_strictly_public 的 SSRF / 私网 egress 核验。local runtime boundary

完整验证还应覆盖 workerd 下的 Facet/持久 RPC、浏览器 iframe 与协作者交互,以及真实 provider 的 contract tests。尤其不能用 mock 通过证明 OAuth scope、Observer verifier 或远端幂等正确;它们验证的系统边界不同。integration testing

11.3 直接调用上游纯函数的复跑命令

下面命令从 ef34d8e 仓库根目录执行,前提是项目依赖已按它的 lockfile 完整安装,Node.js 为支持 TypeScript 类型擦除的 26.x。它直接 import 上游 code-change.ts;没有另写一个简化 patch 函数。测试数据是教学用虚拟文件,不含项目私有数据。预期通过时打印 3 source vectors passed;该命令只核验纯函数,不启动 Workers、不调用模型、不执行部署。

sh
node --input-type=module <<'JS'
import assert from "node:assert/strict";
import {
  applyCodeChange,
  composeEpochChanges,
  validateCodeChangeSchema,
  validateCodeChangeContent,
} from "./packages/workshop-shared/src/code-change.ts";

const code = files => new Map([[7, new Map(files)]]);
const before = code([
  ["server.js", "const limit = 10;\n"],
  ["README.md", "stable\n"],
]);
const replacement = {7: [["server.js", {edit: [14, [2, "25"], 2]}]]};
validateCodeChangeSchema(replacement);
validateCodeChangeContent(replacement, before);
const edited = applyCodeChange(before, replacement);
assert.equal(edited.get(7).get("server.js"), "const limit = 25;\n");
assert.equal(edited.get(7).get("README.md"), "stable\n");
assert.equal(before.get(7).get("server.js"), "const limit = 10;\n");

const createAndDelete = {7: [
  ["notes.txt", {set: "hello\n"}],
  ["README.md", {remove: true}],
]};
validateCodeChangeSchema(createAndDelete);
validateCodeChangeContent(createAndDelete, edited);
const created = applyCodeChange(edited, createAndDelete);
assert.equal(created.get(7).get("notes.txt"), "hello\n");
assert.equal(created.get(7).has("README.md"), false);
assert.equal(created.get(7).get("server.js"), "const limit = 25;\n");

const composed = composeEpochChanges([
  {change: replacement},
  {pins: [{gadgetId: 7}], change: {7: [["README.md", {set: "merged\n"}]]}},
]);
assert.deepEqual(composed, {7: [["README.md", {set: "merged\n"}]]});
const newBase = code([
  ["server.js", "const limit = 30;\n"],
  ["README.md", "stable\n"],
]);
const rerooted = applyCodeChange(newBase, composed);
assert.equal(rerooted.get(7).get("server.js"), "const limit = 30;\n");
assert.equal(rerooted.get(7).get("README.md"), "merged\n");
console.log("3 source vectors passed");
JS

第三组里的 pins 是真实 composeEpochChanges() 接口所接收的 message shape;本命令只演示 re-root 后如何丢弃该 Gadget 旧 compose 结果,再对外部提供的新 commit tree 应用新 change。它没有创建真实 Git commits,也没有调用 mergeChanges(),不能把这一组的通过解释为完整的 epoch reset/accept 集成验证。

11.4 复跑 Agent 与 steering 测试

以下从 d1ff6d8 仓库根目录执行,依赖已由该快照的 pnpm install 安装。先生成 browser runtime、bundled Blueprints,再由 integration task 连同 backend、Context 的 validated Worker 生成 fixture,最后选择精确测试文件;它们使用 mock 模型,不要求个人模型 key。

sh
pnpm --filter @gadgets/workshop-backend exec node scripts/build-browser-runtime.ts
pnpm --filter @gadgets/workshop-backend exec node scripts/build-bundled-blueprints.ts
pnpm exec vp run -F @gadgets/integration-tests build:test-gatekeeper

pnpm --filter @gadgets/workshop-backend exec vitest run \
  __tests__/durable-decision-resume.test.ts \
  __tests__/agent-compaction.test.ts \
  __tests__/anthropic-compaction.test.ts \
  __tests__/agent-progress.test.ts --maxWorkers=1

pnpm --filter @gadgets/integration-tests exec vitest run \
  __tests__/workshop-steering.test.ts --maxWorkers=1

2026-10-20 实跑结果为 backend 4 文件 83 项、steering 1 文件 7 项,均通过。后者通过真实 WebSocket RPC 和本地 Worker 验证 step 间 steer、共享队列与 Stop、失败后 Retry、审批后继续、compaction 期间新消息及重启前后的队列。它能检查输入、日志与恢复的因果关系;没有证明真实 provider 的可用性或外部操作的执行次数语义。backend 恢复测试、Anthropic compaction 测试、steering harness

围绕第 8 节的执行链,另行运行了下面 13 项精确测试,全部通过;与前述 90 项不重叠。被名称过滤的其他测试不计作通过,所有模型响应均为本地 mock。

sh
pnpm --filter @gadgets/workshop-backend exec vitest run \
  __tests__/agent-step-validation.test.ts \
  __tests__/agent-retry.test.ts --maxWorkers=1

pnpm --filter @gadgets/workshop-backend exec vitest run \
  __tests__/agent-tracing.test.ts --maxWorkers=1 \
  -t "traces a whole turn in the dashboard's shape, though requests arrive mid-turn"

pnpm --filter @gadgets/integration-tests exec vitest run \
  __tests__/workshop-agent.test.ts \
  __tests__/workshop-chat-recovery.test.ts --maxWorkers=1 \
  -t 'keeps multi-turn history and returns provider errors|stopping a running agent keeps its completed steps and leaves the chat usable'

pnpm --filter @gadgets/workshop-backend exec vitest run \
  __tests__/chat-changes.test.ts __tests__/agent-calls.test.ts \
  --testNamePattern 'a mid-barrier failure leaves no partial durable state and the chat stays usable|calls recorded before a restart are delivered when the DO is next constructed|a call queued behind a turn that cannot be resumed is still delivered' \
  --maxWorkers=1

结果分别是 7 + 1 + 2 + 3 项:参数或工具运行失败后的纠正、transient stream reset 与永久错误、真实 RPC 的 tool result 进入下一次模型输入、Stop 保留完成步骤、事务中途失败无部分持久状态,以及重启后 callback 不被遗失。agent-progress.test.ts 还通过人为阻塞 commit 验证:屏障释放之前只有第一次模型请求,释放后才出现第二次;这比从流式工具卡推测提交顺序更直接。屏障等待测试、事务回滚测试、callback 重建测试

11.5 核对 Worker 配置与对象类型

下面的源码检查只需要 shell、curl 与 Python 3,不安装产品依赖,不部署资源。在可写的分析目录执行,读取固定提交的 JSON 配置。

bash
curl --fail --silent --show-error --location \
  https://raw.githubusercontent.com/cloudflare/cloudflare-os/ef34d8edc977a73c7216fb4a83b7be0274f2e1be/packages/workshop-backend/wrangler.jsonc \
  --output cloudflare-os-wrangler.json
python3 - <<'PYCODE'
import json
from pathlib import Path

# 该固定快照的 JSONC 开头含有整行注释。
text = Path("cloudflare-os-wrangler.json").read_text()
data = json.loads("\n".join(
    line for line in text.splitlines() if not line.lstrip().startswith("//")
))
print(data["compatibility_date"])
for migration in data["migrations"]:
    print(migration["tag"], ", ".join(migration["new_sqlite_classes"]))
PYCODE

预期输出是 2026-09-04,以及 v0 的 UserDurableObject, OverseerDurableObject、v1 的 AdminSettings、v2 的 PendingLogin、v3 的 UserDirectoryDurableObject。它验证配置中声明的对象类型;运行中的实例数、账户资源与权限行为仍需部署验收。

资料与核验范围

正文以功能和机制组织,源码链接固定到实际读取的提交。Git/OT 等深读及纯函数实验使用 ef34d8e;本地启动、认证与订阅、Agent steering / compaction / 恢复、自动审批和 Context 分享使用 d1ff6d8。未重新读取的引用保留原提交与行号;不同快照的测试结果不互相替代。

2026-10-08 的部署资料核验锁定 starter 3d211477,其 cloudflare-os submodule 固定到 6478a1448a11524e2f7c2575ad66fab0bc47c433,该 gitlink 与正文深读使用的 ef34d8e 不同。这只证明版本不同;本文未执行更换 gitlink 后的部署检查,不能据此断言不兼容。前文的 Worktree、Git release 与新配置方式属于本文源码快照,不能当作该 starter 默认部署的已验收结果。starter gitlink、upgrade guidance

修订说明

内容修订(2026-10-21,Agent:Codex):移除 TextChange 拼接台演示,保留具体文本、UTF-16 坐标、长度拒绝条件与 revision/pin/OT 边界;本次未重跑 Cloudflare OS 或原有实验。执行入口 Codex Desktop;本轮模型完整标识与 reasoning effort 未取得任务级运行记录。

表达修订(2026-10-20,Agent:Codex):移除计数器示例和压印机交互图,保留 Agent 上下文、工具时序、持久化与恢复的源码分析。执行入口为 Codex Desktop;模型完整标识与 reasoning effort 未取得任务级运行记录。

Agent 执行链路深读(2026-10-20,Agent:Codex):补充上下文投影与文件知识重建、模型输出到工具执行的时序、step 的事务范围、DO 崩溃切点,以及执行身份和审批恢复;追加 13 项精确测试。分析使用公开源码与本地 mock,不代表真实模型、OAuth 或生产恢复的端到端验收。执行入口为 Codex Desktop;其余模型运行参数未取得任务级记录。

本地运行与源码复核(2026-10-20,Agent:Codex):以公开提交 d1ff6d8 核对本地启动、浏览器认证与订阅,并修订 running-turn steering、compaction、审批恢复、GitHub/GitLab 自动审批和 Context collection 分享。保留原有固定版本的深读、实验与 TextChange 说明;本地启动和模型端到端验证分别说明。执行入口为 Codex Desktop,其余模型运行参数未取得任务级记录。

标签整理(2026-10-16,Agent:Codex):本次仅补齐分类标签并核对元数据,未改写或重新校验原文技术结论。运行记录:模型 gpt-6.1-sol,reasoning effort ultra,执行入口 Codex Desktop,提供方 openai;CLI 版本 0.162.0-alpha.17.2(不代表桌面 App 版本)。

术语修订(2026-09-07,Agent:Codex):用 exactly-once execution 明确 Scheduler 的执行次数语义。此次修订运行记录:模型 gpt-6-astra,reasoning effort ultra,执行入口 Codex Desktop,提供方 openai,CLI 版本 0.153.4(不代表桌面 App 版本)。

源码与架构修订(2026-10-08,Agent:Codex):根据本轮读取时官方 main 的 4358072 更新实际对象拓扑、Git VFS、Worktree、Blueprint releases、Agent step 持久化、Observer 权限与构建配置,补充 Mermaid 图解。本文进行了固定版本源码与官方文档核验,未运行 Cloudflare OS 完整测试或实际部署。运行记录:模型 gpt-6.1-sol,reasoning effort ultra,执行入口 Codex Desktop,提供方 openai,CLI 版本 0.162.0-alpha.2(不代表桌面 App 版本)。

深度源码修订(2026-10-15,Agent:Codex):以公开源码 ef34d8e 为基线,补充请求与 capability 链路、Agent 输入投影与 step transaction、Git VFS/OT/三方合并、Worktree、Blueprint release、Observer、Scheduler 及可复现实验;增加 TextChange 输入与结果说明。源码分析、纯函数验证与产品端到端测试分别说明,未部署 Cloudflare OS。运行记录:模型 gpt-6.1-sol,reasoning effort ultra,执行入口 Codex Desktop;其余运行时版本未取得任务级记录。

博客助手

正在打开博客助手…