diff --git a/docs/development-plan.md b/docs/development-plan.md index e571ffc..bb8227e 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -267,6 +267,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-029 [Value Path]** `v_skill_library` + `v_skill_audit_log` - 依赖:U-018 + - Done:Skill 库与变更审计日志可查看(原文档缺 Done/覆盖行,2026-07-31 补上) + - **实现补充(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 `——`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()` 守卫的真实页面)。 + - 覆盖: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 ### PH3-S4 配置页 diff --git a/docs/sdd/03-C-控制与接口.md b/docs/sdd/03-C-控制与接口.md index ab822e3..a5fef69 100644 --- a/docs/sdd/03-C-控制与接口.md +++ b/docs/sdd/03-C-控制与接口.md @@ -196,6 +196,13 @@ - `idempotent`:否 - `errors`:`ITERATION_NOT_FOUND`(不可重试) +## 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` 自己的展示需求倒逼补上) + +- `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` 记录) +- `output_contract`:`c_skill_diff` 返回 `{ diff_text: string }`(`git diff `,在 graphlemon 自身仓库执行——见 05 §5.4.1.4);`c_skill_list` 返回裸数组;`c_skill_get` 返回 `skill_iteration` 数组,按 `applied_at` 升序 +- `idempotent`:是 +- `errors`:`NOT_FOUND`(不可重试,仅 `c_skill_get`):`skill_id` 不存在;`INVALID_INPUT`(不可重试,仅 `c_skill_diff`):`before`/`after` 缺失 + ## 3.10 `c_bug_record_create` - `POST /api/bug-records` diff --git a/docs/sdd/05-实施约束与依赖.md b/docs/sdd/05-实施约束与依赖.md index ddb5f87..220f409 100644 --- a/docs/sdd/05-实施约束与依赖.md +++ b/docs/sdd/05-实施约束与依赖.md @@ -54,6 +54,13 @@ - **应对方式**:`self.conn.close()` 也必须包在 `with self.conn_lock:` 里——"关闭"和"读写"对同一个连接对象而言都是需要互斥的操作,不能默认关闭是安全的。已在 U-027 修复,修复后连续跑几十次全部稳定通过。 - 这条不需要等 Claude Code 升级重新验证(跟它无关),但如果以后这里改用连接池/每线程一个连接,这条约束本身会自然消失,到时候可以直接删掉这一节。 +## 5.4.1.4 `c_skill_diff`/`c_skill_rollback` 的仓库路径不能通过 HTTP 暴露(设计约束,2026-07-31,U-029) + +- 背景:Skill 库是 graphlemon 自己平台的资产(不是 target project 的),`c_skill_rollback`(`src/skill_update.py` 的 `rollback_skill()`)会在这个仓库里真的跑 `git commit`;`repo_path` 参数 U-018 就有意不给默认值,防止哪个调用方忘了传就误伤 graphlemon 自己的历史(见 U-018 那次实现补充)。 +- **风险点**:`backend_server.py` 把这个函数接到 `POST /api/skills/:skill_id/rollback` 时,如果 HTTP 层内部写死 `skill_repo_path=`,那么任何针对这条路由的自动化测试(`src/test_backend_server.py`)都会在正在跑的这个 graphlemon 仓库里留下真实 git 提交——测试本身就会污染开发者自己的仓库历史。 +- **应对方式**:参照 `create_server()` 已有的 `db_path` 参数模式,加了 `skill_repo_path` 参数:默认值是 `skill_query._REPO_ROOT`(graphlemon 真实仓库根,生产环境下这就是唯一正确的行为——Skill 变更本来就应该落到这里),但可以在构造 server 时覆盖;测试统一传一个 `tempfile` 建的临时仓库。**这个参数只在 Python 层(进程启动/测试构造)可设,绝不通过 HTTP 请求体/query string 暴露给客户端**——否则等于开了一个"客户端指定任意目录、服务端替它跑 git commit"的口子,属于路径注入/任意写入范畴。 +- 这不是"发现"的约束(不是实测跑出来的意外行为),是设计阶段就该考虑到、写代码时主动做的防护,记录下来是为了以后改这块代码的人不要图省事把 `skill_repo_path` 从请求里读出来。 + ## 5.4.2 Claude Code plan 额度等待重试参数(架构文档 7.5 节决策,2026-07-29) - 退避轮询间隔(5→10→20→30 分钟,上限 30 分钟)、超时兜底阈值(session 8 小时、weekly 9 天)是 MVP 阶段的硬编码默认值,不在管理后台开放用户配置,和 `log_retention_config` 的"保留天数可配、清理频率不可配"是同一个 MVP 简化原则 diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index f4f1019..e75e44d 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -44,7 +44,7 @@ {"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 面板,不重复渲染"}, {"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 未覆盖)"}, {"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 渲染/代码高亮(避免引入前端第三方依赖)"}, - {"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 库与变更审计日志可查看"}, + {"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 可注入,测试绝不碰真实仓库)"}, {"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": "三个配置页可用且改动生效"}, {"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 的全部用例通过一次完整回归"}, diff --git a/frontend/app.js b/frontend/app.js index 9f580b6..06b1383 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -78,15 +78,18 @@ const routes = { "#/": { render: renderDashboard, mount: mountDashboard }, "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, + "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, "#/settings": { render: () => "

