gabriel / muse public
plugin.py python
343 lines 13.0 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
1 """Social domain plugin — cryptographically-grounded, algo-free social network on Muse.
2
3 A social repo's working tree is a directory of JSON files organised into four
4 namespaces that map directly onto Muse primitives:
5
6 posts/<post_id>.json — individual signed posts (content-addressed)
7 reactions/<id>.json — emoji reactions, optional MPay tip attachment
8 graph/following.json — who this identity follows
9 profile.json — display name, bio, avatar ref, AVAX address
10
11 The snapshot is a standard ``SnapshotManifest`` over these files — same
12 content-addressing as every other Muse domain. Your timeline is a live
13 ``muse merge --all-remotes`` of every feed you follow. No algorithm.
14 No suppression. Pure chronological state, owned by you, signed by your
15 Ed25519 key.
16
17 Merge rules
18 -----------
19 ``posts/`` and ``reactions/``
20 Set-algebraic: both sides can independently add posts and reactions.
21 Same content hash ⇒ same ID ⇒ no conflict (reposts deduplicate naturally).
22 ``graph/``
23 Set-algebraic: following list is a content-addressed file; independent
24 changes on each branch merge without conflicts.
25 ``profile.json``
26 Last-writer-wins when only one side changed from base; conflict when both
27 sides changed the profile independently.
28
29 This is Phase 00: core plugin with full protocol compliance. CLI commands,
30 wire streaming, and MuseHub UI integration follow in subsequent phases.
31 """
32
33 import hashlib
34 import os
35 import pathlib
36 import _stat
37
38 from muse._version import __version__
39 from muse.core.diff_algorithms import snapshot_diff
40 from muse.core.schema import (
41 DimensionSpec,
42 DomainSchema,
43 SetSchema,
44 )
45 from muse.core.stat_cache import load_cache
46 from muse.core.types import Manifest
47 from muse.domain import (
48 DriftReport,
49 LiveState,
50 MergeResult,
51 MuseDomainPlugin,
52 SnapshotManifest,
53 StateDelta,
54 StateSnapshot,
55 )
56
57 _DOMAIN_NAME = "social"
58
59 class SocialPlugin:
60 """Domain plugin for social repositories.
61
62 Satisfies the full :class:`~muse.domain.MuseDomainPlugin` protocol.
63 No explicit inheritance needed — structural duck-typing applies.
64
65 All ``muse`` CLI commands work immediately on any social repo once this
66 plugin is registered. The social-specific behaviour is:
67
68 - Posts and reactions are content-addressed objects — same bytes, same ID.
69 - Merge is set-algebraic for posts, reactions, and the follow graph.
70 - Profile edits conflict when both branches change independently.
71 - Every commit carries full Ed25519 provenance via ``--sign``.
72 """
73
74 # ------------------------------------------------------------------
75 # MuseDomainPlugin — required core protocol
76 # ------------------------------------------------------------------
77
78 def snapshot(self, live_state: LiveState) -> StateSnapshot:
79 """Capture the current social state as a content-addressed manifest.
80
81 Walks every JSON file under ``live_state`` (respecting ``.museignore``),
82 hashing raw bytes with SHA-256. Returns a ``SnapshotManifest`` whose
83 ``files`` dict maps workspace-relative POSIX paths to their digests.
84
85 Args:
86 live_state: Either a ``pathlib.Path`` pointing to the social state
87 directory, or a ``SnapshotManifest`` dict for in-memory use.
88
89 Returns:
90 A ``SnapshotManifest`` mapping social file paths to SHA-256 digests.
91 """
92 if isinstance(live_state, pathlib.Path):
93 from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns
94
95 workdir = live_state
96 patterns = resolve_patterns(load_ignore_config(workdir), _DOMAIN_NAME)
97 cache = load_cache(workdir)
98 files: Manifest = {}
99 root_str = str(workdir)
100 prefix_len = len(root_str) + 1
101
102 for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
103 dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
104 for fname in sorted(filenames):
105 if fname.startswith("."):
106 continue
107 abs_str = os.path.join(dirpath, fname)
108 try:
109 st = os.lstat(abs_str)
110 except OSError:
111 continue
112 if not _stat.S_ISREG(st.st_mode):
113 continue
114 rel = abs_str[prefix_len:]
115 if os.sep != "/":
116 rel = rel.replace(os.sep, "/")
117 if is_ignored(rel, patterns):
118 continue
119 files[rel] = cache.get_cached(
120 rel, abs_str, st.st_mtime, st.st_size, st.st_ino
121 )
122
123 cache.prune(set(files))
124 cache.save()
125 return SnapshotManifest(files=files, domain=_DOMAIN_NAME, directories=[])
126
127 return live_state
128
129 def diff(
130 self,
131 base: StateSnapshot,
132 target: StateSnapshot,
133 *,
134 repo_root: pathlib.Path | None = None,
135 ) -> StateDelta:
136 """Compute the typed operation list between two social snapshots.
137
138 Delegates to ``snapshot_diff`` which performs set algebra on the
139 ``files`` dicts: new files → InsertOp, removed files → DeleteOp,
140 replaced files → ReplaceOp.
141
142 Args:
143 base: Snapshot of the earlier state (e.g. HEAD).
144 target: Snapshot of the later state (e.g. working tree).
145
146 Returns:
147 A ``StructuredDelta`` whose ``ops`` list describes every change.
148 """
149 return snapshot_diff(self.schema(), base, target)
150
151 def merge(
152 self,
153 base: StateSnapshot,
154 left: StateSnapshot,
155 right: StateSnapshot,
156 *,
157 repo_root: pathlib.Path | None = None,
158 ) -> MergeResult:
159 """Three-way merge of two social snapshots against a common ancestor.
160
161 Merge rules by namespace:
162 - ``posts/`` and ``reactions/`` — set-algebraic: both sides can
163 independently add posts and reactions. Content-addressed IDs mean
164 the same post from both sides has no conflict.
165 - ``graph/`` — set-algebraic: independent follow-list changes merge
166 without conflicts.
167 - ``profile.json`` — last-writer-wins when only one side changed;
168 conflict when both sides independently modified the profile.
169
170 Args:
171 base: Common ancestor snapshot.
172 left: Snapshot from the current branch (ours).
173 right: Snapshot from the incoming branch (theirs).
174
175 Returns:
176 A ``MergeResult`` with ``merged`` snapshot and ``conflicts`` list.
177 """
178 base_files = base["files"]
179 left_files = left["files"]
180 right_files = right["files"]
181
182 merged: Manifest = dict(base_files)
183 conflicts: list[str] = []
184
185 all_paths = set(base_files) | set(left_files) | set(right_files)
186 for path in sorted(all_paths):
187 b_val = base_files.get(path)
188 l_val = left_files.get(path)
189 r_val = right_files.get(path)
190
191 if l_val == r_val:
192 # Both sides agree (including both deleted)
193 if l_val is None:
194 merged.pop(path, None)
195 else:
196 merged[path] = l_val
197 elif b_val == l_val:
198 # Only right changed
199 if r_val is None:
200 merged.pop(path, None)
201 else:
202 merged[path] = r_val
203 elif b_val == r_val:
204 # Only left changed
205 if l_val is None:
206 merged.pop(path, None)
207 else:
208 merged[path] = l_val
209 else:
210 # Both sides changed independently — conflict
211 conflicts.append(path)
212 merged[path] = l_val or r_val or b_val or ""
213
214 return MergeResult(
215 merged=SnapshotManifest(files=merged, domain=_DOMAIN_NAME, directories=[]),
216 conflicts=conflicts,
217 )
218
219 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
220 """Report how much the social state has drifted from the last commit.
221
222 Called by ``muse status``. Snapshots the current working tree, diffs
223 it against the committed state, and returns a ``DriftReport``.
224
225 Args:
226 committed: The last committed snapshot.
227 live: Current live state (path or snapshot manifest).
228
229 Returns:
230 A ``DriftReport`` with ``has_drift``, ``summary``, and ``delta``.
231 """
232 current = self.snapshot(live)
233 delta = self.diff(committed, current)
234 has_drift = len(delta["ops"]) > 0
235 return DriftReport(
236 has_drift=has_drift,
237 summary=delta["summary"],
238 delta=delta,
239 )
240
241 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
242 """Apply a delta to the social state.
243
244 Social files are atomic JSON blobs — the core engine handles file-level
245 object restoration during ``muse checkout``. No domain-level
246 post-processing is needed.
247
248 Args:
249 delta: The typed operation list to apply.
250 live_state: Current live state.
251
252 Returns:
253 The unchanged live state (core engine handles file writes).
254 """
255 return live_state
256
257 def schema(self) -> DomainSchema:
258 """Declare the structural shape of the social domain.
259
260 Four dimensions model the independent state namespaces of a social feed.
261 Posts and reactions are content-addressed sets — independent merge with
262 no conflict possible. The follow graph is also independently mergeable.
263 Profile edits are non-independent: concurrent changes conflict.
264
265 Returns:
266 A ``DomainSchema`` describing the social domain's structure.
267 """
268 return DomainSchema(
269 domain=_DOMAIN_NAME,
270 description=(
271 "Social domain — cryptographically-grounded, algo-free real-time "
272 "social network on Muse. Every post is a content-addressed object "
273 "signed with Ed25519. Your timeline is a live DAG merge of every "
274 "feed you follow. No algorithm. No suppression. Pure chronological "
275 "state, owned by you."
276 ),
277 top_level=SetSchema(
278 kind="set",
279 element_type="social_object",
280 identity="by_content",
281 ),
282 dimensions=[
283 DimensionSpec(
284 name="posts",
285 description=(
286 "Content-addressed post objects. Same bytes = same post ID — "
287 "reposts across followed feeds deduplicate automatically."
288 ),
289 schema=SetSchema(
290 kind="set",
291 element_type="post",
292 identity="by_content",
293 ),
294 independent_merge=True,
295 ),
296 DimensionSpec(
297 name="reactions",
298 description=(
299 "Emoji reactions with optional MPay tip attachments. "
300 "Content-addressed; concurrent reactions from both branches "
301 "always merge cleanly via set-union."
302 ),
303 schema=SetSchema(
304 kind="set",
305 element_type="reaction",
306 identity="by_content",
307 ),
308 independent_merge=True,
309 ),
310 DimensionSpec(
311 name="graph",
312 description=(
313 "Social graph state: following list, follower list. "
314 "Independent — adding a follow on one branch and a different "
315 "follow on another branch always merges without conflict."
316 ),
317 schema=SetSchema(
318 kind="set",
319 element_type="graph_entry",
320 identity="by_content",
321 ),
322 independent_merge=True,
323 ),
324 DimensionSpec(
325 name="profile",
326 description=(
327 "Identity profile: handle, bio, avatar ref, AVAX address, "
328 "linked accounts. Non-independent — concurrent profile edits "
329 "on both branches produce a conflict requiring human resolution."
330 ),
331 schema=SetSchema(
332 kind="set",
333 element_type="profile",
334 identity="by_content",
335 ),
336 independent_merge=False,
337 ),
338 ],
339 merge_mode="three_way",
340 schema_version=__version__,
341 )
342
343 plugin = SocialPlugin()
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 22 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 30 days ago