# Workspace Evidence File-First 链路审计

**日期:** 2026-05-01  
**范围:** 审计 PR #360/#361/#362 后的 `clinic-cli`、`tidb-clinic-diagnosis collect_evidence` 到 workspace evidence、postpass、File API、UI metric chart 链路。  
**约束:** 本文只记录审计结论，不修改实现代码，不运行 dev FT，不部署。

## Current Design

当前实现的 canonical workspace evidence 根目录是：

```text
<workspace>/evidences/
```

`backend/investibook/core/workspace_setup.py` 定义 `WORKSPACE_EVIDENCES_DIRNAME = "evidences"`；`collect_evidence.py` 和 `clinic-cli` 也都使用 `evidences`。因此 2026-04-27 设计文档里出现的 `workspace-evidence/` 是旧命名残留，不是当前代码的实际路径。

完整诊断链路和 direct-query 链路共享同一个 workspace evidence store，但写入方式不同：

- `tidb-clinic-diagnosis/scripts/collect_evidence.py` 是完整诊断入口。它一次性采集 A1-A6、gate checks 和 links，并写入 canonical 文件组。
- `clinic-cli/scripts/clinic_cli.py` 是 direct-query 数据入口。每个数据命令直接 upsert 对应 raw evidence 文件和 `workspace_evidence_report.json`，stdout 只返回 `persisted_evidence` 路径和 summary，不返回 raw `payload`，也不返回旧的 `workspace_evidence` envelope。
- Backend `workspace_evidence_store.py` 只保留 postpass analysis patch 读写；raw evidence append API 和 ACP 自动解析 `workspace_evidence` envelope 的路径应移除，避免 backend 重新成为 evidence 写入口。
- `workspace_evidence_report.json` 是 UI 和 postpass 的 canonical report。Backend 对 `type == "workspace_evidence_report"` 的 hydration 不读取 sidecar，只做 analysis evidence 链接补全。

当前 canonical report 的核心结构：

```json
{
  "type": "workspace_evidence_report",
  "metadata": {
    "workspace_id": "...",
    "cluster_id": "...",
    "evidence_dir": ".../evidences",
    "time_range": {"start": 0, "end": 0, "step": "60s"},
    "last_raw_evidence": {"at": "...", "message_id": "...", "source": "...", "kind": "..."},
    "last_analysis_update": {"at": "...", "message_id": "...", "source": "backend-postpass", "kind": "analysis"}
  },
  "analysis": {
    "severity": "...",
    "key_evidence": [],
    "supporting_evidence": []
  },
  "evidence": {
    "metrics": [],
    "queries": [],
    "logs": [],
    "events": [],
    "profiles": [],
    "files": [],
    "workload": [],
    "component_health": {}
  },
  "resource_control": {},
  "gate_checks": {},
  "links": {}
}
```

## Write Paths

### `collect_evidence.py` 完整诊断写入

入口：

```bash
PYTHONPATH=.opencode/skills/builtin/clinic-cli/scripts \
python3 .opencode/skills/builtin/tidb-clinic-diagnosis/scripts/collect_evidence.py <cluster_id> <start_ts> <end_ts> [--parent-id <parent_id>]
```

workspace 根目录解析顺序：

1. 从脚本路径向上找目录名以 `ws-` 开头的 workspace。
2. 使用 `INVESTIBOOK_WORKSPACE_DIR`。
3. 如果 cwd 是 `ws-*`，使用 cwd。
4. 否则失败。

写入文件：

```text
<workspace>/evidences/
  evidence_summary_for_agent.txt
  evidence_summary_for_agent.json
  workspace_evidence_report.json
  metrics.json
  workload.json
  resource_control.json
  component_health.json
  queries.json
  logs.json
  tikv_thread_cpu.json
  links.json
  gate_checks.json
  collection_errors.json
```

关键语义：

