# 审计报告 Lane A — KAP 无限画布：数据同步链审计（KMC→画布 sync 面）

- 审计时间：2026-09-19 · 审计方式：绝对只读（sqlite `mode=ro`、仅 GET curl、零仓内写入）
- 锚点复核：KAP 仓 `/data/workspace/kais-aigc-platform` 分支 `feat/ui-direct-action-fix` HEAD `333df7b6` ✔；`src/routes/canvas/v2/` 共 5792 行 ✔；canvas_nodes=11485 / canvas_links=21904 ✔；服务 :10588 在跑（GET health 返回 `totalScopes=185, totalNodes=11485`）✔

---

## 1. 执行摘要（≤200 字 + P0 清单）

同步链主通道（runner 钩子→save-v2→saveFullGraph 事务）工程化程度较高：幂等 upsert、4xx 即停管线、5xx 三次重试、失败有日志与手工修复入口。但实测发现画布资产引用大面积失效（/oss/ filePath 抽样 100 条 37 条落不到磁盘），732 条孤儿边（source 端点不存在），且「degrade 吞网络错误后无自动兜底」的缺口依旧——p15 修复工具未注册，writeback 队列不覆盖 save-v2 通道。历史 7 条线索：2 条已修、3 条部分修复、2 条仍存在。

**P0 清单**
- P0-1 幻影 URL：画布 filePath 大量指向已不存在文件（实测缺失率 37%）
- P0-2 孤儿 links：732 条 source 孤儿 / 739 条 target 孤儿，集中于 project 1785119845700（717 条）

---

## 2. 问题清单

### P0-1 幻影 OSS URL / 失效文件引用（画布资产 404 面）

- **现象**：画布节点 `data.filePath` 指向的文件在静态服务根下不存在，前端加载 404。
- **证据（实测）**：
  - `sqlite3 mode=ro` 抽 100 条 `json_extract(data,'$.filePath') LIKE '/oss/%'`，剥离 `?v=` 查询串后按 `data/oss/<rel>` 与 `/mnt/agents/output/<rel>` 两根检查存在性：**OK=63 MISSING=37**。缺失样例：`/oss/pipeline/ffaece43/S025_first_reverse.jpg`、`/oss/scifi-epic/assets/output/ep6_final.mp4`、`/oss/volvo/03_visual_thumbs/scene05_rear_v2.webp` 等。
  - filePath 前缀分布：`/oss/` 4249 条、`/data/` 247、`/mnt/` 15、http 81、空/无 6893；另有相对路径脏数据（`assets/P04/L2/...`，不可解析）。
- **根因**：
  1. `fsToOssUrl()` 纯字符串翻译、**从不 stat 文件**（`src/routes/canvas/v2/import-from-dir.ts:202-224`，已目验）；
  2. import-from-dir 创建 `data/oss/{basename}` 符号链接失败仅 `console.warn` 继续（`import-from-dir.ts:2114-2120`，已目验），URL 已铸但链接不存在；
  3. sync-assets 原样拷贝 `o_image.filePath` 入库，无任何存在性校验（`src/routes/canvas/v2/sync-assets.ts:90-93`）；
  4. 部分缺失源于 workdir/episode 事后清理，画布侧无回收机制。
- **对 KMC/KGR 影响**：画布作为资产交付面，KGR 复刻工厂按画布取源文件时拿到 404，复刻精度直接受损；KMC 操作员无法区分「未同步」与「文件已丢」。
- **修复建议**：导入/同步时 stat 校验 + 对缺失文件节点打 `stale/missing` 标记而非静默保留；提供对账任务列出失效引用（成本 **M**）。

### P0-2 孤儿 links（端点节点不存在的边）

