hivecode-mcp 0.12.2 → 0.13.0
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/LICENSE +21 -21
- package/README.md +421 -167
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -0
- package/docs/GITHUB_WORKFLOW.md +334 -0
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -118
- package/docs/QUICKSTART.md +266 -0
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +450 -0
- package/docs/TROUBLESHOOTING.md +87 -0
- package/hive-activity.js +336 -310
- package/hive-await.js +207 -0
- package/hive-branch.js +686 -0
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +1904 -0
- package/hive-hook-install.js +179 -103
- package/hive-instructions.js +74 -66
- package/hive-listen.js +81 -0
- package/hive-mailbox.js +520 -386
- package/hive-mcp-http.js +580 -0
- package/hive-mcp.js +579 -137
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +350 -350
- package/hive-notify.js +233 -0
- package/hive-obligations.js +247 -247
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +350 -0
- package/hive-privacy.js +327 -288
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +230 -225
- package/hive-room-key.js +209 -0
- package/hive-secrets.js +89 -0
- package/hive-session.js +114 -0
- package/hive-setup.js +558 -445
- package/hive-slack-agent.js +396 -0
- package/hive-slack.js +1809 -0
- package/hive-stdio.js +172 -168
- package/hive-stop-hook.js +293 -241
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -0
- package/hive-update.js +58 -58
- package/hive-version.js +110 -59
- package/hive-webhook.js +54 -0
- package/icr-merge.js +58 -58
- package/icr.js +851 -745
- package/lang-json.js +211 -211
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -394
- package/package.json +189 -164
- package/reconcile.cjs +614 -477
- package/slack-app-manifest-agent.json +61 -0
- package/slack-app-manifest-inbound.json +36 -0
- package/slack-app-manifest.json +27 -0
- package/substrate.js +252 -252
- package/sync.js +1956 -177
- package/grm-link.cjs +0 -141
package/sync.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import fs from 'fs'
|
|
19
19
|
import os from 'os'
|
|
20
20
|
import path from 'path'
|
|
21
|
+
import crypto from 'crypto'
|
|
21
22
|
import * as Y from 'yjs'
|
|
22
23
|
import { WebsocketProvider } from 'y-websocket'
|
|
23
24
|
import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
|
|
@@ -35,7 +36,13 @@ import { genIdentity, authorChange, verifyReceipt, contentHash } from './substra
|
|
|
35
36
|
// ReferenceError and killed the sync process outright. Nothing caught it -- the client
|
|
36
37
|
// simply died mid-session, which reads to a user as "sync stopped working". Rewriting a
|
|
37
38
|
// whole file is the most common thing an agent does, so this fired constantly.
|
|
38
|
-
import
|
|
39
|
+
import { createRequire } from 'module'
|
|
40
|
+
import { VERSION, VERSION_LABEL } from './hive-version.js' // which build this client is, carried in presence
|
|
41
|
+
// ROOM MEMORY LINK (29 Sep 2026). An optional local add-on that is not in the published package. A
|
|
42
|
+
// checkout that has the file links the room; an npm install simply has no room-memory link, and
|
|
43
|
+
// nothing else changes. Loaded, not imported, because a static import of an unshipped file fails at boot.
|
|
44
|
+
let grmLink = null
|
|
45
|
+
try { grmLink = createRequire(import.meta.url)('./grm-link.cjs') } catch { /* not shipped: no room memory link */ }
|
|
39
46
|
import { structuralMerge, supports, languageFor } from './icr.js' // validates a judged fork resolution before it may land (parse + dangling refs)
|
|
40
47
|
import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from './hive-pr-staleness.js' // room-versus-branch provenance staleness checks (HIV-P9)
|
|
41
48
|
// HIV-P8. The map key MUST be the string 'obligations' — hive-mcp.js:656 reads that exact key, and
|
|
@@ -43,7 +50,9 @@ import { accountFor, sameText, DESCENDS, IDENTICAL, DIVERGED, UNPROVEN } from '.
|
|
|
43
50
|
// the defect this ticket is about, one level down.
|
|
44
51
|
import * as RH from './hive-relay-health.js' // HIV-P13; the ONLY producer of the not-syncing diagnosis
|
|
45
52
|
import { checkRelay, currentRelayFor } from './hive-privacy.js' // HIV-P20; which relay is this, and did anybody choose it
|
|
53
|
+
import { newSecrets, describeSecret } from './hive-secrets.js' // a credential inside a file is held here, not sent to the room
|
|
46
54
|
import * as OB from './hive-obligations.js'
|
|
55
|
+
import { formatTime } from './hive-time.js'
|
|
47
56
|
|
|
48
57
|
// Wire-format/protocol version. Bump when a change makes clients incompatible
|
|
49
58
|
// (e.g. the v2 per-file-subdoc model). Broadcast via awareness so peers can warn
|
|
@@ -52,14 +61,29 @@ const PROTOCOL_VERSION = 2
|
|
|
52
61
|
|
|
53
62
|
// Never sync these, even if not in .gitignore — secrets and obvious junk. This
|
|
54
63
|
// prevents pushing a teammate's .env / private keys / build output to the room.
|
|
55
|
-
|
|
64
|
+
// Exported so hive-branch.js can tell, before seeding a room from a git tree, which tracked
|
|
65
|
+
// files the room will never carry.
|
|
66
|
+
export const ALWAYS_IGNORE = [
|
|
56
67
|
'.git/', 'node_modules/',
|
|
68
|
+
// `.git` as a FILE too. '.git/' matches only a directory, and in a git worktree (or a submodule,
|
|
69
|
+
// or a branch-room folder, which holds a `.git` file that makes git refuse to run there) `.git`
|
|
70
|
+
// is a one-line file naming THIS machine's git directory. The walk skips it by name, but a
|
|
71
|
+
// watcher event does not. Measured 30 Sep 2026 with '.git/' alone: an edit to A's sub/.git was
|
|
72
|
+
// published, then retracted as "deleted" by A's next safety-net scan (whose walk skips it) --
|
|
73
|
+
// and in between, peer B's OWN sub/.git, a submodule pointer to B's git directory, was first
|
|
74
|
+
// overwritten with conflict markers and then deleted. tests/branch-test.mjs pins it (D1, D2).
|
|
75
|
+
'.git',
|
|
57
76
|
'.env', '.env.*', '*.pem', '*.key', '*.pfx', '*.p12', 'id_rsa', 'id_ed25519', '*.keystore',
|
|
77
|
+
// Files that are credentials by name, whatever is in them on a given day: direnv's exports, the
|
|
78
|
+
// login stores of curl/git/pip, and the other private key formats.
|
|
79
|
+
'.envrc', '.netrc', '_netrc', '.git-credentials', '.pypirc', 'id_ecdsa', 'id_dsa', '*.ppk', '*.p8', '*.jks',
|
|
58
80
|
'.hive-id.json', // this client's substrate signing identity (holds a PRIVATE key) — never sync it
|
|
59
81
|
// This client's own session + chat read cursor. Syncing it would push one agent's identity
|
|
60
82
|
// and read position into every other agent's folder, and a shared cursor means one agent
|
|
61
83
|
// consuming another's unread mail.
|
|
62
84
|
'.hive-mcp-session.json',
|
|
85
|
+
// The Stop hook's record of what it already held this agent's turn open for: per agent, like the session.
|
|
86
|
+
'.hive-stop-hook.json',
|
|
63
87
|
// This client's record of what IT last wrote to each file — the common ancestor it uses
|
|
64
88
|
// to tell an authored edit from its own write after a restart (see SHADOW_FILE). It is
|
|
65
89
|
// per-client by definition: syncing it would hand every peer a false ancestor.
|
|
@@ -69,6 +93,10 @@ const ALWAYS_IGNORE = [
|
|
|
69
93
|
// Names a PID on THIS machine. Syncing it would tell every peer their folder is locked by
|
|
70
94
|
// a process that does not exist for them.
|
|
71
95
|
'.hive-lock.json',
|
|
96
|
+
// Copies of files a teammate's delete removed from THIS folder (see keepDeletedCopy). They
|
|
97
|
+
// are this machine's safety net; syncing them would undo every delete in the room.
|
|
98
|
+
'.hive-deleted/',
|
|
99
|
+
'.hive-case-*',
|
|
72
100
|
'*.log', '.DS_Store', 'Thumbs.db', '*.vsix',
|
|
73
101
|
|
|
74
102
|
// MOBILE AND CLOUD SECRETS. These are the files a mobile project keeps OUT of git via
|
|
@@ -136,14 +164,49 @@ export function fusionReason(merged, mine, theirs, lang) {
|
|
|
136
164
|
return ''
|
|
137
165
|
}
|
|
138
166
|
|
|
167
|
+
const endNl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
168
|
+
|
|
169
|
+
// HIV-P17. Why a creation race of relPath must NOT land, '' when it may: 'CONFLICT' when two whole
|
|
170
|
+
// files collide in a file with a language, else fusionReason's answer when a clean-looking union
|
|
171
|
+
// broke code both sides could parse. No language (Markdown) is never refused.
|
|
172
|
+
export function creationRaceVerdict(r0, mine, theirs, relPath) {
|
|
173
|
+
const code = supports(relPath)
|
|
174
|
+
return r0.conflict ? (code ? 'CONFLICT' : '') : fusionReason(r0.text, endNl(mine), endNl(theirs), code ? languageFor(relPath) : null)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// HIV-P17 dead heat: several clients published one file into an empty doc before any saw another, and
|
|
178
|
+
// the CRDT unioned them. textsByClient = each creator's live text, keyed by Yjs client id. The winner
|
|
179
|
+
// is the lowest id -- every peer holds the same union, so every peer picks the same one. The creators
|
|
180
|
+
// are folded in ONE BY ONE and each step is judged, because the union is what would land: with three
|
|
181
|
+
// creators, two can each merge cleanly with the winner and still fuse with each other.
|
|
182
|
+
export function deadHeatVerdict(textsByClient, relPath) {
|
|
183
|
+
const [winner, ...others] = [...textsByClient.keys()].sort((a, b) => a - b)
|
|
184
|
+
const winnerText = textsByClient.get(winner) || ''
|
|
185
|
+
let acc = winnerText
|
|
186
|
+
for (const c of others) {
|
|
187
|
+
const t = textsByClient.get(c) || ''
|
|
188
|
+
const r = icrMerge3('', endNl(acc), endNl(t), relPath)
|
|
189
|
+
const why = creationRaceVerdict(r, acc, t, relPath)
|
|
190
|
+
if (why) return { winner, winnerText, why }
|
|
191
|
+
acc = r.text
|
|
192
|
+
}
|
|
193
|
+
return { winner, winnerText, why: '' }
|
|
194
|
+
}
|
|
195
|
+
|
|
139
196
|
const IGNORE = new Set(['node_modules', '.git'])
|
|
140
197
|
const MAX_BYTES = 1_000_000
|
|
198
|
+
const MAX_BINARY_BYTES = Number(process.env.HIVE_MAX_BINARY_BYTES) || (5 * 1024 * 1024)
|
|
141
199
|
const BOARD_FILE = 'HIVE_BOARD.md' // generated locally from `board`; never synced as a file
|
|
142
200
|
const CHAT_FILE = 'HIVE_CHAT.md' // generated locally from `chat`; the agents' conversation
|
|
143
201
|
const TASKS_FILE = 'HIVE_TASKS.md' // generated locally from `tasks`; directed work + approvals
|
|
144
202
|
const CONFIG_FILE = '.hive.json' // local rendezvous config (room id); not synced
|
|
145
203
|
const SHADOW_FILE = '.hive-shadow.json' // what THIS client last wrote per file; the cross-restart ancestor
|
|
146
204
|
const LOCK_FILE = '.hive-lock.json' // one sync client per folder; see acquireFolderLock
|
|
205
|
+
const DELETED_DIR = '.hive-deleted' // copies of files a teammate's delete removed here; see keepDeletedCopy
|
|
206
|
+
const DELETED_KEEP_MS = 7 * 24 * 3600 * 1000 // ...pruned at start once they are a week old
|
|
207
|
+
// Local deletes wait this long for the deleting to stop, then leave as one batch. See settleLocalDeletes.
|
|
208
|
+
const DELETE_SETTLE_MS = Number(process.env.HIVE_DELETE_SETTLE_MS) || 1500
|
|
209
|
+
const BULK_DELETE = 5 // a batch this big is announced in the room, not just logged
|
|
147
210
|
const ID_FILE = '.hive-id.json' // this client's substrate signing identity (private); never synced
|
|
148
211
|
const RULES_FILE = 'HIVE_RULES.md' // the law every participant follows; written into every room
|
|
149
212
|
const MEMBERS_FILE = 'HIVE_MEMBERS.md' // live presence: who is in the room right now
|
|
@@ -155,11 +218,12 @@ const CLAIMS_FILE = 'HIVE_CLAIMS.md'
|
|
|
155
218
|
const AGENTS_FILE = 'HIVE_FOR_AGENTS.md' // how an AI agent opening this folder joins (via .hive.json) + behaves
|
|
156
219
|
// Generated/coordination files are rendered locally from CRDT state — never
|
|
157
220
|
// synced as ordinary files (that would cause echo loops / conflicts).
|
|
158
|
-
|
|
221
|
+
// Exported for hive-branch.js: a tracked file with one of these names never reaches the room.
|
|
222
|
+
export const SKIP = new Set([BOARD_FILE, CHAT_FILE, TASKS_FILE, CONFIG_FILE, ID_FILE, RULES_FILE, MEMBERS_FILE, CLAIMS_FILE, AGENTS_FILE])
|
|
159
223
|
// Files a client writes into its folder for ITSELF. They are ignored on purpose, so the "created
|
|
160
224
|
// here but is ignored" notice must never name them: on 27 Sep 2026 every agent joining a live room
|
|
161
225
|
// told the whole team about .hive-shadow.json, a file none of them had made.
|
|
162
|
-
const OWN_LOCAL = new Set([SHADOW_FILE, SHADOW_FILE + '.tmp', LOCK_FILE, '.hive-mcp-session.json', '.hive-id.json'])
|
|
226
|
+
const OWN_LOCAL = new Set([SHADOW_FILE, SHADOW_FILE + '.tmp', LOCK_FILE, '.hive-mcp-session.json', '.hive-stop-hook.json', '.hive-id.json'])
|
|
163
227
|
|
|
164
228
|
// Written into every room folder on sync (like the rules). An AI agent that opens
|
|
165
229
|
// this project reads it to learn it's a Hivecode room and how to JOIN — via the
|
|
@@ -239,29 +303,6 @@ it well enough to write it — say so in the room and ask. That is a complete an
|
|
|
239
303
|
2. Read HIVE_BOARD.md — which files were just rewritten (and what was touched).
|
|
240
304
|
3. If a file you plan to edit appears there, RE-READ it before changing it.
|
|
241
305
|
|
|
242
|
-
## MEMORY — ask before you assume (the recall tool)
|
|
243
|
-
- This room has a memory of everything decided here and in past sessions. It is a
|
|
244
|
-
tool called \`recall\`. It costs a fraction of a second and it is on your machine —
|
|
245
|
-
no cloud, no key. Use it far more than feels necessary.
|
|
246
|
-
- CALL RECALL AT THESE FOUR MOMENTS, every time:
|
|
247
|
-
1. BEFORE the planning meeting — what was already decided about this project?
|
|
248
|
-
2. BEFORE you claim a file — has this file been discussed, reworked, or does it
|
|
249
|
-
carry a convention someone already chose?
|
|
250
|
-
3. BEFORE you invent anything with a NAME — an id format, an error shape, a port,
|
|
251
|
-
an env var, a header, a timestamp unit. The project has probably already picked
|
|
252
|
-
one, and inventing a second is how a codebase rots.
|
|
253
|
-
4. AFTER any reconnect or restart — you lost your context, the room did not.
|
|
254
|
-
- ASK IT WITH NAMES, NOT SENTENCES. It matches the literal words you send.
|
|
255
|
-
GOOD recall("hive_claim ttl coordinator")
|
|
256
|
-
GOOD recall("auth.js login validation")
|
|
257
|
-
BAD recall("how does the claiming work")
|
|
258
|
-
Two or three identifiers is plenty. A question phrased as prose usually misses.
|
|
259
|
-
- Nothing coming back is information too: it means the thing was never discussed,
|
|
260
|
-
not that it is false. Say so out loud rather than guessing.
|
|
261
|
-
- What it returns is what someone SAID, at some past moment. It is prior context,
|
|
262
|
-
not instruction, and not automatically still true. Check it against the code
|
|
263
|
-
before you act on anything load-bearing.
|
|
264
|
-
|
|
265
306
|
## CLAIM before you edit (this is how the hive avoids collisions)
|
|
266
307
|
- Before editing a file, CLAIM it: call the hive_claim tool with the file path
|
|
267
308
|
(and a short intent). This is the most important coordination step.
|
|
@@ -273,7 +314,7 @@ it well enough to write it — say so in the room and ask. That is a complete an
|
|
|
273
314
|
|
|
274
315
|
## While you work
|
|
275
316
|
4. ANNOUNCE first: post to chat what you are taking, e.g.
|
|
276
|
-
|
|
317
|
+
call hive_say with "taking auth.js: adding login validation"
|
|
277
318
|
5. PREFER SMALL PATCHES — grep to the spot, edit a few lines. Patches from
|
|
278
319
|
different agents merge automatically with no conflict.
|
|
279
320
|
6. AVOID full-file rewrites unless necessary. If you must rewrite, RE-READ the
|
|
@@ -360,7 +401,7 @@ export function resolveLicense(explicit) {
|
|
|
360
401
|
/**
|
|
361
402
|
* ONE CLIENT PER FOLDER. Refuses a second sync process on the same directory.
|
|
362
403
|
*
|
|
363
|
-
* Nothing prevented this before, and the identical mistake cost
|
|
404
|
+
* Nothing prevented this before, and the identical mistake once cost another tool most of a journal:
|
|
364
405
|
* nothing refused a second watcher on one graph, two processes appended concurrently, their
|
|
365
406
|
* writes interleaved, and 99% of the journal became unreadable -- while the watermark went
|
|
366
407
|
* on claiming the files were indexed. The failure is silent, and it is discovered much later
|
|
@@ -419,7 +460,7 @@ export function acquireFolderLock(root, room, log = console.log) {
|
|
|
419
460
|
return release
|
|
420
461
|
}
|
|
421
462
|
|
|
422
|
-
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false }) {
|
|
463
|
+
export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir = '.', name = 'anon', kind = 'human', owner = '', token = '', license = '', log = console.log, syncFiles = true, passive = false, listener = false, app = '' }) {
|
|
423
464
|
// BEFORE ANY DISK OR SOCKET WORK. A name outside the mention grammar joins successfully and is
|
|
424
465
|
// then unreachable: its mail, its obligations and its wake-on-mention are all silently dead,
|
|
425
466
|
// and the only symptom is other people being told "@X is NOT in the room" about someone who is
|
|
@@ -464,6 +505,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
464
505
|
fs.mkdirSync(ROOT, { recursive: true })
|
|
465
506
|
// FIRST, before any socket or file work: if another client already holds this folder,
|
|
466
507
|
const releaseLock = syncFiles ? acquireFolderLock(ROOT, room, log) : () => {}
|
|
508
|
+
// Set once this folder stops existing under a running session (see loseFolder). From then on
|
|
509
|
+
// nothing in it is written, moved or deleted, and no local absence is sent to the room as a delete.
|
|
510
|
+
let folderLost = null
|
|
511
|
+
if (syncFiles) pruneDeletedCopies()
|
|
467
512
|
|
|
468
513
|
// Respect .gitignore (+ always-ignore secrets/junk) so we never sync a
|
|
469
514
|
// teammate's .env, keys, or build output. Reloaded by reloadIgnores().
|
|
@@ -552,12 +597,82 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
552
597
|
// DISAGREEMENT becomes the signal. One shared "size" field would have silently converged on
|
|
553
598
|
// whichever peer wrote last, which is precisely the failure it exists to catch.
|
|
554
599
|
const swarmAttest = doc.getMap('swarm_attest')
|
|
600
|
+
const swarmVersions = doc.getMap('swarm_versions')
|
|
601
|
+
|
|
602
|
+
function bumpAttestVersion(relPath, newText) {
|
|
603
|
+
if (!swarmAttest.has(relPath)) return // only bump files that already have attestations
|
|
604
|
+
const prev = swarmVersions.get(relPath) || { n: 1, hash: '' }
|
|
605
|
+
const newHash = contentHash(newText)
|
|
606
|
+
if (prev.hash !== newHash) {
|
|
607
|
+
swarmVersions.set(relPath, { n: (prev.n || 1) + 1, hash: newHash })
|
|
608
|
+
}
|
|
609
|
+
}
|
|
555
610
|
// GATE VERDICTS — `<region>|<peerName>` -> the result of that peer running the room's check in
|
|
556
611
|
// ITS OWN folder before handing the file back. Keyed per peer for the same reason swarmAttest
|
|
557
612
|
// is: "the tests pass" is a fact about one folder's bytes, and storing it as a single shared
|
|
558
613
|
// value would converge on whoever released last, silently erasing the disagreement that is the
|
|
559
614
|
// entire signal. Two agents reporting different verdicts for one path both survive here.
|
|
560
615
|
const gateResults = doc.getMap('gate_results')
|
|
616
|
+
|
|
617
|
+
// --- BATCHED ROOM-DOC MUTATIONS (manifest + historyMeta) ---
|
|
618
|
+
// A seeder publishing N files previously sent N manifest.set() updates and N historyMeta.set()
|
|
619
|
+
// updates — 2N distinct Yjs updates on the base-room socket. On the free tier (burst 600, 60/s)
|
|
620
|
+
// this throttled the base room at file ~300.
|
|
621
|
+
// We batch pending operations into a single doc.transact() flushed on setImmediate (or synchronously on stop()).
|
|
622
|
+
const pendingManifestOps = new Map() // relPath -> 'set' | 'delete'
|
|
623
|
+
const pendingHistoryMeta = new Map() // id -> { op: 'set' | 'delete', record?: any }
|
|
624
|
+
let roomFlushTimer = null
|
|
625
|
+
|
|
626
|
+
function inManifest(relPath) {
|
|
627
|
+
if (pendingManifestOps.has(relPath)) {
|
|
628
|
+
return pendingManifestOps.get(relPath) === 'set'
|
|
629
|
+
}
|
|
630
|
+
return manifest.has(relPath)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function flushRoomDoc() {
|
|
634
|
+
if (roomFlushTimer) {
|
|
635
|
+
clearImmediate(roomFlushTimer)
|
|
636
|
+
roomFlushTimer = null
|
|
637
|
+
}
|
|
638
|
+
if (!pendingManifestOps.size && !pendingHistoryMeta.size) return
|
|
639
|
+
doc.transact(() => {
|
|
640
|
+
for (const [r, op] of pendingManifestOps) {
|
|
641
|
+
if (op === 'set') manifest.set(r, 1)
|
|
642
|
+
else manifest.delete(r)
|
|
643
|
+
}
|
|
644
|
+
pendingManifestOps.clear()
|
|
645
|
+
|
|
646
|
+
for (const [id, item] of pendingHistoryMeta) {
|
|
647
|
+
if (item.op === 'set') historyMeta.set(id, item.record)
|
|
648
|
+
else historyMeta.delete(id)
|
|
649
|
+
}
|
|
650
|
+
pendingHistoryMeta.clear()
|
|
651
|
+
pruneMeta()
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function scheduleRoomFlush() {
|
|
656
|
+
if (stopped) { flushRoomDoc(); return }
|
|
657
|
+
if (!roomFlushTimer) {
|
|
658
|
+
roomFlushTimer = setImmediate(flushRoomDoc)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function queueManifestSet(relPath) {
|
|
663
|
+
pendingManifestOps.set(relPath, 'set')
|
|
664
|
+
scheduleRoomFlush()
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function queueManifestDelete(relPath) {
|
|
668
|
+
if (!manifest.has(relPath)) {
|
|
669
|
+
pendingManifestOps.delete(relPath)
|
|
670
|
+
} else {
|
|
671
|
+
pendingManifestOps.set(relPath, 'delete')
|
|
672
|
+
}
|
|
673
|
+
scheduleRoomFlush()
|
|
674
|
+
}
|
|
675
|
+
|
|
561
676
|
// CLAIM LEASE. Expiry is evaluated lazily on read, so the moment a lease passes its TTL the
|
|
562
677
|
// next agent that tries simply gets the file -- that is what stops a stalled agent
|
|
563
678
|
// deadlocking the room.
|
|
@@ -686,8 +801,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
686
801
|
const base = shadow.get(relPath)
|
|
687
802
|
if (base == null || disk === base) return false // no baseline yet, or nothing new on disk
|
|
688
803
|
if (hasConflictMarkers(disk)) return false // half-edited conflict block — not an authored state
|
|
804
|
+
if (!SHARE_SECRETS && newSecrets(disk, fe.text.toString()).length) return false // the ledger is shared: a key stays out of it
|
|
689
805
|
stampIntent(relPath, base, disk) // into the provenance DAG NOW — clobber-proof
|
|
806
|
+
// What this edit was made against, kept as a short HISTORY, not just the last one: a second edit
|
|
807
|
+
// while the first is still held would otherwise record the first (which the room never had) as
|
|
808
|
+
// the base, and a rejoin merging against it reverts the first edit. The rejoin picks the newest
|
|
809
|
+
// base the room's receipts prove it held.
|
|
810
|
+
const hist = capturedFrom.get(relPath) || []
|
|
811
|
+
if (hist[hist.length - 1] !== base) hist.push(base)
|
|
812
|
+
// Drop from the middle, never the oldest: while the room is refusing every write, the oldest is
|
|
813
|
+
// the only base the room actually held (antigravity-3: nine saves while held evicted it).
|
|
814
|
+
while (hist.length > FROM_KEEP) hist.splice(1, 1)
|
|
815
|
+
capturedFrom.set(relPath, hist)
|
|
690
816
|
shadow.set(relPath, disk) // the captured state is the new baseline
|
|
817
|
+
saveShadowSoon()
|
|
691
818
|
return true
|
|
692
819
|
}
|
|
693
820
|
// Build ONE clean N-way conflict block from a common parent and N divergent versions:
|
|
@@ -864,6 +991,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
864
991
|
if (!canWrite(relPath)) return { ok: false, reason: `${relPath} is read-only for you` }
|
|
865
992
|
if (typeof proposal !== 'string' || !proposal.trim()) return { ok: false, reason: 'empty resolution' }
|
|
866
993
|
if (hasConflictMarkers(proposal)) return { ok: false, reason: 'resolution still contains <<<<<<< conflict markers — send the final clean file' }
|
|
994
|
+
if (!SHARE_SECRETS) {
|
|
995
|
+
const key = newSecrets(proposal, fe.text.toString())[0]
|
|
996
|
+
if (key) return { ok: false, reason: `the resolution contains ${describeSecret(key)}, and a resolution is sent to everyone in the room. Read the key from the environment (.env is never shared) and resolve again` }
|
|
997
|
+
}
|
|
867
998
|
let landed = proposal
|
|
868
999
|
if (supports(relPath) && rec.base != null) {
|
|
869
1000
|
const v = structuralMerge(rec.base, proposal, rec.base, { filename: relPath })
|
|
@@ -913,6 +1044,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
913
1044
|
relPath = String(relPath || '').replace(/\\/g, '/')
|
|
914
1045
|
if (!relPath || typeof content !== 'string') return { error: 'need a file path and string content' }
|
|
915
1046
|
if (!isSafeRelPath(relPath)) return { error: 'unsafe path' }
|
|
1047
|
+
if (folderLost) return { error: folderLost.message }
|
|
916
1048
|
if (SKIP.has(relPath) || isIgnored(relPath)) return { error: `${relPath} is not a syncable file` }
|
|
917
1049
|
if (!canOpen(relPath)) return { error: `${relPath} is out of your scope` }
|
|
918
1050
|
if (!canWrite(relPath)) return { error: `${relPath} is read-only for you` }
|
|
@@ -928,7 +1060,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
928
1060
|
const fe = fileDocs.get(relPath)
|
|
929
1061
|
const landed = fe && fe.synced ? fe.text.toString() : content
|
|
930
1062
|
let disk = content; try { disk = fs.readFileSync(full, 'utf8') } catch { }
|
|
931
|
-
|
|
1063
|
+
// A new file's doc has not synced yet when this returns, so the gate in reconcile has not run:
|
|
1064
|
+
// judge the content here too, or the answer would be "syncing to every peer" for a file that
|
|
1065
|
+
// the gate is about to hold.
|
|
1066
|
+
const held = withheld.get(relPath) || (SHARE_SECRETS ? null : newSecrets(content, fe && fe.synced ? fe.text.toString() : '')[0])
|
|
1067
|
+
return { ok: true, file: relPath, landed, conflicted: hasConflictMarkers(disk), ...(held ? { withheld: describeSecret(held) } : {}) }
|
|
932
1068
|
}
|
|
933
1069
|
// Detect a live fork and record it. The version set is a CRDT UNION (per-hash keys), so
|
|
934
1070
|
// this is monotonic + convergent: every peer contributes what it sees and the set grows
|
|
@@ -994,7 +1130,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
994
1130
|
const fileTransport = createFileTransport({ relay, room, wsOpts, log, onFallback: () => trimForSockets() })
|
|
995
1131
|
let stopped = false
|
|
996
1132
|
const provider = new WebsocketProvider(relay, room, doc, wsOpts)
|
|
997
|
-
|
|
1133
|
+
// WHICH COPY OF AN AGENT THIS IS: this machine plus this folder, hashed. Everything in a room is
|
|
1134
|
+
// keyed by NAME (claims, mail, presence), so two different agents under one name are one identity
|
|
1135
|
+
// to everybody; this is what lets a join tell another agent with the same name (refused, see
|
|
1136
|
+
// sameNameElsewhere) from this agent a moment ago, restarting in the same folder (allowed).
|
|
1137
|
+
const IID = crypto.createHash('sha256').update(`${os.hostname()}|${ROOT}`).digest('hex').slice(0, 12)
|
|
1138
|
+
provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID }) // identity is implicit in the client
|
|
998
1139
|
|
|
999
1140
|
// ---- relay reachability, said out loud exactly once -------------------------------
|
|
1000
1141
|
// y-websocket retries forever and silently. That is the right RECOVERY behaviour and
|
|
@@ -1019,7 +1160,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1019
1160
|
}
|
|
1020
1161
|
|
|
1021
1162
|
function explain(code) {
|
|
1022
|
-
if (code === 401)
|
|
1163
|
+
if (code === 401) {
|
|
1164
|
+
return token
|
|
1165
|
+
? 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
1166
|
+
: 'this room needs a token of your own. Ask the room owner to mint you one.'
|
|
1167
|
+
}
|
|
1023
1168
|
if (code === 402) return `this room is over its plan limit. Contact ${SUPPORT} and we will sort it out.`
|
|
1024
1169
|
if (code === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
1025
1170
|
// 429 at the UPGRADE is always Q431 "room at capacity" -- never rate limiting. The rate
|
|
@@ -1196,6 +1341,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1196
1341
|
function fetchFile(relPath, { timeoutMs = 15000 } = {}) {
|
|
1197
1342
|
const r = String(relPath)
|
|
1198
1343
|
if (!canOpen(r)) return Promise.resolve({ ok: false, reason: `${r} is out of your scope` })
|
|
1344
|
+
if (collided.has(r)) {
|
|
1345
|
+
return Promise.resolve({
|
|
1346
|
+
ok: false,
|
|
1347
|
+
collided: true,
|
|
1348
|
+
file: r,
|
|
1349
|
+
reason: `${r} collides in case with ${collided.get(r).with} already on disk. A case-insensitive disk cannot hold both.`,
|
|
1350
|
+
})
|
|
1351
|
+
}
|
|
1199
1352
|
const full = path.join(ROOT, r)
|
|
1200
1353
|
// Already here: report it, and say so rather than implying we went and got it.
|
|
1201
1354
|
if (fs.existsSync(full)) return Promise.resolve({ ok: true, alreadyLocal: true, file: r })
|
|
@@ -1245,6 +1398,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1245
1398
|
const fdoc = new Y.Doc()
|
|
1246
1399
|
const fprovider = fileTransport.provide(fileRoom(room, relPath), fdoc)
|
|
1247
1400
|
const text = fdoc.getText('content')
|
|
1401
|
+
const bin = fdoc.getMap('bin')
|
|
1248
1402
|
// `snap` (in the FILE's own doc, so it's scope-protected with the file) holds
|
|
1249
1403
|
// restore points: id -> { id, file, by, at, ts, content, churn, kind, label }.
|
|
1250
1404
|
// `undo` is a per-file UndoManager tracking THIS client's local edits, so a
|
|
@@ -1272,16 +1426,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1272
1426
|
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
1273
1427
|
const conflict = fdoc.getMap('conflict')
|
|
1274
1428
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
1275
|
-
e = { doc: fdoc, provider: fprovider, text, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
|
|
1429
|
+
e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false }
|
|
1276
1430
|
fileDocs.set(relPath, e)
|
|
1431
|
+
// From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
|
|
1432
|
+
// 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
|
|
1433
|
+
if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
|
|
1277
1434
|
text.observe((_ev, txn) => { if (!txn.local) reconcile(relPath, 'remote') }) // remote content edits
|
|
1435
|
+
bin.observe((_ev, txn) => { if (!txn.local) reconcileBinary(relPath, 'remote') }) // remote binary edits
|
|
1278
1436
|
// A divergent receipt can arrive slightly after (or before) the fused content — so
|
|
1279
1437
|
// re-run the fork check whenever the provenance ledger changes too, not only on edits.
|
|
1280
1438
|
ledger.observe((_ev, txn) => { if (!txn.local) detectFork(relPath) })
|
|
1281
1439
|
// The fork record arrived/changed from a peer: render it locally, or — if it was
|
|
1282
1440
|
// cleared (resolved elsewhere) — adopt the now-clean shared text to disk.
|
|
1283
1441
|
conflict.observe((_ev, txn) => { if (txn.local) return; if (e.conflict.has('meta')) renderConflict(relPath); else adoptResolved(relPath) })
|
|
1284
|
-
fprovider.on('sync', (s) => {
|
|
1442
|
+
fprovider.on('sync', (s) => {
|
|
1443
|
+
if (s) {
|
|
1444
|
+
e.synced = true
|
|
1445
|
+
refusedFiles.delete(relPath)
|
|
1446
|
+
if (e.bin.has('data')) {
|
|
1447
|
+
reconcileBinary(relPath, 'remote')
|
|
1448
|
+
} else {
|
|
1449
|
+
reconcile(relPath, 'remote')
|
|
1450
|
+
detectFork(relPath)
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
})
|
|
1285
1454
|
// A refused upgrade lands here. The provider will keep retrying by itself, which is the
|
|
1286
1455
|
// right behaviour and exactly why nothing surfaces it — so count it and let noteRefusals
|
|
1287
1456
|
// decide whether this is a blip or a relay that will not take us.
|
|
@@ -1493,11 +1662,24 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1493
1662
|
if (SKIP.has(p)) return `${p} is a Hivecode control file, not shared source`
|
|
1494
1663
|
if (!canOpen(p)) return `your access token's scope does not cover ${p}, so its bytes stay in this folder`
|
|
1495
1664
|
if (isIgnored(p)) return `${p} is ignored by a .gitignore here, so this client never opens a sync connection for it -- anything you write stays in this folder and no other agent will ever see it`
|
|
1665
|
+
if (onDisk) {
|
|
1666
|
+
const full = path.join(ROOT, p)
|
|
1667
|
+
const unshared = checkUnshared(full)
|
|
1668
|
+
if (unshared) {
|
|
1669
|
+
if (unshared.reason === 'oversized') {
|
|
1670
|
+
return `${p} is oversized (${Math.round(unshared.size / 1024)}KB > 1MB limit), so it cannot be shared -- anything you write stays in this folder and no other agent will see it`
|
|
1671
|
+
}
|
|
1672
|
+
return `${p} contains binary data (null bytes), so it cannot be shared -- anything you write stays in this folder and no other agent will see it`
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1496
1675
|
return ''
|
|
1497
1676
|
}
|
|
1498
1677
|
|
|
1499
1678
|
const known = new Set()
|
|
1500
1679
|
const mtimes = new Map()
|
|
1680
|
+
const createdTimes = new Map() // relPath -> timestamp of creation/genesis publish
|
|
1681
|
+
const genesisPublished = new Set() // relPath -> files THIS client genesis-published this session
|
|
1682
|
+
const deadHeatResolved = new Set() // relPath -> files where dead-heat creation-race detection already resolved
|
|
1501
1683
|
// Paths that already existed on disk when this client STARTED. This is the
|
|
1502
1684
|
// discriminator between "I cloned a repo and joined a room that already has these
|
|
1503
1685
|
// files" (adopt the room's copy) and "this file was created during the session"
|
|
@@ -1511,6 +1693,32 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1511
1693
|
let roomAtJoin = null
|
|
1512
1694
|
let firstScanDone = false
|
|
1513
1695
|
const shadow = new Map() // relPath -> exact bytes THIS ENGINE last wrote/captured (source-level edit capture)
|
|
1696
|
+
const shaShadow = new Map() // relPath -> sha256 hex string of binary bytes written/seen locally (echo suppression)
|
|
1697
|
+
// Per-file pre-serialized JSON fragments: relPath -> '"path": "content"'.
|
|
1698
|
+
// Assembled into v:1 JSON in saveShadow() without re-stringifying all files, cutting save CPU by ~6.6x.
|
|
1699
|
+
const shadowFragments = new Map()
|
|
1700
|
+
// relPath -> the bases our recent captured edits were made against, oldest first. Saved with the
|
|
1701
|
+
// shadow, because the shadow alone cannot tell "the room has this" from "we sent it and it never
|
|
1702
|
+
// arrived".
|
|
1703
|
+
const FROM_KEEP = 8
|
|
1704
|
+
const capturedFrom = new Map()
|
|
1705
|
+
const priorFrom = new Map()
|
|
1706
|
+
const keptUnsent = new Map() // relPath -> file mtime when an unsent edit was kept; frozen until saved again
|
|
1707
|
+
const origShadowSet = shadow.set.bind(shadow)
|
|
1708
|
+
const origShadowDelete = shadow.delete.bind(shadow)
|
|
1709
|
+
shadow.set = (k, v) => {
|
|
1710
|
+
origShadowSet(k, v)
|
|
1711
|
+
if (typeof v === 'string' && v.length <= MAX_BYTES) {
|
|
1712
|
+
shadowFragments.set(k, JSON.stringify(k) + ':' + JSON.stringify(v))
|
|
1713
|
+
} else {
|
|
1714
|
+
shadowFragments.delete(k)
|
|
1715
|
+
}
|
|
1716
|
+
return shadow
|
|
1717
|
+
}
|
|
1718
|
+
shadow.delete = (k) => {
|
|
1719
|
+
shadowFragments.delete(k)
|
|
1720
|
+
return origShadowDelete(k)
|
|
1721
|
+
}
|
|
1514
1722
|
// THE SAME THING, FROM THE PREVIOUS SESSION, and the reason it exists:
|
|
1515
1723
|
//
|
|
1516
1724
|
// `shadow` in memory is enough to tell an authored edit from our own write while we are
|
|
@@ -1560,16 +1768,324 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1560
1768
|
|
|
1561
1769
|
function readText(full) {
|
|
1562
1770
|
try {
|
|
1771
|
+
const st = fs.statSync(full)
|
|
1772
|
+
if (st.size > MAX_BYTES) return null
|
|
1563
1773
|
const buf = fs.readFileSync(full)
|
|
1564
|
-
if (buf.
|
|
1774
|
+
if (buf.includes(0)) return null
|
|
1565
1775
|
return buf.toString('utf8')
|
|
1566
1776
|
} catch { return null }
|
|
1567
1777
|
}
|
|
1778
|
+
// BRANCH ROOMS CARRY TEXT ONLY, BY DESIGN (hive-branch.js roomCarries): binaries are filled into
|
|
1779
|
+
// each folder from git, and `pr` reads a carried file missing from the room as DELETED. Binary sync
|
|
1780
|
+
// pushed every git-filled image into the room (tests/branch-test A6: 5 files where the seed put 4),
|
|
1781
|
+
// and once one was deleted there, the next joiner's fill would write it back and binary sync would
|
|
1782
|
+
// publish the undo to everyone. So binary sync is off in a branch-room folder, recognised by the
|
|
1783
|
+
// `.git` FILE hive-branch writes into it (NOT_A_CHECKOUT; not imported, hive-branch imports us).
|
|
1784
|
+
// A binary the room already holds (someone pushed it from a plain folder) is still received.
|
|
1785
|
+
const BINARY_SYNC = !process.env.HIVE_NO_BINARY_SYNC &&
|
|
1786
|
+
!(() => { try { return fs.readFileSync(path.join(ROOT, '.git'), 'utf8').includes('hivecode-live-room-not-a-git-checkout') } catch { return false } })()
|
|
1787
|
+
function isBinaryFile(full, st) {
|
|
1788
|
+
if (!BINARY_SYNC) return false
|
|
1789
|
+
try {
|
|
1790
|
+
const s = st || fs.statSync(full)
|
|
1791
|
+
if (!s.isFile()) return false
|
|
1792
|
+
if (s.size > MAX_BINARY_BYTES) return false
|
|
1793
|
+
const buf = fs.readFileSync(full)
|
|
1794
|
+
return buf.includes(0)
|
|
1795
|
+
} catch { return false }
|
|
1796
|
+
}
|
|
1797
|
+
function checkUnshared(full, st) {
|
|
1798
|
+
try {
|
|
1799
|
+
const s = st || fs.statSync(full)
|
|
1800
|
+
if (!s.isFile()) return null
|
|
1801
|
+
if (s.size > MAX_BINARY_BYTES) {
|
|
1802
|
+
return { reason: 'oversized', size: s.size }
|
|
1803
|
+
}
|
|
1804
|
+
const buf = fs.readFileSync(full)
|
|
1805
|
+
if (buf.includes(0)) {
|
|
1806
|
+
if (!BINARY_SYNC) {
|
|
1807
|
+
return { reason: 'binary', size: s.size }
|
|
1808
|
+
}
|
|
1809
|
+
return null
|
|
1810
|
+
}
|
|
1811
|
+
if (s.size > MAX_BYTES) {
|
|
1812
|
+
return { reason: 'oversized', size: s.size }
|
|
1813
|
+
}
|
|
1814
|
+
return null
|
|
1815
|
+
} catch { return null }
|
|
1816
|
+
}
|
|
1817
|
+
const ensuredDirs = new Set()
|
|
1818
|
+
// A CREDENTIAL INSIDE A FILE STAYS ON THIS MACHINE (3 Oct 2026). A file in a room goes to the relay
|
|
1819
|
+
// and to every participant's disk, so a key an agent pasted into config.js "to try something" went
|
|
1820
|
+
// to all of them. Now a file whose content holds a credential the room does not already have
|
|
1821
|
+
// (hive-secrets.js) is not sent: not its first version, not its edits, and a teammate's edits to it
|
|
1822
|
+
// wait too, because a merge made here would carry the key out with them. The person is told once
|
|
1823
|
+
// which file and what kind of key -- never the key itself -- and the file flows again the moment
|
|
1824
|
+
// the key is out of it. A key already in the room holds nothing back: there is nothing left to keep.
|
|
1825
|
+
const SHARE_SECRETS = process.env.HIVE_SHARE_SECRETS === '1' // an explicit opt-out, never a default
|
|
1826
|
+
const withheld = new Map() // relPath -> { kind, line, said } of the first credential found
|
|
1827
|
+
function heldForSecret(relPath, local, shared) {
|
|
1828
|
+
if (SHARE_SECRETS) return false
|
|
1829
|
+
const found = newSecrets(local, shared)
|
|
1830
|
+
if (!found.length) { if (withheld.has(relPath)) clearWithheld(relPath); return false }
|
|
1831
|
+
const prev = withheld.get(relPath)
|
|
1832
|
+
// Said in the room only for a file the room HAS: there, teammates are waiting on a change that is
|
|
1833
|
+
// not coming. A file the room never had leaves nobody missing anything -- and a repo with a
|
|
1834
|
+
// committed key would otherwise have every teammate who checks it out announce the same file.
|
|
1835
|
+
// The agent is told either way (hive_wait, hive_edit).
|
|
1836
|
+
const said = prev ? prev.said : (!SKIP.has(relPath) && inManifest(relPath))
|
|
1837
|
+
const f = { kind: found[0].kind, line: found[0].line, said }
|
|
1838
|
+
withheld.set(relPath, f)
|
|
1839
|
+
if (!prev) {
|
|
1840
|
+
log(`[${name}] holding ${relPath} on this machine: it contains ${describeSecret(f)}`)
|
|
1841
|
+
if (said) { try { say(`⛔ ${relPath}: ${name}'s change stays on ${name}'s machine, because it contains ${describeSecret(f)} and Hivecode does not send keys to the room. It is shared once the key is moved out of the file (into .env, which is never shared).`) } catch { } }
|
|
1842
|
+
}
|
|
1843
|
+
return true
|
|
1844
|
+
}
|
|
1845
|
+
function clearWithheld(relPath) {
|
|
1846
|
+
const w = withheld.get(relPath)
|
|
1847
|
+
withheld.delete(relPath)
|
|
1848
|
+
log(`[${name}] ${relPath} holds no key now: shared again`)
|
|
1849
|
+
if (w && w.said) { try { say(`· ${relPath} from ${name} is shared again: the key is out of it.`) } catch { } }
|
|
1850
|
+
}
|
|
1851
|
+
// Still on disk: a held file that was since deleted or moved away is no longer anybody's concern.
|
|
1852
|
+
function withheldNow() {
|
|
1853
|
+
return [...withheld].filter(([p]) => fs.existsSync(path.join(ROOT, p))).map(([p, f]) => ({ path: p, kind: f.kind, line: f.line }))
|
|
1854
|
+
}
|
|
1855
|
+
// A ROOM WRITE THAT CANNOT LAND HERE. Measured 3 Oct 2026: a teammate's edit to a file that is
|
|
1856
|
+
// read-only on this disk (a Perforce checkout, a generated file, a handle another program holds)
|
|
1857
|
+
// threw EPERM inside the Yjs observer; y-websocket printed "Caught error while handling a Yjs
|
|
1858
|
+
// update" to stderr, the file stayed at the old version while the room moved on, status() read
|
|
1859
|
+
// all green, and nothing ever tried again. Now the path is recorded, the room is told once that
|
|
1860
|
+
// this copy is behind, status() lists it, and every scan retries (retryUnwritable) until it lands.
|
|
1861
|
+
const unwritable = new Map() // relPath -> error code of the last failed write
|
|
1862
|
+
function noteUnwritable(relPath, e) {
|
|
1863
|
+
const code = (e && e.code) || 'error'
|
|
1864
|
+
const first = !unwritable.has(relPath)
|
|
1865
|
+
unwritable.set(relPath, code)
|
|
1866
|
+
if (!first) return
|
|
1867
|
+
log(`[${name}] could not write ${relPath} (${code}); this folder's copy stays behind the room's until it can be written`)
|
|
1868
|
+
if (SKIP.has(relPath)) return // a rendered control file: it is re-rendered on the next change anyway
|
|
1869
|
+
try { say(`⚠ ${relPath} could not be written on ${name}'s disk (${code}: read-only, locked, or a folder in the way), so ${name}'s copy is older than the room's. It is written as soon as the file can be.`) } catch { }
|
|
1870
|
+
}
|
|
1871
|
+
function clearUnwritable(relPath) {
|
|
1872
|
+
unwritable.delete(relPath)
|
|
1873
|
+
log(`[${name}] ${relPath} written: this folder has the room's copy again`)
|
|
1874
|
+
if (!SKIP.has(relPath)) { try { say(`· ${relPath} is current on ${name}'s disk again.`) } catch { } }
|
|
1875
|
+
}
|
|
1876
|
+
// Through reconcile, not a bare write: if the file was edited here while it could not be written,
|
|
1877
|
+
// that edit is merged with the room's version rather than overwritten by it.
|
|
1878
|
+
function retryUnwritable() {
|
|
1879
|
+
for (const r of [...unwritable.keys()]) {
|
|
1880
|
+
if (SKIP.has(r)) { unwritable.delete(r); continue }
|
|
1881
|
+
const fe = fileDocs.get(r)
|
|
1882
|
+
const disk = readText(path.join(ROOT, r))
|
|
1883
|
+
if (fe && fe.synced && disk !== null && disk === fe.text.toString()) { clearUnwritable(r); continue }
|
|
1884
|
+
try { reconcile(r, 'remote') } catch { }
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
// --- CASE-INSENSITIVE PATH COLLISION GUARD ---
|
|
1888
|
+
// On case-insensitive file systems (Windows/macOS), two room paths that differ only
|
|
1889
|
+
// in case (e.g. Case.js and case.js) map to the exact same file on disk. Writing both
|
|
1890
|
+
// causes alternating disk writes and watcher echo loops that clobber the room doc.
|
|
1891
|
+
// We probe case-sensitivity at startup, keep the key already on disk (or first seen),
|
|
1892
|
+
// never write the collided key to disk, never let a scan push the physical file into
|
|
1893
|
+
// the collided key, report collided paths in status(), and announce once to the room.
|
|
1894
|
+
const isCaseInsensitive = (() => {
|
|
1895
|
+
try {
|
|
1896
|
+
const probeName = `.hive-case-probe-${process.pid}-${Date.now()}.tmp`
|
|
1897
|
+
const probePath = path.join(ROOT, probeName)
|
|
1898
|
+
fs.writeFileSync(probePath, '')
|
|
1899
|
+
const probeLower = probePath.toLowerCase()
|
|
1900
|
+
const probeTarget = probePath === probeLower ? probePath.toUpperCase() : probeLower
|
|
1901
|
+
const insensitive = fs.existsSync(probeTarget)
|
|
1902
|
+
try { fs.unlinkSync(probePath) } catch { }
|
|
1903
|
+
return insensitive
|
|
1904
|
+
} catch {
|
|
1905
|
+
return process.platform === 'win32' || process.platform === 'darwin'
|
|
1906
|
+
}
|
|
1907
|
+
})()
|
|
1908
|
+
const fold = (r) => isCaseInsensitive ? r.toLowerCase() : r
|
|
1909
|
+
|
|
1910
|
+
const canonicalCaseKeys = new Map() // fold(r) -> canonical relPath
|
|
1911
|
+
const collided = new Map() // collided relPath -> { with: canonical relPath }
|
|
1912
|
+
const collidedWarned = new Set() // relPath announced to room
|
|
1913
|
+
const collidedTimers = new Map() // relPath -> Timeout (debounced room announcement)
|
|
1914
|
+
|
|
1915
|
+
function clearCollidedTimer(r) {
|
|
1916
|
+
if (collidedTimers.has(r)) {
|
|
1917
|
+
clearTimeout(collidedTimers.get(r))
|
|
1918
|
+
collidedTimers.delete(r)
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function actualDiskPath(relPath) {
|
|
1923
|
+
if (!isCaseInsensitive) return fs.existsSync(path.join(ROOT, relPath)) ? relPath : null
|
|
1924
|
+
const full = path.join(ROOT, relPath)
|
|
1925
|
+
if (!fs.existsSync(full)) return null
|
|
1926
|
+
try {
|
|
1927
|
+
const parts = relPath.replace(/\\/g, '/').split('/').filter(Boolean)
|
|
1928
|
+
let cur = ROOT
|
|
1929
|
+
const matched = []
|
|
1930
|
+
for (const part of parts) {
|
|
1931
|
+
const lower = part.toLowerCase()
|
|
1932
|
+
const entries = fs.readdirSync(cur)
|
|
1933
|
+
const hit = entries.find(e => e.toLowerCase() === lower)
|
|
1934
|
+
if (!hit) return relPath
|
|
1935
|
+
matched.push(hit)
|
|
1936
|
+
cur = path.join(cur, hit)
|
|
1937
|
+
}
|
|
1938
|
+
return matched.join('/')
|
|
1939
|
+
} catch {
|
|
1940
|
+
return relPath
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function fileExistsExact(relPath) {
|
|
1945
|
+
if (!isCaseInsensitive) return fs.existsSync(path.join(ROOT, relPath))
|
|
1946
|
+
return actualDiskPath(relPath) === relPath
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
function ensureExactDiskCasing(relPath) {
|
|
1950
|
+
if (!isCaseInsensitive) return
|
|
1951
|
+
const onDisk = actualDiskPath(relPath)
|
|
1952
|
+
if (onDisk && onDisk !== relPath && fold(onDisk) === fold(relPath)) {
|
|
1953
|
+
const full = path.join(ROOT, relPath)
|
|
1954
|
+
const onDiskFull = path.join(ROOT, onDisk)
|
|
1955
|
+
const dir = path.dirname(full)
|
|
1956
|
+
const hop = path.join(dir, `.hive-case-hop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
|
|
1957
|
+
try {
|
|
1958
|
+
fs.renameSync(onDiskFull, hop)
|
|
1959
|
+
try {
|
|
1960
|
+
fs.renameSync(hop, full)
|
|
1961
|
+
} catch {
|
|
1962
|
+
// If second rename failed, attempt to rename the hop back to onDiskFull
|
|
1963
|
+
try { fs.renameSync(hop, onDiskFull) } catch { }
|
|
1964
|
+
}
|
|
1965
|
+
} catch { }
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function checkCaseCollision(r) {
|
|
1970
|
+
if (!isCaseInsensitive) return false
|
|
1971
|
+
const cf = fold(r)
|
|
1972
|
+
const canon = canonicalCaseKeys.get(cf)
|
|
1973
|
+
if (canon === r) return false
|
|
1974
|
+
|
|
1975
|
+
const onDisk = actualDiskPath(r)
|
|
1976
|
+
|
|
1977
|
+
if (!canon) {
|
|
1978
|
+
if (onDisk) {
|
|
1979
|
+
canonicalCaseKeys.set(cf, onDisk)
|
|
1980
|
+
if (onDisk !== r) {
|
|
1981
|
+
collided.set(r, { with: onDisk })
|
|
1982
|
+
noteCollided(r, onDisk)
|
|
1983
|
+
return true
|
|
1984
|
+
}
|
|
1985
|
+
return false
|
|
1986
|
+
}
|
|
1987
|
+
canonicalCaseKeys.set(cf, r)
|
|
1988
|
+
return false
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// We have a difference between `canon` and `r` for the same folded name.
|
|
1992
|
+
if (onDisk === r && onDisk !== canon) {
|
|
1993
|
+
canonicalCaseKeys.set(cf, r)
|
|
1994
|
+
collided.delete(r)
|
|
1995
|
+
collidedWarned.delete(r)
|
|
1996
|
+
clearCollidedTimer(r)
|
|
1997
|
+
|
|
1998
|
+
// If `canon` is absent on disk with exact case, it was locally renamed to `r`
|
|
1999
|
+
if (actualDiskPath(canon) !== canon) {
|
|
2000
|
+
collided.delete(canon)
|
|
2001
|
+
collidedWarned.delete(canon)
|
|
2002
|
+
clearCollidedTimer(canon)
|
|
2003
|
+
if (!inManifest(r)) {
|
|
2004
|
+
queueManifestSet(r)
|
|
2005
|
+
}
|
|
2006
|
+
noteLocalDelete(canon)
|
|
2007
|
+
return false
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
collided.set(canon, { with: r })
|
|
2011
|
+
noteCollided(canon, r)
|
|
2012
|
+
return false
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
collided.set(r, { with: canon })
|
|
2016
|
+
noteCollided(r, canon)
|
|
2017
|
+
return true
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
function noteCollided(r, withKey) {
|
|
2021
|
+
if (collidedWarned.has(r)) return
|
|
2022
|
+
log(`[${name}] ${withKey} and ${r} both exist in the room; disk cannot hold both, so ${r} is not written here`)
|
|
2023
|
+
if (SKIP.has(r)) return
|
|
2024
|
+
if (collidedTimers.has(r)) return
|
|
2025
|
+
collidedTimers.set(r, setTimeout(() => {
|
|
2026
|
+
collidedTimers.delete(r)
|
|
2027
|
+
if (!collided.has(r) || collidedWarned.has(r)) return
|
|
2028
|
+
if (!inManifest(r) || !inManifest(withKey)) return
|
|
2029
|
+
collidedWarned.add(r)
|
|
2030
|
+
try {
|
|
2031
|
+
say(`${withKey} and ${r} both exist in the room; ${name}'s disk cannot hold both, so ${r} is not written there.`)
|
|
2032
|
+
} catch { }
|
|
2033
|
+
}, 2500))
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1568
2036
|
function writeToDisk(relPath, content) {
|
|
2037
|
+
if (collided.has(relPath)) return
|
|
1569
2038
|
if (!isSafeRelPath(relPath)) return // never write a path that could escape ROOT (defense in depth)
|
|
2039
|
+
// The mkdir below would recreate a lost folder holding one file, and the next scan would read
|
|
2040
|
+
// every other file as deleted -- the exact wipe loseFolder exists to prevent.
|
|
2041
|
+
if (folderLost) return
|
|
2042
|
+
ensureExactDiskCasing(relPath)
|
|
1570
2043
|
const full = path.join(ROOT, relPath)
|
|
1571
|
-
|
|
1572
|
-
|
|
2044
|
+
try {
|
|
2045
|
+
// Only for a path a scan already found unshared. Every remote edit lands through here, so
|
|
2046
|
+
// checking every file would read each one in full before writing it -- double the disk work
|
|
2047
|
+
// on the hottest path, to guard a case the scan has already seen.
|
|
2048
|
+
if ((unsharedMtimes.has(relPath) || unsharedWarned.has(relPath)) && fs.existsSync(full)) {
|
|
2049
|
+
const unshared = checkUnshared(full)
|
|
2050
|
+
if (unshared) {
|
|
2051
|
+
const stamp = deletedStamp()
|
|
2052
|
+
const keptAs = keepDeletedCopy(relPath, stamp)
|
|
2053
|
+
if (keptAs) {
|
|
2054
|
+
log(`[${name}] local unshared file ${relPath} overwritten by room write; copy preserved in ${keptAs}`)
|
|
2055
|
+
try {
|
|
2056
|
+
say(`· ${relPath} on ${name}'s disk was ${unshared.reason} and was overwritten by a room write — local copy preserved in ${keptAs}.`)
|
|
2057
|
+
} catch { }
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
} catch { }
|
|
2062
|
+
const dir = path.dirname(full)
|
|
2063
|
+
try {
|
|
2064
|
+
if (!ensuredDirs.has(dir)) {
|
|
2065
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
2066
|
+
ensuredDirs.add(dir)
|
|
2067
|
+
}
|
|
2068
|
+
try { fs.writeFileSync(full, content) } catch (e) {
|
|
2069
|
+
// The cache only skips mkdir; it must never lose a write. A folder someone deleted or renamed
|
|
2070
|
+
// since we made it is simply made again.
|
|
2071
|
+
if (e.code !== 'ENOENT') throw e
|
|
2072
|
+
ensuredDirs.delete(dir)
|
|
2073
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
2074
|
+
ensuredDirs.add(dir)
|
|
2075
|
+
fs.writeFileSync(full, content)
|
|
2076
|
+
}
|
|
2077
|
+
} catch (e) {
|
|
2078
|
+
// The disk still holds the OLD content. Callers record the new content as this file's base and
|
|
2079
|
+
// shadow -- some before writing, some after -- and a base the disk does not hold makes the stale
|
|
2080
|
+
// copy read as a local edit, merged back over the room: measured, a teammate's change reverted
|
|
2081
|
+
// for everyone. So the bookkeeping goes back to what the disk really holds, and the error still
|
|
2082
|
+
// propagates, so no caller goes on to record this write as done.
|
|
2083
|
+
const onDisk = readText(full)
|
|
2084
|
+
if (onDisk !== null) { bases.set(relPath, onDisk); forkBases.set(relPath, onDisk); shadow.set(relPath, onDisk); saveShadowSoon() }
|
|
2085
|
+
noteUnwritable(relPath, e)
|
|
2086
|
+
throw e
|
|
2087
|
+
}
|
|
2088
|
+
if (unwritable.has(relPath)) clearUnwritable(relPath)
|
|
1573
2089
|
shadow.set(relPath, content) // record what THE ENGINE wrote — any later disk divergence is an authored edit
|
|
1574
2090
|
saveShadowSoon() // ...and survive a restart, or the next join has no ancestor
|
|
1575
2091
|
known.add(relPath)
|
|
@@ -1590,6 +2106,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1590
2106
|
for (const [k, v] of Object.entries(o.files || {})) {
|
|
1591
2107
|
if (typeof k === 'string' && typeof v === 'string' && isSafeRelPath(k)) priorShadow.set(k, v)
|
|
1592
2108
|
}
|
|
2109
|
+
// Carried forward, not just read: an edit re-sent after a rejoin can be lost the same way
|
|
2110
|
+
// again, and the next rejoin needs the same base.
|
|
2111
|
+
for (const [k, v] of Object.entries(o.from || {})) {
|
|
2112
|
+
const hist = (Array.isArray(v) ? v : [v]).filter((s) => typeof s === 'string').slice(-FROM_KEEP)
|
|
2113
|
+
if (typeof k === 'string' && hist.length && isSafeRelPath(k)) { priorFrom.set(k, hist); capturedFrom.set(k, hist.slice()) }
|
|
2114
|
+
}
|
|
1593
2115
|
log(`[hive] recovered ${priorShadow.size} file ancestor(s) from the previous session`)
|
|
1594
2116
|
} catch { /* absent or unreadable: no ancestor, same as before this existed */ }
|
|
1595
2117
|
}
|
|
@@ -1600,15 +2122,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1600
2122
|
if (shadowTimer.unref) shadowTimer.unref() // never hold the process open just to save this
|
|
1601
2123
|
}
|
|
1602
2124
|
function saveShadow() {
|
|
1603
|
-
const files = {}
|
|
1604
|
-
// Only what we could actually merge against later. A file over MAX_BYTES is not synced
|
|
1605
|
-
// in the first place, so storing it would inflate this file for no benefit.
|
|
1606
|
-
for (const [k, v] of shadow) if (typeof v === 'string' && v.length <= MAX_BYTES) files[k] = v
|
|
1607
2125
|
const tmp = path.join(ROOT, SHADOW_FILE + '.tmp')
|
|
1608
2126
|
try {
|
|
1609
|
-
//
|
|
1610
|
-
//
|
|
1611
|
-
|
|
2127
|
+
// Assemble v:1 JSON from cached fragments: avoids walking/stringifying all files
|
|
2128
|
+
// on every debounced flush. The resulting payload is byte-for-byte valid JSON matching
|
|
2129
|
+
// { v: 1, room, files }, preserving the atomic write-then-rename.
|
|
2130
|
+
const parts = []
|
|
2131
|
+
for (const frag of shadowFragments.values()) parts.push(frag)
|
|
2132
|
+
const from = []
|
|
2133
|
+
for (const [k, v] of capturedFrom) {
|
|
2134
|
+
const hist = v.filter((s) => typeof s === 'string' && s.length <= MAX_BYTES)
|
|
2135
|
+
if (hist.length) from.push(JSON.stringify(k) + ':' + JSON.stringify(hist))
|
|
2136
|
+
}
|
|
2137
|
+
const payload = `{"v":1,"room":${JSON.stringify(room)},"files":{${parts.join(',')}},"from":{${from.join(',')}}}`
|
|
2138
|
+
fs.writeFileSync(tmp, payload)
|
|
1612
2139
|
fs.renameSync(tmp, path.join(ROOT, SHADOW_FILE))
|
|
1613
2140
|
} catch (e) {
|
|
1614
2141
|
try { fs.unlinkSync(tmp) } catch { }
|
|
@@ -1618,13 +2145,229 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1618
2145
|
|
|
1619
2146
|
const conflicted = new Set() // files currently holding unresolved <<<<<<< markers
|
|
1620
2147
|
const icrWarned = new Map() // relPath -> last ICR semantic warning announced (dedupe)
|
|
2148
|
+
// HIV-P17: a creation race of CODE that cannot merge lands NOTHING. The doc keeps roomText, the disk
|
|
2149
|
+
// keeps the local draft (also saved as a manual snapshot, so it survives whatever happens next), and
|
|
2150
|
+
// the bases point at roomText, so the next normal edit merges against the room's copy. One function
|
|
2151
|
+
// for all three race paths -- first contact, a dead heat, and a dead heat the peer already resolved
|
|
2152
|
+
// -- so their recovery and their announcement cannot drift apart.
|
|
2153
|
+
// An edit the room never received, and we no longer know what it was made against: re-sending
|
|
2154
|
+
// it as a diff over the room's copy could revert someone else's change, so it is not sent. It is
|
|
2155
|
+
// kept on disk and in the file's history, and said out loud, because the alternative was silence.
|
|
2156
|
+
// Not "save it again": the base is now the disk, so an unchanged save is a no-op and sends nothing
|
|
2157
|
+
// (Antigravity, 1 Nov). hive_edit writes the copy into the room outright. Both copies go into the
|
|
2158
|
+
// file's history so the two can be compared before choosing.
|
|
2159
|
+
function keepUnsent(relPath, disk, roomText) {
|
|
2160
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local edit that never reached the room (kept)' })
|
|
2161
|
+
if (typeof roomText === 'string' && roomText !== disk) captureSnapshot(relPath, roomText, name, { force: true, kind: 'manual', label: 'room copy when the local edit was kept' })
|
|
2162
|
+
bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
2163
|
+
let mt = 0; try { mt = fs.statSync(path.join(ROOT, relPath)).mtimeMs } catch { }
|
|
2164
|
+
keptUnsent.set(relPath, mt)
|
|
2165
|
+
try {
|
|
2166
|
+
say(`⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
|
|
2167
|
+
`The room's copy was NOT applied over it: it is kept on disk, and both copies are in the file's history. Nothing more syncs for this file until ` +
|
|
2168
|
+
`${name} saves it again; that save sends it as it is, replacing the room's copy. Claim ${relPath} first.`)
|
|
2169
|
+
} catch { }
|
|
2170
|
+
}
|
|
2171
|
+
function refuseCreationRace(relPath, why, disk, roomText) {
|
|
2172
|
+
conflicted.add(relPath)
|
|
2173
|
+
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local draft preserved (creation-race refused)' })
|
|
2174
|
+
bases.set(relPath, roomText); forkBases.set(relPath, roomText); shadow.set(relPath, roomText)
|
|
2175
|
+
if (!inManifest(relPath)) queueManifestSet(relPath)
|
|
2176
|
+
if (icrWarned.get(relPath) === why) return
|
|
2177
|
+
icrWarned.set(relPath, why)
|
|
2178
|
+
const conflict = why === 'CONFLICT'
|
|
2179
|
+
log(`[${name}] !! HIV-P17 REFUSED a ${conflict ? 'conflicting' : 'fused'} creation-race merge of ${relPath}${conflict ? '' : `: ${why}`}`)
|
|
2180
|
+
try {
|
|
2181
|
+
say(conflict
|
|
2182
|
+
? `[!] @all ${relPath}: a creation-race merge was REFUSED — two participants created it with conflicting content, and landing both whole files in the room's copy would fuse them. Nothing was written: the room's copy and ${name}'s local copy are both intact. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`
|
|
2183
|
+
: `[!] @all ${relPath}: a creation-race merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
|
|
2184
|
+
} catch { /* the log still carries it */ }
|
|
2185
|
+
}
|
|
2186
|
+
// ONE MESSAGE PER JOIN, IN PLAIN WORDS (onboarding audit, 2 Oct 2026). HIV-P9's outcomes (in
|
|
2187
|
+
// reconcile, below) are right, but each file used to be announced on its own chat line in engine
|
|
2188
|
+
// words ("UNPROVEN", "Resolve deliberately"), so a teammate a few commits behind filled the chat,
|
|
2189
|
+
// and a Windows checkout (CRLF, against a room seeded from git's LF) made nearly every text file
|
|
2190
|
+
// "differ". The outcomes are unchanged. They are collected until the join goes quiet and said
|
|
2191
|
+
// once; the per-file log lines stay. Line endings alone are not worth a chat line: when nothing
|
|
2192
|
+
// else differs, nothing is said. The window slides, so one join of a big repo stays one message.
|
|
2193
|
+
const firstContact = { eol: [], adopted: [], kept: [], timer: null }
|
|
2194
|
+
function noteFirstContact(kind, relPath) {
|
|
2195
|
+
firstContact[kind].push(relPath)
|
|
2196
|
+
if (firstContact.timer) clearTimeout(firstContact.timer)
|
|
2197
|
+
firstContact.timer = setTimeout(flushFirstContact, 3000)
|
|
2198
|
+
}
|
|
2199
|
+
function flushFirstContact() {
|
|
2200
|
+
if (firstContact.timer) { clearTimeout(firstContact.timer); firstContact.timer = null }
|
|
2201
|
+
const { eol, adopted, kept } = firstContact
|
|
2202
|
+
firstContact.eol = []; firstContact.adopted = []; firstContact.kept = []
|
|
2203
|
+
if (!adopted.length && !kept.length) return
|
|
2204
|
+
const total = eol.length + adopted.length + kept.length
|
|
2205
|
+
const names = (l) => l.slice(0, 5).join(', ') + (l.length > 5 ? ` and ${l.length - 5} more` : '')
|
|
2206
|
+
const parts = [`↓ ${name} joined with ${total === 1 ? '1 file that differs' : `${total} files that differ`} from the room's copy.`]
|
|
2207
|
+
if (eol.length) parts.push(`${eol.length} differed only in line endings and now match the room.`)
|
|
2208
|
+
if (adopted.length) {
|
|
2209
|
+
parts.push(`${adopted.length === 1 ? '1 was an older version' : `${adopted.length} were older versions`} the room had already moved past, so ${name} now has the room's copy (the old one is in the file's history): ${names(adopted)}.`)
|
|
2210
|
+
}
|
|
2211
|
+
if (kept.length) {
|
|
2212
|
+
const one = kept.length === 1
|
|
2213
|
+
parts.push(`${one ? '1 holds' : `${kept.length} hold`} content the room has never seen, so neither copy was changed and the room's copy was NOT adopted: ${names(kept)}. ` +
|
|
2214
|
+
`In ${name}'s folder ${one ? 'it still holds' : 'they still hold'} ${name}'s version. To make one the room's version, claim it and hive_edit it with the full file. ` +
|
|
2215
|
+
`Don't delete a local copy to get the room's: a deletion syncs, and removes the file for everyone.`)
|
|
2216
|
+
}
|
|
2217
|
+
try { say(parts.join(' ')) } catch { }
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
function reconcileBinary(relPath, origin = 'local') {
|
|
2221
|
+
if (folderLost) return
|
|
2222
|
+
if (SKIP.has(relPath) || isIgnored(relPath) || !canOpen(relPath)) return
|
|
2223
|
+
if (collided.has(relPath)) return
|
|
2224
|
+
const full = path.join(ROOT, relPath)
|
|
2225
|
+
const exists = !isCaseInsensitive ? fs.existsSync(full) : actualDiskPath(relPath) === relPath
|
|
2226
|
+
let fe = fileDocs.get(relPath)
|
|
2227
|
+
if (!fe) {
|
|
2228
|
+
if (!exists) return
|
|
2229
|
+
openFile(relPath)
|
|
2230
|
+
fe = fileDocs.get(relPath)
|
|
2231
|
+
if (!fe) return
|
|
2232
|
+
}
|
|
2233
|
+
if (!fe.synced && origin !== 'local') return
|
|
2234
|
+
touchFile(relPath)
|
|
2235
|
+
|
|
2236
|
+
if (origin === 'local') {
|
|
2237
|
+
if (!canWrite(relPath)) {
|
|
2238
|
+
if (fe && fe.synced && fe.bin.has('data')) {
|
|
2239
|
+
const remoteData = fe.bin.get('data')
|
|
2240
|
+
const remoteSha = fe.bin.get('sha')
|
|
2241
|
+
if (remoteData && remoteSha) {
|
|
2242
|
+
let localSha = null
|
|
2243
|
+
if (exists) {
|
|
2244
|
+
try {
|
|
2245
|
+
const localBuf = fs.readFileSync(full)
|
|
2246
|
+
localSha = crypto.createHash('sha256').update(localBuf).digest('hex')
|
|
2247
|
+
} catch { }
|
|
2248
|
+
}
|
|
2249
|
+
if (localSha !== remoteSha) {
|
|
2250
|
+
try {
|
|
2251
|
+
fs.writeFileSync(full, Buffer.from(remoteData))
|
|
2252
|
+
shaShadow.set(relPath, remoteSha)
|
|
2253
|
+
mtimes.set(relPath, fs.statSync(full).mtimeMs)
|
|
2254
|
+
} catch { }
|
|
2255
|
+
}
|
|
2256
|
+
} else {
|
|
2257
|
+
ensureExactDiskCasing(relPath)
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return
|
|
2261
|
+
}
|
|
2262
|
+
if (!exists) return
|
|
2263
|
+
let st
|
|
2264
|
+
try { st = fs.statSync(full) } catch { return }
|
|
2265
|
+
if (!st.isFile()) return
|
|
2266
|
+
if (st.size > MAX_BINARY_BYTES) {
|
|
2267
|
+
noticeUnsharedOne(relPath, { reason: 'oversized', size: st.size })
|
|
2268
|
+
return
|
|
2269
|
+
}
|
|
2270
|
+
let buf
|
|
2271
|
+
try { buf = fs.readFileSync(full) } catch { return }
|
|
2272
|
+
const sha = crypto.createHash('sha256').update(buf).digest('hex')
|
|
2273
|
+
if (shaShadow.get(relPath) === sha) return // echo suppression
|
|
2274
|
+
shaShadow.set(relPath, sha)
|
|
2275
|
+
mtimes.set(relPath, st.mtimeMs)
|
|
2276
|
+
|
|
2277
|
+
fe.doc.transact(() => {
|
|
2278
|
+
fe.bin.set('data', new Uint8Array(buf))
|
|
2279
|
+
fe.bin.set('sha', sha)
|
|
2280
|
+
fe.bin.set('size', buf.length)
|
|
2281
|
+
fe.bin.set('by', name)
|
|
2282
|
+
fe.bin.set('at', Date.now())
|
|
2283
|
+
if (fe.text.length > 0) fe.text.delete(0, fe.text.length)
|
|
2284
|
+
})
|
|
2285
|
+
if (!inManifest(relPath)) manifest.set(relPath, 1)
|
|
2286
|
+
known.add(relPath)
|
|
2287
|
+
dischargeUnshared(relPath)
|
|
2288
|
+
return
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// origin === 'remote'
|
|
2292
|
+
if (!fe.bin.has('data')) return
|
|
2293
|
+
const remoteData = fe.bin.get('data')
|
|
2294
|
+
const remoteSha = fe.bin.get('sha')
|
|
2295
|
+
const remoteBy = fe.bin.get('by')
|
|
2296
|
+
if (!remoteData || !remoteSha) return
|
|
2297
|
+
|
|
2298
|
+
let localSha = null
|
|
2299
|
+
if (exists) {
|
|
2300
|
+
try {
|
|
2301
|
+
const localBuf = fs.readFileSync(full)
|
|
2302
|
+
localSha = crypto.createHash('sha256').update(localBuf).digest('hex')
|
|
2303
|
+
} catch { }
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
if (localSha === remoteSha) {
|
|
2307
|
+
shaShadow.set(relPath, remoteSha)
|
|
2308
|
+
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
2309
|
+
known.add(relPath)
|
|
2310
|
+
return
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
let keptAs = null
|
|
2314
|
+
if (exists) {
|
|
2315
|
+
const stamp = deletedStamp()
|
|
2316
|
+
keptAs = keepDeletedCopy(relPath, stamp)
|
|
2317
|
+
if (keptAs) {
|
|
2318
|
+
log(`[${name}] local binary ${relPath} overwritten by remote write (winner: ${remoteBy || 'teammate'}); copy preserved in ${keptAs}`)
|
|
2319
|
+
try {
|
|
2320
|
+
say(`· ${relPath} updated from room (winner: ${remoteBy || 'teammate'}). Your local copy saved to ${keptAs}.`)
|
|
2321
|
+
} catch { }
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
try {
|
|
2326
|
+
fs.mkdirSync(path.dirname(full), { recursive: true })
|
|
2327
|
+
fs.writeFileSync(full, Buffer.from(remoteData))
|
|
2328
|
+
shaShadow.set(relPath, remoteSha)
|
|
2329
|
+
mtimes.set(relPath, fs.statSync(full).mtimeMs)
|
|
2330
|
+
known.add(relPath)
|
|
2331
|
+
dischargeUnshared(relPath)
|
|
2332
|
+
log(`[${name}] <- binary ${relPath} (${remoteData.length} bytes${keptAs ? `, previous saved to ${keptAs}` : ''})`)
|
|
2333
|
+
} catch (err) {
|
|
2334
|
+
log(`[${name}] error writing binary ${relPath}: ${err.message}`)
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
1621
2338
|
function reconcile(relPath, origin = 'local') {
|
|
2339
|
+
if (process.env.HIVE_TEST_STUCK_SYNC && relPath === process.env.HIVE_TEST_STUCK_SYNC) return
|
|
1622
2340
|
if (SKIP.has(relPath) || isIgnored(relPath)) return // never sync secrets/ignored files
|
|
1623
2341
|
if (!canOpen(relPath)) return // out-of-scope OR unsafe path (traversal/control char) — never materialize it
|
|
2342
|
+
if (collided.has(relPath)) {
|
|
2343
|
+
if (origin === 'local') return // never push physical file into collided key
|
|
2344
|
+
const fe = fileDocs.get(relPath)
|
|
2345
|
+
if (fe && fe.synced) {
|
|
2346
|
+
const dt = fe.text.toString()
|
|
2347
|
+
bases.set(relPath, dt)
|
|
2348
|
+
forkBases.set(relPath, dt)
|
|
2349
|
+
}
|
|
2350
|
+
return
|
|
2351
|
+
}
|
|
1624
2352
|
const full = path.join(ROOT, relPath)
|
|
1625
|
-
const exists = fs.existsSync(full)
|
|
2353
|
+
const exists = !isCaseInsensitive ? fs.existsSync(full) : actualDiskPath(relPath) === relPath
|
|
2354
|
+
let fe = fileDocs.get(relPath)
|
|
2355
|
+
if (fe && fe.bin && fe.bin.has('data')) {
|
|
2356
|
+
reconcileBinary(relPath, origin)
|
|
2357
|
+
return
|
|
2358
|
+
}
|
|
1626
2359
|
const disk = exists ? readText(full) : null
|
|
1627
|
-
if (exists && disk === null)
|
|
2360
|
+
if (exists && disk === null) {
|
|
2361
|
+
if (isBinaryFile(full)) {
|
|
2362
|
+
if (origin === 'local') {
|
|
2363
|
+
reconcileBinary(relPath, 'local')
|
|
2364
|
+
return
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
const unshared = checkUnshared(full)
|
|
2368
|
+
if (unshared) noticeUnsharedOne(relPath, unshared)
|
|
2369
|
+
return // present on disk but binary/too-large to represent — leave it untouched (never clobber with stale doc text)
|
|
2370
|
+
}
|
|
1628
2371
|
// View-only file: never PUSH our local change/creation (the relay would drop it
|
|
1629
2372
|
// anyway — it connects this file-room as a reader). Keep disk in sync with the
|
|
1630
2373
|
// shared copy if we already have it; a brand-new local-only file just stays local
|
|
@@ -1639,9 +2382,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1639
2382
|
// "empty file" — acting now would re-seed from disk and fork the CRDT history
|
|
1640
2383
|
// (duplicated/garbled content on rejoin). So: open if needed, then defer until
|
|
1641
2384
|
// the 'sync' handler re-runs us. Once synced, "" genuinely means empty.
|
|
1642
|
-
|
|
2385
|
+
fe = fileDocs.get(relPath)
|
|
1643
2386
|
if (!fe) { if (disk === null) return; openFile(relPath); return }
|
|
1644
2387
|
if (!fe.synced) return
|
|
2388
|
+
// A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
|
|
2389
|
+
// room in either direction would decide something nobody decided: the room's next change would
|
|
2390
|
+
// be written over it, and an unchanged save merged against itself pulled the room's old copy over
|
|
2391
|
+
// it (antigravity-3, 1 Nov). The next save is the decision: it sends the file as it is.
|
|
2392
|
+
if (keptUnsent.has(relPath)) {
|
|
2393
|
+
if (origin !== 'local') return
|
|
2394
|
+
let mt = 0; try { mt = fs.statSync(full).mtimeMs } catch { }
|
|
2395
|
+
if (mt === keptUnsent.get(relPath)) return
|
|
2396
|
+
keptUnsent.delete(relPath)
|
|
2397
|
+
const rt = fe.text.toString()
|
|
2398
|
+
captureSnapshot(relPath, rt, name, { force: true, kind: 'manual', label: 'room copy replaced by a kept edit' })
|
|
2399
|
+
bases.set(relPath, rt); forkBases.set(relPath, rt); shadow.set(relPath, rt)
|
|
2400
|
+
try { say(`↻ ${relPath}: ${name} saved the kept copy again, so it now replaces the room's copy (the room's copy is in the file's history).`) } catch { }
|
|
2401
|
+
// fall through: merged against the room's copy, ours goes out whole and signed like any edit
|
|
2402
|
+
}
|
|
2403
|
+
// Before anything below can publish this disk's content, merge it, or stamp it into the ledger:
|
|
2404
|
+
// a key on disk that the room does not have holds the whole file here (heldForSecret).
|
|
2405
|
+
{
|
|
2406
|
+
const shared0 = fe.text.toString()
|
|
2407
|
+
if (disk !== null && disk !== shared0) { if (heldForSecret(relPath, disk, shared0)) return }
|
|
2408
|
+
else if (withheld.has(relPath)) clearWithheld(relPath)
|
|
2409
|
+
}
|
|
1645
2410
|
touchFile(relPath) // reconciling a file IS using it — keep it in the working set
|
|
1646
2411
|
// SOURCE CAPTURE (fork gate): before ANYTHING in this pass can merge over or render into
|
|
1647
2412
|
// this file, record any authored edit sitting on disk. The edit enters the provenance
|
|
@@ -1711,22 +2476,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1711
2476
|
`priorShadow=${priorShadow.has(relPath)} inManifest=${manifest.has(relPath)} ` +
|
|
1712
2477
|
`preexisting=${preexisting.has(relPath)}`)
|
|
1713
2478
|
}
|
|
1714
|
-
if (docText === '' && (!
|
|
2479
|
+
if (docText === '' && (!inManifest(relPath) || (disk && noHistory))) {
|
|
1715
2480
|
// the relay has no usable copy of this path -> publish ours (a genuinely new file,
|
|
1716
2481
|
// or one whose registration outlived the join that promised it)
|
|
1717
2482
|
if (disk === null) return
|
|
1718
|
-
|
|
1719
|
-
|
|
2483
|
+
const now = Date.now()
|
|
2484
|
+
createdTimes.set(relPath, now)
|
|
2485
|
+
genesisPublished.add(relPath)
|
|
2486
|
+
fe.doc.transact(() => {
|
|
2487
|
+
applyDiff(fe.text, disk)
|
|
2488
|
+
if (fe.head && !fe.head.has('genesis')) fe.head.set('genesis', { at: now, by: name })
|
|
2489
|
+
})
|
|
1720
2490
|
stampProvenance(relPath, '', disk) // genesis: sign the file's first content (base = empty)
|
|
1721
|
-
|
|
2491
|
+
queueManifestSet(relPath)
|
|
1722
2492
|
known.add(relPath); bases.set(relPath, disk); forkBases.set(relPath, disk); shadow.set(relPath, disk)
|
|
1723
2493
|
try { mtimes.set(relPath, fs.statSync(full).mtimeMs) } catch { }
|
|
1724
|
-
captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'created' }); snappedBase.add(relPath)
|
|
1725
2494
|
return
|
|
1726
2495
|
}
|
|
1727
2496
|
const yt = fe.text
|
|
1728
|
-
if (disk === null) {
|
|
2497
|
+
if (disk === null) {
|
|
2498
|
+
if (!inManifest(relPath)) return
|
|
2499
|
+
writeToDisk(relPath, docText); bases.set(relPath, docText); forkBases.set(relPath, docText); return
|
|
2500
|
+
}
|
|
1729
2501
|
if (disk === docText) {
|
|
2502
|
+
ensureExactDiskCasing(relPath)
|
|
1730
2503
|
// CREATING a file is editing it. This branch is where a locally-authored NEW file
|
|
1731
2504
|
// lands: scan() finds it, openFile() syncs the content up, and by the time reconcile
|
|
1732
2505
|
// runs disk and doc already agree -- so it returns below, before the markEditing() at
|
|
@@ -1743,7 +2516,6 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1743
2516
|
// THIS author's fork on first sight or our own local activity (a remote
|
|
1744
2517
|
// change landing on disk doesn't mean the local author has seen it).
|
|
1745
2518
|
if (!forkBases.has(relPath) || origin === 'local') forkBases.set(relPath, disk)
|
|
1746
|
-
if (!snappedBase.has(relPath) && disk) { captureSnapshot(relPath, disk, name, { force: true, kind: 'base', label: 'baseline' }); snappedBase.add(relPath) }
|
|
1747
2519
|
return
|
|
1748
2520
|
}
|
|
1749
2521
|
// FIRST-CONTACT ADOPT (no common ancestor). We have never reconciled this file
|
|
@@ -1843,23 +2615,19 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1843
2615
|
// rewrites of the same module and nobody was told. Now we run the same check:
|
|
1844
2616
|
// if both inputs parsed but the merged result does not, or if the merge duplicated
|
|
1845
2617
|
// structure, refuse it — keep both sides intact and announce loudly.
|
|
1846
|
-
|
|
1847
|
-
|
|
2618
|
+
//
|
|
2619
|
+
// A CONFLICTED race is refused the same way (27 Sep 2026). Two different whole files at one
|
|
2620
|
+
// point used to reach this check "clean": the old one-range merge3 treated two insertions
|
|
2621
|
+
// at the same place as disjoint and CONCATENATED them, and fusionReason caught the result.
|
|
2622
|
+
// The real diff3 (d487e0e) reports them as the conflict they are -- and the lines below
|
|
2623
|
+
// would then have landed both whole files, with markers, in the room's copy. HIV-P17's
|
|
2624
|
+
// answer to a creation race of CODE that cannot merge is to land nothing; that holds
|
|
2625
|
+
// either way. Same scope as the parse check: a file with no language (Markdown) is not
|
|
2626
|
+
// refused, and keeps both versions visibly, with markers, as any other conflict does.
|
|
2627
|
+
{
|
|
2628
|
+
const why = creationRaceVerdict(r0, disk, docText, relPath)
|
|
1848
2629
|
if (why) {
|
|
1849
|
-
|
|
1850
|
-
// Do NOT write the fused result to doc or disk. The doc already holds docText
|
|
1851
|
-
// (the room's copy) and disk holds the local draft — both are intact. Setting
|
|
1852
|
-
// bases to docText means the next normal edit will merge against the room's copy,
|
|
1853
|
-
// which is the same recovery path the normal-merge refusal uses.
|
|
1854
|
-
bases.set(relPath, docText); forkBases.set(relPath, docText); shadow.set(relPath, docText)
|
|
1855
|
-
if (!manifest.has(relPath)) manifest.set(relPath, 1)
|
|
1856
|
-
if (icrWarned.get(relPath) !== why) {
|
|
1857
|
-
icrWarned.set(relPath, why)
|
|
1858
|
-
log(`[${name}] !! HIV-P17 REFUSED a fused creation-race merge of ${relPath}: ${why}`)
|
|
1859
|
-
try {
|
|
1860
|
-
say(`[!] @all ${relPath}: a creation-race merge was REFUSED because it fused rather than merged — ${why}. Nothing was written: the room's copy and ${name}'s local copy are both intact. Someone must reconcile them deliberately; a plain file write will merge against the other version again, hive_edit replaces it outright.`)
|
|
1861
|
-
} catch { /* the log still carries it */ }
|
|
1862
|
-
}
|
|
2630
|
+
refuseCreationRace(relPath, why, disk, docText)
|
|
1863
2631
|
return
|
|
1864
2632
|
}
|
|
1865
2633
|
}
|
|
@@ -1867,7 +2635,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1867
2635
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
1868
2636
|
if (r0.conflict) conflicted.add(relPath)
|
|
1869
2637
|
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
|
|
1870
|
-
if (!
|
|
2638
|
+
if (!inManifest(relPath)) queueManifestSet(relPath)
|
|
1871
2639
|
try { say(`⚠ ${relPath} was created by two participants at once — both versions were merged${r0.conflict ? ', and CONFLICT markers need resolving' : ''}.`) } catch { }
|
|
1872
2640
|
return
|
|
1873
2641
|
}
|
|
@@ -1875,14 +2643,37 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1875
2643
|
// this room, we DO have a common ancestor and the adopt below is simply wrong: the
|
|
1876
2644
|
// local copy may be newer than the room's (edited while disconnected, or the room
|
|
1877
2645
|
// came back stale). This is the data-loss case that was seen live.
|
|
1878
|
-
|
|
2646
|
+
let anc = priorShadow.get(relPath)
|
|
2647
|
+
// ...unless the room never got it. Capturing a local edit moves the ancestor to the edited
|
|
2648
|
+
// bytes BEFORE the relay accepts them. A write held behind someone else's claim
|
|
2649
|
+
// (HIVE_ENFORCE_CLAIMS=queue) is discarded when its writer disconnects, so on rejoin
|
|
2650
|
+
// disk === ancestor while the room still has the old copy, and adopting it deleted the edit
|
|
2651
|
+
// from the room AND from disk, silently (26 Oct). Signed receipts say whether the room ever
|
|
2652
|
+
// held these bytes; with none (provenance off) the old behaviour stands. When it never did,
|
|
2653
|
+
// the real ancestor is what the edit was made against, and the merge below re-sends it.
|
|
2654
|
+
let unsent = false
|
|
2655
|
+
const rcpts = fe.ledger ? fe.ledger.toArray() : []
|
|
2656
|
+
if (anc !== undefined && disk !== null && disk === anc &&
|
|
2657
|
+
accountFor({ rel: relPath, branchText: disk, roomText: docText, receipts: rcpts }).state === DIVERGED) {
|
|
2658
|
+
unsent = true
|
|
2659
|
+
// The newest base the room provably held. An older true ancestor merges safely; a newer
|
|
2660
|
+
// one the room never had (an earlier held edit) would read our own edit as the room
|
|
2661
|
+
// deleting it.
|
|
2662
|
+
const hist = priorFrom.get(relPath) || []
|
|
2663
|
+
let from
|
|
2664
|
+
for (let i = hist.length - 1; i >= 0 && from === undefined; i--) {
|
|
2665
|
+
const st = accountFor({ rel: relPath, branchText: hist[i], roomText: docText, receipts: rcpts }).state
|
|
2666
|
+
if (st === DESCENDS || st === IDENTICAL) from = hist[i]
|
|
2667
|
+
}
|
|
2668
|
+
if (from === undefined || from === disk) { keepUnsent(relPath, disk, docText); return }
|
|
2669
|
+
anc = from
|
|
2670
|
+
}
|
|
1879
2671
|
if (anc !== undefined && disk !== null) {
|
|
1880
2672
|
if (disk === anc) {
|
|
1881
2673
|
// Local is exactly what we last wrote — nobody touched it while we were away, so
|
|
1882
2674
|
// the room's copy is strictly newer and adopting it loses nothing.
|
|
1883
2675
|
writeToDisk(relPath, docText)
|
|
1884
2676
|
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
1885
|
-
if (!snappedBase.has(relPath)) { captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'room copy adopted on rejoin (local was unchanged)' }); snappedBase.add(relPath) }
|
|
1886
2677
|
return
|
|
1887
2678
|
}
|
|
1888
2679
|
// Divergent AND we know the ancestor: merge the two, do not pick a winner. Snapshot
|
|
@@ -1893,8 +2684,13 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1893
2684
|
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
1894
2685
|
if (rj.conflict) conflicted.add(relPath)
|
|
1895
2686
|
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
|
|
1896
|
-
if (!
|
|
1897
|
-
try {
|
|
2687
|
+
if (!inManifest(relPath)) queueManifestSet(relPath)
|
|
2688
|
+
try {
|
|
2689
|
+
say(unsent
|
|
2690
|
+
? `⚠ ${relPath}: ${name}'s last edit never reached the room — it was held while someone else had the file, and ${name}'s connection dropped. ` +
|
|
2691
|
+
`It has been sent again, merged with the room's copy${rj.conflict ? ', and CONFLICT markers need resolving' : ''}; if the file is still claimed, it waits for the release. Pre-merge local copy is in the file's history.`
|
|
2692
|
+
: `⚠ ${relPath} changed locally while ${name} was disconnected — the local edit and the room copy were merged${rj.conflict ? ', and CONFLICT markers need resolving' : ''}. Pre-merge local copy is in the file's history.`)
|
|
2693
|
+
} catch { }
|
|
1898
2694
|
return
|
|
1899
2695
|
}
|
|
1900
2696
|
// Genuinely no ancestor (a clone-then-join, or joining with an existing checkout).
|
|
@@ -1908,8 +2704,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1908
2704
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy at join (room copy adopted)' })
|
|
1909
2705
|
writeToDisk(relPath, docText)
|
|
1910
2706
|
bases.set(relPath, docText); forkBases.set(relPath, docText)
|
|
1911
|
-
|
|
1912
|
-
|
|
2707
|
+
// Past the `disk === docText` return above, IDENTICAL can only mean the line endings differ.
|
|
2708
|
+
log(`[${name}] ↓ ${relPath} already existed in this room (${st.state}) — adopted the room's copy; the local version is in the file's history`)
|
|
2709
|
+
noteFirstContact(st.state === IDENTICAL ? 'eol' : 'adopted', relPath)
|
|
1913
2710
|
return
|
|
1914
2711
|
}
|
|
1915
2712
|
|
|
@@ -1919,11 +2716,70 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1919
2716
|
// Refuse adoption: keep local disk untouched, keep room doc untouched, snapshot local, and announce loudly.
|
|
1920
2717
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: `local copy preserved at join (${st.state}: room copy refused)` })
|
|
1921
2718
|
bases.set(relPath, disk); forkBases.set(relPath, disk)
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
} catch { }
|
|
2719
|
+
log(`[${name}] ⚠ ${relPath} already existed in room with different content (${st.state}) — room copy was NOT adopted; local disk preserved`)
|
|
2720
|
+
noteFirstContact('kept', relPath)
|
|
1925
2721
|
return
|
|
1926
2722
|
}
|
|
2723
|
+
// DEAD-HEAT GENESIS COLLISION DETECTION & RESOLUTION.
|
|
2724
|
+
// When two peers publish to an empty document at genesis concurrently before either
|
|
2725
|
+
// sees the other, both set bases to their own genesis text. When the remote update
|
|
2726
|
+
// lands, bases.has(relPath) is true on both peers, so neither enters the first-contact
|
|
2727
|
+
// creation race branch above. The CRDT unions both inserts. Normal diff3 merge against
|
|
2728
|
+
// base === disk accepts docText (the union), and fusionReason is blinded because 'theirs'
|
|
2729
|
+
// is already the invalid union. Both peers silently write the broken union to disk.
|
|
2730
|
+
//
|
|
2731
|
+
// Detection (bounded): only runs for files THIS client genesis-published this session,
|
|
2732
|
+
// on remote reconciles before anything writes to disk, and at most once per file.
|
|
2733
|
+
if (origin === 'remote' && genesisPublished.has(relPath) && !deadHeatResolved.has(relPath)) {
|
|
2734
|
+
const allGenesisClients = new Set()
|
|
2735
|
+
const liveGenesisClients = new Set()
|
|
2736
|
+
const liveTexts = new Map()
|
|
2737
|
+
|
|
2738
|
+
let it = fe.text._start
|
|
2739
|
+
while (it) {
|
|
2740
|
+
if (it.origin === null && it.rightOrigin === null) {
|
|
2741
|
+
allGenesisClients.add(it.id.client)
|
|
2742
|
+
if (!it.deleted) liveGenesisClients.add(it.id.client)
|
|
2743
|
+
}
|
|
2744
|
+
if (!it.deleted) {
|
|
2745
|
+
const c = it.id.client
|
|
2746
|
+
const s = typeof it.content?.str === 'string'
|
|
2747
|
+
? it.content.str
|
|
2748
|
+
: (Array.isArray(it.content?.getContent?.()) ? it.content.getContent().join('') : '')
|
|
2749
|
+
liveTexts.set(c, (liveTexts.get(c) || '') + s)
|
|
2750
|
+
}
|
|
2751
|
+
it = it.right
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
if (liveGenesisClients.size >= 2) {
|
|
2755
|
+
deadHeatResolved.add(relPath)
|
|
2756
|
+
const { winner: winnerClient, winnerText, why } =
|
|
2757
|
+
deadHeatVerdict(new Map([...liveGenesisClients].map((c) => [c, liveTexts.get(c) || ''])), relPath)
|
|
2758
|
+
// DISJOINT = LEAVE THE UNION ALONE. The CRDT union already holds every author's work.
|
|
2759
|
+
if (why) {
|
|
2760
|
+
// Conflicting or fused: the room keeps ONE whole file, the winner's; the losers keep theirs on disk.
|
|
2761
|
+
fe.doc.transact(() => applyDiff(fe.text, winnerText))
|
|
2762
|
+
if (fe.doc.clientID === winnerClient || disk === winnerText) {
|
|
2763
|
+
// The winner's disk already holds winnerText. Nothing to write, nothing to refuse, and not
|
|
2764
|
+
// marked conflicted: its next clean edit would otherwise announce a "resolved" conflict.
|
|
2765
|
+
bases.set(relPath, winnerText); forkBases.set(relPath, winnerText); shadow.set(relPath, winnerText)
|
|
2766
|
+
if (!inManifest(relPath)) queueManifestSet(relPath)
|
|
2767
|
+
log(`[${name}] HIV-P17 creation-race dead heat resolved: kept local genesis as winner for ${relPath}`)
|
|
2768
|
+
return
|
|
2769
|
+
}
|
|
2770
|
+
refuseCreationRace(relPath, why, disk, winnerText)
|
|
2771
|
+
return
|
|
2772
|
+
}
|
|
2773
|
+
} else if (allGenesisClients.size >= 2 && allGenesisClients.has(fe.doc.clientID) && disk !== docText) {
|
|
2774
|
+
// The winner resolved before this client reconciled: this client's genesis item is already deleted.
|
|
2775
|
+
const why = creationRaceVerdict(icrMerge3('', endNl(disk), endNl(docText), relPath), disk, docText, relPath)
|
|
2776
|
+
if (why) {
|
|
2777
|
+
deadHeatResolved.add(relPath)
|
|
2778
|
+
refuseCreationRace(relPath, why, disk, docText)
|
|
2779
|
+
return
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
1927
2783
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
1928
2784
|
const fork = forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
1929
2785
|
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
|
|
@@ -1952,6 +2808,12 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1952
2808
|
} else {
|
|
1953
2809
|
res = icrMerge3(fork, disk, docText, relPath)
|
|
1954
2810
|
reAdded = !res.conflict && res.text !== disk // we restored remote lines a stale write had dropped
|
|
2811
|
+
// OPEN (1 Nov 2026): a clean handover onto the same lines still shows markers. Four rounds of
|
|
2812
|
+
// rules that decided it from the texts (a claim-time fork, who-holds-it, line counts, shared
|
|
2813
|
+
// tokens) each silently deleted someone's work in a case antigravity-3 then built: from the
|
|
2814
|
+
// four texts, "B replaced A's change" and "B wrote from a copy that predates it" can be
|
|
2815
|
+
// identical. Markers lose nothing; a wrong guess does. The sound fix needs the agent to say
|
|
2816
|
+
// which version it edited (hive_edit carrying its base), so nothing here guesses.
|
|
1955
2817
|
}
|
|
1956
2818
|
} else {
|
|
1957
2819
|
res = icrMerge3(base, disk, docText, relPath) // incoming remote change merged into our working copy
|
|
@@ -1995,6 +2857,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1995
2857
|
// Snapshot the state JUST BEFORE this author's edit lands, attributed to them —
|
|
1996
2858
|
// captured only on local origin so each change is recorded once, by its author.
|
|
1997
2859
|
if (origin === 'local' && res.text !== docText) {
|
|
2860
|
+
let hasBase = false
|
|
2861
|
+
for (const e of fe.snap.values()) {
|
|
2862
|
+
if (e.kind === 'base') { hasBase = true; break }
|
|
2863
|
+
}
|
|
2864
|
+
if (!hasBase) {
|
|
2865
|
+
const createTs = fileCreationTs(fe, relPath)
|
|
2866
|
+
captureSnapshot(relPath, docText, name, { force: true, kind: 'base', label: 'created', ts: createTs })
|
|
2867
|
+
}
|
|
1998
2868
|
const sc = summarizeChange(docText, res.text)
|
|
1999
2869
|
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
2000
2870
|
lastLocalEdit = relPath
|
|
@@ -2006,6 +2876,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2006
2876
|
// fusing. Recording (fork, disk) keeps every concurrent same-line edit anchored to the
|
|
2007
2877
|
// SAME clean parent, so the fork stays detectable no matter how the char-CRDT fuses.
|
|
2008
2878
|
if (origin === 'local') {
|
|
2879
|
+
if (res.text !== docText) bumpAttestVersion(relPath, res.text)
|
|
2009
2880
|
// Gate ON: the author's INTENT was already captured at the source (captureLocal, top
|
|
2010
2881
|
// of this pass) — here we only advance the authority head to the landed content.
|
|
2011
2882
|
// Gate OFF (default): the proven landed-transition stamp, unchanged.
|
|
@@ -2054,7 +2925,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2054
2925
|
detectFork(relPath)
|
|
2055
2926
|
}
|
|
2056
2927
|
|
|
2057
|
-
const fmtTime = () => new Date().toTimeString().slice(0, 8)
|
|
2928
|
+
const fmtTime = (d = Date.now()) => new Date(d).toTimeString().slice(0, 8)
|
|
2058
2929
|
// Log file activity to the shared board: EVERY meaningful edit (so the live
|
|
2059
2930
|
// activity feed reflects ongoing work), tagged with whether it was a full-file
|
|
2060
2931
|
// `rewrite` and a numeric `ts` for ordering. HIVE_BOARD.md stays rewrites-only
|
|
@@ -2066,6 +2937,17 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2066
2937
|
board.set(relPath, { by: name, at: fmtTime(), ts: Date.now(), churn: `${s.changedLines}/${s.totalLines} lines`, symbols: s.symbols, rewrite: s.isRewrite })
|
|
2067
2938
|
if (s.isRewrite) log(`[${name}] board: logged REWRITE of ${relPath} (${s.changedLines}/${s.totalLines} lines; touched ${s.symbols.join(', ') || 'n/a'})`)
|
|
2068
2939
|
}
|
|
2940
|
+
let renderBoardTimer = null
|
|
2941
|
+
function renderBoardSoon() {
|
|
2942
|
+
if (stopped) return
|
|
2943
|
+
if (!renderBoardTimer) {
|
|
2944
|
+
renderBoardTimer = setImmediate(() => {
|
|
2945
|
+
renderBoardTimer = null
|
|
2946
|
+
renderBoard()
|
|
2947
|
+
linkMemory()
|
|
2948
|
+
})
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2069
2951
|
function renderBoard() {
|
|
2070
2952
|
// HIVE_BOARD.md is the coordination doc: full-file REWRITES only (small edits
|
|
2071
2953
|
// live in the activity feed via the board map, but don't clutter this file).
|
|
@@ -2079,7 +2961,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2079
2961
|
for (const e of entries) out.push(`- ${e.at} ${e.by} rewrote \`${e.file}\` (${e.churn}) — touched: ${(e.symbols || []).join(', ') || 'n/a'}`)
|
|
2080
2962
|
writeToDisk(BOARD_FILE, out.join('\n') + '\n')
|
|
2081
2963
|
}
|
|
2082
|
-
if (syncFiles) board.observe(
|
|
2964
|
+
if (syncFiles) board.observe(renderBoardSoon)
|
|
2083
2965
|
|
|
2084
2966
|
// --- rollback: restore points + per-author revert + undo --------------------
|
|
2085
2967
|
// Every meaningful change captures a RESTORE POINT: the file's content from JUST
|
|
@@ -2092,7 +2974,6 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2092
2974
|
const SNAP_MIN_GAP_MS = 4000 // throttle auto-capture per file (fine-grained undo is the editor's job)
|
|
2093
2975
|
const META_KEEP = 400 // global cap on the parent timeline index
|
|
2094
2976
|
const lastSnapAt = new Map() // relPath -> ts of last auto-capture
|
|
2095
|
-
const snappedBase = new Set() // files we've already floored with a baseline snapshot
|
|
2096
2977
|
const snapId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
2097
2978
|
|
|
2098
2979
|
function pruneMeta() {
|
|
@@ -2103,20 +2984,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2103
2984
|
doc.transact(() => { for (let i = 0; i < Math.min(overflow, all.length); i++) historyMeta.delete(all[i].id) })
|
|
2104
2985
|
}
|
|
2105
2986
|
|
|
2987
|
+
// Find file creation timestamp from createdTimes, genesis head, provenance ledger, head cur, mtimes, or disk stat.
|
|
2988
|
+
function fileCreationTs(fe, relPath) {
|
|
2989
|
+
if (createdTimes.has(relPath)) return createdTimes.get(relPath)
|
|
2990
|
+
try {
|
|
2991
|
+
const g = fe && fe.head ? fe.head.get('genesis') : null
|
|
2992
|
+
if (g && typeof g.at === 'number' && !isNaN(g.at)) return g.at
|
|
2993
|
+
} catch { }
|
|
2994
|
+
try {
|
|
2995
|
+
const l0 = fe && fe.ledger && fe.ledger.length > 0 ? fe.ledger.get(0) : null
|
|
2996
|
+
if (l0 && typeof l0.at === 'number' && !isNaN(l0.at)) return l0.at
|
|
2997
|
+
} catch { }
|
|
2998
|
+
try {
|
|
2999
|
+
const cur = fe && fe.head ? fe.head.get('cur') : null
|
|
3000
|
+
if (cur && typeof cur.at === 'number' && !isNaN(cur.at)) return cur.at
|
|
3001
|
+
} catch { }
|
|
3002
|
+
if (mtimes.has(relPath)) return mtimes.get(relPath)
|
|
3003
|
+
try {
|
|
3004
|
+
const full = path.join(ROOT, relPath)
|
|
3005
|
+
if (fs.existsSync(full)) return fs.statSync(full).mtimeMs
|
|
3006
|
+
} catch { }
|
|
3007
|
+
return Date.now()
|
|
3008
|
+
}
|
|
3009
|
+
|
|
2106
3010
|
// Capture `content` as a restore point for relPath, attributed to `byWho`.
|
|
2107
3011
|
// kind: 'auto' (an ordinary edit), 'base' (a file's floor), 'manual' (a named
|
|
2108
3012
|
// checkpoint), 'restore' (the pre-restore state, kept so restore is reversible).
|
|
2109
|
-
function captureSnapshot(relPath, content, byWho, { force = false, churn = '', kind = 'auto', label = '' } = {}) {
|
|
3013
|
+
function captureSnapshot(relPath, content, byWho, { force = false, churn = '', kind = 'auto', label = '', ts = null } = {}) {
|
|
2110
3014
|
if (content == null) return null
|
|
2111
3015
|
const fe = fileDocs.get(relPath); if (!fe) return null
|
|
2112
|
-
const now = Date.now()
|
|
3016
|
+
const now = (typeof ts === 'number' && !isNaN(ts)) ? ts : Date.now()
|
|
2113
3017
|
if (kind === 'auto' && !force && now - (lastSnapAt.get(relPath) || 0) < SNAP_MIN_GAP_MS) return null
|
|
2114
3018
|
const snap = fe.snap
|
|
2115
3019
|
let latest = null
|
|
2116
3020
|
for (const e of snap.values()) if (!latest || (e.ts || 0) > (latest.ts || 0)) latest = e
|
|
2117
3021
|
if (latest && latest.content === content) return null // identical to the most recent point — nothing to keep
|
|
2118
3022
|
const id = snapId()
|
|
2119
|
-
const at = fmtTime()
|
|
3023
|
+
const at = fmtTime(now)
|
|
2120
3024
|
const pruned = []
|
|
2121
3025
|
fe.doc.transact(() => {
|
|
2122
3026
|
snap.set(id, { id, file: relPath, by: byWho, at, ts: now, content, churn, kind, label })
|
|
@@ -2124,17 +3028,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2124
3028
|
for (let i = 0; i < autos.length - SNAP_KEEP; i++) { snap.delete(autos[i].id); pruned.push(autos[i].id) }
|
|
2125
3029
|
})
|
|
2126
3030
|
if (kind === 'auto') lastSnapAt.set(relPath, now)
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
})
|
|
2131
|
-
pruneMeta()
|
|
3031
|
+
pendingHistoryMeta.set(id, { op: 'set', record: { id, file: relPath, by: byWho, at, ts: now, churn, kind, label } })
|
|
3032
|
+
for (const pid of pruned) pendingHistoryMeta.set(pid, { op: 'delete' })
|
|
3033
|
+
scheduleRoomFlush()
|
|
2132
3034
|
return id
|
|
2133
3035
|
}
|
|
2134
3036
|
|
|
2135
3037
|
// Find which file a restore-point id belongs to (via the index, else by scanning
|
|
2136
3038
|
// open file docs as a fallback).
|
|
2137
3039
|
function fileOfSnap(id) {
|
|
3040
|
+
flushRoomDoc()
|
|
2138
3041
|
const m = historyMeta.get(id)
|
|
2139
3042
|
if (m && m.file) return m.file
|
|
2140
3043
|
for (const [rp, fe] of fileDocs) if (fe.snap.has(id)) return rp
|
|
@@ -2171,7 +3074,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2171
3074
|
if (!fe) return { error: 'file not open' }
|
|
2172
3075
|
let best = null
|
|
2173
3076
|
for (const e of fe.snap.values()) if ((e.ts || 0) <= ts && (!best || (e.ts || 0) > (best.ts || 0))) best = e
|
|
2174
|
-
if (!best)
|
|
3077
|
+
if (!best) {
|
|
3078
|
+
if (fe.snap.size === 0) {
|
|
3079
|
+
const createTs = fileCreationTs(fe, relPath)
|
|
3080
|
+
if (typeof ts === 'number' && ts >= createTs) {
|
|
3081
|
+
return { ok: true, unchanged: true, file: relPath }
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
return { error: 'no restore point at/before that time' }
|
|
3085
|
+
}
|
|
2175
3086
|
return restore(best.id, by)
|
|
2176
3087
|
}
|
|
2177
3088
|
|
|
@@ -2182,6 +3093,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2182
3093
|
// For the dangerous case — an agent rewriting files — that's exactly right.
|
|
2183
3094
|
function revertAuthor(who, sinceTs = 0, by = name) {
|
|
2184
3095
|
if (!who) return { error: 'no author given' }
|
|
3096
|
+
flushRoomDoc()
|
|
2185
3097
|
const earliest = new Map() // file -> earliest meta authored by `who` in the window
|
|
2186
3098
|
for (const m of historyMeta.values()) {
|
|
2187
3099
|
if (m.by !== who || (m.ts || 0) < sinceTs) continue
|
|
@@ -2197,6 +3109,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2197
3109
|
|
|
2198
3110
|
// The timeline (newest first), read from the parent index. Optional filters.
|
|
2199
3111
|
function listHistory({ file = null, by = null, limit = 200 } = {}) {
|
|
3112
|
+
flushRoomDoc()
|
|
2200
3113
|
return [...historyMeta.values()]
|
|
2201
3114
|
.filter((e) => (!file || e.file === file) && (!by || e.by === by))
|
|
2202
3115
|
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
@@ -2244,10 +3157,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2244
3157
|
'# and announce what you are about to do (e.g. "taking auth.js: add login").',
|
|
2245
3158
|
'',
|
|
2246
3159
|
]
|
|
2247
|
-
for (const m of chat.toArray()) out.push(`- ${m.at} ${m.by} (${m.kind}): ${m.text}`)
|
|
3160
|
+
for (const m of chat.toArray()) out.push(`- ${formatTime(m.at)} ${m.by} (${m.kind}): ${m.text}`)
|
|
2248
3161
|
writeToDisk(CHAT_FILE, out.join('\n') + '\n')
|
|
2249
3162
|
}
|
|
2250
|
-
// Hand the room's history to
|
|
3163
|
+
// Hand the room's history to the memory add-on, when present, whenever it changes, so this ROOM has a memory: an
|
|
2251
3164
|
// agent joining tomorrow can ask what was decided here last week, including in
|
|
2252
3165
|
// conversations it was never present for.
|
|
2253
3166
|
//
|
|
@@ -2256,10 +3169,20 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2256
3169
|
// compares before it writes, so calling it per message costs four stat() calls and no
|
|
2257
3170
|
// disk write, and the relay watcher never sees needless churn.
|
|
2258
3171
|
let memoryLinked = false
|
|
3172
|
+
let lastLinkedFilesCount = 0
|
|
2259
3173
|
function linkMemory() {
|
|
2260
3174
|
if (!syncFiles) return
|
|
3175
|
+
let count = 0
|
|
3176
|
+
for (const f of ['HIVE_CHAT.md', 'HIVE_BOARD.md', 'HIVE_TASKS.md', 'HIVE_MEMBERS.md']) {
|
|
3177
|
+
try { if (fs.existsSync(path.join(ROOT, f))) count++ } catch { }
|
|
3178
|
+
}
|
|
3179
|
+
if (count === 0 || (memoryLinked && count === lastLinkedFilesCount)) return
|
|
3180
|
+
if (!grmLink) return
|
|
2261
3181
|
const r = grmLink.linkRoom(ROOT, memoryLinked ? () => {} : log) // announce once, not per message
|
|
2262
|
-
if (r.linked)
|
|
3182
|
+
if (r.linked) {
|
|
3183
|
+
memoryLinked = true
|
|
3184
|
+
lastLinkedFilesCount = (r.files && r.files.length) || count
|
|
3185
|
+
}
|
|
2263
3186
|
}
|
|
2264
3187
|
|
|
2265
3188
|
if (syncFiles) chat.observe(() => { renderChat(); linkMemory() })
|
|
@@ -2289,17 +3212,30 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2289
3212
|
else say(`@${to}: ${text} (task ${id} — ${ownerOf} must approve: do it or ignore?)`)
|
|
2290
3213
|
return id
|
|
2291
3214
|
}
|
|
3215
|
+
// A decision is about a task that is WAITING for one. Approving an accepted or done task used to
|
|
3216
|
+
// rewrite it to 'accepted', which hands finished work straight back to the agent to do again.
|
|
3217
|
+
// 'denied' stays decidable: an owner may change their mind.
|
|
2292
3218
|
function decide(id, accept, by = name) {
|
|
2293
3219
|
const t = tasks.get(id)
|
|
2294
3220
|
if (!t) return { error: 'no such task' }
|
|
2295
3221
|
const ownerOf = owners.get(t.to)
|
|
2296
3222
|
if (ownerOf && by !== ownerOf) return { error: `only ${ownerOf} (owner of ${t.to}) can approve task ${id}` }
|
|
3223
|
+
if (t.status !== 'pending' && t.status !== 'denied') return { error: `task ${id} is ${t.status}, not waiting for a decision` }
|
|
2297
3224
|
tasks.set(id, { ...t, status: accept ? 'accepted' : 'denied', decidedBy: by })
|
|
2298
|
-
|
|
3225
|
+
// An approval NAMES the agent (4 Oct 2026). An agent waiting between turns is woken by its
|
|
3226
|
+
// listener, and the listener wakes on @Name, so an approval that named nobody left the agent
|
|
3227
|
+
// asleep on work it had just been cleared to do. The same line is in control.html and the extension.
|
|
3228
|
+
say(`${accept ? `@${t.to} ` : ''}task ${id} ${accept ? 'APPROVED' : 'denied'} by ${by}: "${t.text}"`)
|
|
2299
3229
|
return { ok: true }
|
|
2300
3230
|
}
|
|
3231
|
+
// Only the ASSIGNEE finishes a task, and only work it was cleared to do. Before 29 Sep 2026 anyone
|
|
3232
|
+
// could mark anyone's task done -- including one still PENDING its owner's approval, or DENIED --
|
|
3233
|
+
// so "done" could mean the owner said no and the room was told otherwise. Done twice is done.
|
|
2301
3234
|
function complete(id, note = '') {
|
|
2302
3235
|
const t = tasks.get(id); if (!t) return { error: 'no such task' }
|
|
3236
|
+
if (t.to !== name) return { error: `task ${id} is ${t.to}'s -- only ${t.to} can complete it` }
|
|
3237
|
+
if (t.status === 'done') return { ok: true, already: true }
|
|
3238
|
+
if (t.status !== 'accepted') return { error: `task ${id} is ${t.status}: only accepted work can be completed${t.status === 'pending' ? ` (it is waiting for ${owners.get(t.to) || 'its owner'} to approve)` : ''}` }
|
|
2303
3239
|
tasks.set(id, { ...t, status: 'done', decidedBy: t.decidedBy })
|
|
2304
3240
|
say(`task ${id} done by ${name}${note ? ': ' + note : ''}`)
|
|
2305
3241
|
return { ok: true }
|
|
@@ -2337,7 +3273,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2337
3273
|
let myEditing = null, myLastEdit = null
|
|
2338
3274
|
let editingClearTimer = null
|
|
2339
3275
|
const warnedAt = new Map() // file -> last time we warned about co-editing it
|
|
2340
|
-
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, passive: !!passive })
|
|
3276
|
+
const setUserState = () => provider.awareness.setLocalStateField('user', { name, kind, owner: owner || undefined, editing: myEditing || undefined, lastEdit: myLastEdit || undefined, v: PROTOCOL_VERSION, hv: VERSION_LABEL, passive: !!passive, listener: listener || undefined, app: app || undefined, iid: IID })
|
|
2341
3277
|
function markEditing(relPath) {
|
|
2342
3278
|
myEditing = { file: relPath, at: Date.now() }
|
|
2343
3279
|
myLastEdit = myEditing
|
|
@@ -2436,6 +3372,43 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2436
3372
|
log(`[${name}] ⚠ ${u.name} is on ${theirs} (incompatible with v${PROTOCOL_VERSION}) — they won't sync correctly until everyone updates.`)
|
|
2437
3373
|
try { say(`⚠ ${u.name} is on an incompatible version — files won't sync with them until everyone updates Hivecode.`) } catch { }
|
|
2438
3374
|
}
|
|
3375
|
+
checkBuilds()
|
|
3376
|
+
}
|
|
3377
|
+
// MIXED BUILDS, SEEN BY THE ROOM ITSELF (29 Sep 2026). The same protocol does not mean the same
|
|
3378
|
+
// engine: two builds that merge differently corrupt each other's files with every write green.
|
|
3379
|
+
// Until now the only detector was each agent remembering to say "joined on hivecode X". Every
|
|
3380
|
+
// engine client now carries its build in presence (hv), so the room can see a mismatch without
|
|
3381
|
+
// anyone announcing it. Rules, each against a false alarm:
|
|
3382
|
+
// - only clients that report a build are compared, EXCEPT an AI on this protocol reporting none:
|
|
3383
|
+
// that is an engine older than build reporting, the rollout case, and it cannot warn itself
|
|
3384
|
+
// - humans are not flagged: the extension and the browser panel carry no engine build
|
|
3385
|
+
// - one side of each pair speaks (the name that sorts first), once per build it sees
|
|
3386
|
+
const buildWarned = new Set()
|
|
3387
|
+
function checkBuilds() {
|
|
3388
|
+
// An old engine cannot speak for itself, so SOMEBODY current must -- but only one: the reporting
|
|
3389
|
+
// client whose name sorts first. Otherwise every current client in the room says the same thing.
|
|
3390
|
+
const reporters = [name]
|
|
3391
|
+
for (const s of provider.awareness.getStates().values()) { const u = s && s.user; if (u && u.name && u.hv) reporters.push(u.name) }
|
|
3392
|
+
const speakForOld = reporters.sort()[0] === name
|
|
3393
|
+
for (const s of provider.awareness.getStates().values()) {
|
|
3394
|
+
const u = s && s.user
|
|
3395
|
+
if (!u || !u.name || u.name === name || u.v !== PROTOCOL_VERSION) continue
|
|
3396
|
+
const human = String(u.kind || '').toLowerCase() === 'human'
|
|
3397
|
+
let theirs = ''
|
|
3398
|
+
if (u.hv) {
|
|
3399
|
+
if (u.hv === VERSION_LABEL || name > u.name) continue
|
|
3400
|
+
theirs = `hivecode ${u.hv}`
|
|
3401
|
+
} else if (!human) {
|
|
3402
|
+
if (!speakForOld) continue
|
|
3403
|
+
theirs = `a hivecode build older than build reporting (before ${VERSION})`
|
|
3404
|
+
} else continue
|
|
3405
|
+
const key = `${u.name}|${u.hv || '-'}`
|
|
3406
|
+
if (buildWarned.has(key)) continue
|
|
3407
|
+
buildWarned.add(key)
|
|
3408
|
+
const same = u.hv && u.hv.split('+')[0] === VERSION ? ' (the same release from different commits)' : ''
|
|
3409
|
+
log(`[${name}] ⚠ MIXED BUILDS: ${u.name} runs ${theirs}, this client runs ${VERSION_LABEL}${same}`)
|
|
3410
|
+
try { say(`⚠ MIXED BUILDS: ${u.name} runs ${theirs}, ${name} runs hivecode ${VERSION_LABEL}${same}. Two builds in one room can lose file content with no error on either side -- everyone should run the same build.`) } catch { }
|
|
3411
|
+
}
|
|
2439
3412
|
}
|
|
2440
3413
|
if (syncFiles) provider.awareness.on('change', () => { renderMembers(); renderClaims(); checkVersions() })
|
|
2441
3414
|
|
|
@@ -2469,6 +3442,65 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2469
3442
|
// scroll past — but deleting the case entirely is worse than announcing it cheaply.
|
|
2470
3443
|
const ENGINE_ID = 'hivecode'
|
|
2471
3444
|
const invisibleWarned = new Map() // relPath -> obligation id, or null for the ambient case
|
|
3445
|
+
const unsharedWarned = new Map() // relPath -> { id: obligation id | null, reason: 'binary' | 'oversized', size: number }
|
|
3446
|
+
const unsharedMtimes = new Map() // relPath -> mtimeMs (for CPU skip in scan)
|
|
3447
|
+
|
|
3448
|
+
function noticeUnshared(currentUnshared) {
|
|
3449
|
+
for (const r of [...unsharedWarned.keys()]) if (!currentUnshared.has(r)) dischargeUnshared(r)
|
|
3450
|
+
for (const [r, info] of currentUnshared) noticeUnsharedOne(r, info)
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
function dischargeUnshared(r) {
|
|
3454
|
+
if (!unsharedWarned.has(r) && !unsharedMtimes.has(r)) return
|
|
3455
|
+
const entry = unsharedWarned.get(r)
|
|
3456
|
+
if (entry && entry.id) {
|
|
3457
|
+
try { OB.discharge(doc.getMap('obligations'), { id: entry.id, by: ENGINE_ID }) } catch { }
|
|
3458
|
+
}
|
|
3459
|
+
unsharedWarned.delete(r)
|
|
3460
|
+
unsharedMtimes.delete(r)
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
function noticeUnsharedOne(r, info) {
|
|
3464
|
+
if (SKIP.has(r) || OWN_LOCAL.has(r)) return
|
|
3465
|
+
const holder = coordinator.sense(r)
|
|
3466
|
+
const existing = unsharedWarned.get(r)
|
|
3467
|
+
if (existing && (existing.id || !holder || !holder.by)) return
|
|
3468
|
+
|
|
3469
|
+
const reasonText = info.reason === 'oversized'
|
|
3470
|
+
? `oversized (${Math.round(info.size / 1024)}KB > 1MB limit)`
|
|
3471
|
+
: 'binary (contains null bytes)'
|
|
3472
|
+
const inRoom = inManifest(r)
|
|
3473
|
+
const contextPrefix = inRoom
|
|
3474
|
+
? `${r} is in the room as text, but local file is ${reasonText}`
|
|
3475
|
+
: `${r} is ${reasonText}`
|
|
3476
|
+
|
|
3477
|
+
if (holder && holder.by) {
|
|
3478
|
+
const guidance = `stays on ${holder.by}'s machine only: teammates will not get it from the room. Share it another way (commit it).`
|
|
3479
|
+
let id = null
|
|
3480
|
+
try {
|
|
3481
|
+
const rec = OB.open(doc.getMap('obligations'), {
|
|
3482
|
+
from: ENGINE_ID,
|
|
3483
|
+
to: holder.by,
|
|
3484
|
+
text: `⛔ ${r} is on disk and claimed by you, but cannot be shared because it is ${reasonText} — it ${guidance}`,
|
|
3485
|
+
absent: false,
|
|
3486
|
+
neverSeen: false,
|
|
3487
|
+
})
|
|
3488
|
+
id = rec && rec.id
|
|
3489
|
+
} catch { }
|
|
3490
|
+
unsharedWarned.set(r, { id, reason: info.reason, size: info.size })
|
|
3491
|
+
try {
|
|
3492
|
+
say(`⛔ ${contextPrefix} and claimed by ${holder.by} — it ${guidance}`)
|
|
3493
|
+
} catch { }
|
|
3494
|
+
} else {
|
|
3495
|
+
const guidance = `stays on ${name}'s machine only: teammates will not get it from the room. Share it another way (commit it).`
|
|
3496
|
+
unsharedWarned.set(r, { id: null, reason: info.reason, size: info.size })
|
|
3497
|
+
if (!preexisting.has(r) || inRoom) {
|
|
3498
|
+
try {
|
|
3499
|
+
say(`· ${contextPrefix} — it ${guidance}`)
|
|
3500
|
+
} catch { }
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
}
|
|
2472
3504
|
|
|
2473
3505
|
function noticeBornInvisible(ignoredRel) {
|
|
2474
3506
|
// DISCHARGE FIRST. A file that has gone away takes its debt with it: the question "this will
|
|
@@ -2476,44 +3508,190 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2476
3508
|
// fills with debts about paths that no longer exist, which is the noise the three-state
|
|
2477
3509
|
// lifecycle exists to prevent. Its expiry cannot help here — that only covers names never seen
|
|
2478
3510
|
// in the room, and this path was seen, on disk, by us.
|
|
2479
|
-
for (const
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
3511
|
+
for (const r of [...invisibleWarned.keys()]) if (!ignoredRel.has(r)) dischargeInvisible(r)
|
|
3512
|
+
for (const r of ignoredRel) noticeInvisibleOne(r)
|
|
3513
|
+
}
|
|
3514
|
+
function dischargeInvisible(r) {
|
|
3515
|
+
if (!invisibleWarned.has(r)) return
|
|
3516
|
+
const id = invisibleWarned.get(r)
|
|
3517
|
+
if (id) { try { OB.discharge(doc.getMap('obligations'), { id, by: ENGINE_ID }) } catch { } }
|
|
3518
|
+
invisibleWarned.delete(r)
|
|
3519
|
+
}
|
|
3520
|
+
// Would walk() list this path as an ignored FILE -- the only kind noticeBornInvisible speaks
|
|
3521
|
+
// about? Not when a directory above it is pruned, as node_modules/ and .git/ are: an npm install
|
|
3522
|
+
// must not become ten thousand chat lines because a watcher event named each file.
|
|
3523
|
+
function walkSkipsAsIgnoredFile(r) {
|
|
3524
|
+
const segs = r.split('/')
|
|
3525
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
3526
|
+
if (IGNORE.has(segs[i]) || isIgnored(segs.slice(0, i + 1).join('/') + '/')) return false
|
|
3527
|
+
}
|
|
3528
|
+
return !IGNORE.has(segs[segs.length - 1]) && isIgnored(r)
|
|
3529
|
+
}
|
|
3530
|
+
function noticeInvisibleOne(r) {
|
|
3531
|
+
if (SKIP.has(r) || OWN_LOCAL.has(r)) return // control files are ours and are supposed to be invisible
|
|
3532
|
+
if (preexisting.has(r)) return // it was already here; nobody did this during the session
|
|
3533
|
+
if (invisibleWarned.has(r)) return // once per path, not once per scan tick
|
|
3534
|
+
const holder = coordinator.sense(r)
|
|
3535
|
+
if (holder && holder.by) {
|
|
3536
|
+
let id = null
|
|
3537
|
+
try {
|
|
3538
|
+
const rec = OB.open(doc.getMap('obligations'), {
|
|
3539
|
+
from: ENGINE_ID,
|
|
3540
|
+
to: holder.by,
|
|
3541
|
+
text: `${r} is on disk and claimed by you, but it is ignored by a .gitignore here — ` +
|
|
3542
|
+
`no sync connection is opened for it, so nothing written to it will ever reach the room. ` +
|
|
3543
|
+
`Add it to the allow-list, or confirm the work is deliberately local.`,
|
|
3544
|
+
absent: false,
|
|
3545
|
+
neverSeen: false,
|
|
3546
|
+
})
|
|
3547
|
+
id = rec && rec.id
|
|
3548
|
+
} catch { }
|
|
3549
|
+
invisibleWarned.set(r, id)
|
|
3550
|
+
try {
|
|
3551
|
+
say(`⛔ ${r} is claimed and on disk, but it is ignored by a .gitignore here — its bytes will never leave this folder.`)
|
|
3552
|
+
} catch { }
|
|
3553
|
+
} else {
|
|
3554
|
+
invisibleWarned.set(r, null)
|
|
3555
|
+
try {
|
|
3556
|
+
say(`· ${r} was created here but is ignored by a .gitignore — it stays local, and no other agent will see it.`)
|
|
3557
|
+
} catch { }
|
|
3558
|
+
}
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
// ---- deletes: a missing folder is not a deletion, and a delete from the room keeps a copy ----
|
|
3562
|
+
//
|
|
3563
|
+
// Measured 3 Oct 2026 on a local relay, before any of this existed: one agent's `rm -rf src`
|
|
3564
|
+
// took 30 files out of its teammate's folder within seconds, and nothing kept them -- a delete
|
|
3565
|
+
// from the room was a bare rmSync. Deleting the agent's WHOLE room folder was worse: on Windows
|
|
3566
|
+
// fs.watch threw EPERM with no handler and the agent's hivecode server died, and wherever the
|
|
3567
|
+
// watcher survives, the next scan walked a folder that was not there, read every file as
|
|
3568
|
+
// deleted, and emptied the room for everyone (12 of 12 files gone from the teammate's folder).
|
|
3569
|
+
function rootIsDir() { try { return fs.statSync(ROOT).isDirectory() } catch { return false } }
|
|
3570
|
+
|
|
3571
|
+
// LOCAL DELETES SETTLE, THEN LEAVE AS ONE BATCH. `rm -rf` removes the files first and the folder
|
|
3572
|
+
// last, and each removal used to reach the room 40 ms after its watcher event -- so on a big
|
|
3573
|
+
// folder most of the deletes were already out while the folder still existed, and no check of
|
|
3574
|
+
// the folder could catch it. Nobody needs a delete in 40 ms. Waiting for the deleting to stop
|
|
3575
|
+
// lets the batch be judged whole: the folder gone, or every file in it gone at once, reads as a
|
|
3576
|
+
// lost folder (loseFolder), never as N deliberate deletes. It also stops an editor that saves by
|
|
3577
|
+
// delete-then-write from sending a delete and a re-create of the same file.
|
|
3578
|
+
const pendingLocalDeletes = new Set()
|
|
3579
|
+
let deleteTimer = null
|
|
3580
|
+
function noteLocalDelete(r) {
|
|
3581
|
+
if (pendingLocalDeletes.has(r)) return // a rescan of the same absence must not keep postponing it
|
|
3582
|
+
pendingLocalDeletes.add(r)
|
|
3583
|
+
if (deleteTimer) clearTimeout(deleteTimer)
|
|
3584
|
+
deleteTimer = setTimeout(settleLocalDeletes, DELETE_SETTLE_MS)
|
|
3585
|
+
}
|
|
3586
|
+
function settleLocalDeletes() {
|
|
3587
|
+
if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null }
|
|
3588
|
+
if (!pendingLocalDeletes.size) return
|
|
3589
|
+
const pending = [...pendingLocalDeletes]
|
|
3590
|
+
pendingLocalDeletes.clear()
|
|
3591
|
+
if (folderLost) return
|
|
3592
|
+
if (!rootIsDir()) { loseFolder('is gone'); return }
|
|
3593
|
+
// Gone now = still absent, or ignored since (walk() skips both, and both always retracted the
|
|
3594
|
+
// file from the room). Anything that came back during the wait is an edit, not a delete.
|
|
3595
|
+
const fileExistsExact = (r) => (!isCaseInsensitive ? fs.existsSync(path.join(ROOT, r)) : actualDiskPath(r) === r)
|
|
3596
|
+
const gone = pending.filter((r) => known.has(r) && inManifest(r) && (!fileExistsExact(r) || isIgnored(r)))
|
|
3597
|
+
if (!gone.length) return
|
|
3598
|
+
const held = [...known].filter((r) => inManifest(r)).length
|
|
3599
|
+
if (held >= 5 && gone.length >= held) { loseFolder(`was emptied: all ${held} of its files vanished at once`); return }
|
|
3600
|
+
// A FILE SOMEONE ELSE HOLDS IS NOT DELETED UNDER THEM. Measured 3 Oct 2026, with claims enforced
|
|
3601
|
+
// as queue and as block alike: Bob claimed a.js and was editing it, Alice deleted it, and it left
|
|
3602
|
+
// the room and Bob's folder -- a claim stopped every write except the largest one. Such a delete is
|
|
3603
|
+
// not sent; the file goes back into this folder from the room, so the folder matches the room
|
|
3604
|
+
// and the next scan does not try again; and the room is told why, once.
|
|
3605
|
+
const refused = []
|
|
3606
|
+
const send = []
|
|
3607
|
+
for (const r of gone) {
|
|
3608
|
+
const h = coordinator.sense(r)
|
|
3609
|
+
if (h && h.by && h.by !== name) refused.push([r, h.by]); else send.push(r)
|
|
3610
|
+
}
|
|
3611
|
+
for (const [r] of refused) {
|
|
3612
|
+
if (fileExistsExact(r)) continue // ignored since, not absent: nothing to put back
|
|
3613
|
+
const e = fileDocs.get(r)
|
|
3614
|
+
if (e) writeToDisk(r, e.text.toString())
|
|
3615
|
+
else { known.delete(r); mtimes.delete(r); queueOpenFile(r) } // reopened, it arrives like any room file
|
|
3616
|
+
}
|
|
3617
|
+
if (refused.length) {
|
|
3618
|
+
const what = refused.slice(0, 3).map(([r, by]) => `${r} (held by ${by})`).join(', ') + (refused.length > 3 ? ` (+${refused.length - 3} more)` : '')
|
|
3619
|
+
log(`[${name}] delete not sent, the file is held by someone else: ${what}`)
|
|
3620
|
+
try { say(`⛔ ${name} deleted ${what}, but a file someone else holds is not deleted under them, so that delete was not sent and the file is back in ${name}'s folder. Ask the holder to release it first.`) } catch { }
|
|
3621
|
+
}
|
|
3622
|
+
if (!send.length) return
|
|
3623
|
+
for (const r of send) queueManifestDelete(r)
|
|
3624
|
+
flushRoomDoc()
|
|
3625
|
+
for (const r of send) {
|
|
3626
|
+
closeFile(r); known.delete(r); mtimes.delete(r); bases.delete(r); forkBases.delete(r); shadow.delete(r)
|
|
3627
|
+
if (isCaseInsensitive) {
|
|
3628
|
+
const cf = fold(r)
|
|
3629
|
+
collided.delete(r)
|
|
3630
|
+
collidedWarned.delete(r)
|
|
3631
|
+
clearCollidedTimer(r)
|
|
3632
|
+
if (canonicalCaseKeys.get(cf) === r) {
|
|
3633
|
+
canonicalCaseKeys.delete(cf)
|
|
3634
|
+
for (const [colKey, info] of collided.entries()) {
|
|
3635
|
+
if (info.with === r && inManifest(colKey)) {
|
|
3636
|
+
collided.delete(colKey)
|
|
3637
|
+
collidedWarned.delete(colKey)
|
|
3638
|
+
clearCollidedTimer(colKey)
|
|
3639
|
+
canonicalCaseKeys.set(cf, colKey)
|
|
3640
|
+
break
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
2512
3644
|
}
|
|
2513
3645
|
}
|
|
3646
|
+
const list = send.slice(0, 3).join(', ') + (send.length > 3 ? ` (+${send.length - 3} more)` : '')
|
|
3647
|
+
log(`[${name}] -> deleted for everyone: ${list}`)
|
|
3648
|
+
if (send.length >= BULK_DELETE) {
|
|
3649
|
+
try { say(`· ${name} deleted ${send.length} files for everyone: ${list}. Teammates' folders keep a copy of what they held, in ${DELETED_DIR}/, for 7 days.`) } catch { }
|
|
3650
|
+
}
|
|
2514
3651
|
}
|
|
2515
3652
|
|
|
3653
|
+
// A DELETE FROM THE ROOM KEEPS A COPY. The file moves to .hive-deleted/<when>/<path> in this
|
|
3654
|
+
// folder (ALWAYS_IGNORE, so it never syncs back). If the move fails -- an editor holding the file
|
|
3655
|
+
// on Windows -- it is copied, then removed. If even the copy fails it is still removed, as
|
|
3656
|
+
// before: a file left in place would be read as new by the next scan and resurrected for everyone.
|
|
3657
|
+
function deletedStamp() { return new Date().toISOString().replace(/[:.]/g, '-') }
|
|
3658
|
+
function keepDeletedCopy(relPath, stamp) {
|
|
3659
|
+
if (!isSafeRelPath(relPath)) return null
|
|
3660
|
+
const from = path.join(ROOT, relPath)
|
|
3661
|
+
let st
|
|
3662
|
+
try { st = fs.statSync(from) } catch { return null } // never reached this disk: nothing to keep
|
|
3663
|
+
if (!st.isFile()) return null
|
|
3664
|
+
const keptAs = `${DELETED_DIR}/${stamp}/${relPath}`
|
|
3665
|
+
const to = path.join(ROOT, keptAs)
|
|
3666
|
+
try { fs.mkdirSync(path.dirname(to), { recursive: true }) } catch { }
|
|
3667
|
+
try { fs.renameSync(from, to); return keptAs } catch { }
|
|
3668
|
+
let copied = false
|
|
3669
|
+
try { fs.copyFileSync(from, to); copied = true } catch { }
|
|
3670
|
+
try { fs.rmSync(from) } catch { }
|
|
3671
|
+
return copied ? keptAs : null
|
|
3672
|
+
}
|
|
3673
|
+
// Only folders this code named (an ISO stamp) and only once they are a week old: anything else a
|
|
3674
|
+
// person put in .hive-deleted/ is theirs.
|
|
3675
|
+
function pruneDeletedCopies() {
|
|
3676
|
+
const base = path.join(ROOT, DELETED_DIR)
|
|
3677
|
+
let names = []
|
|
3678
|
+
try { names = fs.readdirSync(base) } catch { return }
|
|
3679
|
+
const cutoff = Date.now() - DELETED_KEEP_MS
|
|
3680
|
+
for (const n of names) {
|
|
3681
|
+
const m = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/.exec(n)
|
|
3682
|
+
if (!m) continue
|
|
3683
|
+
const t = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`)
|
|
3684
|
+
if (t < cutoff) { try { fs.rmSync(path.join(base, n), { recursive: true, force: true }) } catch { } }
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
let scanCount = 0
|
|
2516
3689
|
function scan() {
|
|
3690
|
+
scanCount++
|
|
3691
|
+
if (folderLost) return 0
|
|
3692
|
+
// walk() of a missing folder returns nothing, which used to read as "every file was deleted".
|
|
3693
|
+
if (!rootIsDir()) { loseFolder('is gone'); return 0 }
|
|
3694
|
+
if (unwritable.size) retryUnwritable()
|
|
2517
3695
|
const skippedFulls = []
|
|
2518
3696
|
const diskFulls = walk(ROOT, [], skippedFulls)
|
|
2519
3697
|
const diskRel = new Set(diskFulls.map(rel))
|
|
@@ -2539,61 +3717,399 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2539
3717
|
firstScanDone = true
|
|
2540
3718
|
}
|
|
2541
3719
|
noticeBornInvisible(ignoredRel)
|
|
3720
|
+
const currentUnshared = new Map()
|
|
3721
|
+
let changes = 0
|
|
2542
3722
|
for (const full of diskFulls) {
|
|
2543
3723
|
const r = rel(full)
|
|
2544
3724
|
if (SKIP.has(r)) continue
|
|
3725
|
+
checkCaseCollision(r)
|
|
3726
|
+
if (collided.has(r)) continue
|
|
2545
3727
|
let mt
|
|
2546
3728
|
try { mt = fs.statSync(full).mtimeMs } catch { continue }
|
|
2547
|
-
|
|
2548
|
-
|
|
3729
|
+
|
|
3730
|
+
// 1. If previously known unshared and mtime hasn't changed, skip re-reading buffer!
|
|
3731
|
+
if (unsharedMtimes.get(r) === mt && unsharedWarned.has(r)) {
|
|
3732
|
+
currentUnshared.set(r, unsharedWarned.get(r))
|
|
3733
|
+
continue
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3736
|
+
// 2. If known shareable and mtime hasn't changed, skip!
|
|
3737
|
+
if (mtimes.get(r) === mt && inManifest(r)) continue
|
|
3738
|
+
|
|
3739
|
+
// 3. mtime changed or new file: check unshared
|
|
3740
|
+
const unshared = checkUnshared(full)
|
|
3741
|
+
if (unshared) {
|
|
3742
|
+
unsharedMtimes.set(r, mt)
|
|
3743
|
+
currentUnshared.set(r, unshared)
|
|
3744
|
+
continue
|
|
3745
|
+
} else {
|
|
3746
|
+
unsharedMtimes.delete(r)
|
|
3747
|
+
}
|
|
3748
|
+
|
|
3749
|
+
if (readText(full) === null) {
|
|
3750
|
+
if (isBinaryFile(full)) {
|
|
3751
|
+
mtimes.set(r, mt)
|
|
3752
|
+
reconcileBinary(r, 'local')
|
|
3753
|
+
changes++
|
|
3754
|
+
continue
|
|
3755
|
+
}
|
|
3756
|
+
continue
|
|
3757
|
+
}
|
|
2549
3758
|
mtimes.set(r, mt)
|
|
2550
3759
|
reconcile(r, 'local')
|
|
3760
|
+
changes++
|
|
2551
3761
|
}
|
|
2552
|
-
|
|
3762
|
+
noticeUnshared(currentUnshared)
|
|
3763
|
+
const removed = [...known].filter((r) => !diskRel.has(r) && inManifest(r) && !pendingLocalDeletes.has(r) && !collided.has(r))
|
|
2553
3764
|
if (removed.length) {
|
|
2554
|
-
|
|
2555
|
-
for (const r of removed) {
|
|
3765
|
+
changes += removed.length
|
|
3766
|
+
for (const r of removed) {
|
|
3767
|
+
dischargeUnshared(r)
|
|
3768
|
+
noteLocalDelete(r) // leaves with its batch; see settleLocalDeletes
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
return changes
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
// Non-blocking chunked file opening:
|
|
3775
|
+
// When an existing room doc syncs on join, 150-900 files arrive in manifest at once.
|
|
3776
|
+
// Opening them all synchronously inside Y.applyUpdate froze the Node event loop for
|
|
3777
|
+
// 5-8+ seconds, blowing past hive_join's 8s sync timeout and causing false join failures.
|
|
3778
|
+
// Chunking opens via setImmediate drains the batch smoothly in 32-file slices, firing
|
|
3779
|
+
// provider.on('sync') within milliseconds while keeping single-file live edits instant.
|
|
3780
|
+
const openQueue = []
|
|
3781
|
+
const openQueueSet = new Set()
|
|
3782
|
+
let openTimer = null
|
|
3783
|
+
// Test-only: hold the first drain this long, so a test can land a peer's delete while a key waits.
|
|
3784
|
+
const OPEN_DELAY_MS = Number(process.env.HIVE_TEST_OPEN_DELAY_MS) || 0
|
|
3785
|
+
let openTurns = 0 // drains run; tests/join-scale-test.mjs pins how few a big join takes
|
|
3786
|
+
function drainOpenQueue() {
|
|
3787
|
+
openTimer = null
|
|
3788
|
+
openTurns++
|
|
3789
|
+
// 256 A TURN ON ONE SOCKET, 32 ON A SOCKET PER FILE (27 Sep 2026). Each turn boundary waits
|
|
3790
|
+
// behind whatever else the loop runs in between, and during a big join that is a lot: measured
|
|
3791
|
+
// on a 1,000-file room arriving in one manifest event, every 32-file turn ran 1.1-1.4 s after
|
|
3792
|
+
// the last, so the joiner took ~49 s to have the files on disk; 256 a turn, 25 s. On the mux
|
|
3793
|
+
// transport an OPEN is one frame on a socket that already exists; on per-file sockets each is
|
|
3794
|
+
// a new connection, so that path keeps 32. The 100 ms ceiling keeps a slow machine from
|
|
3795
|
+
// holding the loop through a turn -- past the first 32, a turn stops when it runs out of time.
|
|
3796
|
+
const BATCH = fileTransport.mode === 'mux' ? 256 : 32
|
|
3797
|
+
const t0 = Date.now()
|
|
3798
|
+
let openedCount = 0
|
|
3799
|
+
while (openQueue.length && openedCount < BATCH && !stopped && (openedCount < 32 || Date.now() - t0 < 100)) {
|
|
3800
|
+
const key = openQueue.shift()
|
|
3801
|
+
openQueueSet.delete(key)
|
|
3802
|
+
if (fileDocs.size >= maxOpen()) {
|
|
3803
|
+
openQueue.length = 0
|
|
3804
|
+
openQueueSet.clear()
|
|
3805
|
+
break
|
|
3806
|
+
}
|
|
3807
|
+
// STILL WANTED? A key can wait here while a peer deletes the file. Its document still exists on
|
|
3808
|
+
// the relay, so opening it would bring the deleted file back to this disk and publish it again
|
|
3809
|
+
// for everyone. Wanted = still in the room, or on this disk (a new local file not yet published).
|
|
3810
|
+
const wanted = inManifest(key) || fs.existsSync(path.join(ROOT, key))
|
|
3811
|
+
if (wanted && canOpen(key) && !SKIP.has(key) && !isIgnored(key) && !fileDocs.has(key)) {
|
|
3812
|
+
openFile(key)
|
|
3813
|
+
openedCount++
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
if (openQueue.length && !stopped && fileDocs.size < maxOpen()) {
|
|
3817
|
+
openTimer = setImmediate(drainOpenQueue)
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
function queueOpenFile(key) {
|
|
3822
|
+
if (fileDocs.has(key) || fileDocs.size >= maxOpen() || openQueueSet.has(key)) return
|
|
3823
|
+
openQueueSet.add(key)
|
|
3824
|
+
if (fs.existsSync(path.join(ROOT, key))) openQueue.unshift(key)
|
|
3825
|
+
else openQueue.push(key)
|
|
3826
|
+
if (!openTimer && !stopped) {
|
|
3827
|
+
openTimer = OPEN_DELAY_MS ? setTimeout(drainOpenQueue, OPEN_DELAY_MS) : setImmediate(drainOpenQueue)
|
|
2556
3828
|
}
|
|
2557
3829
|
}
|
|
2558
3830
|
|
|
2559
3831
|
// Manifest changes from OTHERS: a new path -> open its file-room (its sync pulls
|
|
2560
|
-
// the content); a removed path -> tear down
|
|
3832
|
+
// the content); a removed path -> tear down, and move the local file into .hive-deleted/.
|
|
2561
3833
|
if (syncFiles) manifest.observe((ev, txn) => {
|
|
2562
3834
|
if (txn.local) return
|
|
3835
|
+
if (folderLost) return // nothing in a lost folder is written, moved or recreated
|
|
3836
|
+
let stamp = null
|
|
3837
|
+
let kept = 0
|
|
3838
|
+
// Process deletions BEFORE additions so that renames (delete old + add new)
|
|
3839
|
+
// retire the old path and free canonicalCaseKeys before the new casing is checked.
|
|
3840
|
+
const deletes = []
|
|
3841
|
+
const others = []
|
|
2563
3842
|
ev.changes.keys.forEach((change, key) => {
|
|
2564
|
-
if (change.action === 'delete')
|
|
2565
|
-
|
|
2566
|
-
try { fs.rmSync(path.join(ROOT, key)) } catch { }
|
|
2567
|
-
known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key)
|
|
2568
|
-
log(`[${name}] <- deleted ${key}`)
|
|
2569
|
-
} else if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
|
|
2570
|
-
// Same rule as the initial sweep: a path this client ignores must not cost it a
|
|
2571
|
-
// socket just because somebody else registered it.
|
|
2572
|
-
//
|
|
2573
|
-
// AND the same BOUND. This observer is how a joining client learns the room's whole
|
|
2574
|
-
// manifest — every existing path arrives here as a remote change — so capping only
|
|
2575
|
-
// the sweep achieved nothing: the first measurement of this cap showed a client with
|
|
2576
|
-
// cap 8 holding 30 file docs, every one of them opened right here. A path we decline
|
|
2577
|
-
// to open now is not lost; it opens when something touches it.
|
|
2578
|
-
if (fileDocs.size < maxOpen()) openFile(key)
|
|
2579
|
-
}
|
|
3843
|
+
if (change.action === 'delete') deletes.push([key, change])
|
|
3844
|
+
else others.push([key, change])
|
|
2580
3845
|
})
|
|
3846
|
+
|
|
3847
|
+
for (const [key, change] of deletes) {
|
|
3848
|
+
let promotedKey = null
|
|
3849
|
+
if (isCaseInsensitive) {
|
|
3850
|
+
const cf = fold(key)
|
|
3851
|
+
clearCollidedTimer(key)
|
|
3852
|
+
if (collided.has(key)) {
|
|
3853
|
+
collided.delete(key)
|
|
3854
|
+
collidedWarned.delete(key)
|
|
3855
|
+
} else if (canonicalCaseKeys.get(cf) === key) {
|
|
3856
|
+
canonicalCaseKeys.delete(cf)
|
|
3857
|
+
for (const [colKey, info] of collided.entries()) {
|
|
3858
|
+
if (info.with === key && inManifest(colKey)) {
|
|
3859
|
+
collided.delete(colKey)
|
|
3860
|
+
collidedWarned.delete(colKey)
|
|
3861
|
+
clearCollidedTimer(colKey)
|
|
3862
|
+
canonicalCaseKeys.set(cf, colKey)
|
|
3863
|
+
promotedKey = colKey
|
|
3864
|
+
break
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
}
|
|
3869
|
+
pendingManifestOps.delete(key)
|
|
3870
|
+
closeFile(key)
|
|
3871
|
+
{ const qi = openQueue.indexOf(key); if (qi >= 0) { openQueue.splice(qi, 1); openQueueSet.delete(key) } } // deleted while queued
|
|
3872
|
+
if (!stamp) stamp = deletedStamp() // one folder per batch, so a bulk delete comes back together
|
|
3873
|
+
const keptAs = keepDeletedCopy(key, stamp)
|
|
3874
|
+
if (keptAs) kept++
|
|
3875
|
+
known.delete(key); bases.delete(key); forkBases.delete(key); mtimes.delete(key); shadow.delete(key); shaShadow.delete(key); unwritable.delete(key)
|
|
3876
|
+
log(`[${name}] <- deleted ${key}${keptAs ? ` (copy kept: ${keptAs})` : ''}`)
|
|
3877
|
+
if (promotedKey) {
|
|
3878
|
+
reconcile(promotedKey, 'remote')
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
|
|
3882
|
+
for (const [key, change] of others) {
|
|
3883
|
+
if (canOpen(key) && !SKIP.has(key) && !isIgnored(key)) {
|
|
3884
|
+
checkCaseCollision(key)
|
|
3885
|
+
if (ev.changes.keys.size <= 2 && fileDocs.size < maxOpen()) {
|
|
3886
|
+
openFile(key)
|
|
3887
|
+
} else {
|
|
3888
|
+
queueOpenFile(key)
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3892
|
+
if (kept > 1) log(`[${name}] <- ${kept} files deleted by a teammate; copies kept in ${DELETED_DIR}/${stamp}/`)
|
|
2581
3893
|
})
|
|
2582
3894
|
|
|
2583
3895
|
// INSTANT propagation: fs.watch fires on the actual edit, so changes go out in
|
|
2584
3896
|
// ~tens of ms instead of waiting for the poll. A periodic scan stays as a
|
|
2585
3897
|
// safety net (and the only mechanism where recursive watch isn't supported).
|
|
3898
|
+
//
|
|
3899
|
+
// ADAPTIVE SAFETY NET:
|
|
3900
|
+
// When fs.watch is healthy, scanning untouched files every 2s wastes ~37ms of CPU
|
|
3901
|
+
// on a 900-file folder (1,108ms/min). If a scan detects ZERO changes that fs.watch
|
|
3902
|
+
// hadn't already reconciled, we double the interval: 2s -> 4s -> 8s -> 16s.
|
|
3903
|
+
// The instant a scan catches an unobserved disk change (e.g. editor atomic save
|
|
3904
|
+
// or network drive missing watcher events), interval snaps back to 2s immediately.
|
|
3905
|
+
// When recursive watch is unsupported (!watcher), interval stays fixed at 400ms.
|
|
3906
|
+
const SCAN_MIN_MS = 2000
|
|
3907
|
+
const SCAN_MAX_MS = 16000
|
|
3908
|
+
const SCAN_NO_WATCHER_MS = 400
|
|
3909
|
+
let scanInterval = SCAN_MIN_MS
|
|
2586
3910
|
let scanTimer = null
|
|
2587
3911
|
let watcher = null
|
|
2588
3912
|
let debounce = null
|
|
2589
|
-
|
|
3913
|
+
|
|
3914
|
+
function scheduleNextScan(ms) {
|
|
3915
|
+
if (stopped || folderLost) return
|
|
3916
|
+
if (scanTimer) clearTimeout(scanTimer)
|
|
3917
|
+
// Ref'd, like the setInterval it replaced: a headless client whose socket is down may have
|
|
3918
|
+
// nothing else holding the process open, and an unref'd net would let it exit silently.
|
|
3919
|
+
scanTimer = setTimeout(runAdaptiveScan, ms)
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
const pendingFsEvents = new Map() // relPath -> Set<'change'|'rename'>
|
|
3923
|
+
let pendingFsFullScan = false
|
|
3924
|
+
|
|
3925
|
+
function flushFsEvents() {
|
|
3926
|
+
debounce = null
|
|
3927
|
+
if (stopped || folderLost) return
|
|
3928
|
+
if (!rootIsDir()) { pendingFsEvents.clear(); loseFolder('is gone'); return }
|
|
3929
|
+
if (!firstScanDone || pendingFsFullScan || pendingFsEvents.size > 256) {
|
|
3930
|
+
pendingFsEvents.clear()
|
|
3931
|
+
pendingFsFullScan = false
|
|
3932
|
+
scan()
|
|
3933
|
+
return
|
|
3934
|
+
}
|
|
3935
|
+
|
|
3936
|
+
const entries = [...pendingFsEvents.entries()]
|
|
3937
|
+
pendingFsEvents.clear()
|
|
3938
|
+
pendingFsFullScan = false
|
|
3939
|
+
|
|
3940
|
+
let changes = 0
|
|
3941
|
+
for (const [r, evts] of entries) {
|
|
3942
|
+
if (SKIP.has(r) || OWN_LOCAL.has(r)) continue
|
|
3943
|
+
if (isIgnored(r)) {
|
|
3944
|
+
// Never reconciled -- but HIV-P8's born-invisible notice must not wait for the next full
|
|
3945
|
+
// scan (up to 16 s away), and a deleted one takes its debt with it, exactly as scan() does.
|
|
3946
|
+
if (walkSkipsAsIgnoredFile(r)) {
|
|
3947
|
+
let isFile = false
|
|
3948
|
+
try { isFile = fs.statSync(path.join(ROOT, r)).isFile() } catch { }
|
|
3949
|
+
if (isFile) noticeInvisibleOne(r); else dischargeInvisible(r)
|
|
3950
|
+
}
|
|
3951
|
+
continue
|
|
3952
|
+
}
|
|
3953
|
+
const full = path.join(ROOT, r)
|
|
3954
|
+
let st
|
|
3955
|
+
try {
|
|
3956
|
+
st = fs.statSync(full)
|
|
3957
|
+
} catch {
|
|
3958
|
+
// Path does not exist on disk (deleted file or directory)
|
|
3959
|
+
dischargeUnshared(r)
|
|
3960
|
+
if (known.has(r) && inManifest(r)) {
|
|
3961
|
+
changes++
|
|
3962
|
+
noteLocalDelete(r) // leaves with its batch; see settleLocalDeletes
|
|
3963
|
+
continue
|
|
3964
|
+
}
|
|
3965
|
+
// Check if r might have been a directory containing tracked files
|
|
3966
|
+
const dirPrefix = r + '/'
|
|
3967
|
+
let wasKnownDir = false
|
|
3968
|
+
for (const k of known) {
|
|
3969
|
+
if (k.startsWith(dirPrefix)) { wasKnownDir = true; break }
|
|
3970
|
+
}
|
|
3971
|
+
if (wasKnownDir) {
|
|
3972
|
+
scan()
|
|
3973
|
+
return
|
|
3974
|
+
}
|
|
3975
|
+
continue
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
// On case-insensitive filesystems, statSync('Readme.md') succeeds even if the file on disk
|
|
3979
|
+
// was renamed to 'README.md'. Verify exact disk path matches r:
|
|
3980
|
+
if (isCaseInsensitive && actualDiskPath(r) !== r) {
|
|
3981
|
+
dischargeUnshared(r)
|
|
3982
|
+
if (known.has(r) && inManifest(r)) {
|
|
3983
|
+
changes++
|
|
3984
|
+
noteLocalDelete(r)
|
|
3985
|
+
const diskTarget = actualDiskPath(r)
|
|
3986
|
+
if (diskTarget && fold(diskTarget) === fold(r)) {
|
|
3987
|
+
checkCaseCollision(diskTarget)
|
|
3988
|
+
queueManifestSet(diskTarget)
|
|
3989
|
+
}
|
|
3990
|
+
continue
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3994
|
+
if (st.isDirectory()) {
|
|
3995
|
+
// If a directory was created/renamed or received a 'rename' event, fall back to full scan
|
|
3996
|
+
if (evts.has('rename')) {
|
|
3997
|
+
scan()
|
|
3998
|
+
return
|
|
3999
|
+
}
|
|
4000
|
+
// On Windows (and some other OSes), writing to a file inside a directory also emits 'change' on the directory.
|
|
4001
|
+
// Ignore the directory 'change' event; the file itself is processed in this batch.
|
|
4002
|
+
continue
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
if (!st.isFile()) continue
|
|
4006
|
+
if (unsharedMtimes.get(r) === st.mtimeMs && unsharedWarned.has(r)) continue
|
|
4007
|
+
if (mtimes.get(r) === st.mtimeMs && inManifest(r)) continue
|
|
4008
|
+
checkCaseCollision(r)
|
|
4009
|
+
if (collided.has(r)) continue
|
|
4010
|
+
const unshared = checkUnshared(full, st)
|
|
4011
|
+
if (unshared) {
|
|
4012
|
+
unsharedMtimes.set(r, st.mtimeMs)
|
|
4013
|
+
noticeUnsharedOne(r, unshared)
|
|
4014
|
+
continue
|
|
4015
|
+
} else {
|
|
4016
|
+
unsharedMtimes.delete(r)
|
|
4017
|
+
dischargeUnshared(r)
|
|
4018
|
+
}
|
|
4019
|
+
if (readText(full) === null) {
|
|
4020
|
+
if (isBinaryFile(full, st)) {
|
|
4021
|
+
mtimes.set(r, st.mtimeMs)
|
|
4022
|
+
reconcileBinary(r, 'local')
|
|
4023
|
+
changes++
|
|
4024
|
+
continue
|
|
4025
|
+
}
|
|
4026
|
+
continue
|
|
4027
|
+
}
|
|
4028
|
+
mtimes.set(r, st.mtimeMs)
|
|
4029
|
+
reconcile(r, 'local')
|
|
4030
|
+
changes++
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
|
|
4034
|
+
function runAdaptiveScan() {
|
|
4035
|
+
scanTimer = null
|
|
4036
|
+
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
4037
|
+
pendingFsEvents.clear()
|
|
4038
|
+
pendingFsFullScan = false
|
|
4039
|
+
const changes = scan()
|
|
4040
|
+
if (!watcher) {
|
|
4041
|
+
scanInterval = SCAN_NO_WATCHER_MS
|
|
4042
|
+
scheduleNextScan(scanInterval)
|
|
4043
|
+
return
|
|
4044
|
+
}
|
|
4045
|
+
if (changes > 0) {
|
|
4046
|
+
// Caught a change fs.watch missed! Snap back to minimum interval.
|
|
4047
|
+
scanInterval = SCAN_MIN_MS
|
|
4048
|
+
} else {
|
|
4049
|
+
// Nothing missed: back off by doubling interval up to 16s cap.
|
|
4050
|
+
scanInterval = Math.min(SCAN_MAX_MS, scanInterval * 2)
|
|
4051
|
+
}
|
|
4052
|
+
scheduleNextScan(scanInterval)
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
const onFsEvent = (evt, fname) => {
|
|
2590
4056
|
if (fname && String(fname).endsWith('.gitignore')) reloadIgnores()
|
|
4057
|
+
if (!fname) {
|
|
4058
|
+
pendingFsFullScan = true
|
|
4059
|
+
} else {
|
|
4060
|
+
const norm = String(fname).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
4061
|
+
if (!norm || norm === '.') {
|
|
4062
|
+
pendingFsFullScan = true
|
|
4063
|
+
} else {
|
|
4064
|
+
let set = pendingFsEvents.get(norm)
|
|
4065
|
+
if (!set) {
|
|
4066
|
+
set = new Set()
|
|
4067
|
+
pendingFsEvents.set(norm, set)
|
|
4068
|
+
}
|
|
4069
|
+
set.add(evt)
|
|
4070
|
+
if (pendingFsEvents.size > 256) pendingFsFullScan = true
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
2591
4073
|
if (debounce) return
|
|
2592
|
-
debounce = setTimeout(
|
|
4074
|
+
debounce = setTimeout(flushFsEvents, 40)
|
|
2593
4075
|
}
|
|
2594
4076
|
function startWatch() {
|
|
2595
4077
|
try { watcher = fs.watch(ROOT, { recursive: true }, onFsEvent) }
|
|
2596
4078
|
catch { watcher = null } // recursive watch unsupported (some Linux) -> rely on scan
|
|
4079
|
+
// A watcher error used to be an uncaught 'error' event: deleting the room folder on Windows
|
|
4080
|
+
// (EPERM) killed the whole process, and with it the agent's hivecode server.
|
|
4081
|
+
if (watcher) watcher.on('error', onWatchError)
|
|
4082
|
+
}
|
|
4083
|
+
function onWatchError(e) {
|
|
4084
|
+
try { watcher && watcher.close() } catch { }
|
|
4085
|
+
watcher = null
|
|
4086
|
+
if (stopped || folderLost) return
|
|
4087
|
+
if (!rootIsDir()) { loseFolder('is gone'); return }
|
|
4088
|
+
log(`[${name}] the folder watcher failed (${(e && e.code) || e}); checking the folder every ${SCAN_NO_WATCHER_MS} ms instead`)
|
|
4089
|
+
scheduleNextScan(SCAN_NO_WATCHER_MS)
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
// THE FOLDER IS GONE: say so once, and stop touching the disk. Every way back is a fresh join,
|
|
4093
|
+
// which compares the folder with the room the same way a first join does, so nothing a person
|
|
4094
|
+
// put back is overwritten. Stopping, rather than carrying on, is deliberate: a remote write would
|
|
4095
|
+
// recreate the folder holding one file, and the next pass would read the rest as deleted.
|
|
4096
|
+
function loseFolder(why) {
|
|
4097
|
+
if (folderLost || stopped) return
|
|
4098
|
+
folderLost = {
|
|
4099
|
+
at: Date.now(),
|
|
4100
|
+
message: `Q580: this room's folder ${why} (${ROOT}). Nothing was deleted for anyone: ` +
|
|
4101
|
+
`file sync stopped here, so the room keeps every file. Chat and claims still work. To get the ` +
|
|
4102
|
+
`room's files back, call hive_join again with the same link: an empty folder is filled from ` +
|
|
4103
|
+
`the room. If the folder was moved on purpose, join from its new place.`,
|
|
4104
|
+
}
|
|
4105
|
+
try { watcher && watcher.close() } catch { }
|
|
4106
|
+
watcher = null
|
|
4107
|
+
if (scanTimer) { clearTimeout(scanTimer); scanTimer = null }
|
|
4108
|
+
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
4109
|
+
if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null }
|
|
4110
|
+
pendingFsEvents.clear()
|
|
4111
|
+
pendingLocalDeletes.clear()
|
|
4112
|
+
log(`[${name}] ${folderLost.message}`)
|
|
2597
4113
|
}
|
|
2598
4114
|
|
|
2599
4115
|
provider.on('sync', (s) => {
|
|
@@ -2636,7 +4152,8 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2636
4152
|
return la - lb
|
|
2637
4153
|
})
|
|
2638
4154
|
const cap = maxOpen()
|
|
2639
|
-
for (const
|
|
4155
|
+
for (const k of localFirst) checkCaseCollision(k)
|
|
4156
|
+
for (const key of localFirst.slice(0, cap)) queueOpenFile(key)
|
|
2640
4157
|
if (final && localFirst.length > cap) {
|
|
2641
4158
|
log(`[${name}] ${localFirst.length} files in this room, holding ${cap} open (HIVE_MAX_OPEN_FILES). The rest sync when touched.`)
|
|
2642
4159
|
}
|
|
@@ -2670,9 +4187,10 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2670
4187
|
fileTransport.ready.then(() => setTimeout(() => {
|
|
2671
4188
|
try {
|
|
2672
4189
|
const want = [...manifest.keys()].filter((r) => canOpen(r) && !SKIP.has(r))
|
|
4190
|
+
if (want.length <= maxOpen() || stopped) return // the bound leaves nothing out: no need to stat every file
|
|
2673
4191
|
const have = want.filter((r) => fs.existsSync(path.join(ROOT, r)))
|
|
2674
4192
|
const short = want.length - have.length
|
|
2675
|
-
if (short <= 0
|
|
4193
|
+
if (short <= 0) return
|
|
2676
4194
|
log(`[${name}] !! THIS FOLDER IS INCOMPLETE ON PURPOSE. The room has ${want.length} files; ` +
|
|
2677
4195
|
`${have.length} are on disk here and ${short} are NOT. The working set is bounded to ` +
|
|
2678
4196
|
`${maxOpen()} open files (HIVE_MAX_OPEN_FILES), because ` +
|
|
@@ -2684,10 +4202,78 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2684
4202
|
}, 4000).unref?.())
|
|
2685
4203
|
if (!scanTimer) {
|
|
2686
4204
|
startWatch()
|
|
2687
|
-
|
|
4205
|
+
scanInterval = watcher ? SCAN_MIN_MS : SCAN_NO_WATCHER_MS
|
|
4206
|
+
scheduleNextScan(scanInterval)
|
|
2688
4207
|
}
|
|
2689
4208
|
})
|
|
2690
4209
|
|
|
4210
|
+
// Evaluates consensus across peer measurements for a path and version.
|
|
4211
|
+
function evaluateAttestations(rec, curVer, roomDocHash) {
|
|
4212
|
+
const currentPeers = []
|
|
4213
|
+
const formattedPeers = {}
|
|
4214
|
+
for (const [peerName, v] of Object.entries(rec || {})) {
|
|
4215
|
+
if (!v) continue
|
|
4216
|
+
const isCurrent = (v.version || 1) === (curVer.n || 1)
|
|
4217
|
+
if (isCurrent) {
|
|
4218
|
+
currentPeers.push({ peer: peerName, ...v })
|
|
4219
|
+
let note = ''
|
|
4220
|
+
if (v.stuckLagging) {
|
|
4221
|
+
note = ` (${v.stuckReason || 'stuck lagging'})`
|
|
4222
|
+
} else if (v.lagging) {
|
|
4223
|
+
note = ` (${v.lagNotice || 'your folder has not caught up yet'})`
|
|
4224
|
+
}
|
|
4225
|
+
formattedPeers[peerName] = { ...v, stale: false, note }
|
|
4226
|
+
} else {
|
|
4227
|
+
formattedPeers[peerName] = {
|
|
4228
|
+
...v,
|
|
4229
|
+
stale: true,
|
|
4230
|
+
staleReason: `stale: attested version ${v.version || 0}, room is at ${curVer.n}`
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
// Peers that are present, have a hash, and are NOT lagging (stuck peers are kept out of quorum too)
|
|
4236
|
+
const presentValid = currentPeers.filter((v) => !v.absent && v.hash && !v.lagging && !v.stuckLagging)
|
|
4237
|
+
const validHashes = [...new Set(presentValid.map((v) => v.hash))]
|
|
4238
|
+
const validSizes = [...new Set(presentValid.map((v) => v.bytes))]
|
|
4239
|
+
|
|
4240
|
+
// Peers that count toward comparison:
|
|
4241
|
+
// A peer lagging inside the grace period counts toward NEITHER quorum NOR divergence!
|
|
4242
|
+
// Compare hashes over non-lagging peers plus stuck peers only.
|
|
4243
|
+
const comparisonPeers = currentPeers.filter((v) => !v.absent && v.hash && (!v.lagging || v.stuckLagging))
|
|
4244
|
+
const comparisonHashes = [...new Set(comparisonPeers.map((v) => v.hash))]
|
|
4245
|
+
const comparisonSizes = [...new Set(comparisonPeers.map((v) => v.bytes))]
|
|
4246
|
+
const anyAbsent = currentPeers.some((v) => v.absent)
|
|
4247
|
+
const anyStuck = currentPeers.some((v) => v.stuckLagging)
|
|
4248
|
+
|
|
4249
|
+
// Quorum: requires at least 2 non-lagging peers of current version with identical hash, no absent, no stuck
|
|
4250
|
+
let quorum = presentValid.length >= 2 && validHashes.length === 1 && !anyAbsent && !anyStuck
|
|
4251
|
+
|
|
4252
|
+
// Divergence:
|
|
4253
|
+
// 1. Two comparison peers (non-lagging or stuck) disagree on disk hash
|
|
4254
|
+
// 2. OR one comparison peer is absent while another is present
|
|
4255
|
+
// 3. OR any peer is stuck lagging (>grace period passed without catching up)
|
|
4256
|
+
const split = comparisonHashes.length > 1 || (anyAbsent && comparisonPeers.length > 0) || anyStuck
|
|
4257
|
+
|
|
4258
|
+
let quorumOnOlder = false
|
|
4259
|
+
if (quorum && roomDocHash && validHashes[0] && roomDocHash !== validHashes[0]) {
|
|
4260
|
+
quorum = false
|
|
4261
|
+
quorumOnOlder = true
|
|
4262
|
+
}
|
|
4263
|
+
|
|
4264
|
+
return {
|
|
4265
|
+
peers: formattedPeers,
|
|
4266
|
+
currentPeers,
|
|
4267
|
+
count: currentPeers.length,
|
|
4268
|
+
totalCount: Object.keys(rec || {}).length,
|
|
4269
|
+
sizes: comparisonSizes.length ? comparisonSizes : [...new Set(currentPeers.filter(v => !v.absent && v.hash).map(v => v.bytes))],
|
|
4270
|
+
diverged: split,
|
|
4271
|
+
quorum,
|
|
4272
|
+
quorumOnOlder,
|
|
4273
|
+
version: curVer.n || 1
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
|
|
2691
4277
|
return {
|
|
2692
4278
|
doc,
|
|
2693
4279
|
provider,
|
|
@@ -2757,24 +4343,140 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2757
4343
|
// whether the peers DISAGREE — see the swarm_attest declaration for why it is keyed per peer.
|
|
2758
4344
|
attest: (relPath, bytes, lines) => {
|
|
2759
4345
|
const p = String(relPath)
|
|
4346
|
+
const full = path.join(ROOT, p)
|
|
4347
|
+
let measuredHash = null
|
|
4348
|
+
let measuredBytes = null
|
|
4349
|
+
let measuredLines = null
|
|
4350
|
+
let absent = false
|
|
4351
|
+
let mismatch = null
|
|
4352
|
+
let diskContent = null
|
|
4353
|
+
|
|
4354
|
+
if (fs.existsSync(full)) {
|
|
4355
|
+
try {
|
|
4356
|
+
diskContent = fs.readFileSync(full, 'utf8')
|
|
4357
|
+
measuredHash = contentHash(diskContent)
|
|
4358
|
+
measuredBytes = Buffer.byteLength(diskContent, 'utf8')
|
|
4359
|
+
const normalized = diskContent.endsWith('\n') ? diskContent.slice(0, -1) : diskContent
|
|
4360
|
+
measuredLines = diskContent.length === 0 ? 0 : normalized.split(/\r?\n/).length
|
|
4361
|
+
const statedBytes = Number(bytes)
|
|
4362
|
+
if (Number.isFinite(statedBytes) && statedBytes > 0 && statedBytes !== measuredBytes) {
|
|
4363
|
+
mismatch = { stated: statedBytes, measured: measuredBytes }
|
|
4364
|
+
}
|
|
4365
|
+
} catch {
|
|
4366
|
+
absent = true
|
|
4367
|
+
}
|
|
4368
|
+
} else {
|
|
4369
|
+
absent = true
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
// Check if room doc exists and whether disk differs from room doc
|
|
4373
|
+
const fe = fileDocs.get(p)
|
|
4374
|
+
let roomDocHash = null
|
|
4375
|
+
let roomDocBytes = null
|
|
4376
|
+
let lagging = false
|
|
4377
|
+
let lagDirection = null
|
|
4378
|
+
let lagNotice = null
|
|
4379
|
+
|
|
4380
|
+
if (fe && fe.text && fe.text.length > 0 && measuredHash) {
|
|
4381
|
+
const docText = fe.text.toString()
|
|
4382
|
+
roomDocHash = contentHash(docText)
|
|
4383
|
+
roomDocBytes = Buffer.byteLength(docText, 'utf8')
|
|
4384
|
+
if (measuredHash !== roomDocHash) {
|
|
4385
|
+
lagging = true
|
|
4386
|
+
const base = bases.get(p)
|
|
4387
|
+
if (base !== undefined && base === docText) {
|
|
4388
|
+
lagDirection = 'ahead'
|
|
4389
|
+
lagNotice = 'disk ahead of room doc: local edit not published to room yet (your folder has not caught up yet)'
|
|
4390
|
+
} else if (base !== undefined && base === diskContent) {
|
|
4391
|
+
lagDirection = 'behind'
|
|
4392
|
+
lagNotice = 'disk behind room doc: your folder has not caught up yet'
|
|
4393
|
+
} else {
|
|
4394
|
+
lagDirection = 'lagging'
|
|
4395
|
+
lagNotice = 'disk out of step with the room\'s copy (your folder has not caught up yet)'
|
|
4396
|
+
}
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
let curVer = swarmVersions.get(p)
|
|
4401
|
+
if (!curVer) {
|
|
4402
|
+
curVer = { n: 1, hash: measuredHash || '' }
|
|
4403
|
+
swarmVersions.set(p, curVer)
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
const GRACE_MS = Number(process.env.HIVE_ATTEST_GRACE_MS) || 10000
|
|
2760
4407
|
const prev = swarmAttest.get(p) || {}
|
|
2761
|
-
const
|
|
4408
|
+
const prevAttest = prev[name]
|
|
4409
|
+
|
|
4410
|
+
let stuckLagging = false
|
|
4411
|
+
let stuckReason = null
|
|
4412
|
+
if (lagging && prevAttest && (prevAttest.lagging || prevAttest.stuckLagging) && prevAttest.laggingDocHash === roomDocHash) {
|
|
4413
|
+
const prevAt = prevAttest.at ? new Date(prevAttest.at).getTime() : 0
|
|
4414
|
+
const elapsedMs = Date.now() - prevAt
|
|
4415
|
+
if (elapsedMs >= GRACE_MS) {
|
|
4416
|
+
stuckLagging = true
|
|
4417
|
+
stuckReason = `not catching up to room doc after ${Math.round(elapsedMs / 1000)}s: disk ${measuredBytes}b vs doc ${roomDocBytes}b (${lagDirection || 'lagging'})`
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4421
|
+
const next = {
|
|
4422
|
+
...prev,
|
|
4423
|
+
[name]: {
|
|
4424
|
+
bytes: measuredBytes,
|
|
4425
|
+
lines: measuredLines,
|
|
4426
|
+
hash: measuredHash,
|
|
4427
|
+
absent,
|
|
4428
|
+
mismatch,
|
|
4429
|
+
lagging: lagging && !stuckLagging,
|
|
4430
|
+
lagDirection: lagDirection || undefined,
|
|
4431
|
+
lagNotice: lagNotice || undefined,
|
|
4432
|
+
laggingDocHash: lagging ? roomDocHash : undefined,
|
|
4433
|
+
stuckLagging,
|
|
4434
|
+
stuckReason: stuckReason || undefined,
|
|
4435
|
+
version: curVer.n,
|
|
4436
|
+
at: (lagging && prevAttest && (prevAttest.lagging || prevAttest.stuckLagging) && prevAttest.laggingDocHash === roomDocHash)
|
|
4437
|
+
? prevAttest.at
|
|
4438
|
+
: new Date().toISOString()
|
|
4439
|
+
}
|
|
4440
|
+
}
|
|
2762
4441
|
swarmAttest.set(p, next)
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
if (
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
4442
|
+
|
|
4443
|
+
const evaluated = evaluateAttestations(next, curVer, roomDocHash)
|
|
4444
|
+
if (evaluated.diverged) {
|
|
4445
|
+
const detail = evaluated.currentPeers.map((v) => {
|
|
4446
|
+
if (v.absent) return `${v.peer}:ABSENT`
|
|
4447
|
+
if (v.stuckLagging) return `${v.peer}:${v.bytes}b/${v.lines}L[STUCK:${v.stuckReason || 'not catching up'}]`
|
|
4448
|
+
return `${v.peer}:${v.bytes}b/${v.lines}L`
|
|
4449
|
+
}).join(' ')
|
|
4450
|
+
say(`[!] DIVERGENCE on ${p} — peers hold DIFFERENT content: ${detail}. Nobody may write this path until it is reconciled; mark it path:${p}=POISONED if it cannot be.`)
|
|
4451
|
+
}
|
|
4452
|
+
|
|
4453
|
+
return {
|
|
4454
|
+
path: p,
|
|
4455
|
+
mismatch,
|
|
4456
|
+
measuredBytes,
|
|
4457
|
+
measuredLines,
|
|
4458
|
+
measuredHash,
|
|
4459
|
+
lagging: lagging && !stuckLagging,
|
|
4460
|
+
lagNotice,
|
|
4461
|
+
stuckLagging,
|
|
4462
|
+
stuckReason,
|
|
4463
|
+
...evaluated
|
|
4464
|
+
}
|
|
2772
4465
|
},
|
|
2773
4466
|
verified: (relPath) => {
|
|
2774
4467
|
const p = String(relPath)
|
|
4468
|
+
const curVer = swarmVersions.get(p) || { n: 1, hash: '' }
|
|
2775
4469
|
const rec = swarmAttest.get(p) || {}
|
|
2776
|
-
const
|
|
2777
|
-
|
|
4470
|
+
const fe = fileDocs.get(p)
|
|
4471
|
+
let roomDocHash = null
|
|
4472
|
+
if (fe && fe.text && fe.text.length > 0) {
|
|
4473
|
+
roomDocHash = contentHash(fe.text.toString())
|
|
4474
|
+
}
|
|
4475
|
+
const evaluated = evaluateAttestations(rec, curVer, roomDocHash)
|
|
4476
|
+
return {
|
|
4477
|
+
path: p,
|
|
4478
|
+
...evaluated
|
|
4479
|
+
}
|
|
2778
4480
|
},
|
|
2779
4481
|
say, // post a coordination message
|
|
2780
4482
|
assign, // direct a task at a participant (needs approval if target AI has an owner)
|
|
@@ -2797,7 +4499,14 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2797
4499
|
const held = coordinator.acquire(r, i)
|
|
2798
4500
|
// Fire-and-forget on purpose: claim's contract is synchronous and changing that would
|
|
2799
4501
|
// break every caller. Use fetch(path) directly when you need to WAIT for the bytes.
|
|
2800
|
-
if (held) {
|
|
4502
|
+
if (held) {
|
|
4503
|
+
try { fetchFile(r).catch(() => {}) } catch { }
|
|
4504
|
+
try {
|
|
4505
|
+
const full = path.join(ROOT, r)
|
|
4506
|
+
const unshared = checkUnshared(full)
|
|
4507
|
+
if (unshared) noticeUnsharedOne(r, unshared)
|
|
4508
|
+
} catch { }
|
|
4509
|
+
}
|
|
2801
4510
|
return held
|
|
2802
4511
|
}, // true iff you hold it (my intent recorded either way — it's what my receipts sign)
|
|
2803
4512
|
// HIV-P18: bring a file the room has but this folder does not. Resolves when the BYTES have
|
|
@@ -2906,10 +4615,31 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2906
4615
|
const u = s.user
|
|
2907
4616
|
if (!u || !u.name) continue
|
|
2908
4617
|
const prev = seen.get(u.name) || {}
|
|
2909
|
-
seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner, passive: !!(u.passive || prev.passive) })
|
|
4618
|
+
seen.set(u.name, { ...prev, ...u, editing: u.editing || prev.editing, owner: u.owner || prev.owner, passive: !!(u.passive || prev.passive), listener: !!(u.listener || prev.listener) || undefined, app: u.app || prev.app || undefined })
|
|
2910
4619
|
}
|
|
2911
4620
|
return [...seen.values()]
|
|
2912
4621
|
},
|
|
4622
|
+
// ANOTHER PARTICIPANT ALREADY USING THIS NAME. Measured 3 Oct 2026: a second agent joined as
|
|
4623
|
+
// "Claude" beside the first, was told "others here: none yet", and BOTH were then told
|
|
4624
|
+
// "claimed x.js -- it's yours"; the second one's write landed in the first one's folder while
|
|
4625
|
+
// the first held the file. hive_members showed a single Claude. (The same thing once fused a
|
|
4626
|
+
// file to 32,038 lines between two agents called Hermes.) Not counted: this connection, a
|
|
4627
|
+
// listener (it runs under its agent's name on purpose), this same agent a moment ago in this
|
|
4628
|
+
// same folder (same iid), and a person in a second window when this is a person too. A state
|
|
4629
|
+
// with no iid is an older build that cannot say, so it counts.
|
|
4630
|
+
sameNameElsewhere: () => {
|
|
4631
|
+
const me = provider.awareness.clientID
|
|
4632
|
+
const lc = String(name).toLowerCase()
|
|
4633
|
+
const out = []
|
|
4634
|
+
for (const [id, s] of provider.awareness.getStates()) {
|
|
4635
|
+
const u = s && s.user
|
|
4636
|
+
if (id === me || !u || !u.name || String(u.name).toLowerCase() !== lc) continue
|
|
4637
|
+
if (u.listener || u.iid === IID) continue
|
|
4638
|
+
if (kind === 'human' && u.kind === 'human') continue
|
|
4639
|
+
out.push({ name: u.name, kind: u.kind, owner: u.owner, app: u.app })
|
|
4640
|
+
}
|
|
4641
|
+
return out
|
|
4642
|
+
},
|
|
2913
4643
|
// Live connection state. An agent parked on hive_wait has no way to tell a quiet
|
|
2914
4644
|
// room from a dead socket, so it guesses -- and the guess it reaches for is
|
|
2915
4645
|
// hive_join, which tears the session down and rebuilds it (see hive-mcp.js: join
|
|
@@ -2945,10 +4675,18 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2945
4675
|
// So the trade stays and the reporting changes. `files` keeps meaning open docs, because
|
|
2946
4676
|
// tests/open-file-cap-test.mjs:71 pins it as the proof the cap is respected. What is added
|
|
2947
4677
|
// is the pair that was missing: what the ROOM has, and what this client actually HOLDS.
|
|
4678
|
+
flushRoomDoc()
|
|
2948
4679
|
const roomFiles = (() => { try { return [...manifest.keys()].filter((r) => canOpen(r)).length } catch { return 0 } })()
|
|
4680
|
+
// One directory walk, not one existsSync per file: that loop cost 292 ms a call in a
|
|
4681
|
+
// 1,000-file room (dev machine) and blocked the loop throughout. walk() reads a directory
|
|
4682
|
+
// per call; only a path the walk did not list (not on disk yet, or ignored since it was
|
|
4683
|
+
// added) is still checked on its own, so the count means exactly what it meant before.
|
|
2949
4684
|
const held = (() => {
|
|
2950
4685
|
let n = 0
|
|
2951
|
-
try {
|
|
4686
|
+
try {
|
|
4687
|
+
const onDisk = new Set(walk(ROOT).map(rel))
|
|
4688
|
+
for (const r of manifest.keys()) { if (canOpen(r) && !collided.has(r) && (onDisk.has(r) || fs.existsSync(path.join(ROOT, r)))) n++ }
|
|
4689
|
+
} catch { }
|
|
2952
4690
|
return n
|
|
2953
4691
|
})()
|
|
2954
4692
|
return {
|
|
@@ -2963,6 +4701,11 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2963
4701
|
filesMissing: Math.max(0, roomFiles - held),
|
|
2964
4702
|
notSyncing: notSyncing.length,
|
|
2965
4703
|
notSyncingFiles: notSyncing.slice(0, 20),
|
|
4704
|
+
unsharedFiles: [...unsharedWarned.entries()].map(([p, info]) => ({
|
|
4705
|
+
path: p,
|
|
4706
|
+
reason: info.reason,
|
|
4707
|
+
size: info.size,
|
|
4708
|
+
})),
|
|
2966
4709
|
// The working set and its bound. `openCap` x agents must stay under the relay's
|
|
2967
4710
|
// maxConnsTotal, and `evicted` climbing steadily means the cap is too small for
|
|
2968
4711
|
// how this room actually works — both are things an operator can only act on if
|
|
@@ -2971,14 +4714,50 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2971
4714
|
// 'mux' = every file on this client's one socket; 'socket' = a socket per file.
|
|
2972
4715
|
transport: fileTransport.mode,
|
|
2973
4716
|
evicted,
|
|
4717
|
+
scanInterval,
|
|
4718
|
+
...(folderLost ? { folderLost: folderLost.message } : {}),
|
|
4719
|
+
...(unwritable.size ? { unwritable: [...unwritable].map(([p, code]) => ({ path: p, code })) } : {}),
|
|
4720
|
+
...(withheldNow().length ? { withheld: withheldNow() } : {}),
|
|
4721
|
+
...(collided.size ? { collided: [...collided.entries()].map(([p, info]) => ({ path: p, with: info.with })) } : {}),
|
|
2974
4722
|
}
|
|
2975
4723
|
},
|
|
4724
|
+
unshared: () => [...unsharedWarned.entries()].map(([p, info]) => ({ path: p, reason: info.reason, size: info.size })),
|
|
4725
|
+
// The room is told when a change cannot be written here, but in this participant's own name, and
|
|
4726
|
+
// an agent is never handed its own lines. This is how its tools tell it instead.
|
|
4727
|
+
unwritable: () => [...unwritable].map(([p, code]) => ({ path: p, code })),
|
|
4728
|
+
// Files held here because they contain a key: path, kind, line. Never the key.
|
|
4729
|
+
withheld: () => withheldNow(),
|
|
4730
|
+
collided: () => [...collided.entries()].map(([p, info]) => ({ path: p, with: info.with })),
|
|
4731
|
+
_fileSnapSize: (r) => fileDocs.get(r)?.snap?.size || 0,
|
|
4732
|
+
_fileSnaps: (r) => [...(fileDocs.get(r)?.snap?.values() || [])],
|
|
4733
|
+
_fileDoc: (r) => fileDocs.get(r),
|
|
4734
|
+
_fileText: (r) => fileDocs.get(r)?.text?.toString() || '',
|
|
4735
|
+
_watcher: () => watcher,
|
|
4736
|
+
_scanInterval: () => scanInterval,
|
|
4737
|
+
// Just the two booleans hive_say, hive_check/hive_wait and hive_delivered need on every call.
|
|
4738
|
+
// status() also counts filesHeld, one existsSync per file in the room -- 1,000 on a 1,000-file
|
|
4739
|
+
// room, every call; in one profile 0.8 s of a 12 s publish went to polling status() alone.
|
|
4740
|
+
// ...plus folderLost, because the agent learns its folder is gone only where it is looking.
|
|
4741
|
+
connection: () => ({ connected: !!provider.wsconnected, connecting: !!provider.wsconnecting, ...(folderLost ? { folderLost: folderLost.message } : {}) }),
|
|
4742
|
+
_openTurns: () => openTurns,
|
|
4743
|
+
_scanCount: () => scanCount,
|
|
2976
4744
|
stop: () => {
|
|
2977
|
-
|
|
2978
|
-
|
|
4745
|
+
try { flushFirstContact() } catch { } // before `stopped`: a join cut short still says what it kept
|
|
4746
|
+
try { settleLocalDeletes() } catch { } // a delete made just before leaving still leaves, judged like any other
|
|
4747
|
+
stopped = true
|
|
4748
|
+
try { flushRoomDoc() } catch { }
|
|
4749
|
+
if (openTimer) { clearImmediate(openTimer); clearTimeout(openTimer); openTimer = null }
|
|
4750
|
+
if (renderBoardTimer) { clearImmediate(renderBoardTimer); renderBoardTimer = null }
|
|
4751
|
+
openQueue.length = 0
|
|
4752
|
+
openQueueSet.clear()
|
|
4753
|
+
if (scanTimer) clearTimeout(scanTimer)
|
|
4754
|
+
if (debounce) { clearTimeout(debounce); debounce = null }
|
|
4755
|
+
for (const tm of collidedTimers.values()) clearTimeout(tm)
|
|
4756
|
+
collidedTimers.clear()
|
|
4757
|
+
pendingFsEvents.clear()
|
|
4758
|
+
pendingFsFullScan = false
|
|
2979
4759
|
if (editingClearTimer) clearTimeout(editingClearTimer)
|
|
2980
4760
|
try { watcher && watcher.close() } catch { }
|
|
2981
|
-
stopped = true
|
|
2982
4761
|
// The shared socket first: closing it ends every channel at once on the relay, instead of
|
|
2983
4762
|
// one CLOSE frame per file down a socket that is about to go anyway.
|
|
2984
4763
|
try { fileTransport.stop() } catch { }
|
|
@@ -2987,7 +4766,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2987
4766
|
try { provider.destroy() } catch { }
|
|
2988
4767
|
try { doc.destroy() } catch { }
|
|
2989
4768
|
// A room you have left should stop colouring what your agents recall.
|
|
2990
|
-
if (memoryLinked) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
4769
|
+
if (memoryLinked && grmLink) try { grmLink.unlinkRoom(ROOT, log) } catch { }
|
|
2991
4770
|
// Last, so a crash anywhere above still frees the folder on the next run via the
|
|
2992
4771
|
// stale-PID path rather than leaving it locked.
|
|
2993
4772
|
try { releaseLock() } catch { }
|