Commit 1f7cec119c702e32c0083fc8acfebfd09525e49d
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>
Showing
11 changed files
with
531 additions
and
15 deletions
docs/development-plan.md
| ... | ... | @@ -267,6 +267,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 267 | 267 | |
| 268 | 268 | - **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log` |
| 269 | 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 | 274 | ### PH3-S4 配置页 |
| 272 | 275 | ... | ... |
docs/sdd/03-C-控制与接口.md
| ... | ... | @@ -196,6 +196,13 @@ |
| 196 | 196 | - `idempotent`:否 |
| 197 | 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 | 206 | ## 3.10 `c_bug_record_create` |
| 200 | 207 | |
| 201 | 208 | - `POST /api/bug-records` | ... | ... |
docs/sdd/05-实施约束与依赖.md
| ... | ... | @@ -54,6 +54,13 @@ |
| 54 | 54 | - **应对方式**:`self.conn.close()` 也必须包在 `with self.conn_lock:` 里——"关闭"和"读写"对同一个连接对象而言都是需要互斥的操作,不能默认关闭是安全的。已在 U-027 修复,修复后连续跑几十次全部稳定通过。 |
| 55 | 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 | 64 | ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29) |
| 58 | 65 | |
| 59 | 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 | 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 | 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 | 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 | 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 | 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 | 78 | "#/": { render: renderDashboard, mount: mountDashboard }, |
| 79 | 79 | "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, |
| 80 | 80 | "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, |
| 81 | + "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, | |
| 81 | 82 | "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" }, |
| 82 | 83 | "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, |
| 83 | 84 | "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, |
| 84 | 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 | 91 | const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; |
| 92 | +const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; | |
| 90 | 93 | |
| 91 | 94 | let currentRouteCleanup = null; |
| 92 | 95 | function registerRouteCleanup(fn) { |
| ... | ... | @@ -103,10 +106,14 @@ function render() { |
| 103 | 106 | let route = routes[hash]; |
| 104 | 107 | let routeArgs = []; |
| 105 | 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 | 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 | 119 | if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" }; |
| ... | ... | @@ -1075,3 +1082,137 @@ function mountArtifactBrowser() { |
| 1075 | 1082 | }); |
| 1076 | 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
frontend/style.css
| ... | ... | @@ -138,3 +138,21 @@ ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: upperc |
| 138 | 138 | .lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; } |
| 139 | 139 | .lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; } |
| 140 | 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 | 16 | `c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the |
| 17 | 17 | latter reusing the same hand-rolled WS machinery) for raw execution log |
| 18 | 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 | 27 | import json |
| ... | ... | @@ -34,8 +36,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 34 | 36 | |
| 35 | 37 | from src import ( |
| 36 | 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 | 43 | _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") |
| ... | ... | @@ -122,6 +124,7 @@ _WORK_NODES_PREFIX = "/api/work-nodes/" |
| 122 | 124 | _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" |
| 123 | 125 | _LOG_SUFFIX = "/log" |
| 124 | 126 | _ARTIFACTS_PREFIX = "/api/artifacts/" |
| 127 | +_SKILLS_PREFIX = "/api/skills/" | |
| 125 | 128 | |
| 126 | 129 | |
| 127 | 130 | class _Handler(BaseHTTPRequestHandler): |
| ... | ... | @@ -157,6 +160,16 @@ class _Handler(BaseHTTPRequestHandler): |
| 157 | 160 | if artifact_id and "/" not in artifact_id: |
| 158 | 161 | self._handle_artifact_get(artifact_id) |
| 159 | 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 | 174 | entry = _STATIC_FILES.get(self.path) |
| 162 | 175 | if entry is None: |
| ... | ... | @@ -183,6 +196,9 @@ class _Handler(BaseHTTPRequestHandler): |
| 183 | 196 | elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"): |
| 184 | 197 | task_id = self.path[len(_TASKS_PREFIX):-len("/route")] |
| 185 | 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 | 202 | else: |
| 187 | 203 | self._send_json(404, {"error": "NOT_FOUND"}) |
| 188 | 204 | |
| ... | ... | @@ -335,6 +351,51 @@ class _Handler(BaseHTTPRequestHandler): |
| 335 | 351 | else: |
| 336 | 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 | 399 | def _handle_ws_subscribe(self, path, query_string): |
| 339 | 400 | """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. |
| 340 | 401 | Rejects with a normal HTTP response (no 101) before ever touching |
| ... | ... | @@ -514,16 +575,25 @@ class _Server(ThreadingHTTPServer): |
| 514 | 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 | 579 | """port=0 lets the OS assign a free port -- avoids collisions in tests |
| 519 | 580 | and lets multiple graphlemon instances run side by side if ever needed. |
| 520 | 581 | |
| 521 | 582 | db_path defaults to paths.db_path() (U-003's platform_home, so it never |
| 522 | 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 | 593 | conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False) |
| 525 | 594 | _ensure_schema(conn) |
| 526 | 595 | server = _Server((host, port), _Handler) |
| 527 | 596 | server.conn = conn |
| 528 | 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 | 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 | 20 | import unittest |
| 21 | 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 | 26 | class ServerStartStopTest(unittest.TestCase): |
| ... | ... | @@ -887,6 +887,106 @@ class ArtifactRouteTest(unittest.TestCase): |
| 887 | 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 | 990 | class ZeroThirdPartyDependencyTest(unittest.TestCase): |
| 891 | 991 | """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this |
| 892 | 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() | ... | ... |