- **现象**：canvas_links 中大量边的 source/target 在同 scope 内无对应节点。
- **证据（实测 SQL）**：
  - source 孤儿 732 条：`SELECT COUNT(*) FROM canvas_links l WHERE NOT EXISTS (SELECT 1 FROM canvas_nodes n WHERE n.id=l.source_id AND n.project_id=l.project_id AND n.episodes_id=l.episodes_id);` → 732
  - target 孤儿 739 条（同法）→ 739
  - 按 project 分布：`1785119845700 → 717 条`、`1783182686959 → 15 条`；样例 `le-p07/S01_front-p11/S01-01`（source `a-p07/S01_front` 不存在）。
- **根因**：两类机制叠加——(a) import-from-dir 的 artifact 节点 id 是**位置序号**（`a-{phase}-art{i}`，`import-from-dir.ts:700-701`，已目验），目录文件增删导致重编号，merge 模式只覆盖同 id 节点、旧 id 及其边残留为孤儿（`import-from-dir.ts:2203-2208`）；(b) save-v2 的 delete-absent 全量替换删了节点但提交图里未含对应边（或反之）。
- **对 KMC/KGR 影响**：画布加载后边悬空，依赖图遍历的任何下游（对账、复刻依赖分析）会得到错误的拓扑；717 条集中在一个 project 说明该 scope 经历过漂移性重导入。
- **修复建议**：load-v2 或健康检查中对孤儿边计数告警；import merge 模式按内容 hash 而非位置序号定 id，或导入后级联清理孤儿边（成本 **S~M**）。

### P1-1 degrade 吞错后无自动兜底（p15 未注册的缺口仍在）

- **现象**：网络类同步失败被降级吞掉后，除打印一条手工命令外无任何自动恢复。
- **证据**：
  - 传输层：5xx/超时重试 3 次（1/2/4s 退避）后返回 degrade 信封不抛错（`plugins/kais_aigc/canvas.py:193-194, 221-251`）；
  - runner 层：非 4xx 异常 → 记 ERROR + 写 `canvas_sync_failed` 日志行，**episode 继续**（`pipeline/runner.py:1512-1544`）；4xx 才停管线（`runner.py:1489-1497`）；
  - 兜底仅是 `_report_canvas_sync_failed` 打印手工命令 `python3 -m plugins.kais_aigc.sync_canvas_retroactive`（`runner.py:1427-1429`）；
  - p15_canvas_sync（修复工具）确认**不在** PHASE_REGISTRY（`pipeline/phases/__init__.py:218-297` 全量登记清单无此模块；该文件自述 `p15_canvas_sync.py:3-8`）；
  - `canvas_writeback_queue` 只服务 manifest_writeback / g15 动作，**不覆盖 save-v2 通道**（生产者：`src/lib/manifestWriteback.ts:282-287`、`src/routes/canvas/v2/g15-ops.ts:97-103`）。
- **根因**：设计选择——修复工具游离于自动链外，回写队列未泛化到画布同步。
- **对 KMC/KGR 影响**：无人值守跑批时一次网络抖动 = 画布静默缺一段，直到有人看日志；KGR 若按画布拉资产会拿到不完整集合。
- **修复建议**：把 canvas_sync_failed 行接入自动重放（resume 时已有 `_run_retroactive_canvas_sync`，`runner.py:1547-1643`，可前移到 episode 内定时兜底），或注册 p15 为 advisory 终态 phase（成本 **M**）。

### P1-2 import-from-dir 关系层失败仍返回 200（写/读分裂）

- **现象**：导入接口可返回成功，但 canvas_nodes 关系层为空或部分写入。
- **证据**：replace 模式 `saveFullGraph` 失败 try/catch 仅 warn 继续（`import-from-dir.ts:2175-2183`）；关系层物化失败 `console.error` 后继续（`import-from-dir.ts:2281-2283`）；仅 legacy event blob 写失败才 500（`import-from-dir.ts:2301-2304`）。
- **根因**：双写架构（legacy `o_agentWorkData` blob + 关系表）容错策略不对称。
- **对 KMC/KGR 影响**：操作员看到「导入成功」但画布空/半空，排障需翻服务端日志；KMC 批量导入场景误导性强。
- **修复建议**：响应体增加 `relational: {ok, written, errors}` 字段，失败时 HTTP 207/500 或至少 `code!=200`（成本 **S**）。

