hivecode-mcp 0.12.4 → 0.13.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 +11 -40
- package/README.md +421 -346
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +394 -0
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +2 -2
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +105 -0
- package/hive-authors.js +44 -0
- package/hive-await.js +35 -10
- package/hive-branch.js +101 -150
- package/hive-github.js +1944 -0
- package/hive-land.js +691 -0
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +5 -2
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +173 -29
- package/hive-notify.js +233 -0
- package/hive-pr.js +205 -29
- package/hive-relay-health.js +230 -230
- package/hive-repos.js +430 -0
- package/hive-room-key.js +209 -0
- package/hive-room-mode.js +1668 -0
- package/hive-room-watcher.js +561 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +14 -4
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stop-hook.js +8 -5
- package/hive-time.js +71 -0
- package/hive-webhook.js +54 -0
- package/icr.js +851 -851
- package/limits.js +3 -3
- package/package.json +190 -172
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/sync.js +4992 -4691
package/hive-relay-health.js
CHANGED
|
@@ -1,230 +1,230 @@
|
|
|
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 = '', opts = {}) {
|
|
216
|
-
const c = Number(code)
|
|
217
|
-
if (c === 401) {
|
|
218
|
-
if (opts && opts.hasToken === false) {
|
|
219
|
-
return 'this room needs a token of your own. Ask the room owner to mint you one.'
|
|
220
|
-
}
|
|
221
|
-
return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
222
|
-
}
|
|
223
|
-
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
224
|
-
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
225
|
-
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.'
|
|
226
|
-
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.'
|
|
227
|
-
if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
|
|
228
|
-
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.`
|
|
229
|
-
}
|
|
230
|
-
|
|
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 = '', opts = {}) {
|
|
216
|
+
const c = Number(code)
|
|
217
|
+
if (c === 401) {
|
|
218
|
+
if (opts && opts.hasToken === false) {
|
|
219
|
+
return 'this room needs a token of your own. Ask the room owner to mint you one.'
|
|
220
|
+
}
|
|
221
|
+
return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
222
|
+
}
|
|
223
|
+
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
224
|
+
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
225
|
+
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.'
|
|
226
|
+
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.'
|
|
227
|
+
if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
|
|
228
|
+
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.`
|
|
229
|
+
}
|
|
230
|
+
|