skill_update.py 6.83 KB
"""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/<skill_id>/` 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}