hivecode-mcp 0.11.6 → 0.12.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/HIVE_RULES.md +127 -127
- package/README.md +119 -69
- package/core.js +14 -146
- package/docs/MCP.md +19 -11
- package/docs/SELF_HOSTING.md +256 -0
- package/hive-activity.js +310 -0
- package/hive-mcp.js +447 -76
- package/hive-mux-proto.js +58 -0
- package/hive-mux.js +350 -0
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +288 -0
- package/hive-relay-health.js +225 -0
- package/hive-setup.js +445 -0
- package/hive-stdio.js +168 -0
- package/hive-update.js +58 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/limits.js +49 -1
- package/package.json +29 -7
- package/reconcile.cjs +477 -0
- package/sync.js +613 -43
package/hive-privacy.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// WHICH RELAY IS THIS, AND DID ANYBODY CHOOSE IT?
|
|
2
|
+
//
|
|
3
|
+
// Every byte of a team's source leaves their machines through one URL: the relay. The
|
|
4
|
+
// client has always printed that URL and stopped there -- a bare `relay: wss://...` line
|
|
5
|
+
// that says nothing about what it MEANS. That is the whole defect this file exists for,
|
|
6
|
+
// and it is the product's highest-stakes false success:
|
|
7
|
+
//
|
|
8
|
+
// An agent starts with no link and no .hive.json. resolveRoom() falls through to the
|
|
9
|
+
// shipped default -- a relay the team does not run. The agent joins. hive_status says
|
|
10
|
+
// connected. filesSynced climbs. Every signal is green, and the company's source code
|
|
11
|
+
// is now on a stranger's server. Nothing crashed, so nothing reports it.
|
|
12
|
+
//
|
|
13
|
+
// Two separate things are needed and they are NOT the same thing:
|
|
14
|
+
//
|
|
15
|
+
// 1. A VERDICT on every join, so "am I private?" is answered without being asked.
|
|
16
|
+
// Printing the URL is not a verdict. A person who has never run a relay cannot tell
|
|
17
|
+
// wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
|
|
18
|
+
//
|
|
19
|
+
// 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
|
|
20
|
+
// an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
|
|
21
|
+
// ever talk to these relays" -- set once in the MCP config, and no stale link, no
|
|
22
|
+
// typo'd host and no forgotten env var can send a file anywhere else. The refusal
|
|
23
|
+
// happens BEFORE the socket opens, so a refused join publishes nothing.
|
|
24
|
+
//
|
|
25
|
+
// Comparison is on ORIGIN, never on the raw string. wss://r.co, wss://r.co/ and
|
|
26
|
+
// wss://R.co:443 are one relay; a string compare would refuse a correct configuration,
|
|
27
|
+
// and a guard that fires on the healthy path is a guard people disable.
|
|
28
|
+
|
|
29
|
+
// The relay this build falls back to when nobody chose one -- THE ONE PLACE IT IS WRITTEN.
|
|
30
|
+
//
|
|
31
|
+
// It used to be written four times: here, hive-mcp.js, hive-agent.js and hive-say.js, each a
|
|
32
|
+
// literal. On 23 Sep 2026 the hosted relay's Render account was suspended and a new one stood
|
|
33
|
+
// up at a new address, and moving meant hunting down every copy -- any one missed would have
|
|
34
|
+
// sent that client to a dead host, or made this module call the live default "YOUR RELAY".
|
|
35
|
+
// Every client now imports it from here. The extension's JSON setting cannot import, so
|
|
36
|
+
// extension-privacy-parity-test pins it equal to this value instead.
|
|
37
|
+
export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
|
|
38
|
+
|
|
39
|
+
// Relays this project has run publicly and retired. A .hive.json written before a move still
|
|
40
|
+
// names the old one, and if that host ever answers again (a reactivated account, a squatter
|
|
41
|
+
// on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
|
|
42
|
+
// outside this list is called.
|
|
43
|
+
export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The relay a client should actually dial for a URL it was handed.
|
|
47
|
+
*
|
|
48
|
+
* A RETIRED PUBLIC RELAY IS FOLLOWED TO ITS REPLACEMENT, and nothing else is ever rewritten.
|
|
49
|
+
* Measured 23 Sep 2026 on 0.12.1 -- a build that already shipped the new default: a project
|
|
50
|
+
* folder whose .hive.json was written before the move still named the retired host, the client
|
|
51
|
+
* obeyed it, and a returning user got "NOT JOINED ... 503" exactly as if nothing had been fixed.
|
|
52
|
+
* The same held for HIVE_RELAY set to the old host, which docs/MCP.md told people to do.
|
|
53
|
+
*
|
|
54
|
+
* That address was never the user's choice -- it was this project's default, copied into their
|
|
55
|
+
* config by the client and by our own docs -- so following it to the current default honours
|
|
56
|
+
* what they actually chose (the public relay), and moves nobody from anyone to anyone else.
|
|
57
|
+
* A relay the user runs is never in RETIRED_PUBLIC_RELAYS and is returned untouched.
|
|
58
|
+
*
|
|
59
|
+
* Returns { relay, migratedFrom } -- migratedFrom is the retired URL when a move happened,
|
|
60
|
+
* so the caller can say so on screen instead of changing someone's destination silently.
|
|
61
|
+
*/
|
|
62
|
+
export function currentRelayFor(url) {
|
|
63
|
+
const origin = relayOrigin(url)
|
|
64
|
+
if (origin && RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) {
|
|
65
|
+
return { relay: PUBLIC_DEFAULT_RELAY, migratedFrom: String(url).trim() }
|
|
66
|
+
}
|
|
67
|
+
return { relay: url, migratedFrom: null }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalise a relay URL to a comparable origin: scheme://host:port, lowercased, with the
|
|
74
|
+
* scheme's default port made explicit. Returns null for anything that is not a URL --
|
|
75
|
+
* callers must treat null as "unknown", never as "fine".
|
|
76
|
+
*/
|
|
77
|
+
export function relayOrigin(url) {
|
|
78
|
+
const s = String(url || '').trim()
|
|
79
|
+
if (!s) return null
|
|
80
|
+
let u
|
|
81
|
+
try { u = new URL(s) } catch { return null }
|
|
82
|
+
const proto = u.protocol.toLowerCase()
|
|
83
|
+
if (!DEFAULT_PORT[proto]) return null
|
|
84
|
+
const port = u.port || DEFAULT_PORT[proto]
|
|
85
|
+
// ws:// and wss:// to the same host:port are the SAME relay reached two ways, and a pin
|
|
86
|
+
// written as https:// (which is what a person copies out of a browser) must match the
|
|
87
|
+
// wss:// the client actually dials. Scheme is reported separately by classifyRelay so
|
|
88
|
+
// the plaintext warning still fires; it deliberately does not participate in identity.
|
|
89
|
+
const secureScheme = proto === 'wss:' || proto === 'https:'
|
|
90
|
+
return `${secureScheme ? 'wss' : 'ws'}://${u.hostname.toLowerCase()}:${port}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'])
|
|
94
|
+
|
|
95
|
+
function isLoopback(host) {
|
|
96
|
+
const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
97
|
+
if (LOOPBACK.has(h)) return true
|
|
98
|
+
return /^127\./.test(h)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isPrivateLan(host) {
|
|
102
|
+
const h = String(host || '').toLowerCase()
|
|
103
|
+
if (/^10\./.test(h)) return true
|
|
104
|
+
if (/^192\.168\./.test(h)) return true
|
|
105
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
|
|
106
|
+
if (/\.local$/.test(h) || /\.lan$/.test(h) || /\.internal$/.test(h)) return true
|
|
107
|
+
// A PRIVATE MESH (Tailscale and friends) IS A PRIVATE NETWORK, and the first version of this
|
|
108
|
+
// file called it "the public internet in plaintext". 100.64.0.0/10 is the shared address space
|
|
109
|
+
// Tailscale hands out and *.ts.net is its MagicDNS; the transport underneath is WireGuard,
|
|
110
|
+
// end-to-end encrypted between the two devices, and nothing outside the tailnet can even
|
|
111
|
+
// route to the relay. Warning a remote team that chose the most private setup available
|
|
112
|
+
// that their source was travelling in the clear would have taught them to ignore the verdict.
|
|
113
|
+
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true
|
|
114
|
+
if (/\.ts\.net$/.test(h)) return true
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// PUBLIC TUNNELS decrypt at their own edge. A relay on a laptop behind cloudflared or ngrok is
|
|
119
|
+
// the team's relay -- nobody else runs it -- but the tunnel provider terminates TLS and can read
|
|
120
|
+
// every frame, and anyone holding the URL can reach the port. Both facts matter to a team that
|
|
121
|
+
// picked a tunnel *for privacy*, and neither is visible in a URL that starts with wss://.
|
|
122
|
+
const PUBLIC_TUNNEL = /\.(trycloudflare\.com|ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok-free\.dev|loca\.lt|serveo\.net|localhost\.run)$/
|
|
123
|
+
|
|
124
|
+
function isPublicTunnel(host) {
|
|
125
|
+
return PUBLIC_TUNNEL.test(String(host || '').toLowerCase())
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* What kind of relay is this, and does traffic to it leave the machine in the clear?
|
|
130
|
+
*
|
|
131
|
+
* kind: 'public-default' | 'loopback' | 'private-lan' | 'tunnel' | 'own' | 'unknown'
|
|
132
|
+
*
|
|
133
|
+
* 'own' does not mean audited -- it means "not the relay we ship". A team that pastes a
|
|
134
|
+
* link from a blog post lands in 'own' too, which is exactly why the pin exists.
|
|
135
|
+
*/
|
|
136
|
+
export function classifyRelay(url) {
|
|
137
|
+
const origin = relayOrigin(url)
|
|
138
|
+
if (!origin) return { kind: 'unknown', origin: null, host: null, encrypted: false, url: String(url || '') }
|
|
139
|
+
const u = new URL(String(url).trim())
|
|
140
|
+
const host = u.hostname.toLowerCase()
|
|
141
|
+
const encrypted = u.protocol === 'wss:' || u.protocol === 'https:'
|
|
142
|
+
let kind
|
|
143
|
+
if (origin === relayOrigin(PUBLIC_DEFAULT_RELAY) ||
|
|
144
|
+
RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) kind = 'public-default'
|
|
145
|
+
else if (isLoopback(host)) kind = 'loopback'
|
|
146
|
+
else if (isPrivateLan(host)) kind = 'private-lan'
|
|
147
|
+
else if (isPublicTunnel(host)) kind = 'tunnel'
|
|
148
|
+
else kind = 'own'
|
|
149
|
+
return { kind, origin, host, encrypted, url: String(url).trim() }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The pin, from the environment or from <dir>/.hive.json's `pinnedRelay`. Comma-separated,
|
|
154
|
+
* because a team legitimately has a staging relay and a production one.
|
|
155
|
+
*
|
|
156
|
+
* A pin that cannot be parsed is an ERROR, never an empty pin. That distinction is the
|
|
157
|
+
* whole value of the feature: a typo silently collapsing to "no pin" leaves somebody
|
|
158
|
+
* believing they are locked to their own relay while the client dials the public one --
|
|
159
|
+
* the same absence-rendered-as-a-value shape the room has been bitten by before.
|
|
160
|
+
*/
|
|
161
|
+
export function readPin({ env = process.env, dir = null, fs = null } = {}) {
|
|
162
|
+
let raw = String((env && env.HIVE_RELAY_PIN) || '').trim()
|
|
163
|
+
let from = 'HIVE_RELAY_PIN'
|
|
164
|
+
if (!raw && dir && fs) {
|
|
165
|
+
try {
|
|
166
|
+
const cfg = JSON.parse(fs.readFileSync(`${dir}/.hive.json`, 'utf8'))
|
|
167
|
+
if (cfg && cfg.pinnedRelay) { raw = String(cfg.pinnedRelay).trim(); from = '.hive.json pinnedRelay' }
|
|
168
|
+
} catch { /* no config, or not JSON -- that is "no pin", not "bad pin" */ }
|
|
169
|
+
}
|
|
170
|
+
if (!raw) return { pinned: false, origins: [], from: null, raw: '' }
|
|
171
|
+
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
172
|
+
const origins = []
|
|
173
|
+
const bad = []
|
|
174
|
+
for (const p of parts) {
|
|
175
|
+
const o = relayOrigin(p)
|
|
176
|
+
if (o) origins.push(o); else bad.push(p)
|
|
177
|
+
}
|
|
178
|
+
if (bad.length || !origins.length) {
|
|
179
|
+
return { pinned: true, origins, from, raw, error: `not a relay URL: ${bad.join(', ') || raw}` }
|
|
180
|
+
}
|
|
181
|
+
return { pinned: true, origins, from, raw }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const HEADLINE = {
|
|
185
|
+
'public-default': 'PUBLIC RELAY — not yours',
|
|
186
|
+
loopback: 'PRIVATE — relay on this machine',
|
|
187
|
+
'private-lan': 'PRIVATE — relay on your network',
|
|
188
|
+
tunnel: 'YOUR RELAY — through a public tunnel',
|
|
189
|
+
own: 'YOUR RELAY',
|
|
190
|
+
unknown: 'UNKNOWN RELAY',
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The one call a client makes before opening a socket.
|
|
195
|
+
*
|
|
196
|
+
* Returns { ok, code, headline, detail, classification, pin }. `ok:false` means DO NOT
|
|
197
|
+
* CONNECT -- the caller must not open the socket and must not publish anything, because
|
|
198
|
+
* publishing is the leak this prevents. A warning (ok:true with `warn`) is printed and
|
|
199
|
+
* proceeds: a team on a plaintext LAN relay has made a choice, and refusing it would be
|
|
200
|
+
* this guard deciding policy it was not given.
|
|
201
|
+
*
|
|
202
|
+
* Q550 the relay is not one this machine is pinned to
|
|
203
|
+
* Q551 HIVE_PRIVATE is set and this is the public relay we ship
|
|
204
|
+
* Q552 the pin itself is malformed, so nothing is actually pinned
|
|
205
|
+
*/
|
|
206
|
+
export function checkRelay({ relay, env = process.env, dir = null, fs = null } = {}) {
|
|
207
|
+
const c = classifyRelay(relay)
|
|
208
|
+
const pin = readPin({ env, dir, fs })
|
|
209
|
+
const strict = String((env && env.HIVE_PRIVATE) || '') === '1'
|
|
210
|
+
|
|
211
|
+
if (pin.pinned && pin.error) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false, code: 'Q552', classification: c, pin,
|
|
214
|
+
headline: 'Q552 THE RELAY PIN IS MALFORMED — REFUSING TO CONNECT',
|
|
215
|
+
detail: [
|
|
216
|
+
`${pin.from} = ${pin.raw}`,
|
|
217
|
+
`problem: ${pin.error}`,
|
|
218
|
+
'',
|
|
219
|
+
'A pin that cannot be parsed is refused rather than ignored. Ignoring it would leave',
|
|
220
|
+
'you believing this machine is locked to your relay while it dials whatever it likes.',
|
|
221
|
+
'Write it as a full URL, e.g. HIVE_RELAY_PIN=wss://relay.yourcompany.com',
|
|
222
|
+
].join('\n'),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (pin.pinned && c.origin && !pin.origins.includes(c.origin)) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false, code: 'Q550', classification: c, pin,
|
|
229
|
+
headline: 'Q550 THIS RELAY IS NOT THE ONE THIS MACHINE IS PINNED TO — REFUSING TO CONNECT',
|
|
230
|
+
detail: [
|
|
231
|
+
`asked to connect to : ${c.url} (${c.origin})`,
|
|
232
|
+
`pinned to : ${pin.origins.join(', ')} (via ${pin.from})`,
|
|
233
|
+
'',
|
|
234
|
+
'Nothing was opened and nothing was published, so no file has left this machine.',
|
|
235
|
+
c.kind === 'public-default'
|
|
236
|
+
? 'The relay asked for is the PUBLIC one this build ships as a fallback — which is what\nyou get when a link is missing, a .hive.json was not found, or HIVE_RELAY is unset.'
|
|
237
|
+
: 'Check the join link you were given, and HIVE_RELAY in this process\'s environment.',
|
|
238
|
+
].join('\n'),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!pin.pinned && strict && c.kind === 'public-default') {
|
|
243
|
+
return {
|
|
244
|
+
ok: false, code: 'Q551', classification: c, pin,
|
|
245
|
+
headline: 'Q551 HIVE_PRIVATE=1 AND THIS IS THE PUBLIC RELAY — REFUSING TO CONNECT',
|
|
246
|
+
detail: [
|
|
247
|
+
`relay: ${c.url}`,
|
|
248
|
+
'',
|
|
249
|
+
'This is the relay this build falls back to when nobody chose one. Under HIVE_PRIVATE',
|
|
250
|
+
'that fallback is an error rather than a default, because it is reached by omission —',
|
|
251
|
+
'a missing link or an unset HIVE_RELAY — and never by anybody deciding on it.',
|
|
252
|
+
'',
|
|
253
|
+
'Point HIVE_RELAY at your own relay, or unset HIVE_PRIVATE if you meant to use ours.',
|
|
254
|
+
].join('\n'),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Connecting. Say what that means, in one line, every time.
|
|
259
|
+
let warn = null
|
|
260
|
+
if (c.kind === 'public-default') {
|
|
261
|
+
warn = 'Your files are being sent to a relay run by the Hivecode author, not by you. ' +
|
|
262
|
+
'Anyone who learns this room id can read them. For private work run your own relay ' +
|
|
263
|
+
'(npx hivecode-relay) and set HIVE_RELAY.'
|
|
264
|
+
} else if (c.kind === 'tunnel') {
|
|
265
|
+
warn = `${c.host} is a public tunnel. The relay is yours, but the tunnel provider decrypts ` +
|
|
266
|
+
'traffic at its edge and can read it, and anyone with this URL can reach the relay. Fine for ' +
|
|
267
|
+
'a trial with --secret set; for private remote work use a private network (e.g. Tailscale) ' +
|
|
268
|
+
'or a server you control.'
|
|
269
|
+
} else if (c.kind === 'own' && !c.encrypted) {
|
|
270
|
+
warn = `ws:// to ${c.host} is NOT encrypted — your source travels the public internet in ` +
|
|
271
|
+
'plaintext. Put TLS in front of the relay and use wss://.'
|
|
272
|
+
} else if (c.kind === 'unknown') {
|
|
273
|
+
warn = `"${c.url}" does not parse as a relay URL — the client will almost certainly fail ` +
|
|
274
|
+
'to connect, and if it does connect you cannot tell where to.'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
ok: true, code: null, classification: c, pin, warn,
|
|
279
|
+
headline: `${HEADLINE[c.kind]}${c.encrypted ? '' : c.kind === 'loopback' ? '' : ' (UNENCRYPTED)'}`,
|
|
280
|
+
detail: warn || '',
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** One line for a join banner / status block: the URL plus what it means. */
|
|
285
|
+
export function privacyLine(relay, opts = {}) {
|
|
286
|
+
const v = checkRelay({ relay, ...opts })
|
|
287
|
+
return `relay: ${relay} [${v.headline}]`
|
|
288
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// WHY FILES ARE NOT SYNCING — asked of the relay, not guessed from the symptom.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS REPLACES.
|
|
4
|
+
// Two places told an operator why their files were not arriving: sync.js, when file docs went
|
|
5
|
+
// quiet, and hive-mcp.js, when hive_join failed to sync. Both carried the same three faults.
|
|
6
|
+
//
|
|
7
|
+
// 1. Both printed the literal string "<relay-http>/health" -- a placeholder, never
|
|
8
|
+
// substituted, while the relay URL sat in scope a few lines away.
|
|
9
|
+
// 2. Both named only Q430 and only limits.maxConnsTotal. limits.js:176 ALSO refuses with
|
|
10
|
+
// Q431 on a PER-ROOM cap, and the two need different fixes: HIVE_MAX_CONNS_TOTAL for the
|
|
11
|
+
// first, HIVE_SEATS for the second. An operator hitting Q431 and following that advice
|
|
12
|
+
// raises a limit that was never the constraint, sees no change, and is entitled to
|
|
13
|
+
// conclude the product is broken.
|
|
14
|
+
// 3. Both told a HUMAN to go read /health. The client can read it itself, in the same
|
|
15
|
+
// breath, and say which cause it actually is.
|
|
16
|
+
//
|
|
17
|
+
// MEASURED, 21 Sep 2026. A third agent joined room `night` and announced that 49 files could
|
|
18
|
+
// not connect, "the usual cause is the relay being full (Q430)". At that moment the relay
|
|
19
|
+
// reported 56 connections of 512. limits.js keys its per-room counter on the FILE-room, and
|
|
20
|
+
// each file held three connections against a cap of 64. Q430 needed 512 and had 56. Q431 needed
|
|
21
|
+
// 64 and had 3. NEITHER REFUSAL COULD HAVE FIRED.
|
|
22
|
+
//
|
|
23
|
+
// What had actually happened: /health went rooms 44 -> 0 -> 29 -> 31 -> 44 over a few minutes.
|
|
24
|
+
// The relay dropped its room state and every client began reconnecting, one socket per file.
|
|
25
|
+
//
|
|
26
|
+
// "The relay is full" and "the relay just dropped everyone and we are all reconnecting" produce
|
|
27
|
+
// an IDENTICAL local symptom -- N file docs with synced=false -- and need opposite responses: a
|
|
28
|
+
// config change, or thirty seconds of patience. The difference is a number only the relay has.
|
|
29
|
+
// So ask it.
|
|
30
|
+
//
|
|
31
|
+
// ONE PRODUCER, ON PURPOSE. learnings/repair-the-class-not-the-copy.md exists in this repo
|
|
32
|
+
// because a fix landed on one instance and the class survived into two more tickets. Two
|
|
33
|
+
// hand-written copies of a diagnosis is that state exactly: they had already drifted (the
|
|
34
|
+
// hive-mcp.js copy listed three candidate causes, the sync.js copy asserted one). This module is
|
|
35
|
+
// the only place the wording lives, and tests/relay-diagnosis-test.mjs COUNTS the producers so a
|
|
36
|
+
// third copy fails a test rather than shipping.
|
|
37
|
+
|
|
38
|
+
/** A free-tier per-room cap. At or below this, Q431 is the likely refusal and seats are the fix. */
|
|
39
|
+
export const FREE_TIER_ROOM_CAP = 8
|
|
40
|
+
|
|
41
|
+
/** Fraction of maxConnsTotal at which "the relay is effectively full" is the right reading. */
|
|
42
|
+
export const NEAR_CAPACITY = 0.9
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turn a websocket relay URL into its /health URL.
|
|
46
|
+
* Exported because a caller that cannot fetch still wants to NAME the URL correctly, which is
|
|
47
|
+
* the whole of defect (1) above.
|
|
48
|
+
*/
|
|
49
|
+
export function healthUrlFor(relay) {
|
|
50
|
+
const base = String(relay || '')
|
|
51
|
+
.replace(/^wss:/i, 'https:')
|
|
52
|
+
.replace(/^ws:/i, 'http:')
|
|
53
|
+
.replace(/\/+$/, '')
|
|
54
|
+
return /^https?:/i.test(base) ? `${base}/health` : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ask the relay for its own numbers.
|
|
59
|
+
*
|
|
60
|
+
* NEVER THROWS, AND NEVER BLOCKS FOR LONG. This runs inside a diagnostic, and a diagnostic that
|
|
61
|
+
* can hang is a diagnostic that withholds the warning it exists to deliver. The warning matters
|
|
62
|
+
* more than the detail: a failed probe still produces a message, with the failure named.
|
|
63
|
+
*
|
|
64
|
+
* `ok` means "the probe worked", NOT "the relay is healthy". Conflating those is how a monitor
|
|
65
|
+
* reports the absence of an answer as a clean bill of health.
|
|
66
|
+
*/
|
|
67
|
+
export async function probeRelayHealth(relay, { timeoutMs = 2500, fetchImpl } = {}) {
|
|
68
|
+
const url = healthUrlFor(relay)
|
|
69
|
+
if (!url) return { ok: false, url: null, error: 'no usable relay URL' }
|
|
70
|
+
const f = fetchImpl || (typeof fetch === 'function' ? fetch : null)
|
|
71
|
+
if (!f) return { ok: false, url, error: 'no fetch available in this runtime' }
|
|
72
|
+
try {
|
|
73
|
+
const ac = new AbortController()
|
|
74
|
+
const t = setTimeout(() => ac.abort(), timeoutMs)
|
|
75
|
+
if (t && t.unref) t.unref()
|
|
76
|
+
let res
|
|
77
|
+
try { res = await f(url, { signal: ac.signal }) } finally { clearTimeout(t) }
|
|
78
|
+
if (!res || !res.ok) return { ok: false, url, error: `HTTP ${res ? res.status : 'no response'}` }
|
|
79
|
+
const j = await res.json()
|
|
80
|
+
return { ok: true, url, health: j }
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const name = e && e.name
|
|
83
|
+
const why = name === 'AbortError' ? `no response in ${timeoutMs}ms` : ((e && e.message) || 'unreachable')
|
|
84
|
+
return { ok: false, url, error: why }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The four readings this can produce. Named so a test can assert on the verdict, not the prose. */
|
|
89
|
+
export const RELAY_FULL = 'RELAY_FULL'
|
|
90
|
+
export const ROOM_CAP = 'ROOM_CAP'
|
|
91
|
+
export const NOT_CAPACITY = 'NOT_CAPACITY'
|
|
92
|
+
export const UNREACHABLE = 'UNREACHABLE'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decide what the numbers mean, and say it in a way an operator can act on.
|
|
96
|
+
*
|
|
97
|
+
* Returns { verdict, text }. The verdict is for tests and for callers that want to branch; the
|
|
98
|
+
* text is what a human reads. Keeping them separate is what lets the test pin the DECISION
|
|
99
|
+
* rather than the wording, so rephrasing the advice does not break the suite and changing the
|
|
100
|
+
* advice does.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} probe the result of probeRelayHealth()
|
|
103
|
+
* @param {object} opts
|
|
104
|
+
* @param {number} opts.stuck how many files are not synced
|
|
105
|
+
* @param {string} opts.what 'files' (sync.js) or 'join' (hive-mcp.js) -- changes the lead only
|
|
106
|
+
*/
|
|
107
|
+
export function diagnose(probe, { stuck = 0, what = 'files' } = {}) {
|
|
108
|
+
const h = (probe && probe.ok && probe.health) || null
|
|
109
|
+
const url = (probe && probe.url) || 'the relay'
|
|
110
|
+
|
|
111
|
+
const lead = what === 'join'
|
|
112
|
+
? 'The relay did not sync this folder.'
|
|
113
|
+
: `${stuck} file(s) are not synced and will READ AS EMPTY until this clears.`
|
|
114
|
+
|
|
115
|
+
if (!h) {
|
|
116
|
+
// A probe that failed is MORE specific than any guess, not less: the relay is unreachable
|
|
117
|
+
// over HTTP from this machine, so capacity is not the question and no limit needs changing.
|
|
118
|
+
return {
|
|
119
|
+
verdict: UNREACHABLE,
|
|
120
|
+
text: [
|
|
121
|
+
lead,
|
|
122
|
+
`I could not reach ${url}${probe && probe.error ? ` (${probe.error})` : ''}, so the relay may be down,`,
|
|
123
|
+
`restarting, or blocked from this machine. THAT IS NOT A CAPACITY PROBLEM and no limit needs raising.`,
|
|
124
|
+
`Check that the relay is running and that this host can reach it.`,
|
|
125
|
+
].join(' '),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const total = Number(h.limits && h.limits.maxConnsTotal) || 0
|
|
130
|
+
const conns = Number(h.connections) || 0
|
|
131
|
+
const perRoom = Number(h.connsPerRoom) || Number(h.limits && h.limits.maxConnsPerRoom) || 0
|
|
132
|
+
const seats = h.seats ? ` (${h.seats} seats)` : ''
|
|
133
|
+
const facts = `I asked ${url}: ${conns} of ${total} connections in use, ${h.rooms || 0} rooms, per-room cap ${perRoom}${seats}.`
|
|
134
|
+
|
|
135
|
+
// ONE SOCKET PER PARTICIPANT has its own ceiling: the files open across every participant's
|
|
136
|
+
// shared socket (Q438). Checked first, because at that ceiling the socket count looks healthy.
|
|
137
|
+
const chanCap = Number(h.limits && h.limits.maxChannelsTotal) || 0
|
|
138
|
+
const chans = Number(h.channels) || 0
|
|
139
|
+
if (Number(h.mux) >= 1 && chanCap && chans >= chanCap * NEAR_CAPACITY) {
|
|
140
|
+
return {
|
|
141
|
+
verdict: RELAY_FULL,
|
|
142
|
+
text: [lead, `I asked ${url}: ${chans} of ${chanCap} open files in use across all participants.`,
|
|
143
|
+
`THE RELAY IS AT ITS OPEN-FILE CAPACITY — this is Q438. Raise HIVE_MAX_CHANNELS_TOTAL on the relay,`,
|
|
144
|
+
`or narrow what syncs.`].join(' '),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (total && conns >= total * NEAR_CAPACITY) {
|
|
149
|
+
// Who spends the sockets depends on the transport. A relay offering one-socket mode charges
|
|
150
|
+
// those clients 2 each; the older transport, still spoken by the VS Code extension, the browser
|
|
151
|
+
// panel and HIVE_MUX=0, charges one PER FILE -- and that is what exhausts a relay.
|
|
152
|
+
const who = Number(h.mux) >= 1
|
|
153
|
+
? `This relay carries each participant's files on one socket (2 sockets a participant), but clients`
|
|
154
|
+
+ ` on the older transport -- the VS Code extension, the browser panel, HIVE_MUX=0 -- still open one`
|
|
155
|
+
+ ` socket PER FILE, and one large folder on those can exhaust it alone.`
|
|
156
|
+
: `Hivecode opens one socket PER FILE, so a single large folder can exhaust it alone.`
|
|
157
|
+
return {
|
|
158
|
+
verdict: RELAY_FULL,
|
|
159
|
+
text: [lead, facts,
|
|
160
|
+
`THE RELAY IS AT OR NEAR CAPACITY — this is Q430.`, who,
|
|
161
|
+
`Raise HIVE_MAX_CONNS_TOTAL on the relay, or narrow what syncs.`].join(' '),
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (perRoom && perRoom <= FREE_TIER_ROOM_CAP) {
|
|
166
|
+
// The case the old message could not express at all, and the one where its advice was
|
|
167
|
+
// actively harmful: total headroom is fine, so raising the total changes nothing.
|
|
168
|
+
return {
|
|
169
|
+
verdict: ROOM_CAP,
|
|
170
|
+
text: [lead, facts,
|
|
171
|
+
`The relay has total headroom, but its PER-ROOM cap is ${perRoom} and it refuses past that with Q431.`,
|
|
172
|
+
`That is a free-tier default. Set HIVE_SEATS on the relay — raising HIVE_MAX_CONNS_TOTAL will NOT help,`,
|
|
173
|
+
`because the total is not the constraint.`].join(' '),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
verdict: NOT_CAPACITY,
|
|
179
|
+
text: [lead, facts,
|
|
180
|
+
`CAPACITY IS NOT THE CAUSE: ${Math.max(0, total - conns)} of ${total} connections are free and the per-room`,
|
|
181
|
+
`cap is ${perRoom}. These sockets are most likely still reconnecting — the relay drops room state on`,
|
|
182
|
+
`restart and every file reconnects separately, which looks exactly like this for a minute or two.`,
|
|
183
|
+
`Wait and re-check before changing any limit. If it persists, the room may be secured and this client`,
|
|
184
|
+
`may carry no token, or the relay may be failing its licence check closed.`].join(' '),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The whole diagnosis in one call, for the two callers that want it.
|
|
190
|
+
* `trailer` is appended verbatim -- sync.js needs "do not trust this folder's contents", a
|
|
191
|
+
* failed join needs its own instructions, and neither belongs in the capacity reasoning.
|
|
192
|
+
*/
|
|
193
|
+
export async function explainNotSyncing(relay, { stuck = 0, what = 'files', trailer = '', timeoutMs = 2500, fetchImpl } = {}) {
|
|
194
|
+
const probe = await probeRelayHealth(relay, { timeoutMs, fetchImpl })
|
|
195
|
+
const d = diagnose(probe, { stuck, what })
|
|
196
|
+
return { ...d, probe, text: trailer ? `${d.text} ${trailer}` : d.text }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Extract HTTP status code from a WebSocket or upgrade error, if present.
|
|
201
|
+
*/
|
|
202
|
+
export function statusFromError(e) {
|
|
203
|
+
if (!e) return null
|
|
204
|
+
if (typeof e === 'number') return e
|
|
205
|
+
if (e.status && typeof e.status === 'number') return e.status
|
|
206
|
+
if (e.code && typeof e.code === 'number') return e.code
|
|
207
|
+
if (e.statusCode && typeof e.statusCode === 'number') return e.statusCode
|
|
208
|
+
const m = /Unexpected server response:\s*(\d{3})/.exec((e.message || e.reason || String(e)))
|
|
209
|
+
return m ? Number(m[1]) : null
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Authoritative explanations for HTTP status codes returned by the relay on connection upgrade.
|
|
214
|
+
*/
|
|
215
|
+
export function explainStatus(code, relay = '') {
|
|
216
|
+
const c = Number(code)
|
|
217
|
+
if (c === 401) return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
218
|
+
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
219
|
+
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
220
|
+
if (c === 429) return 'this room is FULL, so you were not let in (Q431). A relay admits a limited number of connections per room -- 8 on the free tier -- and this room is at that ceiling. Note it counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, raise it with HIVE_SEATS; otherwise ask whoever does, or contact support@getgrm.tech.'
|
|
221
|
+
if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430 sockets, Q438 open files, or Q439 files held by one participant) or it could not check your licence and is failing closed rather than guessing. It may also be restarting -- if this lasts, contact support@getgrm.tech.'
|
|
222
|
+
if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
|
|
223
|
+
return `the relay at ${relay || 'the relay'} cannot be reached. It may be down, asleep, or blocked by a network. Contact support@getgrm.tech if this continues.`
|
|
224
|
+
}
|
|
225
|
+
|