- `run_dir` 固定为 `<workspace>/evidences`，不再写 collection manifest；当前 `collect_evidence.py` 仍按现有 merge/upsert 语义更新 report 和 raw sidecar。
- `workspace_evidence_report.json` 由 Python 写入，agent 不应手写 `analysis_patch.json` 或 report。
- `metrics.json` 保存 raw metric rows，其中可能包含 `chart_data`；`workspace_evidence_report.json` 只保存 compact metrics，不包含 `chart_data`。
- `evidence_summary_for_agent.txt/json` 是 agent reasoning 输入，不是 UI 的 primary report。

### `clinic-cli` direct-query 写入

入口：

```bash
python3 .opencode/skills/builtin/clinic-cli/scripts/clinic_cli.py <subcommand> ...
```

workspace 根目录解析：

1. 从脚本路径向上找 `ws-*`。
2. 使用 `INVESTIBOOK_WORKSPACE_DIR`。
3. 如果 cwd 是 `ws-*`，使用 cwd。
4. 否则失败。

明确不使用 `WORKSPACE_EVIDENCE_DIR`。测试覆盖了 agent 设置 `WORKSPACE_EVIDENCE_DIR` 到 issue 子目录时，CLI 仍写入 `<workspace>/evidences`。

kind 到 raw 文件映射：

| kind | raw 文件 | 来源 |
| --- | --- | --- |
| `metric` | `metrics.json` | `metrics` |
| `query` | `queries.json` | `sql` |
| `log` | `logs.json` | `logs` |
| `event` | `events.json` | `activity-events` |
| `profile` | `profiles.json` | `continuous-profiling` 和 download |
| `file` | `files.json` | `diagnostic-files` 和 download |
| `cluster_meta` | `cluster_meta.json` | `cluster-metadata` |

stdout 合约：

- 成功时返回 `status`, `contract_version`, `command`, `kind`, `cluster_id`, summary 字段和 `persisted_evidence`。
- 若已持久化，stdout 会移除 raw `payload`。
- stdout 不包含 `workspace_evidence` envelope；这是刻意设计，避免 runtime 依赖大 payload。

`clinic-cli` 同步更新 `workspace_evidence_report.json`：

- `metadata.cluster_id`
- `metadata.evidence_dir`
- `metadata.last_raw_evidence = {source, kind, file_path}`
- 对非 `cluster_meta` kind，把 raw 文件 compact 后更新到 `report.evidence`。
- `cluster_meta` 只写 `metadata.cluster`，不进入 `report.evidence`。
- metric row 自动带 `id`, `group`, `ref`, `metric_group_id`, `summary`, `series_summaries`, `time_window`, `chart_data_ref`；raw `metrics.json` 可保留 `chart_data`，report 中移除 `chart_data`。

### Backend analysis 写入

Backend 不再提供 raw evidence append 入口，也不再从 tool stdout 的 `workspace_evidence` envelope 自动 append raw evidence。当前主链路是“CLI/collect 直接文件写入 + chat complete 触发 postpass”。

Backend 仅在 postpass 阶段写入 analysis：

- 读取 `<workspace>/evidences/workspace_evidence_report.json`。
- 生成并校验 analysis patch。
- 写入 `analysis_patch.json`。
- 将 patch merge 到 `workspace_evidence_report.json.analysis`。

## Read Paths

### Backend report hydration

`backend/investibook/core/acp.py` 有两套 hydration：

- `type == "diagnosis_report"`：仍走旧 `diagnosis_report.json` sidecar merge，读取 `evidence_summary_for_agent.json`、`queries.json`、`logs.json` 等作为 fallback。
- `type == "workspace_evidence_report"`：不读取 sidecar，只复制 canonical report 并补全 analysis evidence 的 link。

这意味着 workspace evidence 的 UI 展示以 `workspace_evidence_report.json` 为准，不会被 sidecar stale 数据污染。

### File API

相关 API：

- `GET /api/workspace/{workspace_id}/files`：递归列出 workspace 文件树。
- `GET /api/workspace/{workspace_id}/file?path=...`：预览文件；如果目标是 `workspace_evidence_report.json` 且 type 正确，返回 hydrated JSON。
- `GET /api/workspace/{workspace_id}/metric-chart?path=evidences/metrics.json&metric_group_id=...`：返回 metric chart。

