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