@twinsearth/w2m-dsh-plugin 0.8.99-tokenprobe.1 → 0.9.1
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/CHANGELOG.md +167 -0
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +0 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +4 -4
- package/docs/LINT-AUDIT.md +45 -0
- package/package.json +21 -21
- package/src/chatops/bridge.mjs +10 -2
- package/src/chatops/file-safety.mjs +6 -2
- package/src/chatops/fleet.mjs +1 -1
- package/src/chatops/ilink.mjs +2 -12
- package/src/chatops/index.mjs +1 -1
- package/src/chatops/outbound-queue.mjs +1 -1
- package/src/plugin/tools.mjs +33 -5
- package/src/relay/server.mjs +12 -0
- package/src/relay/state.mjs +160 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,173 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
5
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.9.1] — 2026-10-14
|
|
8
|
+
|
|
9
|
+
**A powered-off machine stayed `online` and selectable in every fleet view, because `online` was never derived from contact.**
|
|
10
|
+
|
|
11
|
+
### The report
|
|
12
|
+
|
|
13
|
+
> *"Another Windows machine, clearly shut down and offline for a long time, is still online and active — not greyed out, still selectable."* And, in the same breath: *"is the fleet heartbeat not synchronised, not updated, not broadcast?"*
|
|
14
|
+
|
|
15
|
+
All three of those turned out to be one defect with three faces, and the report was exactly right. The relay's own data at the time:
|
|
16
|
+
|
|
17
|
+
| machine | `online` | `last_heartbeat_at` |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `jp-shared` | true | `11:15:55Z` |
|
|
20
|
+
| `macmini` | true | `11:15:41Z` |
|
|
21
|
+
| **`win-desktop` (`549b9fde`)** | **true** | **`null`** |
|
|
22
|
+
| `ToFuture` (here) | true | `11:16:32Z` |
|
|
23
|
+
|
|
24
|
+
A machine reported `online: true` while `last_heartbeat_at` was `null` — meaning it had **never sent a heartbeat at all**. Two fields in one row contradicted each other and nothing reconciled them.
|
|
25
|
+
|
|
26
|
+
### The cause
|
|
27
|
+
|
|
28
|
+
`device.online` was written in exactly two places, and both were the SSE stream:
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
device.streams === 0 && this.state.setOnline(sub.machineId, false, 'stream_closed'); // detach
|
|
32
|
+
this.state.setOnline(machineId, true, 'stream_open'); // attach
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`/v1/devices` then read the flag back:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
online: device.online === true,
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Nothing derived liveness from contact.** So the machine's state was really "is there a TCP connection", and a **half-open stream** — a killed process, a pulled cable, a closed lid; anything that ends the agent without a clean socket close — left `online: true` for as long as the socket survived, which is indefinitely. That is the reported machine: powered off, still listed, still a dispatch target.
|
|
42
|
+
|
|
43
|
+
The "not broadcast" half is the same cause. `peer.bye` *is* broadcast to every other machine — but it is emitted **by the detach that never came**, so no other machine was ever told either, and every machine picker in the fleet kept offering a departed host.
|
|
44
|
+
|
|
45
|
+
And the "heartbeat not synchronised" half is the third face: a heartbeat alone could not fix the flag, because the flag did not read heartbeats.
|
|
46
|
+
|
|
47
|
+
### The fix
|
|
48
|
+
|
|
49
|
+
**Presence is now expired, next to lease expiry.** A new `sweepSilentDevices()`, driven by the relay's existing 1 s maintenance timer, declares a device silent once nothing has contacted it for `DEFAULT_SILENCE_MS`, and goes through the **existing** `setOnline(id, false, 'silent')` — which both flips the flag and emits `peer.bye`. Going through that path rather than setting the flag directly is the point: correcting only the flag would fix `/v1/devices` on the relay while every agent's own view, and therefore every picker, still showed the departed machine.
|
|
50
|
+
|
|
51
|
+
**Reads agree between sweeps.** `online` in both `deviceStatus` and `publicDevice` is now derived as well as stored, so nothing has to wait up to a second for a background timer to be told that a machine which stopped talking is gone.
|
|
52
|
+
|
|
53
|
+
**The threshold is two idle heartbeats (120 s).** An idle agent heartbeats every 60 s and a busy one every 10 s, so the slowest healthy cadence is 60 s and 120 s cannot be reached by a live machine. Deliberately *not* the 20 s lease rule (`heartbeatIntervalMs × missedHeartbeats`): that one guards a task index, where being wrong costs a duplicated execution, while this one guards a display and a target list, where being wrong costs a wasted dispatch. Same reasoning, different price. A test pins the invariant `DEFAULT_SILENCE_MS >= DEFAULT_IDLE_HEARTBEAT_MS * 2`, because a threshold at or below one beat would flap every healthy idle machine.
|
|
54
|
+
|
|
55
|
+
**`offline_reason` and `silent_ms` are now reported** (`stream_closed` | `silent` | `null`), on `/v1/devices`, in `/v1/status`, and through `w2m_stats`. "Offline" without a reason is precisely the shape that hid this: a departed machine looked identical to a quiet one, and a reader had no field to check.
|
|
56
|
+
|
|
57
|
+
### What the tests had to be told, twice
|
|
58
|
+
|
|
59
|
+
Both mistakes are recorded in the test file because both are traps a future reader will fall into:
|
|
60
|
+
|
|
61
|
+
1. **`RelayServer.handle` calls `touchDevice` on every authenticated request.** The first version of the wire test polled `/v1/devices` *with the token of the machine under test* — so the silent machine refreshed its own `last_seen_at` by asking, and the test read it as online. The observer must be a different device.
|
|
62
|
+
2. **The threshold has to exceed the heartbeat cadence used by the test.** The first version used `silenceMs: 5_000` against a witness that heartbeated every 60 s, so the witness was legitimately swept alongside the machine under test.
|
|
63
|
+
|
|
64
|
+
A third correction was to the fix itself, and it is the subtle one: `offline_reason` originally tested `device.online !== true` **first**, which reports `stream_closed` for exactly the half-open case — the stream *is* still attached. Silence has to be tested first, or the reason names the wrong thing on the one case that matters.
|
|
65
|
+
|
|
66
|
+
### Verified
|
|
67
|
+
|
|
68
|
+
- `test/relay.test.mjs` — **150 tests, 0 failures** (4 new: the sweep plus its broadcast, the pre-sweep read, the healthy-quiet distinction, and the threshold invariant).
|
|
69
|
+
- The 13 suites that touch device or fleet state — **398 tests, 0 failures**.
|
|
70
|
+
- `eslint .` — **0 errors**, 127 warnings.
|
|
71
|
+
|
|
72
|
+
### Deployment note — this fix is on the wrong side of the wire
|
|
73
|
+
|
|
74
|
+
The defect lives in the **relay**, so upgrading the plugin does **not** fix a running relay: a machine running v0.9.1 still shows every other machine's stale `online` flag until the relay itself is restarted on v0.9.1. The relay at the shared default address was still serving the stale view when this was written. Restarting it is an operator action, and the relay's own operator token is not something this repository holds.
|
|
75
|
+
|
|
76
|
+
## [0.9.0] — 2026-10-14
|
|
77
|
+
|
|
78
|
+
**Three defects that were mounted, wired, and doing nothing — and the lint gate that had stopped being able to say so.**
|
|
79
|
+
|
|
80
|
+
### The shape all three share
|
|
81
|
+
|
|
82
|
+
Each was, in the words an earlier release note already used for this class: *the record was created,
|
|
83
|
+
and the part that makes it useful was not filled in*. All three were invisible to the suite, and all
|
|
84
|
+
three were found by reading lint output rather than by running anything.
|
|
85
|
+
|
|
86
|
+
### 1. `ReferenceError: all is not defined` — on the path an operator actually reaches
|
|
87
|
+
|
|
88
|
+
`src/relay/state.mjs` threw an undefined identifier while *building a refusal*:
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
if (targets.length === 0) {
|
|
92
|
+
throw new ProtocolError('NO_ONLINE_DEVICE', 'no paired device available for this task', {
|
|
93
|
+
known_devices: all.length, // ← `all` exists nowhere in the file
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`targets` is empty exactly when the fleet has nothing to dispatch to — a freshly configured machine
|
|
99
|
+
before its first peer joins, or a fleet whose machines are all scoped out. So the one case where the
|
|
100
|
+
operator needs the detail most answered with a `ReferenceError` instead of the refusal. The count they
|
|
101
|
+
need is the **unfiltered** device set, because the answer distinguishes *"nothing is paired"* from
|
|
102
|
+
*"something is paired but scoped out"*; that is `this.devices`, the same collection handed to
|
|
103
|
+
`targetsFor` a few lines above.
|
|
104
|
+
|
|
105
|
+
The project's own lint audit records `no-undef` as the rule that "caught the one real defect" of its
|
|
106
|
+
round, and notes why it matters: *an unbound identifier in an ESM module is a `ReferenceError` at
|
|
107
|
+
evaluation time, not an `undefined` that quietly takes a branch.* This is the same defect one release
|
|
108
|
+
later.
|
|
109
|
+
|
|
110
|
+
### 2. The in-process agent's status was built and then dropped
|
|
111
|
+
|
|
112
|
+
`src/plugin/tools.mjs` documented a feature and never performed it:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
// §8.2: when the agent runs inside this process its `rttMs` is reachable directly, so `w2m_status`
|
|
116
|
+
// can report it without a file.
|
|
117
|
+
const statusGetter = agent.status ?? agent.state ?? null; // ← and nothing else
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`readRtt` opens with `if (inProcessAgentStatus)` and skips the whole in-process branch when it is
|
|
121
|
+
null. Nothing ever assigned it, so on a machine whose agent *is* in that process the tool fell through
|
|
122
|
+
to reading a state file and reported:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
no agent round-trip time is available: the agent is not running in this process and no agent
|
|
126
|
+
state file was found under <stateDir>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
— wrong twice, and wrong in the direction that costs most: it tells the reader to doubt the agent, on
|
|
130
|
+
the one call they make to find out what is actually happening. The getter is now assigned.
|
|
131
|
+
|
|
132
|
+
### 3. A parameter that promised a binding it never made
|
|
133
|
+
|
|
134
|
+
`src/chatops/bridge.mjs` had `function actionsFor(windowKey)` under a doc line claiming the window was
|
|
135
|
+
"bound in", and a body that never mentioned `windowKey`. The real binding is per call
|
|
136
|
+
(`pending: (window) => broker.listFor(window)`), so the parameter is gone rather than renamed: a
|
|
137
|
+
caller passing a window there was reading a promise the signature did not keep.
|
|
138
|
+
|
|
139
|
+
### The lint gate, which is why nobody saw any of this
|
|
140
|
+
|
|
141
|
+
`eslint .` reported **155 errors**, and the release pre-flight requires zero — so the gate was red, and
|
|
142
|
+
a gate that is red for 117 pieces of noise is a gate nobody reads. Which is how three defects sat in
|
|
143
|
+
the tree.
|
|
144
|
+
|
|
145
|
+
| Rule | Count | Disposition |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `no-console` | 117 | **Configuration.** Every one is in `scripts/` — `ilink-*.mjs` operator probes whose entire output *is* a transcribed QR flow, and `chatops-*-e2e.mjs` end-to-end drivers whose value is their trace. The rule's rationale ("a leftover debug statement") does not describe a file that returns nothing to a caller. `scripts/**` joins `bin/**` and `deploy/**` in the existing exception; `src/` and `test/` keep it at error. |
|
|
148
|
+
| `no-unused-vars` | 20 | 13 dead imports removed; 7 unused **bindings** annotated with a leading underscore, per the config's own convention. Two of those are load-bearing calls whose *result* is unused — `stats = await lstat(lexical)` is how a missing file is detected — so the call stays and the binding is annotated. |
|
|
149
|
+
| `no-promise-executor-return` | 4 | The idiomatic `new Promise((r) => setTimeout(r, ms))`. The rule is right that the executor returns a value; braces remove it without changing what the promise does. |
|
|
150
|
+
| `prefer-const` | 2 | Both real; one (`cursor`) carried a `let` that suggested a resumption the script does not implement. |
|
|
151
|
+
| `no-sequences` | 4 | Comma-operator arrows in a test, rewritten as block bodies — same order, same value, readable without knowing the operator. |
|
|
152
|
+
| `no-loss-of-precision` | 2 | **Dispositioned, not fixed.** These are the nineteen-digit measured `message_id` values the iLink contract fixture exists to demonstrate: JavaScript rounds them on parse, and a production path recovers the exact digits from the raw text *because* of that. Quoting the literals would delete the case under test, so they carry a scoped `eslint-disable` with the reason inline. |
|
|
153
|
+
|
|
154
|
+
Result: **0 errors**, measured with the repository's own config and `--report-unused-disable-directives`,
|
|
155
|
+
so a suppression that no longer suppresses anything fails the run instead of sitting there looking
|
|
156
|
+
like it does.
|
|
157
|
+
|
|
158
|
+
`docs/LINT-AUDIT.md` keeps its measurements as written and gains an addendum for this round, because
|
|
159
|
+
an audit document that silently disagrees with the tree is worse than no audit document.
|
|
160
|
+
|
|
161
|
+
### Verified
|
|
162
|
+
|
|
163
|
+
- `node scripts/verify-join.mjs` — 16/16, unchanged by the edits above.
|
|
164
|
+
- `node scripts/check-entrypoints.mjs` — all entry points import.
|
|
165
|
+
- The four touched suites — **119 tests / 25 suites / 0 failures** (`chatops-commands`,
|
|
166
|
+
`chatops-ilink-contract`, `chatops-approval`, `chatops-durability`).
|
|
167
|
+
- `eslint .` — 0 errors.
|
|
168
|
+
|
|
169
|
+
The three defects do **not** carry new tests, and that is a stated gap rather than an oversight: each
|
|
170
|
+
sits on a path the existing suite does not construct (an empty target set at dispatch, an in-process
|
|
171
|
+
agent status getter, a bridge action set built with an argument). They were found by lint and fixed by
|
|
172
|
+
reading the surrounding contract; a test for each is the honest next step.
|
|
173
|
+
|
|
7
174
|
## [0.8.11] — 2026-10-14
|
|
8
175
|
|
|
9
176
|
**The in-process join threw before it could pair — and a missing fingerprint took the fleet key with it.**
|
package/bin/w2m-localside.mjs
CHANGED
|
File without changes
|
package/bin/w2m-rabbit.mjs
CHANGED
|
File without changes
|
package/bin/w2m-stun.mjs
CHANGED
|
File without changes
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
* Usage: node mac-self-update.mjs --tarball <path> --sha256 <hex> [--verify-fleet <url>]
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { execFileSync
|
|
35
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync, readdirSync
|
|
34
|
+
import { execFileSync } from 'node:child_process';
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync, readdirSync } from 'node:fs';
|
|
36
36
|
import { createHash } from 'node:crypto';
|
|
37
37
|
import { homedir, tmpdir } from 'node:os';
|
|
38
38
|
import { join } from 'node:path';
|
|
@@ -120,7 +120,7 @@ function findDshProcess() {
|
|
|
120
120
|
* `machineName` is read from the environment the agent was started with, falling back to the
|
|
121
121
|
* hostname — the relay lists machines by the name they paired under.
|
|
122
122
|
*/
|
|
123
|
-
async function fleetHasMachine(url,
|
|
123
|
+
async function fleetHasMachine(url, _name) {
|
|
124
124
|
try {
|
|
125
125
|
const res = await fetch(new URL('/healthz', url).toString(), { signal: AbortSignal.timeout(10_000) });
|
|
126
126
|
const body = await res.json();
|
|
@@ -133,7 +133,7 @@ async function fleetHasMachine(url, name) {
|
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
136
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
137
137
|
|
|
138
138
|
async function main() {
|
|
139
139
|
const install = findInstall();
|
package/docs/LINT-AUDIT.md
CHANGED
|
@@ -12,6 +12,51 @@
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
+
## Addendum — v0.9.0: the gate was cleared, and this document is why it had to be
|
|
16
|
+
|
|
17
|
+
*The measurements above are left exactly as written. They were correct for 2026-10-08 and they are
|
|
18
|
+
the record of a real decision. But they no longer describe the tree, and a reader arriving at this
|
|
19
|
+
file today would draw the wrong conclusion from it — so the change is recorded here rather than by
|
|
20
|
+
editing the numbers.*
|
|
21
|
+
|
|
22
|
+
**What happened between then and v0.9.0.** The audit's disposition for most findings was "report, do
|
|
23
|
+
not fix", on the reasoning that ~30 findings were rule noise on idiomatic code and that rewriting
|
|
24
|
+
working code to satisfy a heuristic is the wrong trade. That reasoning still holds. What changed is
|
|
25
|
+
volume: the tree grew `scripts/ilink-*.mjs` and `scripts/chatops-*-e2e.mjs`, which between them added
|
|
26
|
+
**117 `no-console` errors** — every one of them a CLI script for which the console *is* the output
|
|
27
|
+
channel. `eslint .` went from 31 blocking errors to **155**.
|
|
28
|
+
|
|
29
|
+
The consequence is the one this document could not have anticipated: a lint gate that is red for 117
|
|
30
|
+
pieces of noise is a gate nobody reads, and **three real defects then sat in the tree undetected** —
|
|
31
|
+
including `ReferenceError: all is not defined` in `src/relay/state.mjs`, thrown while building a
|
|
32
|
+
refusal on the exact path an operator reaches when their fleet has nothing to dispatch to. §3.1's
|
|
33
|
+
claim that `no-undef` is "what caught the one real defect" was true, and it stayed true: `no-undef`
|
|
34
|
+
caught this one too, once anyone was reading.
|
|
35
|
+
|
|
36
|
+
**What v0.9.0 did about it**, per rule (full reasoning in `CHANGELOG.md`):
|
|
37
|
+
|
|
38
|
+
| Rule | Then | Now |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `no-console` | 31 blocking, all in `test/`+`src/` as errors | 117 moved to **configuration**: `scripts/**` joins `bin/**`+`deploy/**` in the existing `no-console: off` block. `src/` and `test/` unchanged. |
|
|
41
|
+
| `no-unused-vars` | 21 | 13 dead imports removed, 7 unused bindings annotated `_`-prefixed. |
|
|
42
|
+
| `no-promise-executor-return` | 31 (demoted to warn in `test/`+`scripts/`) | The 4 **errors** in `src/`/`deploy/` given braces. The warnings are untouched. |
|
|
43
|
+
| `prefer-const` | — | 2 fixed. The rule's report on `scripts/ilink-talk.mjs`'s `cursor` was correct: the `let` implied a resumption the script does not implement. |
|
|
44
|
+
| `no-sequences` | — | 4 fixed in `test/chatops-commands.test.mjs`. |
|
|
45
|
+
| `no-loss-of-precision` | — | 2 **dispositioned**, not fixed: the nineteen-digit measured ids that the iLink fixture exists to demonstrate. Scoped `eslint-disable` with the reason inline. |
|
|
46
|
+
| `no-useless-assignment`, `no-control-regex` | off / left as error (§1.2) | Unchanged. `no-control-regex` still has no occurrence to fire on. |
|
|
47
|
+
|
|
48
|
+
**Current state: 0 errors, 127 warnings.** Measured with this repository's config plus
|
|
49
|
+
`--report-unused-disable-directives`, so a suppression that has stopped suppressing anything fails the
|
|
50
|
+
run rather than remaining as decoration.
|
|
51
|
+
|
|
52
|
+
**What §1.1 still costs, and it is not fixed by any of this.** Type-aware rules remain unavailable,
|
|
53
|
+
so `@typescript-eslint/no-floating-promises` — the canonical "you forgot `await`" check — is still
|
|
54
|
+
absent. Two of the three defects v0.9.0 fixed were *unused* things (an unassigned variable read
|
|
55
|
+
nowhere, a parameter mentioned nowhere), which `no-unused-vars` catches; the third was `no-undef`. A
|
|
56
|
+
missing `await` would still be invisible. That conclusion is unchanged.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
15
60
|
## 0. TL;DR
|
|
16
61
|
|
|
17
62
|
| Question | Answer |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH: Windows2MacOS
|
|
3
|
+
"version": "0.9.1",
|
|
4
|
+
"description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -110,6 +110,23 @@
|
|
|
110
110
|
"optional": true
|
|
111
111
|
}
|
|
112
112
|
},
|
|
113
|
+
"scripts": {
|
|
114
|
+
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/chatops-fleet-join.test.mjs",
|
|
115
|
+
"test:e2e": "node --test --test-force-exit 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/p2p-agent.test.mjs",
|
|
116
|
+
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
117
|
+
"test:agent": "node --test --test-force-exit test/agent.test.mjs",
|
|
118
|
+
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
119
|
+
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
120
|
+
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/chatops-fleet-join.test.mjs",
|
|
121
|
+
"pack": "node scripts/pack.mjs",
|
|
122
|
+
"verify:join": "node scripts/verify-join.mjs",
|
|
123
|
+
"lint": "eslint .",
|
|
124
|
+
"lint:fix": "eslint . --fix",
|
|
125
|
+
"format": "prettier --write .",
|
|
126
|
+
"format:check": "prettier --check .",
|
|
127
|
+
"verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
|
|
128
|
+
"test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs"
|
|
129
|
+
},
|
|
113
130
|
"keywords": [
|
|
114
131
|
"dsh-bundle",
|
|
115
132
|
"dsh-plugin",
|
|
@@ -134,22 +151,5 @@
|
|
|
134
151
|
"publishConfig": {
|
|
135
152
|
"access": "public"
|
|
136
153
|
},
|
|
137
|
-
"license": "MIT"
|
|
138
|
-
|
|
139
|
-
"test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/chatops-fleet-join.test.mjs",
|
|
140
|
-
"test:e2e": "node --test --test-force-exit 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/p2p-agent.test.mjs",
|
|
141
|
-
"test:relay": "node --test --test-force-exit test/relay.test.mjs",
|
|
142
|
-
"test:agent": "node --test --test-force-exit test/agent.test.mjs",
|
|
143
|
-
"test:tools": "node --test --test-force-exit test/tools.test.mjs",
|
|
144
|
-
"test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
|
|
145
|
-
"test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.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/chatops-fleet-join.test.mjs",
|
|
146
|
-
"pack": "node scripts/pack.mjs",
|
|
147
|
-
"verify:join": "node scripts/verify-join.mjs",
|
|
148
|
-
"lint": "eslint .",
|
|
149
|
-
"lint:fix": "eslint . --fix",
|
|
150
|
-
"format": "prettier --write .",
|
|
151
|
-
"format:check": "prettier --check .",
|
|
152
|
-
"verify": "node scripts/check-entrypoints.mjs && node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/tools.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/p2p-node.test.mjs test/p2p-agent.test.mjs test/client-bundle.test.mjs test/loader-entry.test.mjs",
|
|
153
|
-
"test:p2p": "node --test --test-force-exit test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/stun-server.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs"
|
|
154
|
-
}
|
|
155
|
-
}
|
|
154
|
+
"license": "MIT"
|
|
155
|
+
}
|
package/src/chatops/bridge.mjs
CHANGED
|
@@ -241,8 +241,16 @@ export function createBridge(options) {
|
|
|
241
241
|
return { action: routed.kind, name: routed.name, refusal: routed.refusal, taskId: routed.taskId };
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
/**
|
|
245
|
-
|
|
244
|
+
/**
|
|
245
|
+
* The action set the command table sees.
|
|
246
|
+
*
|
|
247
|
+
* v0.9.0: this took a `windowKey` and never used it. The doc line above used to claim the window
|
|
248
|
+
* was "bound in", which was not true of the body -- the real binding is that `pending` takes the
|
|
249
|
+
* window per call (`broker.listFor(window)`), so the injected set is window-independent by design.
|
|
250
|
+
* The parameter is gone rather than renamed: a caller passing a window here was reading a promise
|
|
251
|
+
* the signature did not keep.
|
|
252
|
+
*/
|
|
253
|
+
function actionsFor() {
|
|
246
254
|
const injected = options.actions ?? {};
|
|
247
255
|
return {
|
|
248
256
|
...injected,
|
|
@@ -164,9 +164,13 @@ export function createFileReader(options) {
|
|
|
164
164
|
// Cheap refusal first, so a traversal never even reaches the filesystem.
|
|
165
165
|
if (!contains(root, lexical)) return refuse(FILE_REFUSAL.ESCAPED, `lexical: ${lexical}`);
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
// The binding is unused by design: the *call* is the check. A missing path must be refused with
|
|
168
|
+
// `MISSING` here rather than surfacing as a `realpath` failure below, because the two are
|
|
169
|
+
// different answers for the caller -- "there is no such file" versus "there is one and it escapes
|
|
170
|
+
// the root". Underscored, per the config's own convention for an unused binding.
|
|
171
|
+
let _stats;
|
|
168
172
|
try {
|
|
169
|
-
|
|
173
|
+
_stats = await lstat(lexical);
|
|
170
174
|
} catch {
|
|
171
175
|
return refuse(FILE_REFUSAL.MISSING, relPath);
|
|
172
176
|
}
|
package/src/chatops/fleet.mjs
CHANGED
|
@@ -111,7 +111,7 @@ export function createFleetExecutor(options) {
|
|
|
111
111
|
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
112
112
|
const doFetch = options.fetch ?? globalThis.fetch;
|
|
113
113
|
const now = options.now ?? (() => Date.now());
|
|
114
|
-
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
114
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => { setTimeout(r, ms); }));
|
|
115
115
|
const audit = options.audit ?? (() => {});
|
|
116
116
|
|
|
117
117
|
/** One relay call. Resolves for every HTTP status: a refusal is an answer, not an exception. */
|
package/src/chatops/ilink.mjs
CHANGED
|
@@ -36,17 +36,7 @@ import { CHANNEL_STATE, CHANNEL_FEATURE } from './channel.mjs';
|
|
|
36
36
|
// from here; the definitions live in ilink-protocol.mjs, which is what the contract suite pins.
|
|
37
37
|
export * from './ilink-protocol.mjs';
|
|
38
38
|
import { createTransport } from './ilink-transport.mjs';
|
|
39
|
-
import {
|
|
40
|
-
QR_BASE_URL,
|
|
41
|
-
ENDPOINTS,
|
|
42
|
-
BOT_TYPE,
|
|
43
|
-
LOGIN_STATUS,
|
|
44
|
-
MESSAGE,
|
|
45
|
-
baseInfo,
|
|
46
|
-
isAllowedApiUrl,
|
|
47
|
-
extractText,
|
|
48
|
-
messageId,
|
|
49
|
-
} from './ilink-protocol.mjs';
|
|
39
|
+
import { QR_BASE_URL, ENDPOINTS, BOT_TYPE, MESSAGE, baseInfo, isAllowedApiUrl, extractText, messageId } from './ilink-protocol.mjs';
|
|
50
40
|
|
|
51
41
|
/** Long poll: the platform holds the request open this long, so the deadline must exceed it. */
|
|
52
42
|
const LONG_POLL_MS = 35_000;
|
|
@@ -117,7 +107,7 @@ export function createILinkChannel(options = {}) {
|
|
|
117
107
|
return apiBase;
|
|
118
108
|
}
|
|
119
109
|
|
|
120
|
-
const
|
|
110
|
+
const _url = (endpoint) => new URL(endpoint, QR_BASE_URL).toString();
|
|
121
111
|
|
|
122
112
|
/** One request, with the outcome returned as data. Never throws on a business refusal. */
|
|
123
113
|
async function call(endpoint, body, { base = null, authenticated = true, timeoutMs = 20_000, signal } = {}) {
|
package/src/chatops/index.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import { createILinkChannel } from './ilink.mjs';
|
|
|
37
37
|
import { isAllowedApiUrl } from './ilink-protocol.mjs';
|
|
38
38
|
import { createFleetExecutor } from './fleet.mjs';
|
|
39
39
|
import { createChatopsActions } from './actions.mjs';
|
|
40
|
-
import { createAccountIdentity,
|
|
40
|
+
import { createAccountIdentity, SN_STATE_KEY } from './account.mjs';
|
|
41
41
|
import { helpText, commandTable } from './commands.mjs';
|
|
42
42
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
43
43
|
import { join, dirname } from 'node:path';
|
|
@@ -120,7 +120,7 @@ export function createOutboundQueue(options) {
|
|
|
120
120
|
const random = options.random ?? Math.random;
|
|
121
121
|
const audit = options.audit ?? (() => {});
|
|
122
122
|
|
|
123
|
-
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
+
const realSleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
124
124
|
const sleep = options.sleep ?? realSleep;
|
|
125
125
|
|
|
126
126
|
/** window -> lane -> { tail, pending, lastSentAt } */
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -180,7 +180,7 @@ const MAX_STAGES = 16;
|
|
|
180
180
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
181
181
|
* overrides it at runtime for tests and packagers.
|
|
182
182
|
*/
|
|
183
|
-
const PLUGIN_VERSION = '0.
|
|
183
|
+
const PLUGIN_VERSION = '0.9.1';
|
|
184
184
|
|
|
185
185
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
186
186
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -4621,6 +4621,17 @@ export async function apply(ctx, config = {}) {
|
|
|
4621
4621
|
machine_id: d?.machine_id ?? null,
|
|
4622
4622
|
machine_name: d?.machine_name ?? null,
|
|
4623
4623
|
online: d?.online === true,
|
|
4624
|
+
/**
|
|
4625
|
+
* v0.9.1: why a machine is not usable, when it is not.
|
|
4626
|
+
*
|
|
4627
|
+
* Passed through rather than summarised, because "offline" alone was the shape that
|
|
4628
|
+
* hid the reported defect: a machine powered off for hours was listed `online` and
|
|
4629
|
+
* selectable, and there was no field anywhere that would have said otherwise. A
|
|
4630
|
+
* relay older than v0.9.1 sends no such field, so this reads `null` there rather than
|
|
4631
|
+
* inventing a reason.
|
|
4632
|
+
*/
|
|
4633
|
+
offline_reason: d?.offline_reason ?? null,
|
|
4634
|
+
silent_ms: d?.silent_ms ?? null,
|
|
4624
4635
|
rtt_ms: d?.rtt_ms ?? null,
|
|
4625
4636
|
rtt_stale: d?.rtt_stale ?? null,
|
|
4626
4637
|
last_heartbeat_at: d?.last_heartbeat_at ?? null,
|
|
@@ -4930,7 +4941,7 @@ async function startLocalStack(ctx, cfg) {
|
|
|
4930
4941
|
if (attempt < attempts) {
|
|
4931
4942
|
const waitMs = Math.min(2000 * 2 ** (attempt - 1), 30000);
|
|
4932
4943
|
ctx.logger?.info?.(`w2m: no fleet key yet (${describeIdentityFailure(last)}); retrying in ${waitMs}ms`);
|
|
4933
|
-
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
4944
|
+
await new Promise((resolve) => { setTimeout(resolve, waitMs); });
|
|
4934
4945
|
}
|
|
4935
4946
|
}
|
|
4936
4947
|
// Not fatal: the caller may pair by code, and a later attempt may still succeed.
|
|
@@ -5057,7 +5068,6 @@ async function startLocalStack(ctx, cfg) {
|
|
|
5057
5068
|
/** True when the last attempt failed without sending anything to the relay. */
|
|
5058
5069
|
let joinLastWasKeyless = false;
|
|
5059
5070
|
let joinSucceeded = false;
|
|
5060
|
-
let joinAttempts = 0;
|
|
5061
5071
|
let joinLastError = null;
|
|
5062
5072
|
async function joinFleet() {
|
|
5063
5073
|
if (joinSucceeded) return { paired: true, by: 'existing token' };
|
|
@@ -5067,7 +5077,6 @@ async function startLocalStack(ctx, cfg) {
|
|
|
5067
5077
|
if (coolingDown) return joinPromise;
|
|
5068
5078
|
joinPromiseAt = Date.now();
|
|
5069
5079
|
joinPromise = (async () => {
|
|
5070
|
-
joinAttempts += 1;
|
|
5071
5080
|
if (alreadyPaired) {
|
|
5072
5081
|
joinSucceeded = true;
|
|
5073
5082
|
ctx.logger?.info?.(`w2m: already paired (token in ${loaded.path}); not pairing again`);
|
|
@@ -5190,10 +5199,29 @@ async function startLocalStack(ctx, cfg) {
|
|
|
5190
5199
|
}
|
|
5191
5200
|
if (!alreadyPaired) scheduleJoinRetry();
|
|
5192
5201
|
|
|
5193
|
-
//
|
|
5202
|
+
// §8.2: when the agent runs inside this process its `rttMs` is reachable directly, so `w2m_status`
|
|
5194
5203
|
// can report it without a file. When the agent is its own process this stays null and the tool
|
|
5195
5204
|
// says so instead of guessing.
|
|
5205
|
+
//
|
|
5206
|
+
// v0.9.0: **this assignment is the whole feature, and it was missing.**
|
|
5207
|
+
//
|
|
5208
|
+
// The getter was built and then dropped on the floor, so `inProcessAgentStatus` stayed `null` for
|
|
5209
|
+
// the life of the process. `readRtt` opens with `if (inProcessAgentStatus)` and skips the entire
|
|
5210
|
+
// in-process branch when it is null -- so it fell through to reading a state file, and reported
|
|
5211
|
+
//
|
|
5212
|
+
// no agent round-trip time is available: the agent is not running in this process and no agent
|
|
5213
|
+
// state file was found under <stateDir>
|
|
5214
|
+
//
|
|
5215
|
+
// on a machine whose agent *was* in that process and *was* publishing that file. The wording is
|
|
5216
|
+
// wrong twice over, and it is wrong in the direction that costs the most: it tells the reader to
|
|
5217
|
+
// doubt the agent, on the one call they make to find out what is actually happening.
|
|
5218
|
+
//
|
|
5219
|
+
// This is the defect shape the release notes keep recording -- *the record was created and the
|
|
5220
|
+
// part that makes it useful was not filled in* -- and it is the same shape as the identity line
|
|
5221
|
+
// fixed above. ESLint's `no-unused-vars` is what surfaced it: the variable was read nowhere
|
|
5222
|
+
// because nothing was ever assigned to the thing it was meant to feed.
|
|
5196
5223
|
const statusGetter = agent.status ?? agent.state ?? null;
|
|
5224
|
+
if (typeof statusGetter === 'function') inProcessAgentStatus = statusGetter;
|
|
5197
5225
|
|
|
5198
5226
|
return async () => {
|
|
5199
5227
|
clearJoinRetry();
|
package/src/relay/server.mjs
CHANGED
|
@@ -1086,6 +1086,18 @@ export class RelayServer {
|
|
|
1086
1086
|
/** Periodic maintenance, called by the sweep timer. */
|
|
1087
1087
|
maintenance() {
|
|
1088
1088
|
const expired = this.sweep();
|
|
1089
|
+
/**
|
|
1090
|
+
* v0.9.1: presence expiry, alongside lease expiry.
|
|
1091
|
+
*
|
|
1092
|
+
* `this.sweep()` writes off lease *holds*; this writes off *presence*. They were conflated, so a
|
|
1093
|
+
* machine that stopped existing was only ever noticed when its SSE stream detached — and a
|
|
1094
|
+
* half-open stream never detaches.
|
|
1095
|
+
*/
|
|
1096
|
+
const silent = this.state.sweepSilentDevices();
|
|
1097
|
+
if (silent.length > 0) {
|
|
1098
|
+
this._log(`[w2m-rabbit] marked ${silent.length} silent device(s) offline: `
|
|
1099
|
+
+ silent.map((s) => `${s.machine_name} (${Math.round(s.silent_ms / 1000)}s)`).join(', '));
|
|
1100
|
+
}
|
|
1089
1101
|
this.pairLimiter.prune();
|
|
1090
1102
|
this.peers.sweep();
|
|
1091
1103
|
// Keep the nonce cache from growing without bound in a long-running process.
|
package/src/relay/state.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
16
16
|
// v0.7.7: fleet scoping. Imported rather than inlined so the rule has one home with its own
|
|
17
17
|
// exhaustive tests — three lines inside this object would be untestable without a live relay.
|
|
18
18
|
import { targetsFor } from '../chatops/fleet-wiring.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { deviceFleetFields } from '../chatops/fleet-wiring.mjs';
|
|
20
20
|
// v0.8.1: the account-derived fleet key, so a machine can join with no pairing code.
|
|
21
21
|
import { planJoin } from '../chatops/fleet-join.mjs';
|
|
22
22
|
|
|
@@ -121,6 +121,37 @@ export const MAX_RTT_MS = 24 * 60 * 60 * 1000;
|
|
|
121
121
|
export const DEFAULT_RTT_STALE_MS = 180_000;
|
|
122
122
|
/** v0.3.0: recommended cadence for an agent with no lease (reference for the agent side). */
|
|
123
123
|
export const DEFAULT_IDLE_HEARTBEAT_MS = 60_000;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* v0.9.1: how long a device may stay `online` without any contact before the relay calls it silent.
|
|
127
|
+
*
|
|
128
|
+
* ## The defect this closes
|
|
129
|
+
*
|
|
130
|
+
* `online` was written in exactly two places, both driven by the SSE stream: set true when a
|
|
131
|
+
* subscriber attached, false when it detached. **Nothing derived it from contact.** So a machine
|
|
132
|
+
* whose process died without the TCP connection closing — a half-open stream, which is what a killed
|
|
133
|
+
* process or a yanked network path leaves behind — stayed `online: true` in `/v1/devices`
|
|
134
|
+
* indefinitely, and stayed selectable as a dispatch target. Reported from a real fleet: a Windows
|
|
135
|
+
* machine powered off hours earlier was still listed online, with `last_heartbeat_at: null` to say it
|
|
136
|
+
* had never heartbeated at all.
|
|
137
|
+
*
|
|
138
|
+
* `peer.bye` did not help either, because it is emitted *by* the stream closing (`setOnline(…, false)`
|
|
139
|
+
* on detach). With no close there is no bye, so the machine was never removed from any other
|
|
140
|
+
* machine's fleet view either — which is the "not broadcast" half of the same report.
|
|
141
|
+
*
|
|
142
|
+
* ## The threshold
|
|
143
|
+
*
|
|
144
|
+
* An idle agent heartbeats every {@link DEFAULT_IDLE_HEARTBEAT_MS} (60 s), and a busy one renews its
|
|
145
|
+
* lease every {@link DEFAULT_HEARTBEAT_INTERVAL_MS} (10 s), so the *slowest* healthy cadence is 60 s.
|
|
146
|
+
* Two skipped idle beats is therefore 120 s: a live machine cannot reach it, and a dead one is
|
|
147
|
+
* noticed within two cycles plus the sweep tick.
|
|
148
|
+
*
|
|
149
|
+
* Deliberately longer than `heartbeatIntervalMs * missedHeartbeats` (the 20 s lease rule): that rule
|
|
150
|
+
* is about holding a *task index*, where being wrong costs a duplicated execution. This one is about
|
|
151
|
+
* a display and a target list, where being wrong costs a wasted dispatch. Same reasoning, different
|
|
152
|
+
* price, so not the same number.
|
|
153
|
+
*/
|
|
154
|
+
export const DEFAULT_SILENCE_MS = DEFAULT_IDLE_HEARTBEAT_MS * 2;
|
|
124
155
|
export const ENVELOPE_VERSION = '1.0';
|
|
125
156
|
export const FINGERPRINT_ALGO = 'git-temp-index-tree/v1';
|
|
126
157
|
|
|
@@ -416,6 +447,11 @@ export class RabbitState {
|
|
|
416
447
|
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
417
448
|
this.missedHeartbeats = options.missedHeartbeats ?? DEFAULT_MISSED_HEARTBEATS;
|
|
418
449
|
this.leaseGraceMs = options.leaseGraceMs ?? DEFAULT_LEASE_GRACE_MS;
|
|
450
|
+
/**
|
|
451
|
+
* v0.9.1: how long an `online` device may go without contact before it is declared silent.
|
|
452
|
+
* See {@link DEFAULT_SILENCE_MS} for why this is not the lease number.
|
|
453
|
+
*/
|
|
454
|
+
this.silenceMs = options.silenceMs ?? DEFAULT_SILENCE_MS;
|
|
419
455
|
/**
|
|
420
456
|
* v0.4.0: how long the relay holds its own offer back when the dispatcher says it will push the
|
|
421
457
|
* offer over the direct path (`origin_machine_id` + `p2p.mode`).
|
|
@@ -1049,6 +1085,67 @@ export class RabbitState {
|
|
|
1049
1085
|
};
|
|
1050
1086
|
}
|
|
1051
1087
|
|
|
1088
|
+
/**
|
|
1089
|
+
* v0.9.1: how long since this device last made any contact, or null when it never has.
|
|
1090
|
+
*
|
|
1091
|
+
* `last_seen_at_ms` is the right field rather than `last_heartbeat_at_ms`: `touchDevice` advances it
|
|
1092
|
+
* on every contact the relay accepts — a heartbeat, a task poll, a lease renewal — so it measures
|
|
1093
|
+
* "is anything there", while `last_heartbeat_at_ms` measures "has it ever heartbeated", which is
|
|
1094
|
+
* null for an agent old enough not to send them.
|
|
1095
|
+
*
|
|
1096
|
+
* Falls back to `paired_at_ms` so that a device which paired but has not spoken since is judged
|
|
1097
|
+
* from the moment it arrived rather than from `null`, which would have read as "silent forever" and
|
|
1098
|
+
* marked it offline the instant it appeared.
|
|
1099
|
+
*
|
|
1100
|
+
* @param {object} device
|
|
1101
|
+
* @param {number} nowMs
|
|
1102
|
+
* @returns {number|null}
|
|
1103
|
+
*/
|
|
1104
|
+
silenceAgeMs(device, nowMs = this.nowMs()) {
|
|
1105
|
+
const seen = device.last_seen_at_ms ?? device.last_heartbeat_at_ms ?? device.paired_at_ms ?? null;
|
|
1106
|
+
if (seen === null || seen === undefined) return null;
|
|
1107
|
+
return Math.max(0, nowMs - seen);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* v0.9.1 §4.3 companion: declare a device silent once nothing has contacted the relay for
|
|
1112
|
+
* {@link DEFAULT_SILENCE_MS}.
|
|
1113
|
+
*
|
|
1114
|
+
* ## Why this exists next to `sweepExpired`
|
|
1115
|
+
*
|
|
1116
|
+
* `sweepExpired` writes off lease *holds*; this writes off *presence*. Both are expiry, and they
|
|
1117
|
+
* were conflated: a machine that stopped existing was only noticed when its SSE stream actually
|
|
1118
|
+
* detached, so a half-open stream — a killed process, a pulled cable, a laptop lid — left it
|
|
1119
|
+
* `online: true` and selectable for as long as the socket stayed open, which can be hours or
|
|
1120
|
+
* indefinitely.
|
|
1121
|
+
*
|
|
1122
|
+
* Going through {@link setOnline} is the point rather than an implementation detail: it flips the
|
|
1123
|
+
* flag **and emits `peer.bye`**, which is what tells every other machine in the fleet. Fixing only
|
|
1124
|
+
* the flag would correct `/v1/devices` on the relay while leaving every agent's own view — and
|
|
1125
|
+
* therefore every machine picker — still showing a departed machine.
|
|
1126
|
+
*
|
|
1127
|
+
* @param {number} [nowMs]
|
|
1128
|
+
* @returns {Array<{machine_id: string, machine_name: string, silent_ms: number}>} what was swept
|
|
1129
|
+
*/
|
|
1130
|
+
sweepSilentDevices(nowMs = this.nowMs()) {
|
|
1131
|
+
const went = [];
|
|
1132
|
+
for (const device of this.devices.values()) {
|
|
1133
|
+
if (device.online !== true) continue;
|
|
1134
|
+
const age = this.silenceAgeMs(device, nowMs);
|
|
1135
|
+
if (age === null || age < this.silenceMs) continue;
|
|
1136
|
+
this.emit('notice', {
|
|
1137
|
+
level: 'info',
|
|
1138
|
+
code: 'DEVICE_SILENT',
|
|
1139
|
+
message: `machine ${device.machine_name} (${device.machine_id}) stopped making contact; marking it offline`,
|
|
1140
|
+
machine_id: device.machine_id,
|
|
1141
|
+
silent_ms: age,
|
|
1142
|
+
});
|
|
1143
|
+
this.setOnline(device.machine_id, false, 'silent');
|
|
1144
|
+
went.push({ machine_id: device.machine_id, machine_name: device.machine_name, silent_ms: age });
|
|
1145
|
+
}
|
|
1146
|
+
return went;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1052
1149
|
/**
|
|
1053
1150
|
* v0.3.0 single-machine status view.
|
|
1054
1151
|
*
|
|
@@ -1056,19 +1153,37 @@ export class RabbitState {
|
|
|
1056
1153
|
* between them is the whole point: a machine can be heartbeating (alive) while
|
|
1057
1154
|
* its RTT number is old (a v0.2.3 agent that never sends `rtt_ms`), and a
|
|
1058
1155
|
* machine can be gone while a plausible-looking RTT is still on file.
|
|
1156
|
+
*
|
|
1157
|
+
* v0.9.1: `online` is additionally cross-checked against silence, so a direct call to this method
|
|
1158
|
+
* (a test, or a caller that never runs the sweep) cannot report a departed machine as present.
|
|
1159
|
+
* The sweep is still what fixes the flag and broadcasts; this only refuses to repeat a stale `true`.
|
|
1059
1160
|
*/
|
|
1060
1161
|
deviceStatus(machineId, nowMs = this.nowMs()) {
|
|
1061
1162
|
const device = this.devices.get(machineId);
|
|
1062
1163
|
if (!device) return null;
|
|
1063
1164
|
const rttAt = device.rtt_at_ms ?? null;
|
|
1064
1165
|
const lastHeartbeat = device.last_heartbeat_at_ms ?? null;
|
|
1166
|
+
const silentMs = this.silenceAgeMs(device, nowMs);
|
|
1167
|
+
const silent = device.online === true && silentMs !== null && silentMs >= this.silenceMs;
|
|
1065
1168
|
return {
|
|
1066
1169
|
machine_id: device.machine_id,
|
|
1067
1170
|
machine_name: device.machine_name,
|
|
1068
1171
|
relay_id: this.relayId,
|
|
1069
1172
|
connected: (device.streams ?? 0) > 0,
|
|
1070
1173
|
streams: device.streams ?? 0,
|
|
1071
|
-
online: device.online === true,
|
|
1174
|
+
online: device.online === true && !silent,
|
|
1175
|
+
/**
|
|
1176
|
+
* Why this machine is not usable, when it is not. `null` for a healthy one.
|
|
1177
|
+
*
|
|
1178
|
+
* Added because "offline" without a reason is exactly the shape that made this defect hard to
|
|
1179
|
+
* see: a machine that had been powered off for hours was listed `online`, and a machine that is
|
|
1180
|
+
* correctly offline still gives no clue whether it is gone or merely quiet.
|
|
1181
|
+
*
|
|
1182
|
+
* `silent` first, for the same reason as in `publicDevice`: the stored flag is `true` in the
|
|
1183
|
+
* half-open case, so the silence is the finding.
|
|
1184
|
+
*/
|
|
1185
|
+
offline_reason: silent ? 'silent' : (device.online !== true ? 'stream_closed' : null),
|
|
1186
|
+
silent_ms: silentMs,
|
|
1072
1187
|
rtt_ms: device.rtt_ms ?? null,
|
|
1073
1188
|
rtt_at: rttAt === null ? null : rfc3339(rttAt),
|
|
1074
1189
|
rtt_age_ms: this.rttAgeMs(device, nowMs),
|
|
@@ -1089,6 +1204,9 @@ export class RabbitState {
|
|
|
1089
1204
|
|
|
1090
1205
|
/** Public device view — never leaks device_token. */
|
|
1091
1206
|
publicDevice(device) {
|
|
1207
|
+
// Computed once, so the three fields below cannot disagree with each other.
|
|
1208
|
+
const silentMs = this.silenceAgeMs(device);
|
|
1209
|
+
const silent = silentMs !== null && silentMs >= this.silenceMs;
|
|
1092
1210
|
return {
|
|
1093
1211
|
machine_id: device.machine_id,
|
|
1094
1212
|
machine_name: device.machine_name,
|
|
@@ -1114,8 +1232,28 @@ export class RabbitState {
|
|
|
1114
1232
|
// list is read by every device, and there is no reason to hand every machine every other
|
|
1115
1233
|
// machine's key when the verification happens relay-side.
|
|
1116
1234
|
public_key_present: typeof device.public_key === 'string' && device.public_key !== '',
|
|
1117
|
-
|
|
1235
|
+
/**
|
|
1236
|
+
* v0.9.1: `online` is derived, not just read.
|
|
1237
|
+
*
|
|
1238
|
+
* It used to be the raw stored flag, which is only ever written by the SSE stream attaching or
|
|
1239
|
+
* detaching. A half-open stream therefore reported `online: true` for a machine that was gone,
|
|
1240
|
+
* and `/v1/devices` handed that to every machine picker in the fleet. The sweep is what flips
|
|
1241
|
+
* the stored flag and broadcasts `peer.bye`; this expression makes the read agree with it even
|
|
1242
|
+
* between sweeps, so no caller can observe the stale value at all.
|
|
1243
|
+
*/
|
|
1244
|
+
online: device.online === true && !silent,
|
|
1118
1245
|
streams: device.streams,
|
|
1246
|
+
/**
|
|
1247
|
+
* v0.9.1: why this machine is not usable, when it is not.
|
|
1248
|
+
*
|
|
1249
|
+
* `silent` is tested FIRST, and that order is the whole subtlety: a half-open stream leaves the
|
|
1250
|
+
* stored `online` flag `true`, so testing `device.online !== true` first reported
|
|
1251
|
+
* `stream_closed` for a machine whose stream was precisely the thing still attached. The
|
|
1252
|
+
* reported defect in one line -- the flag says connected, the truth says gone, and the reason
|
|
1253
|
+
* has to name the truth.
|
|
1254
|
+
*/
|
|
1255
|
+
offline_reason: silent ? 'silent' : (device.online !== true ? 'stream_closed' : null),
|
|
1256
|
+
silent_ms: silentMs,
|
|
1119
1257
|
paired_at: rfc3339(device.paired_at_ms),
|
|
1120
1258
|
last_seen_at: rfc3339(device.last_seen_at_ms),
|
|
1121
1259
|
// v0.3.0: `null` means "never measured", which is NOT the same as 0ms.
|
|
@@ -1488,7 +1626,25 @@ export class RabbitState {
|
|
|
1488
1626
|
|
|
1489
1627
|
if (targets.length === 0) {
|
|
1490
1628
|
throw new ProtocolError('NO_ONLINE_DEVICE', 'no paired device available for this task', {
|
|
1491
|
-
|
|
1629
|
+
/**
|
|
1630
|
+
* v0.9.0: `all` was never defined here — this threw `ReferenceError: all is not defined`
|
|
1631
|
+
* instead of the refusal it was written to be, and it did so on **the one path an operator
|
|
1632
|
+
* actually reaches**: `targets` is empty exactly when the fleet has nothing to dispatch to,
|
|
1633
|
+
* which is what a freshly configured machine sees before its first peer joins.
|
|
1634
|
+
*
|
|
1635
|
+
* The count an operator needs there is "how many devices does this relay know at all",
|
|
1636
|
+
* because the answer distinguishes *"nothing is paired"* from *"something is paired but
|
|
1637
|
+
* scoped out"* — and `targets` being fleet-filtered cannot answer that. `this.devices` is
|
|
1638
|
+
* the unfiltered set the same call hands to `targetsFor` a few lines above (see the
|
|
1639
|
+
* `devices: [...this.devices.values()]` there), so its size is that number.
|
|
1640
|
+
*
|
|
1641
|
+
* ESLint's `no-undef` found this, which is the rule the project's own lint audit credits as
|
|
1642
|
+
* "what caught the one real defect" of that round. This is the same class of defect one
|
|
1643
|
+
* release later, and the audit's note about it is worth repeating: an unbound identifier in
|
|
1644
|
+
* an ESM module is a `ReferenceError` at evaluation time, not an `undefined` that quietly
|
|
1645
|
+
* takes a branch.
|
|
1646
|
+
*/
|
|
1647
|
+
known_devices: this.devices.size,
|
|
1492
1648
|
});
|
|
1493
1649
|
}
|
|
1494
1650
|
|