gabriel / musehub public
repair_commit_signatures.py python
187 lines 8.2 KB
Raw
sha256:ba5a234585aa1451950c0333b3af2c5a416d38b41233fec5f1eb16efad84bc11 chore: add --offset to repair_commit_signatures.py Sonnet 5 minor ⚠ breaking 19 hours ago
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 subprocess
30 import sys
31 import time
32
33 # Keys present in `muse read --json` output that are never part of a
34 # WireCommit repair payload -- either envelope fields (identical on every
35 # muse command) or read-only diff-summary fields computed from the commit's
36 # relationship to its parent, not stored commit identity.
37 _ENVELOPE_KEYS = frozenset({
38 "muse_version", "schema", "exit_code", "duration_ms", "timestamp", "warnings",
39 })
40 _DIFF_SUMMARY_KEYS = frozenset({
41 "status", "files_added", "files_removed", "files_modified",
42 "dirs_added", "dirs_removed", "total_changes",
43 })
44
45 # Every WireCommit field typed as plain `str = ""` (never `str | None`).
46 # `muse read` reports an unset optional provenance field as null, but
47 # WireCommit's validator rejects None outright for these -- "Input should
48 # be a valid string" -- which is exactly the 422 seen live when the first
49 # version of this list only covered toolchain_id/prompt_hash and missed
50 # agent_id/model_id on human (non-agent) commits. Keep this exhaustive
51 # against musehub/models/wire.py::WireCommit, not a partial guess --
52 # snapshot_id, parent_commit_id, and parent2_commit_id are deliberately
53 # excluded: those ARE `str | None` in WireCommit, so None is the correct,
54 # valid value for them and must not be coerced.
55 _NULLABLE_STRING_FIELDS = (
56 "repo_id", "branch", "message", "committed_at", "author", "sem_ver_bump",
57 "agent_id", "model_id", "toolchain_id", "prompt_hash",
58 "signature", "signer_public_key", "signer_key_id",
59 )
60
61
62 def project_to_wire_commit_fields(muse_read_record: dict) -> dict:
63 """Project a `muse read --json` record to exactly the WireCommit-shaped
64 subset needed for a repair-commit request body.
65
66 Pure function -- no I/O, no network, fully unit-testable.
67 """
68 result = {
69 k: v for k, v in muse_read_record.items()
70 if k not in _ENVELOPE_KEYS and k not in _DIFF_SUMMARY_KEYS
71 }
72 for field in _NULLABLE_STRING_FIELDS:
73 if result.get(field) is None:
74 result[field] = ""
75 return result
76
77
78 def muse_read(repo: str, commit_id: str) -> dict:
79 result = subprocess.run(
80 ["muse", "-C", repo, "read", commit_id, "--json"],
81 capture_output=True, text=True, check=True,
82 )
83 return json.loads(result.stdout)
84
85
86 def muse_verify_failures(repo: str) -> list[dict]:
87 """Return the `failures` list from `muse verify --json` for *repo*."""
88 result = subprocess.run(
89 ["muse", "-C", repo, "verify", "--json"],
90 capture_output=True, text=True,
91 )
92 data = json.loads(result.stdout)
93 return [f for f in data.get("failures", []) if f.get("kind") == "signature"]
94
95
96 def repair_one(url: str, hub: str, payload: dict, *, dry_run: bool) -> tuple[bool, str]:
97 """Call repair-commit for one commit. Returns (ok, message)."""
98 if dry_run:
99 return True, "dry-run, not sent"
100 body = json.dumps({"commit": payload})
101 result = subprocess.run(
102 [
103 "muse", "sign", "request",
104 "--method", "POST",
105 "--url", f"{url.rstrip('/')}/repair-commit",
106 "--hub", hub,
107 "--body", body,
108 "--json",
109 ],
110 capture_output=True, text=True,
111 )
112 if result.returncode != 0:
113 return False, result.stderr.strip() or result.stdout.strip()
114 return True, result.stdout.strip()
115
116
117 def run(
118 repo: str, verify_repo: str, url: str, hub: str, *,
119 dry_run: bool, limit: int | None, offset: int = 0,
120 ) -> None:
121 """*repo* is the trusted, canonical source of correct data (`muse read`).
122 *verify_repo* is a fresh clone reflecting the server's actual (possibly
123 corrupted) state -- `muse verify` must run there, never against *repo*,
124 since the canonical copy is correct by definition and would always
125 report zero failures.
126
127 *offset* lets repeated runs advance through one verify-repo clone's
128 failure list without re-cloning between batches (re-cloning + muse
129 verify takes ~15-25s and isn't needed every batch -- repair-commit is
130 idempotent, so even re-targeting an already-fixed commit from a stale
131 list is harmless, just wasted work). Re-clone and restart from offset=0
132 periodically to pick up the true remaining count and catch anything a
133 concurrent write raced past this verify-repo's snapshot."""
134 print(f"Scanning {verify_repo} (server-state clone) for signature-verification failures…")
135 failures = muse_verify_failures(verify_repo)
136 print(f"Found {len(failures)} signature failures.")
137 if offset:
138 failures = failures[offset:]
139 print(f"Skipping first {offset} (already processed in a prior batch against this clone).")
140 if limit is not None:
141 failures = failures[:limit]
142 print(f"Limiting to {limit} for this run.")
143
144 repaired = 0
145 errors = 0
146 t0 = time.monotonic()
147
148 for i, failure in enumerate(failures, 1):
149 commit_id = failure["id"]
150 try:
151 record = muse_read(repo, commit_id)
152 except subprocess.CalledProcessError as exc:
153 print(f" ✗ {commit_id[:20]}… muse read failed: {exc.stderr.strip()}", file=sys.stderr)
154 errors += 1
155 continue
156
157 payload = project_to_wire_commit_fields(record)
158 ok, message = repair_one(url, hub, payload, dry_run=dry_run)
159 if ok:
160 repaired += 1
161 tag = "[DRY RUN] " if dry_run else ""
162 print(f" {tag}✓ [{i}/{len(failures)}] {commit_id[:20]}…")
163 else:
164 errors += 1
165 print(f" ✗ [{i}/{len(failures)}] {commit_id[:20]}… {message}", file=sys.stderr)
166
167 elapsed = time.monotonic() - t0
168 tag = "[DRY RUN] " if dry_run else ""
169 print(f"\n{tag}Done in {elapsed:.1f}s: repaired={repaired} errors={errors}")
170
171
172 def main() -> None:
173 parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
174 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")
175 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")
176 parser.add_argument("--url", required=True, help="Full repo URL on the hub (e.g. https://staging.musehub.ai/gabriel/musehub)")
177 parser.add_argument("--hub", required=True, help="Hub base URL, determines which signing key to use")
178 parser.add_argument("--dry-run", action="store_true")
179 parser.add_argument("--limit", type=int, default=None, help="Repair at most N commits this run")
180 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)")
181 args = parser.parse_args()
182
183 run(args.repo, args.verify_repo, args.url, args.hub, dry_run=args.dry_run, limit=args.limit, offset=args.offset)
184
185
186 if __name__ == "__main__":
187 main()
File History 1 commit
sha256:ba5a234585aa1451950c0333b3af2c5a416d38b41233fec5f1eb16efad84bc11 chore: add --offset to repair_commit_signatures.py Sonnet 5 minor ⚠ 19 hours ago