From c788bbacbef7234a2925e7080813a6de5a4ec154 Mon Sep 17 00:00:00 2001 From: graphlemon Date: Thu, 13 Aug 2026 23:15:18 +0800 Subject: [PATCH] graphlemon: prd artifact for task b243dfe8-4da8-49bd-895b-8b9e9ed17280 --- .graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/prd.md | 209 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+), 0 deletions(-) create mode 100644 .graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/prd.md diff --git a/.graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/prd.md b/.graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/prd.md new file mode 100644 index 0000000..7b4e9da --- /dev/null +++ b/.graphlemon/runs/b243dfe8-4da8-49bd-895b-8b9e9ed17280/prd.md @@ -0,0 +1,209 @@ +# PRD:Skill 详情页展示真实内容 + +## 1. 背景与问题 + +`http://127.0.0.1:8420/#/skills/:skill_id/audit`(`v_skill_audit_log`)目前只查询并展示 +`skill_iteration` 表(“变更审计”面板)。当某个 Skill 还没有任何自主迭代记录时(当前所有从 +Hermes profile 迁移来的 Skill 都是这种状态),页面只显示“该 Skill 尚无自主迭代记录”,用户看 +不到 Skill 本身写了什么——即 `skills//SKILL.md` 的真实文本内容。 + +根因:`src/skill_query.py` 只有 `list_skills()`(读 `skill` 表)和 `get_skill_audit_log()`(读 +`skill_iteration` 表),没有任何函数读取磁盘上 `skills//SKILL.md` 的真实内容。这批真 +实文件是最近一次迁移任务才创建的(`skill_update.py` 旧注释“`skills//` doesn't exist +as real files”已过期,需在实现时一并核实/更新该注释,但不属于本次改动范围本身)。 + +本次修复是纯粹的“读取 + 展示”接入,不改变 Skill 迁移逻辑,不改变现有迭代审计逻辑。 + +## 2. 目标(Goals) + +- G1:`v_skill_audit_log` 页面新增“Skill 内容”面板,与既有“变更审计”面板并列展示,用户打开任 + 意 Skill 详情页都能看到该 Skill 的真实 `SKILL.md` 文本内容。 +- G2:后端新增只读接口,暴露 Skill 内容 + 支持文件清单,且对 `skill_id` 这个用户可控输入做路 + 径穿越防护。 +- G3:内容渲染方式与 `v_artifact_browser` 保持一致(纯 `
` 文本,不引入 Markdown/代码高亮
+  库),维持“纯 vanilla JS、无构建步骤”的既有约定。
+
+## 3. 非目标(Non-goals)
+
+- 不改动 `skill_update.py` / Skill 迁移脚本的任何逻辑。
+- 不改动 `get_skill_audit_log()` 现有查询逻辑、现有 `/api/skills/:skill_id/audit` 响应契约、
+  现有“迭代记录”面板的 DOM 结构——两个信息源并列,不做二选一或合并覆盖。
+- 不提供 Skill 内容的在线编辑/写入能力(本次是只读展示,写入仍只能走
+  `c_skill_update`/`c_skill_rollback` 现有链路)。
+- 不引入 Markdown 渲染、代码高亮或任何新的前端第三方依赖。
+- 不做支持文件(`references/`/`templates/`/`scripts/` 等)的内容预览——本次只做文件清单展
+  示,点击查看子文件内容不在范围内(当前仓库内所有已迁移 Skill 目录下都还没有这类子文件,属
+  于面向未来的占位展示,先满足“看得见有哪些文件”,内容预览留待真实需求出现时再建,避免过度设
+  计)。
+- 不做通用二进制文件预览。
+
+## 4. 关键设计决策(解决 BRD 遗留的两个开放问题)
+
+### 4.1 决策一:新增接口的形态
+
+新增 `GET /api/skills/:skill_id`(Skill 详情接口),与已有的 `GET /api/skills`(列表)、
+`GET /api/skills/:skill_id/audit`(迭代审计)并列,不复用/不修改 `/audit` 的响应契约。
+
+理由:
+- 参照本仓库已有先例 `GET /api/artifacts/:artifact_id`——列表接口返回摘要,详情接口返回完整
+  内容(含 `content` 字段,`null` 表示文件不可用)。`/skills/:skill_id` 遵循同一 REST 资源语
+  义:Skill 本身的详情 vs. Skill 的迭代审计子资源,职责天然分离。
+- 修改 `/audit` 的响应体形状会牵动其现有测试(`SkillRouteTest`)和前端既有渲染逻辑,增加不必
+  要的回归面;新增一个兄弟接口是零风险的纵向增量。
+- 前端页面通过 `Promise.all` 并行请求 `/api/skills/:skill_id` 与
+  `/api/skills/:skill_id/audit`(参照 `mountArtifactsOverviewPanel` 已有的并行请求写法),两
+  个面板各自独立渲染、独立失败态,符合“两个 independent 信息源”的要求。
+
+`GET /api/skills/:skill_id` 响应体:
+
+```json
+{
+  "skill_id": "code-agent-engineering",
+  "owner_org_node": "coder",
+  "file_path": "skills/code-agent-engineering/",
+  "current_git_commit_sha": "unknown",
+  "source": "migrated_from_hermes",
+  "migrated_from_profile": "code",
+  "status": "active",
+  "content": "",
+  "supporting_files": ["references/foo.md", "scripts/bar.py"]
+}
+```
+
+- 404 `{"error": "NOT_FOUND"}`:`skill_id` 在 `skill` 表中不存在(与 `/audit` 现有 404 行为保
+  持一致的错误形状)。
+- `content: null`:`skill` 表中有该行,但对应的 `SKILL.md` 文件在磁盘上缺失(与
+  `v_artifact_browser` 中 `artifact.content == null` 的既有约定一致,不视为错误,200 返回)。
+
+### 4.2 决策二:支持文件的呈现方式
+
+`supporting_files` 是一个字符串数组,元素为相对 Skill 目录的相对路径(如
+`"references/foo.md"`),按字典序排序;只列出文件本身是否存在,不读取、不返回其内容。前端只
+渲染成一个纯文本清单(`
    `/`
    `,非可点击的预览入口);目录为空时显示“无支持文件”提示
    +文案,不请求也不渲染额外内容面板。
    +
    +理由:当前仓库所有已迁移 Skill 目录下都只有 `SKILL.md` 一个文件,`references/`/`templates/`/
    +`scripts/` 完全是面向未来的假设场景;先给出“存在哪些文件”的最小可见性即可满足 BRD 要求
    +(“判断要不要一并列出/展示”),把内容预览这个更大的功能面留给真实出现该场景时再单独立项,避
    +免为不存在的需求建可点击的子路由/子预览逻辑。
    +
    +## 5. 功能需求(Functional Requirements)
    +
    +### FR-1:`src/skill_query.py` 新增 `get_skill_content(conn, skill_id, repo_path=None)`
    +
    +- 复用现有 `SkillNotFoundError`:先按 `skill_id` 查 `skill` 表(参数化 SQL,与
    +  `get_skill_audit_log()` 完全相同的校验前置模式),查不到直接抛出,不触碰文件系统。
    +- **不得**用 `skill_id` 直接拼接文件系统路径(例如
    +  `os.path.join(repo_root, "skills", skill_id, "SKILL.md")` 这种写法是本次要杜绝的漏洞模式)。
    +  必须复用查到的 `skill.file_path` 列值来解析真实路径——这是 `skill_update.py` 的
    +  `_skill_file_path()` 已经在用的解析规则(`.md` 结尾直接用,否则拼接 `SKILL.md`)。为避免两
    +  处实现漂移,`skill_query.py` 应直接复用 `skill_update._skill_file_path`(导入复用),不要重
    +  写一份等价逻辑。
    +- 纵深防御:即便路径已经来自 DB 而非直接来自 `skill_id`,仍需在打开文件前做一次路径包含性校
    +  验——对解析出的绝对路径和 `repo_root/skills` 的绝对路径都做 `os.path.realpath`,确认前者位
    +  于后者之内(`os.path.commonpath` 或等价前缀比较,注意分隔符边界,避免
    +  `skills-evil/` 这类前缀误判)。校验失败时抛出新增的 `SkillPathTraversalError`,**不读取任何
    +  文件**,即“失败关闭”(fail closed)。这一层是防止 `skill.file_path` 列未来被写入非预期值
    +  (人为改库、迁移脚本 bug 等)时仍然造成越界读取。
    +- 读取 `SKILL.md`:文件存在则以 UTF-8 读取全文返回;文件不存在(DB 有行但磁盘文件缺失,例如
    +  尚未迁移完成)时 `content` 置为 `None`,不抛异常(对齐 `v_artifact_browser` 的“文件不可用”
    +  既有约定)。
    +- 枚举支持文件:遍历 Skill 目录(`os.walk`,跳过 `SKILL.md` 自身),返回排序后的相对路径列
    +  表;目录本身不存在时返回空列表,不报错。
    +- 返回值:`dict`,字段见 4.1 中的 JSON 结构(不含 `skill_id` 以外的 DB 字段拼装可以留给
    +  backend handler 做,也可以在本函数里一并拼好——由实现选择,只要最终 API 响应体符合 4.1 的
    +  字段集合)。
    +
    +### FR-2:`src/backend_server.py` 新增路由 `GET /api/skills/:skill_id`
    +
    +- 路由匹配顺序:先匹配 `.../audit` 和 `.../rollback` 后缀(现有逻辑不变),都不命中且路径以
    +  `_SKILLS_PREFIX` 开头、且不等于 `/api/skills`(列表)、不等于 `/api/skills/diff` 时,落到新
    +  的详情 handler,把剩余路径段当作 `skill_id`。
    +- Handler 调用 `skill_query.get_skill_content`:
    +  - `SkillNotFoundError` → 404 `{"error": "NOT_FOUND"}`。
    +  - `SkillPathTraversalError` → 400 `{"error": "INVALID_SKILL_ID"}`(现实中几乎不会触发,因为
    +    `skill_id` 已先过 DB 校验;但必须存在这条分支,不能让底层异常冒泡成 500)。
    +  - 成功 → 200,响应体为 4.1 定义的 JSON,同时带上该 Skill 行的其余字段(`owner_org_node` 等,
    +    复用 `list_skills()` 已有的字段读取方式)。
    +
    +### FR-3:前端 `v_skill_audit_log`(`renderSkillAuditLog`/`mountSkillAuditLog`)
    +
    +- `renderSkillAuditLog`:在既有 `#skill_audit_list` 面板**之上或并列**新增一个“Skill 内容”面
    +  板,结构参照 `v_artifact_browser` 的 `.preview` 区块:
    +  ```html
    +  
    +

    Skill 内容

    +
    加载中…
    +
    + ``` + 不删除、不修改任何既有 `#skill_audit_error` / `#skill_audit_list` 相关 DOM。 +- `mountSkillAuditLog`:新增一个 `refreshContent()`,与既有 `refresh()`(迭代记录)各自独立 + 发起 fetch、各自独立渲染,二者互不阻塞(其中一个失败不影响另一个正常展示)。两者共用同一个 + `#skill_audit_error` 顶部错误横幅——因为 404(Skill 完全不存在)这一失败态对两个面板都成 + 立,无需拆成两条错误提示。 +- 内容渲染: + ```html +
    ${content == null ? "(内容文件不可用)" : escapeHtml(content)}
    + ``` + 与 `showPreview()` 里 `artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)` + 完全同构,不引入任何新的渲染方式。 +- 支持文件清单:`supporting_files.length` 为 0 时显示“无支持文件”提示文案;非空时渲染为纯文 + 本 `
    • ` 列表(`escapeHtml` 处理),不加点击事件、不做预览入口。 + +## 6. 安全需求(路径穿越防护,对应 BRD 要求 4) + +| 层 | 校验内容 | 失败行为 | +|---|---|---| +| DB 查询 | `skill_id` 通过参数化 SQL 查询 `skill` 表 | 查不到 → `SkillNotFoundError` → 404,不触碰文件系统 | +| 路径来源 | 文件路径必须来自 DB 的 `file_path` 列,不直接拼接原始 `skill_id` | 代码审查/测试拦截任何 `os.path.join(..., skill_id, ...)` 式写法 | +| 路径包含性 | 解析出的绝对路径必须位于 `repo_root/skills/` 之内(`realpath` + 前缀校验) | 越界 → `SkillPathTraversalError` → 400,不读取文件 | + +必须补充的测试场景(对应 §8 测试计划):向 `skill` 表插入一条 `file_path` 被人为篡改为 +`"../../etc/"` 或 `"../secrets/"` 的记录,断言 `get_skill_content` 拒绝读取并抛出 +`SkillPathTraversalError`,而不是把上层目录内容读出来返回。 + +## 7. 兼容性与回归范围 + +- `GET /api/skills`、`GET /api/skills/:skill_id/audit`、`GET /api/skills/diff`、 + `POST /api/skills/:skill_id/rollback` 四个既有接口的路由匹配顺序、响应契约、行为均不变——新 + 路由是在这些后缀匹配全部落空之后才命中的新分支。 +- 现有 `src/test_skill_query.py`、`src/test_backend_server.py::SkillRouteTest` 中的既有用例必 + 须全部保持通过,不允许为了实现新功能而修改其断言。 +- `skill_update.py` 顶部“`skills//` doesn't exist as real files”这句过期注释,若实 + 现时顺手核实到已不成立,可以更新措辞,但不是本次改动的必答项,不应扩大到重构 + `skill_update.py` 其他逻辑。 + +## 8. 测试计划 + +- `src/test_skill_query.py` 新增: + - 正常路径:Skill 目录下有真实 `SKILL.md`,`get_skill_content` 返回预期 `content` 文本。 + - 磁盘文件缺失:DB 有行、`SKILL.md` 不存在,`content` 为 `None`,不抛异常。 + - `SkillNotFoundError`:`skill_id` 不在 `skill` 表中。 + - 支持文件枚举:目录下存在 `references/`、`scripts/` 等子路径时,`supporting_files` 返回排 + 序后的相对路径列表;目录下只有 `SKILL.md` 时返回空列表。 + - 路径穿越拒绝:`file_path` 被篡改为越界值时抛出 `SkillPathTraversalError`,且断言底层没有 + 发生对越界路径的 `open()`/读取(可用临时目录 + 越界哨兵文件验证内容未被读出)。 +- `src/test_backend_server.py::SkillRouteTest` 新增: + - `GET /api/skills/skill-1` → 200,响应体含 `content`/`supporting_files` 字段。 + - `GET /api/skills/no-such-skill` → 404 `NOT_FOUND`(与 `/audit` 现有 404 行为对齐)。 + - 既有 `.../audit`、`.../rollback`、`/api/skills/diff` 用例路由不受新分支影响(保持通过即 + 是回归验证)。 +- 前端:本仓库无构建步骤、无前端单测框架(仅 `frontend/test_graph_console_smoke.js` 这一支现 + 有 smoke 脚本),本次不新增前端自动化测试基建;以后端契约测试 + 手工核对 + `docs/wireframes/v_skill_audit_log.html` 对应字段为验收依据,与仓库现有前端验证惯例一致。 + +## 9. 验收标准(Acceptance Criteria) + +1. 打开任意已有真实 `SKILL.md` 内容的 Skill 详情页,“Skill 内容”面板显示该文件的真实全文文 + 本(纯 `
      `,无 Markdown 渲染)。
      +2. 该 Skill 的“变更审计”面板行为与改动前完全一致(空态文案、迭代列表、diff、rollback 均不
      +   变)。
      +3. 两个面板同时展示、互不覆盖、互不依赖对方是否为空。
      +4. 磁盘 `SKILL.md` 缺失但 DB 中有该 Skill 记录时,内容面板显示“(内容文件不可用)”,不报
      +   500,不使页面崩溃。
      +5. `skill_id` 不存在时,整页显示“该 Skill 不存在”错误态(两个面板都不渲染),接口返回 404。
      +6. 构造 `file_path` 越界的 Skill 记录,接口不会读出 `skills/` 目录之外的任意文件内容,返回
      +   400,不是 500,也不是把越界内容当成功返回。
      +7. `src/test_skill_query.py`、`src/test_backend_server.py` 全量既有用例 + 本次新增用例全部通
      +   过;未修改任何既有断言。
      +8. 前端未引入任何新的第三方依赖(无 `