Commit 1f7cec119c702e32c0083fc8acfebfd09525e49d

Authored by bboonnee
1 parent accfe51f

U-029: v_skill_library + v_skill_audit_log

c_skill_diff 和 U-028 的 c_artifact_get 一样,只存在于机器可读的
sdd-mvcp-contract.json,03-C 正文从未写出来,已订正补全。
c_skill_list/c_skill_get 两份文档都没定义,是 v_skill_library/
v_skill_audit_log 自己的展示需求倒逼出来的缺口。

新增 src/skill_query.py:list_skills()、get_skill_audit_log()、
get_diff()(git diff <before> <after>——skill_update.py 的
_write_and_commit 把每次迭代做成原子单文件 commit,整仓库范围的 diff
天然只涉及那一个 Skill 文件)。

关键安全考量:Skill 库活在 graphlemon 自己的仓库里,c_skill_rollback
会真的在这个仓库里跑 git commit——如果 HTTP 层写死指向真实仓库根目
录,任何针对这条路由的测试都会在正在跑的这个 graphlemon 仓库里留下
真实提交。参照 db_path 已有的做法,给 create_server() 加了
skill_repo_path 参数(默认 graphlemon 真实仓库根,测试传临时仓库),
不通过 HTTP 暴露这个参数,避免变成任意写入的口子。记录在 05
§5.4.1.4。

真实服务器 + 临时仓库端到端验证过完整的 update→diff→rollback 闭环,
确认过跑完之后 graphlemon 自己仓库的 git log 完全没变化。

v_skill_library/v_skill_audit_log 不检查"当前项目"——Skill 是平台级
资产,跟项目无关。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
@@ -267,6 +267,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 @@ -267,6 +267,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
267 267
268 - **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log` 268 - **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log`
269 - 依赖:U-018 269 - 依赖:U-018
  270 + - Done:Skill 库与变更审计日志可查看(原文档缺 Done/覆盖行,2026-07-31 补上)
  271 + - **实现补充(2026-07-31)**:`c_skill_diff` 和 U-028 的 `c_artifact_get` 一样,只存在于机器可读的 `sdd-mvcp-contract.json`(`GET /api/skills/diff?before=&after=`),03-C 正文从未写出来,本 Unit 订正补全;list/get 系读接口(`c_skill_list`/`c_skill_get`)两份文档都没定义,跟 `c_task_list`/`c_artifact_list` 是同一类"视图倒逼出来的缺口"。新增 `src/skill_query.py`:`list_skills()`、`get_skill_audit_log()`、`get_diff()`(`git diff <before> <after>`——`skill_update.py` 的 `_write_and_commit` 把每次迭代做成原子的单文件 commit,所以整仓库范围的 diff 天然就只涉及这一个 Skill 文件,不需要额外传 path 过滤)。**关键安全考量**:Skill 库活在 graphlemon 自己的仓库里(不是某个 target project 的),`c_skill_rollback` 会真的在这个仓库里跑 `git commit`——如果 HTTP 层写死指向真实仓库根目录,任何针对这条路由的测试都会在正在跑的这个 graphlemon 仓库里留下真实提交。参照 `db_path` 已有的做法,给 `create_server()` 加了 `skill_repo_path` 参数(默认 `skill_query._REPO_ROOT`,即 graphlemon 真实仓库根,生产环境下这就是对的行为;测试传一个 tempfile 建的临时仓库,绝不碰真实历史),不通过 HTTP 暴露这个参数(只在进程/测试构造时可设,避免变成一个可以让客户端指定任意写入目标的口子)。真实服务器 + 临时仓库端到端验证过完整的 update→diff→rollback 闭环,并且确认过跑完之后 graphlemon 自己仓库的 git log 完全没变化。前端 `v_skill_library`/`v_skill_audit_log` 两个页面不检查"当前项目"(Skill 是平台级资产,跟项目无关,是这个控制台里唯一不需要 `getCurrentProjectId()` 守卫的真实页面)。
  272 + - 覆盖:02-V `v_skill_library`/`v_skill_audit_log`;03-C `c_skill_diff`(正文订正补全)+ 新增 `c_skill_list`/`c_skill_get`;`c_skill_rollback`(U-018 已实现 `rollback_skill()`)首次接上 HTTP
270 273
271 ### PH3-S4 配置页 274 ### PH3-S4 配置页
272 275
docs/sdd/03-C-控制与接口.md
@@ -196,6 +196,13 @@ @@ -196,6 +196,13 @@
196 - `idempotent`:否 196 - `idempotent`:否
197 - `errors`:`ITERATION_NOT_FOUND`(不可重试) 197 - `errors`:`ITERATION_NOT_FOUND`(不可重试)
198 198
  199 +## 3.9.1 `c_skill_diff` / `c_skill_list` / `c_skill_get`(2026-07-31 订正:`c_skill_diff` 此前只存在于机器可读的 `sdd-mvcp-contract.json`,正文一直没写;`c_skill_list`/`c_skill_get` 连 MVCP JSON 里都没有,`v_skill_library`/`v_skill_audit_log` 自己的展示需求倒逼补上)
  200 +
  201 +- `GET /api/skills/diff?before=&after=`(`c_skill_diff`)、`GET /api/skills`(`c_skill_list`)、`GET /api/skills/:skill_id/audit`(`c_skill_get` 的落地形态——直接返回该 Skill 的 `skill_iteration` 列表,供 `v_skill_audit_log` 消费,不是单条 `skill` 记录)
  202 +- `output_contract`:`c_skill_diff` 返回 `{ diff_text: string }`(`git diff <before> <after>`,在 graphlemon 自身仓库执行——见 05 §5.4.1.4);`c_skill_list` 返回裸数组;`c_skill_get` 返回 `skill_iteration` 数组,按 `applied_at` 升序
  203 +- `idempotent`:是
  204 +- `errors`:`NOT_FOUND`(不可重试,仅 `c_skill_get`):`skill_id` 不存在;`INVALID_INPUT`(不可重试,仅 `c_skill_diff`):`before`/`after` 缺失
  205 +
