hivecode-mcp 0.14.4 → 0.14.5
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/core.js +2 -0
- package/icr-merge.js +8 -1
- package/icr.js +3 -1
- package/package.json +1 -1
- package/reconcile.cjs +123 -21
- package/sync.js +512 -9
package/core.js
CHANGED
package/icr-merge.js
CHANGED
|
@@ -36,7 +36,8 @@ function describeConflicts(conflicts) {
|
|
|
36
36
|
// fixed-point, verified by icr-converge-test.js), so its 'auto' result is used as the
|
|
37
37
|
// merged bytes for supported files; merge3 covers conflicts, fallbacks, and other languages.
|
|
38
38
|
export function icrMerge3(base, mine, theirs, relPath) {
|
|
39
|
-
|
|
39
|
+
// The sync merge: edits on adjacent lines that each replace lines one for one go side by side (#17).
|
|
40
|
+
const lm = merge3(base, mine, theirs, { sideBySide: true })
|
|
40
41
|
if (!supports(relPath)) return lm
|
|
41
42
|
|
|
42
43
|
let r
|
|
@@ -44,6 +45,12 @@ export function icrMerge3(base, mine, theirs, relPath) {
|
|
|
44
45
|
catch { return lm } // ICR must never break a sync
|
|
45
46
|
|
|
46
47
|
if (r.status === 'auto') {
|
|
48
|
+
// THE LINE MERGE KEEPS THE AUTHORS' LAYOUT (#19). A one-line top-level statement is a unit keyed by
|
|
49
|
+
// its own text, so the structural merge sees an edit as a delete plus an insert and can leave the
|
|
50
|
+
// deleted line's blank lines behind. When the line merge is clean and the two differ only in blank
|
|
51
|
+
// lines, the line merge is exactly both sides' edits: take it.
|
|
52
|
+
const blankless = (t) => t.split('\n').filter((l) => l.trim() !== '').join('\n')
|
|
53
|
+
if (!lm.conflict && r.text !== lm.text && blankless(r.text) === blankless(lm.text)) return lm
|
|
47
54
|
const renamed = r.renames && r.renames.length
|
|
48
55
|
return {
|
|
49
56
|
text: r.text, conflict: false, icr: renamed ? 'rename' : 'structural',
|
package/icr.js
CHANGED
|
@@ -644,7 +644,9 @@ function spliceUnits(baseText, baseUnits, order, textByKey, opts = {}) {
|
|
|
644
644
|
// New unit: insert it exactly where the merge order dictated
|
|
645
645
|
const val = textByKey.get(k)
|
|
646
646
|
if (val != null) {
|
|
647
|
-
|
|
647
|
+
// A unit that already ends with its newline (Python, Ruby, TOML, YAML) gets no second one: that
|
|
648
|
+
// put a blank line no author wrote after every edited top-level line (#19).
|
|
649
|
+
out += (out.endsWith('\n') ? '' : '\n') + val + (val.endsWith('\n') ? '' : '\n')
|
|
648
650
|
}
|
|
649
651
|
}
|
|
650
652
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hivecode-mcp",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
4
4
|
"description": "MCP server that lets any AI coding agent join a Hivecode room — humans and multiple AI agents edit one live codebase with folder-scoped permissions and approval gates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/reconcile.cjs
CHANGED
|
@@ -47,39 +47,38 @@ function hasConflictMarkers(text) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// Strip and de-nest conflict markers to prevent exponential 2^N code ballooning
|
|
50
|
+
// Strip conflict markers, keeping the 'local' side (before the first =======), the 'theirs' side (after
|
|
51
|
+
// it), or 'all': every version in the block, in order, each distinct one once. A separator may carry a
|
|
52
|
+
// label ("======= Bob"): the 3-version blocks of a same-line race do.
|
|
50
53
|
function stripConflictMarkers(text, prefer = 'local') {
|
|
51
54
|
if (!text || !hasConflictMarkers(text)) return text || ''
|
|
52
55
|
const lines = text.split('\n')
|
|
53
56
|
const out = []
|
|
54
57
|
let inConflict = false
|
|
55
|
-
let
|
|
56
|
-
const localBlock = []
|
|
57
|
-
const theirsBlock = []
|
|
58
|
+
let sections = []
|
|
58
59
|
|
|
59
60
|
for (let i = 0; i < lines.length; i++) {
|
|
60
61
|
const line = lines[i]
|
|
61
62
|
if (/^<<<<<<< /.test(line)) {
|
|
62
63
|
inConflict = true
|
|
63
|
-
|
|
64
|
-
localBlock.length = 0
|
|
65
|
-
theirsBlock.length = 0
|
|
64
|
+
sections = [[]]
|
|
66
65
|
continue
|
|
67
66
|
}
|
|
68
|
-
if (
|
|
69
|
-
|
|
67
|
+
if (/^=======( |$)/.test(line) && inConflict) {
|
|
68
|
+
sections.push([])
|
|
70
69
|
continue
|
|
71
70
|
}
|
|
72
71
|
if (/^>>>>>>> /.test(line) && inConflict) {
|
|
73
72
|
inConflict = false
|
|
74
|
-
|
|
73
|
+
if (prefer === 'all') {
|
|
74
|
+
const seen = new Set()
|
|
75
|
+
for (const sec of sections) { const k = sec.join('\n'); if (seen.has(k)) continue; seen.add(k); out.push(...sec) }
|
|
76
|
+
} else if (prefer === 'theirs') out.push(...sections.slice(1).flat())
|
|
77
|
+
else out.push(...sections[0])
|
|
75
78
|
continue
|
|
76
79
|
}
|
|
77
|
-
if (inConflict)
|
|
78
|
-
|
|
79
|
-
else localBlock.push(line)
|
|
80
|
-
} else {
|
|
81
|
-
out.push(line)
|
|
82
|
-
}
|
|
80
|
+
if (inConflict) sections[sections.length - 1].push(line)
|
|
81
|
+
else out.push(line)
|
|
83
82
|
}
|
|
84
83
|
return out.join('\n')
|
|
85
84
|
}
|
|
@@ -193,22 +192,38 @@ function slideSpan(b, [bs, be, os, oe], src) {
|
|
|
193
192
|
// changes conflict, as in git: two edits with no unchanged line between them are one decision). A
|
|
194
193
|
// region only one side changed takes that side; both sides making the same change takes it once;
|
|
195
194
|
// anything else is a conflict of THAT region only. Null when either diff is past the work bound.
|
|
196
|
-
|
|
195
|
+
//
|
|
196
|
+
// SIDE BY SIDE (sideBySide, the sync merge only; #17, 10 Nov 2026). Two edits that only TOUCH
|
|
197
|
+
// are applied side by side when both replace lines one for one (the same number of lines out as
|
|
198
|
+
// in). It is P0-F's footprint rule, and what the character CRDT does with the same two edits made at
|
|
199
|
+
// the same moment, so a save that lands a moment late no longer gets markers the on-time one did not
|
|
200
|
+
// (bigproject W1: README.md, five agents on adjacent lines). Overlap, an insertion or deletion at the
|
|
201
|
+
// edge, and a change of line count still conflict. git-clash and plain merge3 keep git's rule.
|
|
202
|
+
function diff3Lines(b, mine, theirs, sideBySide = false) {
|
|
197
203
|
const hm = lineHunks(b, mine), ht = lineHunks(b, theirs)
|
|
198
204
|
if (!hm || !ht) return null
|
|
199
205
|
const sm = hm.map((h) => slideSpan(b, h, mine)), st = ht.map((h) => slideSpan(b, h, theirs))
|
|
206
|
+
const oneForOne = (h) => h[1] - h[0] === h[3] - h[2] // lineHunks has no empty hunk, so this is a real replacement
|
|
207
|
+
// Does a hunk starting at `s` join a region reaching `reach`? Overlap always; touching, unless both
|
|
208
|
+
// the hunk and every hunk ending at `reach` are one-for-one replacements (side by side).
|
|
209
|
+
const joins = (s, h, reach, edgeRep) => s < reach || (s === reach && !(sideBySide && edgeRep && oneForOne(h)))
|
|
200
210
|
const out = []
|
|
201
211
|
let conflict = false, pos = 0, i = 0, j = 0
|
|
202
212
|
while (i < hm.length || j < ht.length) {
|
|
203
213
|
const fromMine = j >= ht.length || (i < hm.length && hm[i][0] <= ht[j][0])
|
|
204
214
|
const first = fromMine ? hm[i] : ht[j]
|
|
205
215
|
let reach = fromMine ? sm[i++][1] : st[j++][1] // how far this region's edits could extend
|
|
216
|
+
let edgeRep = oneForOne(first) // every hunk ending at `reach` is one-for-one
|
|
206
217
|
const rm = fromMine ? [first] : [], rt = fromMine ? [] : [first]
|
|
207
218
|
const rs = first[0]
|
|
208
219
|
let re = first[1]
|
|
220
|
+
const take = (h, span) => {
|
|
221
|
+
re = Math.max(re, h[1])
|
|
222
|
+
if (span[1] > reach) { reach = span[1]; edgeRep = oneForOne(h) } else if (span[1] === reach) edgeRep = edgeRep && oneForOne(h)
|
|
223
|
+
}
|
|
209
224
|
for (;;) {
|
|
210
|
-
if (i < hm.length && sm[i][0]
|
|
211
|
-
if (j < ht.length && st[j][0]
|
|
225
|
+
if (i < hm.length && joins(sm[i][0], hm[i], reach, edgeRep)) { take(hm[i], sm[i]); rm.push(hm[i++]); continue }
|
|
226
|
+
if (j < ht.length && joins(st[j][0], ht[j], reach, edgeRep)) { take(ht[j], st[j]); rt.push(ht[j++]); continue }
|
|
212
227
|
break
|
|
213
228
|
}
|
|
214
229
|
for (let x = pos; x < rs; x++) out.push(b[x])
|
|
@@ -248,8 +263,84 @@ function summarizeChange(base, next) {
|
|
|
248
263
|
return { isRewrite, changedLines: churn, totalLines: total, symbols: symbolsIn(r.newLines.join('\n')) }
|
|
249
264
|
}
|
|
250
265
|
|
|
266
|
+
// A LATE SAVE AGAINST A CONFLICT BLOCK (P0-F-9, 10 Nov 2026). The author edited a clean copy; the
|
|
267
|
+
// room's copy now holds one or more blocks. Every version the room holds is a whole file: block j
|
|
268
|
+
// read as section j (a block with fewer sections repeats its last). The late save is merged into
|
|
269
|
+
// EACH of them, a merge that conflicts giving both its sides, and the distinct results are written
|
|
270
|
+
// as ONE block per region where they differ. A block is never flattened into its sections side by
|
|
271
|
+
// side: that came out clean with lines no author wrote (`a = 1 / b = 1 / a = 2 / b = 2`, P0-F p0f),
|
|
272
|
+
// and 0.14.4's one-side strip silently dropped every other version. Null: the caller's old path
|
|
273
|
+
// (a section holding markers of its own, or a diff past its work bound).
|
|
274
|
+
function blockVersions(text) {
|
|
275
|
+
const lines = text.split('\n'), segs = [] // a segment: a plain line (string) or a block {head, foot, secs}
|
|
276
|
+
let blk = null
|
|
277
|
+
for (const line of lines) {
|
|
278
|
+
if (/^<<<<<<< /.test(line)) { if (blk) return null; blk = { head: line, secs: [[]] }; continue }
|
|
279
|
+
if (blk && /^=======( |$)/.test(line)) { blk.secs.push([]); continue }
|
|
280
|
+
if (blk && /^>>>>>>> /.test(line)) { blk.foot = line; segs.push(blk); blk = null; continue }
|
|
281
|
+
if (blk) blk.secs[blk.secs.length - 1].push(line)
|
|
282
|
+
else segs.push(line)
|
|
283
|
+
}
|
|
284
|
+
if (blk) return null // an unclosed block: not ours to read
|
|
285
|
+
const blocks = segs.filter((s) => typeof s !== 'string')
|
|
286
|
+
const k = Math.max(...blocks.map((b) => b.secs.length))
|
|
287
|
+
const versions = []
|
|
288
|
+
for (let j = 0; j < k; j++) versions.push(segs.flatMap((s) => (typeof s === 'string' ? [s] : s.secs[Math.min(j, s.secs.length - 1)])).join('\n'))
|
|
289
|
+
return { versions, blocks }
|
|
290
|
+
}
|
|
291
|
+
function mergeLateSave(base, mine, theirs, opts) {
|
|
292
|
+
const bv = blockVersions(theirs)
|
|
293
|
+
if (!bv) return null
|
|
294
|
+
const variants = [], lates = []
|
|
295
|
+
const add = (t, to = variants) => { if (!variants.some((v) => v.text === t.text) && !lates.some((v) => v.text === t.text)) to.push(t) }
|
|
296
|
+
for (const T of bv.versions) {
|
|
297
|
+
if (hasConflictMarkers(T)) return null
|
|
298
|
+
const r = merge3(base, mine, T, opts)
|
|
299
|
+
if (!r.conflict) { add({ text: r.text, mine: false }); continue }
|
|
300
|
+
// The room's versions first, in the block's order, so its header and footer still name its sections;
|
|
301
|
+
// the late save's versions join after all of them.
|
|
302
|
+
add({ text: stripConflictMarkers(r.text, 'theirs'), mine: false })
|
|
303
|
+
add({ text: stripConflictMarkers(r.text, 'local'), mine: true }, lates)
|
|
304
|
+
}
|
|
305
|
+
variants.push(...lates.filter((l) => !variants.some((v) => v.text === l.text)))
|
|
306
|
+
if (variants.length === 1) return { text: variants[0].text, conflict: false }
|
|
307
|
+
// Regions, in the first version's lines, where any other version differs.
|
|
308
|
+
const anchor = variants[0].text.split('\n')
|
|
309
|
+
const hunksOf = variants.map((v, i) => (i === 0 ? [] : lineHunks(anchor, v.text.split('\n'))))
|
|
310
|
+
if (hunksOf.some((h) => h === null)) return null
|
|
311
|
+
const spans = hunksOf.flat().map((h) => [h[0], h[1]]).sort((a, b) => a[0] - b[0] || a[1] - b[1])
|
|
312
|
+
const regions = []
|
|
313
|
+
for (const [s, e] of spans) {
|
|
314
|
+
const last = regions[regions.length - 1]
|
|
315
|
+
if (last && s <= last[1]) last[1] = Math.max(last[1], e)
|
|
316
|
+
else regions.push([s, e])
|
|
317
|
+
}
|
|
318
|
+
const heads = bv.blocks.map((b) => b.head), feet = bv.blocks.map((b) => b.foot.replace(/^>>>>>>> /, '').replace(/ \(\d+ versions\)$/, ''))
|
|
319
|
+
const out = []
|
|
320
|
+
let pos = 0
|
|
321
|
+
regions.forEach(([rs, re], ri) => {
|
|
322
|
+
for (let x = pos; x < rs; x++) out.push(anchor[x])
|
|
323
|
+
const secs = []
|
|
324
|
+
variants.forEach((v, vi) => {
|
|
325
|
+
const src = v.text.split('\n')
|
|
326
|
+
const sec = sideOfRegion(anchor, rs, re, hunksOf[vi].filter((h) => h[0] >= rs && h[1] <= re), src)
|
|
327
|
+
if (!secs.some((x) => x.join('\n') === sec.join('\n'))) secs.push(sec)
|
|
328
|
+
})
|
|
329
|
+
if (secs.length === 1) out.push(...secs[0])
|
|
330
|
+
else {
|
|
331
|
+
const who = feet[Math.min(ri, feet.length - 1)]
|
|
332
|
+
const lateName = String(opts.lateName || 'local (yours)').replace(/[\r\n]/g, ' ')
|
|
333
|
+
const late = variants.some((v) => v.mine) && !who.split(', ').includes(lateName) ? `, ${lateName}` : ''
|
|
334
|
+
out.push(heads[Math.min(ri, heads.length - 1)], ...secs[0], ...secs.slice(1).flatMap((x) => ['=======', ...x]), `>>>>>>> ${who}${late} (${secs.length} versions)`)
|
|
335
|
+
}
|
|
336
|
+
pos = re
|
|
337
|
+
})
|
|
338
|
+
for (let x = pos; x < anchor.length; x++) out.push(anchor[x])
|
|
339
|
+
return { text: out.join('\n'), conflict: true }
|
|
340
|
+
}
|
|
341
|
+
|
|
251
342
|
// 3-way merge for the sync layer (disk <-> shared doc)
|
|
252
|
-
function merge3(base, mine, theirs) {
|
|
343
|
+
function merge3(base, mine, theirs, opts = {}) {
|
|
253
344
|
const norm = (s) => (s || '').replace(/\r\n/g, '\n')
|
|
254
345
|
const nm0 = norm(mine), nt0 = norm(theirs), nb0 = norm(base)
|
|
255
346
|
if (nm0 === nt0) return { text: nt0, conflict: false }
|
|
@@ -258,7 +349,17 @@ function merge3(base, mine, theirs) {
|
|
|
258
349
|
|
|
259
350
|
const cleanBase = hasConflictMarkers(base) ? stripConflictMarkers(base, 'local') : (base || '')
|
|
260
351
|
const cleanMine = hasConflictMarkers(mine) ? stripConflictMarkers(mine, 'local') : (mine || '')
|
|
261
|
-
|
|
352
|
+
// A LATE SAVE: the author edited a clean copy and the room's copy now holds a conflict block. Keep
|
|
353
|
+
// EVERY version in the block: keeping one side dropped the other authors' work (P0-F,
|
|
354
|
+
// late-save-merge-test). Only then: when the base or the author's copy holds the block too, all three
|
|
355
|
+
// are stripped alike, as before, or the extra versions read as new lines every round and pile up
|
|
356
|
+
// (merge-balloon-test).
|
|
357
|
+
const lateSave = !hasConflictMarkers(base) && !hasConflictMarkers(mine)
|
|
358
|
+
if (lateSave && hasConflictMarkers(theirs)) {
|
|
359
|
+
const r = mergeLateSave(norm(base), norm(mine), norm(theirs), opts)
|
|
360
|
+
if (r) return r
|
|
361
|
+
}
|
|
362
|
+
const cleanTheirs = hasConflictMarkers(theirs) ? stripConflictMarkers(theirs, lateSave ? 'all' : 'theirs') : (theirs || '')
|
|
262
363
|
|
|
263
364
|
const nm = norm(cleanMine), nt = norm(cleanTheirs), nb = norm(cleanBase)
|
|
264
365
|
if (nm === nt) return { text: nt, conflict: false }
|
|
@@ -266,7 +367,7 @@ function merge3(base, mine, theirs) {
|
|
|
266
367
|
if (nb === nm) return { text: nt, conflict: false }
|
|
267
368
|
|
|
268
369
|
const b = nb.split('\n')
|
|
269
|
-
const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'))
|
|
370
|
+
const d3 = diff3Lines(b, nm.split('\n'), nt.split('\n'), !!opts.sideBySide)
|
|
270
371
|
if (d3) return { text: d3.lines.join('\n'), conflict: d3.conflict }
|
|
271
372
|
// Past diff3's work bound (two sides that share almost nothing): one range per side.
|
|
272
373
|
const mr = changedRange(b, nm.split('\n'))
|
|
@@ -616,6 +717,7 @@ module.exports = {
|
|
|
616
717
|
merge3,
|
|
617
718
|
lineHunks,
|
|
618
719
|
diff3Lines,
|
|
720
|
+
blockVersions,
|
|
619
721
|
describeConflicts,
|
|
620
722
|
icrMerge3
|
|
621
723
|
}
|
package/sync.js
CHANGED
|
@@ -24,7 +24,7 @@ import { WebsocketProvider } from 'y-websocket'
|
|
|
24
24
|
import { createFileTransport } from './hive-mux.js' // ONE SOCKET PER PARTICIPANT: every file doc on one physical socket when the relay offers it
|
|
25
25
|
import { HiveSocket, transportFor } from './hive-transport.js' // a WebSocket, or HTTPS long-poll where WebSockets are refused
|
|
26
26
|
import ignore from 'ignore'
|
|
27
|
-
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3 } from './core.js'
|
|
27
|
+
import { applyDiff, summarizeChange, changedRange, hasConflictMarkers, merge3, lineHunks, blockVersions } from './core.js'
|
|
28
28
|
import { icrMerge3 } from './icr-merge.js' // structure/intent-aware merge; falls back to line merge3
|
|
29
29
|
import { makeCoordinator } from './hive-coord.js' // decentralized claim layer (collision PREVENTION)
|
|
30
30
|
import { RULES_MD, AGENT_MANUAL } from './hive-instructions.js'
|
|
@@ -168,6 +168,222 @@ export function fusionReason(merged, mine, theirs, lang) {
|
|
|
168
168
|
|
|
169
169
|
const endNl = (s) => (s === '' || s.endsWith('\n') ? s : s + '\n')
|
|
170
170
|
|
|
171
|
+
// SAME-LINE RACE (P0-F, docs/rules-rewrite/p0f-design.md). Two saves of one line made before either
|
|
172
|
+
// client saw the other reach the shared Y.Text as concurrent character edits, and the CRDT keeps both:
|
|
173
|
+
// "ship friday" saved as "ship monday" and "ship tuesday" became "ship montuesday" in every folder, and
|
|
174
|
+
// the same change twice became "ship monmonday". Every version from 0.6.0 to 0.14.3 did it. So each save
|
|
175
|
+
// also records its INTENT -- the line hunks it changed and a hash of the text it was made from -- in the
|
|
176
|
+
// same transaction, and these pure functions decide from the intents what the file should say.
|
|
177
|
+
//
|
|
178
|
+
// An intent: { b: contentHash(base), h: [[baseStart, baseEnd, newLines]...] }. Null when the diff is too
|
|
179
|
+
// big to describe (lineHunks gave up) or the hunks are larger than INTENT_MAX_LINES: no detection then,
|
|
180
|
+
// exactly as before.
|
|
181
|
+
const INTENT_MAX_LINES = 400
|
|
182
|
+
export function intentHunks(base, next) {
|
|
183
|
+
const a = base.split('\n'), b = next.split('\n')
|
|
184
|
+
const hs = lineHunks(a, b)
|
|
185
|
+
if (!hs) return null
|
|
186
|
+
let n = 0
|
|
187
|
+
const out = hs.map(([bs, be, ss, se]) => { n += se - ss; return [bs, be, b.slice(ss, se)] })
|
|
188
|
+
return n > INTENT_MAX_LINES ? null : out
|
|
189
|
+
}
|
|
190
|
+
// The base text with one intent's hunks applied: that author's whole version.
|
|
191
|
+
export function applyIntent(base, h) {
|
|
192
|
+
const lines = base.split('\n')
|
|
193
|
+
for (const [bs, be, nl] of [...h].sort((x, y) => y[0] - x[0])) lines.splice(bs, be - bs, ...nl)
|
|
194
|
+
return lines.join('\n')
|
|
195
|
+
}
|
|
196
|
+
// Can two saves made from the same text damage each other in the character CRDT? (Opus-Cloud's rule,
|
|
197
|
+
// fuzzed at 0 bad over about 78k pairs.) applyDiff sends ONE character span per save, from its first
|
|
198
|
+
// changed line to its last, so a save's footprint is [first hunk start, last hunk end), not its hunks:
|
|
199
|
+
// A editing lines 1 and 5 while B edits line 3 lost B's line under a per-hunk rule (8,439 of 49,748
|
|
200
|
+
// "no clash" pairs fused or lost an edit). Footprints that overlap clash. Footprints that only touch
|
|
201
|
+
// clash too, because a pure insertion or deletion slides along repeated text into its neighbour, except
|
|
202
|
+
// two same-size line replacements side by side: that is the W1 shape, and the CRDT merges it correctly
|
|
203
|
+
// (0 of 915 fused), so it must not become a conflict as the fork gate makes it.
|
|
204
|
+
const footprint = (h) => (h.length ? [h[0][0], h[h.length - 1][1], h.length === 1 ? h[0][2] : null] : null)
|
|
205
|
+
export function hunksClash(h1, h2) {
|
|
206
|
+
const a = footprint(h1), b = footprint(h2)
|
|
207
|
+
if (!a || !b) return false
|
|
208
|
+
if (Math.max(a[0], b[0]) < Math.min(a[1], b[1])) return true
|
|
209
|
+
const rep = (f) => f[2] !== null && f[1] > f[0] && f[1] - f[0] === f[2].length
|
|
210
|
+
const touch = a[1] === b[0] || b[1] === a[0] || (a[0] === a[1] && a[0] >= b[0] && a[0] <= b[1]) || (b[0] === b[1] && b[0] >= a[0] && b[0] <= a[1])
|
|
211
|
+
return touch && !(rep(a) && rep(b))
|
|
212
|
+
}
|
|
213
|
+
// Do two saves change the same base LINE? The finer question, asked inside a clash: when no two saves
|
|
214
|
+
// do, they can all be applied cleanly and the result needs no markers.
|
|
215
|
+
export function linesOverlap(h1, h2) {
|
|
216
|
+
for (const [a0, a1] of h1) for (const [b0, b1] of h2) {
|
|
217
|
+
if (Math.max(a0, b0) < Math.min(a1, b1)) return true
|
|
218
|
+
if (a0 === a1 && b0 < a0 && a0 < b1) return true
|
|
219
|
+
if (b0 === b1 && a0 < b0 && b0 < a1) return true
|
|
220
|
+
}
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
// Two hunks share a base line: spans that overlap, or a line inserted strictly inside the other's span.
|
|
224
|
+
function spansMeet([a0, a1], [b0, b1]) {
|
|
225
|
+
if (Math.max(a0, b0) < Math.min(a1, b1)) return true
|
|
226
|
+
if (a0 === a1 && b0 < a0 && a0 < b1) return true
|
|
227
|
+
if (b0 === b1 && a0 < b0 && b0 < a1) return true
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
// The clash region and everything around it, for intents (Map clientID -> h) all made from `base`.
|
|
231
|
+
// [rs, re) spans every base line that two intents both changed. Outside it the intents' hunks are
|
|
232
|
+
// disjoint, so the lines before and after are the base's with those hunks applied, the same for every
|
|
233
|
+
// author; inside it each author has their own version. Null when nothing clashes.
|
|
234
|
+
export function clashRegion(base, intents) {
|
|
235
|
+
let rs = Infinity, re = -Infinity
|
|
236
|
+
const all = [...intents.values()]
|
|
237
|
+
for (let i = 0; i < all.length; i++) for (let j = 0; j < all.length; j++) {
|
|
238
|
+
if (i === j) continue
|
|
239
|
+
for (const hi of all[i]) if (hunksClash([hi], all[j])) { rs = Math.min(rs, hi[0]); re = Math.max(re, hi[1]) }
|
|
240
|
+
}
|
|
241
|
+
if (rs === Infinity) return null
|
|
242
|
+
const b = base.split('\n')
|
|
243
|
+
// Before the region, inside it, or after. An insertion AT either edge is inside: it touches the clash.
|
|
244
|
+
const where = ([bs, be]) => (be < rs || (be === rs && bs < rs) ? 'pre' : bs > re || (bs === re && be > re) ? 'post' : 'in')
|
|
245
|
+
const hs = tagged(intents)
|
|
246
|
+
const pre = applyTagged(b.slice(0, rs), hs.filter((x) => where(x) === 'pre'))
|
|
247
|
+
const post = applyTagged(b.slice(re), hs.filter((x) => where(x) === 'post'), re)
|
|
248
|
+
const inside = hs.filter((x) => where(x) === 'in')
|
|
249
|
+
const sides = new Map()
|
|
250
|
+
for (const id of [...intents.keys()].sort((x, y) => x - y)) sides.set(id, applyTagged(b.slice(rs, re), inside.filter((x) => x[3] === id), rs))
|
|
251
|
+
return { rs, re, pre, post, sides, inside }
|
|
252
|
+
}
|
|
253
|
+
// Every hunk tagged with its author, in the one order every peer applies them: from the bottom up; at one
|
|
254
|
+
// start line a span before an insertion there (inserting first let the span delete the inserted line);
|
|
255
|
+
// insertions at one point higher client first, so the lower client's lines come first. Never arrival order.
|
|
256
|
+
function tagged(intents) {
|
|
257
|
+
return [...intents].flatMap(([id, h]) => h.map((x) => [x[0], x[1], x[2], id])).sort((x, y) => y[0] - x[0] || (y[1] - y[0]) - (x[1] - x[0]) || y[3] - x[3])
|
|
258
|
+
}
|
|
259
|
+
function applyTagged(lines, hs, off = 0) { for (const [bs, be, nl] of hs) lines.splice(bs - off, be - bs, ...nl); return lines }
|
|
260
|
+
// What the file should say once these intents (every save made from `base` at that moment) are folded in.
|
|
261
|
+
// Every peer computes the same text:
|
|
262
|
+
// - no two authors changed the same line: every change applied cleanly (the merge the CRDT got wrong);
|
|
263
|
+
// - otherwise one marker block over the region where they did, holding each distinct version once, in
|
|
264
|
+
// client order; an author who left that region as it was is not a version. Changes elsewhere, by
|
|
265
|
+
// anyone, are applied around the block. (merge3 cannot fold a third version in: it strips existing
|
|
266
|
+
// markers so blocks never nest, and the third author's line was lost that way, R6.)
|
|
267
|
+
export function clashFinal(base, intents, names = new Map()) {
|
|
268
|
+
const r = clashRegion(base, intents)
|
|
269
|
+
if (!r) return applyTagged(base.split('\n'), tagged(intents)).join('\n')
|
|
270
|
+
const reg = base.split('\n').slice(r.rs, r.re)
|
|
271
|
+
const changed = [...r.sides].filter(([, l]) => J(l) !== J(reg))
|
|
272
|
+
const meet = r.inside.some((p, i) => r.inside.some((q, j) => j > i && p[3] !== q[3] && J(p.slice(0, 3)) !== J(q.slice(0, 3)) && spansMeet(p, q)))
|
|
273
|
+
let mid
|
|
274
|
+
if (!changed.length) mid = reg
|
|
275
|
+
else if (!meet) {
|
|
276
|
+
const seen = new Set()
|
|
277
|
+
mid = applyTagged(reg.slice(), r.inside.filter((x) => { const k = J(x.slice(0, 3)); if (seen.has(k)) return false; seen.add(k); return true }), r.rs)
|
|
278
|
+
} else {
|
|
279
|
+
const distinct = []
|
|
280
|
+
for (const [id, lines] of changed) if (!distinct.some((d) => J(d.lines) === J(lines))) distinct.push({ id, lines })
|
|
281
|
+
const label = (id) => String(names.get(id) || `client ${id}`).replace(/[\r\n]/g, ' ')
|
|
282
|
+
if (distinct.length === 1) mid = distinct[0].lines
|
|
283
|
+
else if (distinct.length === 2) mid = [`<<<<<<< ${label(distinct[0].id)}`, ...distinct[0].lines, '=======', ...distinct[1].lines, `>>>>>>> ${label(distinct[1].id)}`]
|
|
284
|
+
else {
|
|
285
|
+
// Plain separators: a labelled one ("======= Bob") is not one any merge reads, and a late save
|
|
286
|
+
// merged against such a block lost every version in it (late-save-merge-test L2).
|
|
287
|
+
mid = [`<<<<<<< ${label(distinct[0].id)}`, ...distinct[0].lines]
|
|
288
|
+
for (const d of distinct.slice(1)) mid.push('=======', ...d.lines)
|
|
289
|
+
mid.push(`>>>>>>> ${distinct.slice(1).map((d) => label(d.id)).join(', ')} (${distinct.length} versions)`)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return [...r.pre, ...mid, ...r.post].join('\n')
|
|
293
|
+
}
|
|
294
|
+
const J = (x) => JSON.stringify(x)
|
|
295
|
+
// The text every folder holds once these saves (Map clientID -> hunks, all made from `base`) have crossed:
|
|
296
|
+
// their character union. It depends only on the base text, each author's text and their client ids, not on
|
|
297
|
+
// the history that built the base (tests/same-line-intent-unit-test.mjs, CU rows), so every peer can
|
|
298
|
+
// rebuild it exactly from the intents alone.
|
|
299
|
+
export function clashUnion(base, intents, laters = []) {
|
|
300
|
+
const root = new Y.Doc(); root.clientID = 0; root.getText('t').insert(0, base)
|
|
301
|
+
const seed = Y.encodeStateAsUpdate(root)
|
|
302
|
+
const docs = new Map()
|
|
303
|
+
for (const [id, h] of [...intents].sort((x, y) => x[0] - y[0])) { const d = new Y.Doc(); d.clientID = id; Y.applyUpdate(d, seed); applyDiff(d.getText('t'), applyIntent(base, h)); docs.set(id, d) }
|
|
304
|
+
// A later save (F5): made by `id` on a text that already held the saves in `after`, so its doc had their
|
|
305
|
+
// updates (and its own earlier ones, if `id` saved before) when it applied its hunks.
|
|
306
|
+
for (const L of laters) {
|
|
307
|
+
let d = docs.get(L.id)
|
|
308
|
+
if (!d) { d = new Y.Doc(); d.clientID = L.id; Y.applyUpdate(d, seed); docs.set(L.id, d) }
|
|
309
|
+
for (const a of L.after) if (a !== L.id && docs.has(a)) Y.applyUpdate(d, Y.encodeStateAsUpdate(docs.get(a)))
|
|
310
|
+
const t = d.getText('t')
|
|
311
|
+
applyDiff(t, applyIntent(t.toString(), L.h))
|
|
312
|
+
}
|
|
313
|
+
const all = [...docs.values()]
|
|
314
|
+
if (!all.length) { root.destroy(); return base }
|
|
315
|
+
const ups = all.map((d) => Y.encodeStateAsUpdate(d))
|
|
316
|
+
for (const u of ups) Y.applyUpdate(all[0], u)
|
|
317
|
+
const out = all[0].getText('t').toString()
|
|
318
|
+
for (const d of all) d.destroy(); root.destroy()
|
|
319
|
+
return out
|
|
320
|
+
}
|
|
321
|
+
// F5: which text was a later save made from? A save that arrives while a race from `base` is pending, but
|
|
322
|
+
// was made from another text, was usually made from what its author held: one side of the race, or the
|
|
323
|
+
// clean merge of several sides that do not clash. Returns { after, text } -- the saves that text held and
|
|
324
|
+
// the text itself -- when `bHash` is the hash of such a text, else null. Never a FUSED union: a version made
|
|
325
|
+
// from fused text would carry the fused line into the resolution as if someone had written it.
|
|
326
|
+
// A SAME-LINE SAVE THAT ARRIVES AFTER THE RACE WAS RESOLVED (P0-F-12). `base` is the text the save was made
|
|
327
|
+
// from and `side` the author's file. The save reached the shared text as a character edit made against
|
|
328
|
+
// `base`, but the room had already replaced that line with a conflict block, so the CRDT put what the save
|
|
329
|
+
// inserted next to the characters it was anchored on, which the block had deleted: `wednes<<<<<<< Bob`.
|
|
330
|
+
// Returns the room's text before the save -- `current` with the save's inserted text taken out -- when
|
|
331
|
+
// that is a well-formed block whose versions are other edits of the lines this save changed. Null
|
|
332
|
+
// otherwise: not this shape, so not ours to repair.
|
|
333
|
+
export function lateOverBlock(base, side, current) {
|
|
334
|
+
let p = 0
|
|
335
|
+
while (p < base.length && p < side.length && base[p] === side[p]) p++
|
|
336
|
+
let s = 0
|
|
337
|
+
while (s < base.length - p && s < side.length - p && base[base.length - 1 - s] === side[side.length - 1 - s]) s++
|
|
338
|
+
const ins = side.slice(p, side.length - s)
|
|
339
|
+
// (Not hasConflictMarkers: the opener this repairs no longer starts its line.)
|
|
340
|
+
if (!ins || !/<<<<<<< |>>>>>>> /.test(current) || current.includes(side)) return null
|
|
341
|
+
// Already a version of a block in the room: nothing to repair.
|
|
342
|
+
const cv = blockVersions(current)
|
|
343
|
+
if (cv && cv.versions.includes(side)) return null
|
|
344
|
+
const mine = intentHunks(base, side)
|
|
345
|
+
if (!mine || !mine.length) return null
|
|
346
|
+
const lo = mine[0][0], hi = mine[mine.length - 1][1]
|
|
347
|
+
for (let i = current.indexOf(ins); i >= 0; i = current.indexOf(ins, i + 1)) {
|
|
348
|
+
// The CRDT can only have put it where its anchor characters were, and the block deleted those: at an
|
|
349
|
+
// edge of the block, just before its opener or at the end of its closing line. Anywhere else (inside a
|
|
350
|
+
// version, say) it is someone's text, not a stray.
|
|
351
|
+
const before = current.slice(0, i), after = current.slice(i + ins.length)
|
|
352
|
+
const lineStart = before.lastIndexOf('\n') + 1
|
|
353
|
+
const atOpen = after.startsWith('<<<<<<< ') && lineStart === i
|
|
354
|
+
const atClose = /^>>>>>>> /.test(current.slice(lineStart, i)) && (after === '' || after[0] === '\n')
|
|
355
|
+
if (!atOpen && !atClose) continue
|
|
356
|
+
const x = before + after
|
|
357
|
+
if (!hasConflictMarkers(x) || /[^\n](<<<<<<< |>>>>>>> )/.test(x)) continue
|
|
358
|
+
const bv = blockVersions(x)
|
|
359
|
+
if (!bv || bv.versions.length < 2) continue
|
|
360
|
+
// Every version the block holds is an edit of this save's lines, from `base`. It may differ from
|
|
361
|
+
// `base` elsewhere too: someone changed another line since (a far line, in same-line-soak-test seed
|
|
362
|
+
// 3, and the repair was refused). The late-save merge carries that change into every version.
|
|
363
|
+
const ok = bv.versions.every((v) => {
|
|
364
|
+
const h = intentHunks(base, v)
|
|
365
|
+
return h && h.some(([bs, be]) => be >= lo && bs <= hi)
|
|
366
|
+
})
|
|
367
|
+
if (ok) return x
|
|
368
|
+
}
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
export function explainBase(base, firsts, bHash) {
|
|
372
|
+
const ids = [...firsts.keys()].sort((x, y) => x - y).slice(0, 6)
|
|
373
|
+
const pick = (k, from = 0) => (k === 0 ? [[]] : ids.slice(from).flatMap((id, i) => pick(k - 1, from + i + 1).map((r) => [id, ...r])))
|
|
374
|
+
for (let k = 1; k <= ids.length; k++) for (const sub of pick(k)) {
|
|
375
|
+
const m = new Map(sub.map((i) => [i, firsts.get(i)]))
|
|
376
|
+
const t = clashUnion(base, m)
|
|
377
|
+
if (contentHash(t) === bHash && t === clashFinal(base, m)) return { after: sub, text: t }
|
|
378
|
+
}
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
export function clashOnlyRegion(base, intents, final, current, laters = []) {
|
|
382
|
+
if (final === current) return true
|
|
383
|
+
if (!clashRegion(base, intents)) return false
|
|
384
|
+
return current === clashUnion(base, intents, laters)
|
|
385
|
+
}
|
|
386
|
+
|
|
171
387
|
// HIV-P17. Why a creation race of relPath must NOT land, '' when it may: 'CONFLICT' when two whole
|
|
172
388
|
// files collide in a file with a language, else fusionReason's answer when a clean-looking union
|
|
173
389
|
// broke code both sides could parse. No language (Markdown) is never refused.
|
|
@@ -777,7 +993,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
777
993
|
let real = false
|
|
778
994
|
for (let i = 0; i < arr.length && !real; i++)
|
|
779
995
|
for (let j = i + 1; j < arr.length && !real; j++) {
|
|
780
|
-
const m = merge3(base, arr[i].text, arr[j].text)
|
|
996
|
+
const m = merge3(base, arr[i].text, arr[j].text, { sideBySide: true }) // the sync rule (#17)
|
|
781
997
|
if (m.conflict || hasConflictMarkers(m.text)) real = true
|
|
782
998
|
}
|
|
783
999
|
if (!real) continue
|
|
@@ -1392,6 +1608,270 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1392
1608
|
})
|
|
1393
1609
|
}
|
|
1394
1610
|
|
|
1611
|
+
// ---- SAME-LINE RACE (P0-F; docs/rules-rewrite/p0f-design.md) ----
|
|
1612
|
+
// A save held on the way for less than LATE_OWN_MS is still part of its race; longer is an offline edit (F4).
|
|
1613
|
+
const LATE_OWN_MS = 2 * 60 * 1000, CLASH_SETTLE_MS = Number(process.env.HIVE_CLASH_SETTLE_MS) || 750, CLASH_HOLD_MS = 8000, RECENT_TEXTS = 8
|
|
1614
|
+
// The last few texts this folder held for a file, by hash: a peer's intent names the text it was made
|
|
1615
|
+
// from, and this is how we know it.
|
|
1616
|
+
function rememberText(fe, t) {
|
|
1617
|
+
const h = contentHash(t)
|
|
1618
|
+
if (fe.recent.has(h)) fe.recent.delete(h)
|
|
1619
|
+
fe.recent.set(h, t)
|
|
1620
|
+
while (fe.recent.size > RECENT_TEXTS) fe.recent.delete(fe.recent.keys().next().value)
|
|
1621
|
+
return h
|
|
1622
|
+
}
|
|
1623
|
+
// Called inside the transaction that sends a change from `from` to `to`.
|
|
1624
|
+
function recordIntent(relPath, fe, from, to, mine = null) {
|
|
1625
|
+
// A text holding conflict markers is a merge's verdict, not one author's edit, and the room has been
|
|
1626
|
+
// told about it already. As an intent it made a neighbour's edit look like a clash (bigproject W1), so
|
|
1627
|
+
// it never starts one (m). But it is still recorded (F5): a race resolved after it must know this
|
|
1628
|
+
// change is in the shared text, and what the author's own version was (v: from -> their file).
|
|
1629
|
+
const marked = hasConflictMarkers(to)
|
|
1630
|
+
if (marked && (mine == null || hasConflictMarkers(mine))) return
|
|
1631
|
+
const h = intentHunks(from, to)
|
|
1632
|
+
if (!h) return
|
|
1633
|
+
const vh = marked ? intentHunks(from, mine) : null
|
|
1634
|
+
if (marked && !vh) return
|
|
1635
|
+
const b = rememberText(fe, from)
|
|
1636
|
+
rememberText(fe, to)
|
|
1637
|
+
const me = fe.doc.clientID
|
|
1638
|
+
const v = marked ? { b, h, n: name, m: 1, v: vh } : { b, h, n: name }
|
|
1639
|
+
// Our own last save, for P0-F-12: if it reaches the room after a race on its line was resolved, this
|
|
1640
|
+
// client is the one that repairs it. Kept apart from `intents`, which forgets after the window.
|
|
1641
|
+
if (!marked) fe.lastOwn = { base: from, side: to, at: Date.now() }
|
|
1642
|
+
fe.intent.set(String(me), v)
|
|
1643
|
+
fe.intents.set(me, { ...v, at: Date.now() })
|
|
1644
|
+
// An intent only matters while a race could still be arriving. Left in place it stays in the file's
|
|
1645
|
+
// history forever, and a client gets a new id on every start (review F3), so take ours out after the
|
|
1646
|
+
// window, unless a later save has replaced it. The window is LATE_OWN_MS, not 15 s: a client whose
|
|
1647
|
+
// connection dropped for longer than that resyncs in ONE update, where an intent set and deleted is
|
|
1648
|
+
// not there at all. Nobody saw the race, and the fused line stayed, unmarked (soak, a 25 s hold).
|
|
1649
|
+
const t = setTimeout(() => {
|
|
1650
|
+
if (stopped || !fileDocs.has(relPath) || fe.intent.get(String(me)) !== v) return
|
|
1651
|
+
try { fe.doc.transact(() => fe.intent.delete(String(me))) } catch { }
|
|
1652
|
+
}, LATE_OWN_MS + 1000)
|
|
1653
|
+
if (t.unref) t.unref()
|
|
1654
|
+
}
|
|
1655
|
+
// P0-F-12: A SAME-LINE SAVE THAT ARRIVES AFTER ITS RACE WAS RESOLVED (same-line-very-late-test). It was
|
|
1656
|
+
// made at the same moment as the race's saves, but reached the room only after the room had written
|
|
1657
|
+
// the race's block, and the CRDT put what it inserted beside the block (`wednes<<<<<<< Bob`). Any
|
|
1658
|
+
// client that can explain the text that way (lateOverBlock) knows the repair: the late author's
|
|
1659
|
+
// version added to the block (merge3's late-save merge). The late author's own client writes it -- it
|
|
1660
|
+
// is online, its save just arrived -- and every other client holds its disk until it lands. Returns the
|
|
1661
|
+
// bases it took over, so the race path leaves them alone.
|
|
1662
|
+
function checkLate(relPath, txn) {
|
|
1663
|
+
const fe = fileDocs.get(relPath)
|
|
1664
|
+
const taken = new Set()
|
|
1665
|
+
if (!fe || !fe.intent) return taken
|
|
1666
|
+
const current = fe.text.toString()
|
|
1667
|
+
const now = Date.now()
|
|
1668
|
+
// Only against a race's resolution: its block names its authors. A block a merge raised ("local
|
|
1669
|
+
// (yours)" / "incoming (theirs)") is an author's save, and the race path decides how it joins (F5
|
|
1670
|
+
// builds the block from what each author saved, same-line-later-save-test R10).
|
|
1671
|
+
const repair = (base, side, who) => {
|
|
1672
|
+
const x = lateOverBlock(base, side, current)
|
|
1673
|
+
if (x == null || /^(<<<<<<<|>>>>>>>) .*(local \(yours\)|incoming \(theirs\))/m.test(x)) return null
|
|
1674
|
+
return merge3(base, side, x, { lateName: who }).text
|
|
1675
|
+
}
|
|
1676
|
+
const own = fe.lastOwn
|
|
1677
|
+
if (own && (now - own.at >= LATE_OWN_MS || current === own.side || (blockVersions(current) || { versions: [] }).versions.includes(own.side))) fe.lastOwn = null
|
|
1678
|
+
else if (own) {
|
|
1679
|
+
const final = repair(own.base, own.side, name)
|
|
1680
|
+
if (final != null && final !== current) {
|
|
1681
|
+
taken.add(contentHash(own.base))
|
|
1682
|
+
fe.clashHold = { final, until: now + CLASH_HOLD_MS, late: true }
|
|
1683
|
+
log(`[${name}] ${relPath}: this save of a line reached the room after a race on that line was resolved; adding it to the block`)
|
|
1684
|
+
setTimeout(() => {
|
|
1685
|
+
// The text moved on (another change arrived in the same burst): that change's own check has
|
|
1686
|
+
// already taken this save up again, against the newer text. lastOwn stays until a repair lands.
|
|
1687
|
+
if (stopped || fe.text.toString() !== current) { if (fe.clashHold && fe.clashHold.final === final) releaseHold(relPath, fe); return }
|
|
1688
|
+
fe.lastOwn = null
|
|
1689
|
+
fe.doc.transact(() => applyDiff(fe.text, final))
|
|
1690
|
+
rememberText(fe, final)
|
|
1691
|
+
try { postOnce(`late:${relPath}:${contentHash(final)}`, `[!] ${relPath}: ${name} saved the same line as the others at the same moment, but the save arrived after their conflict was written. ${name}'s version is now one more version in the conflict block.`) } catch { }
|
|
1692
|
+
releaseHold(relPath, fe)
|
|
1693
|
+
}, 0)
|
|
1694
|
+
return taken
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
const keys = txn.changed && txn.changed.get(fe.intent)
|
|
1698
|
+
if (!keys) return taken
|
|
1699
|
+
for (const k of keys) {
|
|
1700
|
+
const v = fe.intent.get(k)
|
|
1701
|
+
if (!v || v.m || !Array.isArray(v.h) || typeof v.b !== 'string' || Number(k) === fe.doc.clientID) continue
|
|
1702
|
+
const base = fe.recent.get(v.b)
|
|
1703
|
+
if (base == null) continue
|
|
1704
|
+
const final = repair(base, applyIntent(base, v.h), v.n || `client ${k}`)
|
|
1705
|
+
if (final == null || final === current) continue
|
|
1706
|
+
taken.add(v.b)
|
|
1707
|
+
fe.clashHold = { final, until: now + CLASH_HOLD_MS, late: true }
|
|
1708
|
+
log(`[${name}] ${relPath}: ${v.n || k}'s save of a line arrived after the race on it was resolved; waiting for it to be added to the block`)
|
|
1709
|
+
const t = setTimeout(() => {
|
|
1710
|
+
if (stopped || fe.clashHold == null || fe.clashHold.final !== final) return
|
|
1711
|
+
if (fe.text.toString() !== final) try { postOnce(`late:${relPath}:${k}:${v.b}`, `[!] ${relPath}: ${v.n || k}'s save of a line arrived after the conflict on it was written, and it was not added to the block in ${CLASH_HOLD_MS / 1000} s. The block may hold a fragment of that save. Check it by hand.`, { observer: true }) } catch { }
|
|
1712
|
+
releaseHold(relPath, fe)
|
|
1713
|
+
}, CLASH_HOLD_MS + 50)
|
|
1714
|
+
if (t.unref) t.unref()
|
|
1715
|
+
}
|
|
1716
|
+
return taken
|
|
1717
|
+
}
|
|
1718
|
+
// A remote transaction arrived. If it carries an intent made from the same text as another recent
|
|
1719
|
+
// intent, and the two changed the same line, the shared text now holds their character union.
|
|
1720
|
+
//
|
|
1721
|
+
// WHY THE WAIT. Saves made "at the same moment" are saves made before any of them reached the others,
|
|
1722
|
+
// so they arrive within about one round trip of each other, in different orders on different peers.
|
|
1723
|
+
// Resolving on the second arrival let two peers each see a different pair, each be the lowest of its
|
|
1724
|
+
// pair, and both write: three saves became two, and one author's line was lost (R6). So every peer
|
|
1725
|
+
// first holds its disk, waits CLASH_SETTLE_MS for the rest of the race to arrive, and then decides from
|
|
1726
|
+
// everything made from that text: the same group, the same text, and one writer, the lowest client.
|
|
1727
|
+
function checkClash(relPath, txn, taken = new Set()) {
|
|
1728
|
+
const fe = fileDocs.get(relPath)
|
|
1729
|
+
if (!fe || !fe.intent) return
|
|
1730
|
+
const keys = txn.changed && txn.changed.get(fe.intent)
|
|
1731
|
+
if (!keys || !keys.size) return
|
|
1732
|
+
const now = Date.now()
|
|
1733
|
+
// Kept as long as a held save still counts as a race (LATE_OWN_MS), not just the race window: a
|
|
1734
|
+
// save 18 s late met a line one other author had changed, and only those two clients still knew of
|
|
1735
|
+
// both saves, so every other disk took the fused line until the block landed (same-line-soak-test).
|
|
1736
|
+
for (const [c, v] of fe.intents) if (now - v.at > LATE_OWN_MS) fe.intents.delete(c)
|
|
1737
|
+
// Our own last save stays in the race until it has been seen (P0-F-12): when it was held on the way,
|
|
1738
|
+
// the others' saves reach us long after the window, and without it here their union went to this disk.
|
|
1739
|
+
const own = fe.lastOwn, me = fe.doc.clientID
|
|
1740
|
+
if (own && now - own.at < LATE_OWN_MS && !fe.intents.has(me)) {
|
|
1741
|
+
const h = intentHunks(own.base, own.side)
|
|
1742
|
+
if (h) fe.intents.set(me, { b: contentHash(own.base), h, n: name, at: own.at })
|
|
1743
|
+
}
|
|
1744
|
+
const bases = new Set()
|
|
1745
|
+
for (const k of keys) {
|
|
1746
|
+
const v = fe.intent.get(k)
|
|
1747
|
+
const c = Number(k)
|
|
1748
|
+
if (!v || !Array.isArray(v.h) || typeof v.b !== 'string' || c === fe.doc.clientID) continue
|
|
1749
|
+
fe.intents.set(c, { b: v.b, h: v.h, n: v.n, at: now, ...(v.m ? { m: 1, v: Array.isArray(v.v) ? v.v : v.h } : {}) })
|
|
1750
|
+
if (!v.m) bases.add(v.b)
|
|
1751
|
+
}
|
|
1752
|
+
for (const b of bases) {
|
|
1753
|
+
if (taken.has(b) || (fe.clashHold && fe.clashHold.late)) continue
|
|
1754
|
+
if (fe.recent.get(b) == null || !clashGroup(fe, b)) continue
|
|
1755
|
+
// Hold this disk now: the shared text may already be the union.
|
|
1756
|
+
fe.clashHold = { final: null, until: now + CLASH_SETTLE_MS + CLASH_HOLD_MS }
|
|
1757
|
+
if (fe.clashTimers.has(b)) continue
|
|
1758
|
+
const t = setTimeout(() => { fe.clashTimers.delete(b); if (!stopped) resolveClash(relPath, fe, b) }, CLASH_SETTLE_MS)
|
|
1759
|
+
if (t.unref) t.unref()
|
|
1760
|
+
fe.clashTimers.set(b, t)
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
// The intents made from text b that clash with another one, as clientID -> hunks; null if none do.
|
|
1764
|
+
function clashGroup(fe, b) {
|
|
1765
|
+
const same = [...fe.intents].filter(([, v]) => v.b === b && !v.m)
|
|
1766
|
+
const group = new Map()
|
|
1767
|
+
for (const [c, v] of same) for (const [d, w] of same) if (c !== d && hunksClash(v.h, w.h)) { group.set(c, v.h); break }
|
|
1768
|
+
return group.size >= 2 ? group : null
|
|
1769
|
+
}
|
|
1770
|
+
function releaseHold(relPath, fe) {
|
|
1771
|
+
fe.clashHold = null
|
|
1772
|
+
if (fe.pendingLocal) runDeferred(relPath, fe)
|
|
1773
|
+
else reconcile(relPath, 'remote')
|
|
1774
|
+
}
|
|
1775
|
+
// The save that waited (deferLocal) goes out now. It was made from what this disk held -- the last
|
|
1776
|
+
// synced text -- and the resolution has landed since. When its lines and the resolution's do not
|
|
1777
|
+
// overlap, both are applied to that text, line by line: exact, no character CRDT and no merge3 (which
|
|
1778
|
+
// strips markers, and dropped Bob's version of the resolved block, R13). When they overlap, the ordinary
|
|
1779
|
+
// merge decides, against that same text.
|
|
1780
|
+
function runDeferred(relPath, fe) {
|
|
1781
|
+
fe.pendingLocal = false
|
|
1782
|
+
let disk = null
|
|
1783
|
+
try { disk = readText(path.join(ROOT, relPath)) } catch { }
|
|
1784
|
+
const base = bases.get(relPath), doc = fe.text.toString()
|
|
1785
|
+
if (disk == null || base == null || disk === base || disk === doc) { reconcile(relPath, 'remote'); return }
|
|
1786
|
+
const mine = intentHunks(base, disk), theirs = intentHunks(base, doc)
|
|
1787
|
+
if (mine && theirs && !linesOverlap(mine, theirs)) {
|
|
1788
|
+
const merged = applyTagged(base.split('\n'), tagged(new Map([[0, theirs], [1, mine]]))).join('\n')
|
|
1789
|
+
log(`[${name}] ${relPath}: the save made while a race was resolved goes out on top of the resolution`)
|
|
1790
|
+
fe.doc.transact(() => { applyDiff(fe.text, merged); if (!FORK_GATE) recordIntent(relPath, fe, doc, merged, merged) })
|
|
1791
|
+
if (merged !== disk) writeToDisk(relPath, merged)
|
|
1792
|
+
known.add(relPath); bases.set(relPath, merged); forkBases.set(relPath, merged)
|
|
1793
|
+
return
|
|
1794
|
+
}
|
|
1795
|
+
fe.deferredMerge = true
|
|
1796
|
+
try { reconcile(relPath, 'local') } finally { fe.deferredMerge = false }
|
|
1797
|
+
}
|
|
1798
|
+
function deferLocal(relPath, fe) {
|
|
1799
|
+
unsentLocal.add(relPath)
|
|
1800
|
+
if (fe.pendingLocal) return
|
|
1801
|
+
fe.pendingLocal = true
|
|
1802
|
+
const t = setTimeout(() => { if (!stopped && fe.pendingLocal) runDeferred(relPath, fe) }, Math.max(0, fe.clashHold.until - Date.now()) + 20)
|
|
1803
|
+
if (t.unref) t.unref()
|
|
1804
|
+
}
|
|
1805
|
+
function resolveClash(relPath, fe, b) {
|
|
1806
|
+
if (fe.clashHold && fe.clashHold.late) return // P0-F-12 has this file: its own timer ends the hold
|
|
1807
|
+
const base = fe.recent.get(b)
|
|
1808
|
+
const group = base == null ? null : clashGroup(fe, b)
|
|
1809
|
+
if (!group) { releaseHold(relPath, fe); return }
|
|
1810
|
+
const ids = [...group.keys()].sort((x, y) => x - y)
|
|
1811
|
+
const key = `${b}|${ids.join(',')}`
|
|
1812
|
+
// Every save made from this text at the same moment is in the shared text's union, the ones that
|
|
1813
|
+
// clash and the ones that do not: the resolution is computed from all of them, or it would delete a
|
|
1814
|
+
// non-clashing author's edit (and be refused, since the union would not match: R9).
|
|
1815
|
+
const all = new Map([...fe.intents].filter(([, v]) => v.b === b && !v.m).map(([c, v]) => [c, v.h]))
|
|
1816
|
+
// F5. A save made from another text after the race began. When that text was what its author held --
|
|
1817
|
+
// one side, or a clean merge of sides -- the save is that author's version of the file from `base`,
|
|
1818
|
+
// and it joins the resolution (its hunks rebased onto `base`). Any other is someone's later edit that
|
|
1819
|
+
// the resolution would overwrite: then do not resolve, and say so.
|
|
1820
|
+
const since = Math.min(...ids.map((i) => (fe.intents.get(i) || {}).at || Infinity))
|
|
1821
|
+
const members = new Map(all), laters = []
|
|
1822
|
+
let unexplained = false
|
|
1823
|
+
// Whether a save came after the race is decided by what it was made from, not by when this peer heard
|
|
1824
|
+
// of it: arrival times are local, and a side's time here can be later than a save built on that side
|
|
1825
|
+
// (R9, 1 run in 40). Only a save nobody can explain is judged by time: older ones predate the race.
|
|
1826
|
+
for (const [c, v] of [...fe.intents].filter(([, v]) => v.b !== b).sort((x, y) => x[1].at - y[1].at)) {
|
|
1827
|
+
const ex = explainBase(base, all, v.b)
|
|
1828
|
+
// The author's own version: for a merge that raised markers, the file they saved, not the markers.
|
|
1829
|
+
const rebased = ex && intentHunks(base, applyIntent(ex.text, v.m ? v.v : v.h))
|
|
1830
|
+
if (!rebased) { if (v.at > since) { unexplained = true; break } continue }
|
|
1831
|
+
members.set(c, rebased)
|
|
1832
|
+
laters.push({ id: c, after: ex.after, h: v.h })
|
|
1833
|
+
}
|
|
1834
|
+
const final = clashFinal(base, members, new Map([...members.keys()].map((i) => [i, (fe.intents.get(i) || {}).n])))
|
|
1835
|
+
const current = fe.text.toString()
|
|
1836
|
+
const who = ids.map((i) => (fe.intents.get(i) || {}).n || String(i)).join(', ')
|
|
1837
|
+
if (fe.clashDone.has(key) || current === final) { releaseHold(relPath, fe); return }
|
|
1838
|
+
fe.clashDone.add(key)
|
|
1839
|
+
if (unexplained || !clashOnlyRegion(base, all, final, current, laters)) {
|
|
1840
|
+
// The file changed outside the clash since: writing the resolution would delete that change.
|
|
1841
|
+
log(`[${name}] same-line race in ${relPath} (${who}) not resolved: the file changed since`)
|
|
1842
|
+
// Markers already in the file: a merge has flagged it and said so. A second line is noise --
|
|
1843
|
+
// unless the file also holds a line nobody wrote: the markers are then another race's, on other
|
|
1844
|
+
// lines, and this one's fused line would stay with nothing said (same-line-soak-test, seed 2).
|
|
1845
|
+
const wrote = new Set(base.split('\n'))
|
|
1846
|
+
for (const [, v] of fe.intents) { const t = fe.recent.get(v.b); if (t != null) for (const l of applyIntent(t, v.m ? v.v : v.h).split('\n')) wrote.add(l) }
|
|
1847
|
+
const fused = current.split('\n').some((l) => !wrote.has(l) && !/^(<<<<<<<|>>>>>>>)( |$)|^=======$/.test(l))
|
|
1848
|
+
if (fused || /[^\n](<<<<<<< |>>>>>>> )/.test(current)) try { postOnce(`race:${relPath}:${key}`, `[!] ${relPath}: ${who} saved the same line at the same moment, and the file changed again before it could be resolved, so the line may hold a mix of their text. Check it by hand.`, { observer: ids[0] !== fe.doc.clientID }) } catch { }
|
|
1849
|
+
releaseHold(relPath, fe); return
|
|
1850
|
+
}
|
|
1851
|
+
const writer = ids[0] === fe.doc.clientID
|
|
1852
|
+
log(`[${name}] same-line race in ${relPath}: ${who} saved one line at the same moment; ${writer ? 'writing' : 'waiting for'} the resolution`)
|
|
1853
|
+
if (writer) {
|
|
1854
|
+
fe.doc.transact(() => applyDiff(fe.text, final))
|
|
1855
|
+
rememberText(fe, final)
|
|
1856
|
+
releaseHold(relPath, fe)
|
|
1857
|
+
return
|
|
1858
|
+
}
|
|
1859
|
+
// Everyone else holds the disk until the resolution lands. If the writer never delivers it, a client
|
|
1860
|
+
// in the race writes it itself, and a bystander lets the shared text through.
|
|
1861
|
+
const participant = group.has(fe.doc.clientID)
|
|
1862
|
+
fe.clashHold = { final, until: Date.now() + CLASH_HOLD_MS }
|
|
1863
|
+
const t = setTimeout(() => {
|
|
1864
|
+
if (stopped || fe.clashHold == null || fe.clashHold.final !== final) return
|
|
1865
|
+
const cur = fe.text.toString()
|
|
1866
|
+
if (cur !== final && participant && clashOnlyRegion(base, all, final, cur, laters)) {
|
|
1867
|
+
log(`[${name}] same-line race in ${relPath}: no resolution arrived in ${CLASH_HOLD_MS / 1000} s, writing it`)
|
|
1868
|
+
fe.doc.transact(() => applyDiff(fe.text, final))
|
|
1869
|
+
}
|
|
1870
|
+
releaseHold(relPath, fe)
|
|
1871
|
+
}, CLASH_HOLD_MS + 50)
|
|
1872
|
+
if (t.unref) t.unref()
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1395
1875
|
function openFile(relPath) {
|
|
1396
1876
|
let e = fileDocs.get(relPath)
|
|
1397
1877
|
if (e) return e
|
|
@@ -1426,20 +1906,26 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
1426
1906
|
// Deterministic (computed from the ledger), converged across peers, and rendered to
|
|
1427
1907
|
// each peer's DISK — never into the shared text — so conflict markers can't fuse.
|
|
1428
1908
|
const conflict = fdoc.getMap('conflict')
|
|
1909
|
+
// intent: clientID -> { b, h, n }, what this client's last save changed and from which text (P0-F).
|
|
1910
|
+
const intent = fdoc.getMap('intent')
|
|
1429
1911
|
const undo = new Y.UndoManager(text, { captureTimeout: 350 }) // default tracks local-origin txns
|
|
1430
1912
|
// diskAtOpen: this folder's copy when the file started waiting for the room. An edit made before
|
|
1431
1913
|
// the file syncs -- through hive_edit OR the agent's own file tool -- was made on top of it, so it
|
|
1432
1914
|
// is that edit's ancestor at first contact, if the room held it (0.14.3 P0-1b-R3). Dropped once synced.
|
|
1433
1915
|
let diskAtOpen
|
|
1434
1916
|
try { const f0 = path.join(ROOT, relPath); if (fs.statSync(f0).size <= MAX_BYTES) diskAtOpen = readDiskText(f0) } catch { diskAtOpen = undefined }
|
|
1435
|
-
e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false, diskAtOpen }
|
|
1917
|
+
e = { doc: fdoc, provider: fprovider, text, bin, snap, ledger, head, versions, forkmark, conflict, undo, synced: false, diskAtOpen, intent, recent: new Map(), intents: new Map(), clashDone: new Set(), clashHold: null, clashTimers: new Map() }
|
|
1436
1918
|
fileDocs.set(relPath, e)
|
|
1437
1919
|
// A local transaction is one this client made; a peer's arrives through applyUpdate (local false).
|
|
1438
1920
|
fdoc.on('update', (_u, _origin, _doc, txn) => { if (!txn || txn.local) unsentLocal.add(relPath) })
|
|
1439
1921
|
// From the mtime scan() already took, not two more syscalls per open: a 1,000-file join opens
|
|
1440
1922
|
// 1,000 files, and fileCreationTs() still falls back to a stat for any file this missed.
|
|
1441
1923
|
if (!createdTimes.has(relPath) && mtimes.has(relPath)) createdTimes.set(relPath, mtimes.get(relPath))
|
|
1442
|
-
text
|
|
1924
|
+
// Every text this client itself puts in the room is remembered, however it got there: a file's
|
|
1925
|
+
// first publish records no intent, so its author did not know the text two later saves were made
|
|
1926
|
+
// from, saw no race, and wrote their fused line to its disk (same-line-soak-test, seed 2).
|
|
1927
|
+
text.observe((_ev, txn) => { if (txn.local) rememberText(e, text.toString()) })
|
|
1928
|
+
text.observe((_ev, txn) => { if (!txn.local) { if (!FORK_GATE) checkClash(relPath, txn, checkLate(relPath, txn)); reconcile(relPath, 'remote') } }) // remote content edits
|
|
1443
1929
|
bin.observe((_ev, txn) => { if (!txn.local) reconcileBinary(relPath, 'remote') }) // remote binary edits
|
|
1444
1930
|
// A divergent receipt can arrive slightly after (or before) the fused content — so
|
|
1445
1931
|
// re-run the fork check whenever the provenance ledger changes too, not only on edits.
|
|
@@ -2562,6 +3048,16 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2562
3048
|
// and until then leave() must wait for it (0.14.3 P0-1: a new file written just before leaving).
|
|
2563
3049
|
if (!fe) { if (disk === null) return; openFile(relPath); if (origin === 'local') unsentLocal.add(relPath); return }
|
|
2564
3050
|
if (!fe.synced) { if (origin === 'local' && disk !== null) unsentLocal.add(relPath); return }
|
|
3051
|
+
// A detected same-line race is being resolved (P0-F): write nothing from the shared text until the
|
|
3052
|
+
// resolution arrives, so the fused text never reaches this disk. A local save still goes through.
|
|
3053
|
+
if (fe.clashHold && fe.text.toString() !== fe.clashHold.final && Date.now() < fe.clashHold.until) {
|
|
3054
|
+
// A local save made while a race is being resolved waits for the resolution too (F5): merged now,
|
|
3055
|
+
// against the shared text, it would take the fused line into this disk and publish it again. The
|
|
3056
|
+
// edit is safe on disk meanwhile; it goes out as soon as the hold ends.
|
|
3057
|
+
if (origin === 'local') deferLocal(relPath, fe)
|
|
3058
|
+
return
|
|
3059
|
+
}
|
|
3060
|
+
if (fe.clashHold) { fe.clashHold = null; if (fe.pendingLocal) { runDeferred(relPath, fe); return } }
|
|
2565
3061
|
// A KEPT UNSENT EDIT IS FROZEN until its author saves the file again. Merging it against the
|
|
2566
3062
|
// room in either direction would decide something nobody decided: the room's next change would
|
|
2567
3063
|
// be written over it, and an unchanged save merged against itself pulled the room's old copy over
|
|
@@ -2639,6 +3135,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2639
3135
|
}
|
|
2640
3136
|
dropStackedAwaits(relPath, fe, disk) // before anything reads the room's copy (0.14.3 P0-5-3)
|
|
2641
3137
|
const docText = fe.text.toString()
|
|
3138
|
+
rememberText(fe, docText)
|
|
2642
3139
|
// An empty shared doc is authoritative ONLY if we have history for this path.
|
|
2643
3140
|
// A file's NAME lives in the parent doc and its CONTENT in the file's own doc, on two
|
|
2644
3141
|
// different connections — there is no atomicity across them. A client that joins a large
|
|
@@ -2820,7 +3317,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2820
3317
|
return
|
|
2821
3318
|
}
|
|
2822
3319
|
}
|
|
2823
|
-
if (r0.text !== docText) { fe.doc.transact(() => applyDiff(yt, r0.text)); signPublished(relPath, docText, r0.text, disk) }
|
|
3320
|
+
if (r0.text !== docText) { fe.doc.transact(() => { applyDiff(yt, r0.text); if (!FORK_GATE) recordIntent(relPath, fe, docText, r0.text) }); signPublished(relPath, docText, r0.text, disk) }
|
|
2824
3321
|
if (r0.text !== disk) writeToDisk(relPath, r0.text)
|
|
2825
3322
|
if (r0.conflict) conflicted.add(relPath)
|
|
2826
3323
|
bases.set(relPath, r0.text); forkBases.set(relPath, r0.text); shadow.set(relPath, r0.text)
|
|
@@ -2889,7 +3386,7 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
2889
3386
|
// the local side first so the pre-merge bytes are recoverable regardless of outcome.
|
|
2890
3387
|
captureSnapshot(relPath, disk, name, { force: true, kind: 'manual', label: 'local copy before rejoin merge' })
|
|
2891
3388
|
const rj = icrMerge3(anc, disk, docText, relPath)
|
|
2892
|
-
if (rj.text !== docText) { fe.doc.transact(() => applyDiff(yt, rj.text)); signPublished(relPath, docText, rj.text, disk) }
|
|
3389
|
+
if (rj.text !== docText) { fe.doc.transact(() => { applyDiff(yt, rj.text); if (!FORK_GATE) recordIntent(relPath, fe, docText, rj.text) }); signPublished(relPath, docText, rj.text, disk) }
|
|
2893
3390
|
if (rj.text !== disk) writeToDisk(relPath, rj.text)
|
|
2894
3391
|
if (rj.conflict) conflicted.add(relPath)
|
|
2895
3392
|
bases.set(relPath, rj.text); forkBases.set(relPath, rj.text); shadow.set(relPath, rj.text)
|
|
@@ -3005,11 +3502,15 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3005
3502
|
}
|
|
3006
3503
|
}
|
|
3007
3504
|
const base = bases.has(relPath) ? bases.get(relPath) : disk
|
|
3008
|
-
|
|
3505
|
+
// A save that waited out a race (F5) was made from what this disk held then -- the last synced text --
|
|
3506
|
+
// not from the resolution that has landed since: merge it from there, and never read the markers it
|
|
3507
|
+
// never saw as resolved by it.
|
|
3508
|
+
const deferred = origin === 'local' && fe.deferredMerge
|
|
3509
|
+
const fork = deferred ? base : forkBases.has(relPath) ? forkBases.get(relPath) : base
|
|
3009
3510
|
if (origin === 'local') { noteCoEditing(relPath); markEditing(relPath) } // broadcast activity + warn if someone else is on this file
|
|
3010
3511
|
|
|
3011
3512
|
let res, reAdded = false
|
|
3012
|
-
if (origin === 'local' && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
3513
|
+
if (origin === 'local' && !deferred && hasConflictMarkers(docText) && !hasConflictMarkers(disk)) {
|
|
3013
3514
|
// Resolving a conflict: the doc still has <<<<<<< markers and this local
|
|
3014
3515
|
// write removed them — the author has resolved it. Their clean version wins.
|
|
3015
3516
|
res = { text: disk, conflict: false }
|
|
@@ -3099,7 +3600,9 @@ export function startSync({ relay = 'ws://localhost:1234', room = 'default', dir
|
|
|
3099
3600
|
captureSnapshot(relPath, docText, name, { force: sc.isRewrite, churn: `${sc.changedLines}/${sc.totalLines} lines` })
|
|
3100
3601
|
lastLocalEdit = relPath
|
|
3101
3602
|
}
|
|
3102
|
-
|
|
3603
|
+
// The save's INTENT travels in the same transaction as its characters (P0-F): what it changed, and
|
|
3604
|
+
// from which text, so a peer that receives two saves of one line made from the same text can tell.
|
|
3605
|
+
fileDocs.get(relPath).doc.transact(() => { applyDiff(yt, res.text); if (!FORK_GATE && res.text !== docText) recordIntent(relPath, fe, docText, res.text, disk) })
|
|
3103
3606
|
// Sign this landed local change into the file's provenance ledger (I1). Stamp the
|
|
3104
3607
|
// author's TRUE intent — the state THEY edited from (fork) -> the content THEY wrote
|
|
3105
3608
|
// (disk) — NOT the live shared text, which under truly-concurrent edits is already
|