### P1-3 project_id env 回退仍在两处存活（project 分裂残余通道）

- **现象**：特定执行路径下同步可写入错误 project，节点跨项目泄漏。
- **证据**：
  - `_canvas_helper.auto_canvas_sync()` 直接读 `KAIS_CANVAS_URL/KAIS_CANVAS_PROJECT_ID` env，**完全绕过 `.canvas.json` 钉住配置**（`pipeline/phases/_canvas_helper.py:17-19`，已目验），与 runner 自述契约「phase modules must NOT fall back to env vars when sync_canvas is None」（`runner.py:4961-4962`）直接矛盾；
  - runner 五级解析链第 4 级仍保留 env 回退（`runner.py:4932`），但已有 `.canvas.json` 钉住（`runner.py:4887, 4938-4940`）与解析失败硬退出（`runner.py:4989-5027`）在前兜底。
- **根因**：历史修复只钉住了 CLI 主链，agent 路径的 env helper 未同步整改。
- **对 KMC/KGR 影响**：tmux/subagent 环境 env 漂移时，phase 直跑（非 runner 全链）会把产物写到别的项目画布——正是历史缺陷 #5 描述的泄漏。
- **修复建议**：`_canvas_helper` 改为优先读 workdir `.canvas.json`，env 仅作显式 override 并打 WARN（成本 **S**）。

### P2-1 sync-assets 去重口径与 import-from-dir 不一致 → 跨通道重复节点

- **现象**：同一底层文件经两个通道各产生一个画布节点。
- **证据**：sync-assets 用 `canvas_nodes.data.filePath` 字符串相等做去重集合（`sync-assets.ts:213-225`），存的是 `o_image.filePath` 原值（实测 o_image 中为 `1/asset_cat.png`、`/home/kai/...` 等原始形态）；import-from-dir 存的是 `/oss/` 翻译后 URL（`import-from-dir.ts:432-433`）。两通道对同一文件永不相等 → 去重失效。**待验证**：本库实测是否有同文件双节点（需按 basename 对账，本次未展开）。
- **修复建议**：统一归一化（basename+size 或统一转 /oss/ 再比对）（成本 **S**）。

### P2-2 跨 phase artifact ID 碰撞残余面（generic fallback 未命名空间化）

- **现象**：主路径已修复，但通用回退提取器仍用裸 id。
- **证据**：已修处 `plugins/kais_aigc/canvas_sync.py:4782-4796`（`item_id = f"{out_key}-{_raw_id}"`）；回退分支 `canvas_sync.py:5166-5176`（已目验）仍 `_add_artifact(str(item.get("id") or f"{key}-{i}"))`，两个 phase 共享未映射 list key 且 item id 重叠时仍碰撞（upsert 整节点覆盖）。
- **修复建议**：fallback 分支同样加 `{key}-` 前缀（成本 **S**）。

### P2-3 canvas_writeback_queue 运维缺陷（非孤儿，但只进不出 + 懒启动）

- **现象**：队列有生产者与消费者（非孤儿），但：done/failed 行**永不删除**（`src/lib/writebackQueue.ts:86-127`，唯一 `.del()` 在验证脚本）；drain 仅在首次命中 select-winner/gold-gap/g15-ops 时懒启动，非服务启动即跑（`select-winner.ts:219`、`gold-gap.ts:346`、`g15-ops.ts:91`）；`ensureDrainStarted` 先注册者胜（`writebackQueue.ts:135-145`，已目验），若 select-winner 先启动，g15 动作行会被按 manifest 参数误回放（**待验证**，取决于请求顺序）。
- **实测**：队列共 2 行：`done×1`、`failed×1`（id=2，attempts=8 终态，`last_error="episode 目录解析失败(refs=ep1,1 ...)"`，2026-08-29）——终态 failed 行无人告警、无人清理。
- **修复建议**：failed 终态接入告警；done 行定期归档删除；drain 改为服务启动时挂载且按 action 分发（成本 **S**）。

