plugin.py
python
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3
docs: revert migrate hub-scoping/domain-integers rows from …
Sonnet 5
12 hours ago
| 1 | """Todo domain plugin — a flat, content-addressed set of tasks. |
| 2 | |
| 3 | Built live in Build With Muse, Episode 06, starting from |
| 4 | ``muse/plugins/scaffold``. Implements only the required six-method core |
| 5 | protocol (no address-keyed merge extension, no CRDT) — the simplest |
| 6 | possible real domain: one tracked file, ``todo.txt``, one task per line. |
| 7 | |
| 8 | Tasks are addressed by their own content, not by line position. Two |
| 9 | branches adding two different tasks never conflict, no matter what — |
| 10 | that's the whole point of an address-keyed set. |
| 11 | """ |
| 12 | |
| 13 | import hashlib |
| 14 | import os |
| 15 | import pathlib |
| 16 | import stat as _stat |
| 17 | |
| 18 | from muse._version import __version__ |
| 19 | from muse.core.object_store import read_object, write_object |
| 20 | from muse.core.schema import DomainSchema, SetSchema |
| 21 | from muse.core.types import Manifest, blob_id |
| 22 | from muse.domain import ( |
| 23 | AddressedDeleteOp, |
| 24 | AddressedInsertOp, |
| 25 | DriftReport, |
| 26 | LiveState, |
| 27 | MergeResult, |
| 28 | PatchOp, |
| 29 | SnapshotManifest, |
| 30 | StateDelta, |
| 31 | StateSnapshot, |
| 32 | StructuredDelta, |
| 33 | ) |
| 34 | |
| 35 | _DOMAIN_NAME = "todo" |
| 36 | _TASK_FILE = "todo.txt" |
| 37 | |
| 38 | |
| 39 | def _task_address(task: str) -> str: |
| 40 | digest = hashlib.sha256(task.encode("utf-8")).hexdigest()[:12] |
| 41 | return f"{_TASK_FILE}::{digest}" |
| 42 | |
| 43 | |
| 44 | def _task_content_id(task: str) -> str: |
| 45 | return blob_id(task.encode("utf-8")) |
| 46 | |
| 47 | |
| 48 | def _load_tasks(repo_root: pathlib.Path | None, snap: StateSnapshot) -> set[str]: |
| 49 | object_id = snap["files"].get(_TASK_FILE) |
| 50 | if object_id is None or repo_root is None: |
| 51 | return set() |
| 52 | raw = read_object(repo_root, object_id) |
| 53 | if raw is None: |
| 54 | # Uncommitted working-tree state: snapshot() hashes the live file |
| 55 | # but never writes it to the object store (only `commit` does that, |
| 56 | # for whichever paths actually get committed) -- so for drift/diff |
| 57 | # against live state, fall back to reading the file straight off |
| 58 | # disk rather than the store. |
| 59 | live_path = repo_root / _TASK_FILE |
| 60 | if not live_path.is_file(): |
| 61 | return set() |
| 62 | raw = live_path.read_bytes() |
| 63 | return {line for line in raw.decode("utf-8").splitlines() if line.strip()} |
| 64 | |
| 65 | |
| 66 | def _write_tasks(repo_root: pathlib.Path, tasks: set[str]) -> str: |
| 67 | body = "\n".join(sorted(tasks)) |
| 68 | if body: |
| 69 | body += "\n" |
| 70 | raw = body.encode("utf-8") |
| 71 | object_id = blob_id(raw) |
| 72 | write_object(repo_root, object_id, raw) |
| 73 | return object_id |
| 74 | |
| 75 | |
| 76 | class TodoPlugin: |
| 77 | """A flat todo list. One file, one task per line, content-addressed.""" |
| 78 | |
| 79 | def snapshot(self, live_state: LiveState) -> StateSnapshot: |
| 80 | if isinstance(live_state, pathlib.Path): |
| 81 | workdir = live_state |
| 82 | todo_path = workdir / _TASK_FILE |
| 83 | files: Manifest = {} |
| 84 | if todo_path.is_file(): |
| 85 | st = os.lstat(todo_path) |
| 86 | if _stat.S_ISREG(st.st_mode): |
| 87 | raw = todo_path.read_bytes() |
| 88 | files[_TASK_FILE] = blob_id(raw) |
| 89 | return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[]) |
| 90 | return live_state |
| 91 | |
| 92 | def diff( |
| 93 | self, |
| 94 | base: StateSnapshot, |
| 95 | target: StateSnapshot, |
| 96 | *, |
| 97 | repo_root: pathlib.Path | None = None, |
| 98 | ) -> StateDelta: |
| 99 | base_oid = base["files"].get(_TASK_FILE) |
| 100 | target_oid = target["files"].get(_TASK_FILE) |
| 101 | if base_oid == target_oid: |
| 102 | return StructuredDelta(domain=_DOMAIN_NAME, ops=[], summary="no change") |
| 103 | |
| 104 | base_tasks = _load_tasks(repo_root, base) |
| 105 | target_tasks = _load_tasks(repo_root, target) |
| 106 | |
| 107 | # Task-level detail, nested inside the file-level op below. This is |
| 108 | # what makes the diff *structured* rather than "todo.txt changed" -- |
| 109 | # each task is its own address, so two different additions never |
| 110 | # collide, no matter which file they both happen to live in. |
| 111 | child_ops: list[AddressedInsertOp | AddressedDeleteOp] = [] |
| 112 | for task in sorted(target_tasks - base_tasks): |
| 113 | child_ops.append(AddressedInsertOp( |
| 114 | op="insert", address=_task_address(task), |
| 115 | content_id=_task_content_id(task), content_summary=f"added: {task}", |
| 116 | )) |
| 117 | for task in sorted(base_tasks - target_tasks): |
| 118 | child_ops.append(AddressedDeleteOp( |
| 119 | op="delete", address=_task_address(task), |
| 120 | content_id=_task_content_id(task), content_summary=f"removed: {task}", |
| 121 | )) |
| 122 | |
| 123 | added = len(target_tasks - base_tasks) |
| 124 | removed = len(base_tasks - target_tasks) |
| 125 | summary = f"{added} task(s) added, {removed} task(s) removed" |
| 126 | |
| 127 | # The outer op is keyed by the *file* (todo.txt) -- this is what |
| 128 | # `muse checkout` actually reads to know which object to restore or |
| 129 | # delete on disk. The task-level child_ops above are for display and |
| 130 | # merge reasoning; checkout only ever looks at the outer address. |
| 131 | if base_oid is None: |
| 132 | ops = [PatchOp( |
| 133 | op="patch", address=_TASK_FILE, child_ops=child_ops, |
| 134 | child_domain=_DOMAIN_NAME, child_summary=summary, file_change="added", |
| 135 | )] |
| 136 | elif target_oid is None: |
| 137 | ops = [PatchOp( |
| 138 | op="patch", address=_TASK_FILE, child_ops=child_ops, |
| 139 | child_domain=_DOMAIN_NAME, child_summary=summary, file_change="deleted", |
| 140 | )] |
| 141 | else: |
| 142 | ops = [PatchOp( |
| 143 | op="patch", address=_TASK_FILE, child_ops=child_ops, |
| 144 | child_domain=_DOMAIN_NAME, child_summary=summary, file_change="modified", |
| 145 | )] |
| 146 | |
| 147 | return StructuredDelta(domain=_DOMAIN_NAME, ops=ops, summary=summary) |
| 148 | |
| 149 | def merge( |
| 150 | self, |
| 151 | base: StateSnapshot, |
| 152 | left: StateSnapshot, |
| 153 | right: StateSnapshot, |
| 154 | *, |
| 155 | repo_root: pathlib.Path | None = None, |
| 156 | ) -> MergeResult: |
| 157 | base_tasks = _load_tasks(repo_root, base) |
| 158 | left_tasks = _load_tasks(repo_root, left) |
| 159 | right_tasks = _load_tasks(repo_root, right) |
| 160 | |
| 161 | removed = (base_tasks - left_tasks) | (base_tasks - right_tasks) |
| 162 | merged_tasks = (base_tasks | left_tasks | right_tasks) - removed |
| 163 | |
| 164 | if repo_root is not None: |
| 165 | object_id = _write_tasks(repo_root, merged_tasks) |
| 166 | files: Manifest = {_TASK_FILE: object_id} if merged_tasks else {} |
| 167 | else: |
| 168 | files = {} |
| 169 | |
| 170 | # Address-keyed elements never conflict with each other — only a |
| 171 | # true same-address edit could, and this domain has no edit op, |
| 172 | # only add/remove of whole, content-addressed tasks. |
| 173 | return MergeResult( |
| 174 | merged=SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[]), |
| 175 | conflicts=[], |
| 176 | ) |
| 177 | |
| 178 | def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport: |
| 179 | current = self.snapshot(live) |
| 180 | repo_root = live if isinstance(live, pathlib.Path) else None |
| 181 | delta = self.diff(committed, current, repo_root=repo_root) |
| 182 | return DriftReport( |
| 183 | has_drift=len(delta["ops"]) > 0, |
| 184 | summary=delta["summary"], |
| 185 | delta=delta, |
| 186 | ) |
| 187 | |
| 188 | def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState: |
| 189 | # The core engine already restores todo.txt's bytes from the object |
| 190 | # store during checkout — nothing domain-specific to do here. |
| 191 | return live_state |
| 192 | |
| 193 | def schema(self) -> DomainSchema: |
| 194 | return DomainSchema( |
| 195 | domain=_DOMAIN_NAME, |
| 196 | description=( |
| 197 | "A flat todo list. Tasks are content-addressed, unordered " |
| 198 | "elements in a single file (todo.txt) — order never matters, " |
| 199 | "so two people adding two different tasks never conflicts." |
| 200 | ), |
| 201 | top_level=SetSchema(kind="set", element_type="task", identity="by_content"), |
| 202 | dimensions=[], |
| 203 | merge_mode="three_way", |
| 204 | schema_version=__version__, |
| 205 | ) |
File History
1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3
docs: revert migrate hub-scoping/domain-integers rows from …
Sonnet 5
12 hours ago