diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
| commit | 2c0903c648e24b4e2adf20492398e8b67d033b49 (patch) | |
| tree | 0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-node/src/meshbay_node/roots.py | |
| parent | 0ed078c92cabab1dab0f70f321562032ea549ce6 (diff) | |
| parent | eeda274d751c537f4ecef3087994a16a9517478f (diff) | |
| download | meshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz | |
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3.
The root model replaces the old `upload` flag and group-wide `member_upload`
with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that
both front doors — the loopback API and signed MNP — reach through the same
`ops` functions. MNP goes to 1.1, additively: the roots table now rides on
`index_delta`, so a root added, removed, ejected or plugged reaches every
connected client instead of only whoever reloaded.
The group UI becomes a plugin architecture: an application is a registry
entry in `apps.js` plus its own files, with directories stored generically
by `ops.set_app_directories` under whatever the app is called. A reference
application, hidden behind `?dev=1`, is what makes that claim testable —
adding it is what found the two places still naming apps by hand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-node/src/meshbay_node/roots.py')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/roots.py | 101 |
1 files changed, 64 insertions, 37 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/roots.py b/packages/meshbay-node/src/meshbay_node/roots.py index 74ea2f6..d288231 100644 --- a/packages/meshbay-node/src/meshbay_node/roots.py +++ b/packages/meshbay-node/src/meshbay_node/roots.py @@ -120,10 +120,9 @@ class Root: name: str path: Path kind: str = "generic" - upload: bool = False - direct: bool = False - # Runtime, not configuration: set by the indexer when the directory can no - # longer be read, and cleared when it comes back. + writable: bool = False + removable: bool = False + ejected: bool = False available: bool = True @property @@ -165,6 +164,13 @@ class RootSet: roots: list[Root] = field(default_factory=list) + # Roots this set ejected by itself — a removable device that went away + # without the operator clicking Eject. Drained by the indexer, which is + # the only caller holding a roster to write the state to. Without that + # the flag is lost on the next restart, and the surprise unplug looks + # like a deletion all over again on the pass after it. + auto_ejected: list[str] = field(default_factory=list) + # ── Construction ───────────────────────────────────────────────────────── @classmethod @@ -172,8 +178,9 @@ class RootSet: """ Build from configuration, refusing anything ambiguous. - `specs` are dicts with `path`, and optionally `name`, `kind`, `upload`. - Raises RootError with a message meant for an operator reading a log. + `specs` are dicts with `path`, and optionally `name`, `kind`, `writable`, + `removable`. Raises RootError with a message meant for an operator reading + a log. """ roots: list[Root] = [] by_folded: dict[str, Root] = {} @@ -209,34 +216,22 @@ class RootSet: log.warning("root %r: unknown kind %r — using 'generic'", name, kind) kind = "generic" + # Backward compat: old configs use `upload` instead of `writable` + writable = bool(spec.get("writable", spec.get("upload", False))) + # `ejected` is runtime state, not configuration — it reaches here + # only from the roster, restored at startup so a drive ejected + # before a restart does not come back on its own. root = Root(name=name, path=path, kind=kind, - upload=bool(spec.get("upload", False)), - direct=bool(spec.get("direct", False))) + writable=writable, + removable=bool(spec.get("removable", False)), + ejected=bool(spec.get("ejected", False)), + available=not bool(spec.get("ejected", False))) _refuse_nesting(root, roots) roots.append(root) by_folded[root.folded] = root - cls._settle_upload_root(roots) return cls(roots=roots) - @staticmethod - def _settle_upload_root(roots: list[Root]) -> None: - """ - Exactly one root receives uploads, and the operator picks it. - - Not guessed when several are marked, because "uploads went somewhere - else" is discovered weeks later. With none marked and a single root, the - answer is not ambiguous, so it is taken. - """ - marked = [r for r in roots if r.upload] - if len(marked) > 1: - names = ", ".join(r.name for r in marked) - raise RootError( - f"several roots are marked upload = true ({names}) — exactly one " - f"receives uploads") - if not marked and len(roots) == 1: - roots[0].upload = True - # ── Lookup ─────────────────────────────────────────────────────────────── def by_name(self, name: str) -> Root | None: @@ -247,11 +242,8 @@ class RootSet: return None @property - def upload_root(self) -> Root | None: - for root in self.roots: - if root.upload: - return root - return None + def writable_roots(self) -> list[Root]: + return [r for r in self.roots if r.writable] @property def names(self) -> list[str]: @@ -336,10 +328,28 @@ class RootSet: Called periodically and after a filesystem event that looks like a disappearance. A change here never edits the index: a root going away freezes its entries, and a root coming back triggers a rescan. + + An ejected root stays unavailable regardless of `is_live()` — the + operator must explicitly plug it back. A removable root whose path + disappears without an eject is auto-ejected as a safety net. """ changed: list[tuple[Root, bool]] = [] for root in self.roots: + if root.ejected: + if root.available: + root.available = False + changed.append((root, False)) + continue live = root.is_live() + if not live and root.removable: + root.ejected = True + # Recorded for the caller to persist. A flag that only lives + # in memory would be forgotten on the next restart, and the + # rescan that followed would read an empty mount point as an + # erased library — the exact outcome eject exists to prevent. + self.auto_ejected.append(root.name) + log.warning("Root %r auto-ejected (device disappeared): %s", + root.name, root.path) if live != root.available: root.available = live changed.append((root, live)) @@ -347,14 +357,31 @@ class RootSet: "available" if live else "UNAVAILABLE", root.path) return changed - def describe(self) -> list[dict]: - """Per-root state for the index payload and the admin UI.""" + def describe(self, *, with_paths: bool = False) -> list[dict]: + """ + Per-root state for the index payload and the admin UI. + + Deliberately no paths by default: this is what every member receives. + `with_paths=True` is the operator's own view, over a channel that is + already theirs alone (loopback + run token). + """ out = [] for r in self.roots: d: dict = {"name": r.name, "kind": r.kind, - "available": r.available, "upload": r.upload} - if r.direct: - d["direct"] = True + "available": r.available, + "writable": r.writable, + "removable": r.removable, + "ejected": r.ejected, + # Backward compat for MNP 1.0 clients + "upload": r.writable} + # `with_paths` is for the operator's *own* channels only — the + # loopback API and the CLI reading it, both of which already + # require being on this machine with the run token. A member is + # told what exists and whether it is readable, never where on the + # operator's disk it lives, and the index payload every member + # receives must keep calling this without the flag. + if with_paths: + d["path"] = str(r.path) out.append(d) return out |