199 ## 3.10 `c_bug_record_create` 206 ## 3.10 `c_bug_record_create`
200 207
201 - `POST /api/bug-records` 208 - `POST /api/bug-records`
docs/sdd/05-实施约束与依赖.md
@@ -54,6 +54,13 @@ @@ -54,6 +54,13 @@
54 - **应对方式**:`self.conn.close()` 也必须包在 `with self.conn_lock:` 里——"关闭"和"读写"对同一个连接对象而言都是需要互斥的操作,不能默认关闭是安全的。已在 U-027 修复,修复后连续跑几十次全部稳定通过。 54 - **应对方式**:`self.conn.close()` 也必须包在 `with self.conn_lock:` 里——"关闭"和"读写"对同一个连接对象而言都是需要互斥的操作,不能默认关闭是安全的。已在 U-027 修复,修复后连续跑几十次全部稳定通过。
55 - 这条不需要等 Claude Code 升级重新验证(跟它无关),但如果以后这里改用连接池/每线程一个连接,这条约束本身会自然消失,到时候可以直接删掉这一节。 55 - 这条不需要等 Claude Code 升级重新验证(跟它无关),但如果以后这里改用连接池/每线程一个连接,这条约束本身会自然消失,到时候可以直接删掉这一节。
56 56
  57 +## 5.4.1.4 `c_skill_diff`/`c_skill_rollback` 的仓库路径不能通过 HTTP 暴露(设计约束,2026-07-31,U-029)
  58 +
  59 +- 背景:Skill 库是 graphlemon 自己平台的资产(不是 target project 的),`c_skill_rollback`(`src/skill_update.py` 的 `rollback_skill()`)会在这个仓库里真的跑 `git commit`;`repo_path` 参数 U-018 就有意不给默认值,防止哪个调用方忘了传就误伤 graphlemon 自己的历史(见 U-018 那次实现补充)。
  60 +- **风险点**:`backend_server.py` 把这个函数接到 `POST /api/skills/:skill_id/rollback` 时,如果 HTTP 层内部写死 `skill_repo_path=<graphlemon 真实仓库根>`,那么任何针对这条路由的自动化测试(`src/test_backend_server.py`)都会在正在跑的这个 graphlemon 仓库里留下真实 git 提交——测试本身就会污染开发者自己的仓库历史。
  61 +- **应对方式**:参照 `create_server()` 已有的 `db_path` 参数模式,加了 `skill_repo_path` 参数:默认值是 `skill_query._REPO_ROOT`(graphlemon 真实仓库根,生产环境下这就是唯一正确的行为——Skill 变更本来就应该落到这里),但可以在构造 server 时覆盖;测试统一传一个 `tempfile` 建的临时仓库。**这个参数只在 Python 层(进程启动/测试构造)可设,绝不通过 HTTP 请求体/query string 暴露给客户端**——否则等于开了一个"客户端指定任意目录、服务端替它跑 git commit"的口子,属于路径注入/任意写入范畴。
  62 +- 这不是"发现"的约束(不是实测跑出来的意外行为),是设计阶段就该考虑到、写代码时主动做的防护,记录下来是为了以后改这块代码的人不要图省事把 `skill_repo_path` 从请求里读出来。
  63 +
