skill_update.py
6.83 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""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}