Settings

placeholder -- real content is PH3 (U-030)

" }, "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, }; -// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a -// full path-matching router for a single pattern. +// #/tasks/:task_id (v_task_detail) and #/skills/:skill_id/audit +// (v_skill_audit_log) are the only dynamic routes -- not worth a full +// path-matching router for two patterns. const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; +const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; let currentRouteCleanup = null; function registerRouteCleanup(fn) { @@ -103,10 +106,14 @@ function render() { let route = routes[hash]; let routeArgs = []; if (!route) { - const match = hash.match(TASK_DETAIL_RE); - if (match) { + const taskMatch = hash.match(TASK_DETAIL_RE); + const skillMatch = hash.match(SKILL_AUDIT_RE); + if (taskMatch) { route = { render: renderTaskDetail, mount: mountTaskDetail }; - routeArgs = [match[1]]; + routeArgs = [taskMatch[1]]; + } else if (skillMatch) { + route = { render: renderSkillAuditLog, mount: mountSkillAuditLog }; + routeArgs = [skillMatch[1]]; } } if (!route) route = { render: () => "

Not found

" + hash + "

" }; @@ -1075,3 +1082,137 @@ function mountArtifactBrowser() { }); registerRouteCleanup(() => socket.close()); } + +// ---- v_skill_library (02-V §2.9) + v_skill_audit_log (02-V §2.10) ---- +// Skills are a platform-wide asset living in graphlemon's own repo (see +// skill_query.py), not scoped to any target project -- these two pages +// deliberately don't check getCurrentProjectId(), unlike every other real +// page in this console. + +const SOURCE_TAG_LABELS = { + migrated_from_hermes: "migrated_from_hermes", new: "new", auto_iterated: "auto_iterated", +}; + +function renderSkillLibrary() { + return ` +
+

技能库

+
加载中…
+
+ `; +} + +function mountSkillLibrary() { + const groupsEl = document.getElementById("skill_groups"); + + fetch("/api/skills").then((r) => r.json()).then((skills) => { + if (skills.length === 0) { + groupsEl.innerHTML = '
暂无 Skill
'; + return; + } + const byOrgNode = { planner: [], coder: [], validator: [] }; + for (const s of skills) (byOrgNode[s.owner_org_node] || []).push(s); + + groupsEl.innerHTML = ORG_NODE_ORDER.map((node) => { + const rows = byOrgNode[node]; + if (!rows || rows.length === 0) return ""; + const rowsHtml = rows.map((s) => + '
' + + '' + escapeHtml(s.skill_id) + "" + + '' + + escapeHtml(SOURCE_TAG_LABELS[s.source] || s.source) + "" + + "
" + ).join(""); + return ( + '

' + node.charAt(0).toUpperCase() + node.slice(1) + "

" + + '
' + rowsHtml + "
" + ); + }).join(""); + + for (const row of groupsEl.querySelectorAll(".skill-row")) { + row.addEventListener("click", () => { + window.location.hash = "#/skills/" + row.dataset.skillId + "/audit"; + }); + } + }); +} + +function renderSkillAuditLog(skillId) { + return ` +
+ +

