05 · 共享契约与 API 设计¶
一句话结论: Paperclip 用
shared包把状态词汇、DTO 和输入校验同步给 Server/UI/CLI,再由 route 负责 HTTP 语义、service 负责业务不变量;OpenAPI 是从同一批 Zod schema 组装出的外部视图。
1. 为什么多 Agent 控制面特别怕契约漂移¶
一个 Issue 状态如果只在后端改名,至少会影响:
- 数据库默认值与 check/index 条件;
- Server 迁移和状态转换;
- API 输入校验与响应类型;
- UI 筛选、颜色、看板列和 optimistic update;
- CLI 参数与输出;
- recovery/watchdog 对 terminal/live 的判断;
- adapter prompt 与技能文档。
所以 Paperclip 的仓库约束强调“DB、shared、server、UI 同步修改”。共享契约不是方便 import,而是防止分布式状态机在单仓库内分叉。
2. shared 的四层结构¶
packages/shared/src/
├── constants.ts # 有限状态集合与标签
├── types/ # 输出 DTO / domain view types
├── validators/ # Zod 输入与部分响应 schema
├── api.ts # 稳定 API path 常量
└── config-schema.ts # 配置文件契约
2.1 常量是状态机的字典¶
例如:
HEARTBEAT_RUN_STATUSES = [
"queued", "scheduled_retry", "running", "succeeded",
"interrupted", "failed", "cancelled", "timed_out"
]
对应 TypeScript union 直接由数组推导。Issue、Agent、Approval、Membership、Tool Policy、Live Event 等都采用类似方式。
优势:运行时代码可校验,编译期也有 union;代价:新增状态必须审计所有 exhaustiveness 分支,不能只看类型通过。
2.2 Types 描述“线上看到什么”¶
types/ 不等同于 DB row。API 往往会:
- 把 nullable/Date 做序列化;
- 合并聚合信息;
- 隐去密钥或内部字段;
- 输出兼容旧字段;
- 添加权限/health/summary 等读模型字段。
因此不要直接把 $inferSelect 当公共 API。
2.3 Validators 描述“允许写进来什么”¶
Zod schema 对 create/update/resolve/test 等输入做运行时验证,并导出 z.infer 类型。常见模式包括:
- create 必填,update
.partial()但保留跨字段 refine; - enum 来自 constants;
- discriminated union 表达 transport/provider/type;
- preprocess/transform 处理空字符串、默认值和兼容输入;
superRefine校验跨字段约束。
2.4 API path 常量只解决一部分同步¶
api.ts 保存健康、公司、Issue、工具等公共路径,但大型路由仍有大量具体子路径。它不是完整 client generator;UI/CLI 仍维护各自的调用封装。
3. 从请求到数据库的标准路径¶
sequenceDiagram
participant C as UI / CLI / Agent
participant M as Middleware
participant R as Route
participant Z as Zod
participant A as Authorization
participant S as Service
participant D as Drizzle / PostgreSQL
participant E as Activity / Live Event
C->>M: HTTP request
M->>M: establish actor + logging context
M->>R: routed request
R->>Z: validate body/query/params
Z-->>R: typed input
R->>A: can actor perform action on resource?
A-->>R: allow / deny + reason
R->>S: business operation
S->>D: transaction / conditional mutation
D-->>S: persisted row(s)
S->>E: activity / audit / live event
S-->>R: domain result
R-->>C: JSON + status code
实际路由可能把授权放在 service 前后两次:先做不泄露存在性的粗过滤,加载资源后再做精确 action 判断。
4. validate() 很小,责任却很明确¶
Server 的通用 validate(schema) middleware 只做:
它不自动验证 query/params,也不替 route 做业务授权。这种小抽象避免了“万能路由框架”隐藏控制流;复杂 query 常在 route 中显式 safeParse(req.query)。
5. Route 与 Service 的边界¶
Route 应负责¶
- path 参数、query、content type、HTTP status;
- 请求 actor 与资源可见性;
- 输入 schema;
- 把业务错误映射成 4xx;
- 响应 DTO 和 headers;
- 文件上传/下载和 stream。
Service 应负责¶
- 跨入口一致的不变量;
- transaction、锁、条件更新;
- 状态迁移与幂等;
- 关联对象同公司校验;
- 活动、成本、唤醒和恢复副作用;
- 对 route/heartbeat/plugin 共用的业务操作。
例如 checkout 如果只写在 REST route 中,heartbeat auto-checkout 和 MCP/CLI 路径就可能绕开并发保护;把它放进 Issue service 才能统一。
6. HTTP 状态码表达并发语义¶
Paperclip 常见错误可按类别理解:
| 状态 | 含义 | 例子 |
|---|---|---|
| 400 | 输入形状或不合法组合 | 非法状态、错误 workspace 配置 |
| 401 | 未建立可信身份 | 缺失/无效 token |
| 403 | 身份明确但动作不允许 | 无 company membership、scope 被限 |
| 404 | 不存在或不可见 | 跨 company 资源常不暴露存在性 |
| 409 | 当前事实与前置条件冲突 | checkout 已被其他 run 持有、approval required |
| 422 | 语义上无法执行 | 某些配置/依赖不满足 |
| 429 | rate limit / quota | 工具策略或 API 限流 |
| 5xx | 未预期或基础设施失败 | DB/provider/runtime 错误 |
对 Agent client 而言,409 不是普通失败:它通常意味着应该刷新事实、等待批准或停止重复动作。
7. 幂等不是统一 Idempotency-Key 就结束¶
Paperclip 根据对象语义使用多种幂等手段:
- Issue create 有专门 idempotency key 表;
- 自动化 Issue 用 origin kind/id/fingerprint 条件唯一索引;
- wakeup 用 coalescing/idempotency key;
- pipeline automation 用 case + automation + triggering event 唯一键;
- document revision 用递增 revision number 唯一键;
- approval resolve 用条件状态更新,并允许同结果重复调用;
- checkout 对同一 run 返回幂等成功。
这是一个重要经验:幂等键必须落在真正的业务身份上,不能让所有操作共用一条没有语义的 request UUID。
8. OpenAPI 如何生成¶
routes/openapi.ts 导入大量 shared Zod schema,将它们转换为 JSON Schema 并注册 path、request 和 response。它还处理:
- optional/default/effects 的 unwrap;
- object、array、enum、union 等结构;
- body content type;
- path/query 参数;
- 组件和响应注册。
但它不是从 Express route 自动反射:路由列表仍需显式维护。优点是输出可控,代价是新增 endpoint 时要同步注册并靠测试防漂移。
9. UI API client 的约定¶
ui/src/api/*.ts 按资源拆分,底层 client 统一:
- base URL 与 credentials;
- JSON 编解码;
- 错误 payload;
- auth/session 行为;
- upload/download 的特殊路径。
TanStack Query key 在 queryKeys.ts 集中定义。例如同一个 Issue 会有:
detail
comments / interactions
documents / revisions / annotations
activity / runs / liveRuns / activeRun
approvals / workProducts / fileResources
这不是冗余,而是让实时事件只刷新受影响的读模型。
10. CLI client 的 context 与 parity¶
CLI 解析优先级大致是:
控制面命令最终走 HTTP,不直接连 DB。测试中专门验证 CLI 与 API route 的 parity,避免命令行成为绕过业务约束的管理后门。
11. 共享契约仍不能替代数据库约束¶
TypeScript 和 Zod 只约束进入当前进程的值,不能解决:
- 两个 Server/请求同时写入;
- 旧版本进程或迁移脚本写入;
- 事务之间的竞争;
- 运行中状态被另一请求改变。
所以完整防线是:
TypeScript(开发期)
+ Zod(请求边界)
+ Authorization(主体/资源边界)
+ DB constraint / CAS(并发事实)
+ Audit / Recovery(事后可见与收敛)
下一章的 checkout 是这套组合的典型例子。
12. 兼容与演进策略¶
从代码可见几种兼容手法:
- config schema
.passthrough()保留扩展键,merge 时保留未知字段; - 新字段有数据库默认值或 nullable 过渡;
- API DTO 在迁移期同时容纳旧/新字段;
- update schema 区分“未提供”和“显式置空”;
- migration 先 backfill,再增加更强约束;
- catalog/manifest 记录内容 hash 与 package version。
这种渐进策略对长时间运行的 Agent 系统尤其重要,因为一次部署时可能还有旧 run/session 在数据库中。
13. 常见改动检查表¶
如果新增一个 Issue 状态:
- shared constants 和 type;
- Zod create/update schema;
- DB default/check/index/migration;
- Issue service transitions;
- terminal/live/liveness/recovery 集合;
- UI badge、board column、filter、optimistic update;
- LiveUpdates cache handling;
- CLI 输入/输出;
- OpenAPI;
- 单测、并发测试和 E2E;
- 文档与 skills 中的协议说明。
这个清单说明“增加一个字符串”为什么可能是跨系统变更。
14. 可复用经验¶
- 把有限状态集合做成运行时常量,再从它推导 TypeScript union。
- 公共 DTO 不要直接等同 DB row,给演进和脱敏留空间。
- Route 管 HTTP,Service 管跨入口业务不变量,数据库管并发事实。
- 幂等键应对应业务身份,并用唯一索引/条件更新落地。
- OpenAPI 即使复用 schema,也要有 route parity 测试。
- 实时 UI 应按读模型拆 query key,避免“任何事件刷新所有数据”。
源码入口¶
packages/shared/src/constants.tspackages/shared/src/types/index.tspackages/shared/src/validators/index.tspackages/shared/src/api.tsserver/src/middleware/validate.tsserver/src/routes/openapi.tsui/src/api/client.ts、ui/src/lib/queryKeys.tscli/src/client/context.ts、cli/src/client/http.ts
下一章进入第一个核心状态机:06 · Issue 状态机与原子认领。