### P2-4 响应信封 code 恒为 400（含 HTTP 500 场景）

- **证据**：`src/lib/responseFormat.ts:17-23` `error()` 体 `code:400` 硬编码；save-v2 500 分支同样返回体 code 400（`save-v2.ts:85-91`）。
- **影响**：KMC 客户端若按 body code 判错因会误判为数据问题；目前 KMC 按 HTTP 状态码分流（4xx 停、5xx 重试），未踩雷，但属契约陷阱。
- **修复建议**：body code 跟随 HTTP status（成本 **S**）。

### P2-5 其他次级项（证据齐全，从简）

- **nodes/links POST 409 检查-后-upsert 竞态**：`nodes.ts:75-78`、`links.ts:56-61`，并发下同 id 双写后者静默胜（成本 S）。
- **`_workdirToOss` 模块级可变全局**：`import-from-dir.ts:176, 1896-1897`，并发 import 不同 workdir 可能交叉翻译路径（**待验证**，无锁）（成本 S）。
- **存量脏数据**：`total_duration_sec` 仍有 4 条 integer 型（实测 `json_type` 分组：integer=4 / text=39），save-v2 现要求 string——这些是修复前入库的存量，重存这些节点会 400（成本 S：一次性数据清洗）。
- **saveFullGraph 全量替换语义**：同 scope 提交图中缺失的行即删（`canvasRelationalStore.ts:879-1011`）——对「全图提交」客户端是正确语义，但任何「只提交局部」的调用方会误删；KMC 侧有 degrade 信封跳过保存的防护（`canvas_sync.py:1196-1213`），属已防护的锐利边缘。

---

## 3. 历史线索核查表（工单第 2 节 7 条）

| # | 线索 | 结论 | 证据 |
|---|------|------|------|
| 1 | canvas_sync 查 o_assets.filePath（实际在 o_image） | **已修** | sync-assets 正确 LEFT JOIN o_image 取 `img.filePath`（`sync-assets.ts:172-195`，已目验）；legacy convert.ts 同样正确（`convert.ts:68-69`） |
| 2 | p15 未注册 → degrade 吞错无兜底 | **仍存在**（缺口部分缓解） | 注册表确认缺席（`phases/__init__.py:218-297`）；但已有：失败日志行 + resume 重放（`runner.py:1547-1643`）+ 手工命令提示（`runner.py:1427-1429`）。自动兜底仍无，见 P1-1 |
| 3 | 跨 phase artifact ID 碰撞 | **部分修复** | 主路径已加 out_key 命名空间（`canvas_sync.py:4782-4796`）；generic fallback 仍裸 id（`canvas_sync.py:5166-5176`）。DB 实测：1271 个节点 id 出现在 >1 个 project（如 `a-S01` 跨 5 项目、`a-S01_B01` 跨 9 项目）——跨 project 同 id 属 PK 设计允许，但也说明裸 id 命名曾被广泛使用 |
| 4 | 幻影 OSS URL | **仍存在** | `fsToOssUrl` 无 stat（`import-from-dir.ts:202-224`）；symlink 失败仅 warn（`:2114-2120`）；实测 100 抽样 37 缺失（P0-1） |
| 5 | project_id 分裂（env 回退） | **部分修复** | 主链已钉 `.canvas.json` + 硬失败（`runner.py:4887-5027`）；但 `_canvas_helper.py:17-19` 仍 env 直读绕过钉住，runner 第 4 级 env 回退仍在（`runner.py:4932`），见 P1-3 |
| 6 | canvas_writeback_queue 是否孤儿队列 | **非孤儿**（有生产者+消费者），但有运维缺陷 | 生产者 `manifestWriteback.ts:282-287`、`g15-ops.ts:97-103`；消费者 30s 间隔 drain（`writebackQueue.ts:91-145`），由三个端点懒启动。实测 1 行 failed 终态无人处理，见 P2-3 |
| 7 | save-v2 幂等语义 / DB 重复 node_id | **已修（真 upsert）** | `saveFullGraph` 单事务 `INSERT ... ON CONFLICT(id,project_id,episodes_id) DO UPDATE` + delete-absent（`canvasRelationalStore.ts:790-1032`）；实测同 scope 重复 node_id = **0**（PK 强制） |

