gabriel / muse public
plugin.py python
501 lines 19.1 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
1 """Scaffold domain plugin — copy-paste template for a new Muse domain.
2
3 How to use this file
4 --------------------
5 1. Copy this entire ``scaffold/`` directory:
6 cp -r muse/plugins/scaffold muse/plugins/<your_domain>
7
8 2. Rename ``ScaffoldPlugin`` to ``<YourDomain>Plugin`` throughout.
9
10 3. Replace every ``raise NotImplementedError(...)`` with real implementation.
11 Each method carries a detailed docstring explaining the contract.
12
13 4. Register the plugin in ``muse/plugins/registry.py``:
14 from muse.plugins.<your_domain>.plugin import <YourDomain>Plugin
15 _REGISTRY["<your_domain>"] = <YourDomain>Plugin()
16
17 5. Run ``muse init --domain <your_domain>`` in a project directory.
18
19 6. All 14 ``muse`` CLI commands work immediately — no core changes needed.
20
21 See ``docs/guide/plugin-authoring-guide.md`` for the full walkthrough including
22 Domain Schema, address-keyed map merge, and CRDT convergent merge extensions.
23
24 Protocol capabilities implemented here
25 ---------------------------------------
26 - Core: ``MuseDomainPlugin`` (required — 6 methods including ``schema()``)
27 - Address-keyed merge: ``AddressedMergePlugin`` (optional — remove if not needed)
28 - CRDT: ``CRDTPlugin`` (optional — remove if not needed)
29 """
30
31 import hashlib
32 import json
33 import os
34 import pathlib
35 import stat as _stat
36
37 from muse._version import __version__
38 from muse.core.crdts import ORSet, VectorClock
39 from muse.core.diff_algorithms import snapshot_diff
40 from muse.core.op_merge import merge_op_lists
41 from muse.core.stat_cache import load_cache
42 from muse.core.schema import (
43 CRDTDimensionSpec,
44 DimensionSpec,
45 DomainSchema,
46 SequenceSchema,
47 SetSchema,
48 )
49 from muse.core.types import Manifest
50 from muse.domain import (
51 CRDTSnapshotManifest,
52 DomainOp,
53 DriftReport,
54 LiveState,
55 MergeResult,
56 SnapshotManifest,
57 StateDelta,
58 StateSnapshot,
59 StructuredDelta,
60 )
61
62 # ---------------------------------------------------------------------------
63 # TODO: replace with your domain name and the file extension(s) you version.
64 # ---------------------------------------------------------------------------
65 _DOMAIN_NAME = "scaffold"
66 _FILE_GLOB = "*.scaffold" # e.g. "*.mid" for music, "*.fasta" for genomics
67
68 class ScaffoldPlugin:
69 """Scaffold implementation — replace every NotImplementedError with real code.
70
71 This class satisfies all three optional protocol levels (Phases 2–4) via
72 structural duck-typing — no explicit inheritance from the Protocol classes
73 is needed or desired (see ``MidiPlugin`` for the reference example).
74
75 If your domain only needs Phases 1–2, delete ``merge_ops`` and the four
76 CRDT methods.
77
78 See ``docs/guide/plugin-authoring-guide.md`` for detailed guidance.
79 """
80
81 # ------------------------------------------------------------------
82 # MuseDomainPlugin — required core protocol
83 # ------------------------------------------------------------------
84
85 def snapshot(self, live_state: LiveState) -> StateSnapshot:
86 """Capture the current working tree as a content-addressed manifest.
87
88 Walk every domain file under ``live_state`` and hash its raw bytes with
89 SHA-256. Paths matched by ``.museignore`` are excluded before hashing.
90 Returns a ``SnapshotManifest`` with ``files`` and ``domain``.
91
92 Args:
93 live_state: Either a ``pathlib.Path`` pointing to the working tree
94 directory, or a ``SnapshotManifest`` dict for in-memory use.
95
96 Returns:
97 A ``SnapshotManifest`` mapping workspace-relative POSIX paths to
98 their SHA-256 content digests.
99
100 Note:
101 ``.museignore`` contract — ``.museignore`` lives in the repository
102 root (the working tree root). Global patterns and patterns
103 under ``[domain.<name>]`` matching this plugin's domain are applied.
104 """
105 if isinstance(live_state, pathlib.Path):
106 from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns
107
108 workdir = live_state
109 patterns = resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME)
110 cache = load_cache(workdir)
111 files: Manifest = {}
112 root_str = str(workdir)
113 prefix_len = len(root_str) + 1
114
115 for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
116 dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
117 for fname in sorted(filenames):
118 if fname.startswith("."):
119 continue
120 abs_str = os.path.join(dirpath, fname)
121 try:
122 st = os.lstat(abs_str)
123 except OSError:
124 continue
125 if not _stat.S_ISREG(st.st_mode):
126 continue
127 rel = abs_str[prefix_len:]
128 if os.sep != "/":
129 rel = rel.replace(os.sep, "/")
130 if is_ignored(rel, patterns):
131 continue
132 files[rel] = cache.get_cached(rel, abs_str, st.st_mtime, st.st_size, st.st_ino)
133
134 cache.prune(set(files))
135 cache.save()
136 return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[])
137
138 # SnapshotManifest dict path — used by merge / diff in memory
139 return live_state
140
141 def diff(
142 self,
143 base: StateSnapshot,
144 target: StateSnapshot,
145 *,
146 repo_root: pathlib.Path | None = None,
147 ) -> StateDelta:
148 """Compute the typed operation list between two snapshots.
149
150 For a file-level implementation this is set algebra on the ``files``
151 dict: paths in target but not base → ``InsertOp``, paths in base but
152 not target → ``DeleteOp``, paths in both with different hashes →
153 ``ReplaceOp``.
154
155 For sub-file granularity (Phases 2–3), parse each file and diff its
156 internal elements using ``diff_by_schema()`` from
157 ``muse.core.diff_algorithms``.
158
159 Args:
160 base: Snapshot of the earlier state (e.g. HEAD).
161 target: Snapshot of the later state (e.g. working tree).
162
163 Returns:
164 A ``StructuredDelta`` whose ``ops`` list describes every change.
165 """
166 # snapshot_diff provides the "auto diff" promised by Phase 2: any plugin
167 # that declares a DomainSchema can call this instead of writing file-set
168 # algebra from scratch. For sub-file granularity, build PatchOps on top.
169 return snapshot_diff(self.schema(), base, target)
170
171 def merge(
172 self,
173 base: StateSnapshot,
174 left: StateSnapshot,
175 right: StateSnapshot,
176 *,
177 repo_root: pathlib.Path | None = None,
178 ) -> MergeResult:
179 """Three-way merge at file granularity (fallback for cherry-pick etc.).
180
181 Implements standard three-way logic:
182 - left and right agree → use the consensus
183 - only one side changed → take that side
184 - both sides changed differently → conflict
185
186 If you implement address-keyed merge (``merge_ops``), this method is
187 only called for ``muse cherry-pick`` and contexts without structured deltas.
188
189 Args:
190 base: Common ancestor snapshot.
191 left: Snapshot from the current branch (ours).
192 right: Snapshot from the incoming branch (theirs).
193 repo_root: Path to the repository root for ``.museattributes``.
194 ``None`` in tests and non-file-system contexts.
195
196 Returns:
197 A ``MergeResult`` with ``merged`` snapshot, ``conflicts`` path list,
198 ``applied_strategies``, and ``dimension_reports``.
199 """
200 base_files = base["files"]
201 left_files = left["files"]
202 right_files = right["files"]
203
204 merged: Manifest = dict(base_files)
205 conflicts: list[str] = []
206
207 all_paths = set(base_files) | set(left_files) | set(right_files)
208 for path in sorted(all_paths):
209 b_val = base_files.get(path)
210 l_val = left_files.get(path)
211 r_val = right_files.get(path)
212
213 if l_val == r_val:
214 # Both sides agree — consensus wins (including both deleted)
215 if l_val is None:
216 merged.pop(path, None)
217 else:
218 merged[path] = l_val
219 elif b_val == l_val:
220 # Only right changed
221 if r_val is None:
222 merged.pop(path, None)
223 else:
224 merged[path] = r_val
225 elif b_val == r_val:
226 # Only left changed
227 if l_val is None:
228 merged.pop(path, None)
229 else:
230 merged[path] = l_val
231 else:
232 # Both changed differently — conflict; keep left as placeholder
233 conflicts.append(path)
234 merged[path] = l_val or r_val or b_val or ""
235
236 return MergeResult(
237 merged=SnapshotManifest(files=merged, domain=_DOMAIN_NAME, directories=[]),
238 conflicts=conflicts,
239 )
240
241 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
242 """Report how much the working tree has drifted from the last commit.
243
244 Called by ``muse status``. Produces a ``DriftReport`` dataclass with
245 ``has_drift``, ``summary``, and ``delta`` fields.
246
247 Args:
248 committed: The last committed snapshot.
249 live: Current live state (path or snapshot manifest).
250
251 Returns:
252 A ``DriftReport`` describing what has changed since the last commit.
253 """
254 current = self.snapshot(live)
255 delta = self.diff(committed, current)
256 has_drift = len(delta["ops"]) > 0
257 return DriftReport(
258 has_drift=has_drift,
259 summary=delta["summary"],
260 delta=delta,
261 )
262
263 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
264 """Apply a delta to the working tree.
265
266 Called by ``muse checkout`` after the core engine has already restored
267 file-level objects from the object store. Use this hook for any
268 domain-level post-processing (e.g. recompiling derived artefacts,
269 updating an index).
270
271 For most domains this is a no-op — the core engine handles file
272 restoration and nothing more is needed.
273
274 Args:
275 delta: The typed operation list to apply.
276 live_state: Current live state.
277
278 Returns:
279 The updated live state.
280 """
281 # TODO: add domain-level post-processing if needed.
282 return live_state
283
284 # ------------------------------------------------------------------
285 # Domain schema — required
286 # ------------------------------------------------------------------
287
288 def schema(self) -> DomainSchema:
289 """Declare the structural shape of this domain's data.
290
291 The schema drives diff algorithm selection, the ``muse domains``
292 capability display, and routing between three-way and CRDT merge.
293
294 Returns:
295 A ``DomainSchema`` describing the top-level element type, semantic
296 dimensions, merge mode, and schema version.
297 """
298 # TODO: replace with your domain's actual elements and dimensions.
299 return DomainSchema(
300 domain=_DOMAIN_NAME,
301 description=(
302 "Scaffold domain — replace this description with your domain's purpose. "
303 "TODO: update domain, description, top_level, and dimensions."
304 ),
305 top_level=SetSchema(
306 kind="set",
307 element_type="record", # TODO: rename to your element type
308 identity="by_content",
309 ),
310 dimensions=[
311 DimensionSpec(
312 name="primary",
313 description=(
314 "Primary data dimension. "
315 "TODO: rename and describe what this dimension represents."
316 ),
317 schema=SequenceSchema(
318 kind="sequence",
319 element_type="record", # TODO: rename
320 identity="by_position",
321 diff_algorithm="lcs",
322 alphabet=None,
323 ),
324 independent_merge=True,
325 ),
326 DimensionSpec(
327 name="metadata",
328 description=(
329 "Metadata / annotation dimension. "
330 "TODO: rename or remove if not applicable."
331 ),
332 schema=SetSchema(
333 kind="set",
334 element_type="label", # TODO: rename
335 identity="by_content",
336 ),
337 independent_merge=True,
338 ),
339 ],
340 merge_mode="three_way", # TODO: change to "crdt" if implementing CRDT convergent merge
341 schema_version=__version__,
342 )
343
344 # ------------------------------------------------------------------
345 # AddressedMergePlugin — optional address-keyed map merge extension
346 # Remove this method if your domain does not need sub-file merge.
347 # ------------------------------------------------------------------
348
349 def merge_ops(
350 self,
351 base: StateSnapshot,
352 ours_snap: StateSnapshot,
353 theirs_snap: StateSnapshot,
354 ours_ops: list[DomainOp],
355 theirs_ops: list[DomainOp],
356 *,
357 repo_root: pathlib.Path | None = None,
358 ) -> MergeResult:
359 """Operation-level three-way merge using address-keyed map semantics.
360
361 The core engine calls this when both branches have a ``StructuredDelta``.
362 ``merge_op_lists`` determines which ops commute (auto-mergeable) and
363 which conflict (need human resolution via Harmony).
364
365 Args:
366 base: Common ancestor snapshot.
367 ours_snap: Our branch's final snapshot.
368 theirs_snap: Their branch's final snapshot.
369 ours_ops: Our branch's typed operation list.
370 theirs_ops: Their branch's typed operation list.
371 repo_root: Repository root path for ``.museattributes`` loading.
372
373 Returns:
374 A ``MergeResult`` whose ``conflicts`` list is empty if all ops
375 commute (can auto-merge) or populated for genuine conflicts.
376 """
377 result = merge_op_lists(
378 base_ops=[],
379 ours_ops=ours_ops,
380 theirs_ops=theirs_ops,
381 )
382
383 conflicts: list[str] = []
384 if result.conflict_ops:
385 seen: set[str] = set()
386 for our_op, _their_op in result.conflict_ops:
387 seen.add(our_op["address"])
388 conflicts = sorted(seen)
389
390 # Fallback re-runs the file-level three-way merge to produce the
391 # merged manifest. We also propagate any file-level conflicts it
392 # detects that the OT check missed (e.g. mixed op types or commuting
393 # ops on the same file whose merged blob cannot be reconstructed
394 # without a text-merge pass).
395 fallback = self.merge(base, ours_snap, theirs_snap, repo_root=repo_root)
396 ot_conflict_paths: set[str] = set(conflicts)
397 auto_resolved_paths: set[str] = {
398 p for p, strat in fallback.applied_strategies.items()
399 if strat in ("ours", "theirs", "base", "union")
400 }
401 propagated: list[str] = [
402 p for p in fallback.conflicts
403 if p not in ot_conflict_paths and p not in auto_resolved_paths
404 ]
405 all_conflicts: list[str] = conflicts + sorted(propagated)
406 return MergeResult(
407 merged=fallback.merged,
408 conflicts=all_conflicts,
409 applied_strategies=fallback.applied_strategies,
410 dimension_reports=fallback.dimension_reports,
411 )
412
413 # ------------------------------------------------------------------
414 # CRDTPlugin — optional convergent merge extension
415 # Remove these methods and CRDTPlugin from the base classes if your
416 # domain does not need convergent multi-agent join semantics.
417 # ------------------------------------------------------------------
418
419 def crdt_schema(self) -> list[CRDTDimensionSpec]:
420 """Declare which dimensions use which CRDT primitive.
421
422 Returns:
423 One ``CRDTDimensionSpec`` per CRDT-enabled dimension.
424 """
425 # TODO: replace with your domain's CRDT dimensions.
426 return [
427 CRDTDimensionSpec(
428 name="labels",
429 description="Annotation labels — concurrent adds win.",
430 crdt_type="or_set",
431 independent_merge=True,
432 ),
433 ]
434
435 def join(
436 self,
437 a: CRDTSnapshotManifest,
438 b: CRDTSnapshotManifest,
439 ) -> CRDTSnapshotManifest:
440 """Convergent join of two CRDT snapshot manifests.
441
442 ``join`` always succeeds — no conflict state ever exists.
443
444 Args:
445 a: First CRDT snapshot manifest.
446 b: Second CRDT snapshot manifest.
447
448 Returns:
449 The joined manifest (least upper bound of ``a`` and ``b``).
450 """
451 # TODO: join each CRDT dimension declared in crdt_schema().
452 vc_a = VectorClock.from_dict(a["vclock"])
453 vc_b = VectorClock.from_dict(b["vclock"])
454 merged_vc = vc_a.merge(vc_b)
455
456 # ORSet stores per-label OR-Set state serialised as JSON strings
457 labels_a = ORSet.from_dict(json.loads(a["crdt_state"].get("labels", "{}")))
458 labels_b = ORSet.from_dict(json.loads(b["crdt_state"].get("labels", "{}")))
459 merged_labels = labels_a.join(labels_b)
460
461 return CRDTSnapshotManifest(
462 files=a["files"],
463 domain=_DOMAIN_NAME,
464 vclock=merged_vc.to_dict(),
465 crdt_state={"labels": json.dumps(merged_labels.to_dict())},
466 schema_version=__version__,
467 )
468
469 def to_crdt_state(self, snapshot: StateSnapshot) -> CRDTSnapshotManifest:
470 """Lift a plain snapshot into CRDT state.
471
472 Called when merging a snapshot produced before CRDT mode was enabled,
473 or when bootstrapping CRDT state for the first time.
474
475 Args:
476 snapshot: A plain ``SnapshotManifest``.
477
478 Returns:
479 A ``CRDTSnapshotManifest`` with empty CRDT state.
480 """
481 return CRDTSnapshotManifest(
482 files=snapshot["files"],
483 domain=_DOMAIN_NAME,
484 vclock=VectorClock().to_dict(),
485 crdt_state={"labels": json.dumps(ORSet().to_dict())},
486 schema_version=__version__,
487 )
488
489 def from_crdt_state(self, crdt: CRDTSnapshotManifest) -> StateSnapshot:
490 """Materialise a CRDT manifest back into a plain snapshot.
491
492 Called after a CRDT join to produce the snapshot the core engine writes
493 to the commit record.
494
495 Args:
496 crdt: A ``CRDTSnapshotManifest``.
497
498 Returns:
499 A plain ``SnapshotManifest``.
500 """
501 return SnapshotManifest(files=crdt["files"], domain=_DOMAIN_NAME, directories=[])
File History 13 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 50 days ago
sha256:38c900fd86a94a8fc5077b9aba718b6198945ccbc165da69b9383f4d818ff829 refactor: delete StructuredMergePlugin alias — AddressedMer… Sonnet 4.6 minor 51 days ago
sha256:be3641f35bdbcc094677776a77b9aa6a5dab891f8fab201dc162d03c2bab5aea fix(read): strip position:null from structured_delta ops in… Sonnet 4.6 patch 51 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 57 days ago