gabriel / muse public
unpack_objects.py python
200 lines 7.0 KB
Raw
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 12 days ago
1 """muse unpack-objects — read a MPack from stdin and write to store.
2
3 Reads a MPack msgpack binary from stdin and idempotently writes its
4 commits, snapshots, blobs, and tags into the local ``.muse/`` store.
5
6 Usage::
7
8 cat pack.muse | muse unpack-objects
9 muse pack-objects HEAD | muse unpack-objects
10
11 Output::
12
13 {
14 "commits_written": 12,
15 "snapshots_written": 12,
16 "blobs_written": 47,
17 "blobs_skipped": 3,
18 "tags_written": 2,
19 "duration_ms": 8.3,
20 "exit_code": 0
21 }
22
23 Output contract
24 ---------------
25
26 - Exit 0: blobs unpacked (idempotent — already-present blobs are skipped).
27 - Exit 1: invalid input (bad format flag, malformed msgpack, not a top-level map).
28 - Exit 3: write failure (``apply_mpack`` raised ``OSError``).
29
30 JSON error contract
31 -------------------
32
33 When ``--json`` (or ``--format json``) is active, **all** errors are emitted
34 to **stdout** (not stderr) so agents can always parse a structured response::
35
36 {"error": "<key>", "message": "<description>", "duration_ms": 0.3, "exit_code": 1}
37
38 Exit codes::
39
40 0 — success
41 1 — user error (bad format, invalid msgpack, not a map)
42 3 — write failure (OSError from apply_mpack)
43 """
44
45 import argparse
46 import json
47 import logging
48 import sys
49 from typing import TypeGuard
50
51 from muse.core.envelope import EnvelopeJson, make_envelope
52 from muse.core.errors import ExitCode
53 from muse.core.mpack import MPack, apply_mpack
54 from muse.core.repo import require_repo
55 from muse.core.io import MAX_PACK_MSGPACK_BYTES, safe_unpackb
56 from muse.core.types import MsgpackValue
57 from muse.core.commits import CommitDict
58 from muse.core.snapshots import SnapshotDict
59 from muse.core.types import BranchHeads
60 from muse.core.timing import start_timer
61
62 class _UnpackObjectsJson(EnvelopeJson):
63 commits_written: int
64 snapshots_written: int
65 blobs_written: int
66 blobs_skipped: int
67 tags_written: int
68
69 logger = logging.getLogger(__name__)
70
71 def _is_commit_dict(v: MsgpackValue) -> TypeGuard[CommitDict]:
72 """TypeGuard for narrowing MsgpackValue → CommitDict at the wire boundary."""
73 return isinstance(v, dict)
74
75 def _is_snapshot_dict(v: MsgpackValue) -> TypeGuard[SnapshotDict]:
76 """TypeGuard for narrowing MsgpackValue → SnapshotDict at the wire boundary."""
77 return isinstance(v, dict)
78
79 def _as_branch_heads(v: MsgpackValue) -> BranchHeads:
80 """Extract branch_heads from a wire payload, narrowing safely."""
81 if not isinstance(v, dict):
82 return {}
83 return {str(k): str(val) for k, val in v.items() if isinstance(val, str)}
84
85 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
86 """Register the unpack-objects subcommand."""
87 parser = subparsers.add_parser(
88 "unpack-objects",
89 help="Read a MPack JSON from stdin, write to the local store.",
90 description=__doc__,
91 )
92 parser.add_argument(
93 "--json", "-j",
94 action="store_true",
95 dest="json_out",
96 help="Emit machine-readable JSON on stdout.",
97 )
98 parser.set_defaults(func=run, json_out=False)
99
100 def run(args: argparse.Namespace) -> None:
101 """Read a MPack from stdin and write to the local store.
102
103 Idempotently writes commits, snapshots, blobs, and tags from a msgpack
104 binary on stdin. Already-present blobs are silently skipped. Partial
105 packs are safe to re-apply — the store is always consistent after the call.
106
107 Agent quickstart::
108
109 cat pack.muse | muse unpack-objects --json
110 muse pack-objects HEAD | muse unpack-objects --json
111
112 JSON fields::
113
114 commits_written Number of commit records written to the store.
115 snapshots_written Number of snapshot records written to the store.
116 blobs_written Number of content blobs written.
117 blobs_skipped Number of blobs already present (skipped).
118 tags_written Number of tag records written.
119 muse_version Muse release that produced this output.
120 schema Envelope schema version (int).
121 exit_code 0 on success, 1 on user error, 3 on write failure.
122 duration_ms Wall-clock milliseconds for the command.
123 timestamp ISO-8601 UTC timestamp of command completion.
124 warnings List of non-fatal advisory messages.
125
126 Exit codes::
127
128 0 Objects unpacked successfully.
129 1 User error (bad format, malformed msgpack, not a top-level map).
130 3 Write failure (OSError from apply_mpack).
131 """
132 elapsed = start_timer()
133 json_out: bool = args.json_out
134
135 def _emit_error(msg: str, code: int, error_key: str = "error") -> None:
136 if json_out:
137 print(json.dumps({**make_envelope(elapsed, exit_code=code), "error": error_key, "message": msg}))
138 else:
139 print(f"❌ {msg}", file=sys.stderr)
140 raise SystemExit(code)
141
142 root = require_repo()
143
144 raw_bytes = sys.stdin.buffer.read()
145 try:
146 raw_dict = safe_unpackb(
147 raw_bytes,
148 context="stdin",
149 max_bytes=MAX_PACK_MSGPACK_BYTES,
150 allow_binary=True,
151 )
152 except Exception as exc:
153 _emit_error(f"Invalid msgpack from stdin: {exc}", ExitCode.USER_ERROR, "invalid_msgpack")
154
155 if not isinstance(raw_dict, dict):
156 _emit_error("Expected a msgpack map at the top level.", ExitCode.USER_ERROR, "invalid_format")
157
158 from muse.core.mpack import BlobPayload
159
160 raw_blobs: list[BlobPayload] = []
161 raw_blobs_v = raw_dict.get("blobs")
162 for item in (raw_blobs_v if isinstance(raw_blobs_v, list) else []):
163 if isinstance(item, dict):
164 oid = item.get("object_id")
165 content = item.get("content")
166 if isinstance(oid, str) and isinstance(content, (bytes, bytearray)):
167 raw_blobs.append(BlobPayload(object_id=oid, content=bytes(content)))
168
169 raw_commits = raw_dict.get("commits")
170 raw_snapshots = raw_dict.get("snapshots")
171 mpack = MPack(
172 commits=[r for r in raw_commits if _is_commit_dict(r)] if isinstance(raw_commits, list) else [],
173 snapshots=[r for r in raw_snapshots if _is_snapshot_dict(r)] if isinstance(raw_snapshots, list) else [],
174 blobs=raw_blobs,
175 branch_heads=_as_branch_heads(raw_dict.get("branch_heads")),
176 )
177
178 try:
179 result = apply_mpack(root, mpack)
180 except OSError as exc:
181 _emit_error(str(exc), ExitCode.INTERNAL_ERROR, "write_error")
182
183 if not json_out:
184 print(
185 f"Wrote {result['commits_written']} commits, "
186 f"{result['snapshots_written']} snapshots, "
187 f"{result['blobs_written']} blobs, "
188 f"{result['tags_written']} tags "
189 f"({result['blobs_skipped']} skipped)."
190 )
191 return
192
193 print(json.dumps(_UnpackObjectsJson(
194 **make_envelope(elapsed),
195 commits_written=result["commits_written"],
196 snapshots_written=result["snapshots_written"],
197 blobs_written=result["blobs_written"],
198 blobs_skipped=result["blobs_skipped"],
199 tags_written=result["tags_written"],
200 )))
File History 6 commits
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 12 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 19 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:0313c134f0ef4518a9c3a0ec359ffdc42546dc720010730374edfe0857caf7ef rename: delta_add → delta_upsert across wire format, source… Sonnet 4.6 minor 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago