跳转至

05 · 共享契约与 API 设计

ZodDTOOpenAPIRoute / Service错误语义

一句话结论: 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 只做:

req.body = schema.parse(req.body)

它不自动验证 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 解析优先级大致是:

命令行 flag
  > 选中 profile/context
  > 环境变量指向的 secret
  > 默认 base URL / company

控制面命令最终走 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. 可复用经验

  1. 把有限状态集合做成运行时常量,再从它推导 TypeScript union。
  2. 公共 DTO 不要直接等同 DB row,给演进和脱敏留空间。
  3. Route 管 HTTP,Service 管跨入口业务不变量,数据库管并发事实。
  4. 幂等键应对应业务身份,并用唯一索引/条件更新落地。
  5. OpenAPI 即使复用 schema,也要有 route parity 测试。
  6. 实时 UI 应按读模型拆 query key,避免“任何事件刷新所有数据”。

源码入口

  • packages/shared/src/constants.ts
  • packages/shared/src/types/index.ts
  • packages/shared/src/validators/index.ts
  • packages/shared/src/api.ts
  • server/src/middleware/validate.ts
  • server/src/routes/openapi.ts
  • ui/src/api/client.ts、ui/src/lib/queryKeys.ts
  • cli/src/client/context.ts、cli/src/client/http.ts

下一章进入第一个核心状态机:06 · Issue 状态机与原子认领。