${escapeHtml(skillId)} 变更审计

+ +
加载中…
+
+ `; +} + +function mountSkillAuditLog(skillId) { + const listEl = document.getElementById("skill_audit_list"); + const errorEl = document.getElementById("skill_audit_error"); + + async function refresh() { + const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit"); + if (resp.status === 404) { + listEl.innerHTML = ""; + errorEl.textContent = "该 Skill 不存在"; + errorEl.style.display = "block"; + return; + } + const iterations = await resp.json(); + if (iterations.length === 0) { + listEl.innerHTML = '
该 Skill 尚无自主迭代记录
'; + return; + } + // newest first for display, oldest-first order from the API gives each its #N + listEl.innerHTML = iterations.slice().reverse().map((it, i) => { + const number = iterations.length - i; + return ( + '
' + + '
迭代 #' + number + "applied_at: " + escapeHtml(it.applied_at) + "
" + + '
triggered_by_bug_id: ' + escapeHtml(it.triggered_by_bug_id) + " · " + escapeHtml(it.change_summary) + "
" + + '
' + + '' + + '' + + "
" + + '' + + "
" + ); + }).join(""); + + for (const el of listEl.querySelectorAll(".iteration")) { + const diffOutput = el.querySelector(".diff-output"); + el.querySelector(".btn-diff").addEventListener("click", async () => { + if (diffOutput.style.display === "block") { + diffOutput.style.display = "none"; + return; + } + diffOutput.textContent = "加载中…"; + diffOutput.style.display = "block"; + const resp = await fetch( + "/api/skills/diff?before=" + encodeURIComponent(el.dataset.before) + "&after=" + encodeURIComponent(el.dataset.after) + ); + const body = await resp.json(); + diffOutput.textContent = body.diff_text || "(无差异)"; + }); + el.querySelector(".btn-rollback").addEventListener("click", async () => { + const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/rollback", { + method: "POST", + body: JSON.stringify({ iteration_id: el.dataset.iterationId }), + }); + if (resp.status !== 200) { + const body = await resp.json(); + errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); + errorEl.style.display = "block"; + return; + } + errorEl.style.display = "none"; + refresh(); + }); + } + } + + refresh(); +} diff --git a/frontend/index.html b/frontend/index.html index 46ce3fd..65a83c5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,7 @@ Dashboard Tasks Artifacts + Skills Settings
diff --git a/frontend/style.css b/frontend/style.css index 4817f8d..1b67532 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -138,3 +138,21 @@ ul.artifact-list .type { font-size: 11px; color: #6b7280; text-transform: upperc .lineage { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; font-size: 12px; color: #6b7280; margin-bottom: 14px; } .lineage span.node { border: 1px solid #e5e7eb; border-radius: 999px; padding: 3px 10px; background: #fff; } pre.code { background: #0b1120; color: #d1d5db; padding: 14px; border-radius: 8px; font-size: 12px; overflow: auto; white-space: pre-wrap; } + +/* U-029: v_skill_library / v_skill_audit_log -- class names match + docs/wireframes/v_skill_library.html, v_skill_audit_log.html */ +.group { margin-bottom: 22px; } +.group h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #6b7280; margin: 0 0 10px; } +.skill-list { border: 1px solid #e5e7eb; border-radius: 10px; overflow: hidden; } +.skill-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #e5e7eb; cursor: pointer; } +.skill-row:last-child { border-bottom: none; } +.skill-row:hover { background: #f9fafb; } +.skill-row .name { font-weight: 600; } +.tag { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: #eef2ff; color: #4f46e5; } +.tag.auto { background: #fef3c7; color: #92400e; } +.iteration { border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; background: #f9fafb; } +.iteration .top { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 6px; } +.iteration .t { color: #6b7280; font-size: 12px; } +.iteration .summary { font-size: 13px; margin-bottom: 8px; } +.iteration .actions { display: flex; gap: 8px; } +.iteration .diff-output { margin-top: 10px; } diff --git a/src/backend_server.py b/src/backend_server.py index 0623651..44ad7d3 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -16,10 +16,12 @@ no `websockets` dependency) for real-time `execution_event` push, `c_execution_log_get`/`c_ws_log_tail` (03-C §3.6.1/§3.6.2, U-027, the latter reusing the same hand-rolled WS machinery) for raw execution log viewing, `c_artifact_list`/`c_artifact_get` (U-028, the former unnamed in -either 03-C or the MVCP JSON contract) for `v_artifact_browser`, and -serving the U-008 frontend shell as static files (no build step, 05 §5.7: -whitelisted by exact path, not a generic directory walker, so there's no -path-traversal surface to reason about). +either 03-C or the MVCP JSON contract) for `v_artifact_browser`, +`c_skill_diff`/`c_skill_rollback` + skill/skill_iteration list/get (U-029, +`c_skill_diff` likewise only named in the MVCP JSON) for `v_skill_library`/ +`v_skill_audit_log`, and serving the U-008 frontend shell as static files +(no build step, 05 §5.7: whitelisted by exact path, not a generic +directory walker, so there's no path-traversal surface to reason about). """ import json @@ -34,8 +36,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from src import ( artifact_query, budget_guardrail, bug_record, execution_log, - org_loop_route, paths, project_scaffold, schema, task_create, - task_query, ws_subscribe, + org_loop_route, paths, project_scaffold, schema, skill_query, + skill_update, task_create, task_query, ws_subscribe, ) _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/" _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" _LOG_SUFFIX = "/log" _ARTIFACTS_PREFIX = "/api/artifacts/" +_SKILLS_PREFIX = "/api/skills/" class _Handler(BaseHTTPRequestHandler): @@ -157,6 +160,16 @@ class _Handler(BaseHTTPRequestHandler): if artifact_id and "/" not in artifact_id: self._handle_artifact_get(artifact_id) return + if parsed.path == "/api/skills": + self._handle_skill_list() + return + if parsed.path == "/api/skills/diff": + self._handle_skill_diff(parsed.query) + return + if parsed.path.startswith(_SKILLS_PREFIX) and parsed.path.endswith("/audit"): + skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] + self._handle_skill_audit_log(skill_id) + return entry = _STATIC_FILES.get(self.path) if entry is None: @@ -183,6 +196,9 @@ class _Handler(BaseHTTPRequestHandler): elif self.path.startswith(_TASKS_PREFIX) and self.path.endswith("/route"): task_id = self.path[len(_TASKS_PREFIX):-len("/route")] self._handle_task_route(task_id) + elif self.path.startswith(_SKILLS_PREFIX) and self.path.endswith("/rollback"): + skill_id = self.path[len(_SKILLS_PREFIX):-len("/rollback")] + self._handle_skill_rollback(skill_id) else: self._send_json(404, {"error": "NOT_FOUND"}) @@ -335,6 +351,51 @@ class _Handler(BaseHTTPRequestHandler): else: self._send_json(200, result) + def _handle_skill_list(self): + with self.server.conn_lock: + result = skill_query.list_skills(self.server.conn) + self._send_json(200, result) + + def _handle_skill_audit_log(self, skill_id): + try: + with self.server.conn_lock: + result = skill_query.get_skill_audit_log(self.server.conn, skill_id) + except skill_query.SkillNotFoundError: + self._send_json(404, {"error": "NOT_FOUND"}) + else: + self._send_json(200, result) + + def _handle_skill_diff(self, query_string): + params = urllib.parse.parse_qs(query_string) + before = params.get("before", [None])[0] + after = params.get("after", [None])[0] + if not before or not after: + self._send_json(400, {"error": "INVALID_INPUT", "detail": "before and after are both required"}) + return + try: + diff_text = skill_query.get_diff(before, after, repo_path=self.server.skill_repo_path) + except subprocess.CalledProcessError as e: + self._send_json(400, {"error": "INVALID_PARAM", "detail": e.stderr}) + else: + self._send_json(200, {"diff_text": diff_text}) + + def _handle_skill_rollback(self, skill_id): + payload, error = self._read_json_body() + if error: + self._send_json(400, error) + return + iteration_id = payload.get("iteration_id") + if not iteration_id: + self._send_json(400, {"error": "INVALID_INPUT", "detail": "iteration_id is required"}) + return + try: + with self.server.conn_lock: + result = skill_update.rollback_skill(self.server.conn, iteration_id, self.server.skill_repo_path) + except skill_update.IterationNotFoundError as e: + self._send_json(404, {"error": "ITERATION_NOT_FOUND", "detail": str(e)}) + else: + self._send_json(200, result) + def _handle_ws_subscribe(self, path, query_string): """c_ws_subscribe (03-C §3.14): WS /api/ws/projects/:project_id. Rejects with a normal HTTP response (no 101) before ever touching @@ -514,16 +575,25 @@ class _Server(ThreadingHTTPServer): self.conn.close() -def create_server(host="127.0.0.1", port=0, db_path=None): +def create_server(host="127.0.0.1", port=0, db_path=None, skill_repo_path=None): """port=0 lets the OS assign a free port -- avoids collisions in tests and lets multiple graphlemon instances run side by side if ever needed. db_path defaults to paths.db_path() (U-003's platform_home, so it never lives inside a target project's own repo); tests pass ":memory:" or a - tempfile so runs don't touch the real developer's ~/.graphlemon/.""" + tempfile so runs don't touch the real developer's ~/.graphlemon/. + + skill_repo_path defaults to skill_query._REPO_ROOT (graphlemon's own + checkout -- Skills genuinely live there, U-018/U-029) but is injectable + for the same reason db_path is: c_skill_rollback does a real `git + commit`, and a test hitting that route with the real default would + write an actual commit into whatever repo this process happens to be + running from. Never exposed to HTTP clients -- only settable here, at + process/test-construction time.""" conn = schema.connect(db_path if db_path is not None else paths.db_path(), check_same_thread=False) _ensure_schema(conn) server = _Server((host, port), _Handler) server.conn = conn server.conn_lock = threading.Lock() + server.skill_repo_path = skill_repo_path if skill_repo_path is not None else skill_query._REPO_ROOT return server diff --git a/src/skill_query.py b/src/skill_query.py new file mode 100644 index 0000000..395e987 --- /dev/null +++ b/src/skill_query.py @@ -0,0 +1,64 @@ +"""c_skill_diff (named only in `sdd-mvcp-contract.json`, prose 03-C never +wrote it up) + list/get read helpers for `v_skill_library`/`v_skill_audit_log` +(02-V §2.9/§2.10). Skills live in graphlemon's own repo, not any target +project's (`src/skill_update.py`'s own docstring: "Skill 库是platform +asset,不是target-project"), so the diff runs against graphlemon's own +checkout by default -- reuses `agent_profile.py`'s own `_REPO_ROOT` pattern +rather than inventing a second way to locate it. +""" + +import os +import subprocess + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def list_skills(conn): + rows = conn.execute( + "SELECT skill_id, owner_org_node, file_path, current_git_commit_sha, source, " + "migrated_from_profile, status FROM skill ORDER BY owner_org_node, skill_id" + ).fetchall() + return [ + { + "skill_id": r[0], "owner_org_node": r[1], "file_path": r[2], "current_git_commit_sha": r[3], + "source": r[4], "migrated_from_profile": r[5], "status": r[6], + } + for r in rows + ] + + +class SkillNotFoundError(Exception): + """NOT_FOUND -- c_skill_get's own skill_id doesn't exist.""" + + +def get_skill_audit_log(conn, skill_id): + """v_skill_audit_log (02-V §2.10): every skill_iteration row for this + skill, oldest first.""" + if conn.execute("SELECT 1 FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() is None: + raise SkillNotFoundError(skill_id) + rows = conn.execute( + "SELECT iteration_id, triggered_by_bug_id, before_git_commit_sha, after_git_commit_sha, " + "change_summary, applied_at, rollback_of_iteration_id FROM skill_iteration " + "WHERE skill_id = ? ORDER BY applied_at", + (skill_id,), + ).fetchall() + return [ + { + "iteration_id": r[0], "triggered_by_bug_id": r[1], "before_git_commit_sha": r[2], + "after_git_commit_sha": r[3], "change_summary": r[4], "applied_at": r[5], + "rollback_of_iteration_id": r[6], + } + for r in rows + ] + + +def get_diff(before, after, repo_path=None): + """c_skill_diff: `git diff ` -- skill_update.py's + _write_and_commit makes every iteration an atomic single-file commit, + so a plain repo-wide diff between the two commits naturally scopes + itself to that one skill file without needing a path filter.""" + result = subprocess.run( + ["git", "diff", before, after], cwd=repo_path or _REPO_ROOT, + capture_output=True, text=True, check=True, + ) + return result.stdout diff --git a/src/test_backend_server.py b/src/test_backend_server.py index 6eb2cba..2db1ce8 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -20,7 +20,7 @@ import time import unittest from unittest.mock import patch -from src import backend_server, execution_log, ws_subscribe +from src import backend_server, execution_log, skill_update, ws_subscribe class ServerStartStopTest(unittest.TestCase): @@ -887,6 +887,106 @@ class ArtifactRouteTest(unittest.TestCase): self.assertEqual(body["error"], "NOT_FOUND") +class SkillRouteTest(unittest.TestCase): + """U-029: c_skill_diff (GET /api/skills/diff) + c_skill_rollback + (POST /api/skills/:skill_id/rollback) + list/get for v_skill_library / + v_skill_audit_log. skill_repo_path points at a throwaway temp repo -- + NEVER the real graphlemon checkout, since c_skill_rollback does a real + `git commit` (see backend_server.create_server's skill_repo_path note).""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + subprocess.run(["git", "init"], cwd=self.tmp.name, check=True, capture_output=True) + self.server = backend_server.create_server(db_path=":memory:", skill_repo_path=self.tmp.name) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.server.conn.execute( + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) " + "VALUES ('skill-1', 'coder', 'skills/skill-1', 'unknown', 'new', 'active')" + ) + self.server.conn.execute( + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, " + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')" + ) + self.server.conn.commit() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def _get(self, path): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("GET", path) + resp = conn.getresponse() + body = json.loads(resp.read()) + conn.close() + return resp.status, body + + def _post(self, path, payload): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("POST", path, body=json.dumps(payload).encode("utf-8")) + resp = conn.getresponse() + body = json.loads(resp.read()) + conn.close() + return resp.status, body + + def test_list_returns_seeded_skill(self): + status, body = self._get("/api/skills") + self.assertEqual(status, 200) + self.assertEqual(len(body), 1) + self.assertEqual(body[0]["skill_id"], "skill-1") + + def test_audit_log_empty_for_skill_with_no_iterations(self): + status, body = self._get("/api/skills/skill-1/audit") + self.assertEqual(status, 200) + self.assertEqual(body, []) + + def test_audit_log_nonexistent_skill_returns_404(self): + status, body = self._get("/api/skills/no-such-skill/audit") + self.assertEqual(status, 404) + + def test_diff_missing_query_params_returns_400(self): + status, body = self._get("/api/skills/diff") + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_INPUT") + + def test_full_update_diff_rollback_cycle(self): + skill_update._write_and_commit(self.tmp.name, "skills/skill-1/SKILL.md", "v1 content", "seed skill") + with self.server.conn_lock: + result = skill_update.update_skill( + self.server.conn, "skill-1", "bug-1", "v2 content", "improve it", self.tmp.name + ) + iteration_id = result["iteration_id"] + + status, body = self._get( + "/api/skills/diff?before=" + self._before_sha(iteration_id) + "&after=" + result["after_git_commit_sha"] + ) + self.assertEqual(status, 200) + self.assertIn("v2 content", body["diff_text"]) + + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": iteration_id}) + self.assertEqual(status, 200) + self.assertIn("new_iteration_id", body) + + def _before_sha(self, iteration_id): + return self.server.conn.execute( + "SELECT before_git_commit_sha FROM skill_iteration WHERE iteration_id = ?", (iteration_id,) + ).fetchone()[0] + + def test_rollback_nonexistent_iteration_returns_404(self): + status, body = self._post("/api/skills/skill-1/rollback", {"iteration_id": "no-such-iteration"}) + self.assertEqual(status, 404) + self.assertEqual(body["error"], "ITERATION_NOT_FOUND") + + def test_rollback_missing_iteration_id_returns_400(self): + status, body = self._post("/api/skills/skill-1/rollback", {}) + self.assertEqual(status, 400) + + class ZeroThirdPartyDependencyTest(unittest.TestCase): """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this doesn't silently rot if someone adds an import later.""" diff --git a/src/test_skill_query.py b/src/test_skill_query.py new file mode 100644 index 0000000..b97a63f --- /dev/null +++ b/src/test_skill_query.py @@ -0,0 +1,105 @@ +"""U-029 tests: c_skill_diff + list/get read helpers for v_skill_library / +v_skill_audit_log. Real sqlite + real git in a throwaway temp repo (never +graphlemon's own -- same reasoning as test_skill_update.py). + +Run: python3 -m unittest src.test_skill_query -v +""" + +import subprocess +import tempfile +import unittest + +from src import schema, skill_query, skill_update + + +def _init_git_repo(path): + subprocess.run(["git", "init"], cwd=path, check=True, capture_output=True) + + +class SkillQueryTestBase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + _init_git_repo(self.tmp.name) + + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + def _insert_skill(self, skill_id, owner_org_node, source="new", migrated_from_profile=None): + self.conn.execute( + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, " + "migrated_from_profile, status) VALUES (?, ?, ?, 'unknown', ?, ?, 'active')", + (skill_id, owner_org_node, "skills/" + skill_id, source, migrated_from_profile), + ) + self.conn.commit() + + +class ListSkillsTest(SkillQueryTestBase): + def test_lists_all_skills(self): + self._insert_skill("s1", "planner") + self._insert_skill("s2", "coder") + result = skill_query.list_skills(self.conn) + self.assertEqual({s["skill_id"] for s in result}, {"s1", "s2"}) + + def test_includes_owner_org_node_and_source(self): + self._insert_skill("s1", "validator", source="migrated_from_hermes", migrated_from_profile="qa") + result = skill_query.list_skills(self.conn) + self.assertEqual(result[0]["owner_org_node"], "validator") + self.assertEqual(result[0]["source"], "migrated_from_hermes") + self.assertEqual(result[0]["migrated_from_profile"], "qa") + + def test_empty_returns_empty_list(self): + self.assertEqual(skill_query.list_skills(self.conn), []) + + +class GetSkillAuditLogTest(SkillQueryTestBase): + def test_nonexistent_skill_raises(self): + with self.assertRaises(skill_query.SkillNotFoundError): + skill_query.get_skill_audit_log(self.conn, "no-such-skill") + + def test_skill_with_no_iterations_returns_empty_list(self): + self._insert_skill("s1", "planner") + self.assertEqual(skill_query.get_skill_audit_log(self.conn, "s1"), []) + + def test_returns_iterations_oldest_first(self): + self._insert_skill("s1", "coder") + self.conn.execute( + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, root_cause_detail, " + "suggested_route, status) VALUES ('bug-1', 'validator', 'x', 'skill_defect', 'd', 'fast_lane', 'routed')" + ) + self.conn.execute( + "INSERT INTO skill_iteration (iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, " + "after_git_commit_sha, change_summary, applied_at) VALUES " + "('it-1', 's1', 'bug-1', 'sha0', 'sha1', 'fix A', '2026-07-01 00:00:00')" + ) + self.conn.execute( + "INSERT INTO skill_iteration (iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, " + "after_git_commit_sha, change_summary, applied_at) VALUES " + "('it-2', 's1', 'bug-1', 'sha1', 'sha2', 'fix B', '2026-07-02 00:00:00')" + ) + self.conn.commit() + result = skill_query.get_skill_audit_log(self.conn, "s1") + self.assertEqual([r["iteration_id"] for r in result], ["it-1", "it-2"]) + self.assertEqual(result[0]["change_summary"], "fix A") + + +class GetDiffTest(SkillQueryTestBase): + def test_diff_between_two_commits_shows_content_change(self): + before_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v1 content\n", "seed") + after_sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "v2 content\n", "update") + diff_text = skill_query.get_diff(before_sha, after_sha, repo_path=self.tmp.name) + self.assertIn("-v1 content", diff_text) + self.assertIn("+v2 content", diff_text) + + def test_identical_commits_produce_empty_diff(self): + sha = skill_update._write_and_commit(self.tmp.name, "skills/s1/SKILL.md", "same\n", "seed") + diff_text = skill_query.get_diff(sha, sha, repo_path=self.tmp.name) + self.assertEqual(diff_text, "") + + +if __name__ == "__main__": + unittest.main()