repair_commit_signatures.py
python
sha256:195787044debd61f93cc31b92754ee0f78e1b6f66757ee07ea5058ed5ce5197d
fix: dedup fetch.mpack.prebuild against running jobs, not j…
Sonnet 5
minor
⚠ breaking
17 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(repo: str, verify_repo: str, url: str, hub: str, *, dry_run: bool, limit: int | None) -> None: |
| 118 | """*repo* is the trusted, canonical source of correct data (`muse read`). |
| 119 | *verify_repo* is a fresh clone reflecting the server's actual (possibly |
| 120 | corrupted) state -- `muse verify` must run there, never against *repo*, |
| 121 | since the canonical copy is correct by definition and would always |
| 122 | report zero failures.""" |
| 123 | print(f"Scanning {verify_repo} (server-state clone) for signature-verification failures…") |
| 124 | failures = muse_verify_failures(verify_repo) |
| 125 | print(f"Found {len(failures)} signature failures.") |
| 126 | if limit is not None: |
| 127 | failures = failures[:limit] |
| 128 | print(f"Limiting to first {limit} for this run.") |
| 129 | |
| 130 | repaired = 0 |
| 131 | errors = 0 |
| 132 | t0 = time.monotonic() |
| 133 | |
| 134 | for i, failure in enumerate(failures, 1): |
| 135 | commit_id = failure["id"] |
| 136 | try: |
| 137 | record = muse_read(repo, commit_id) |
| 138 | except subprocess.CalledProcessError as exc: |
| 139 | print(f" ✗ {commit_id[:20]}… muse read failed: {exc.stderr.strip()}", file=sys.stderr) |
| 140 | errors += 1 |
| 141 | continue |
| 142 | |
| 143 | payload = project_to_wire_commit_fields(record) |
| 144 | ok, message = repair_one(url, hub, payload, dry_run=dry_run) |
| 145 | if ok: |
| 146 | repaired += 1 |
| 147 | tag = "[DRY RUN] " if dry_run else "" |
| 148 | print(f" {tag}✓ [{i}/{len(failures)}] {commit_id[:20]}…") |
| 149 | else: |
| 150 | errors += 1 |
| 151 | print(f" ✗ [{i}/{len(failures)}] {commit_id[:20]}… {message}", file=sys.stderr) |
| 152 | |
| 153 | elapsed = time.monotonic() - t0 |
| 154 | tag = "[DRY RUN] " if dry_run else "" |
| 155 | print(f"\n{tag}Done in {elapsed:.1f}s: repaired={repaired} errors={errors}") |
| 156 | |
| 157 | |
| 158 | def main() -> None: |
| 159 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 160 | 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") |
| 161 | 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") |
| 162 | parser.add_argument("--url", required=True, help="Full repo URL on the hub (e.g. https://staging.musehub.ai/gabriel/musehub)") |
| 163 | parser.add_argument("--hub", required=True, help="Hub base URL, determines which signing key to use") |
| 164 | parser.add_argument("--dry-run", action="store_true") |
| 165 | parser.add_argument("--limit", type=int, default=None, help="Repair at most N commits this run") |
| 166 | args = parser.parse_args() |
| 167 | |
| 168 | run(args.repo, args.verify_repo, args.url, args.hub, dry_run=args.dry_run, limit=args.limit) |
| 169 | |
| 170 | |
| 171 | if __name__ == "__main__": |
| 172 | main() |
File History
1 commit
sha256:195787044debd61f93cc31b92754ee0f78e1b6f66757ee07ea5058ed5ce5197d
fix: dedup fetch.mpack.prebuild against running jobs, not j…
Sonnet 5
minor
⚠
17 hours ago