fix(heicode): close client gap-analysis P0/P1 items for unified API

Address the desktop client team gap analysis on the unified /api/heicode/*
surface:

- P0-1 GET device auth: no-body V2 signed-GET path (fetch forbids GET body),
  same Ed25519 canonical with empty-body hash; UserOrV2DeviceAuth +
  OptionalV2DeviceAuth dispatch on signature headers. Unit test added.
- P0-2 approval inbox: GET .../tasks/{id}/approvals?status=pending.
- P0-3 project_folder: artifacts list normalizes the primary code deliverable
  to display_artifact_type=project_folder + is_project + manifest/files/
  archive/revisions subpaths.
- P0-4 archive contract: real application/zip + Content-Disposition +
  Content-Length; ARTIFACT_ARCHIVE_NOT_READY (retryable) when no files yet.
- P1-1 file path: GET .../files?path=<url-encoded> (no segment ambiguity).
- P1-2/P1-3 revision: local edits stored as accepted baseline; /messages and
  /execute consume the latest accepted revision (-> applied), return
  active_project_revision.
- P1-4 doc: Swarm same-shape routes stated explicitly.

Doc updated to match. Build + middleware/controller tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 11:48:03 +08:00
co-authored by Claude Opus 4.8
parent f903fd0c19
commit 2613c5763c
10 changed files with 493 additions and 41 deletions
+83 -16
View File
@@ -25,7 +25,7 @@ Manager 生产地址:`https://code.xinghanlab.com`
6. display_status=completed 后:
GET .../tasks/{id}/artifacts 拿 artifact_id
GET .../artifacts/{artifact_id}/manifest 项目文件树
GET .../artifacts/{artifact_id}/files/{path} 单文件正文
GET .../artifacts/{artifact_id}/files?path=<路径> 单文件正文
GET .../artifacts/{artifact_id}/archive 整包 zip
7. (可选) 用户本地改后: POST .../artifacts/{id}/local-edits
8. (可选) 部署: GET /api/heicode/deployment-targets -> POST .../tasks/{id}/deployments
@@ -39,14 +39,27 @@ Manager 生产地址:`https://code.xinghanlab.com`
| 方式 | 适用 |
|---|---|
| **V2 加密 body + 设备签名**(`Content-Encoding: heicode-aead-v1` + `X-Heicode-*`,ChaCha20-Poly1305 + Ed25519) | 桌面客户端所有有 body 的请求(POST/PUT/DELETE) |
| Manager session cookie + `New-Api-User: <user_id>` | Web 控制台 / 未加密 GET 兼容路径 |
| **V2 加密 body + 设备签名**(`Content-Encoding: heicode-aead-v1` + `X-Heicode-*`,ChaCha20-Poly1305 + Ed25519) | 有 body 的请求(POST/PUT/DELETE) |
| **V2 无 body 设备签名**(`X-Heicode-*` 签名头,**不带** `Content-Encoding`) | GET 等无 body 请求(fetch 规范禁止 GET 带 body) |
| Manager session cookie + `New-Api-User: <user_id>` | Web 控制台兼容路径(桌面端可不用) |
要点(与模型调用相同,未变):
- `aad` / `canonical` 里的 `path_with_query` 必须是 Manager 实际收到的 path,例如 `/api/heicode/sub-agile/tasks`,不含 origin。
- 每次新 `X-Heicode-Nonce` + 新 X25519 临时 key;签名里 `sha256_hex(plaintext_body)` 与加密前 JSON 字节一致。
- 服务端在新路由上用同一中间件 `UserOrV2DeviceAuth` 解密+验签,从设备绑定 token 解析用户身份;失败读响应头 `X-Heicode-Auth-Error`。
- `GET` 查询当前兼容 session + `New-Api-User`;如需全程无 cookie,后续再补无 body 的 V2 签名 GET。
**GET 全程无 cookie 的设备签名(已支持)**:GET 没有 body,因此**不发** `Content-Encoding`、**不加密**,但仍发同一套签名头并用相同 canonical 公式签名,`sha256_hex(plaintext_body)` 处填**空 body 的哈希** `sha256("")`:
```text
canonical = method + "\n" # "GET"
+ path_with_query + "\n" # 例如 /api/heicode/sub-agile/tasks/dep_x/workflow
+ timestamp_ms + "\n"
+ nonce_hex + "\n"
+ device_fingerprint + "\n"
+ ephemeral_pubkey_b64 + "\n" # GET 仍生成一对临时 X25519 key 并带 X-Heicode-Eph-Pubkey
+ sha256_hex("") # e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
signature = base64( ed25519_sign(device_priv, sha256(canonical)) )
```
即:GET 与写请求共用同一签名实现,区别仅是「不加密 body、body 哈希为空串哈希」。服务端凭 `X-Heicode-Signature` + `X-Heicode-Device-Id` + `X-Heicode-Eph-Pubkey` 三个头识别该模式(gap P0-1 已闭环)。客户端可用同一 `encryptedFetch`,对 GET 走「跳过加密、空 body」分支即可。
## 2. 能力发现
@@ -67,7 +80,7 @@ Manager 生产地址:`https://code.xinghanlab.com`
## 3. 统一任务接口
两套前缀,按模式选择:`/api/heicode/sub-agile/*`(→ agent_management)、`/api/heicode/swarm/*`(→ HeiCode-Swarm)。下表以 sub-agile 为例,swarm 同形。
两套前缀,按模式选择:`/api/heicode/sub-agile/*`(→ agent_management)、`/api/heicode/swarm/*`(→ HeiCode-Swarm)。**下表以 sub-agile 为例,swarm 路径把前缀 `sub-agile` 换成 `swarm` 后完全同形(含 artifacts/files/archive/revisions/local-edits/approvals/deployments 全部子接口,gap P1-4)**——两组路由由同一注册函数生成,不存在「只实现了 sub-agile」的情况。
| 方法 | 路径 | 说明 |
|---|---|---|
@@ -85,9 +98,16 @@ Manager 生产地址:`https://code.xinghanlab.com`
| GET | `/api/heicode/sub-agile/tasks/{task_id}/metrics` | 指标 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/diagnostics` | 诊断(直查 Runtime 状态 + warnings) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/sk-snapshots` | SK 快照 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts` | 产物列表 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts` | 产物列表(主产物归一化为 `project_folder`,见 §5) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts/{artifact_id}/manifest` | 项目文件树 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts/{artifact_id}/files?path=` | 单文件正文 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts/{artifact_id}/archive` | 整包 zip |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/artifacts/{artifact_id}/revisions` | revision 列表 |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/artifacts/{artifact_id}/local-edits` | 本地修改回传(批量 `.../local-edits/batch`) |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/approvals?status=pending` | 审批列表(gap P0-2) |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/approvals/{approval_id}/approve` | 同意审批 |
| POST | `/api/heicode/sub-agile/tasks/{task_id}/approvals/{approval_id}/reject` | 拒绝审批 |
| GET | `/api/heicode/sub-agile/tasks/{task_id}/deployments` · POST 同路径 | 云部署列表 / 发起(见 §7) |
### 3.1 创建 body(需求包)
@@ -138,6 +158,22 @@ Manager 生产地址:`https://code.xinghanlab.com`
**任务详情** `GET .../tasks/{task_id}` 返回同形 + 完整 `orchestration_plan`;**任务列表** `GET .../tasks` 返回 `{"items":[ ...同上... ],"total":N}`。客户端按 `display_status` 渲染,详情/列表读取时 Manager 会自动从 Runtime 收敛终态。
### 3.2 审批列表(gap P0-2)
`GET .../tasks/{task_id}/approvals?status=pending`:
```json
{ "success": true, "data": {
"task_id":"dep_xxx","total":1,
"items":[
{"approval_id":"appr_xxx","deployment_id":"dep_xxx","type":"deploy","risk_level":"high",
"title":"生产部署审批","status":"pending","created_at":"2026-06-02T00:00:00Z"}
]
}}
```
`status` 省略则返回全部;对每条 `pending` 项调 `.../approvals/{approval_id}/approve|reject` 闭环。
## 4. 状态模型(display_status)
客户端只展示 `display_status`(Manager 裁决结果):
@@ -192,22 +228,28 @@ Runtime 返回单个文本/markdown 多文件 artifact;Manager 解析成项目
```json
{ "success": true, "data": {
"deployment_id":"dep_xxx","total":1,
"artifacts":[
"deployment_id":"dep_xxx","task_id":"dep_xxx","total":1,
"items":[
{"artifact_id":"art_swm_xxx_backend_1","artifact_type":"code_patch","title":"backend task delivery",
"summary":"...","uri":"azblob://...","metadata":{"content_hash":"sha256:...","summary_only":false}}
"summary":"...","uri":"azblob://...",
"is_project":true,"display_artifact_type":"project_folder",
"manifest_path":".../artifacts/art_swm_xxx_backend_1/manifest",
"files_path":".../artifacts/art_swm_xxx_backend_1/files",
"archive_path":".../artifacts/art_swm_xxx_backend_1/archive",
"revisions_path":".../artifacts/art_swm_xxx_backend_1/revisions"}
]
}}
```
判断是否真实交付:`display_status=completed` 且 artifact 的 `metadata.synthesized != true`(Manager 已裁决,客户端无需自判)。
**主产物类型以 `display_artifact_type` 为准**(gap P0-3):结构性代码任务的主交付物 Manager 一律归一化为 `display_artifact_type:"project_folder"` 且 `is_project:true`,并直接给出 manifest/files/archive/revisions 子路径——客户端不用再靠原始 `artifact_type`(可能是 `code_patch`)猜它是不是完整项目。非交付物(纯摘要)`is_project:false`,`display_artifact_type` 回落为原始类型。
判断是否真实交付:`display_status=completed` 且 `is_project:true`(Manager 已裁决,客户端无需自判)。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `.../artifacts/{artifact_id}/content` | 原始正文(兼容) |
| GET | `.../artifacts/{artifact_id}/manifest` | 项目文件树(entries[path,type,mime,size,hash,content_path] + archive) |
| GET | `.../artifacts/{artifact_id}/files/{path}` | 单个文件正文 |
| GET | `.../artifacts/{artifact_id}/archive` | 整个项目 zip 下载 |
| GET | `.../artifacts/{artifact_id}/files?path=<相对路径>` | 单个文件正文(见下「路径编码」) |
| GET | `.../artifacts/{artifact_id}/archive` | 整个项目 zip 下载(见下「下载契约」) |
manifest 示例:
@@ -216,13 +258,28 @@ manifest 示例:
"artifact_id":"art_xxx","artifact_type":"project_folder","root_dir":"project","revision":1,
"content_hash":"sha256:...","file_count":2,
"entries":[
{"path":"app.py","type":"file","mime_type":"text/x-python","size_bytes":420,"content_hash":"sha256:...","content_path":".../files/app.py"},
{"path":"test_app.py","type":"file","mime_type":"text/x-python","size_bytes":300,"content_hash":"sha256:...","content_path":".../files/test_app.py"}
{"path":"app.py","type":"file","mime_type":"text/x-python","size_bytes":420,"content_hash":"sha256:...","content_path":".../files?path=app.py"},
{"path":"frontend/src/App.tsx","type":"file","mime_type":"text/typescript","size_bytes":300,"content_hash":"sha256:...","content_path":".../files?path=frontend%2Fsrc%2FApp.tsx"}
],
"archive":{"format":"zip","download_path":".../archive"}
}}
```
**单文件「路径编码」(gap P1-1)**:用 `?path=` query 传相对路径,整体做一次 URL encode(含 `/`、空格、中文、`#`、`?` 等保留字符)。例:`GET .../files?path=frontend%2Fsrc%2FApp.tsx`。直接用 manifest 里给的 `content_path` 即可,无需自己拼。找不到文件返回 `{"error":{"code":"FILE_NOT_FOUND"}}`;缺 path 返回 `FILE_PATH_REQUIRED`。
**整包「下载契约」(gap P0-4)**:`GET .../archive` 成功时返回真实 zip 二进制,响应头:
```text
HTTP 200
Content-Type: application/zip
Content-Disposition: attachment; filename="art_xxx.zip"
Content-Length: <字节数>
```
客户端必须读 `Content-Disposition` 文件名、按 `Content-Length` 校验落盘,**保存成功后才提示「已下载」**。产物尚未就绪(无文件)时返回 `HTTP 200` body 为:
```json
{ "success": false, "error": { "code": "ARTIFACT_ARCHIVE_NOT_READY", "message": "项目压缩包尚未生成(产物还未就绪),请稍后重试。", "retryable": true } }
```
此时客户端**不要**提示已下载,按 `retryable` 稍后重试。
## 6. 本地修改回传(revision)
用户在本地改了产物后,必须显式上传;Manager 存为新 revision。
@@ -242,9 +299,19 @@ manifest 示例:
批量请求:`{ "base_revision":1, "changes":[ {"op":"update","path":"a.py","content":"..."},{"op":"create","path":"b.py","content":"..."},{"op":"delete","path":"c.py"} ], "change_summary":"..." }`
成功响应:`{ "success":true,"data":{ "artifact_id":"art_xxx","revision":2,"project_revision":2,"source":"client_local_edit","status":"received","content_hash":"sha256:new" } }`
成功响应:`{ "success":true,"data":{ "artifact_id":"art_xxx","revision":2,"project_revision":2,"source":"client_local_edit","status":"accepted","content_hash":"sha256:new" } }`
冲突:若 `base_revision` 不等于当前最新,返回 `{ "success":false,"error":{"code":"ARTIFACT_REVISION_CONFLICT",...},"data":{"current_project_revision":3} }`,客户端提示用户先同步再提交。
**revision 状态语义(gap P1-2)**:
| status | 含义 |
|---|---|
| `accepted` | 已被接受为**当前可用基线**(无冲突即直接接受,Manager 持有基线) |
| `applied` | 已被 Runtime 在后续 `/messages` 或 `/execute` 消费 |
| `conflict` | 与最新 revision 冲突(见下,不落库) |
**后续执行使用最新 accepted revision(gap P1-3)**:调用 `POST .../tasks/{id}/messages` 或 `.../execute` 时,Manager 自动取该任务最新 `accepted` revision 作为基线并置为 `applied`,响应回带 `active_project_revision`(无本地修改时不返回该字段)。客户端无需手动指定 revision。
冲突:若 `base_revision` 不等于当前最新,返回 `{ "success":false,"error":{"code":"ARTIFACT_REVISION_CONFLICT","retryable":false},"data":{"current_project_revision":3} }`,客户端提示用户先同步最新再提交。
## 7. 云部署(控制面,执行待 Deploy Worker)