---

## 4. C1 同步链端到端跳数图（一条 phase 产物 → 画布可见）

```
[H1] phase run() 完成，store.save_checkpoint
      └─ 失败模式: 无（本地）；可观测性: checkpoint 文件
[H2] runner._try_canvas_sync（_phase_complete_lock 互斥, runner.py:1479-1481）
      └─ 失败模式: 回调异常被吞（runner 保证订阅者不炸 episode, runner.py:457-459）
[H3] CanvasSyncSubscriber: load 全图 → 合并本 phase 节点 → Gate-B 缺字段降级为 script 类型
     （canvas_sync.py:1196-1213, 5704-5728）→ sanitize（canvas_sync.py:1455-1459）
      └─ 失败模式: load 拿到 degrade 信封 → 整段跳过同步（防 delete-absent 清画布）；类型降级会掩盖缺字段事实
[H4] CanvasClient.save_canvas POST :10588 /api/canvas/v2/save-v2
     （canvas.py:385-390；4xx 立即抛 / 5xx·超时重试3次 1s/2s/4s → degrade 信封）
      └─ 失败模式: 网络抖动→3次后吞掉（P1-1）；400→停管线；可观测性: canvas-sync-errors.jsonl
[H5] KAP validateFields(zod) → FlowGraphV2Schema.safeParse → validateGraphNodes
     （save-v2.ts:27-34, 39-42, 52-61）
      └─ 失败模式: 任一不过 → HTTP 400 全图拒收（无部分写入）；契约错配面已被 KMC 侧 sanitize 前置吸收
[H6] processGraphThumbnails（save-v2.ts:64-68，已目验）
      └─ 失败模式: 缩略图失败 console.warn 继续（有意吞错，缩略图缺失无告警）
[H7] saveFullGraph 单事务：chunked upsert 四表 + delete-absent + meta（canvasRelationalStore.ts:790-1032）
      └─ 失败模式: 事务级回滚 → HTTP 500（体 code 误标 400, P2-4）；无部分写
[H8] broadcast graph:saved（save-v2.ts:78-83，先于 HTTP 响应）→ 前端可见
```

degrade 吞错面集中在 H3（load 跳过）、H4（网络吞）、H6（缩略图吞）三跳；其中 H4 无自动兜底（P1-1）。

---

## 5. 改进建议（标注服务对象）

| 建议 | 服务对象 | 成本 |
|------|----------|------|
| 画布失效引用对账器：扫描 filePath stat + 标记 missing，输出报告（直接复用本报告抽样 SQL+shell 方法） | KGR 复刻精度 / 平台可靠性 | M |
| import/同步写路径 stat 校验，缺失文件节点打标而非静默铸 URL | KGR 复刻精度 | S |
| load-v2/health 增加孤儿边、孤儿节点计数指标 + 阈值告警 | 平台可靠性 | S |
| import-from-dir 响应体暴露关系层物化结果（P1-2） | KMC 生产效率 | S |
| canvas_sync_failed 接入自动重放 / p15 注册为 advisory 终态 phase（P1-1） | KMC 生产效率 | M |
| `_canvas_helper` 改读 `.canvas.json` 优先（P1-3） | KMC 生产效率 | S |
| fallback 提取器 id 加 `{key}-` 前缀（P2-2）；sync-assets 去重归一化（P2-1） | KGR 复刻精度 | S |
| writeback queue：failed 告警、done 归档、drain 随服务启动 + 按 action 分发（P2-3） | 平台可靠性 | S |
| error() body code 跟随 HTTP status（P2-4） | 平台可靠性 | S |
| artifact 节点 id 改内容 hash（治本位置序号漂移，P0-2 根因之一） | KGR 复刻精度 | M |

