transport.py
python
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
92 days ago
| 1 | """Muse transport layer — presign + mpack remote communication. |
| 2 | |
| 3 | The :class:`MuseTransport` Protocol defines the interface between the Muse CLI |
| 4 | and a remote host. Use :func:`make_transport` instead of constructing |
| 5 | :class:`HttpTransport` directly — it always returns an ``HttpTransport``. |
| 6 | |
| 7 | Push flow |
| 8 | --------- |
| 9 | 1. ``POST {url}/push/mpack-presign`` — get presigned PUT URL for the mpack. |
| 10 | 2. ``PUT <presigned_url>`` — upload mpack to R2/MinIO directly. |
| 11 | 3. ``POST {url}/push/unpack-mpack`` — notify server to index the uploaded mpack. |
| 12 | |
| 13 | Fetch flow |
| 14 | ---------- |
| 15 | 1. ``POST {url}/fetch`` — receive presigned GET URL for the fetch mpack. |
| 16 | 2. ``GET <mpack_url>`` — download mpack from R2/MinIO directly. |
| 17 | 3. Verify ``sha256(bytes) == mpack_id`` before applying. |
| 18 | |
| 19 | Authentication |
| 20 | -------------- |
| 21 | All endpoints accept ``Authorization: MSign handle="<handle>" ts=<unix> |
| 22 | sig="<b64url>"`` for Ed25519 per-request signing. The signing identity is |
| 23 | never written to any log line. |
| 24 | |
| 25 | Error codes |
| 26 | ----------- |
| 27 | 401 Unauthorized — invalid or missing signature |
| 28 | 404 Not found — repo does not exist on the remote |
| 29 | 409 Conflict — push rejected (non-fast-forward without ``--force``) |
| 30 | 5xx Server error |
| 31 | |
| 32 | Security model |
| 33 | -------------- |
| 34 | - Refuses all HTTP redirects (prevents credential leakage to other hosts). |
| 35 | - Rejects non-HTTPS URLs when signing credentials are present. |
| 36 | - Caps response bodies at ``MAX_RESPONSE_BYTES`` (64 MiB) to prevent OOM. |
| 37 | """ |
| 38 | |
| 39 | import collections.abc |
| 40 | import contextlib |
| 41 | import json |
| 42 | import logging |
| 43 | import os |
| 44 | import pathlib |
| 45 | import signal |
| 46 | import threading |
| 47 | import time as _time_mod |
| 48 | import urllib.parse |
| 49 | from typing import TYPE_CHECKING, NamedTuple, Protocol, TypedDict |
| 50 | |
| 51 | if TYPE_CHECKING: |
| 52 | import ssl |
| 53 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 54 | |
| 55 | class SigningIdentity(NamedTuple): |
| 56 | """Ed25519 signing identity for MSign request authentication.""" |
| 57 | |
| 58 | handle: str # hub handle, e.g. "gabriel" |
| 59 | private_key: Ed25519PrivateKey |
| 60 | |
| 61 | import msgpack |
| 62 | |
| 63 | from muse.core.mpack import ( |
| 64 | BlobPayload, |
| 65 | MPack, |
| 66 | PushResult, |
| 67 | RemoteInfo, |
| 68 | WireTag, |
| 69 | ) |
| 70 | from muse.core.semver import ChangelogEntry, SemVerTag |
| 71 | from muse.core.io import safe_unpackb |
| 72 | from muse.core.types import ( |
| 73 | BranchHeads, |
| 74 | Manifest, |
| 75 | Metadata, |
| 76 | MsgpackDict, |
| 77 | ) |
| 78 | from muse.core.commits import CommitDict |
| 79 | from muse.core.snapshots import SnapshotDict |
| 80 | from muse.core.releases import ReleaseDict |
| 81 | from muse.core.validation import ( |
| 82 | MAX_RESPONSE_BYTES, |
| 83 | sanitize_display, |
| 84 | ) |
| 85 | from muse.core.types import SemVerBump, blob_id |
| 86 | |
| 87 | logger = logging.getLogger(__name__) |
| 88 | |
| 89 | import ssl as _ssl_mod |
| 90 | import urllib.error |
| 91 | import urllib.request |
| 92 | |
| 93 | _TIMEOUT_SECONDS = 300 |
| 94 | |
| 95 | # Presigned mpack PUT has its own timeout budget — independent of the global |
| 96 | # API timeout — because it moves megabytes over user uplinks, not just JSON. |
| 97 | # |
| 98 | # Floor / max are intentionally generous: a 35 MB upload at 90 KB/s (seen in |
| 99 | # issue #40) takes ~390s, well above the old 300s ceiling. Max prevents an |
| 100 | # absurdly large value from masking a truly hung connection. |
| 101 | _PUT_TIMEOUT_FLOOR: int = 1800 # 30 min minimum |
| 102 | _PUT_TIMEOUT_MAX: int = 7200 # 2 hr hard cap |
| 103 | _PUT_MIN_BYTES_PER_SEC: int = 50_000 # 50 KB/s — floor throughput assumption |
| 104 | |
| 105 | |
| 106 | def _mpack_put_timeout(size_bytes: int) -> int: |
| 107 | """Return the PUT timeout (seconds) for a mpack of ``size_bytes``. |
| 108 | |
| 109 | Scaled by size at a 50 KB/s floor, clamped to [_PUT_TIMEOUT_FLOOR, |
| 110 | _PUT_TIMEOUT_MAX]. MUSE_PUSH_TIMEOUT_SECONDS env var overrides everything. |
| 111 | """ |
| 112 | import os as _os |
| 113 | env = _os.environ.get("MUSE_PUSH_TIMEOUT_SECONDS", "") |
| 114 | if env.strip(): |
| 115 | try: |
| 116 | return int(env.strip()) |
| 117 | except ValueError: |
| 118 | pass |
| 119 | scaled = size_bytes // _PUT_MIN_BYTES_PER_SEC |
| 120 | return max(_PUT_TIMEOUT_FLOOR, min(scaled, _PUT_TIMEOUT_MAX)) |
| 121 | |
| 122 | def _mkcert_ca() -> "pathlib.Path | None": |
| 123 | """Return the mkcert root CA path, or None if mkcert is not installed. |
| 124 | |
| 125 | Tries ``mkcert -CAROOT`` first (cross-platform), then falls back to the |
| 126 | well-known platform paths. |
| 127 | """ |
| 128 | import subprocess |
| 129 | try: |
| 130 | r = subprocess.run(["mkcert", "-CAROOT"], capture_output=True, text=True, timeout=3) |
| 131 | if r.returncode == 0: |
| 132 | ca = pathlib.Path(r.stdout.strip()) / "rootCA.pem" |
| 133 | if ca.exists(): |
| 134 | return ca |
| 135 | except (FileNotFoundError, subprocess.TimeoutExpired): |
| 136 | pass |
| 137 | # Platform fallbacks |
| 138 | candidates = [ |
| 139 | pathlib.Path.home() / "Library" / "Application Support" / "mkcert" / "rootCA.pem", # macOS |
| 140 | pathlib.Path.home() / ".local" / "share" / "mkcert" / "rootCA.pem", # Linux |
| 141 | ] |
| 142 | for p in candidates: |
| 143 | if p.exists(): |
| 144 | return p |
| 145 | return None |
| 146 | |
| 147 | _default_ssl_ctx: "_ssl_mod.SSLContext | None" = None |
| 148 | _localhost_ssl_ctx: "_ssl_mod.SSLContext | None" = None |
| 149 | |
| 150 | def _make_ssl_ctx(url: str) -> "_ssl_mod.SSLContext": |
| 151 | """Return an SSLContext appropriate for *url*, cached after first use. |
| 152 | |
| 153 | - localhost HTTPS → context loaded from the mkcert root CA |
| 154 | - everything else → default system CA context |
| 155 | |
| 156 | Both branches are memoized process-wide: ``create_default_context()`` |
| 157 | and (for localhost) shelling out to ``mkcert -CAROOT`` are non-trivial |
| 158 | work with no per-call variance for a fixed CA source, so repeating them |
| 159 | on every request (a real cost measured at 500 sequential calls) is pure |
| 160 | waste. Safe to share — an ``SSLContext`` has no per-request state. |
| 161 | """ |
| 162 | global _default_ssl_ctx, _localhost_ssl_ctx |
| 163 | if url.startswith("https://localhost") or url.startswith("https://127.0.0.1"): |
| 164 | if _localhost_ssl_ctx is None: |
| 165 | ca = _mkcert_ca() |
| 166 | _localhost_ssl_ctx = ( |
| 167 | _ssl_mod.create_default_context(cafile=str(ca)) if ca is not None |
| 168 | else _ssl_mod.create_default_context() |
| 169 | ) |
| 170 | return _localhost_ssl_ctx |
| 171 | if _default_ssl_ctx is None: |
| 172 | _default_ssl_ctx = _ssl_mod.create_default_context() |
| 173 | return _default_ssl_ctx |
| 174 | |
| 175 | class _Request(NamedTuple): |
| 176 | """Minimal HTTP request value passed between ``_build_request`` and ``_execute``.""" |
| 177 | |
| 178 | url: str |
| 179 | method: str |
| 180 | headers: dict[str, str] |
| 181 | data: bytes | None = None |
| 182 | |
| 183 | # Recursive type alias for msgpack-serializable values. |
| 184 | # Covers every type that msgpack can encode/decode natively — no base64, |
| 185 | # no `object`, no `Any`. Python 3.12+ `type` statement creates a proper |
| 186 | # TypeAlias that mypy treats as a first-class recursive type. |
| 187 | type _MsgVal = ( |
| 188 | str | int | float | bool | bytes | None |
| 189 | | list[_MsgVal] |
| 190 | | dict[str, _MsgVal] |
| 191 | ) |
| 192 | type _MsgDict = dict[str, _MsgVal] # top-level msgpack response dict |
| 193 | type _WireVal = _MsgVal # wire-protocol value (same shape) |
| 194 | type _WireDict = dict[str, _WireVal] # wire-protocol response dict |
| 195 | type _HeadersDict = dict[str, str] # HTTP request/response headers |
| 196 | |
| 197 | # Maximum number of objects to include in a single push batch. |
| 198 | # Must stay strictly below the server's MAX_OBJECTS_PER_PUSH limit (1 000). |
| 199 | # Large batches are split into sequential POSTs so each request body stays |
| 200 | # well within Cloudflare's body size limits. |
| 201 | CHUNK_OBJECTS: int = 500 |
| 202 | |
| 203 | # Maximum number of commits to include in a single POST /push call. |
| 204 | # Large histories are split into sequential chunked pushes so that each |
| 205 | # request stays well within Cloudflare's 100 MB upload limit. |
| 206 | # Each intermediate chunk uses force=True; the final chunk uses the caller's |
| 207 | # force flag. |
| 208 | # |
| 209 | # 500 commits/chunk means ~2 round-trips for a 1000-commit history vs the |
| 210 | # previous 5 at 200/chunk. The server now uses bulk INSERT ON CONFLICT DO |
| 211 | # NOTHING so per-chunk processing time is proportional to commit count, not |
| 212 | # the number of individual INSERT round-trips. |
| 213 | CHUNK_COMMITS: int = 500 |
| 214 | |
| 215 | |
| 216 | # --------------------------------------------------------------------------- |
| 217 | # Result TypedDict |
| 218 | # --------------------------------------------------------------------------- |
| 219 | |
| 220 | class FetchMPackResult(TypedDict): |
| 221 | """Result from ``fetch_mpack()`` — Phase 2 presigned-URL fetch. |
| 222 | |
| 223 | ``blobs`` — raw blob dicts from the mpack; passed to ``apply_mpack``. |
| 224 | ``blobs_received`` — count of blobs in the mpack. |
| 225 | """ |
| 226 | |
| 227 | repo_id: str |
| 228 | domain: str |
| 229 | default_branch: str |
| 230 | branch_heads: BranchHeads |
| 231 | commits: list[CommitDict] |
| 232 | snapshots: list[SnapshotDict] |
| 233 | blobs: list[dict] |
| 234 | blobs_received: int |
| 235 | shallow_commits: list[str] |
| 236 | |
| 237 | class _UnpackMPackResult(TypedDict): |
| 238 | """Response from POST /push/unpack-mpack.""" |
| 239 | job_id: str |
| 240 | head: str |
| 241 | branch: str |
| 242 | blobs_in_mpack: int |
| 243 | commits_in_mpack: int |
| 244 | |
| 245 | |
| 246 | class _PresignResult(TypedDict): |
| 247 | """Response from POST /push/mpack-presign.""" |
| 248 | upload_url: str |
| 249 | mpack_key: str |
| 250 | |
| 251 | |
| 252 | @contextlib.contextmanager |
| 253 | def _ignore_sigpipe() -> collections.abc.Generator[None, None, None]: |
| 254 | """Temporarily ignore SIGPIPE during socket I/O (main thread only). |
| 255 | |
| 256 | ``muse/cli/app.py`` sets ``SIGPIPE = SIG_DFL`` at startup so that piping |
| 257 | ``muse`` output to ``head``/``grep``/``jq`` exits cleanly. But ``SIG_DFL`` |
| 258 | also kills the process when a large HTTP request body is still in-flight |
| 259 | and the remote closes the connection early (auth failure, 409 conflict, |
| 260 | server crash, …). Without this guard the push command dies with exit code |
| 261 | 141 instead of raising ``TransportError``. |
| 262 | |
| 263 | Signal handlers may only be changed from the main interpreter thread. |
| 264 | Worker threads launched by ``ThreadPoolExecutor`` (used for parallel object |
| 265 | uploads) are already safe: Python blocks ``SIGPIPE`` in non-main threads, |
| 266 | so broken-socket writes raise ``BrokenPipeError`` rather than killing the |
| 267 | process. This context manager is therefore a no-op in non-main threads. |
| 268 | |
| 269 | In the main thread the context manager saves the current SIGPIPE |
| 270 | disposition, sets it to ``SIG_IGN`` for the duration of the network call |
| 271 | (so that broken-pipe conditions surface as ``BrokenPipeError`` → |
| 272 | ``TransportError``), then restores the original disposition on exit. On |
| 273 | platforms without ``SIGPIPE`` (Windows) this is a no-op. |
| 274 | """ |
| 275 | if not hasattr(signal, "SIGPIPE") or not threading.current_thread() is threading.main_thread(): |
| 276 | yield |
| 277 | return |
| 278 | old_handler: signal.Handlers = signal.signal(signal.SIGPIPE, signal.SIG_IGN) |
| 279 | try: |
| 280 | yield |
| 281 | finally: |
| 282 | signal.signal(signal.SIGPIPE, old_handler) |
| 283 | |
| 284 | # --------------------------------------------------------------------------- |
| 285 | # Exception |
| 286 | # --------------------------------------------------------------------------- |
| 287 | |
| 288 | class TransportError(Exception): |
| 289 | """Raised when the remote returns a non-2xx response or is unreachable. |
| 290 | |
| 291 | Attributes: |
| 292 | status_code: HTTP status code (e.g. ``401``, ``404``, ``409``, ``500``). |
| 293 | ``0`` for network-level failures (DNS, connection refused). |
| 294 | retry_after: Seconds the caller should wait before retrying, parsed from |
| 295 | the server's ``Retry-After`` header. ``None`` when absent. |
| 296 | """ |
| 297 | |
| 298 | def __init__( |
| 299 | self, |
| 300 | message: str, |
| 301 | status_code: int, |
| 302 | *, |
| 303 | retry_after: "int | None" = None, |
| 304 | ) -> None: |
| 305 | super().__init__(message) |
| 306 | self.status_code = status_code |
| 307 | self.retry_after = retry_after |
| 308 | |
| 309 | # --------------------------------------------------------------------------- |
| 310 | # Protocol — the seam between CLI commands and the transport implementation |
| 311 | # --------------------------------------------------------------------------- |
| 312 | |
| 313 | class MuseTransport(Protocol): |
| 314 | """Protocol for Muse remote transport implementations. |
| 315 | |
| 316 | All methods are synchronous — the Muse CLI is synchronous by design. |
| 317 | """ |
| 318 | |
| 319 | def fetch_remote_info(self, url: str, signing: SigningIdentity | None) -> RemoteInfo: |
| 320 | """Return repository metadata from ``GET {url}/refs``. |
| 321 | |
| 322 | Args: |
| 323 | url: Remote repository URL. |
| 324 | token: Ed25519 signing identity, or ``None`` for public repos. |
| 325 | |
| 326 | Raises: |
| 327 | :class:`TransportError` on HTTP 4xx/5xx or network failure. |
| 328 | """ |
| 329 | ... |
| 330 | |
| 331 | def fetch_mpack( |
| 332 | self, |
| 333 | url: str, |
| 334 | signing: "SigningIdentity | None", |
| 335 | want: "list[str]", |
| 336 | have: "list[str]", |
| 337 | *, |
| 338 | on_object: "collections.abc.Callable[[BlobPayload], None] | None" = None, |
| 339 | ttl_seconds: int = 3600, |
| 340 | ) -> "FetchMPackResult": |
| 341 | """Fetch the delta as a single content-addressed MPack. |
| 342 | |
| 343 | POSTs to ``{url}/fetch/mpack``. When the server returns |
| 344 | ``presign=False`` the mpack bytes are inline; when ``presign=True`` |
| 345 | the client GETs the mpack from the presigned URL. |
| 346 | |
| 347 | Either way ``sha256(mpack_bytes) == mpack_id`` is verified before |
| 348 | the mpack is unpacked — one hash covers all contents. |
| 349 | |
| 350 | Args: |
| 351 | url: Canonical repo URL. |
| 352 | signing: Ed25519 signing identity; ``None`` for public repos. |
| 353 | want: Commit IDs the client wants. |
| 354 | have: Commit IDs already present locally (ancestry cut). |
| 355 | on_object: Callback invoked for each object in the mpack. |
| 356 | ttl_seconds: Presigned URL TTL forwarded to the server. |
| 357 | |
| 358 | Raises: |
| 359 | :class:`TransportError` on non-200 response, integrity failure, |
| 360 | or network error. |
| 361 | """ |
| 362 | ... |
| 363 | |
| 364 | def push_tags( |
| 365 | self, |
| 366 | url: str, |
| 367 | signing: SigningIdentity | None, |
| 368 | tags: list[WireTag], |
| 369 | ) -> int: |
| 370 | """Push local tags to the remote via ``POST {url}/tags``. |
| 371 | |
| 372 | Tags are immutable once created on the remote — the server skips any |
| 373 | it already holds. Returns the number of tags newly stored. |
| 374 | |
| 375 | Args: |
| 376 | url: Remote repository URL. |
| 377 | token: Ed25519 signing identity, or ``None``. |
| 378 | tags: Tags to push. |
| 379 | |
| 380 | Raises: |
| 381 | :class:`TransportError` on HTTP 4xx/5xx or network failure. |
| 382 | """ |
| 383 | ... |
| 384 | |
| 385 | def create_release( |
| 386 | self, |
| 387 | url: str, |
| 388 | signing: SigningIdentity | None, |
| 389 | release: ReleaseDict, |
| 390 | ) -> str: |
| 391 | """Create a release on the remote via ``POST {url}/releases``. |
| 392 | |
| 393 | Returns the ``release_id`` assigned by the server. |
| 394 | |
| 395 | Args: |
| 396 | url: Remote repository URL. |
| 397 | token: Ed25519 signing identity. |
| 398 | release: Fully-populated :class:`~muse.core.store.ReleaseDict`. |
| 399 | |
| 400 | Raises: |
| 401 | :class:`TransportError` on HTTP 4xx/5xx or network failure. |
| 402 | """ |
| 403 | ... |
| 404 | |
| 405 | def list_releases_remote( |
| 406 | self, |
| 407 | url: str, |
| 408 | signing: SigningIdentity | None, |
| 409 | channel: str | None = None, |
| 410 | include_drafts: bool = False, |
| 411 | ) -> list[ReleaseDict]: |
| 412 | """Fetch releases from the remote via ``GET {url}/releases``. |
| 413 | |
| 414 | Args: |
| 415 | url: Remote repository URL. |
| 416 | token: Ed25519 signing identity, or ``None``. |
| 417 | channel: Filter by release channel; ``None`` returns all. |
| 418 | include_drafts: Include draft releases when ``True``. |
| 419 | |
| 420 | Raises: |
| 421 | :class:`TransportError` on HTTP 4xx/5xx or network failure. |
| 422 | """ |
| 423 | ... |
| 424 | |
| 425 | def delete_release_remote( |
| 426 | self, |
| 427 | url: str, |
| 428 | signing: SigningIdentity | None, |
| 429 | tag: str, |
| 430 | ) -> None: |
| 431 | """Retract a release from the remote via ``DELETE {url}/releases/{tag}``. |
| 432 | |
| 433 | Removes only the named label from the remote registry. The underlying |
| 434 | commit and snapshot objects are **not** affected. |
| 435 | |
| 436 | Args: |
| 437 | url: Remote repository URL. |
| 438 | token: Ed25519 signing identity (owner credentials required). |
| 439 | tag: Semver tag of the release to retract (e.g. ``"v1.2.0"``). |
| 440 | |
| 441 | Raises: |
| 442 | :class:`TransportError` on HTTP 4xx/5xx, network failure, or if |
| 443 | the release does not exist on the remote. |
| 444 | """ |
| 445 | ... |
| 446 | |
| 447 | def delete_branch_remote( |
| 448 | self, |
| 449 | url: str, |
| 450 | signing: SigningIdentity | None, |
| 451 | branch: str, |
| 452 | ) -> None: |
| 453 | """Delete a branch on the remote via ``DELETE {url}/branches/{branch}``. |
| 454 | |
| 455 | Equivalent to ``git push origin --delete <branch>``. The branch ref is |
| 456 | removed from the server; commits and objects are unaffected. |
| 457 | |
| 458 | Args: |
| 459 | url: Remote repository URL. |
| 460 | token: Ed25519 signing identity (owner credentials required). |
| 461 | branch: Branch name to delete (e.g. ``"feat/my-thing"``). |
| 462 | |
| 463 | Raises: |
| 464 | :class:`TransportError` on HTTP 4xx/5xx, network failure, or if |
| 465 | the branch does not exist on the remote. |
| 466 | """ |
| 467 | ... |
| 468 | |
| 469 | def push_version_tags( |
| 470 | self, |
| 471 | url: str, |
| 472 | signing: "SigningIdentity | None", |
| 473 | tags: "list[dict]", |
| 474 | *, |
| 475 | force: bool = False, |
| 476 | ) -> "dict": |
| 477 | """Push local version tags to the remote via ``POST {url}/version-tags``. |
| 478 | |
| 479 | Tags are semantic version pointers (e.g. ``v1.0.0``). When *force* is |
| 480 | ``False`` the server rejects a push that would overwrite an existing tag |
| 481 | pointing to a different commit (HTTP 409). *force* instructs the server |
| 482 | to overwrite regardless. |
| 483 | |
| 484 | Returns ``{"stored": N, "skipped": M}`` where *stored* is the number of |
| 485 | newly written tags and *skipped* is the number already present with the |
| 486 | same commit target. |
| 487 | |
| 488 | Raises: |
| 489 | :class:`TransportError` on HTTP 4xx/5xx or network failure. |
| 490 | """ |
| 491 | ... |
| 492 | |
| 493 | def fetch_version_tags( |
| 494 | self, |
| 495 | url: str, |
| 496 | signing: "SigningIdentity | None", |
| 497 | ) -> "dict": |
| 498 | """Fetch all version tags from the remote via ``GET {url}/version-tags``. |
| 499 | |
| 500 | Returns ``{"tags": [<VersionTagDict>, ...]}`` — the full list of version |
| 501 | tags stored on the remote. Empty list when no tags exist. |
| 502 | |
| 503 | Raises: |
| 504 | :class:`TransportError` on HTTP 4xx/5xx or network failure. |
| 505 | """ |
| 506 | ... |
| 507 | |
| 508 | # --------------------------------------------------------------------------- |
| 509 | # HTTP/1.1 implementation (stdlib, zero extra dependencies) |
| 510 | # --------------------------------------------------------------------------- |
| 511 | |
| 512 | def _http_error_message_from_bytes(status_code: int, body: bytes) -> str: |
| 513 | """Build a safe error message from a status code and raw response bytes.""" |
| 514 | if status_code == 401: |
| 515 | return "Authentication failed (HTTP 401). Run 'muse auth register'." |
| 516 | raw = body[:300].decode("utf-8", errors="replace") |
| 517 | safe = sanitize_display(raw[:200]) |
| 518 | if safe: |
| 519 | return f"HTTP {status_code}: {safe}" |
| 520 | return f"HTTP {status_code}" |
| 521 | |
| 522 | def _http_error_message(err: "urllib.error.HTTPError") -> str: |
| 523 | """Return a safe, credential-free error message for an HTTP error. |
| 524 | |
| 525 | HTTP 401 — NEVER includes the server body (which may echo the signing |
| 526 | credential). Returns a fixed generic string. |
| 527 | |
| 528 | All other codes — include the body, capped at 200 chars, with all C0/C1 |
| 529 | control characters stripped to prevent ANSI/OSC terminal injection. |
| 530 | """ |
| 531 | if err.code == 401: |
| 532 | return "Authentication failed (HTTP 401). Run 'muse auth register'." |
| 533 | try: |
| 534 | raw_body = err.read(300).decode("utf-8", errors="replace") |
| 535 | except Exception: |
| 536 | raw_body = "" |
| 537 | safe_body = sanitize_display(raw_body[:200]) |
| 538 | if safe_body: |
| 539 | return f"HTTP {err.code}: {safe_body}" |
| 540 | return f"HTTP {err.code}" |
| 541 | |
| 542 | class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): |
| 543 | """urllib redirect handler that raises immediately on any 3xx response.""" |
| 544 | def redirect_request( # type: ignore[override] |
| 545 | self, |
| 546 | req: urllib.request.Request, |
| 547 | fp: "Any", |
| 548 | code: int, |
| 549 | msg: str, |
| 550 | headers: "Any", |
| 551 | newurl: str, |
| 552 | ) -> None: |
| 553 | raise urllib.error.HTTPError(newurl, code, f"Redirect to {newurl}", headers, fp) |
| 554 | |
| 555 | |
| 556 | def _open_url(req: "urllib.request.Request") -> "Any": |
| 557 | """Thin seam around ``urllib.request.urlopen`` — patchable in tests.""" |
| 558 | return urllib.request.urlopen(req) # noqa: S310 |
| 559 | |
| 560 | |
| 561 | def _parse_retry_after(headers: "Any") -> "int | None": |
| 562 | """Parse an RFC 7231 Retry-After header value into whole seconds. |
| 563 | |
| 564 | Accepts the delta-seconds form only (``"30"``, ``"60"``). The HTTP-date |
| 565 | form is intentionally treated as ``None`` — the server only emits |
| 566 | delta-seconds and brittle date parsing is not worth the risk. |
| 567 | |
| 568 | Returns ``None`` on a missing, non-integer, or negative value. Never |
| 569 | raises. |
| 570 | """ |
| 571 | if headers is None: |
| 572 | return None |
| 573 | raw = headers.get("Retry-After") |
| 574 | if raw is None: |
| 575 | return None |
| 576 | try: |
| 577 | secs = int(str(raw).strip()) |
| 578 | except (TypeError, ValueError): |
| 579 | return None |
| 580 | return secs if secs >= 0 else None |
| 581 | |
| 582 | |
| 583 | # --------------------------------------------------------------------------- |
| 584 | # Retry primitives — bounded poll that honors Retry-After (MWP-5, RC-5) |
| 585 | # --------------------------------------------------------------------------- |
| 586 | |
| 587 | _FETCH_RETRY_BUDGET_DEFAULT_S: float = 120.0 |
| 588 | _RETRY_AFTER_CEILING_S: float = 60.0 # never sleep longer than this per attempt |
| 589 | _RETRY_AFTER_FLOOR_S: float = 1.0 # never busy-spin if server says 0 |
| 590 | _RETRY_DEFAULT_BACKOFF_S: float = 5.0 # fallback when 503 carries no Retry-After |
| 591 | |
| 592 | |
| 593 | def _sleep(seconds: float) -> None: |
| 594 | """Thin wrapper over time.sleep — patched in tests to record wait schedule.""" |
| 595 | _time_mod.sleep(seconds) |
| 596 | |
| 597 | |
| 598 | def _next_wait(retry_after: "int | None") -> float: |
| 599 | """Clamp the server's Retry-After value to a sane per-attempt sleep window. |
| 600 | |
| 601 | Floors at ``_RETRY_AFTER_FLOOR_S`` (avoids busy-spin on 0 or missing |
| 602 | header) and ceilings at ``_RETRY_AFTER_CEILING_S`` (prevents runaway |
| 603 | waits if the server ever emits an unusually large value). |
| 604 | """ |
| 605 | base = float(retry_after) if retry_after is not None else _RETRY_DEFAULT_BACKOFF_S |
| 606 | return min(max(base, _RETRY_AFTER_FLOOR_S), _RETRY_AFTER_CEILING_S) |
| 607 | |
| 608 | |
| 609 | def _resolve_retry_budget(explicit: "float | None") -> float: |
| 610 | """Return the effective fetch-retry wall-clock budget in seconds. |
| 611 | |
| 612 | Priority: explicit arg > ``MUSE_FETCH_RETRY_BUDGET_S`` env var > module |
| 613 | default (120 s). Negative values are clamped to 0 (== no retry). |
| 614 | A junk env value (non-numeric) is silently ignored and the default is used. |
| 615 | """ |
| 616 | if explicit is not None: |
| 617 | return max(0.0, explicit) |
| 618 | env = os.environ.get("MUSE_FETCH_RETRY_BUDGET_S") |
| 619 | if env: |
| 620 | try: |
| 621 | return max(0.0, float(env)) |
| 622 | except ValueError: |
| 623 | pass |
| 624 | return _FETCH_RETRY_BUDGET_DEFAULT_S |
| 625 | |
| 626 | |
| 627 | def _urllib_do( |
| 628 | method: str, |
| 629 | url: str, |
| 630 | headers: "dict[str, str]", |
| 631 | data: "bytes | None" = None, |
| 632 | *, |
| 633 | timeout: int = _TIMEOUT_SECONDS, |
| 634 | follow_redirects: bool = False, |
| 635 | max_bytes: "int | None" = MAX_RESPONSE_BYTES, |
| 636 | ) -> bytes: |
| 637 | """Execute an HTTP request via urllib and return the response body. |
| 638 | |
| 639 | Raises :class:`TransportError` on non-2xx or any network/SSL error. |
| 640 | Response body is capped at ``max_bytes`` (default: ``MAX_RESPONSE_BYTES``). |
| 641 | Pass ``max_bytes=None`` for uncapped streaming reads (e.g. mpack downloads). |
| 642 | """ |
| 643 | ssl_ctx = _make_ssl_ctx(url) |
| 644 | opener_handlers: list[urllib.request.BaseHandler] = [urllib.request.HTTPSHandler(context=ssl_ctx)] |
| 645 | if not follow_redirects: |
| 646 | opener_handlers.append(_NoRedirectHandler()) |
| 647 | opener = urllib.request.build_opener(*opener_handlers) |
| 648 | req = urllib.request.Request(url, data=data, headers=headers, method=method) |
| 649 | try: |
| 650 | with opener.open(req, timeout=timeout) as resp: |
| 651 | if max_bytes is None: |
| 652 | import io as _io |
| 653 | buf = _io.BytesIO() |
| 654 | _CHUNK = 1 << 20 # 1 MiB chunks |
| 655 | while True: |
| 656 | chunk = resp.read(_CHUNK) |
| 657 | if not chunk: |
| 658 | break |
| 659 | buf.write(chunk) |
| 660 | body = buf.getvalue() |
| 661 | else: |
| 662 | body = resp.read(max_bytes + 1) |
| 663 | if len(body) > max_bytes: |
| 664 | raise TransportError( |
| 665 | f"Response body exceeds the {max_bytes // (1024 * 1024)} MiB cap.", 0 |
| 666 | ) |
| 667 | return body |
| 668 | except urllib.error.HTTPError as err: |
| 669 | raise TransportError( |
| 670 | _http_error_message(err), err.code, |
| 671 | retry_after=_parse_retry_after(err.headers), |
| 672 | ) from err |
| 673 | except urllib.error.URLError as err: |
| 674 | raise TransportError(sanitize_display(str(err.reason)), 0) from err |
| 675 | except TransportError: |
| 676 | raise |
| 677 | except Exception as exc: |
| 678 | raise TransportError(sanitize_display(str(exc)), 0) from exc |
| 679 | |
| 680 | class HttpTransport: |
| 681 | """HTTPS transport using urllib. |
| 682 | |
| 683 | One short-lived connection per call. Signing identity values are |
| 684 | **never** written to any log line. |
| 685 | """ |
| 686 | |
| 687 | def _build_request( |
| 688 | self, |
| 689 | method: str, |
| 690 | url: str, |
| 691 | signing: SigningIdentity | None, |
| 692 | body_bytes: bytes | None = None, |
| 693 | content_type: str = "application/x-msgpack", |
| 694 | extra_headers: dict[str, str] | None = None, |
| 695 | ) -> _Request: |
| 696 | _parsed = urllib.parse.urlparse(url) |
| 697 | _is_loopback = _parsed.hostname in { |
| 698 | "localhost", "127.0.0.1", "::1", "host.docker.internal", |
| 699 | } |
| 700 | if signing and _parsed.scheme != "https" and not _is_loopback: |
| 701 | raise TransportError( |
| 702 | f"Refusing to send credentials to a non-HTTPS URL: {url!r}. " |
| 703 | "Ensure the remote URL uses https://.", |
| 704 | 0, |
| 705 | ) |
| 706 | |
| 707 | # TOFU hub certificate fingerprint pinning — only for signed requests. |
| 708 | if not _is_loopback and signing is not None: |
| 709 | try: |
| 710 | from muse.core.hub_trust import check_and_pin |
| 711 | base_url = f"{_parsed.scheme}://{_parsed.netloc}" |
| 712 | check_and_pin(base_url) |
| 713 | except Exception as _hub_exc: # noqa: BLE001 |
| 714 | from muse.core.errors import HubFingerprintMismatchError |
| 715 | if isinstance(_hub_exc, HubFingerprintMismatchError): |
| 716 | raise TransportError(str(_hub_exc), 0) from _hub_exc |
| 717 | logger.warning("⚠️ Hub trust check failed: %s", _hub_exc) |
| 718 | |
| 719 | headers: _HeadersDict = { |
| 720 | "Accept": "application/x-msgpack, application/json", |
| 721 | } |
| 722 | if body_bytes is not None: |
| 723 | headers["Content-Type"] = content_type |
| 724 | if extra_headers: |
| 725 | headers.update(extra_headers) |
| 726 | if signing: |
| 727 | from muse.core.msign import build_msign_header |
| 728 | headers["Authorization"] = build_msign_header(signing, method, url, body_bytes) |
| 729 | return _Request(url=url, method=method, headers=headers, data=body_bytes) |
| 730 | |
| 731 | @staticmethod |
| 732 | def _decode(raw: bytes) -> _MsgDict: |
| 733 | """Decode a msgpack server response into a plain dict. |
| 734 | |
| 735 | Raises :class:`TransportError` if the payload is not valid msgpack. |
| 736 | """ |
| 737 | if not raw: |
| 738 | return {} |
| 739 | try: |
| 740 | # MAX_RESPONSE_BYTES already caps the network read — safe_unpackb |
| 741 | # adds per-field limits as a second layer against billion-laughs payloads. |
| 742 | # allow_binary=True because pack/mpack responses carry raw blob content. |
| 743 | result: _MsgVal = safe_unpackb(raw, context="server response", allow_binary=True) |
| 744 | except Exception as exc: |
| 745 | raise TransportError(f"Server returned invalid msgpack: {exc}", 0) from exc |
| 746 | if not isinstance(result, dict): |
| 747 | return {} |
| 748 | return result |
| 749 | |
| 750 | def _execute(self, req: "_Request | urllib.request.Request") -> bytes: |
| 751 | """Send *req* and return raw response bytes. |
| 752 | |
| 753 | Accepts either an internal ``_Request`` or a ``urllib.request.Request``. |
| 754 | Both paths use urllib via ``_urllib_do`` / ``_execute_fetch``. |
| 755 | |
| 756 | Raises: |
| 757 | :class:`TransportError` on non-2xx HTTP or any network error. |
| 758 | """ |
| 759 | if isinstance(req, urllib.request.Request): |
| 760 | return self._execute_fetch(req) |
| 761 | with _ignore_sigpipe(): |
| 762 | return _urllib_do(req.method, req.url, req.headers, req.data) |
| 763 | |
| 764 | def _execute_fetch(self, req: "urllib.request.Request") -> bytes: |
| 765 | """Send *req* via urllib (HTTP/1.1) and return raw response bytes. |
| 766 | |
| 767 | Uses ``_open_url`` as the network seam — patchable in tests. |
| 768 | |
| 769 | Raises: |
| 770 | :class:`TransportError` on non-2xx HTTP or any network error. |
| 771 | """ |
| 772 | try: |
| 773 | with _open_url(req) as resp: |
| 774 | body_bytes = resp.read(MAX_RESPONSE_BYTES + 1) |
| 775 | if len(body_bytes) > MAX_RESPONSE_BYTES: |
| 776 | raise TransportError( |
| 777 | f"Response body exceeds the {MAX_RESPONSE_BYTES // (1024 * 1024)} MiB cap.", |
| 778 | 0, |
| 779 | ) |
| 780 | return body_bytes |
| 781 | except urllib.error.HTTPError as err: |
| 782 | raise TransportError( |
| 783 | _http_error_message(err), err.code, |
| 784 | retry_after=_parse_retry_after(err.headers), |
| 785 | ) from err |
| 786 | except urllib.error.URLError as err: |
| 787 | raise TransportError(sanitize_display(str(err.reason)), 0) from err |
| 788 | except TransportError: |
| 789 | raise |
| 790 | except Exception as exc: |
| 791 | raise TransportError(sanitize_display(str(exc)), 0) from exc |
| 792 | |
| 793 | def hub_json( |
| 794 | self, |
| 795 | method: str, |
| 796 | url: str, |
| 797 | signing: "SigningIdentity | None", |
| 798 | body: "dict[str, object] | None" = None, |
| 799 | ) -> "dict[str, object]": |
| 800 | """Make an authenticated JSON request and return the parsed response dict. |
| 801 | |
| 802 | Uses urllib with the mkcert SSL context for localhost URLs — the same |
| 803 | path as all other transport calls. Raises :class:`TransportError` on |
| 804 | any HTTP or network error. |
| 805 | |
| 806 | Args: |
| 807 | method: HTTP verb (``GET``, ``POST``, ``PATCH``, ``DELETE``). |
| 808 | url: Full URL including scheme and path. |
| 809 | signing: MSign identity, or ``None`` for unauthenticated calls. |
| 810 | body: Optional JSON-serializable dict sent as the request body. |
| 811 | """ |
| 812 | import json as _json_mod |
| 813 | body_bytes = _json_mod.dumps(body).encode() if body is not None else None |
| 814 | req = self._build_request( |
| 815 | method, url, signing, |
| 816 | body_bytes=body_bytes, |
| 817 | content_type="application/json", |
| 818 | extra_headers={"Accept": "application/json"}, |
| 819 | ) |
| 820 | raw = self._execute(req) |
| 821 | try: |
| 822 | result = _json_mod.loads(raw.decode("utf-8")) |
| 823 | except Exception as exc: |
| 824 | raise TransportError(f"Server returned invalid JSON: {exc}", 0) from exc |
| 825 | if not isinstance(result, dict): |
| 826 | return {} |
| 827 | return result |
| 828 | |
| 829 | def hub_bytes( |
| 830 | self, |
| 831 | url: str, |
| 832 | signing: "SigningIdentity | None", |
| 833 | ) -> bytes: |
| 834 | """Download raw bytes from *url* with optional MSign auth. |
| 835 | |
| 836 | Uses urllib with the mkcert SSL context for localhost URLs. |
| 837 | Raises :class:`TransportError` on any HTTP or network error. |
| 838 | |
| 839 | Args: |
| 840 | url: Full URL including scheme and path. |
| 841 | signing: MSign identity, or ``None`` for unauthenticated calls. |
| 842 | """ |
| 843 | req = self._build_request( |
| 844 | "GET", url, signing, |
| 845 | extra_headers={"Accept": "*/*"}, |
| 846 | ) |
| 847 | return self._execute(req) |
| 848 | |
| 849 | def fetch_remote_info(self, url: str, signing: SigningIdentity | None) -> RemoteInfo: |
| 850 | """Fetch repository metadata from ``GET {url}/refs``.""" |
| 851 | endpoint = f"{url.rstrip('/')}/refs" |
| 852 | logger.debug("transport: GET %s", endpoint) |
| 853 | req = self._build_request("GET", endpoint, signing) |
| 854 | raw = self._execute(req) |
| 855 | return _parse_remote_info(raw) |
| 856 | |
| 857 | def push_mpack_put(self, upload_url: str, mpack_bytes: bytes, mpack_key: str = "") -> None: |
| 858 | """PUT mpack_bytes directly to a presigned MinIO URL (Step 2). |
| 859 | |
| 860 | No MuseHub API involvement. No Authorization header. The presigned |
| 861 | URL is the credential. Raises :class:`TransportError` on non-2xx. |
| 862 | """ |
| 863 | import sys as _sys |
| 864 | from muse.core.types import blob_id as _blob_id_put |
| 865 | pre_put_hash = _blob_id_put(mpack_bytes) |
| 866 | print(f"[PUSH step 6] pre-PUT integrity check:", file=_sys.stderr) |
| 867 | print(f"[PUSH step 6] len(mpack_bytes) = {len(mpack_bytes)}", file=_sys.stderr) |
| 868 | print(f"[PUSH step 6] blob_id(mpack_bytes) = {pre_put_hash}", file=_sys.stderr) |
| 869 | if mpack_key: |
| 870 | match = pre_put_hash == mpack_key |
| 871 | print(f"[PUSH step 6] mpack_key = {mpack_key}", file=_sys.stderr) |
| 872 | print(f"[PUSH step 6] match = {match} {'✓' if match else '✗ MISMATCH'}", file=_sys.stderr) |
| 873 | body = _urllib_do( |
| 874 | "PUT", upload_url, {"Content-Type": "application/x-muse-pack"}, mpack_bytes, |
| 875 | follow_redirects=False, |
| 876 | timeout=_mpack_put_timeout(len(mpack_bytes)), |
| 877 | ) |
| 878 | print(f"[PUSH step 6] PUT → HTTP 200 response_len={len(body)}", file=_sys.stderr) |
| 879 | |
| 880 | def push_mpack_unpack( |
| 881 | self, |
| 882 | url: str, |
| 883 | signing: "SigningIdentity | None", |
| 884 | mpack_key: str, |
| 885 | *, |
| 886 | branch: str = "main", |
| 887 | head: str = "", |
| 888 | commits_count: int = 0, |
| 889 | blobs_count: int = 0, |
| 890 | force: bool = False, |
| 891 | ) -> _UnpackMPackResult: |
| 892 | """POST /push/unpack-mpack — notify server to index the uploaded mpack (Step 3). |
| 893 | |
| 894 | Returns ``{"job_id": str, "head": str, "branch": str, |
| 895 | "blobs_in_mpack": int, "commits_in_mpack": int}``. |
| 896 | Raises :class:`TransportError` on non-2xx or missing job_id. |
| 897 | """ |
| 898 | from muse.core.mpack import build_unpack_payload |
| 899 | endpoint = f"{url.rstrip('/')}/push/unpack-mpack" |
| 900 | payload = build_unpack_payload( |
| 901 | mpack_key, branch=branch, head=head, |
| 902 | commits_count=commits_count, blobs_count=blobs_count, force=force, |
| 903 | ) |
| 904 | body_bytes = msgpack.packb(payload, use_bin_type=True) |
| 905 | req = self._build_request("POST", endpoint, signing, body_bytes) |
| 906 | raw = _urllib_do("POST", endpoint, dict(req.headers), body_bytes) |
| 907 | data = self._decode(raw) |
| 908 | return { |
| 909 | "job_id": str(data.get("job_id") or ""), |
| 910 | "head": str(data.get("head") or head), |
| 911 | "branch": str(data.get("branch") or branch), |
| 912 | "blobs_in_mpack": int(data.get("blobs_in_mpack") or 0), |
| 913 | "commits_in_mpack": int(data.get("commits_in_mpack") or 0), |
| 914 | } |
| 915 | |
| 916 | def push_mpack_presign( |
| 917 | self, |
| 918 | url: str, |
| 919 | signing: "SigningIdentity | None", |
| 920 | mpack_bytes: bytes, |
| 921 | ttl_seconds: int = 3600, |
| 922 | ) -> _PresignResult: |
| 923 | """POST /push/mpack-presign — get a presigned PUT URL for the mpack. |
| 924 | |
| 925 | Returns ``{"upload_url": str, "mpack_key": str}``. |
| 926 | Raises :class:`TransportError` on non-2xx or a missing upload_url. |
| 927 | """ |
| 928 | from muse.core.mpack import build_presign_payload |
| 929 | endpoint = f"{url.rstrip('/')}/push/mpack-presign" |
| 930 | payload = build_presign_payload(mpack_bytes) |
| 931 | body_bytes = msgpack.packb(payload, use_bin_type=True) |
| 932 | req = self._build_request("POST", endpoint, signing, body_bytes) |
| 933 | raw = _urllib_do("POST", endpoint, dict(req.headers), body_bytes) |
| 934 | data = self._decode(raw) |
| 935 | upload_url = str(data.get("upload_url") or "") |
| 936 | if not upload_url: |
| 937 | raise TransportError("push/mpack-presign: server response missing upload_url", 0) |
| 938 | return {"upload_url": upload_url, "mpack_key": str(data.get("mpack_key") or payload["mpack_key"])} |
| 939 | |
| 940 | def _fetch_mpack_once( |
| 941 | self, |
| 942 | url: str, |
| 943 | signing: "SigningIdentity | None", |
| 944 | want: "list[str]", |
| 945 | have: "list[str]", |
| 946 | *, |
| 947 | ttl_seconds: int = 3600, |
| 948 | ) -> "FetchMPackResult": |
| 949 | """Single-attempt fetch — no retry logic. Called by fetch_mpack.""" |
| 950 | endpoint = f"{url.rstrip('/')}/fetch/mpack" |
| 951 | body_bytes = msgpack.packb( |
| 952 | {"want": want, "have": have, "ttl_seconds": ttl_seconds}, |
| 953 | use_bin_type=True, |
| 954 | ) |
| 955 | req = self._build_request("POST", endpoint, signing, body_bytes) |
| 956 | |
| 957 | import sys as _sys |
| 958 | t0 = _time_mod.monotonic() |
| 959 | print(f"[transport] POST {endpoint} want={len(want)} have={len(have)}", file=_sys.stderr, flush=True) |
| 960 | try: |
| 961 | raw_post = _urllib_do("POST", endpoint, dict(req.headers), body_bytes, follow_redirects=True) |
| 962 | t_post = _time_mod.monotonic() |
| 963 | print( |
| 964 | f"[transport] POST done status=200 body={len(raw_post)}B t={int((t_post - t0)*1000)}ms", |
| 965 | file=_sys.stderr, flush=True, |
| 966 | ) |
| 967 | |
| 968 | data = msgpack.unpackb(raw_post, raw=False) |
| 969 | bundle_id: str = str(data.get("mpack_id") or data.get("bundle_id") or "") |
| 970 | mpack_url_raw: str = str(data.get("mpack_url") or "") |
| 971 | t_parse = _time_mod.monotonic() |
| 972 | print( |
| 973 | f"[transport] parsed bundle_id={bundle_id[:20] if bundle_id else 'none'}" |
| 974 | f" mpack_url={mpack_url_raw[:100] if mpack_url_raw else 'null'}" |
| 975 | f" t={int((t_parse - t0)*1000)}ms", |
| 976 | file=_sys.stderr, flush=True, |
| 977 | ) |
| 978 | |
| 979 | if mpack_url_raw: |
| 980 | print(f"[transport] GET mpack {mpack_url_raw[:120]}", file=_sys.stderr, flush=True) |
| 981 | mpack_bytes_dl = _urllib_do("GET", mpack_url_raw, {}, follow_redirects=True, max_bytes=None) |
| 982 | t_dl = _time_mod.monotonic() |
| 983 | dl_mb = len(mpack_bytes_dl) / (1024 * 1024) |
| 984 | print( |
| 985 | f"[transport] GET done status=200 size={dl_mb:.2f}MB" |
| 986 | f" t={int((t_dl - t_parse)*1000)}ms", |
| 987 | file=_sys.stderr, flush=True, |
| 988 | ) |
| 989 | actual_id = blob_id(mpack_bytes_dl) |
| 990 | t_verify = _time_mod.monotonic() |
| 991 | print( |
| 992 | f"[transport] sha256 match={actual_id == bundle_id} t={int((t_verify - t_dl)*1000)}ms", |
| 993 | file=_sys.stderr, flush=True, |
| 994 | ) |
| 995 | if actual_id != bundle_id: |
| 996 | raise TransportError( |
| 997 | f"fetch/mpack: integrity failure mpack_id={bundle_id!r} " |
| 998 | f"actual={actual_id!r}", |
| 999 | 0, |
| 1000 | ) |
| 1001 | if mpack_bytes_dl[:4] == b"MUSE": |
| 1002 | from muse.core.mpack import parse_wire_mpack |
| 1003 | mpack = parse_wire_mpack(mpack_bytes_dl) |
| 1004 | else: |
| 1005 | mpack = msgpack.unpackb(mpack_bytes_dl, raw=False) |
| 1006 | t_unpack = _time_mod.monotonic() |
| 1007 | print( |
| 1008 | f"[transport] unpackb commits={len(mpack.get('commits') or [])} snaps={len(mpack.get('snapshots') or [])}" |
| 1009 | f" blobs={len(mpack.get('blobs') or [])} t={int((t_unpack - t_verify)*1000)}ms", |
| 1010 | file=_sys.stderr, flush=True, |
| 1011 | ) |
| 1012 | else: |
| 1013 | # mpack_url absent or null → all data is inline in the response body |
| 1014 | # (Phase 3 inline protocol — commits and snapshots in resp.content already parsed). |
| 1015 | print("[transport] no mpack_url — inline protocol (no S3 download)", file=_sys.stderr, flush=True) |
| 1016 | mpack = {"commits": [], "snapshots": [], "blobs": [], "branch_heads": {}} |
| 1017 | t_unpack = _time_mod.monotonic() |
| 1018 | |
| 1019 | except TransportError: |
| 1020 | raise |
| 1021 | except Exception as exc: |
| 1022 | raise TransportError(sanitize_display(str(exc)), 0) from exc |
| 1023 | |
| 1024 | raw_commits = mpack.get("commits") or [] |
| 1025 | raw_snaps = mpack.get("snapshots") or [] |
| 1026 | raw_blobs = mpack.get("blobs") or [] |
| 1027 | raw_heads = data.get("branch_heads") or mpack.get("branch_heads") or {} |
| 1028 | |
| 1029 | t_coerce0 = _time_mod.monotonic() |
| 1030 | commits: list[CommitDict] = [ |
| 1031 | _coerce_commit_dict(dict(c)) for c in raw_commits if isinstance(c, dict) |
| 1032 | ] |
| 1033 | snapshots: list[SnapshotDict] = [ |
| 1034 | _coerce_snapshot_dict(dict(s)) for s in raw_snaps if isinstance(s, dict) |
| 1035 | ] |
| 1036 | branch_heads: BranchHeads = { |
| 1037 | str(k): str(v) for k, v in raw_heads.items() |
| 1038 | if isinstance(k, str) and isinstance(v, str) |
| 1039 | } |
| 1040 | blobs: list[dict] = [obj for obj in raw_blobs if isinstance(obj, dict)] |
| 1041 | t_coerce1 = _time_mod.monotonic() |
| 1042 | print( |
| 1043 | f"[transport] coerce commits={len(commits)} snaps={len(snapshots)} blobs={len(blobs)} heads={len(branch_heads)}" |
| 1044 | f" t={int((t_coerce1 - t_coerce0)*1000)}ms TOTAL={int((t_coerce1 - t0)*1000)}ms", |
| 1045 | file=_sys.stderr, flush=True, |
| 1046 | ) |
| 1047 | |
| 1048 | return FetchMPackResult( |
| 1049 | repo_id=str(data.get("repo_id") or ""), |
| 1050 | domain=str(data.get("domain") or ""), |
| 1051 | default_branch=str(data.get("default_branch") or "main"), |
| 1052 | branch_heads=branch_heads, |
| 1053 | commits=commits, |
| 1054 | snapshots=snapshots, |
| 1055 | blobs=blobs, |
| 1056 | blobs_received=len(blobs), |
| 1057 | shallow_commits=[], |
| 1058 | ) |
| 1059 | |
| 1060 | def fetch_mpack( |
| 1061 | self, |
| 1062 | url: str, |
| 1063 | signing: "SigningIdentity | None", |
| 1064 | want: "list[str]", |
| 1065 | have: "list[str]", |
| 1066 | *, |
| 1067 | ttl_seconds: int = 3600, |
| 1068 | retry_budget_s: "float | None" = None, |
| 1069 | ) -> "FetchMPackResult": |
| 1070 | """Fetch the commit delta as one content-addressed MPack, with retry. |
| 1071 | |
| 1072 | POSTs ``{url}/fetch/mpack``. On a ``503`` response the server is |
| 1073 | still building the fetch cache; the ``Retry-After`` header says how |
| 1074 | long to wait. This method retries within a configurable wall-clock |
| 1075 | budget rather than failing immediately. |
| 1076 | |
| 1077 | Budget is tracked as accumulated sleep time (virtual time) so the |
| 1078 | check is correct whether ``_sleep`` is real or patched in tests. |
| 1079 | |
| 1080 | Args: |
| 1081 | url: Canonical repo URL. |
| 1082 | signing: Ed25519 signing identity; ``None`` for public repos. |
| 1083 | want: Commit IDs the client wants. |
| 1084 | have: Commit IDs already present locally. |
| 1085 | ttl_seconds: Presigned URL TTL forwarded to the server. |
| 1086 | retry_budget_s: Max cumulative wait in seconds. ``None`` uses the |
| 1087 | module default (120 s) or ``MUSE_FETCH_RETRY_BUDGET_S`` |
| 1088 | env var. Pass ``0`` to disable retry entirely. |
| 1089 | |
| 1090 | Raises: |
| 1091 | :class:`TransportError` on non-200 (non-retryable) response, |
| 1092 | integrity failure, network error, or budget exhaustion. |
| 1093 | """ |
| 1094 | import sys as _sys |
| 1095 | budget = _resolve_retry_budget(retry_budget_s) |
| 1096 | total_waited: float = 0.0 |
| 1097 | attempt = 0 |
| 1098 | while True: |
| 1099 | attempt += 1 |
| 1100 | try: |
| 1101 | return self._fetch_mpack_once( |
| 1102 | url, signing, want, have, ttl_seconds=ttl_seconds |
| 1103 | ) |
| 1104 | except TransportError as exc: |
| 1105 | if exc.status_code != 503: |
| 1106 | raise # non-retryable — fail immediately, zero added latency |
| 1107 | wait = _next_wait(exc.retry_after) |
| 1108 | if total_waited + wait > budget: |
| 1109 | raise # budget exhausted — surface the server's real 503 |
| 1110 | print( |
| 1111 | f"⏳ remote preparing fetch data (server busy, attempt {attempt}); " |
| 1112 | f"retrying in {int(wait)}s … " |
| 1113 | f"(waited {int(total_waited)}s / {int(budget)}s budget)", |
| 1114 | file=_sys.stderr, |
| 1115 | flush=True, |
| 1116 | ) |
| 1117 | _sleep(wait) |
| 1118 | total_waited += wait |
| 1119 | |
| 1120 | def push_tags( |
| 1121 | self, |
| 1122 | url: str, |
| 1123 | signing: SigningIdentity | None, |
| 1124 | tags: list[WireTag], |
| 1125 | ) -> int: |
| 1126 | """Push tags via ``POST {url}/tags``.""" |
| 1127 | endpoint = f"{url.rstrip('/')}/tags" |
| 1128 | logger.debug("transport: POST %s (tags=%d)", endpoint, len(tags)) |
| 1129 | body_bytes: bytes = msgpack.packb({"tags": list(tags)}, use_bin_type=True) |
| 1130 | req = self._build_request("POST", endpoint, signing, body_bytes) |
| 1131 | raw = self._execute(req) |
| 1132 | parsed = self._decode(raw) |
| 1133 | stored_val = parsed.get("stored") |
| 1134 | return int(stored_val) if isinstance(stored_val, int) else 0 |
| 1135 | |
| 1136 | def create_release( |
| 1137 | self, |
| 1138 | url: str, |
| 1139 | signing: SigningIdentity | None, |
| 1140 | release: ReleaseDict, |
| 1141 | ) -> str: |
| 1142 | """Create a release via ``POST {url}/releases``.""" |
| 1143 | endpoint = f"{url.rstrip('/')}/releases" |
| 1144 | logger.debug("transport: POST %s (tag=%s)", endpoint, release.get("tag", "")) |
| 1145 | # ReleaseDict contains no bytes fields so JSON encoding works directly. |
| 1146 | body_bytes: bytes = json.dumps(release).encode("utf-8") |
| 1147 | req = self._build_request("POST", endpoint, signing, body_bytes, content_type="application/json") |
| 1148 | raw = self._execute(req) |
| 1149 | parsed = self._decode(raw) |
| 1150 | release_id_val = parsed.get("release_id") |
| 1151 | return str(release_id_val) if isinstance(release_id_val, str) else "" |
| 1152 | |
| 1153 | def list_releases_remote( |
| 1154 | self, |
| 1155 | url: str, |
| 1156 | signing: SigningIdentity | None, |
| 1157 | channel: str | None = None, |
| 1158 | include_drafts: bool = False, |
| 1159 | ) -> list[ReleaseDict]: |
| 1160 | """List releases via ``GET {url}/releases``.""" |
| 1161 | qs_parts: list[str] = [] |
| 1162 | if channel: |
| 1163 | qs_parts.append(f"channel={urllib.parse.quote(channel)}") |
| 1164 | if include_drafts: |
| 1165 | qs_parts.append("include_drafts=1") |
| 1166 | endpoint = f"{url.rstrip('/')}/releases" |
| 1167 | if qs_parts: |
| 1168 | endpoint = f"{endpoint}?{'&'.join(qs_parts)}" |
| 1169 | logger.debug("transport: GET %s", endpoint) |
| 1170 | req = self._build_request("GET", endpoint, signing) |
| 1171 | raw = self._execute(req) |
| 1172 | parsed = self._decode(raw) |
| 1173 | return _parse_releases_list(parsed) |
| 1174 | |
| 1175 | def delete_release_remote( |
| 1176 | self, |
| 1177 | url: str, |
| 1178 | signing: SigningIdentity | None, |
| 1179 | tag: str, |
| 1180 | ) -> None: |
| 1181 | """Retract a release via ``DELETE {url}/releases/{tag}``.""" |
| 1182 | endpoint = f"{url.rstrip('/')}/releases/{urllib.parse.quote(tag, safe='')}" |
| 1183 | logger.debug("transport: DELETE %s", endpoint) |
| 1184 | req = self._build_request("DELETE", endpoint, signing) |
| 1185 | self._execute(req) |
| 1186 | |
| 1187 | def delete_branch_remote( |
| 1188 | self, |
| 1189 | url: str, |
| 1190 | signing: SigningIdentity | None, |
| 1191 | branch: str, |
| 1192 | ) -> None: |
| 1193 | """Delete a branch via ``DELETE {url}/branches/{branch}``.""" |
| 1194 | endpoint = f"{url.rstrip('/')}/branches/{urllib.parse.quote(branch, safe='')}" |
| 1195 | logger.debug("transport: DELETE %s", endpoint) |
| 1196 | req = self._build_request("DELETE", endpoint, signing) |
| 1197 | self._execute(req) |
| 1198 | |
| 1199 | def push_version_tags( |
| 1200 | self, |
| 1201 | url: str, |
| 1202 | signing: "SigningIdentity | None", |
| 1203 | tags: "list[dict]", |
| 1204 | *, |
| 1205 | force: bool = False, |
| 1206 | ) -> "dict": |
| 1207 | """Push version tags via ``POST {url}/version-tags``.""" |
| 1208 | endpoint = f"{url.rstrip('/')}/version-tags" |
| 1209 | if force: |
| 1210 | endpoint += "?force=true" |
| 1211 | body: bytes = msgpack.packb({"tags": tags}, use_bin_type=True) |
| 1212 | logger.debug("transport: POST %s (%d tags)", endpoint, len(tags)) |
| 1213 | req = self._build_request("POST", endpoint, signing, body) |
| 1214 | raw = self._execute(req) |
| 1215 | parsed = self._decode(raw) |
| 1216 | stored = parsed.get("stored", 0) |
| 1217 | skipped = parsed.get("skipped", 0) |
| 1218 | return {"stored": int(stored) if isinstance(stored, int) else 0, |
| 1219 | "skipped": int(skipped) if isinstance(skipped, int) else 0} |
| 1220 | |
| 1221 | def fetch_version_tags( |
| 1222 | self, |
| 1223 | url: str, |
| 1224 | signing: "SigningIdentity | None", |
| 1225 | ) -> "dict": |
| 1226 | """Fetch version tags via ``GET {url}/version-tags``.""" |
| 1227 | endpoint = f"{url.rstrip('/')}/version-tags" |
| 1228 | logger.debug("transport: GET %s", endpoint) |
| 1229 | req = self._build_request("GET", endpoint, signing) |
| 1230 | raw = self._execute(req) |
| 1231 | parsed = self._decode(raw) |
| 1232 | tags_raw = parsed.get("tags", []) |
| 1233 | tags: list[dict] = [] |
| 1234 | if isinstance(tags_raw, list): |
| 1235 | for item in tags_raw: |
| 1236 | if isinstance(item, dict): |
| 1237 | tags.append({k: v for k, v in item.items()}) |
| 1238 | return {"tags": tags} |
| 1239 | |
| 1240 | # --------------------------------------------------------------------------- |
| 1241 | # Response parsers — JSON bytes → typed TypedDicts |
| 1242 | # --------------------------------------------------------------------------- |
| 1243 | # json.loads() returns Any (per typeshed), so we use isinstance narrowing |
| 1244 | # throughout. No explicit Any annotations appear in this file. |
| 1245 | # --------------------------------------------------------------------------- |
| 1246 | |
| 1247 | def _parse_remote_info(raw: bytes) -> RemoteInfo: |
| 1248 | """Parse ``GET /refs`` response bytes into a :class:`~muse.core.mpack.RemoteInfo`.""" |
| 1249 | parsed = HttpTransport._decode(raw) |
| 1250 | repo_id_val = parsed.get("repo_id") |
| 1251 | domain_val = parsed.get("domain") |
| 1252 | default_branch_val = parsed.get("default_branch") |
| 1253 | branch_heads_raw = parsed.get("branch_heads") |
| 1254 | branch_heads: BranchHeads = {} |
| 1255 | if isinstance(branch_heads_raw, dict): |
| 1256 | for k, v in branch_heads_raw.items(): |
| 1257 | if isinstance(k, str) and isinstance(v, str): |
| 1258 | branch_heads[k] = v |
| 1259 | info = RemoteInfo( |
| 1260 | repo_id=str(repo_id_val) if isinstance(repo_id_val, str) else "", |
| 1261 | domain=str(domain_val) if isinstance(domain_val, str) else "midi", |
| 1262 | default_branch=( |
| 1263 | str(default_branch_val) if isinstance(default_branch_val, str) else "main" |
| 1264 | ), |
| 1265 | branch_heads=branch_heads, |
| 1266 | ) |
| 1267 | return info |
| 1268 | |
| 1269 | def _parse_mpack(raw: bytes) -> MPack: |
| 1270 | """Parse ``POST /fetch`` response bytes into a :class:`~muse.core.mpack.MPack`.""" |
| 1271 | parsed = HttpTransport._decode(raw) |
| 1272 | mpack: MPack = {} |
| 1273 | |
| 1274 | # Commits — each item is a raw dict that CommitRecord.from_dict() will validate. |
| 1275 | commits_raw = parsed.get("commits") |
| 1276 | if isinstance(commits_raw, list): |
| 1277 | commits: list[CommitDict] = [] |
| 1278 | for item in commits_raw: |
| 1279 | if isinstance(item, dict): |
| 1280 | commits.append(_coerce_commit_dict(item)) |
| 1281 | mpack["commits"] = commits |
| 1282 | |
| 1283 | # Snapshots |
| 1284 | snapshots_raw = parsed.get("snapshots") |
| 1285 | if isinstance(snapshots_raw, list): |
| 1286 | snapshots: list[SnapshotDict] = [] |
| 1287 | for item in snapshots_raw: |
| 1288 | if isinstance(item, dict): |
| 1289 | snapshots.append(_coerce_snapshot_dict(item)) |
| 1290 | mpack["snapshots"] = snapshots |
| 1291 | |
| 1292 | # Blobs — raw bytes in "content" (mpack wire format) |
| 1293 | blobs_raw = parsed.get("blobs") |
| 1294 | if isinstance(blobs_raw, list): |
| 1295 | blobs: list[BlobPayload] = [] |
| 1296 | for item in blobs_raw: |
| 1297 | if not isinstance(item, dict): |
| 1298 | continue |
| 1299 | oid = item.get("object_id") |
| 1300 | if not isinstance(oid, str): |
| 1301 | continue |
| 1302 | content_raw = item.get("content") |
| 1303 | if isinstance(content_raw, (bytes, bytearray)): |
| 1304 | blobs.append(BlobPayload(object_id=oid, content=bytes(content_raw))) |
| 1305 | mpack["blobs"] = blobs |
| 1306 | |
| 1307 | # Branch heads |
| 1308 | heads_raw = parsed.get("branch_heads") |
| 1309 | if isinstance(heads_raw, dict): |
| 1310 | branch_heads: BranchHeads = {} |
| 1311 | for k, v in heads_raw.items(): |
| 1312 | if isinstance(k, str) and isinstance(v, str): |
| 1313 | branch_heads[k] = v |
| 1314 | mpack["branch_heads"] = branch_heads |
| 1315 | |
| 1316 | return mpack |
| 1317 | |
| 1318 | def _parse_push_result(raw: bytes) -> PushResult: |
| 1319 | """Parse ``POST /push`` response bytes into a :class:`~muse.core.mpack.PushResult`.""" |
| 1320 | parsed = HttpTransport._decode(raw) |
| 1321 | ok_val = parsed.get("ok") |
| 1322 | msg_val = parsed.get("message") |
| 1323 | heads_raw = parsed.get("branch_heads") |
| 1324 | branch_heads: BranchHeads = {} |
| 1325 | if isinstance(heads_raw, dict): |
| 1326 | for k, v in heads_raw.items(): |
| 1327 | if isinstance(k, str) and isinstance(v, str): |
| 1328 | branch_heads[k] = v |
| 1329 | return PushResult( |
| 1330 | ok=bool(ok_val) if isinstance(ok_val, bool) else False, |
| 1331 | message=str(msg_val) if isinstance(msg_val, str) else "", |
| 1332 | branch_heads=branch_heads, |
| 1333 | ) |
| 1334 | |
| 1335 | def _coerce_sem_ver_bump(raw: _MsgVal) -> SemVerBump: |
| 1336 | """Safely coerce a raw value to a :class:`~muse.domain.SemVerBump` literal.""" |
| 1337 | if raw == "major": |
| 1338 | return "major" |
| 1339 | if raw == "minor": |
| 1340 | return "minor" |
| 1341 | if raw == "patch": |
| 1342 | return "patch" |
| 1343 | return "none" |
| 1344 | |
| 1345 | def _parse_releases_list(parsed: _MsgDict) -> list[ReleaseDict]: |
| 1346 | """Extract a list of :class:`~muse.core.store.ReleaseDict` from a parsed response.""" |
| 1347 | releases_raw = parsed.get("releases") |
| 1348 | if not isinstance(releases_raw, list): |
| 1349 | return [] |
| 1350 | results: list[ReleaseDict] = [] |
| 1351 | for item in releases_raw: |
| 1352 | if not isinstance(item, dict): |
| 1353 | continue |
| 1354 | try: |
| 1355 | semver_raw = item.get("semver") |
| 1356 | if isinstance(semver_raw, dict): |
| 1357 | sv_major = semver_raw.get("major") |
| 1358 | sv_minor = semver_raw.get("minor") |
| 1359 | sv_patch = semver_raw.get("patch") |
| 1360 | sv_pre = semver_raw.get("pre") |
| 1361 | sv_build = semver_raw.get("build") |
| 1362 | semver: SemVerTag = SemVerTag( |
| 1363 | major=int(sv_major) if isinstance(sv_major, int) else 0, |
| 1364 | minor=int(sv_minor) if isinstance(sv_minor, int) else 0, |
| 1365 | patch=int(sv_patch) if isinstance(sv_patch, int) else 0, |
| 1366 | pre=sv_pre if isinstance(sv_pre, str) else "", |
| 1367 | build=sv_build if isinstance(sv_build, str) else "", |
| 1368 | ) |
| 1369 | else: |
| 1370 | semver = SemVerTag(major=0, minor=0, patch=0, pre="", build="") |
| 1371 | changelog_raw = item.get("changelog") or [] |
| 1372 | changelog: list[ChangelogEntry] = [] |
| 1373 | if isinstance(changelog_raw, list): |
| 1374 | for entry in changelog_raw: |
| 1375 | if not isinstance(entry, dict): |
| 1376 | continue |
| 1377 | bc_raw = entry.get("breaking_changes") |
| 1378 | bc_list: list[str] = [str(b) for b in bc_raw if isinstance(b, str)] if isinstance(bc_raw, list) else [] |
| 1379 | changelog.append(ChangelogEntry( |
| 1380 | commit_id=str(entry.get("commit_id", "")), |
| 1381 | message=str(entry.get("message", "")), |
| 1382 | sem_ver_bump=_coerce_sem_ver_bump(entry.get("sem_ver_bump")), |
| 1383 | breaking_changes=bc_list, |
| 1384 | author=str(entry.get("author", "")), |
| 1385 | committed_at=str(entry.get("committed_at", "")), |
| 1386 | agent_id=str(entry.get("agent_id", "")), |
| 1387 | model_id=str(entry.get("model_id", "")), |
| 1388 | )) |
| 1389 | results.append(ReleaseDict( |
| 1390 | release_id=str(item.get("release_id", "")), |
| 1391 | repo_id=str(item.get("repo_id", "")), |
| 1392 | tag=str(item.get("tag", "")), |
| 1393 | semver=semver, |
| 1394 | channel=str(item.get("channel", "stable")), |
| 1395 | commit_id=str(item.get("commit_id", "")), |
| 1396 | snapshot_id=str(item.get("snapshot_id", "")), |
| 1397 | title=str(item.get("title", "")), |
| 1398 | body=str(item.get("body", "")), |
| 1399 | changelog=changelog, |
| 1400 | agent_id=str(item.get("agent_id", "")), |
| 1401 | model_id=str(item.get("model_id", "")), |
| 1402 | is_draft=bool(item.get("is_draft", False)), |
| 1403 | gpg_signature=str(item.get("gpg_signature", "")), |
| 1404 | created_at=str(item.get("created_at", "")), |
| 1405 | )) |
| 1406 | except (KeyError, TypeError, ValueError): |
| 1407 | continue |
| 1408 | return results |
| 1409 | |
| 1410 | # --------------------------------------------------------------------------- |
| 1411 | # TypedDict coercion helpers — extract known string fields from raw JSON dicts |
| 1412 | # --------------------------------------------------------------------------- |
| 1413 | # CommitDict and SnapshotDict are total=False (all fields optional), so we |
| 1414 | # only extract the string/scalar fields we can safely validate here. |
| 1415 | # CommitRecord.from_dict() and SnapshotRecord.from_dict() re-validate |
| 1416 | # required fields when apply_mpack() calls them. |
| 1417 | # --------------------------------------------------------------------------- |
| 1418 | |
| 1419 | def _str(val: _WireVal) -> str: |
| 1420 | """Return *val* as str, or empty string if not a str.""" |
| 1421 | return val if isinstance(val, str) else "" |
| 1422 | |
| 1423 | def _str_or_none(val: _WireVal) -> str | None: |
| 1424 | """Return *val* as str, or None if not a str.""" |
| 1425 | return val if isinstance(val, str) else None |
| 1426 | |
| 1427 | def _int_or(val: _WireVal, default: int) -> int: |
| 1428 | """Return *val* as int, or *default* if not an int.""" |
| 1429 | return val if isinstance(val, int) else default |
| 1430 | |
| 1431 | def _coerce_commit_dict(raw: _WireDict) -> CommitDict: |
| 1432 | """Extract typed scalar fields from *raw* into a :class:`~muse.core.store.CommitDict`. |
| 1433 | |
| 1434 | Only primitive fields are validated here; ``structured_delta`` is |
| 1435 | preserved as-is because :class:`~muse.core.store.CommitRecord.from_dict` |
| 1436 | already handles it gracefully. |
| 1437 | """ |
| 1438 | metadata_raw = raw.get("metadata") |
| 1439 | metadata: Metadata = {} |
| 1440 | if isinstance(metadata_raw, dict): |
| 1441 | for k, v in metadata_raw.items(): |
| 1442 | if isinstance(k, str) and isinstance(v, str): |
| 1443 | metadata[k] = v |
| 1444 | |
| 1445 | reviewed_by_raw = raw.get("reviewed_by") |
| 1446 | reviewed_by: list[str] = [] |
| 1447 | if isinstance(reviewed_by_raw, list): |
| 1448 | for item in reviewed_by_raw: |
| 1449 | if isinstance(item, str): |
| 1450 | reviewed_by.append(item) |
| 1451 | |
| 1452 | breaking_changes_raw = raw.get("breaking_changes") |
| 1453 | breaking_changes: list[str] = [] |
| 1454 | if isinstance(breaking_changes_raw, list): |
| 1455 | for item in breaking_changes_raw: |
| 1456 | if isinstance(item, str): |
| 1457 | breaking_changes.append(item) |
| 1458 | |
| 1459 | sem_ver_raw = raw.get("sem_ver_bump") |
| 1460 | sem_ver: SemVerBump |
| 1461 | if sem_ver_raw == "major": |
| 1462 | sem_ver = "major" |
| 1463 | elif sem_ver_raw == "minor": |
| 1464 | sem_ver = "minor" |
| 1465 | elif sem_ver_raw == "patch": |
| 1466 | sem_ver = "patch" |
| 1467 | else: |
| 1468 | sem_ver = "none" |
| 1469 | |
| 1470 | return CommitDict( |
| 1471 | commit_id=_str(raw.get("commit_id")), |
| 1472 | repo_id=_str(raw.get("repo_id")), |
| 1473 | branch=_str(raw.get("branch") or raw.get("created_on_branch")), |
| 1474 | snapshot_id=_str(raw.get("snapshot_id")), |
| 1475 | message=_str(raw.get("message")), |
| 1476 | committed_at=_str(raw.get("committed_at")), |
| 1477 | parent_commit_id=_str_or_none(raw.get("parent_commit_id")), |
| 1478 | parent2_commit_id=_str_or_none(raw.get("parent2_commit_id")), |
| 1479 | author=_str(raw.get("author")), |
| 1480 | metadata=metadata, |
| 1481 | structured_delta=None, |
| 1482 | sem_ver_bump=sem_ver, |
| 1483 | breaking_changes=breaking_changes, |
| 1484 | agent_id=_str(raw.get("agent_id")), |
| 1485 | model_id=_str(raw.get("model_id")), |
| 1486 | toolchain_id=_str(raw.get("toolchain_id")), |
| 1487 | prompt_hash=_str(raw.get("prompt_hash")), |
| 1488 | signature=_str(raw.get("signature")), |
| 1489 | signer_public_key=_str(raw.get("signer_public_key")), |
| 1490 | signer_key_id=_str(raw.get("signer_key_id")), |
| 1491 | reviewed_by=reviewed_by, |
| 1492 | test_runs=_int_or(raw.get("test_runs"), 0), |
| 1493 | ) |
| 1494 | |
| 1495 | def _coerce_snapshot_dict(raw: _WireDict) -> SnapshotDict: |
| 1496 | """Extract typed fields from *raw* into a :class:`~muse.core.store.SnapshotDict`. |
| 1497 | |
| 1498 | Two wire formats are accepted: |
| 1499 | 1. Delta format (fetch mpack): {snapshot_id, parent_snapshot_id, |
| 1500 | delta_upsert, delta_remove} — pass through delta fields so |
| 1501 | _apply_snapshot_deltas can reconstruct the manifest. |
| 1502 | 2. Full-manifest format: {snapshot_id, manifest} — coerce into |
| 1503 | SnapshotDict directly. |
| 1504 | """ |
| 1505 | manifest_raw = raw.get("manifest") |
| 1506 | delta_upsert_raw = raw.get("delta_upsert") |
| 1507 | |
| 1508 | # Delta format: no manifest, but delta_upsert present. |
| 1509 | # _apply_snapshot_deltas handles reconstruction; pass through fields as-is. |
| 1510 | if manifest_raw is None and isinstance(delta_upsert_raw, dict): |
| 1511 | directories_raw = raw.get("directories") |
| 1512 | return { # type: ignore[return-value] |
| 1513 | "snapshot_id": _str(raw.get("snapshot_id")), |
| 1514 | "parent_snapshot_id": _str_or_none(raw.get("parent_snapshot_id")), |
| 1515 | "delta_upsert": { |
| 1516 | str(k): str(v) for k, v in delta_upsert_raw.items() |
| 1517 | if isinstance(k, str) and isinstance(v, str) |
| 1518 | }, |
| 1519 | "delta_remove": [ |
| 1520 | str(p) for p in (raw.get("delta_remove") or []) |
| 1521 | if isinstance(p, str) |
| 1522 | ], |
| 1523 | "directories": [ |
| 1524 | str(d) for d in (directories_raw or []) if isinstance(d, str) |
| 1525 | ] if isinstance(directories_raw, list) else [], |
| 1526 | "created_at": _str(raw.get("created_at")), |
| 1527 | "manifest": None, # type: ignore[typeddict-item] |
| 1528 | "note": "", |
| 1529 | } |
| 1530 | |
| 1531 | # Full-manifest format. |
| 1532 | manifest: Manifest = {} |
| 1533 | if isinstance(manifest_raw, dict): |
| 1534 | for k, v in manifest_raw.items(): |
| 1535 | if isinstance(k, str) and isinstance(v, str): |
| 1536 | manifest[k] = v |
| 1537 | # ``directories`` is hashed into snapshot_id — dropping it produces a |
| 1538 | # SnapshotRecord whose snapshot_id never matches the recomputed hash, |
| 1539 | # making every snapshot with non-empty directories permanently unreadable. |
| 1540 | directories_raw = raw.get("directories") |
| 1541 | directories: list[str] = [] |
| 1542 | if isinstance(directories_raw, list): |
| 1543 | for item in directories_raw: |
| 1544 | if isinstance(item, str): |
| 1545 | directories.append(item) |
| 1546 | return SnapshotDict( |
| 1547 | snapshot_id=_str(raw.get("snapshot_id")), |
| 1548 | manifest=manifest, |
| 1549 | directories=directories, |
| 1550 | created_at=_str(raw.get("created_at")), |
| 1551 | note=_str(raw.get("note", "")), |
| 1552 | ) |
| 1553 | |
| 1554 | |
| 1555 | # --------------------------------------------------------------------------- |
| 1556 | # Factory |
| 1557 | # --------------------------------------------------------------------------- |
| 1558 | |
| 1559 | def make_transport(url: str) -> "HttpTransport": |
| 1560 | """Return an :class:`HttpTransport` for *url*. |
| 1561 | |
| 1562 | Args: |
| 1563 | url: Remote repository URL. |
| 1564 | |
| 1565 | Returns: |
| 1566 | An ``HttpTransport`` instance implementing :class:`MuseTransport`. |
| 1567 | """ |
| 1568 | return HttpTransport() |
File History
8 commits
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
92 days ago
sha256:6b91dc90e1c59c5209b764a276c1ee824bd369320a454a403d28ce79890103f2
fix: stream mpack downloads without size cap
Sonnet 4.6
minor
⚠
97 days ago
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4
fix: carry dev changes harmony dropped in merge — detached …
Sonnet 4.6
minor
⚠
102 days ago
sha256:b37d33a9dbf176b32955b34e5c9e983c4d8c6e7bfa4e2714edc938f31e721561
update transport logs
Human
minor
⚠
103 days ago
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f
chore: merge main — carry all urllib/typing/test fixes from dev
Sonnet 4.6
minor
⚠
105 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156
chore: bump version to 0.2.0rc11; fix typing audit violatio…
Sonnet 4.6
minor
⚠
105 days ago
sha256:633dfa2940e97bf1a3d04996c772027a57d70d103f1693c96da04969613dba6c
fix: urllib migration regressions — force flag, job_id, Con…
Sonnet 4.6
minor
⚠
105 days ago
sha256:00cec040ce5f70bf8191d2ce6a9f308fbde553911068f0c303217f4eb6d4e775
fix: migrate httpx → urllib in transport.py and push.py; fi…
Sonnet 4.6
minor
⚠
105 days ago