57 ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29) 64 ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29)
58 65
59 - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则 66 - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则
docs/sdd/unit_matrix.json
@@ -44,7 +44,7 @@ @@ -44,7 +44,7 @@
44 {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"}, 44 {"id": "U-026", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_graph_visualizer(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_graph_visualizer"], "controller": [], "process": [], "requirement": []}, "done": "Work Graph DAG 分层视图含 waiting_for_quota 信息态;Org Graph 层复用 v_task_detail 已有的 org-strip 面板,不重复渲染"},
45 {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": ["p_log_retention_cleanup"], "requirement": ["REQ-13"]}, "done": "REQ-13 happy/boundary/failure 通过;补上此前从未存在的 append_log 写路径;顺带修了 U-024 就存在的 server_close() 并发 segfault 隐患(conn_lock 未覆盖)"}, 45 {"id": "U-027", "phase": "PH3", "sub_phase": "PH3-S3", "name": "execution_log_file + 日志查看器(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": ["execution_log_file", "log_retention_config"], "view": ["v_log_viewer"], "controller": ["c_execution_log_get", "c_ws_log_tail"], "process": ["p_log_retention_cleanup"], "requirement": ["REQ-13"]}, "done": "REQ-13 happy/boundary/failure 通过;补上此前从未存在的 append_log 写路径;顺带修了 U-024 就存在的 server_close() 并发 segfault 隐患(conn_lock 未覆盖)"},
46 {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": ["c_artifact_get", "c_artifact_list"], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用;预览为纯文本,不做 Markdown 渲染/代码高亮(避免引入前端第三方依赖)"}, 46 {"id": "U-028", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_artifact_browser(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-011"], "coverage": {"model": [], "view": ["v_artifact_browser"], "controller": ["c_artifact_get", "c_artifact_list"], "process": [], "requirement": []}, "done": "Artifact 列表+预览+血缘链可用;预览为纯文本,不做 Markdown 渲染/代码高亮(避免引入前端第三方依赖)"},
47 - {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": [], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看"}, 47 + {"id": "U-029", "phase": "PH3", "sub_phase": "PH3-S3", "name": "v_skill_library + v_skill_audit_log(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-018"], "coverage": {"model": [], "view": ["v_skill_library", "v_skill_audit_log"], "controller": ["c_skill_diff", "c_skill_list", "c_skill_get", "c_skill_rollback"], "process": [], "requirement": []}, "done": "Skill 库与变更审计日志可查看;c_skill_rollback 首次接上 HTTP(skill_repo_path 可注入,测试绝不碰真实仓库)"},
48 {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效"}, 48 {"id": "U-030", "phase": "PH3", "sub_phase": "PH3-S4", "name": "并发/预算/日志保留配置页", "classification": "Value Path", "depends_on": ["U-019", "U-021"], "coverage": {"model": ["org_concurrency_config"], "view": ["v_concurrency_settings", "v_budget_settings", "v_log_retention_settings"], "controller": ["c_concurrency_config_update", "c_budget_config_update", "c_log_retention_config_update"], "process": [], "requirement": []}, "done": "三个配置页可用且改动生效"},
49 49
50 {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归"}, 50 {"id": "U-031", "phase": "PH4", "sub_phase": "PH4-S1", "name": "REQ-01~14 全量自动化回归", "classification": "Risk Gate", "depends_on": ["U-014", "U-017", "U-020", "U-021", "U-022", "U-023", "U-024.5", "U-025", "U-026", "U-027", "U-028", "U-029", "U-030"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-01", "REQ-02", "REQ-03", "REQ-04", "REQ-05", "REQ-06", "REQ-07", "REQ-08", "REQ-09", "REQ-10", "REQ-11", "REQ-12", "REQ-13", "REQ-14"]}, "done": "06 节全部 14 条 REQ 的全部用例通过一次完整回归"},
frontend/app.js
@@ -78,15 +78,18 @@ const routes = { @@ -78,15 +78,18 @@ const routes = {
78 "#/": { render: renderDashboard, mount: mountDashboard }, 78 "#/": { render: renderDashboard, mount: mountDashboard },
79 "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, 79 "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
80 "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, 80 "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser },
  81 + "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary },
81 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" }, 82 "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
82 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, 83 "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
83 "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, 84 "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
84 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, 85 "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
85 }; 86 };
86 87
87 -// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a  
88 -// full path-matching router for a single pattern. 88 +// #/tasks/:task_id (v_task_detail) and #/skills/:skill_id/audit
  89 +// (v_skill_audit_log) are the only dynamic routes -- not worth a full
  90 +// path-matching router for two patterns.
89 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; 91 const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
  92 +const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/;
90 93
91 let currentRouteCleanup = null; 94 let currentRouteCleanup = null;
92 function registerRouteCleanup(fn) { 95 function registerRouteCleanup(fn) {
@@ -103,10 +106,14 @@ function render() { @@ -103,10 +106,14 @@ function render() {
103 let route = routes[hash]; 106 let route = routes[hash];
104 let routeArgs = []; 107 let routeArgs = [];
105 if (!route) { 108 if (!route) {
106 - const match = hash.match(TASK_DETAIL_RE);  
107 - if (match) { 109 + const taskMatch = hash.match(TASK_DETAIL_RE);
  110 + const skillMatch = hash.match(SKILL_AUDIT_RE);
  111 + if (taskMatch) {
108 route = { render: renderTaskDetail, mount: mountTaskDetail }; 112 route = { render: renderTaskDetail, mount: mountTaskDetail };
109 - routeArgs = [match[1]]; 113 + routeArgs = [taskMatch[1]];
  114 + } else if (skillMatch) {
  115 + route = { render: renderSkillAuditLog, mount: mountSkillAuditLog };
  116 + routeArgs = [skillMatch[1]];
110 } 117 }
111 } 118 }
112 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" }; 119 if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
@@ -1075,3 +1082,137 @@ function mountArtifactBrowser() { @@ -1075,3 +1082,137 @@ function mountArtifactBrowser() {
1075 }); 1082 });
1076 registerRouteCleanup(() => socket.close()); 1083 registerRouteCleanup(() => socket.close());
1077 } 1084 }
  1085 +
  1086 +// ---- v_skill_library (02-V §2.9) + v_skill_audit_log (02-V §2.10) ----
  1087 +// Skills are a platform-wide asset living in graphlemon's own repo (see
  1088 +// skill_query.py), not scoped to any target project -- these two pages
  1089 +// deliberately don't check getCurrentProjectId(), unlike every other real
  1090 +// page in this console.
  1091 +
  1092 +const SOURCE_TAG_LABELS = {
  1093 + migrated_from_hermes: "migrated_from_hermes", new: "new", auto_iterated: "auto_iterated",
  1094 +};
  1095 +
  1096 +function renderSkillLibrary() {
  1097 + return `
  1098 + <div class="page page-wide">
  1099 + <h1>技能库</h1>
  1100 + <div id="skill_groups"><div class="hint">加载中…</div></div>
  1101 + </div>
  1102 + `;
  1103 +}
  1104 +
  1105 +function mountSkillLibrary() {
  1106 + const groupsEl = document.getElementById("skill_groups");
  1107 +
  1108 + fetch("/api/skills").then((r) => r.json()).then((skills) => {
  1109 + if (skills.length === 0) {
  1110 + groupsEl.innerHTML = '<div class="hint">暂无 Skill</div>';
  1111 + return;
  1112 + }
  1113 + const byOrgNode = { planner: [], coder: [], validator: [] };
  1114 + for (const s of skills) (byOrgNode[s.owner_org_node] || []).push(s);
  1115 +
  1116 + groupsEl.innerHTML = ORG_NODE_ORDER.map((node) => {
  1117 + const rows = byOrgNode[node];
  1118 + if (!rows || rows.length === 0) return "";
  1119 + const rowsHtml = rows.map((s) =>
  1120 + '<div class="skill-row" data-skill-id="' + s.skill_id + '">'
  1121 + + '<span class="name">' + escapeHtml(s.skill_id) + "</span>"
  1122 + + '<span class="tag' + (s.source === "auto_iterated" ? " auto" : "") + '">'
  1123 + + escapeHtml(SOURCE_TAG_LABELS[s.source] || s.source) + "</span>"
  1124 + + "</div>"
  1125 + ).join("");
  1126 + return (
  1127 + '<div class="group"><h2>' + node.charAt(0).toUpperCase() + node.slice(1) + "</h2>"
  1128 + + '<div class="skill-list">' + rowsHtml + "</div></div>"
  1129 + );
  1130 + }).join("");
  1131 +
  1132 + for (const row of groupsEl.querySelectorAll(".skill-row")) {
  1133 + row.addEventListener("click", () => {
  1134 + window.location.hash = "#/skills/" + row.dataset.skillId + "/audit";
  1135 + });
  1136 + }
  1137 + });
  1138 +}
  1139 +
  1140 +function renderSkillAuditLog(skillId) {
  1141 + return `
  1142 + <div class="page page-wide">
  1143 + <div class="breadcrumb"><a href="#/skills">技能库</a> / ${escapeHtml(skillId)}</div>
  1144 + <h1>${escapeHtml(skillId)} 变更审计</h1>
  1145 + <div id="skill_audit_error" class="error-banner" style="display:none;"></div>
  1146 + <div id="skill_audit_list"><div class="hint">加载中…</div></div>
  1147 + </div>
  1148 + `;
  1149 +}
  1150 +
  1151 +function mountSkillAuditLog(skillId) {
  1152 + const listEl = document.getElementById("skill_audit_list");
  1153 + const errorEl = document.getElementById("skill_audit_error");
  1154 +
  1155 + async function refresh() {
  1156 + const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit");
  1157 + if (resp.status === 404) {
  1158 + listEl.innerHTML = "";
  1159 + errorEl.textContent = "该 Skill 不存在";
  1160 + errorEl.style.display = "block";
  1161 + return;
  1162 + }
  1163 + const iterations = await resp.json();
  1164 + if (iterations.length === 0) {
  1165 + listEl.innerHTML = '<div class="hint">该 Skill 尚无自主迭代记录</div>';
  1166 + return;
  1167 + }
  1168 + // newest first for display, oldest-first order from the API gives each its #N
  1169 + listEl.innerHTML = iterations.slice().reverse().map((it, i) => {
  1170 + const number = iterations.length - i;
  1171 + return (
  1172 + '<div class="iteration" data-iteration-id="' + it.iteration_id + '" '
  1173 + + 'data-before="' + it.before_git_commit_sha + '" data-after="' + it.after_git_commit_sha + '">'
  1174 + + '<div class="top"><b>迭代 #' + number + "</b><span class=\"t\">applied_at: " + escapeHtml(it.applied_at) + "</span></div>"
  1175 + + '<div class="summary">triggered_by_bug_id: ' + escapeHtml(it.triggered_by_bug_id) + " · " + escapeHtml(it.change_summary) + "</div>"
  1176 + + '<div class="actions">'
  1177 + + '<button class="btn btn-diff" type="button">查看 diff</button>'
  1178 + + '<button class="btn btn-rollback" type="button">回滚到此版本前</button>'
  1179 + + "</div>"
  1180 + + '<pre class="code diff-output" style="display:none;"></pre>'
  1181 + + "</div>"
  1182 + );
  1183 + }).join("");
  1184 +
  1185 + for (const el of listEl.querySelectorAll(".iteration")) {
  1186 + const diffOutput = el.querySelector(".diff-output");
  1187 + el.querySelector(".btn-diff").addEventListener("click", async () => {
  1188 + if (diffOutput.style.display === "block") {
  1189 + diffOutput.style.display = "none";
  1190 + return;
  1191 + }
  1192 + diffOutput.textContent = "加载中…";
  1193 + diffOutput.style.display = "block";
  1194 + const resp = await fetch(
  1195 + "/api/skills/diff?before=" + encodeURIComponent(el.dataset.before) + "&after=" + encodeURIComponent(el.dataset.after)
  1196 + );
  1197 + const body = await resp.json();
  1198 + diffOutput.textContent = body.diff_text || "(无差异)";
  1199 + });
  1200 + el.querySelector(".btn-rollback").addEventListener("click", async () => {
  1201 + const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/rollback", {
  1202 + method: "POST",
  1203 + body: JSON.stringify({ iteration_id: el.dataset.iterationId }),
  1204 + });
  1205 + if (resp.status !== 200) {
  1206 + const body = await resp.json();
  1207 + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : "");
  1208 + errorEl.style.display = "block";
  1209 + return;
  1210 + }
  1211 + errorEl.style.display = "none";
  1212 + refresh();
  1213 + });
  1214 + }
  1215 + }
  1216 +
  1217 + refresh();
  1218 +}
