From 14911aae21ec4975f7a805b91e3c4867cb41974b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 12 Oct 2026 00:17:32 +0200 Subject: feat: let the operator pause and resume a root's indexing (MNP 6.2) A whole-root walk now checks its root before every file: removed or ejected, it stops instead of reading the drive to the end; paused, it finishes from the hash cache alone. A paused root is not watched, not swept by reconcile and survives a restart (roster), its indexed files stay served. Resume runs the reconcile sweep over the root, so entries and their enrichment are kept. Signed root_pause / root_resume over MNP, loopback routes, CLI `root pause|resume`, a pause button on the indexing dock and pause/resume in the roots table of Group settings. Co-Authored-By: Claude Opus 5.5 --- .../kotlin/org/meshbay/client/keys/Transcripts.kt | 2 +- packages/meshbay-client/src/main.js | 2 + packages/meshbay-client/src/transcripts.js | 2 +- .../meshbay-common/src/meshbay_common/__init__.py | 8 +- .../meshbay-common/src/meshbay_common/adminop.py | 5 + .../meshbay-common/src/meshbay_common/protocol.py | 6 + .../src/meshbay_hub/static/group-page.js | 8 +- .../src/meshbay_hub/static/group-settings.js | 31 +- .../src/meshbay_hub/static/index-dock-model.js | 25 +- .../src/meshbay_hub/static/index-dock.js | 62 +- .../src/meshbay_hub/static/locales/de.js | 4 + .../src/meshbay_hub/static/locales/en.js | 4 + .../src/meshbay_hub/static/locales/es.js | 4 + .../src/meshbay_hub/static/locales/fr.js | 4 + .../src/meshbay_hub/static/locales/it.js | 4 + .../src/meshbay_hub/static/locales/ja.js | 4 + .../src/meshbay_hub/static/locales/nl.js | 4 + .../src/meshbay_hub/static/locales/pl.js | 4 + .../src/meshbay_hub/static/locales/pt-BR.js | 4 + .../src/meshbay_hub/static/locales/zh-CN.js | 4 + .../meshbay-hub/src/meshbay_hub/static/style.css | 2 + .../src/meshbay_hub/static/transport-admin.js | 21 + .../src/meshbay_hub/static/transport.js | 10 +- packages/meshbay-hub/tests/test_indexing_dock.py | 34 +- .../meshbay-node/src/meshbay_node/cli/groups.py | 29 +- .../meshbay-node/src/meshbay_node/cli/parser.py | 4 +- packages/meshbay-node/src/meshbay_node/daemon.py | 17 +- .../src/meshbay_node/indexer/indexer.py | 281 ++++-- .../meshbay-node/src/meshbay_node/ops/__init__.py | 4 + .../meshbay-node/src/meshbay_node/ops/roots.py | 73 +- packages/meshbay-node/src/meshbay_node/roots.py | 15 +- packages/meshbay-node/src/meshbay_node/roster.py | 25 + .../src/meshbay_node/transport/webrtc/admin.py | 4 + .../src/meshbay_node/transport/webrtc/dispatch.py | 2 + .../src/meshbay_node/transport/webrtc/node_ops.py | 62 +- packages/meshbay-node/src/meshbay_node/ui/app.py | 10 + packages/meshbay-node/tests/golden/cli.json | 4 +- packages/meshbay-node/tests/golden/dispatch.json | 982 ++++++++++++++++++--- .../meshbay-node/tests/test_root_availability.py | 2 +- .../tests/test_root_paths_are_operator_only.py | 2 +- packages/meshbay-node/tests/test_root_pause.py | 313 +++++++ packages/meshbay-node/tests/test_roots.py | 13 +- 42 files changed, 1864 insertions(+), 236 deletions(-) create mode 100644 packages/meshbay-node/tests/test_root_pause.py (limited to 'packages') diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt index a48c4cf..b2d3d68 100644 --- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt +++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/keys/Transcripts.kt @@ -171,7 +171,7 @@ class Transcripts(private val now: () -> Double = { System.currentTimeMillis() / "invite_cancel", "member_revoke", "apps_enabled", "set_scan_settings", "tmdb_config", "tmdb_enabled", "tmdb_override", "tmdb_rematch", "musicbrainz_enabled", "root_remove", "root_eject", "root_plug", - "app_directories", "chat_directory", "chat_link_preview", "search_listed", + "root_pause", "root_resume", "app_directories", "chat_directory", "chat_link_preview", "search_listed", "chat_epoch", "chat_purge", ) const val PREFIX_JOIN = "meshbay:join:v1" diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index 1af4386..33388e8 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -2396,6 +2396,8 @@ Its log: ${nodeLogHint()}`); updateRoot: (a) => ['PATCH', root(a), anObject(a.updates)], ejectRoot: (a) => ['PUT', `${root(a)}/eject`], plugRoot: (a) => ['PUT', `${root(a)}/plug`], + pauseRoot: (a) => ['PUT', `${root(a)}/pause`], + resumeRoot: (a) => ['PUT', `${root(a)}/resume`], removeRoot: (a) => ['DELETE', root(a)], initGek: (a) => ['POST', `${group(a)}/gek${a.rotate ? '?rotate=true' : ''}`], pairOperator: () => ['POST', '/api/operator/pair'], diff --git a/packages/meshbay-client/src/transcripts.js b/packages/meshbay-client/src/transcripts.js index ffba3bf..8119d71 100644 --- a/packages/meshbay-client/src/transcripts.js +++ b/packages/meshbay-client/src/transcripts.js @@ -42,7 +42,7 @@ const ADMIN_OPS = new Set([ 'invite_cancel', 'member_revoke', 'apps_enabled', 'set_scan_settings', 'tmdb_config', 'tmdb_enabled', 'tmdb_override', 'tmdb_rematch', 'musicbrainz_enabled', 'root_remove', 'root_eject', 'root_plug', - 'app_directories', 'chat_directory', 'chat_link_preview', 'search_listed', + 'root_pause', 'root_resume', 'app_directories', 'chat_directory', 'chat_link_preview', 'search_listed', 'chat_epoch', 'chat_purge', ]); diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py index e8a9579..b86dec2 100644 --- a/packages/meshbay-common/src/meshbay_common/__init__.py +++ b/packages/meshbay-common/src/meshbay_common/__init__.py @@ -276,5 +276,11 @@ __version__ = "0.19.0" # 6.1 (2026-10-08) adds the signed `chat_purge` / `chat_purge_ack`: the operator # deletes a group's stored chat. Additive — a 6.0 node answers nothing, as for # any unknown type, and nothing else changes. The floor stays at 4.0. -MNP_VERSION = "6.1" +# +# 6.2 (2026-10-11) adds the signed `root_pause` / `root_resume` and their acks: +# the operator stops a root's indexing and starts it again, and each root in +# the roots table carries `paused`. Additive — a 6.1 node answers nothing to +# either, and a client that ignores `paused` shows a paused root as an ordinary +# one. The floor stays at 4.0. +MNP_VERSION = "6.2" MHP_VERSION = "0.1" diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py index f59b420..43d2a7f 100644 --- a/packages/meshbay-common/src/meshbay_common/adminop.py +++ b/packages/meshbay-common/src/meshbay_common/adminop.py @@ -113,6 +113,11 @@ OP_CHAT_EPOCH = "chat_epoch" OP_CHAT_PURGE = "chat_purge" OP_ROOT_EJECT = "root_eject" OP_ROOT_PLUG = "root_plug" +# Stop and restart the indexing of one root (MNP 6.2). The subject is the root +# name, like eject and plug. Pausing narrows what the node does and resuming +# returns to what the operator already shared, so neither widens anything. +OP_ROOT_PAUSE = "root_pause" +OP_ROOT_RESUME = "root_resume" # Also gone with 6.0, because no client ever sent them: `gek_rotate`, # `member_unpin`, `transfer_limits` and `group_detach`. Rotating the group key, # forgetting an identity, the per-member transfer caps and no longer hosting a diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 5073b66..e5ceaa7 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -252,6 +252,12 @@ class MNP: ROOT_EJECT_ACK = "root_eject_ack" ROOT_PLUG = "root_plug" # operator → node: re-enable an ejected root ROOT_PLUG_ACK = "root_plug_ack" + # Operator → node: stop / restart indexing one root (MNP 6.2). The acks are + # broadcast, like eject's, so every open table shows the new state. + ROOT_PAUSE = "root_pause" + ROOT_PAUSE_ACK = "root_pause_ack" + ROOT_RESUME = "root_resume" + ROOT_RESUME_ACK = "root_resume_ack" # Member → node: who is in this group and which device keys they hold, with # the countersignature that admitted each one. Scoped to one group and # answers any member of it, because the point is that a member diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 48b1c80..3e3b483 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -510,6 +510,9 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, setNodeRoots(msg.roots); } }; + // The dock's pause button, for a node that is not on this machine. + const pauseFromDock = (rootName) => + transport.pauseRoot(groupId, rootName, transport.signFn); // The node's own scan (a root added while we were already connected, // or reconcile catching one back up) — never the entries, just // enough to animate the sidebar dot. Guaranteed a final push at the @@ -526,7 +529,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, // The operator's indexing dock. The push names no root, so the // page that opened the roots table names it. if (transport.memberRole === 'operator') { - reportIndexPush(groupId, status, nodeRootsRef.current); + reportIndexPush(groupId, status, nodeRootsRef.current, pauseFromDock); } }; setOperatorPaired(transport.memberRole === 'operator'); @@ -637,7 +640,8 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, } } if (transport.memberRole === 'operator' && ack.indexing) { - reportIndexPush(groupId, ack.indexing, indexMsg.roots || nodeRootsRef.current); + reportIndexPush(groupId, ack.indexing, indexMsg.roots || nodeRootsRef.current, + pauseFromDock); } } catch (err) { if (cancelled) return; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index 1a367be..a666c29 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -55,8 +55,9 @@ async function readLocalNode(node, groupId, attempts = NODE_CHECK_ATTEMPTS, * mode="live" — a hosted group. What widens the node's sharing — adding * a directory, its `writable` and `removable` switches — goes * through the loopback API only, so only on the node's own - * machine. Removing, ejecting and plugging are signed ops - * over MNP, or the loopback API when there is no connection. + * machine. Removing, ejecting, plugging, pausing and + * resuming are signed ops over MNP, or the loopback API when + * there is no connection. * mode="local" — the wizard, before the group exists. Changes are held in * an array the caller owns; nothing is persisted until the * group is attached. @@ -72,7 +73,7 @@ async function readLocalNode(node, groupId, attempts = NODE_CHECK_ATTEMPTS, * * Props: * roots — the node's current roots: { name, path, writable, - * removable, ejected, available, kind } + * removable, ejected, paused, available, kind } * groupId — the group id * transport — MeshBayTransport instance, or null when not connected * signFn — signing function for admin ops @@ -153,7 +154,7 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, // signature proves that the operator's key signed, not that the operator // meant it: in a browser that key is driven by code the hub serves. // - // Narrowing — remove, eject, plug — MNP first, then loopback. + // Narrowing — remove, eject, plug, pause, resume — MNP first, then loopback. const overMnp = !isLocal && transport && transport.connected; const overLoopback = !isLocal && !overMnp && nodeAvail; const onNodeMachine = !isLocal && nodeAvail; @@ -220,6 +221,18 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, else throw new Error(t('node.root_no_route')); }), [overMnp, overLoopback, transport, groupId, signFn, run]); + const doPauseRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.pauseRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.op('pauseRoot', { groupId, rootName }); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); + + const doResumeRoot = useCallback((rootName) => run(async () => { + if (overMnp) await transport.resumeRoot(groupId, rootName, signFn); + else if (overLoopback) await platform.node.op('resumeRoot', { groupId, rootName }); + else throw new Error(t('node.root_no_route')); + }), [overMnp, overLoopback, transport, groupId, signFn, run]); + const doRemoveRoot = useCallback(async (rootName) => { if (isLocal) { if (onLocalRootsChange) { @@ -321,6 +334,8 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, ${t('group.root_ejected')}`} ${!isLocal && r.available === false && !r.ejected && html` ${t('node.unavailable')}`} + ${!isLocal && r.paused && html` + ${t('node.root_paused')}`} ${hasPaths && html` ${r.path || ''}`} @@ -346,6 +361,14 @@ function SharedDirectoriesTable({ roots, groupId, transport, signFn, `} + ${canEdit && !isLocal && html` + + `} ${canEdit && !isLocal && html` + ${!done && onPause && html` + + `} ${!done && html`