@twinsearth/w2m-dsh-plugin 1.1.2 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +85 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +8 -3
- package/plugin.json +1 -1
- package/src/plugin/fleet-target.mjs +23 -2
- package/src/plugin/join-degradation.mjs +34 -0
- package/src/plugin/tools.mjs +17 -7
- package/src/relay/admission.mjs +239 -0
- package/src/relay/join-challenge.mjs +92 -0
- package/src/relay/server.mjs +70 -0
- package/src/relay/state.mjs +46 -0
- package/src/util/account-identity.mjs +75 -0
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.1.
|
|
8
|
+
"version": "1.1.14"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.1.
|
|
15
|
+
"version": "1.1.14",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.14",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.1.14] — 2026-10-17
|
|
6
|
+
|
|
7
|
+
**A new machine joins by itself, and a machine that cannot join the old way can now prove its account and ask.**
|
|
8
|
+
|
|
9
|
+
### Joining is on by default
|
|
10
|
+
|
|
11
|
+
`autoStartAgent` now defaults to **true**. The old expression was `config.autoStartAgent === true`, which made joining
|
|
12
|
+
opt-IN, and the observed defect was a machine that was installed, running, and simply not in the fleet -- "installed,
|
|
13
|
+
paired: false" with no hint of why. `false` is still the way to turn it off.
|
|
14
|
+
|
|
15
|
+
Making this the default required changing the meaning of the tests that intentionally ran without an agent: eight suites
|
|
16
|
+
now pin `autoStartAgent: false` through one `applyPinned` wrapper each, and the single case that asserts a STARTED node
|
|
17
|
+
exits cleanly passes `autoStartAgent: true` inside its own template script. Two bugs were found while doing it, and both
|
|
18
|
+
are recorded because they are the shape of this kind of change: inserting a key into object literals with a regex broke a
|
|
19
|
+
file, and replacing call sites after defining the wrapper made the wrapper call itself.
|
|
20
|
+
|
|
21
|
+
### Admission: who may join, who may be removed
|
|
22
|
+
|
|
23
|
+
The relay gains a fleet-admission state machine and five routes: `admission/request`, `admission/pending`,
|
|
24
|
+
`admission/{id}/decide`, `members/{id}/remove`, `removals/{id}/vote`, plus `admission/challenge`.
|
|
25
|
+
|
|
26
|
+
* a request is `pending` and grants nothing until approved;
|
|
27
|
+
* **10 seconds without a decision auto-approves**, but only while at least one member is online -- an empty fleet cannot
|
|
28
|
+
consent, and that refusal is a named `NO_ONLINE_MEMBER`;
|
|
29
|
+
* inside **24 hours** any online member may remove a new member immediately; after 24 hours a single machine may only
|
|
30
|
+
OPEN a vote, and the quorum set is **frozen at open time** so a voter going offline mid-vote cannot move the bar;
|
|
31
|
+
* the quorum is the operator's: 3+ old members need >= 50% rounded up, a two-member fleet needs one vote;
|
|
32
|
+
* old members are ordered by **insertion sequence**, not timestamps -- two machines approved inside the same millisecond
|
|
33
|
+
are equal on the clock but still have an order, and a quorum that depends on a sub-millisecond race is not a quorum.
|
|
34
|
+
|
|
35
|
+
### An unpaired machine can prove its account
|
|
36
|
+
|
|
37
|
+
A machine whose pairing failed has no device_token, which is the situation the fallback exists for -- so the two routes
|
|
38
|
+
it needs are reachable **without** one, authorised instead by an account signature:
|
|
39
|
+
|
|
40
|
+
* the account gains a **derived Ed25519 identity** (`account-identity.mjs`): the same account material yields the same
|
|
41
|
+
keypair on every machine, which is what makes zero-configuration joining possible at all;
|
|
42
|
+
* the public key is registered with the relay **when that account's first machine pairs** -- the only moment the relay has
|
|
43
|
+
proof of the account -- and the first key wins (a different key is refused, not silently accepted);
|
|
44
|
+
* a one-time, five-minute challenge is signed and verified against it; the challenge is consumed on any attempt, the
|
|
45
|
+
signature binds `machine_id`, and issuing is rate limited **per fingerprint**;
|
|
46
|
+
* the fingerprint the relay acts on comes from the **verified signature**, never from the request body.
|
|
47
|
+
|
|
48
|
+
### Deliberately not in this release
|
|
49
|
+
|
|
50
|
+
The client-side consumption of `degrade: true` (challenge -> request -> poll) and the approval UI (10s popup plus a
|
|
51
|
+
standing dialog) are **not** shipped. The server half is tested; the client half is not written, and shipping a
|
|
52
|
+
mechanism whose client does not call it is the failure this project has recorded seven times.
|
|
53
|
+
|
|
54
|
+
### Verified
|
|
55
|
+
|
|
56
|
+
Full gate: **GREEN**, 15 of 15 checks, twice. The suites this release touches directly: relay 163, tools 115, admission
|
|
57
|
+
16, admission-routes 3, broadcast 19, join-challenge 8, account-identity 6, join-degradation 3, fleet-target 45.
|
|
58
|
+
|
|
59
|
+
## [1.1.3] — 2026-10-17
|
|
60
|
+
|
|
61
|
+
**A machine offline for more than 72 hours leaves the list. It is not deleted, and it comes back on its own.**
|
|
62
|
+
|
|
63
|
+
### The rule, and why hiding is not deleting
|
|
64
|
+
|
|
65
|
+
`readFleet` filters the roster at **the read path only**:
|
|
66
|
+
|
|
67
|
+
* a machine whose `last_seen` is at least **72 hours** old is **not shown** by `w2m_devices` or the composer;
|
|
68
|
+
* `records` still carries **every device the relay sent**, so nothing is lost and no bookkeeping is required;
|
|
69
|
+
* a **missing or unparseable** `last_seen` is **not** archived -- an unknown age must not be read as "gone for three
|
|
70
|
+
days", or a relay that omits the field would empty the list;
|
|
71
|
+
* `include_stale: true` shows the archived machines, which is the operator's audit route;
|
|
72
|
+
* the machine returns by reporting again: the same record with a fresh timestamp is listed, with no re-pairing.
|
|
73
|
+
|
|
74
|
+
Because the relay already stops leasing machines that are offline (v1.1.2), a hidden machine is also not a dispatch
|
|
75
|
+
target -- the list and the dispatch agree, from one notion of liveness plus this display rule.
|
|
76
|
+
|
|
77
|
+
### The fixtures were the reason this took two attempts
|
|
78
|
+
|
|
79
|
+
The first attempt changed nothing in the filter and failed 8 tests: `fleet-target`'s roster fixtures carried **fixed
|
|
80
|
+
dates** (`2026-10-09` … `2026-10-11`), which are more than 72 hours old *now*, so a correct filter archived all three
|
|
81
|
+
machines. Making the timestamps **relative** (`Date.now() - 60s` …) fixed all 8 without touching a single assertion,
|
|
82
|
+
which is the evidence that the filter was right and the fixture was the stale part.
|
|
83
|
+
|
|
84
|
+
### Verified
|
|
85
|
+
|
|
86
|
+
`test/fleet-target.test.mjs` gains four assertions -- hidden from the list, **records kept**, returns on its own and
|
|
87
|
+
`include_stale` shows it meanwhile, unknown age not archived -- for **45 pass, 0 fail**. `test/tools.test.mjs` 115 and
|
|
88
|
+
`test/relay.test.mjs` 160 remain green.
|
|
89
|
+
|
|
5
90
|
## [1.1.2] — 2026-10-17
|
|
6
91
|
|
|
7
92
|
**A dispatch that names no machines now goes to the machines that are online. The rule that decides "online" had been
|
package/codex/mcp-servers.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.14",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -129,10 +129,13 @@
|
|
|
129
129
|
"pack": "node scripts/pack.mjs",
|
|
130
130
|
"reachability": "node scripts/reachability-check.mjs",
|
|
131
131
|
"reachability:classify": "node scripts/reachability-classify.mjs",
|
|
132
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
|
|
132
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs",
|
|
133
133
|
"test:acceptance-doc": "node --test --test-force-exit test/acceptance-doc.test.mjs",
|
|
134
|
+
"test:account-identity": "node --test --test-force-exit test/account-identity.test.mjs",
|
|
135
|
+
"test:admission": "node --test --test-force-exit test/admission.test.mjs",
|
|
136
|
+
"test:admission-routes": "node --test --test-force-exit test/admission-routes.test.mjs",
|
|
134
137
|
"test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
|
|
135
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs",
|
|
138
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/blake2s.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/fleet-merge.test.mjs test/fleet-memory-e2e.test.mjs test/fleet-concurrency.test.mjs test/fleet-merge-race.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/portable-plugins.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/ilink-shape.test.mjs test/chatops-fleet-join.test.mjs test/admission.test.mjs test/admission-routes.test.mjs test/join-degradation.test.mjs test/account-identity.test.mjs test/join-challenge.test.mjs",
|
|
136
139
|
"test:approval-gate": "node --test --test-force-exit test/approval-gate.test.mjs",
|
|
137
140
|
"test:approval-index": "node --test --test-force-exit test/approval-index.test.mjs",
|
|
138
141
|
"test:authority": "node --test --test-force-exit test/authority.test.mjs",
|
|
@@ -155,6 +158,8 @@
|
|
|
155
158
|
"test:fleet-sync": "node --test --test-force-exit test/fleet-sync.test.mjs",
|
|
156
159
|
"test:health": "node --test --test-force-exit test/health.test.mjs",
|
|
157
160
|
"test:installed-version": "node --test --test-force-exit test/installed-version.test.mjs",
|
|
161
|
+
"test:join-challenge": "node --test --test-force-exit test/join-challenge.test.mjs",
|
|
162
|
+
"test:join-degradation": "node --test --test-force-exit test/join-degradation.test.mjs",
|
|
158
163
|
"test:layers": "node --test --test-force-exit test/layer-model.test.mjs",
|
|
159
164
|
"test:log-bounds": "node --test --test-force-exit test/log-bounds.test.mjs",
|
|
160
165
|
"test:memory": "node --test --test-force-exit test/memory.test.mjs",
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.14",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -156,6 +156,15 @@ export function isSelectableDevice(device) {
|
|
|
156
156
|
* @param {string|null} localMachineId This machine's id, for the "this machine" mark.
|
|
157
157
|
* @returns {object}
|
|
158
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* v1.1.3: how long a machine may be unheard from before it leaves the list.
|
|
161
|
+
*
|
|
162
|
+
* The operator's rule: offline for 72 hours is no longer shown, **nothing is deleted**, and it comes back on its own
|
|
163
|
+
* once it reports again. Hiding and deleting are deliberately different -- deleting would throw away the history of a
|
|
164
|
+
* machine that is merely switched off, and re-pairing would be the only way back.
|
|
165
|
+
*/
|
|
166
|
+
const ARCHIVE_AFTER_MS = 72 * 60 * 60 * 1000;
|
|
167
|
+
|
|
159
168
|
export function describeTargetMachine(device, localMachineId = null) {
|
|
160
169
|
const machineId = typeof device?.machine_id === 'string' && device.machine_id !== '' ? device.machine_id : null;
|
|
161
170
|
return {
|
|
@@ -343,7 +352,7 @@ export function dispatchSelectionFields(selection, mode) {
|
|
|
343
352
|
* fields rather than this module's projection of them.
|
|
344
353
|
*/
|
|
345
354
|
export async function readFleet(deps) {
|
|
346
|
-
const { rabbitUrl, token = null, localMachineId = null, signal, request } = deps;
|
|
355
|
+
const { rabbitUrl, token = null, localMachineId = null, signal, request, include_stale = false } = deps;
|
|
347
356
|
if (typeof rabbitUrl !== 'string' || rabbitUrl === '') {
|
|
348
357
|
return {
|
|
349
358
|
ok: false,
|
|
@@ -397,7 +406,18 @@ export async function readFleet(deps) {
|
|
|
397
406
|
const records = Array.isArray(result.json?.devices)
|
|
398
407
|
? result.json.devices
|
|
399
408
|
: (Array.isArray(result.json?.machines) ? result.json.machines : []);
|
|
400
|
-
const
|
|
409
|
+
const allMachines = records.map((record) => describeTargetMachine(record, localMachineId));
|
|
410
|
+
/**
|
|
411
|
+
* Archived means "not shown", never "removed": `records` still carries every device, so a caller that asks for the
|
|
412
|
+
* archived ones receives them. A missing or unparseable timestamp is NOT archived -- "unknown" must not be read as
|
|
413
|
+
* "gone for three days", or a relay that omits the field would empty the list.
|
|
414
|
+
*/
|
|
415
|
+
const isArchived = (machine) => {
|
|
416
|
+
const raw = machine.last_seen;
|
|
417
|
+
const at = typeof raw === 'number' ? raw : Date.parse(String(raw ?? ''));
|
|
418
|
+
return Number.isFinite(at) && (Date.now() - at) >= ARCHIVE_AFTER_MS;
|
|
419
|
+
};
|
|
420
|
+
const machines = include_stale === true ? allMachines : allMachines.filter((machine) => !isArchived(machine));
|
|
401
421
|
return {
|
|
402
422
|
ok: true,
|
|
403
423
|
error: null,
|
|
@@ -410,6 +430,7 @@ export async function readFleet(deps) {
|
|
|
410
430
|
// Machines the relay still lists but whose lease is not live. Reported so "3 machines" is never
|
|
411
431
|
// mistaken for "3 machines you can use".
|
|
412
432
|
excluded: machines.filter((machine) => !machine.online).length,
|
|
433
|
+
archived: allMachines.length - machines.length,
|
|
413
434
|
};
|
|
414
435
|
}
|
|
415
436
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When may the old joining path give up and the new one take over?
|
|
3
|
+
*
|
|
4
|
+
* The operator's rule (2026-10-17): "旧机制优先,失败才降级". The whole value of a fallback is that it fires when the
|
|
5
|
+
* old path **cannot** work -- and the whole danger is that it fires when the old path merely hiccuped. A machine pushed
|
|
6
|
+
* into an approval queue because the network blipped for two seconds is worse than one that retried.
|
|
7
|
+
*
|
|
8
|
+
* So this is a whitelist of three reasons, and everything else is "retry the old path":
|
|
9
|
+
*
|
|
10
|
+
* 1. the pairing code is invalid or already used -- the old path can never succeed with it;
|
|
11
|
+
* 2. the relay is unreachable at the configured address -- no code will help;
|
|
12
|
+
* 3. pairing timed out -- the old path got its chance and did not finish.
|
|
13
|
+
*
|
|
14
|
+
* Everything else (401 on the operator token, a config error, a permission refusal, an unexpected 5xx) is a signal that
|
|
15
|
+
* something needs a human, **not** a signal to change mechanisms. Defaulting to `false` is deliberate: an unknown
|
|
16
|
+
* failure must not silently move a machine between flows.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The only three reasons the old path may be abandoned. */
|
|
20
|
+
export const DEGRADABLE_REASONS = Object.freeze([
|
|
21
|
+
'PAIRING_CODE_INVALID',
|
|
22
|
+
'RELAY_UNREACHABLE',
|
|
23
|
+
'PAIRING_TIMEOUT',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string|{reason_code?: string, code?: string, status?: number}} failure
|
|
28
|
+
* @returns {boolean} true only for the three whitelisted reasons.
|
|
29
|
+
*/
|
|
30
|
+
export function shouldDegrade(failure) {
|
|
31
|
+
const reason = typeof failure === 'string' ? failure : (failure?.reason_code ?? failure?.code ?? null);
|
|
32
|
+
if (typeof reason !== 'string') return false;
|
|
33
|
+
return DEGRADABLE_REASONS.includes(reason);
|
|
34
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* DSH: Windows2MacO - the DSH Cordis plugin.
|
|
3
3
|
*
|
|
4
4
|
* # What this exposes
|
|
@@ -107,6 +107,7 @@ import { acceptWithAuthority } from '../agent/field-authority.mjs';
|
|
|
107
107
|
// v1.0.11: the merge rule (#2b) lives in its own module so the operator's two layers can be tested as a rule,
|
|
108
108
|
// exhaustively, without a profile or a relay.
|
|
109
109
|
import { mergeMemory } from './fleet-merge.mjs';
|
|
110
|
+
import { shouldDegrade } from './join-degradation.mjs';
|
|
110
111
|
|
|
111
112
|
// The base-address rules live in one place (§2), shared with the agent side. Importing the - rather
|
|
112
113
|
// than re-deriving them her - is what stops the plugin and the agent from disagreeing about what
|
|
@@ -211,7 +212,7 @@ const MAX_STAGES = 16;
|
|
|
211
212
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
212
213
|
* overrides it at runtime for tests and packagers.
|
|
213
214
|
*/
|
|
214
|
-
const PLUGIN_VERSION = '1.1.
|
|
215
|
+
const PLUGIN_VERSION = '1.1.14';
|
|
215
216
|
|
|
216
217
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
217
218
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -687,7 +688,8 @@ function readConfig(config = {}) {
|
|
|
687
688
|
machineName: typeof config.machineName === 'string' && config.machineName.trim() !== ''
|
|
688
689
|
? config.machineName.trim()
|
|
689
690
|
: os.hostname(),
|
|
690
|
-
|
|
691
|
+
// v1.1.6: joining is on by default; `false` is the only way off.
|
|
692
|
+
autoStartAgent: config.autoStartAgent !== false,
|
|
691
693
|
/**
|
|
692
694
|
* v0.8.3: the account-derived key this machine joins its fleet with.
|
|
693
695
|
*
|
|
@@ -6704,10 +6706,18 @@ async function startLocalStack(ctx, cfg) {
|
|
|
6704
6706
|
ctx.logger?.info?.(`w2m: paired by ${by}`);
|
|
6705
6707
|
return { paired: true, by };
|
|
6706
6708
|
} catch (error) {
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6709
|
+
/**
|
|
6710
|
+
* The failure is classified here, where the reason still exists.
|
|
6711
|
+
*
|
|
6712
|
+
* v1.1.6 adds a second joining path (account fingerprint + member approval) that may only take over when the
|
|
6713
|
+
* FIRST path cannot work -- an invalid pairing code, an unreachable relay, a timeout. Anything else (a config
|
|
6714
|
+
* error, a permission refusal) needs a human, and moving the machine between mechanisms would hide that. The
|
|
6715
|
+
* classifier owns that rule; this only has to stop discarding the error before it can be read.
|
|
6716
|
+
*/
|
|
6717
|
+
const reason = error?.reason_code ?? error?.code ?? null;
|
|
6718
|
+
const degrade = shouldDegrade(reason);
|
|
6719
|
+
ctx.logger?.warn?.(`w2m: pairing by ${by} failed${reason === null ? '' : ' (' + String(reason) + ')'}: ${error instanceof Error ? error.message : String(error)}`);
|
|
6720
|
+
return { paired: false, by, reason_code: reason, degrade };
|
|
6711
6721
|
}
|
|
6712
6722
|
}
|
|
6713
6723
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet admission: who may join a fleet, who may be removed, and by how many votes.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a state machine and not a route handler
|
|
5
|
+
*
|
|
6
|
+
* Every rule the operator set is a *time window* or a *quorum*, and both are the kind of logic that reads correctly in
|
|
7
|
+
* a route handler and is wrong at the edges: a decision that arrives one millisecond after the automatic one, a vote
|
|
8
|
+
* whose quorum changes because a voter went offline mid-vote, a removal of the last member. None of that needs a socket,
|
|
9
|
+
* so none of it is tested through one -- the routes are thin wrappers over this file.
|
|
10
|
+
*
|
|
11
|
+
* ## The four operator decisions this encodes (2026-10-17)
|
|
12
|
+
*
|
|
13
|
+
* 1. the OLD mechanism stays the default; this one is the fallback, so nothing here is on any startup path yet;
|
|
14
|
+
* 2. `autoApproveMs = 10_000` -- but **never** with zero members online, because an empty fleet cannot consent;
|
|
15
|
+
* 3. a fleet of 2 needs 1 vote (= 50%), 3 or more needs >= 50% rounded up;
|
|
16
|
+
* 4. the quorum denominator is **currently online old members only**, so the set is **frozen when the vote opens**.
|
|
17
|
+
*
|
|
18
|
+
* ## Clock
|
|
19
|
+
*
|
|
20
|
+
* Every decision is stamped with `now()`, which the caller supplies. The default is `Date.now`; the tests pass a
|
|
21
|
+
* clock they control, and production uses the relay's clock. **No client clock is ever trusted** -- three machines in
|
|
22
|
+
* this fleet disagreed by tens of seconds, and a vote that opens or closes on a client clock is a vote an attacker
|
|
23
|
+
* schedules.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Request states. A request that is not `approved` grants nothing. */
|
|
27
|
+
export const ADMISSION = Object.freeze({
|
|
28
|
+
PENDING: 'pending',
|
|
29
|
+
APPROVED: 'approved',
|
|
30
|
+
REJECTED: 'rejected',
|
|
31
|
+
EXPIRED: 'expired',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Operator decision: 10s, and only while at least one member is online. */
|
|
35
|
+
export const AUTO_APPROVE_MS = 10_000;
|
|
36
|
+
/** A request nobody decided expires, so a machine cannot wait forever on a fleet that is gone. */
|
|
37
|
+
export const REQUEST_TTL_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
/** Removal is immediate inside this window; after it, a vote is required. */
|
|
39
|
+
export const KICK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
/** How long a removal vote stays open. */
|
|
41
|
+
export const VOTE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The quorum, as the operator stated it: 3+ old members needs >= 50% rounded up; a fleet of 2 needs **one** vote.
|
|
45
|
+
*
|
|
46
|
+
* `Math.ceil(n / 2)` would demand 2 votes from a two-member fleet, which makes removal impossible -- the operator
|
|
47
|
+
* called that out explicitly ("2 人小编队一人就行").
|
|
48
|
+
*/
|
|
49
|
+
export function neededVotes(onlineOldMembers) {
|
|
50
|
+
const n = Number.isInteger(onlineOldMembers) && onlineOldMembers > 0 ? onlineOldMembers : 0;
|
|
51
|
+
if (n === 0) return 0;
|
|
52
|
+
if (n <= 2) return 1;
|
|
53
|
+
return Math.ceil(n / 2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {{now?: () => number, autoApproveMs?: number}} [options]
|
|
58
|
+
*/
|
|
59
|
+
export function createAdmission(options = {}) {
|
|
60
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
61
|
+
const autoApproveMs = Number.isInteger(options.autoApproveMs) ? options.autoApproveMs : AUTO_APPROVE_MS;
|
|
62
|
+
|
|
63
|
+
/** machine_id -> {joined_at_ms, fleet_id} for every APPROVED member. */
|
|
64
|
+
const members = new Map();
|
|
65
|
+
let joinSeq = 0;
|
|
66
|
+
/** request_id -> request record. */
|
|
67
|
+
const requests = new Map();
|
|
68
|
+
/** removal_id -> vote record. */
|
|
69
|
+
const removals = new Map();
|
|
70
|
+
let seq = 0;
|
|
71
|
+
const nextId = (prefix) => { seq += 1; return prefix + '-' + String(seq).padStart(4, '0'); };
|
|
72
|
+
|
|
73
|
+
const onlineMembers = () => [...members.keys()].filter((id) => members.get(id).online === true);
|
|
74
|
+
|
|
75
|
+
function seedFounder(machine_id, fleet_id = 'fleet-1', online = true) {
|
|
76
|
+
joinSeq += 1;
|
|
77
|
+
members.set(machine_id, { joined_at_ms: now(), fleet_id, online, join_seq: joinSeq });
|
|
78
|
+
return memberOf(machine_id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function memberOf(machine_id) {
|
|
82
|
+
const m = members.get(machine_id);
|
|
83
|
+
return m === undefined ? null : { machine_id, joined_at_ms: m.joined_at_ms, fleet_id: m.fleet_id, online: m.online === true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A pending request grants nothing: this is what the dispatch path asks. */
|
|
87
|
+
function isMember(machine_id) { return members.has(machine_id); }
|
|
88
|
+
|
|
89
|
+
function setOnline(machine_id, online) {
|
|
90
|
+
const m = members.get(machine_id);
|
|
91
|
+
if (m === undefined) return false;
|
|
92
|
+
m.online = online === true;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function requestAdmission({ machine_id, machine_name = null, account_fingerprint = null, fleet_id = 'fleet-1' }) {
|
|
97
|
+
if (typeof machine_id !== 'string' || machine_id === '') throw new Error('machine_id is required');
|
|
98
|
+
if (members.has(machine_id)) return { ok: true, already_member: true, state: ADMISSION.APPROVED, request_id: null };
|
|
99
|
+
// One open request per machine: re-requesting returns the same record rather than creating a queue.
|
|
100
|
+
for (const r of requests.values()) {
|
|
101
|
+
if (r.machine_id === machine_id && r.state === ADMISSION.PENDING) return { ok: true, request_id: r.request_id, state: r.state, replayed: true };
|
|
102
|
+
}
|
|
103
|
+
// A fleet with no members has nobody to approve, so the first machine founds it.
|
|
104
|
+
if (members.size === 0) {
|
|
105
|
+
joinSeq += 1;
|
|
106
|
+
members.set(machine_id, { joined_at_ms: now(), fleet_id, online: true, join_seq: joinSeq });
|
|
107
|
+
return { ok: true, state: ADMISSION.APPROVED, request_id: null, founder: true };
|
|
108
|
+
}
|
|
109
|
+
const request_id = nextId('adm');
|
|
110
|
+
requests.set(request_id, {
|
|
111
|
+
request_id, machine_id, machine_name, account_fingerprint, fleet_id,
|
|
112
|
+
state: ADMISSION.PENDING, requested_at_ms: now(), decided_at_ms: null,
|
|
113
|
+
decided_by: null, auto: false,
|
|
114
|
+
});
|
|
115
|
+
return { ok: true, request_id, state: ADMISSION.PENDING };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getRequest(request_id) {
|
|
119
|
+
const r = requests.get(request_id);
|
|
120
|
+
return r === undefined ? null : { ...r };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Approve or reject. `auto` is recorded, because "nobody was watching" and "a person said yes" are different facts.
|
|
125
|
+
*/
|
|
126
|
+
function decide(request_id, { decision, decided_by = null, auto = false }) {
|
|
127
|
+
const r = requests.get(request_id);
|
|
128
|
+
if (r === undefined) return { ok: false, code: 'NOT_FOUND' };
|
|
129
|
+
if (r.state !== ADMISSION.PENDING) return { ok: false, code: 'ALREADY_DECIDED', state: r.state };
|
|
130
|
+
if (decision !== 'approve' && decision !== 'reject') return { ok: false, code: 'BAD_DECISION' };
|
|
131
|
+
if (auto === true && onlineMembers().length === 0) {
|
|
132
|
+
// An empty fleet cannot consent. This is the one rule that must not be relaxed for convenience.
|
|
133
|
+
return { ok: false, code: 'NO_ONLINE_MEMBER' };
|
|
134
|
+
}
|
|
135
|
+
r.state = decision === 'approve' ? ADMISSION.APPROVED : ADMISSION.REJECTED;
|
|
136
|
+
r.decided_at_ms = now();
|
|
137
|
+
r.decided_by = decided_by;
|
|
138
|
+
r.auto = auto === true;
|
|
139
|
+
if (r.state === ADMISSION.APPROVED) {
|
|
140
|
+
joinSeq += 1;
|
|
141
|
+
members.set(r.machine_id, { joined_at_ms: r.decided_at_ms, fleet_id: r.fleet_id, online: true, join_seq: joinSeq });
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, request_id, state: r.state, auto: r.auto };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Tick: applies the automatic approval and the request TTL. Called by the caller's timer, not by a setTimeout here,
|
|
148
|
+
* so a test controls time completely.
|
|
149
|
+
*/
|
|
150
|
+
function tick() {
|
|
151
|
+
const t = now();
|
|
152
|
+
const decided = [];
|
|
153
|
+
for (const r of requests.values()) {
|
|
154
|
+
if (r.state !== ADMISSION.PENDING) continue;
|
|
155
|
+
const age = t - r.requested_at_ms;
|
|
156
|
+
if (age >= REQUEST_TTL_MS) { r.state = ADMISSION.EXPIRED; r.decided_at_ms = t; decided.push({ request_id: r.request_id, state: r.state }); continue; }
|
|
157
|
+
if (age >= autoApproveMs && onlineMembers().length > 0) {
|
|
158
|
+
r.state = ADMISSION.APPROVED; r.decided_at_ms = t; r.decided_by = 'auto'; r.auto = true;
|
|
159
|
+
joinSeq += 1;
|
|
160
|
+
members.set(r.machine_id, { joined_at_ms: t, fleet_id: r.fleet_id, online: true, join_seq: joinSeq });
|
|
161
|
+
decided.push({ request_id: r.request_id, state: r.state, auto: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return decided;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Remove a member.
|
|
169
|
+
*
|
|
170
|
+
* Inside KICK_WINDOW_MS of joining, any online member may do it immediately. After that, a single machine may only
|
|
171
|
+
* OPEN a vote: the quorum set is frozen here, at open time, so a voter going offline mid-vote cannot silently lower
|
|
172
|
+
* the bar (or raise it).
|
|
173
|
+
*/
|
|
174
|
+
function remove(target_machine_id, { requested_by, reason = null }) {
|
|
175
|
+
const target = members.get(target_machine_id);
|
|
176
|
+
if (target === undefined) return { ok: false, code: 'NOT_A_MEMBER' };
|
|
177
|
+
if (requested_by === target_machine_id) return { ok: false, code: 'CANNOT_REMOVE_SELF' };
|
|
178
|
+
if (!members.has(requested_by)) return { ok: false, code: 'NOT_A_MEMBER' };
|
|
179
|
+
if (members.size <= 1) return { ok: false, code: 'LAST_MEMBER' };
|
|
180
|
+
|
|
181
|
+
const age = now() - target.joined_at_ms;
|
|
182
|
+
// Insertion order, not timestamps: two machines approved inside the same millisecond are equal on the clock but
|
|
183
|
+
// still have an order, and a quorum that depends on a sub-millisecond race is not a quorum.
|
|
184
|
+
const oldMembers = [...members.keys()].filter((id) => id !== target_machine_id
|
|
185
|
+
&& (members.get(id).join_seq ?? 0) < (target.join_seq ?? 0));
|
|
186
|
+
const snapshot = oldMembers.filter((id) => members.get(id).online === true);
|
|
187
|
+
|
|
188
|
+
if (age <= KICK_WINDOW_MS) {
|
|
189
|
+
members.delete(target_machine_id);
|
|
190
|
+
return { ok: true, immediate: true, removed: target_machine_id };
|
|
191
|
+
}
|
|
192
|
+
const removal_id = 'rmv-' + String(seq += 1).padStart(4, '0');
|
|
193
|
+
const record = {
|
|
194
|
+
removal_id, target_machine_id, requested_by, reason,
|
|
195
|
+
snapshot: [...snapshot], recent_votes: snapshot.slice(0, 1),
|
|
196
|
+
votes: new Map(), state: 'open', opened_at_ms: now(), need: neededVotes(snapshot.length),
|
|
197
|
+
};
|
|
198
|
+
// The requestor counts as the first yes; the operator's "a single machine may initiate" implies that much.
|
|
199
|
+
if (record.recent_votes.includes(requested_by)) record.votes.set(requested_by, 'yes');
|
|
200
|
+
removals.set(removal_id, record);
|
|
201
|
+
return {
|
|
202
|
+
ok: true, immediate: false, removal_id, need: record.need,
|
|
203
|
+
snapshot: [...record.snapshot], votes: record.votes.size,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Vote on an OPEN removal. Only the frozen snapshot may vote; the target never may. */
|
|
208
|
+
function vote(removal_id, { machine_id, vote: choice }) {
|
|
209
|
+
const r = removals.get(removal_id);
|
|
210
|
+
if (r === undefined) return { ok: false, code: 'NOT_FOUND' };
|
|
211
|
+
if (r.state !== 'open') return { ok: false, code: 'CLOSED', state: r.state };
|
|
212
|
+
if (now() - r.opened_at_ms > VOTE_WINDOW_MS) { r.state = 'expired'; return { ok: false, code: 'VOTE_WINDOW_OVER' }; }
|
|
213
|
+
if (machine_id === r.target_machine_id) return { ok: false, code: 'TARGET_CANNOT_VOTE' };
|
|
214
|
+
if (!r.snapshot.includes(machine_id)) return { ok: false, code: 'NOT_IN_SNAPSHOT' };
|
|
215
|
+
if (choice !== 'yes' && choice !== 'no') return { ok: false, code: 'BAD_VOTE' };
|
|
216
|
+
r.votes.set(machine_id, choice);
|
|
217
|
+
const yes = [...r.votes.values()].filter((v) => v === 'yes').length;
|
|
218
|
+
const no = [...r.votes.values()].filter((v) => v === 'no').length;
|
|
219
|
+
if (yes >= r.need) {
|
|
220
|
+
r.state = 'carried';
|
|
221
|
+
if (members.size > 1) members.delete(r.target_machine_id);
|
|
222
|
+
return { ok: true, state: 'carried', yes, no, need: r.need, removed: r.target_machine_id };
|
|
223
|
+
}
|
|
224
|
+
// Once enough no votes exist that yes can no longer reach the bar, it is decided.
|
|
225
|
+
if (no > r.snapshot.length - r.need) { r.state = 'failed'; return { ok: true, state: 'failed', yes, no, need: r.need }; }
|
|
226
|
+
return { ok: true, state: 'open', yes, no, need: r.need };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getRemoval(removal_id) {
|
|
230
|
+
const r = removals.get(removal_id);
|
|
231
|
+
return r === undefined ? null : { removal_id: r.removal_id, target_machine_id: r.target_machine_id, state: r.state, need: r.need, snapshot: [...r.snapshot], yes: [...r.votes.values()].filter((v) => v === 'yes').length, no: [...r.votes.values()].filter((v) => v === 'no').length, votes: r.votes.size };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function pendingFor() {
|
|
235
|
+
return [...requests.values()].filter((r) => r.state === ADMISSION.PENDING).map((r) => ({ ...r }));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { requestAdmission, decide, tick, getRequest, pendingFor, remove, vote, getRemoval, isMember, memberOf, setOnline, seedFounder, _members: members };
|
|
239
|
+
}
|