frontend/index.html
@@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
10 <a href="#/">Dashboard</a> 10 <a href="#/">Dashboard</a>
11 <a href="#/tasks">Tasks</a> 11 <a href="#/tasks">Tasks</a>
12 <a href="#/artifacts">Artifacts</a> 12 <a href="#/artifacts">Artifacts</a>
  13 + <a href="#/skills">Skills</a>
13 <a href="#/settings">Settings</a> 14 <a href="#/settings">Settings</a>
14 </nav> 15 </nav>
15 <main id="app"></main> 16 <main id="app"></main>
frontend/style.css
@@ -138,3 +138,21 @@ ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: upperc @@ -138,3 +138,21 @@ ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: upperc
138 .lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; } 138 .lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; }
139 .lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; } 139 .lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; }
140 pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre-wrap; } 140 pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre-wrap; }
  141 +
  142 +/* U-029: v_skill_library / v_skill_audit_log -- class names match
  143 + docs/wireframes/v_skill_library.html, v_skill_audit_log.html */
  144 +.group { margin-bottom: 22px; }
  145 +.group h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 0 0 10px; }
  146 +.skill-list { border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; }
  147 +.skill-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #e5e7eb; cursor: pointer; }
  148 +.skill-row:last-child { border-bottom: none; }
  149 +.skill-row:hover { background: #f9fafb; }
  150 +.skill-row .name { font-weight: 600; }
  151 +.tag { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #eef2ff; color: #4f46e5; }
  152 +.tag.auto { background: #fef3c7; color: #92400e; }
  153 +.iteration { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; background: #f9fafb; }
  154 +.iteration .top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; }
  155 +.iteration .t { color: #6b7280; font-size: 12px; }
  156 +.iteration .summary { font-size: 13px; margin-bottom: 8px; }
  157 +.iteration .actions { display: flex; gap: 8px; }
  158 +.iteration .diff-output { margin-top: 10px; }
src/backend_server.py
@@ -16,10 +16,12 @@ no `websockets` dependency) for real-time `execution_event` push, @@ -16,10 +16,12 @@ no `websockets` dependency) for real-time `execution_event` push,
16 `c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the 16 `c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the
17 latter reusing the same hand-rolled WS machinery) for raw execution log 17 latter reusing the same hand-rolled WS machinery) for raw execution log
18 viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in 18 viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in
19 -either 03-C or the MVCP JSON contract) for `v_artifact_browser`, and  
20 -serving the U-008 frontend shell as static files (no build step, 05 §5.7:  
21 -whitelisted by exact path, not a generic directory walker, so there's no  
22 -path-traversal surface to reason about). 19 +either 03-C or the MVCP JSON contract) for `v_artifact_browser`,
  20 +`c_skill_diff`/`c_skill_rollback` + skill/skill_iteration list/get (U-029,
  21 +`c_skill_diff` likewise only named in the MVCP JSON) for `v_skill_library`/
  22 +`v_skill_audit_log`, and serving the U-008 frontend shell as static files
  23 +(no build step, 05 §5.7: whitelisted by exact path, not a generic
  24 +directory walker, so there's no path-traversal surface to reason about).
23 """ 25 """
24 26
25 import json 27 import json
@@ -34,8 +36,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -34,8 +36,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
34 36
35 from src import ( 37 from src import (
36 artifact_query, budget_guardrail, bug_record, execution_log, 38 artifact_query, budget_guardrail, bug_record, execution_log,
37 - org_loop_route, paths, project_scaffold, schema, task_create,  
38 - task_query, ws_subscribe, 39 + org_loop_route, paths, project_scaffold, schema, skill_query,
  40 + skill_update, task_create, task_query, ws_subscribe,
39 ) 41 )
40 42
41 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") 43 _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
@@ -122,6 +124,7 @@ _WORK_NODES_PREFIX = &quot;/api/work-nodes/&quot; @@ -122,6 +124,7 @@ _WORK_NODES_PREFIX = &quot;/api/work-nodes/&quot;
122 _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" 124 _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/"
123 _LOG_SUFFIX = "/log" 125 _LOG_SUFFIX = "/log"
124 _ARTIFACTS_PREFIX = "/api/artifacts/" 126 _ARTIFACTS_PREFIX = "/api/artifacts/"
  127 +_SKILLS_PREFIX = "/api/skills/"
125 128
126 129
127 class _Handler(BaseHTTPRequestHandler): 130 class _Handler(BaseHTTPRequestHandler):
@@ -157,6 +160,16 @@ class _Handler(BaseHTTPRequestHandler): @@ -157,6 +160,16 @@ class _Handler(BaseHTTPRequestHandler):
157 if artifact_id and "/" not in artifact_id: 160 if artifact_id and "/" not in artifact_id:
158 self._handle_artifact_get(artifact_id) 161 self._handle_artifact_get(artifact_id)
159 return 162 return
  163 + if parsed.path == "/api/skills":
  164 + self._handle_skill_list()
  165 + return
  166 + if parsed.path == "/api/skills/diff":
  167 + self._handle_skill_diff(parsed.query)
  168 + return
  169 + if parsed.path.startswith(_SKILLS_PREFIX) and parsed.path.endswith("/audit"):
  170 + skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")]
  171 + self._handle_skill_audit_log(skill_id)
  172 + return
160 173
161 entry = _STATIC_FILES.get(self.path) 174 entry = _STATIC_FILES.get(self.path)
162 if entry is None: 175 if entry is None:
@@ -183,6 +196,9 @@ class _Handler(BaseHTTPRequestHandler): @@ -183,6 +196,9 @@ class _Handler(BaseHTTPRequestHandler):
183 elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"): 196 elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"):
184 task_id = self.path[len(_TASKS_PREFIX):-len("/route")] 197 task_id = self.path[len(_TASKS_PREFIX):-len("/route")]
185 self._handle_task_route(task_id) 198 self._handle_task_route(task_id)
  199 + elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"):
  200 + skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")]
  201 + self._handle_skill_rollback(skill_id)
