"""c_skill_update (03-C §3.8) / c_skill_rollback (03-C §3.9): Skill self-iteration (architecture doc §8 decision 1) -- a Validator-triggered rewrite takes effect immediately, but every change is its own Git commit in graphlemon's own repo (§13.1: Skill 库 is a platform asset, not a target-project one -- same reasoning as agents/, see src/agent_profile.py), so anything can be inspected/rolled back later. **Resolves U-015's deferred skill_defect gap** now that this Unit exists to close it: 03-C §3.10 says root_cause_category=skill_defect "必须同时创建 skill_iteration_suggestion", but skill_iteration.before/after_git_commit_sha are NOT NULL and can't be populated until an actual rewrite happens -- which is THIS controller, not create_bug_record. 04-P §4.3's own transition table shows the real sequence: bug_recorded is a state reached by c_bug_record_create alone, and skill_iteration_triggered (-> c_skill_update) is a *separate, later* transition triggered off root_cause_category= skill_defect. Read that way, 03-C §3.10's "必须同时创建" is a same-flow policy requirement (a skill_defect bug must eventually get an iteration), not a same-API-call one. update_skill() closes the loop by writing the new iteration_id back onto the originating bug_record once it exists. src/bug_record.py's accepted root_cause_category set has been widened to include skill_defect accordingly. **Known gap, not this Unit's job**: no skill content has actually been migrated from the Hermes profiles yet (05 §5.1 names the skill IDs; src/agent_profile.py's FRONTMATTER `skills:` lists references them by name) -- `skills//` doesn't exist as real files in this repo. That's a separate migration concern this Unit doesn't attempt; tests here seed a placeholder skill file, same as U-010/011 seeded placeholder artifacts for units whose real upstream producer doesn't exist yet. """ import os import subprocess import uuid _GIT_IDENTITY = ["-c", "user.name=graphlemon", "-c", "user.email=graphlemon@localhost"] class MissingTriggerRefError(Exception): """MISSING_TRIGGER_REF -- 03-C §3.8: triggered_by_bug_id is required, no source-less Skill changes allowed.""" class IterationNotFoundError(Exception): """ITERATION_NOT_FOUND -- 03-C §3.9.""" def _skill_file_path(file_path): return file_path if file_path.endswith(".md") else os.path.join(file_path, "SKILL.md") def _current_commit_sha(repo_path): return subprocess.run( ["git", "rev-parse", "HEAD"], cwd=repo_path, capture_output=True, text=True, check=True ).stdout.strip() def _write_and_commit(repo_path, rel_path, content, message): abs_path = os.path.join(repo_path, rel_path) os.makedirs(os.path.dirname(abs_path), exist_ok=True) with open(abs_path, "w", encoding="utf-8") as f: f.write(content) subprocess.run(["git", "add", rel_path], cwd=repo_path, check=True, capture_output=True, text=True) subprocess.run( ["git"] + _GIT_IDENTITY + ["commit", "-m", message], cwd=repo_path, check=True, capture_output=True, text=True ) return _current_commit_sha(repo_path) def update_skill(conn, skill_id, triggered_by_bug_id, new_content, change_summary, repo_path): """`repo_path` is required, not defaulted to graphlemon's own checkout -- an accidental missing override must fail loudly, not silently commit real Skill-library changes into whichever repo happens to be this module's own location. transaction (03-C §3.8: "文件写入 + Git commit 需原子完成,commit 失败则 整体回滚,不留半成品 Skill 文件"): the DB insert only happens after the git commit succeeds, so a git failure naturally leaves no DB row -- no separate rollback machinery needed since nothing was written yet.""" if not triggered_by_bug_id: raise MissingTriggerRefError("triggered_by_bug_id is required for c_skill_update") row = conn.execute("SELECT file_path FROM skill WHERE skill_id = ?", (skill_id,)).fetchone() if row is None: raise ValueError("no such skill: " + skill_id) rel_path = _skill_file_path(row[0]) before_sha = _current_commit_sha(repo_path) after_sha = _write_and_commit( repo_path, rel_path, new_content, "graphlemon: skill {} iteration, triggered by bug {}".format(skill_id, triggered_by_bug_id), ) iteration_id = str(uuid.uuid4()) conn.execute( "INSERT INTO skill_iteration " "(iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, after_git_commit_sha, change_summary) " "VALUES (?, ?, ?, ?, ?, ?)", (iteration_id, skill_id, triggered_by_bug_id, before_sha, after_sha, change_summary), ) conn.execute("UPDATE skill SET current_git_commit_sha = ? WHERE skill_id = ?", (after_sha, skill_id)) conn.execute( "UPDATE bug_record SET skill_iteration_suggestion_id = ? " "WHERE bug_id = ? AND skill_iteration_suggestion_id IS NULL", (iteration_id, triggered_by_bug_id), ) conn.commit() return {"iteration_id": iteration_id, "after_git_commit_sha": after_sha} def rollback_skill(conn, iteration_id, repo_path): """`repo_path` is required, not defaulted -- see update_skill's note. "回滚到该次迭代之前的版本" -- restores the content from before_git_commit_sha and commits *that* as a brand-new iteration (03-C §3.9: "回滚本身也生成一条新的 skill_iteration 记录"), not a history rewrite. """ row = conn.execute( "SELECT skill_id, before_git_commit_sha, triggered_by_bug_id FROM skill_iteration WHERE iteration_id = ?", (iteration_id,), ).fetchone() if row is None: raise IterationNotFoundError("no such skill_iteration: " + iteration_id) skill_id, before_sha, original_bug_id = row rel_path = _skill_file_path(conn.execute("SELECT file_path FROM skill WHERE skill_id = ?", (skill_id,)).fetchone()[0]) prior_content = subprocess.run( ["git", "show", "{}:{}".format(before_sha, rel_path)], cwd=repo_path, capture_output=True, text=True, check=True, ).stdout current_sha = _current_commit_sha(repo_path) new_after_sha = _write_and_commit( repo_path, rel_path, prior_content, "graphlemon: rollback skill {} iteration {}".format(skill_id, iteration_id) ) new_iteration_id = str(uuid.uuid4()) conn.execute( "INSERT INTO skill_iteration " "(iteration_id, skill_id, triggered_by_bug_id, before_git_commit_sha, after_git_commit_sha, " "change_summary, rollback_of_iteration_id) VALUES (?, ?, ?, ?, ?, ?, ?)", ( new_iteration_id, skill_id, original_bug_id, current_sha, new_after_sha, "rollback of iteration " + iteration_id, iteration_id, ), ) conn.execute("UPDATE skill SET current_git_commit_sha = ? WHERE skill_id = ?", (new_after_sha, skill_id)) conn.commit() return {"new_iteration_id": new_iteration_id}