`metric-chart` 只允许 path 指向 `metrics.json` 且路径在 `evidences/` 下。它先读取 raw `chart_data`；如果 raw 文件里没有 chart series，则使用 `chart_data_ref` 或 sibling `workspace_evidence_report.json` 的 metadata 调 Clinic Data Proxy replay。

### UI 文件和 artifact 发现

前端 `StudioPanel` 通过 files API 构造 virtual evidence artifact：

- 只识别根目录为 `evidences/` 的 `.json` 文件。
- `workspace_evidence_report.json` 和 raw sidecar 是 UI/postpass 的 current view；collection manifest 不应再作为 evidence artifact 或验证条件。
- `workspace_evidence_report.json` 标记为 `reportType = "workspace_evidence"`。
- evidence group 以 `evidences` 作为 workspace evidence root。

Chat SSE 的 `artifact` event 也可以直接加入右侧 report artifact；工具完成时会触发文件树 refresh，因此 `collect_evidence.py` 或 `clinic-cli` 写文件后 UI 可以通过 files API 发现最新文件。

## Postpass Contract

触发：

- `api/chat.py` 在 chat completed 后统一调用 `schedule_workspace_evidence_postpass(workspace_id, assistant_message_id)`。
- postpass 读取 `workspace_evidence_report.json` 和 raw sidecar 文件；不存在 backend raw append 成功条件。
- postpass 以 `(workspace_id, message_id)` 去重，默认延迟 3 秒。

是否需要运行由 `get_workspace_evidence_postpass_state()` 判定：

- report 必须存在且 `type == "workspace_evidence_report"`。
- `message_id` 非空。
- `metadata.last_raw_evidence.message_id` 为空或等于当前 assistant message id。
- `metadata.last_analysis_update.message_id` 不等于当前 assistant message id。
- report 中存在 raw evidence：metrics、queries、logs、workload、component_health，或 links、gate_checks、resource_control。

`clinic-cli` 的 `last_raw_evidence` 不带 `message_id`，因此 chat complete 后的 postpass 可以匹配 `raw_message_id == ""` 并运行。`collect_evidence.py` 未显式写 `last_raw_evidence` 时也走同一兼容路径。

postpass prompt：

- Backend-only，不使用 ACP session，不使用工具。
- 输入是 `_build_postpass_summary(report)` 生成的小摘要，明确不包含 raw `chart_data`。
- 输出必须是 JSON analysis patch。
- 支持字段：`severity`, `key_evidence`, `supporting_evidence`, `add_key_evidence`, `remove_key_evidence`, `add_supporting_evidence`, `remove_supporting_evidence`。
- metric evidence 应包含 `metric_ref.metric_group_id`；如果 LLM 省略，`_normalize_analysis_patch_refs()` 会尽量从 report 的 metrics 匹配补齐。
- patch 通过 `validate_analysis_patch()` 后写入 `analysis_patch.json` 并 merge 到 `workspace_evidence_report.json`。

当前 fallback `_fallback_patch_from_report()` 返回空对象，所以如果没有 `INVESTIBOOK_OPENAI_API_KEY`、LLM 失败或输出空 patch，postpass 会安静跳过 analysis 更新。

## UI Contract

### Report artifact contract

UI 读取 `workspace_evidence_report.json`，关注：

- `type == "workspace_evidence_report"`
- `analysis.key_evidence`
- `analysis.supporting_evidence`
- `evidence.metrics`
- `evidence.queries`
- `links`
- `gate_checks`

analysis evidence item 的最小 UI 合约：

```json
{
  "type": "metric",
  "name": "TiKV CPU",
  "reason": "...",
  "metric_ref": {
    "metric_group_id": "tikv_cpu:db",
    "instances": ["db-tikv-19"]
  }
}
```

非 metric evidence 可以通过 hydration 自动补 link；metric evidence 不显示普通 link，而是尝试渲染 chart。