186 else: 202 else:
187 self._send_json(404, {"error": "NOT_FOUND"}) 203 self._send_json(404, {"error": "NOT_FOUND"})
188 204
@@ -335,6 +351,51 @@ class _Handler(BaseHTTPRequestHandler): @@ -335,6 +351,51 @@ class _Handler(BaseHTTPRequestHandler):
335 else: 351 else:
336 self._send_json(200, result) 352 self._send_json(200, result)
337 353
  354 + def _handle_skill_list(self):
  355 + with self.server.conn_lock:
  356 + result = skill_query.list_skills(self.server.conn)
  357 + self._send_json(200, result)
  358 +
  359 + def _handle_skill_audit_log(self, skill_id):
  360 + try:
  361 + with self.server.conn_lock:
  362 + result = skill_query.get_skill_audit_log(self.server.conn, skill_id)
  363 + except skill_query.SkillNotFoundError:
  364 + self._send_json(404, {"error": "NOT_FOUND"})
  365 + else:
  366 + self._send_json(200, result)
  367 +
  368 + def _handle_skill_diff(self, query_string):
  369 + params = urllib.parse.parse_qs(query_string)
  370 + before = params.get("before", [None])[0]
  371 + after = params.get("after", [None])[0]
  372 + if not before or not after:
  373 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "before and after are both required"})
  374 + return
  375 + try:
  376 + diff_text = skill_query.get_diff(before, after, repo_path=self.server.skill_repo_path)
  377 + except subprocess.CalledProcessError as e:
  378 + self._send_json(400, {"error": "INVALID_PARAM", "detail": e.stderr})
  379 + else:
  380 + self._send_json(200, {"diff_text": diff_text})
  381 +
  382 + def _handle_skill_rollback(self, skill_id):
  383 + payload, error = self._read_json_body()
  384 + if error:
  385 + self._send_json(400, error)
  386 + return
  387 + iteration_id = payload.get("iteration_id")
  388 + if not iteration_id:
  389 + self._send_json(400, {"error": "INVALID_INPUT", "detail": "iteration_id is required"})
  390 + return
  391 + try:
  392 + with self.server.conn_lock:
  393 + result = skill_update.rollback_skill(self.server.conn, iteration_id, self.server.skill_repo_path)
  394 + except skill_update.IterationNotFoundError as e:
  395 + self._send_json(404, {"error": "ITERATION_NOT_FOUND", "detail": str(e)})
  396 + else:
  397 + self._send_json(200, result)
  398 +
