From 2b84874515bedc34535d9d5ed7a0c23f99cec5ca Mon Sep 17 00:00:00 2001 From: "m2 (AI Agent)" Date: Thu, 2 Jul 2026 04:12:46 +0200 Subject: [PATCH] wedge(T034): snapshot commits to registry audit/ via Forgejo contents API Co-Authored-By: Claude Fable 5 --- ledger/src/m2_ledger/api.py | 11 ++++++--- ledger/src/m2_ledger/snapshot.py | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 ledger/src/m2_ledger/snapshot.py diff --git a/ledger/src/m2_ledger/api.py b/ledger/src/m2_ledger/api.py index b22014e..19da1c4 100644 --- a/ledger/src/m2_ledger/api.py +++ b/ledger/src/m2_ledger/api.py @@ -143,6 +143,11 @@ def snapshot(conn=Depends(get_conn), _key: str = Depends(require_admin)): date = datetime.now(timezone.utc).strftime("%Y-%m-%d") path = f"audit/{date}.json" content = json.dumps(balances, sort_keys=True, indent=2) - # TODO(T034): commit `content` to `path` in m2/market-registry via Forgejo API - # (REGISTRY_REPO_URL + FORGEJO_TOKEN); this endpoint only produces the JSON. - return {"path": path, "content": content} + from m2_ledger.snapshot import SnapshotError, commit_snapshot + + try: + result = commit_snapshot(path, content) + except SnapshotError as exc: + # Snapshot content is still returned so an operator can commit by hand. + return {"path": path, "content": content, "committed": False, "error": str(exc)} + return {"path": path, "content": content, "committed": True, "commit": result["commit"]} diff --git a/ledger/src/m2_ledger/snapshot.py b/ledger/src/m2_ledger/snapshot.py new file mode 100644 index 0000000..680bceb --- /dev/null +++ b/ledger/src/m2_ledger/snapshot.py @@ -0,0 +1,42 @@ +"""Daily balance snapshot → committed to the registry's audit/ (constitution V, T034). + +The commit uses the Forgejo contents API: create the file, or update it (same-day +re-runs) with the current blob sha. Config via env: FORGEJO_URL, FORGEJO_TOKEN, +REGISTRY_REPO (default m2/market-registry), FORGEJO_USER (default m2). +""" + +from __future__ import annotations + +import base64 +import os + +import httpx + + +class SnapshotError(RuntimeError): + pass + + +def commit_snapshot(path: str, content: str) -> dict: + forgejo_url = os.environ.get("FORGEJO_URL", "https://git.machinemachine.ai").rstrip("/") + token = os.environ.get("FORGEJO_TOKEN", "") + repo = os.environ.get("REGISTRY_REPO", "m2/market-registry") + user = os.environ.get("FORGEJO_USER", "m2") + if not token: + raise SnapshotError("FORGEJO_TOKEN not set — snapshot produced but not committed") + + api = f"{forgejo_url}/api/v1/repos/{repo}/contents/{path}" + b64 = base64.b64encode(content.encode()).decode() + message = f"audit: ledger balance snapshot {path.rsplit('/', 1)[-1]}" + + with httpx.Client(auth=(user, token), timeout=15.0) as client: + existing = client.get(api) + if existing.status_code == 200: + sha = existing.json().get("sha") + resp = client.put(api, json={"content": b64, "message": message, "sha": sha}) + else: + resp = client.post(api, json={"content": b64, "message": message}) + if resp.status_code not in (200, 201): + raise SnapshotError(f"registry commit failed: {resp.status_code} {resp.text[:200]}") + data = resp.json() + return {"path": path, "commit": (data.get("commit") or {}).get("sha")}