Commit c788bbacbef7234a2925e7080813a6de5a4ec154
1 parent
2c19914b
graphlemon: prd artifact for task b243dfe8-4da8-49bd-895b-8b9e9ed17280
Showing
1 changed file
with
209 additions
and
0 deletions
.graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/prd.md
0 → 100644
| 1 | +# PRD:Skill 详情页展示真实内容 | ||
| 2 | + | ||
| 3 | +## 1. 背景与问题 | ||
| 4 | + | ||
| 5 | +`http://127.0.0.1:8420/#/skills/:skill_id/audit`(`v_skill_audit_log`)目前只查询并展示 | ||
| 6 | +`skill_iteration` 表(“变更审计”面板)。当某个 Skill 还没有任何自主迭代记录时(当前所有从 | ||
| 7 | +Hermes profile 迁移来的 Skill 都是这种状态),页面只显示“该 Skill 尚无自主迭代记录”,用户看 | ||
| 8 | +不到 Skill 本身写了什么——即 `skills/<skill_id>/SKILL.md` 的真实文本内容。 | ||
| 9 | + | ||
| 10 | +根因:`src/skill_query.py` 只有 `list_skills()`(读 `skill` 表)和 `get_skill_audit_log()`(读 | ||
| 11 | +`skill_iteration` 表),没有任何函数读取磁盘上 `skills/<skill_id>/SKILL.md` 的真实内容。这批真 | ||
| 12 | +实文件是最近一次迁移任务才创建的(`skill_update.py` 旧注释“`skills/<skill_id>/` doesn't exist | ||
| 13 | +as real files”已过期,需在实现时一并核实/更新该注释,但不属于本次改动范围本身)。 | ||
| 14 | + | ||
| 15 | +本次修复是纯粹的“读取 + 展示”接入,不改变 Skill 迁移逻辑,不改变现有迭代审计逻辑。 | ||
| 16 | + | ||
| 17 | +## 2. 目标(Goals) | ||
| 18 | + | ||
| 19 | +- G1:`v_skill_audit_log` 页面新增“Skill 内容”面板,与既有“变更审计”面板并列展示,用户打开任 | ||
| 20 | + 意 Skill 详情页都能看到该 Skill 的真实 `SKILL.md` 文本内容。 | ||
| 21 | +- G2:后端新增只读接口,暴露 Skill 内容 + 支持文件清单,且对 `skill_id` 这个用户可控输入做路 | ||
| 22 | + 径穿越防护。 | ||
| 23 | +- G3:内容渲染方式与 `v_artifact_browser` 保持一致(纯 `<pre>` 文本,不引入 Markdown/代码高亮 | ||
| 24 | + 库),维持“纯 vanilla JS、无构建步骤”的既有约定。 | ||
| 25 | + | ||
| 26 | +## 3. 非目标(Non-goals) | ||
| 27 | + | ||
| 28 | +- 不改动 `skill_update.py` / Skill 迁移脚本的任何逻辑。 | ||
| 29 | +- 不改动 `get_skill_audit_log()` 现有查询逻辑、现有 `/api/skills/:skill_id/audit` 响应契约、 | ||
| 30 | + 现有“迭代记录”面板的 DOM 结构——两个信息源并列,不做二选一或合并覆盖。 | ||
| 31 | +- 不提供 Skill 内容的在线编辑/写入能力(本次是只读展示,写入仍只能走 | ||
| 32 | + `c_skill_update`/`c_skill_rollback` 现有链路)。 | ||
| 33 | +- 不引入 Markdown 渲染、代码高亮或任何新的前端第三方依赖。 | ||
| 34 | +- 不做支持文件(`references/`/`templates/`/`scripts/` 等)的内容预览——本次只做文件清单展 | ||
| 35 | + 示,点击查看子文件内容不在范围内(当前仓库内所有已迁移 Skill 目录下都还没有这类子文件,属 | ||
| 36 | + 于面向未来的占位展示,先满足“看得见有哪些文件”,内容预览留待真实需求出现时再建,避免过度设 | ||
| 37 | + 计)。 | ||
| 38 | +- 不做通用二进制文件预览。 | ||
| 39 | + | ||
| 40 | +## 4. 关键设计决策(解决 BRD 遗留的两个开放问题) | ||
| 41 | + | ||
| 42 | +### 4.1 决策一:新增接口的形态 | ||
| 43 | + | ||
| 44 | +新增 `GET /api/skills/:skill_id`(Skill 详情接口),与已有的 `GET /api/skills`(列表)、 | ||
| 45 | +`GET /api/skills/:skill_id/audit`(迭代审计)并列,不复用/不修改 `/audit` 的响应契约。 | ||
| 46 | + | ||
| 47 | +理由: | ||
| 48 | +- 参照本仓库已有先例 `GET /api/artifacts/:artifact_id`——列表接口返回摘要,详情接口返回完整 | ||
| 49 | + 内容(含 `content` 字段,`null` 表示文件不可用)。`/skills/:skill_id` 遵循同一 REST 资源语 | ||
| 50 | + 义:Skill 本身的详情 vs. Skill 的迭代审计子资源,职责天然分离。 | ||
| 51 | +- 修改 `/audit` 的响应体形状会牵动其现有测试(`SkillRouteTest`)和前端既有渲染逻辑,增加不必 | ||
| 52 | + 要的回归面;新增一个兄弟接口是零风险的纵向增量。 | ||
| 53 | +- 前端页面通过 `Promise.all` 并行请求 `/api/skills/:skill_id` 与 | ||
| 54 | + `/api/skills/:skill_id/audit`(参照 `mountArtifactsOverviewPanel` 已有的并行请求写法),两 | ||
| 55 | + 个面板各自独立渲染、独立失败态,符合“两个 independent 信息源”的要求。 | ||
| 56 | + | ||
| 57 | +`GET /api/skills/:skill_id` 响应体: | ||
| 58 | + | ||
| 59 | +```json | ||
| 60 | +{ | ||
| 61 | + "skill_id": "code-agent-engineering", | ||
| 62 | + "owner_org_node": "coder", | ||
| 63 | + "file_path": "skills/code-agent-engineering/", | ||
| 64 | + "current_git_commit_sha": "unknown", | ||
| 65 | + "source": "migrated_from_hermes", | ||
| 66 | + "migrated_from_profile": "code", | ||
| 67 | + "status": "active", | ||
| 68 | + "content": "<SKILL.md 全文文本,UTF-8;文件不存在时为 null>", | ||
| 69 | + "supporting_files": ["references/foo.md", "scripts/bar.py"] | ||
| 70 | +} | ||
| 71 | +``` | ||
| 72 | + | ||
| 73 | +- 404 `{"error": "NOT_FOUND"}`:`skill_id` 在 `skill` 表中不存在(与 `/audit` 现有 404 行为保 | ||
| 74 | + 持一致的错误形状)。 | ||
| 75 | +- `content: null`:`skill` 表中有该行,但对应的 `SKILL.md` 文件在磁盘上缺失(与 | ||
| 76 | + `v_artifact_browser` 中 `artifact.content == null` 的既有约定一致,不视为错误,200 返回)。 | ||
| 77 | + | ||
| 78 | +### 4.2 决策二:支持文件的呈现方式 | ||
| 79 | + | ||
| 80 | +`supporting_files` 是一个字符串数组,元素为相对 Skill 目录的相对路径(如 | ||
| 81 | +`"references/foo.md"`),按字典序排序;只列出文件本身是否存在,不读取、不返回其内容。前端只 | ||
| 82 | +渲染成一个纯文本清单(`<ul>`/`<pre>`,非可点击的预览入口);目录为空时显示“无支持文件”提示 | ||
| 83 | +文案,不请求也不渲染额外内容面板。 | ||
| 84 | + | ||
| 85 | +理由:当前仓库所有已迁移 Skill 目录下都只有 `SKILL.md` 一个文件,`references/`/`templates/`/ | ||
| 86 | +`scripts/` 完全是面向未来的假设场景;先给出“存在哪些文件”的最小可见性即可满足 BRD 要求 | ||
| 87 | +(“判断要不要一并列出/展示”),把内容预览这个更大的功能面留给真实出现该场景时再单独立项,避 | ||
| 88 | +免为不存在的需求建可点击的子路由/子预览逻辑。 | ||
| 89 | + | ||
| 90 | +## 5. 功能需求(Functional Requirements) | ||
| 91 | + | ||
| 92 | +### FR-1:`src/skill_query.py` 新增 `get_skill_content(conn, skill_id, repo_path=None)` | ||
| 93 | + | ||
| 94 | +- 复用现有 `SkillNotFoundError`:先按 `skill_id` 查 `skill` 表(参数化 SQL,与 | ||
| 95 | + `get_skill_audit_log()` 完全相同的校验前置模式),查不到直接抛出,不触碰文件系统。 | ||
| 96 | +- **不得**用 `skill_id` 直接拼接文件系统路径(例如 | ||
| 97 | + `os.path.join(repo_root, "skills", skill_id, "SKILL.md")` 这种写法是本次要杜绝的漏洞模式)。 | ||
| 98 | + 必须复用查到的 `skill.file_path` 列值来解析真实路径——这是 `skill_update.py` 的 | ||
| 99 | + `_skill_file_path()` 已经在用的解析规则(`.md` 结尾直接用,否则拼接 `SKILL.md`)。为避免两 | ||
| 100 | + 处实现漂移,`skill_query.py` 应直接复用 `skill_update._skill_file_path`(导入复用),不要重 | ||
| 101 | + 写一份等价逻辑。 | ||
| 102 | +- 纵深防御:即便路径已经来自 DB 而非直接来自 `skill_id`,仍需在打开文件前做一次路径包含性校 | ||
| 103 | + 验——对解析出的绝对路径和 `repo_root/skills` 的绝对路径都做 `os.path.realpath`,确认前者位 | ||
| 104 | + 于后者之内(`os.path.commonpath` 或等价前缀比较,注意分隔符边界,避免 | ||
| 105 | + `skills-evil/` 这类前缀误判)。校验失败时抛出新增的 `SkillPathTraversalError`,**不读取任何 | ||
| 106 | + 文件**,即“失败关闭”(fail closed)。这一层是防止 `skill.file_path` 列未来被写入非预期值 | ||
| 107 | + (人为改库、迁移脚本 bug 等)时仍然造成越界读取。 | ||
| 108 | +- 读取 `SKILL.md`:文件存在则以 UTF-8 读取全文返回;文件不存在(DB 有行但磁盘文件缺失,例如 | ||
| 109 | + 尚未迁移完成)时 `content` 置为 `None`,不抛异常(对齐 `v_artifact_browser` 的“文件不可用” | ||
| 110 | + 既有约定)。 | ||
| 111 | +- 枚举支持文件:遍历 Skill 目录(`os.walk`,跳过 `SKILL.md` 自身),返回排序后的相对路径列 | ||
| 112 | + 表;目录本身不存在时返回空列表,不报错。 | ||
| 113 | +- 返回值:`dict`,字段见 4.1 中的 JSON 结构(不含 `skill_id` 以外的 DB 字段拼装可以留给 | ||
| 114 | + backend handler 做,也可以在本函数里一并拼好——由实现选择,只要最终 API 响应体符合 4.1 的 | ||
| 115 | + 字段集合)。 | ||
| 116 | + | ||
| 117 | +### FR-2:`src/backend_server.py` 新增路由 `GET /api/skills/:skill_id` | ||
| 118 | + | ||
| 119 | +- 路由匹配顺序:先匹配 `.../audit` 和 `.../rollback` 后缀(现有逻辑不变),都不命中且路径以 | ||
| 120 | + `_SKILLS_PREFIX` 开头、且不等于 `/api/skills`(列表)、不等于 `/api/skills/diff` 时,落到新 | ||
| 121 | + 的详情 handler,把剩余路径段当作 `skill_id`。 | ||
| 122 | +- Handler 调用 `skill_query.get_skill_content`: | ||
| 123 | + - `SkillNotFoundError` → 404 `{"error": "NOT_FOUND"}`。 | ||
| 124 | + - `SkillPathTraversalError` → 400 `{"error": "INVALID_SKILL_ID"}`(现实中几乎不会触发,因为 | ||
| 125 | + `skill_id` 已先过 DB 校验;但必须存在这条分支,不能让底层异常冒泡成 500)。 | ||
| 126 | + - 成功 → 200,响应体为 4.1 定义的 JSON,同时带上该 Skill 行的其余字段(`owner_org_node` 等, | ||
| 127 | + 复用 `list_skills()` 已有的字段读取方式)。 | ||
| 128 | + | ||
| 129 | +### FR-3:前端 `v_skill_audit_log`(`renderSkillAuditLog`/`mountSkillAuditLog`) | ||
| 130 | + | ||
| 131 | +- `renderSkillAuditLog`:在既有 `#skill_audit_list` 面板**之上或并列**新增一个“Skill 内容”面 | ||
| 132 | + 板,结构参照 `v_artifact_browser` 的 `.preview` 区块: | ||
| 133 | + ```html | ||
| 134 | + <div class="panel"> | ||
| 135 | + <h2>Skill 内容</h2> | ||
| 136 | + <div id="skill_content_body"><div class="hint">加载中…</div></div> | ||
| 137 | + </div> | ||
| 138 | + ``` | ||
| 139 | + 不删除、不修改任何既有 `#skill_audit_error` / `#skill_audit_list` 相关 DOM。 | ||
| 140 | +- `mountSkillAuditLog`:新增一个 `refreshContent()`,与既有 `refresh()`(迭代记录)各自独立 | ||
| 141 | + 发起 fetch、各自独立渲染,二者互不阻塞(其中一个失败不影响另一个正常展示)。两者共用同一个 | ||
| 142 | + `#skill_audit_error` 顶部错误横幅——因为 404(Skill 完全不存在)这一失败态对两个面板都成 | ||
| 143 | + 立,无需拆成两条错误提示。 | ||
| 144 | +- 内容渲染: | ||
| 145 | + ```html | ||
| 146 | + <pre class="code">${content == null ? "(内容文件不可用)" : escapeHtml(content)}</pre> | ||
| 147 | + ``` | ||
| 148 | + 与 `showPreview()` 里 `artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)` | ||
| 149 | + 完全同构,不引入任何新的渲染方式。 | ||
| 150 | +- 支持文件清单:`supporting_files.length` 为 0 时显示“无支持文件”提示文案;非空时渲染为纯文 | ||
| 151 | + 本 `<ul><li>` 列表(`escapeHtml` 处理),不加点击事件、不做预览入口。 | ||
| 152 | + | ||
| 153 | +## 6. 安全需求(路径穿越防护,对应 BRD 要求 4) | ||
| 154 | + | ||
| 155 | +| 层 | 校验内容 | 失败行为 | | ||
| 156 | +|---|---|---| | ||
| 157 | +| DB 查询 | `skill_id` 通过参数化 SQL 查询 `skill` 表 | 查不到 → `SkillNotFoundError` → 404,不触碰文件系统 | | ||
| 158 | +| 路径来源 | 文件路径必须来自 DB 的 `file_path` 列,不直接拼接原始 `skill_id` | 代码审查/测试拦截任何 `os.path.join(..., skill_id, ...)` 式写法 | | ||
| 159 | +| 路径包含性 | 解析出的绝对路径必须位于 `repo_root/skills/` 之内(`realpath` + 前缀校验) | 越界 → `SkillPathTraversalError` → 400,不读取文件 | | ||
| 160 | + | ||
| 161 | +必须补充的测试场景(对应 §8 测试计划):向 `skill` 表插入一条 `file_path` 被人为篡改为 | ||
| 162 | +`"../../etc/"` 或 `"../secrets/"` 的记录,断言 `get_skill_content` 拒绝读取并抛出 | ||
| 163 | +`SkillPathTraversalError`,而不是把上层目录内容读出来返回。 | ||
| 164 | + | ||
| 165 | +## 7. 兼容性与回归范围 | ||
| 166 | + | ||
| 167 | +- `GET /api/skills`、`GET /api/skills/:skill_id/audit`、`GET /api/skills/diff`、 | ||
| 168 | + `POST /api/skills/:skill_id/rollback` 四个既有接口的路由匹配顺序、响应契约、行为均不变——新 | ||
| 169 | + 路由是在这些后缀匹配全部落空之后才命中的新分支。 | ||
| 170 | +- 现有 `src/test_skill_query.py`、`src/test_backend_server.py::SkillRouteTest` 中的既有用例必 | ||
| 171 | + 须全部保持通过,不允许为了实现新功能而修改其断言。 | ||
| 172 | +- `skill_update.py` 顶部“`skills/<skill_id>/` doesn't exist as real files”这句过期注释,若实 | ||
| 173 | + 现时顺手核实到已不成立,可以更新措辞,但不是本次改动的必答项,不应扩大到重构 | ||
| 174 | + `skill_update.py` 其他逻辑。 | ||
| 175 | + | ||
| 176 | +## 8. 测试计划 | ||
| 177 | + | ||
| 178 | +- `src/test_skill_query.py` 新增: | ||
| 179 | + - 正常路径:Skill 目录下有真实 `SKILL.md`,`get_skill_content` 返回预期 `content` 文本。 | ||
| 180 | + - 磁盘文件缺失:DB 有行、`SKILL.md` 不存在,`content` 为 `None`,不抛异常。 | ||
| 181 | + - `SkillNotFoundError`:`skill_id` 不在 `skill` 表中。 | ||
| 182 | + - 支持文件枚举:目录下存在 `references/`、`scripts/` 等子路径时,`supporting_files` 返回排 | ||
| 183 | + 序后的相对路径列表;目录下只有 `SKILL.md` 时返回空列表。 | ||
| 184 | + - 路径穿越拒绝:`file_path` 被篡改为越界值时抛出 `SkillPathTraversalError`,且断言底层没有 | ||
| 185 | + 发生对越界路径的 `open()`/读取(可用临时目录 + 越界哨兵文件验证内容未被读出)。 | ||
| 186 | +- `src/test_backend_server.py::SkillRouteTest` 新增: | ||
| 187 | + - `GET /api/skills/skill-1` → 200,响应体含 `content`/`supporting_files` 字段。 | ||
| 188 | + - `GET /api/skills/no-such-skill` → 404 `NOT_FOUND`(与 `/audit` 现有 404 行为对齐)。 | ||
| 189 | + - 既有 `.../audit`、`.../rollback`、`/api/skills/diff` 用例路由不受新分支影响(保持通过即 | ||
| 190 | + 是回归验证)。 | ||
| 191 | +- 前端:本仓库无构建步骤、无前端单测框架(仅 `frontend/test_graph_console_smoke.js` 这一支现 | ||
| 192 | + 有 smoke 脚本),本次不新增前端自动化测试基建;以后端契约测试 + 手工核对 | ||
| 193 | + `docs/wireframes/v_skill_audit_log.html` 对应字段为验收依据,与仓库现有前端验证惯例一致。 | ||
| 194 | + | ||
| 195 | +## 9. 验收标准(Acceptance Criteria) | ||
| 196 | + | ||
| 197 | +1. 打开任意已有真实 `SKILL.md` 内容的 Skill 详情页,“Skill 内容”面板显示该文件的真实全文文 | ||
| 198 | + 本(纯 `<pre>`,无 Markdown 渲染)。 | ||
| 199 | +2. 该 Skill 的“变更审计”面板行为与改动前完全一致(空态文案、迭代列表、diff、rollback 均不 | ||
| 200 | + 变)。 | ||
| 201 | +3. 两个面板同时展示、互不覆盖、互不依赖对方是否为空。 | ||
| 202 | +4. 磁盘 `SKILL.md` 缺失但 DB 中有该 Skill 记录时,内容面板显示“(内容文件不可用)”,不报 | ||
| 203 | + 500,不使页面崩溃。 | ||
| 204 | +5. `skill_id` 不存在时,整页显示“该 Skill 不存在”错误态(两个面板都不渲染),接口返回 404。 | ||
| 205 | +6. 构造 `file_path` 越界的 Skill 记录,接口不会读出 `skills/` 目录之外的任意文件内容,返回 | ||
| 206 | + 400,不是 500,也不是把越界内容当成功返回。 | ||
| 207 | +7. `src/test_skill_query.py`、`src/test_backend_server.py` 全量既有用例 + 本次新增用例全部通 | ||
| 208 | + 过;未修改任何既有断言。 | ||
| 209 | +8. 前端未引入任何新的第三方依赖(无 `<script src="...markdown...">` 之类的新增外部库引用)。 |