338 def _handle_ws_subscribe(self, path, query_string): 399 def _handle_ws_subscribe(self, path, query_string):
339 """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. 400 """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id.
340 Rejects with a normal HTTP response (no 101) before ever touching 401 Rejects with a normal HTTP response (no 101) before ever touching
@@ -514,16 +575,25 @@ class _Server(ThreadingHTTPServer): @@ -514,16 +575,25 @@ class _Server(ThreadingHTTPServer):
514 self.conn.close() 575 self.conn.close()
515 576
516 577
517 -def create_server(host="127.0.0.1", port=0, db_path=None): 578 +def create_server(host="127.0.0.1", port=0, db_path=None, skill_repo_path=None):
518 """port=0 lets the OS assign a free port -- avoids collisions in tests 579 """port=0 lets the OS assign a free port -- avoids collisions in tests
519 and lets multiple graphlemon instances run side by side if ever needed. 580 and lets multiple graphlemon instances run side by side if ever needed.
520 581
521 db_path defaults to paths.db_path() (U-003's platform_home, so it never 582 db_path defaults to paths.db_path() (U-003's platform_home, so it never
522 lives inside a target project's own repo); tests pass ":memory:" or a 583 lives inside a target project's own repo); tests pass ":memory:" or a
523 - tempfile so runs don't touch the real developer's ~/.graphlemon/.""" 584 + tempfile so runs don't touch the real developer's ~/.graphlemon/.
  585 +
  586 + skill_repo_path defaults to skill_query._REPO_ROOT (graphlemon's own
  587 + checkout -- Skills genuinely live there, U-018/U-029) but is injectable
  588 + for the same reason db_path is: c_skill_rollback does a real `git
  589 + commit`, and a test hitting that route with the real default would
  590 + write an actual commit into whatever repo this process happens to be
  591 + running from. Never exposed to HTTP clients -- only settable here, at
  592 + process/test-construction time."""
524 conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False) 593 conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False)
525 _ensure_schema(conn) 594 _ensure_schema(conn)
526 server = _Server((host, port), _Handler) 595 server = _Server((host, port), _Handler)
527 server.conn = conn 596 server.conn = conn
528 server.conn_lock = threading.Lock() 597 server.conn_lock = threading.Lock()
  598 + server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT
