gabriel / musehub public

repair_commit_signatures.py file-level

at sha256:1 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:4 feat: muse#53 item 4 server side — musehub_symlog_entries Server-side … · gabriel · Oct 22, 2026
1 """Repair commits whose server-stored signature/signer_public_key no longer
2 verify, by replaying the correct values from local Muse history.
3
4 Root cause (muse#108 wire-protocol investigation, continued from the
5 structured_delta clone-loss fix): the rc10 object-store migration stamped a
6 spurious signer_public_key *and signature* onto historical commit rows
7 without recomputing commit_id (see wire_repair_commit's docstring in
8 musehub_wire_push.py). The repair mechanism (POST /{owner}/{slug}/repair-commit)
9 already exists and already works correctly -- MWP-8 shipped its cache
10 invalidation. What never happened is actually running it against the full
11 set of affected rows; only two commits in gabriel/muse were ever manually
12 repaired. This script does that, for any repo, driven by `muse verify`'s own
13 failure list.
14
15 Usage (run from the host, not inside Docker -- needs the local muse CLI and
16 a registered identity with write/admin access to the target repo):
17 python3 deploy/repair_commit_signatures.py \\
18 --repo ~/ecosystem/musehub --url https://staging.musehub.ai/gabriel/musehub \\
19 --hub https://staging.musehub.ai --dry-run
20
21 python3 deploy/repair_commit_signatures.py \\
22 --repo ~/ecosystem/musehub --url https://staging.musehub.ai/gabriel/musehub \\
23 --hub https://staging.musehub.ai
24 """
25 from __future__ import annotations
26
27 import argparse
28 import json
29 import os
30 import subprocess
31 import sys
32 import tempfile
33 import time
34
35 # Keys present in `muse read --json` output that are never part of a
36 # WireCommit repair payload -- either envelope fields (identical on every
37 # muse command) or read-only diff-summary fields computed from the commit's
38 # relationship to its parent, not stored commit identity.
39 _ENVELOPE_KEYS = frozenset({
40 "muse_version", "schema", "exit_code", "duration_ms", "timestamp", "warnings",
41 })
42 _DIFF_SUMMARY_KEYS = frozenset({
43 "status", "files_added", "files_removed", "files_modified",
44 "dirs_added", "dirs_removed", "total_changes",
45 })
46
47 # Every WireCommit field typed as plain `str = ""` (never `str | None`).
48 # `muse read` reports an unset optional provenance field as null, but
49 # WireCommit's validator rejects None outright for these -- "Input should
50 # be a valid string" -- which is exactly the 422 seen live when the first
51 # version of this list only covered toolchain_id/prompt_hash and missed
52 # agent_id/model_id on human (non-agent) commits. Keep this exhaustive
53 # against musehub/models/wire.py::WireCommit, not a partial guess --
54 # snapshot_id, parent_commit_id, and parent2_commit_id are deliberately
55 # excluded: those ARE `str | None` in WireCommit, so None is the correct,
56 # valid value for them and must not be coerced.
57 _NULLABLE_STRING_FIELDS = (
58 "repo_id", "branch", "message", "committed_at", "author", "sem_ver_bump",
59 "agent_id", "model_id", "toolchain_id", "prompt_hash",
60 "signature", "signer_public_key", "signer_key_id",
61 )
62
63
64 def project_to_wire_commit_fields(muse_read_record: dict) -> dict:
65 """Project a `muse read --json` record to exactly the WireCommit-shaped
66 subset needed for a repair-commit request body.
67
68 Pure function -- no I/O, no network, fully unit-testable.
69 """
70 result = {
71 k: v for k, v in muse_read_record.items()
72 if k not in _ENVELOPE_KEYS and k not in _DIFF_SUMMARY_KEYS
73 }
74 for field in _NULLABLE_STRING_FIELDS:
75 if result.get(field) is None:
76 result[field] = ""
77 return result
78
79
80 def muse_read(repo: str, commit_id: str) -> dict:
81 result = subprocess.run(
82 ["muse", "-C", repo, "read", commit_id, "--json"],
83 capture_output=True, text=True, check=True,
84 )
85 return json.loads(result.stdout)
86
87
88 def muse_verify_failures(repo: str) -> list[dict]:
89 """Return the `failures` list from `muse verify --json` for *repo*."""
90 result = subprocess.run(
91 ["muse", "-C", repo, "verify", "--json"],
92 capture_output=True, text=True,
93 )
94 data = json.loads(result.stdout)
95 return [f for f in data.get("failures", []) if f.get("kind") == "signature"]
96
97
98 def repair_one(url: str, hub: str, payload: dict, *, dry_run: bool) -> tuple[bool, str]:
99 """Call repair-commit for one commit. Returns (ok, message).
100
101 Writes the body to a temp file and uses --body-file rather than --body
102 <inline-string> -- a large structured_delta can make the JSON body big
103 enough to exceed the OS argv size limit (confirmed live: "OSError:
104 [Errno 7] Argument list too long" crashed the whole script outright,
105 silently skipping every remaining commit in that batch with no retry,
106 since the OSError happens at exec() before subprocess.run even returns
107 a CompletedProcess for the normal returncode-check path to catch).
108 """
109 if dry_run:
110 return True, "dry-run, not sent"
111 body = json.dumps({"commit": payload})
112 with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
113 f.write(body)
114 body_path = f.name
115 try:
116 result = subprocess.run(
117 [
118 "muse", "sign", "request",
119 "--method", "POST",
120 "--url", f"{url.rstrip('/')}/repair-commit",
121 "--hub", hub,
122 "--body-file", body_path,
123 "--json",
124 ],
125 capture_output=True, text=True,
126 )
127 finally:
128 os.unlink(body_path)
129 if result.returncode != 0:
130 return False, result.stderr.strip() or result.stdout.strip()
131 return True, result.stdout.strip()
132
133
134 def run(
135 repo: str, verify_repo: str, url: str, hub: str, *,
136 dry_run: bool, limit: int | None, offset: int = 0,
137 ) -> None:
138 """*repo* is the trusted, canonical source of correct data (`muse read`).
139 *verify_repo* is a fresh clone reflecting the server's actual (possibly
140 corrupted) state -- `muse verify` must run there, never against *repo*,
141 since the canonical copy is correct by definition and would always
142 report zero failures.
143
144 *offset* lets repeated runs advance through one verify-repo clone's
145 failure list without re-cloning between batches (re-cloning + muse
146 verify takes ~15-25s and isn't needed every batch -- repair-commit is
147 idempotent, so even re-targeting an already-fixed commit from a stale
148 list is harmless, just wasted work). Re-clone and restart from offset=0
149 periodically to pick up the true remaining count and catch anything a
150 concurrent write raced past this verify-repo's snapshot."""
151 print(f"Scanning {verify_repo} (server-state clone) for signature-verification failures…")
152 failures = muse_verify_failures(verify_repo)
153 print(f"Found {len(failures)} signature failures.")
154 if offset:
155 failures = failures[offset:]
156 print(f"Skipping first {offset} (already processed in a prior batch against this clone).")
157 if limit is not None:
158 failures = failures[:limit]
159 print(f"Limiting to {limit} for this run.")
160
161 repaired = 0
162 errors = 0
163 t0 = time.monotonic()
164
165 for i, failure in enumerate(failures, 1):
166 commit_id = failure["id"]
167 try:
168 record = muse_read(repo, commit_id)
169 except subprocess.CalledProcessError as exc:
170 print(f" ✗ {commit_id[:20]}… muse read failed: {exc.stderr.strip()}", file=sys.stderr)
171 errors += 1
172 continue
173
174 payload = project_to_wire_commit_fields(record)
175 try:
176 ok, message = repair_one(url, hub, payload, dry_run=dry_run)
177 except OSError as exc:
178 # A single oversized or otherwise unrepairable commit must never
179 # abort the rest of the batch -- confirmed live: one commit's
180 # "Argument list too long" (now fixed via --body-file, but kept
181 # as a defensive catch-all) crashed the whole script, silently
182 # skipping every remaining commit in that batch with zero retry.
183 ok, message = False, f"{type(exc).__name__}: {exc}"
184 if ok:
185 repaired += 1
186 tag = "[DRY RUN] " if dry_run else ""
187 print(f" {tag}✓ [{i}/{len(failures)}] {commit_id[:20]}…")
188 else:
189 errors += 1
190 print(f" ✗ [{i}/{len(failures)}] {commit_id[:20]}… {message}", file=sys.stderr)
191
192 elapsed = time.monotonic() - t0
193 tag = "[DRY RUN] " if dry_run else ""
194 print(f"\n{tag}Done in {elapsed:.1f}s: repaired={repaired} errors={errors}")
195
196
197 def main() -> None:
198 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
199 parser.add_argument("--repo", required=True, help="Path to the trusted, canonical local Muse repo (e.g. ~/ecosystem/musehub) -- source of correct data for muse read")
200 parser.add_argument("--verify-repo", required=True, help="Path to a FRESH CLONE reflecting the server's actual state -- muse verify runs here, never against --repo")
201 parser.add_argument("--url", required=True, help="Full repo URL on the hub (e.g. https://staging.musehub.ai/gabriel/musehub)")
202 parser.add_argument("--hub", required=True, help="Hub base URL, determines which signing key to use")
203 parser.add_argument("--dry-run", action="store_true")
204 parser.add_argument("--limit", type=int, default=None, help="Repair at most N commits this run")
205 parser.add_argument("--offset", type=int, default=0, help="Skip the first N failures from --verify-repo's list (advance through one clone across batches without re-cloning)")
206 args = parser.parse_args()
207
208 run(args.repo, args.verify_repo, args.url, args.hub, dry_run=args.dry_run, limit=args.limit, offset=args.offset)
209
210
211 if __name__ == "__main__":
212 main()