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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// hive-mux-proto.js -- the wire format for ONE socket per participant, shared by relay and client.
|
|
2
|
+
//
|
|
3
|
+
// WHY: Hivecode opened one WebSocket per FILE. A participant held 1 + min(files, 128) sockets,
|
|
4
|
+
// so an agent in a 1000-file repository saw only 128 files on disk, and a relay's connection cap
|
|
5
|
+
// divided by 129 per person. The mux carries every file doc of one room over one socket.
|
|
6
|
+
//
|
|
7
|
+
// The design rule that makes it safe: the relay does not grow a second enforcement path. Each
|
|
8
|
+
// OPEN runs the SAME per-doc admission a per-file upgrade runs (token scope for that path, the
|
|
9
|
+
// per-room ceiling, the committer limit), and the accepted channel is wrapped in a VirtualConn
|
|
10
|
+
// that looks exactly like a ws connection to installUsageLimits, makeReadOnly, installWriteGate
|
|
11
|
+
// and setupWSConnection. The bytes inside a MSG frame are the same y-protocols message a
|
|
12
|
+
// per-file socket would carry.
|
|
13
|
+
//
|
|
14
|
+
// Frames are binary, lib0-encoded:
|
|
15
|
+
// client -> relay OPEN [0, id, docName] open this file doc as channel `id`
|
|
16
|
+
// CLOSE [1, id] the client is done with it
|
|
17
|
+
// MSG [2, id, ...payload] a y-protocols message for that doc
|
|
18
|
+
// relay -> client MSG [2, id, ...payload]
|
|
19
|
+
// OPENED [3, id] admitted; the relay's sync step 1 follows
|
|
20
|
+
// REFUSED [4, id, code, reason] not admitted -- the HTTP status a per-file
|
|
21
|
+
// upgrade would have answered, and why
|
|
22
|
+
// CLOSED [5, id, reason] the relay closed that channel
|
|
23
|
+
// BEAT [6, 0] liveness, so a dead socket is noticed
|
|
24
|
+
// Channel ids are chosen by the client and are only meaningful on the socket that opened them.
|
|
25
|
+
import * as encoding from 'lib0/encoding'
|
|
26
|
+
import * as decoding from 'lib0/decoding'
|
|
27
|
+
|
|
28
|
+
export const MUX_PREFIX = '_hive_mux/'
|
|
29
|
+
export const MUX_VERSION = 1
|
|
30
|
+
export const F = Object.freeze({ OPEN: 0, CLOSE: 1, MSG: 2, OPENED: 3, REFUSED: 4, CLOSED: 5, BEAT: 6 })
|
|
31
|
+
|
|
32
|
+
export function encodeFrame(kind, id, a, b) {
|
|
33
|
+
const e = encoding.createEncoder()
|
|
34
|
+
encoding.writeVarUint(e, kind)
|
|
35
|
+
encoding.writeVarUint(e, id >>> 0)
|
|
36
|
+
if (kind === F.OPEN) encoding.writeVarString(e, String(a))
|
|
37
|
+
else if (kind === F.MSG) encoding.writeUint8Array(e, a instanceof Uint8Array ? a : new Uint8Array(a))
|
|
38
|
+
else if (kind === F.REFUSED) { encoding.writeVarUint(e, a >>> 0); encoding.writeVarString(e, String(b || '')) }
|
|
39
|
+
else if (kind === F.CLOSED) encoding.writeVarString(e, String(a || ''))
|
|
40
|
+
return encoding.toUint8Array(e)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Decode one frame. Throws on garbage, which the caller treats as a protocol violation. */
|
|
44
|
+
export function decodeFrame(buf) {
|
|
45
|
+
const u8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
|
46
|
+
const d = decoding.createDecoder(u8)
|
|
47
|
+
const kind = decoding.readVarUint(d)
|
|
48
|
+
const id = decoding.readVarUint(d)
|
|
49
|
+
if (kind === F.OPEN) return { kind, id, docName: decoding.readVarString(d) }
|
|
50
|
+
if (kind === F.MSG) return { kind, id, payload: u8.subarray(d.pos) }
|
|
51
|
+
if (kind === F.REFUSED) return { kind, id, code: decoding.readVarUint(d), reason: decoding.readVarString(d) }
|
|
52
|
+
if (kind === F.CLOSED) return { kind, id, reason: decoding.readVarString(d) }
|
|
53
|
+
if (kind === F.CLOSE || kind === F.OPENED || kind === F.BEAT) return { kind, id }
|
|
54
|
+
throw new Error(`unknown mux frame kind ${kind}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The mux upgrade path for a base room, as the relay parses it (pathname minus the slash). */
|
|
58
|
+
export function muxPath(room) { return MUX_PREFIX + encodeURIComponent(room) }
|
package/hive-mux.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// hive-mux.js -- the client half of ONE socket per participant (wire format: hive-mux-proto.js).
|
|
2
|
+
//
|
|
3
|
+
// sync.js asks this module for a provider per file doc and gets back an object with the surface
|
|
4
|
+
// it already used from y-websocket's WebsocketProvider: `synced`, the 'sync' / 'status' /
|
|
5
|
+
// 'connection-error' / 'connection-close' events, and destroy(). Behind it is EITHER a channel
|
|
6
|
+
// on the participant's one mux socket, or -- when the relay does not advertise mux in /health,
|
|
7
|
+
// or HIVE_MUX=0 -- a plain per-file WebsocketProvider exactly as before. An old relay therefore
|
|
8
|
+
// keeps working with a new client, and the choice is made once per session, before any file
|
|
9
|
+
// socket exists, so a room never ends up half on one transport by accident.
|
|
10
|
+
//
|
|
11
|
+
// Inside a channel the y-protocols exchange is the one y-websocket performs: on admission send
|
|
12
|
+
// sync step 1, answer the relay's step 1, count the session synced when step 2 arrives, forward
|
|
13
|
+
// local updates, never echo an update this provider applied. A refused OPEN is reported as a
|
|
14
|
+
// 'connection-error' carrying the same HTTP status a refused per-file upgrade carried, so every
|
|
15
|
+
// piece of refusal reporting in sync.js (Q430/Q431 counts, notSyncing, explain()) works unchanged.
|
|
16
|
+
import * as syncProtocol from 'y-protocols/sync'
|
|
17
|
+
import * as encoding from 'lib0/encoding'
|
|
18
|
+
import * as decoding from 'lib0/decoding'
|
|
19
|
+
import { ObservableV2 } from 'lib0/observable'
|
|
20
|
+
import { WebsocketProvider } from 'y-websocket'
|
|
21
|
+
import { F, encodeFrame, decodeFrame, muxPath, MUX_VERSION } from './hive-mux-proto.js'
|
|
22
|
+
|
|
23
|
+
const messageSync = 0
|
|
24
|
+
|
|
25
|
+
export const httpBase = (relay) => String(relay).replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '')
|
|
26
|
+
|
|
27
|
+
/** The provider sync.js holds per file. Delegates to a mux channel or a legacy provider. */
|
|
28
|
+
class FileProvider extends ObservableV2 {
|
|
29
|
+
constructor(docName, doc) {
|
|
30
|
+
super()
|
|
31
|
+
this.docName = docName
|
|
32
|
+
this.doc = doc
|
|
33
|
+
this.destroyed = false
|
|
34
|
+
this._impl = null
|
|
35
|
+
}
|
|
36
|
+
get synced() { return !!(this._impl && this._impl.synced) }
|
|
37
|
+
get wsconnected() { return !!(this._impl && this._impl.wsconnected) }
|
|
38
|
+
get transport() { return this._impl ? this._impl.transport : 'pending' }
|
|
39
|
+
_bindLegacy(relay, wsOpts) {
|
|
40
|
+
const p = new WebsocketProvider(relay, this.docName, this.doc, wsOpts)
|
|
41
|
+
for (const ev of ['sync', 'status', 'connection-error', 'connection-close']) p.on(ev, (...args) => this.emit(ev, args))
|
|
42
|
+
this._impl = { transport: 'socket', get synced() { return p.synced }, get wsconnected() { return p.wsconnected }, destroy: () => p.destroy() }
|
|
43
|
+
}
|
|
44
|
+
destroy() {
|
|
45
|
+
if (this.destroyed) return
|
|
46
|
+
this.destroyed = true
|
|
47
|
+
try { if (this._impl) this._impl.destroy() } catch { }
|
|
48
|
+
super.destroy()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class Channel {
|
|
53
|
+
constructor(mux, id, docName, doc, facade) {
|
|
54
|
+
this.mux = mux
|
|
55
|
+
this.id = id
|
|
56
|
+
this.docName = docName
|
|
57
|
+
this.doc = doc
|
|
58
|
+
this.facade = facade
|
|
59
|
+
this.transport = 'mux'
|
|
60
|
+
this.opened = false
|
|
61
|
+
this.synced = false
|
|
62
|
+
this.retries = 0
|
|
63
|
+
this.retryTimer = null
|
|
64
|
+
this.destroyed = false
|
|
65
|
+
this._onUpdate = (update, origin) => {
|
|
66
|
+
if (origin === this.facade || !this.opened) return // gaps are closed by the next step1/step2
|
|
67
|
+
const e = encoding.createEncoder()
|
|
68
|
+
encoding.writeVarUint(e, messageSync)
|
|
69
|
+
syncProtocol.writeUpdate(e, update)
|
|
70
|
+
this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
|
|
71
|
+
}
|
|
72
|
+
doc.on('update', this._onUpdate)
|
|
73
|
+
}
|
|
74
|
+
get wsconnected() { return this.opened && this.mux.connected }
|
|
75
|
+
sendOpen() {
|
|
76
|
+
if (this.destroyed || !this.mux.connected) return
|
|
77
|
+
clearTimeout(this.retryTimer); this.retryTimer = null
|
|
78
|
+
this.mux.send(encodeFrame(F.OPEN, this.id, this.docName))
|
|
79
|
+
}
|
|
80
|
+
retryLater() {
|
|
81
|
+
if (this.destroyed || this.retryTimer) return
|
|
82
|
+
// Refused or closed by the relay: back off, like the per-file provider did, but further --
|
|
83
|
+
// an OPEN is cheap to send and a relay at capacity should not be asked 40 times a second.
|
|
84
|
+
const ms = Math.min(15000, 500 * 2 ** Math.min(this.retries++, 5))
|
|
85
|
+
this.retryTimer = setTimeout(() => { this.retryTimer = null; this.sendOpen() }, ms)
|
|
86
|
+
if (this.retryTimer.unref) this.retryTimer.unref()
|
|
87
|
+
}
|
|
88
|
+
onFrame(f) {
|
|
89
|
+
if (this.destroyed) return
|
|
90
|
+
if (f.kind === F.OPENED) {
|
|
91
|
+
this.opened = true
|
|
92
|
+
this.retries = 0
|
|
93
|
+
const e = encoding.createEncoder()
|
|
94
|
+
encoding.writeVarUint(e, messageSync)
|
|
95
|
+
syncProtocol.writeSyncStep1(e, this.doc)
|
|
96
|
+
this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
|
|
97
|
+
this.facade.emit('status', [{ status: 'connected' }])
|
|
98
|
+
} else if (f.kind === F.MSG) {
|
|
99
|
+
try { this.onMessage(f.payload) } catch (e) {
|
|
100
|
+
// A message this doc could not apply. Skipping it would leave a gap that every later
|
|
101
|
+
// update builds on, silently; reopening makes the relay run the sync exchange again,
|
|
102
|
+
// and that carries whatever was missed.
|
|
103
|
+
this.mux.log(`[hive] could not apply a relay message for ${JSON.stringify(this.docName)} (${e && e.message}); resyncing it`)
|
|
104
|
+
this.reopen()
|
|
105
|
+
}
|
|
106
|
+
} else if (f.kind === F.REFUSED) {
|
|
107
|
+
this.opened = false
|
|
108
|
+
this.facade.emit('connection-error', [{ code: f.code, status: f.code, reason: f.reason, message: `Unexpected server response: ${f.code} ${f.reason}` }])
|
|
109
|
+
this.retryLater()
|
|
110
|
+
} else if (f.kind === F.CLOSED) {
|
|
111
|
+
this.lost({ code: 4000, reason: f.reason })
|
|
112
|
+
this.retryLater()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
onMessage(payload) {
|
|
116
|
+
let d
|
|
117
|
+
try { d = decoding.createDecoder(payload) } catch { return }
|
|
118
|
+
const type = decoding.readVarUint(d)
|
|
119
|
+
if (type !== messageSync) return // awareness/auth/query: file docs carry no presence
|
|
120
|
+
const e = encoding.createEncoder()
|
|
121
|
+
encoding.writeVarUint(e, messageSync)
|
|
122
|
+
const t = syncProtocol.readSyncMessage(d, e, this.doc, this.facade)
|
|
123
|
+
if (t === syncProtocol.messageYjsSyncStep2 && !this.synced) {
|
|
124
|
+
this.synced = true
|
|
125
|
+
this.facade.emit('sync', [true])
|
|
126
|
+
}
|
|
127
|
+
if (encoding.length(e) > 1) this.mux.send(encodeFrame(F.MSG, this.id, encoding.toUint8Array(e)))
|
|
128
|
+
}
|
|
129
|
+
reopen() {
|
|
130
|
+
if (this.destroyed) return
|
|
131
|
+
if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
|
|
132
|
+
this.lost({ code: 4001, reason: 'resync' })
|
|
133
|
+
this.retryLater()
|
|
134
|
+
}
|
|
135
|
+
/** The channel is gone (socket dropped, or the relay closed it); a reconnect will reopen it. */
|
|
136
|
+
lost(ev = null) {
|
|
137
|
+
const wasOpen = this.opened
|
|
138
|
+
this.opened = false
|
|
139
|
+
if (this.synced) { this.synced = false; this.facade.emit('sync', [false]) }
|
|
140
|
+
if (wasOpen) {
|
|
141
|
+
this.facade.emit('connection-close', [ev])
|
|
142
|
+
this.facade.emit('status', [{ status: 'disconnected' }])
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
destroy() {
|
|
146
|
+
if (this.destroyed) return
|
|
147
|
+
this.destroyed = true
|
|
148
|
+
clearTimeout(this.retryTimer)
|
|
149
|
+
try { this.doc.off('update', this._onUpdate) } catch { }
|
|
150
|
+
if (this.mux.connected) this.mux.send(encodeFrame(F.CLOSE, this.id))
|
|
151
|
+
this.mux.channels.delete(this.id)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The participant's one physical socket, carrying a channel per open file doc. */
|
|
156
|
+
class MuxConnection {
|
|
157
|
+
constructor({ relay, room, params, WebSocketPolyfill, log, onRefused }) {
|
|
158
|
+
const q = new URLSearchParams({ ...(params || {}), mux: String(MUX_VERSION) })
|
|
159
|
+
this.url = `${String(relay).replace(/\/+$/, '')}/${muxPath(room)}?${q}`
|
|
160
|
+
this.WS = WebSocketPolyfill
|
|
161
|
+
this.log = log || (() => { })
|
|
162
|
+
this.onRefused = onRefused || (() => { })
|
|
163
|
+
this.channels = new Map()
|
|
164
|
+
this.nextId = 1
|
|
165
|
+
this.ws = null
|
|
166
|
+
this.connected = false
|
|
167
|
+
this.connecting = false
|
|
168
|
+
this.destroyed = false
|
|
169
|
+
this.failures = 0
|
|
170
|
+
this.lastHeard = 0
|
|
171
|
+
this.reconnectTimer = null
|
|
172
|
+
// The relay sends a BEAT every 20s; 45s of silence means the socket is dead even if the OS
|
|
173
|
+
// has not said so, which is exactly the half-open state a laptop lid produces.
|
|
174
|
+
this.liveness = setInterval(() => {
|
|
175
|
+
// terminate, not close: a close handshake cannot complete on a dead link, and ws waits 30s more for it
|
|
176
|
+
if (this.connected && Date.now() - this.lastHeard > 45000) { try { this.ws.terminate ? this.ws.terminate() : this.ws.close() } catch { } }
|
|
177
|
+
}, 5000)
|
|
178
|
+
if (this.liveness.unref) this.liveness.unref()
|
|
179
|
+
}
|
|
180
|
+
channel(docName, doc, facade) {
|
|
181
|
+
const ch = new Channel(this, this.nextId++, docName, doc, facade)
|
|
182
|
+
this.channels.set(ch.id, ch)
|
|
183
|
+
if (this.connected) ch.sendOpen()
|
|
184
|
+
else this.connect()
|
|
185
|
+
return ch
|
|
186
|
+
}
|
|
187
|
+
send(bytes) {
|
|
188
|
+
if (!this.connected || !this.ws) return
|
|
189
|
+
try { this.ws.send(bytes) } catch { /* the close handler takes it from here */ }
|
|
190
|
+
}
|
|
191
|
+
connect() {
|
|
192
|
+
if (this.destroyed || this.connected || this.connecting) return
|
|
193
|
+
this.connecting = true
|
|
194
|
+
let ws
|
|
195
|
+
try { ws = new this.WS(this.url) } catch (e) { this.connecting = false; this.scheduleReconnect(); return }
|
|
196
|
+
ws.binaryType = 'arraybuffer'
|
|
197
|
+
this.ws = ws
|
|
198
|
+
ws.onopen = () => {
|
|
199
|
+
if (this.ws !== ws) return
|
|
200
|
+
this.connecting = false
|
|
201
|
+
this.connected = true
|
|
202
|
+
this.failures = 0
|
|
203
|
+
this.lastHeard = Date.now()
|
|
204
|
+
for (const ch of this.channels.values()) ch.sendOpen()
|
|
205
|
+
}
|
|
206
|
+
ws.onmessage = (ev) => {
|
|
207
|
+
if (this.ws !== ws) return
|
|
208
|
+
this.lastHeard = Date.now()
|
|
209
|
+
let f
|
|
210
|
+
try { f = decodeFrame(new Uint8Array(ev.data)) } catch { return }
|
|
211
|
+
if (f.kind === F.BEAT) return
|
|
212
|
+
const ch = this.channels.get(f.id)
|
|
213
|
+
if (ch) ch.onFrame(f)
|
|
214
|
+
}
|
|
215
|
+
ws.onerror = (ev) => {
|
|
216
|
+
if (this.ws !== ws) return
|
|
217
|
+
// A refused UPGRADE of the mux socket itself (401/402/403/429/503) refuses every file on it.
|
|
218
|
+
// Hand each unsynced channel the same event a refused per-file socket produced.
|
|
219
|
+
const m = /Unexpected server response:\s*(\d{3})/.exec((ev && (ev.message || (ev.error && ev.error.message))) || '')
|
|
220
|
+
const code = m ? Number(m[1]) : null
|
|
221
|
+
for (const ch of this.channels.values()) {
|
|
222
|
+
if (!ch.synced) ch.facade.emit('connection-error', [{ code, status: code, message: (ev && ev.message) || 'mux socket error' }])
|
|
223
|
+
}
|
|
224
|
+
if (code) this.onRefused(code)
|
|
225
|
+
}
|
|
226
|
+
ws.onclose = (ev) => {
|
|
227
|
+
if (this.ws !== ws) return
|
|
228
|
+
this.ws = null
|
|
229
|
+
this.connected = false
|
|
230
|
+
this.connecting = false
|
|
231
|
+
for (const ch of this.channels.values()) ch.lost(ev)
|
|
232
|
+
this.scheduleReconnect()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
scheduleReconnect() {
|
|
236
|
+
if (this.destroyed || this.reconnectTimer || !this.channels.size) return
|
|
237
|
+
const ms = Math.min(5000, 100 * 2 ** Math.min(this.failures++, 6))
|
|
238
|
+
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect() }, ms)
|
|
239
|
+
if (this.reconnectTimer.unref) this.reconnectTimer.unref()
|
|
240
|
+
}
|
|
241
|
+
destroy() {
|
|
242
|
+
this.destroyed = true
|
|
243
|
+
clearInterval(this.liveness)
|
|
244
|
+
clearTimeout(this.reconnectTimer)
|
|
245
|
+
const ws = this.ws
|
|
246
|
+
this.ws = null
|
|
247
|
+
// Disconnected FIRST: closing the socket ends every channel on the relay at once, so no
|
|
248
|
+
// channel sends a CLOSE frame of its own down a socket that is about to go.
|
|
249
|
+
this.connected = false
|
|
250
|
+
for (const ch of [...this.channels.values()]) { try { ch.destroy() } catch { } }
|
|
251
|
+
try { if (ws) ws.close() } catch { }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* One per startSync. provide(docName, doc) returns the provider for a file doc; the transport is
|
|
257
|
+
* decided once -- HIVE_MUX=1 forces mux, HIVE_MUX=0 forces a socket per file, anything else asks
|
|
258
|
+
* the relay's /health -- and file providers created before the answer are bound when it comes.
|
|
259
|
+
*/
|
|
260
|
+
export function createFileTransport({ relay, room, wsOpts = {}, log = () => { }, env = process.env, fetchImpl = globalThis.fetch, onFallback = () => { }, retryMs = 1000, detectForMs = 90000 }) {
|
|
261
|
+
const forced = env.HIVE_MUX === '0' || env.HIVE_MUX === '1'
|
|
262
|
+
let mode = env.HIVE_MUX === '0' ? 'socket' : env.HIVE_MUX === '1' ? 'mux' : null
|
|
263
|
+
let mux = null
|
|
264
|
+
let stopped = false
|
|
265
|
+
const pending = new Set()
|
|
266
|
+
const health = () => Promise.resolve()
|
|
267
|
+
.then(() => fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) }))
|
|
268
|
+
.then((r) => (r && r.ok ? r.json() : null))
|
|
269
|
+
const offersMux = (j) => !!j && Number(j.mux) >= MUX_VERSION
|
|
270
|
+
|
|
271
|
+
// THE KILL SWITCH HAS TO REACH CLIENTS THAT ARE ALREADY CONNECTED. A relay that stops serving
|
|
272
|
+
// mux -- an operator setting HIVE_RELAY_MUX=off, or a rollback to a build that predates it --
|
|
273
|
+
// refuses the mux socket from then on, and without this every file would stay refused until
|
|
274
|
+
// the agent restarted. A 404 says so outright. Any other refusal is checked against /health, at
|
|
275
|
+
// most once a minute, because a relay that predates mux reads the mux path as a room name and
|
|
276
|
+
// refuses the token -- a 401 that looks exactly like a real one.
|
|
277
|
+
let lastCheck = 0
|
|
278
|
+
const onRefused = (code) => {
|
|
279
|
+
if (forced || stopped || mode !== 'mux') return
|
|
280
|
+
if (code === 404) return fallBack('the relay no longer serves one-socket mode (404)')
|
|
281
|
+
const now = Date.now()
|
|
282
|
+
if (now - lastCheck < 60000) return
|
|
283
|
+
lastCheck = now
|
|
284
|
+
health().then((j) => { if (j && !offersMux(j)) fallBack('the relay stopped offering one-socket mode') }, () => { })
|
|
285
|
+
}
|
|
286
|
+
const fallBack = (why) => {
|
|
287
|
+
if (stopped || mode !== 'mux') return
|
|
288
|
+
mode = 'socket'
|
|
289
|
+
log(`[hive] ${why}: falling back to a socket per file`)
|
|
290
|
+
// sync.js lets go of every file beyond the per-file bound FIRST. Without that, a client holding
|
|
291
|
+
// 900 files on one socket would open 900 sockets at once -- the storm the bound exists to stop.
|
|
292
|
+
try { onFallback() } catch { }
|
|
293
|
+
const moving = mux ? [...mux.channels.values()].map((ch) => ch.facade) : []
|
|
294
|
+
try { if (mux) mux.destroy() } catch { }
|
|
295
|
+
mux = null
|
|
296
|
+
for (const fp of moving) { if (!fp.destroyed) fp._bindLegacy(relay, wsOpts) }
|
|
297
|
+
}
|
|
298
|
+
const bind = (fp) => {
|
|
299
|
+
if (fp.destroyed) return
|
|
300
|
+
if (mode === 'mux') {
|
|
301
|
+
if (!mux) mux = new MuxConnection({ relay, room, params: wsOpts.params, WebSocketPolyfill: wsOpts.WebSocketPolyfill, log, onRefused })
|
|
302
|
+
fp._impl = mux.channel(fp.docName, fp.doc, fp)
|
|
303
|
+
} else {
|
|
304
|
+
fp._bindLegacy(relay, wsOpts)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
let resolveReady
|
|
308
|
+
const ready = new Promise((r) => { resolveReady = r })
|
|
309
|
+
const decide = (m) => {
|
|
310
|
+
if (!mode) mode = m
|
|
311
|
+
for (const fp of pending) bind(fp)
|
|
312
|
+
pending.clear()
|
|
313
|
+
resolveReady(mode)
|
|
314
|
+
}
|
|
315
|
+
// ONLY AN ANSWER DECIDES. A free Render instance takes 30-60s to wake and says nothing at all
|
|
316
|
+
// meanwhile; reading that silence as "no mux" pinned the whole session to a socket per file.
|
|
317
|
+
// So silence (a timeout, a refused connection, a 5xx from a proxy fronting a booting service) is
|
|
318
|
+
// asked again for up to ~90s. Any real answer decides at once -- including an old relay's
|
|
319
|
+
// plain-text /health, which is a relay that has never heard of mux. Out of time: a socket per
|
|
320
|
+
// file, the transport every relay has always spoken.
|
|
321
|
+
const detect = async () => {
|
|
322
|
+
const deadline = Date.now() + detectForMs
|
|
323
|
+
for (let wait = retryMs; ; wait = Math.min(wait * 2, 15000)) {
|
|
324
|
+
try {
|
|
325
|
+
const r = await fetchImpl(`${httpBase(relay)}/health`, { signal: AbortSignal.timeout(20000) })
|
|
326
|
+
if (r && r.status < 500) {
|
|
327
|
+
let j = null
|
|
328
|
+
try { j = JSON.parse(await r.text()) } catch { /* not JSON: an old relay, or not a relay */ }
|
|
329
|
+
return r.ok && offersMux(j) ? 'mux' : 'socket'
|
|
330
|
+
}
|
|
331
|
+
} catch { /* no answer yet */ }
|
|
332
|
+
if (stopped || Date.now() + wait > deadline) return 'socket'
|
|
333
|
+
await new Promise((res) => { const t = setTimeout(res, wait); if (t.unref) t.unref() })
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (mode) resolveReady(mode)
|
|
337
|
+
else detect().then(decide, () => decide('socket'))
|
|
338
|
+
return {
|
|
339
|
+
ready,
|
|
340
|
+
get mode() { return mode || 'detecting' },
|
|
341
|
+
provide(docName, doc) {
|
|
342
|
+
const fp = new FileProvider(docName, doc)
|
|
343
|
+
if (mode) bind(fp)
|
|
344
|
+
else pending.add(fp)
|
|
345
|
+
return fp
|
|
346
|
+
},
|
|
347
|
+
stats() { return { mode: mode || 'detecting', channels: mux ? mux.channels.size : 0, muxConnected: !!(mux && mux.connected) } },
|
|
348
|
+
stop() { stopped = true; try { if (mux) mux.destroy() } catch { } },
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// hive-pr-staleness.js — do not write a file over a branch change you cannot account for.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT, MEASURED 19 Sep 2026. hive-pr generated a PR that looked perfect: three commits,
|
|
4
|
+
// one per agent, each agent as the real git author, signed ids in `Hivecode-Author:` trailers,
|
|
5
|
+
// files with no verified receipt refused rather than committed. A reviewer would have had every
|
|
6
|
+
// reason to trust it.
|
|
7
|
+
//
|
|
8
|
+
// Diffed against the branch it was cut from:
|
|
9
|
+
//
|
|
10
|
+
// hive-linear.js -77
|
|
11
|
+
// tests/hive-linear-test.mjs -114
|
|
12
|
+
// tests/hive-mcp-http-test.mjs -49
|
|
13
|
+
// tests/hive-governor-test.mjs -32
|
|
14
|
+
// sync.js -39
|
|
15
|
+
//
|
|
16
|
+
// It deleted the HIV-27 claim guard, deleted the HIV-14 truncation fix, deleted ~200 lines of
|
|
17
|
+
// tests, and restored the 10x dashboard bug -- the one where thirty minutes of runtime displays
|
|
18
|
+
// as 3.0. Filed HIV-28.
|
|
19
|
+
//
|
|
20
|
+
// THE CAUSE IS ONE LINE. hive-pr.js did `fs.copyFileSync(f.abs, dest)` and then `git add`. It
|
|
21
|
+
// treats the room as truth and writes it over the branch. Grepping that file for `ahead`,
|
|
22
|
+
// `behind`, `stale`, `diverged`, `merge-base` or `rev-list` returned nothing: it never asked
|
|
23
|
+
// whether the branch had moved.
|
|
24
|
+
//
|
|
25
|
+
// This is not an exotic case, it is the NORMAL one. Any commit reaching the branch by another
|
|
26
|
+
// route puts it ahead -- a hotfix, a second developer, CI, or a human doing exactly the
|
|
27
|
+
// review-then-push workflow this tool exists to serve. In the measured incident the other route
|
|
28
|
+
// was me, working in the git checkout instead of my room folder.
|
|
29
|
+
//
|
|
30
|
+
// WHY "AHEAD/BEHIND" IS THE WRONG QUESTION. There is no merge-base between a Yjs room and a git
|
|
31
|
+
// branch; they are not two git histories. `git rev-list --count` cannot answer this, and reaching
|
|
32
|
+
// for it would produce a check that looks rigorous and measures nothing.
|
|
33
|
+
//
|
|
34
|
+
// THE QUESTION THAT IS ANSWERABLE, per file:
|
|
35
|
+
//
|
|
36
|
+
// has the room ever SEEN what is currently on the branch?
|
|
37
|
+
//
|
|
38
|
+
// It can be answered exactly, from state that already exists. Every landed change appends a
|
|
39
|
+
// receipt carrying `parent` (the contentHash the author built on) and `contentHash` (the result),
|
|
40
|
+
// so a file's ledger IS a hash chain of every state the room has passed through. `provenanceOf()`
|
|
41
|
+
// already returns it -- hive-pr reads it for attribution and then ignores it for safety.
|
|
42
|
+
//
|
|
43
|
+
// If the branch's current bytes hash to something in that chain, the room descends from the
|
|
44
|
+
// branch and writing is continuing the line. If they do not, the branch holds a change the room
|
|
45
|
+
// has never seen, and writing would delete it. That is the whole check.
|
|
46
|
+
|
|
47
|
+
import { contentHash } from './substrate.js'
|
|
48
|
+
|
|
49
|
+
/** Nothing to write: the branch already holds exactly this. */
|
|
50
|
+
export const IDENTICAL = 'IDENTICAL'
|
|
51
|
+
/** The file is not on the branch at all, so there is nothing to revert. */
|
|
52
|
+
export const NEW = 'NEW'
|
|
53
|
+
/** The branch's bytes are in the room's chain: the room descends from them. Safe. */
|
|
54
|
+
export const DESCENDS = 'DESCENDS'
|
|
55
|
+
/** The branch holds bytes the room has never seen. REFUSE. */
|
|
56
|
+
export const DIVERGED = 'DIVERGED'
|
|
57
|
+
/** The room has no chain for this file, so nothing can be proved either way. REFUSE. */
|
|
58
|
+
export const UNPROVEN = 'UNPROVEN'
|
|
59
|
+
|
|
60
|
+
/** The only two states that may be written. A list, so widening it is a visible edit. */
|
|
61
|
+
export const WRITABLE = [NEW, DESCENDS]
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* LINE ENDINGS ARE PRESENTATION, AND ON THIS REPO THEY DIFFER BY DEFAULT.
|
|
65
|
+
*
|
|
66
|
+
* Measured here: `hive-mcp.js` and `sync.js` are CRLF in the working tree and LF in the blob,
|
|
67
|
+
* because `core.autocrlf` is true. So the bytes git stored and the bytes the room holds are not
|
|
68
|
+
* equal for most files in this project, while the file is by any useful definition the same file.
|
|
69
|
+
*
|
|
70
|
+
* A byte comparison would therefore mark nearly everything DIVERGED and refuse every run. That is
|
|
71
|
+
* worse than no check: a guard that fires on the HEALTHY path is the one that gets a `--force`
|
|
72
|
+
* alias in somebody's shell history within a week, and then it is not a guard at all.
|
|
73
|
+
*
|
|
74
|
+
* So both the equality test and the chain lookup are done modulo line endings. Three variants, not
|
|
75
|
+
* a normalisation, because the chain's hashes were computed by substrate.js over whatever text the
|
|
76
|
+
* room actually held at the time -- there is no single canonical form to normalise TO.
|
|
77
|
+
*/
|
|
78
|
+
const variants = (t) => {
|
|
79
|
+
const lf = String(t).replace(/\r\n/g, '\n')
|
|
80
|
+
return [String(t), lf, lf.replace(/\n/g, '\r\n')]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Same file, ignoring how the lines end. */
|
|
84
|
+
export const sameText = (a, b) =>
|
|
85
|
+
a != null && b != null && String(a).replace(/\r\n/g, '\n') === String(b).replace(/\r\n/g, '\n')
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every content state this file has passed through in the room.
|
|
89
|
+
*
|
|
90
|
+
* Both fields go in. `parent` is the state an author BUILT ON -- which may be a state no receipt
|
|
91
|
+
* ever produced, because the first write's parent is the hash of the empty string and a rejoin
|
|
92
|
+
* can land on a state whose own receipt lives on another peer. Taking only `contentHash` would
|
|
93
|
+
* miss exactly those, and each miss reads as DIVERGED: a refusal on a healthy file, which is the
|
|
94
|
+
* failure mode that gets a safety check disabled.
|
|
95
|
+
*/
|
|
96
|
+
export function chainOf(receipts) {
|
|
97
|
+
const seen = new Set()
|
|
98
|
+
for (const r of receipts || []) {
|
|
99
|
+
if (!r) continue
|
|
100
|
+
if (r.parent) seen.add(r.parent)
|
|
101
|
+
if (r.contentHash) seen.add(r.contentHash)
|
|
102
|
+
}
|
|
103
|
+
return seen
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decide whether the room's copy may be written over the branch's.
|
|
108
|
+
*
|
|
109
|
+
* `branchText` is null when the path does not exist on the branch. It must be the RAW blob --
|
|
110
|
+
* read with `git cat-file blob`, never `git show` through a working tree, or a checkout with
|
|
111
|
+
* `core.autocrlf` hands back bytes git never stored and every hash misses.
|
|
112
|
+
*/
|
|
113
|
+
export function accountFor({ rel, branchText, roomText, receipts }) {
|
|
114
|
+
if (branchText == null) return { rel, state: NEW, why: 'not on the branch yet — nothing can be reverted' }
|
|
115
|
+
// Modulo line endings on purpose. A file that differs ONLY in how its lines end must not be
|
|
116
|
+
// rewritten: it would produce a diff of every line in the file, for no change anybody made.
|
|
117
|
+
if (sameText(branchText, roomText)) return { rel, state: IDENTICAL, why: 'the branch already holds this (line endings aside)' }
|
|
118
|
+
|
|
119
|
+
const chain = chainOf(receipts)
|
|
120
|
+
if (!chain.size) {
|
|
121
|
+
return {
|
|
122
|
+
rel, state: UNPROVEN,
|
|
123
|
+
why: 'the room has no provenance chain for this file, so there is no way to tell whether its copy is newer than the branch or older',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hashes = variants(branchText).map(contentHash)
|
|
128
|
+
if (hashes.some((h) => chain.has(h))) {
|
|
129
|
+
return { rel, state: DESCENDS, why: 'the room has passed through the branch\'s current content, so its copy is a descendant' }
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
rel, state: DIVERGED, branchHash: hashes[0],
|
|
133
|
+
why: 'the branch holds content this room has never seen — writing the room\'s copy would delete it',
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** May this decision be written? Never inline `state !== DIVERGED` — that admits UNPROVEN. */
|
|
138
|
+
export const writable = (d) => WRITABLE.includes(d.state)
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The run-level verdict.
|
|
142
|
+
*
|
|
143
|
+
* ALL OR NOTHING, and that is the design. Committing the safe files and skipping the rest produces
|
|
144
|
+
* a PR that looks complete and is not -- the same false success the tool already produced once,
|
|
145
|
+
* one level up. A refusal a human can read beats a branch they have to audit.
|
|
146
|
+
*/
|
|
147
|
+
export function verdict(decisions) {
|
|
148
|
+
const blocked = decisions.filter((d) => !writable(d) && d.state !== IDENTICAL)
|
|
149
|
+
return {
|
|
150
|
+
ok: blocked.length === 0,
|
|
151
|
+
blocked,
|
|
152
|
+
writable: decisions.filter(writable),
|
|
153
|
+
identical: decisions.filter((d) => d.state === IDENTICAL),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What the operator reads. `lastTouch` is supplied by the caller (hive-pr runs the git command)
|
|
159
|
+
* so this module stays pure and testable without a repository.
|
|
160
|
+
*
|
|
161
|
+
* It names the commit and the person, because "diverged" alone sends someone to `git log` to work
|
|
162
|
+
* out whether this is their own forgotten hotfix or somebody else's live work -- and that lookup
|
|
163
|
+
* is the moment a tired operator reaches for --force.
|
|
164
|
+
*/
|
|
165
|
+
export function explain(blocked, lastTouch = () => '') {
|
|
166
|
+
// Q506, the next free code in hive-pr's own Q500-Q505 block. Q443 would have read naturally
|
|
167
|
+
// here -- it is the other "you are being handed an incomplete picture" refusal -- and it is
|
|
168
|
+
// already the Linear truncation code. Two meanings behind one code is the duplicated-wire
|
|
169
|
+
// -contract defect wearing a three-digit hat.
|
|
170
|
+
const lines = ['Q506 REFUSING TO WRITE — the branch has moved under these files:', '']
|
|
171
|
+
for (const d of blocked) {
|
|
172
|
+
const touch = lastTouch(d.rel)
|
|
173
|
+
lines.push(` ${d.rel}`)
|
|
174
|
+
lines.push(` ${d.why}`)
|
|
175
|
+
if (touch) lines.push(` last changed on the branch by: ${touch}`)
|
|
176
|
+
}
|
|
177
|
+
lines.push('')
|
|
178
|
+
lines.push('Nothing was written and no branch was created. Writing the room\'s copy over these')
|
|
179
|
+
lines.push('would silently revert whatever reached the branch another way — a hotfix, a second')
|
|
180
|
+
lines.push('developer, CI, or your own work done outside the room. That has happened once on this')
|
|
181
|
+
lines.push('project and the resulting PR looked perfect.')
|
|
182
|
+
lines.push('')
|
|
183
|
+
lines.push('Fix it one of two ways:')
|
|
184
|
+
lines.push(' - bring the branch\'s version INTO the room (edit it there, or hive_edit it), so the')
|
|
185
|
+
lines.push(' room\'s copy descends from what is on the branch. Then run this again.')
|
|
186
|
+
lines.push(' - --force "<reason>" if you have decided the room\'s copy should win. The reason is')
|
|
187
|
+
lines.push(' recorded in the PR body, because somebody will ask.')
|
|
188
|
+
return lines.join('\n')
|
|
189
|
+
}
|