### Metric chart contract

UI 渲染 metric chart 的实际链路：

1. 在同一个 evidence group 里找到 `metrics.json` artifact。
2. 对每个 `type == "metric"` 的 authored evidence item，读取 `metric_ref.metric_group_id`。
3. 请求：

```text
GET /api/workspace/{workspace_id}/metric-chart?path=evidences/metrics.json&metric_group_id=<id>
```

4. File API 返回：

```json
{
  "key": "tikv_cpu:db",
  "title": "...",
  "metricName": "...",
  "timeRange": [0, 0],
  "data": {
    "instance": [{"timestamp": 0, "value": 1.2, "instance": "instance"}]
  },
  "metrics": []
}
```

chart 数据来源优先级：

1. raw `metrics.json` 中对应 `metric_group_id` 行的 `chart_data.series`。
2. fallback replay：使用 `chart_data_ref` 或 sibling report metadata + row.promql 去 Clinic Data Proxy 重新查询。

如果缺少 `metrics.json`、缺少 `metric_ref.metric_group_id`、metric group 不存在、没有 chart series 且无法 replay，UI 会显示对应空状态或 HTTP 错误文本。

## Known Risks / Follow-ups

1. **旧命名文档残留：中风险。** 2026-04-27 设计文档仍大量写 `workspace-evidence/`，当前实现实际是 `evidences/`。代码路径已统一，但历史文档和验收片段会误导后续排障。

2. **`diagnosis_report` 旧 hydration 仍存在：低到中风险。** `acp.py` 仍支持 `type == "diagnosis_report"` 的 sidecar merge，这是 legacy 路径。对 `workspace_evidence_report` 已明确不读 sidecar，因此不会污染当前 canonical report；但如果未来某个 agent 或工具再次写出 `diagnosis_report.json`，前端/后端仍可能展示旧语义产物。FT 脚本已检查完整诊断不应生成 `diagnosis_report.json`。

3. **`collect_evidence.py` 与 `clinic-cli` 仍各有一套 Clinic 查询和 report/raw 写入逻辑：中风险。** backend raw append 已不应再作为写入口，但 collect 和 CLI 之间仍需保持 stdout/file/report 边界一致。长期应把 collect 的底层查询收敛到 clinic-cli，或抽出共享 schema/formatter。

4. **文件写入没有跨进程锁：中风险。** `collect_evidence.py`、`clinic-cli` 仍是 read-modify-write JSON。并发执行多个 CLI 或完整 collection 时可能出现 last-writer-wins、丢失 merge 或 `last_raw_evidence` 覆盖。当前去重只能处理单进程顺序 merge，不能防止并发 race。

5. **同一 workspace 多次 `collect_evidence.py` 会 merge/upsert 到 current report/raw files：低概率风险。** 一个 workspace 通常只诊断一个问题，因此本轮不修；若同 workspace 用不同 cluster/time range 重复 collect，旧 metrics/queries/analysis 可能混入当前 report。

6. **`collect_evidence.py` 与 `clinic-cli` 对 report metadata 的 `last_raw_evidence` 语义不同：低到中风险。** `collect_evidence.py` 写完整 report 时没有设置 `last_raw_evidence`；但因为 chat complete 会 schedule postpass，`raw_message_id == ""` 可以通过条件。`clinic-cli` 写 `{source, kind, file_path}`。postpass 目前能兼容，但审计和排障时字段不完全一致。

7. **postpass 无 LLM key 时没有 fallback analysis：中风险。** `_fallback_patch_from_report()` 返回 `{}`。如果 `INVESTIBOOK_OPENAI_API_KEY` 未配置，UI 仍有 raw evidence/report，但不会生成 `key_evidence`，metric chart 也不会从 authored evidence 自动展开。