---

## 6. 验证方式（operator 独立复核）

```bash
# 锚点
cd /data/workspace/kais-aigc-platform && git rev-parse --abbrev-ref HEAD && git rev-parse --short HEAD

# 健康（只读 GET）
curl -s http://localhost:10588/api/canvas/v2/health | head -c 400

# 重复 node_id（应=0）
sqlite3 "file:data/db2.sqlite?mode=ro" \
 "SELECT COUNT(*) FROM (SELECT id,project_id,episodes_id FROM canvas_nodes GROUP BY 1,2,3 HAVING COUNT(*)>1);"

# 孤儿 links
sqlite3 "file:data/db2.sqlite?mode=ro" \
 "SELECT COUNT(*) FROM canvas_links l WHERE NOT EXISTS (SELECT 1 FROM canvas_nodes n WHERE n.id=l.source_id AND n.project_id=l.project_id AND n.episodes_id=l.episodes_id);"
# target 版把 l.source_id 换成 l.target_id

# 幻影 URL 抽样（P0-1 复核）
sqlite3 "file:data/db2.sqlite?mode=ro" \
 "SELECT json_extract(data,'$.filePath') FROM canvas_nodes WHERE json_extract(data,'$.filePath') LIKE '/oss/%' ORDER BY RANDOM() LIMIT 100;" |
 while IFS= read -r p; do p="${p%%\?*}"; rel="${p#/oss/}";
   [ -e "data/oss/$rel" ] || [ -e "/mnt/agents/output/$rel" ] || echo "MISSING $p"; done

# writeback 队列状态
sqlite3 "file:data/db2.sqlite?mode=ro" \
 "SELECT state,action,COUNT(*) FROM canvas_writeback_queue GROUP BY 1,2;"

# 代码证据复核（行号直读）
sed -n '172,195p' src/routes/canvas/v2/sync-assets.ts          # o_image JOIN（线索1）
sed -n '202,224p' src/routes/canvas/v2/import-from-dir.ts     # fsToOssUrl 无 stat（线索4）
sed -n '790,880p' src/lib/canvasRelationalStore.ts            # saveFullGraph upsert（线索7）
sed -n '5166,5176p' /data/workspace/kais-hermes-skills/plugins/kais_aigc/canvas_sync.py  # fallback 裸 id（线索3）
sed -n '9,41p'   /data/workspace/kais-hermes-skills/skills/kais-movie-pipeline/pipeline/phases/_canvas_helper.py  # env 回退（线索5）
grep -n p15_canvas_sync /data/workspace/kais-hermes-skills/skills/kais-movie-pipeline/pipeline/phases/__init__.py  # 应无输出（线索2）
```

---

## 附：≤300 字摘要

主同步链（runner→save-v2→saveFullGraph）工程质量良好：真 upsert 幂等（实测重复 node_id=0）、单事务、4xx 停管线、5xx 三重试。历史 7 条线索中 o_image JOIN 与 ID 碰撞主路径已修。核心问题三：其一，幻影 URL 实测 37%（100 抽样）——fsToOssUrl 不 stat、symlink 失败仅 warn、事后清理无回收，KGR 按画布取源文件必踩 404；其二，732 条孤儿边集中于单一 project，根因是位置序号 id 遇目录漂移重编号 + merge 模式不清理；其三，degrade 吞网络错误后仍无自动兜底——p15 未注册、writeback 队列不覆盖 save-v2，无人值守跑批一次抖动即静默缺段。另发现 agent 路径 `_canvas_helper` 仍 env 直读绕过 `.canvas.json` 钉住（project 分裂残余通道）、import-from-dir 关系层失败仍 200、writeback 队列 done/failed 行只进不出。建议优先落地：失效引用对账器（M）、孤儿边告警（S）、sync 失败自动重放（M）。