529 return server 599 return server
src/skill_query.py 0 → 100644
  1 +"""c_skill_diff (named only in `sdd-mvcp-contract.json`, prose 03-C never
  2 +wrote it up) + list/get read helpers for `v_skill_library`/`v_skill_audit_log`
  3 +(02-V §2.9/§2.10). Skills live in graphlemon's own repo, not any target
  4 +project's (`src/skill_update.py`'s own docstring: "Skill 库是platform
  5 +asset,不是target-project"), so the diff runs against graphlemon's own
  6 +checkout by default -- reuses `agent_profile.py`'s own `_REPO_ROOT` pattern
  7 +rather than inventing a second way to locate it.
  8 +"""
  9 +
  10 +import os
  11 +import subprocess
  12 +
  13 +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  14 +
  15 +
  16 +def list_skills(conn):
  17 + rows = conn.execute(
  18 + "SELECT skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  19 + "migrated_from_profile, status FROM skill ORDER BY owner_org_node, skill_id"
  20 + ).fetchall()
  21 + return [
  22 + {
  23 + "skill_id": r[0], "owner_org_node": r[1], "file_path": r[2], "current_git_commit_sha": r[3],
  24 + "source": r[4], "migrated_from_profile": r[5], "status": r[6],
  25 + }
  26 + for r in rows
  27 + ]
  28 +
  29 +
  30 +class SkillNotFoundError(Exception):
  31 + """NOT_FOUND -- c_skill_get's own skill_id doesn't exist."""
  32 +
  33 +
  34 +def get_skill_audit_log(conn, skill_id):
  35 + """v_skill_audit_log (02-V §2.10): every skill_iteration row for this
  36 + skill, oldest first."""
  37 + if conn.execute("SELECT 1 FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() is None:
  38 + raise SkillNotFoundError(skill_id)
  39 + rows = conn.execute(
  40 + "SELECT iteration_id, triggered_by_bug_id, before_git_commit_sha, after_git_commit_sha, "
  41 + "change_summary, applied_at, rollback_of_iteration_id FROM skill_iteration "
  42 + "WHERE skill_id = ? ORDER BY applied_at",
  43 + (skill_id,),
  44 + ).fetchall()
  45 + return [
  46 + {
  47 + "iteration_id": r[0], "triggered_by_bug_id": r[1], "before_git_commit_sha": r[2],
  48 + "after_git_commit_sha": r[3], "change_summary": r[4], "applied_at": r[5],
  49 + "rollback_of_iteration_id": r[6],
  50 + }
  51 + for r in rows
  52 + ]
  53 +
  54 +
  55 +def get_diff(before, after, repo_path=None):
  56 + """c_skill_diff: `git diff <before> <after>` -- skill_update.py's
  57 + _write_and_commit makes every iteration an atomic single-file commit,
  58 + so a plain repo-wide diff between the two commits naturally scopes
  59 + itself to that one skill file without needing a path filter."""
  60 + result = subprocess.run(
  61 + ["git", "diff", before, after], cwd=repo_path or _REPO_ROOT,
  62 + capture_output=True, text=True, check=True,
  63 + )
  64 + return result.stdout
src/test_backend_server.py
@@ -20,7 +20,7 @@ import time @@ -20,7 +20,7 @@ import time
20 import unittest 20 import unittest
21 from unittest.mock import patch 21 from unittest.mock import patch
22 22
23 -from src import backend_server, execution_log, ws_subscribe 23 +from src import backend_server, execution_log, skill_update, ws_subscribe
24 24
25 25
26 class ServerStartStopTest(unittest.TestCase): 26 class ServerStartStopTest(unittest.TestCase):
@@ -887,6 +887,106 @@ class ArtifactRouteTest(unittest.TestCase): @@ -887,6 +887,106 @@ class ArtifactRouteTest(unittest.TestCase):
887 self.assertEqual(body["error"], "NOT_FOUND") 887 self.assertEqual(body["error"], "NOT_FOUND")
888 888
889 889
  890 +class SkillRouteTest(unittest.TestCase):
  891 + """U-029: c_skill_diff (GET /api/skills/diff) + c_skill_rollback
  892 + (POST /api/skills/:skill_id/rollback) + list/get for v_skill_library /
  893 + v_skill_audit_log. skill_repo_path points at a throwaway temp repo --
  894 + NEVER the real graphlemon checkout, since c_skill_rollback does a real
  895 + `git commit` (see backend_server.create_server's skill_repo_path note)."""
  896 +
  897 + def setUp(self):
  898 + self.tmp = tempfile.TemporaryDirectory()
  899 + self.addCleanup(self.tmp.cleanup)
  900 + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True)
  901 + self.server = backend_server.create_server(db_path=":memory:", skill_repo_path=self.tmp.name)
  902 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  903 + self.thread.start()
  904 + self.server.conn.execute(
  905 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  906 + "VALUES ('skill-1', 'coder', 'skills/skill-1', 'unknown', 'new', 'active')"
  907 + )
  908 + self.server.conn.execute(
  909 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
  910 + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')"
  911 + )
  912 + self.server.conn.commit()
  913 +
  914 + def tearDown(self):
  915 + self.server.shutdown()
  916 + self.server.server_close()
  917 + self.thread.join(timeout=5)
  918 +
  919 + def _get(self, path):
  920 + host, port = self.server.server_address
  921 + conn = http.client.HTTPConnection(host, port, timeout=5)
  922 + conn.request("GET", path)
  923 + resp = conn.getresponse()
  924 + body = json.loads(resp.read())
  925 + conn.close()
  926 + return resp.status, body
  927 +
  928 + def _post(self, path, payload):
  929 + host, port = self.server.server_address
  930 + conn = http.client.HTTPConnection(host, port, timeout=5)
  931 + conn.request("POST", path, body=json.dumps(payload).encode("utf-8"))
  932 + resp = conn.getresponse()
  933 + body = json.loads(resp.read())
  934 + conn.close()
  935 + return resp.status, body
  936 +
  937 + def test_list_returns_seeded_skill(self):
  938 + status, body = self._get("/api/skills")
  939 + self.assertEqual(status, 200)
  940 + self.assertEqual(len(body), 1)
  941 + self.assertEqual(body[0]["skill_id"], "skill-1")
  942 +
  943 + def test_audit_log_empty_for_skill_with_no_iterations(self):
  944 + status, body = self._get("/api/skills/skill-1/audit")
  945 + self.assertEqual(status, 200)
  946 + self.assertEqual(body, [])
  947 +
  948 + def test_audit_log_nonexistent_skill_returns_404(self):
  949 + status, body = self._get("/api/skills/no-such-skill/audit")
  950 + self.assertEqual(status, 404)
  951 +
  952 + def test_diff_missing_query_params_returns_400(self):
  953 + status, body = self._get("/api/skills/diff")
  954 + self.assertEqual(status, 400)
  955 + self.assertEqual(body["error"], "INVALID_INPUT")
  956 +
  957 + def test_full_update_diff_rollback_cycle(self):
  958 + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "v1 content", "seed skill")
  959 + with self.server.conn_lock:
  960 + result = skill_update.update_skill(
  961 + self.server.conn, "skill-1", "bug-1", "v2 content", "improve it", self.tmp.name
  962 + )
  963 + iteration_id = result["iteration_id"]
  964 +
  965 + status, body = self._get(
  966 + "/api/skills/diff?before=" + self._before_sha(iteration_id) + "&after=" + result["after_git_commit_sha"]
  967 + )
  968 + self.assertEqual(status, 200)
  969 + self.assertIn("v2 content", body["diff_text"])
  970 +
  971 + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": iteration_id})
  972 + self.assertEqual(status, 200)
  973 + self.assertIn("new_iteration_id", body)
  974 +
  975 + def _before_sha(self, iteration_id):
  976 + return self.server.conn.execute(
  977 + "SELECT before_git_commit_sha FROM skill_iteration WHERE iteration_id = ?", (iteration_id,)
  978 + ).fetchone()[0]
  979 +
  980 + def test_rollback_nonexistent_iteration_returns_404(self):
  981 + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": "no-such-iteration"})
  982 + self.assertEqual(status, 404)
  983 + self.assertEqual(body["error"], "ITERATION_NOT_FOUND")
  984 +
  985 + def test_rollback_missing_iteration_id_returns_400(self):
  986 + status, body = self._post("/api/skills/skill-1/rollback", {})
  987 + self.assertEqual(status, 400)
  988 +
  989 +
