English | 中文
本文是修改 Cloud 代码时的操作指南:代码放在哪里、新增接口或表要改哪些地方、几种常见扩展的固定做法,以及容易踩的坑。通用的编码规则
(错误处理、并发、依赖、文件大小)以仓库根目录的 AGENTS.md 为准;涉及状态转换、归属、持久化、外部副作用、恢复、安全或兼容性的修改,
先读相关的 specs ADR。
| 位置 | 放什么 |
|---|---|
cmd/* |
只做配置加载、依赖装配、生命周期和退出码;没有业务逻辑、没有 SQL |
internal/core |
全部权威状态和策略:聚合、状态转换、事务、授权判定 |
internal/api/router |
HTTP 路由白名单、双凭据校验、严格解码、Fault 到 HTTP 的映射 |
internal/controlgrpc |
gRPC 到 Store.Control 的翻译,不含业务 |
internal/contract |
OpenAPI 文档的 Go 模型 |
internal/gateway、internal/modelgateway、internal/gitserver |
各自独立进程的实现;只通过 core 暴露的方法读写业务状态 |
internal/repository、internal/config、internal/logger |
连接池、配置加载、日志 |
internal/simulator、internal/collab |
只用于开发和测试的替身与夹具 |
依赖单向:cmd 依赖其余包;router 与 controlgrpc 依赖 core;core 只依赖标准库、GORM 和迁移目录,不反向导入任何表现层包。core 需要外部
能力时在自己这边定义接口(例如对象存储、工作流资产下载、协作端口),由 cmd 注入实现。新的实现包放在 internal/ 下;只有确实要作为公开
API 给外部模块复用的代码才放进 pkg/。
- 路由:在
router.Routes()里加一条{Method, Path, "", Fields},Fields是请求体允许的字段。新的路径参数在路由处理里绑定到core.PublicRequest的字段上,必要时给PublicRequest加字段。 - 字段类型:不是字符串的请求字段,在
validField里加一个分支,固定它的类型或元素形状。类型系统是封闭的,默认一切都是字符串。 - 分派:写操作在
Store.Public、读操作在readPublic里加分支(见下方"分派顺序")。业务逻辑写在 core 的事务里:限定租户、校验成员与 角色、需要时检查版本;POST和DELETE的幂等由Public统一处理。 - OpenAPI:在
internal/contract/openapi.go里补 schema 和响应、请求、可选字段的定义,列表接口要被识别为列表。响应里的每个字段都必须在 schema 里声明。 - 生成:运行
task frontend:generate(会先运行task openapi),把api/openapi.json和frontend/src/api与源码放在同一个提交里。 生成物不手改。 - 测试:在
integration/里经路由驱动新接口;测试的 HTTP 传输会用 OpenAPI 校验每个真实响应。修改 Space 内的业务状态时,记得在事务里收集 对应的 SSE 失效事件。
- 新建
internal/core/migrations/NNNN_描述.sql,编号接在合并时 main 上的最新编号之后;已应用的迁移文件永远不改。改表用新的迁移ALTER。 - 遵循已有约定:
id uuid主键,可变资源带version(CHECK(version>0))和created_at、updated_at,业务行用deleted_at软删除;每张表 按tenant_id限定作用域,引用用户的列通过(tenant_id, user_id)复合外键指向tenant_memberships。能由数据库保证的跨行规则写成约束。 - 每个租户都要有的数据(例如 Issue 的系统状态列)不能在迁移里预置,因为租户只在运行时产生;在第一次读写时用
ON CONFLICT DO NOTHING懒惰补齐。 - 迁移要能安全重试,并同时测试新库和从上一版 schema 升级。
integration/cloud_test.go的升级测试有一份固定的"升级前基线"列表,只有在测试本来就 要经过你的迁移升级时才把它加进去。
- 修改
proto/下的.proto:v1只做非破坏性修改,破坏性变更用并列的v2package。运行task proto:lint、task proto:breaking。 task proto:generate重新生成internal/controlpb,与改动一起提交。- 在
internal/controlgrpc里把新 RPC 翻译成一个core.ControlRequest动作;业务写在 core 的Store.Control里,改变状态的动作用提交身份包裹, 需要租约的动作校验 epoch。 - 契约的语义由 specs 的 ADR 拥有;desktop 锁定合入 main 的提交并重新生成客户端(见 Controller 内部控制契约)。
Cloud server 的周期性工作都是同一个形状,新增时照做:
- 循环由
cmd/server拥有:用pluginmarket.RunSyncLoop启动,挂在同一个WaitGroup上,进程停止时取消并等待正在执行的一轮结束。 - 每一轮先在全局锁之外做一次有界、按确定顺序排序的只读扫描,再为每个对象开一个短事务,在事务里重新读取扫描时的条件,并以比较并交换的方式转换。 重复的一轮必须什么都不做。
- 时间窗口一律按数据库时间比较;某个对象暂时卡住时回滚、留待下一轮,不刷日志,只有扫描本身失败才返回错误。
Agent 运行的业务转换写在控制面的五个钩子里(见 同一事务里的业务钩子):
- 钩子在调用方的事务里运行,通过
businessTransaction找回外层事务;不开新事务、不起 goroutine、不做外部 I/O、不推迟任何提交后的副作用。 - 钩子只写业务表,执行证据只由控制面写;返回错误会以
409 business_hook_failed回滚整个接管,所以不能"容忍"一个无法由权威数据证明的转换。
- 分派按路径子串匹配,顺序很重要。
Public和readPublic里,路径更长、更具体的分支必须排在更宽泛的分支之前:Issue 下的子资源(评论、标签、 订阅、交互、Thread)要在 Issue 的分支内部分派;.../runs/:rid/thread要在.../runs之前;.../workflows/:wfid/runs要在 Issue 的/runs分支之前。OpenAPI 生成器里同样有按/issues子串匹配的分支,新资源的分支要放在它前面。 - Gin 的静态段优先于参数段:
/issues/batch是安全的,字面量batch胜过:iid,但这时c.Param("iid")为空。 - 只有顶层列名转成 camelCase,
jsonb里嵌套的键保持数据库原样;聚合列按 SQL 别名读取。 updated_at不是自动的,每个UPDATE自己写updated_at=now();每个读改写都要version=version+1。- 软删除要在每个查询里显式写
deleted_at IS NULL。 - 事务里不做外部 I/O:HTTP、Git、文件、对象存储、Node 都放到事务之外,需要时拆成"事务 → 外部调用 → 事务",第二个事务重新检查前提。
- 评论不是运行:不要给评论表加
run_id或interaction_id,运行的来源记在运行自己的trigger_evidence_*上。 - 对不可见资源返回
404,不要用403泄露"存在但无权访问"。 - 不要在公开文档里暴露内部控制字段:运行 Workspace、阶段、执行身份、对象键这类字段在投影里显式剥离,新增列之后检查它会不会随
SELECT *漏出去。
- 行为、配置或接口变化时,同一个改动里更新
docs/下对应的文档,两种语言一起改;包内实现细节更新所在模块的README.md与README.en.md。 - 实现 specs ADR 的改动,在 PR 描述里写明 ADR 的状态;补齐了实现时同时在 specs 把 ADR 改为
implemented,并保持核心用例的证据状态准确。