8. **metric chart replay 依赖后端 Clinic API key：低到中风险。** raw `chart_data` 存在时无需 replay；但 `clinic-cli metrics` 默认只写 `chart_data_ref`，未加 `--inline-chart-data` 时 UI chart 需要后端有 `SKILL_CLINIC_API_KEY`、`CLINIC_API_KEY` 或 `INVESTIBOOK_CLINIC_API_KEY`。缺 key 会返回 503。`collect_evidence.py` 的 top instance rows通常带 `chart_data`，风险较低。

9. **postpass metric_ref 匹配可能不够精确：低风险。** 如果多个 metrics 名称相近且 LLM 省略 `metric_ref.metric_group_id`，`_normalize_analysis_patch_refs()` 可能匹配到第一个 name/group candidate。prompt 已要求复制 metric_group_id，且验证允许事后补齐，但仍建议在测试中覆盖同名不同 group 的场景。

10. **raw payload 泄漏到 report：当前未见高风险。** 已看到 `collect_evidence.py` 使用 `compact_metrics_for_ai()` 写 report，`clinic-cli` 在 report compact 时剔除 `chart_data`。postpass summary 也不包含 `chart_data`。残余风险是 queries/logs 的文本字段本身可能较长，但不是 raw time-series payload。

11. **`collect_evidence.py` agent summary 可能缺 `summary/chart_data_ref`：待后续确认。** 当前 `compact_metric_for_ai()` 的字段白名单不含 `summary` 和 `chart_data_ref`。如果 diagnosis strict assert 要求 agent summary 或 report compact metric 必须带这两个字段，需要在 `collect_evidence.py` 内统一补齐。

12. **旧 `WORKSPACE_EVIDENCE_DIR` 残留：当前实现风险低。** `clinic-cli` 测试明确忽略 `WORKSPACE_EVIDENCE_DIR`；代码搜索未发现生产写路径使用该变量。测试名仍 delenv/setenv 该变量，是为了防回归。

## Verification Checklist

不在本次任务中执行 FT 或部署。建议后续用以下清单做回归：

- [ ] 新建 workspace 后运行 `collect_evidence.py`，确认只生成 `<workspace>/evidences/*`，不生成 `diagnosis_report.json`。
- [ ] 检查不依赖 collection manifest，UI/postpass 以 `evidences/workspace_evidence_report.json` 和 raw sidecar 为准。
- [ ] 检查 `workspace_evidence_report.json`：`type == "workspace_evidence_report"`，`evidence.metrics` 不包含 `chart_data`。
- [ ] 检查 `metrics.json`：metric row 带 `id/group/ref/metric_group_id/summary/chart_data_ref`；需要 UI 本地渲染的 metric group 至少有 `chart_data`，或有可 replay 的 `chart_data_ref`。
- [ ] 运行 `clinic-cli metrics`，确认 stdout 不包含 `payload` 和 `workspace_evidence`，只包含路径、summary 和小型元信息，包含 `persisted_evidence`。
- [ ] 设置 `WORKSPACE_EVIDENCE_DIR` 到非 canonical 子目录，再运行 `clinic-cli metrics`，确认仍写 `<workspace>/evidences/metrics.json`。
- [ ] chat complete 后检查 postpass：`metadata.last_analysis_update.message_id` 等于 assistant message id，或在无 LLM key 时确认日志显示 empty patch 且 raw evidence 可展示。
- [ ] 通过 files API 预览 `evidences/workspace_evidence_report.json`，确认返回 hydrated report。
- [ ] 通过 metric-chart API 请求 `evidences/metrics.json + metric_group_id`，确认 raw chart 或 replay 至少一种成功。
- [ ] UI StudioPanel 中确认 Workspace Evidence group 出现，metric authored evidence 能展开 chart；若失败，错误应是缺 `metric_ref`、缺 `metrics.json`、404 group not found 或 503 Clinic key missing 中的一种可解释状态。
- [ ] 并发风险专项：并行运行两个 `clinic-cli metrics` 或 `clinic-cli metrics` + `collect_evidence.py`，检查 JSON 文件是否丢数据；当前代码没有锁，若要支持并发需要补文件锁或单 writer 队列。