890 class ZeroThirdPartyDependencyTest(unittest.TestCase): 990 class ZeroThirdPartyDependencyTest(unittest.TestCase):
891 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this 991 """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this
892 doesn't silently rot if someone adds an import later.""" 992 doesn't silently rot if someone adds an import later."""
src/test_skill_query.py 0 → 100644
  1 +"""U-029 tests: c_skill_diff + list/get read helpers for v_skill_library /
  2 +v_skill_audit_log. Real sqlite + real git in a throwaway temp repo (never
  3 +graphlemon's own -- same reasoning as test_skill_update.py).
  4 +
  5 +Run: python3 -m unittest src.test_skill_query -v
  6 +"""
  7 +
  8 +import subprocess
  9 +import tempfile
  10 +import unittest
  11 +
  12 +from src import schema, skill_query, skill_update
  13 +
  14 +
  15 +def _init_git_repo(path):
  16 + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True)
  17 +
  18 +
  19 +class SkillQueryTestBase(unittest.TestCase):
  20 + def setUp(self):
  21 + self.tmp = tempfile.TemporaryDirectory()
  22 + self.addCleanup(self.tmp.cleanup)
  23 + _init_git_repo(self.tmp.name)
  24 +
  25 + self.conn = schema.connect(":memory:")
  26 + schema.create_schema(self.conn)
  27 + self.conn.commit()
  28 +
  29 + def tearDown(self):
  30 + self.conn.close()
  31 +
  32 + def _insert_skill(self, skill_id, owner_org_node, source="new", migrated_from_profile=None):
  33 + self.conn.execute(
  34 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, "
  35 + "migrated_from_profile, status) VALUES (?, ?, ?, 'unknown', ?, ?, 'active')",
  36 + (skill_id, owner_org_node, "skills/" + skill_id, source, migrated_from_profile),
  37 + )
  38 + self.conn.commit()
  39 +
  40 +
  41 +class ListSkillsTest(SkillQueryTestBase):
  42 + def test_lists_all_skills(self):
  43 + self._insert_skill("s1", "planner")
  44 + self._insert_skill("s2", "coder")
  45 + result = skill_query.list_skills(self.conn)
  46 + self.assertEqual({s["skill_id"] for s in result}, {"s1", "s2"})
  47 +
  48 + def test_includes_owner_org_node_and_source(self):
  49 + self._insert_skill("s1", "validator", source="migrated_from_hermes", migrated_from_profile="qa")
  50 + result = skill_query.list_skills(self.conn)
  51 + self.assertEqual(result[0]["owner_org_node"], "validator")
  52 + self.assertEqual(result[0]["source"], "migrated_from_hermes")
  53 + self.assertEqual(result[0]["migrated_from_profile"], "qa")
  54 +
  55 + def test_empty_returns_empty_list(self):
  56 + self.assertEqual(skill_query.list_skills(self.conn), [])
  57 +
  58 +
  59 +class GetSkillAuditLogTest(SkillQueryTestBase):
  60 + def test_nonexistent_skill_raises(self):
  61 + with self.assertRaises(skill_query.SkillNotFoundError):
  62 + skill_query.get_skill_audit_log(self.conn, "no-such-skill")
  63 +
  64 + def test_skill_with_no_iterations_returns_empty_list(self):
  65 + self._insert_skill("s1", "planner")
  66 + self.assertEqual(skill_query.get_skill_audit_log(self.conn, "s1"), [])
  67 +
  68 + def test_returns_iterations_oldest_first(self):
  69 + self._insert_skill("s1", "coder")
  70 + self.conn.execute(
  71 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, "
  72 + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')"
  73 + )
  74 + self.conn.execute(
  75 + "INSERT INTO skill_iteration (iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, "
  76 + "after_git_commit_sha, change_summary, applied_at) VALUES "
  77 + "('it-1', 's1', 'bug-1', 'sha0', 'sha1', 'fix A', '2026-07-01 00:00:00')"
  78 + )
  79 + self.conn.execute(
  80 + "INSERT INTO skill_iteration (iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, "
  81 + "after_git_commit_sha, change_summary, applied_at) VALUES "
  82 + "('it-2', 's1', 'bug-1', 'sha1', 'sha2', 'fix B', '2026-07-02 00:00:00')"
  83 + )
  84 + self.conn.commit()
  85 + result = skill_query.get_skill_audit_log(self.conn, "s1")
  86 + self.assertEqual([r["iteration_id"] for r in result], ["it-1", "it-2"])
  87 + self.assertEqual(result[0]["change_summary"], "fix A")
  88 +
  89 +
  90 +class GetDiffTest(SkillQueryTestBase):
  91 + def test_diff_between_two_commits_shows_content_change(self):
  92 + before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed")
  93 + after_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v2 content\n", "update")
  94 + diff_text = skill_query.get_diff(before_sha, after_sha, repo_path=self.tmp.name)
  95 + self.assertIn("-v1 content", diff_text)
  96 + self.assertIn("+v2 content", diff_text)
  97 +
  98 + def test_identical_commits_produce_empty_diff(self):
  99 + sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "same\n", "seed")
  100 + diff_text = skill_query.get_diff(sha, sha, repo_path=self.tmp.name)
  101 + self.assertEqual(diff_text, "")
  102 +
  103 +
  104 +if __name__ == "__main__":
  105 + unittest.main()