amalgm 0.1.152 → 0.1.153
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/lib/tunnel-events.js +268 -49
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/app-resources.js +2 -21
- package/runtime/scripts/amalgm-mcp/state/db.js +50 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +351 -31
- package/runtime/scripts/amalgm-mcp/state/merge3.js +197 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +79 -20
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +11 -1
- package/runtime/scripts/amalgm-mcp/tests/state-docs-merge.test.js +251 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs-versioning.test.js +394 -0
- package/runtime/scripts/amalgm-mcp/tests/state-stream-bootstrap.test.js +223 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal three-way line-based merge (diff3) for live-document reconcile.
|
|
5
|
+
*
|
|
6
|
+
* merge3(base, ours, theirs) — all three are whole-file strings — answers:
|
|
7
|
+
* given that both OURS and THEIRS evolved from BASE, what single text keeps
|
|
8
|
+
* both sides' changes? Hunks (line regions changed relative to BASE) that
|
|
9
|
+
* touch different base regions merge automatically; hunks that overlap are
|
|
10
|
+
* conflicts. On conflict this module NEVER picks a winner silently: the
|
|
11
|
+
* merged text keeps OURS for the conflicted region and the conflict is
|
|
12
|
+
* reported to the caller, which is responsible for preserving THEIRS
|
|
13
|
+
* durably (docs.js writes a sidecar file).
|
|
14
|
+
*
|
|
15
|
+
* No dependencies by design — this ships in the published package. The diff
|
|
16
|
+
* is a plain LCS over lines (common prefix/suffix trimmed first); regions too
|
|
17
|
+
* large to LCS degrade to a single whole-region hunk, which can only ever
|
|
18
|
+
* OVER-report a conflict, never lose an edit.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Cap on the LCS DP table (cells = changed-lines² after prefix/suffix trim).
|
|
22
|
+
// 4M cells ≈ 16MB of Int32 — beyond it the changed region collapses into one
|
|
23
|
+
// hunk per side, trading merge granularity for bounded memory.
|
|
24
|
+
const MAX_LCS_CELLS = 4 * 1024 * 1024;
|
|
25
|
+
|
|
26
|
+
/** Split into lines with their terminators kept, so join('') reassembles exactly. */
|
|
27
|
+
function splitLines(text) {
|
|
28
|
+
if (text === '') return [];
|
|
29
|
+
return text.split(/(?<=\n)/);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Longest common subsequence between a[aStart..aEnd) and b[bStart..bEnd),
|
|
34
|
+
* returned as matched index pairs [aIndex, bIndex] in increasing order.
|
|
35
|
+
*/
|
|
36
|
+
function lcsMatches(a, aStart, aEnd, b, bStart, bEnd) {
|
|
37
|
+
const n = aEnd - aStart;
|
|
38
|
+
const m = bEnd - bStart;
|
|
39
|
+
const width = m + 1;
|
|
40
|
+
const table = new Int32Array((n + 1) * width);
|
|
41
|
+
for (let i = 1; i <= n; i += 1) {
|
|
42
|
+
const line = a[aStart + i - 1];
|
|
43
|
+
for (let j = 1; j <= m; j += 1) {
|
|
44
|
+
table[i * width + j] = line === b[bStart + j - 1]
|
|
45
|
+
? table[(i - 1) * width + (j - 1)] + 1
|
|
46
|
+
: Math.max(table[(i - 1) * width + j], table[i * width + (j - 1)]);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const matches = [];
|
|
50
|
+
let i = n;
|
|
51
|
+
let j = m;
|
|
52
|
+
while (i > 0 && j > 0) {
|
|
53
|
+
if (a[aStart + i - 1] === b[bStart + j - 1]
|
|
54
|
+
&& table[i * width + j] === table[(i - 1) * width + (j - 1)] + 1) {
|
|
55
|
+
matches.push([aStart + i - 1, bStart + j - 1]);
|
|
56
|
+
i -= 1;
|
|
57
|
+
j -= 1;
|
|
58
|
+
} else if (table[(i - 1) * width + j] >= table[i * width + (j - 1)]) {
|
|
59
|
+
i -= 1;
|
|
60
|
+
} else {
|
|
61
|
+
j -= 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
matches.reverse();
|
|
65
|
+
return matches;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Regions where `side` differs from `base`, as
|
|
70
|
+
* {baseStart, baseLength, sideStart, sideLength} in base order. Outside these
|
|
71
|
+
* hunks the two arrays match line for line.
|
|
72
|
+
*/
|
|
73
|
+
function diffHunks(base, side) {
|
|
74
|
+
let prefix = 0;
|
|
75
|
+
const maxPrefix = Math.min(base.length, side.length);
|
|
76
|
+
while (prefix < maxPrefix && base[prefix] === side[prefix]) prefix += 1;
|
|
77
|
+
let baseEnd = base.length;
|
|
78
|
+
let sideEnd = side.length;
|
|
79
|
+
while (baseEnd > prefix && sideEnd > prefix && base[baseEnd - 1] === side[sideEnd - 1]) {
|
|
80
|
+
baseEnd -= 1;
|
|
81
|
+
sideEnd -= 1;
|
|
82
|
+
}
|
|
83
|
+
const n = baseEnd - prefix;
|
|
84
|
+
const m = sideEnd - prefix;
|
|
85
|
+
if (n === 0 && m === 0) return [];
|
|
86
|
+
if (n === 0 || m === 0 || n * m > MAX_LCS_CELLS) {
|
|
87
|
+
return [{ baseStart: prefix, baseLength: n, sideStart: prefix, sideLength: m }];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hunks = [];
|
|
91
|
+
let b = prefix;
|
|
92
|
+
let s = prefix;
|
|
93
|
+
for (const [mb, ms] of lcsMatches(base, prefix, baseEnd, side, prefix, sideEnd)) {
|
|
94
|
+
if (mb > b || ms > s) {
|
|
95
|
+
hunks.push({ baseStart: b, baseLength: mb - b, sideStart: s, sideLength: ms - s });
|
|
96
|
+
}
|
|
97
|
+
b = mb + 1;
|
|
98
|
+
s = ms + 1;
|
|
99
|
+
}
|
|
100
|
+
if (baseEnd > b || sideEnd > s) {
|
|
101
|
+
hunks.push({ baseStart: b, baseLength: baseEnd - b, sideStart: s, sideLength: sideEnd - s });
|
|
102
|
+
}
|
|
103
|
+
return hunks;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The side's lines corresponding to the group's base range [start, end).
|
|
108
|
+
* Between its own hunks a side matches the base line for line, so the range
|
|
109
|
+
* extends from the first hunk (pulled back by the matched lead-in) to the
|
|
110
|
+
* last hunk (pushed forward by the matched tail).
|
|
111
|
+
*/
|
|
112
|
+
function sideSlice(group, sideHunks, baseLines, sideLines) {
|
|
113
|
+
if (sideHunks.length === 0) return baseLines.slice(group.start, group.end);
|
|
114
|
+
const first = sideHunks[0];
|
|
115
|
+
const last = sideHunks[sideHunks.length - 1];
|
|
116
|
+
const lo = first.sideStart - (first.baseStart - group.start);
|
|
117
|
+
const hi = last.sideStart + last.sideLength + (group.end - (last.baseStart + last.baseLength));
|
|
118
|
+
return sideLines.slice(lo, hi);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Three-way merge of whole-file strings.
|
|
123
|
+
*
|
|
124
|
+
* Returns {text, conflicts}: `text` is the merged result; `conflicts` lists
|
|
125
|
+
* every overlapping region as {baseStart, baseEnd, ours, theirs} (line
|
|
126
|
+
* indices into BASE; `ours`/`theirs` are that region's text on each side).
|
|
127
|
+
* For conflicted regions `text` keeps OURS.
|
|
128
|
+
*
|
|
129
|
+
* Fast paths: either side identical to base (or to the other side) short-
|
|
130
|
+
* circuits without diffing.
|
|
131
|
+
*/
|
|
132
|
+
function merge3(baseText, oursText, theirsText) {
|
|
133
|
+
if (oursText === theirsText) return { text: oursText, conflicts: [] };
|
|
134
|
+
if (oursText === baseText) return { text: theirsText, conflicts: [] };
|
|
135
|
+
if (theirsText === baseText) return { text: oursText, conflicts: [] };
|
|
136
|
+
|
|
137
|
+
const base = splitLines(baseText);
|
|
138
|
+
const ours = splitLines(oursText);
|
|
139
|
+
const theirs = splitLines(theirsText);
|
|
140
|
+
const hunks = [
|
|
141
|
+
...diffHunks(base, ours).map((h) => ({ ...h, side: 'ours' })),
|
|
142
|
+
...diffHunks(base, theirs).map((h) => ({ ...h, side: 'theirs' })),
|
|
143
|
+
].sort((a, b) => (a.baseStart - b.baseStart)
|
|
144
|
+
|| (a.side === b.side ? 0 : a.side === 'ours' ? -1 : 1));
|
|
145
|
+
|
|
146
|
+
// Group hunks whose base ranges overlap. Adjacent (touching) ranges stay
|
|
147
|
+
// separate — they merge cleanly — with one exception: insertions from both
|
|
148
|
+
// sides at the exact same base point have no defined order, so they group
|
|
149
|
+
// (and therefore conflict) too.
|
|
150
|
+
const groups = [];
|
|
151
|
+
for (const hunk of hunks) {
|
|
152
|
+
const group = groups[groups.length - 1];
|
|
153
|
+
const joins = group && (
|
|
154
|
+
hunk.baseStart < group.end
|
|
155
|
+
|| (hunk.baseStart === group.end && hunk.baseLength === 0
|
|
156
|
+
&& group.hunks.some((h) => h.side !== hunk.side
|
|
157
|
+
&& h.baseLength === 0 && h.baseStart === hunk.baseStart))
|
|
158
|
+
);
|
|
159
|
+
if (joins) {
|
|
160
|
+
group.hunks.push(hunk);
|
|
161
|
+
group.end = Math.max(group.end, hunk.baseStart + hunk.baseLength);
|
|
162
|
+
} else {
|
|
163
|
+
groups.push({ start: hunk.baseStart, end: hunk.baseStart + hunk.baseLength, hunks: [hunk] });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const out = [];
|
|
168
|
+
const conflicts = [];
|
|
169
|
+
let cursor = 0;
|
|
170
|
+
for (const group of groups) {
|
|
171
|
+
out.push(base.slice(cursor, group.start).join(''));
|
|
172
|
+
const oursHunks = group.hunks.filter((h) => h.side === 'ours');
|
|
173
|
+
const theirsHunks = group.hunks.filter((h) => h.side === 'theirs');
|
|
174
|
+
const oursRegion = sideSlice(group, oursHunks, base, ours).join('');
|
|
175
|
+
const theirsRegion = sideSlice(group, theirsHunks, base, theirs).join('');
|
|
176
|
+
if (theirsHunks.length === 0 || oursRegion === theirsRegion) {
|
|
177
|
+
out.push(oursRegion);
|
|
178
|
+
} else if (oursHunks.length === 0) {
|
|
179
|
+
out.push(theirsRegion);
|
|
180
|
+
} else {
|
|
181
|
+
// Overlap with different content on each side: keep OURS in the text,
|
|
182
|
+
// surface the conflict to the caller.
|
|
183
|
+
conflicts.push({
|
|
184
|
+
baseStart: group.start,
|
|
185
|
+
baseEnd: group.end,
|
|
186
|
+
ours: oursRegion,
|
|
187
|
+
theirs: theirsRegion,
|
|
188
|
+
});
|
|
189
|
+
out.push(oursRegion);
|
|
190
|
+
}
|
|
191
|
+
cursor = group.end;
|
|
192
|
+
}
|
|
193
|
+
out.push(base.slice(cursor).join(''));
|
|
194
|
+
return { text: out.join(''), conflicts };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { merge3 };
|
|
@@ -109,6 +109,21 @@ async function handleDocUpdate(body, sendJson) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
// Versioned whole-text write (the SDK write contract — semantics live in
|
|
113
|
+
// docs.writeDocText): body {path, text, baseHash?, baseSeq?}. `baseHash` is
|
|
114
|
+
// the sha256 returned by open/write for the exact text the writer read;
|
|
115
|
+
// with it, stale writes three-way merge instead of clobbering live edits.
|
|
116
|
+
async function handleDocWrite(body, sendJson) {
|
|
117
|
+
try {
|
|
118
|
+
sendJson(200, require('./docs').writeDocText(body?.path, body?.text, {
|
|
119
|
+
baseHash: body?.baseHash,
|
|
120
|
+
baseSeq: body?.baseSeq,
|
|
121
|
+
}));
|
|
122
|
+
} catch (error) {
|
|
123
|
+
sendHandlerError(error, sendJson);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
async function handleResourceList(sendJson) {
|
|
113
128
|
try {
|
|
114
129
|
sendJson(200, { resources: appResources.listAppResources() });
|
|
@@ -134,7 +149,11 @@ async function handleEvents(query, sendJson) {
|
|
|
134
149
|
|
|
135
150
|
function handleStream(req, res, query) {
|
|
136
151
|
appResources.ensureAppResourcesLoaded();
|
|
137
|
-
|
|
152
|
+
// Ordered-bootstrap mode (docs/local-live-protocol.md §3): the snapshot
|
|
153
|
+
// travels on the stream itself as the first data frame, so `after` is
|
|
154
|
+
// ignored — the snapshot IS the resume point.
|
|
155
|
+
const bootstrap = String(query.bootstrap || '').trim() === '1';
|
|
156
|
+
const after = bootstrap ? 0 : parsePositiveInt(query.after, 0);
|
|
138
157
|
|
|
139
158
|
res.writeHead(200, {
|
|
140
159
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
@@ -144,23 +163,26 @@ function handleStream(req, res, query) {
|
|
|
144
163
|
});
|
|
145
164
|
res.write(': local-live-store connected\n\n');
|
|
146
165
|
|
|
147
|
-
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
166
|
+
let backlog = [];
|
|
167
|
+
if (!bootstrap) {
|
|
168
|
+
const gap = replayGapAfter(after);
|
|
169
|
+
if (gap) {
|
|
170
|
+
sendSseControl(res, 'reset', { ...gap, reset: true });
|
|
171
|
+
res.end();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
153
174
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
175
|
+
backlog = listEventsAfter(after, { limit: STREAM_REPLAY_MAX_EVENTS + 1 });
|
|
176
|
+
if (backlog.length > STREAM_REPLAY_MAX_EVENTS) {
|
|
177
|
+
sendSseControl(res, 'reset', {
|
|
178
|
+
code: 'state_replay_too_large',
|
|
179
|
+
message: 'Too many missed events to replay; fetch a fresh snapshot.',
|
|
180
|
+
after,
|
|
181
|
+
reset: true,
|
|
182
|
+
});
|
|
183
|
+
res.end();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
// One teardown for every way the stream can die. Idempotent because close
|
|
@@ -195,12 +217,48 @@ function handleStream(req, res, query) {
|
|
|
195
217
|
}
|
|
196
218
|
};
|
|
197
219
|
|
|
220
|
+
// The subscriber registers BEFORE the bootstrap snapshot is built so no
|
|
221
|
+
// event can slip between them: events landing mid-build are held here and
|
|
222
|
+
// flushed after the snapshot frame. `floorSeq` is the resume point — the
|
|
223
|
+
// client's `after` in legacy mode, the snapshot's seq once it is built.
|
|
224
|
+
let heldWhileBootstrapping = bootstrap ? [] : null;
|
|
225
|
+
let floorSeq = after;
|
|
198
226
|
unsubscribe = subscribeStateEvents((event) => {
|
|
199
|
-
if (
|
|
227
|
+
if (heldWhileBootstrapping) {
|
|
228
|
+
heldWhileBootstrapping.push(event);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (event.seq > floorSeq) writeSafe(() => sendSseEvent(res, event));
|
|
200
232
|
});
|
|
201
233
|
|
|
202
|
-
|
|
203
|
-
|
|
234
|
+
if (bootstrap) {
|
|
235
|
+
let snapshot;
|
|
236
|
+
try {
|
|
237
|
+
// Exactly the REST snapshot path (handleSnapshot), including its
|
|
238
|
+
// failure contract: a resource read throwing must surface as a clean
|
|
239
|
+
// stream error, not a response that never completes.
|
|
240
|
+
snapshot = buildSnapshot(query.resources);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
writeSafe(() => sendSseControl(res, 'error', {
|
|
243
|
+
status: Number(error?.statusCode) || 500,
|
|
244
|
+
error: error?.message || 'internal error',
|
|
245
|
+
}));
|
|
246
|
+
teardown();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
floorSeq = snapshot.seq;
|
|
250
|
+
writeSafe(() => sendSseControl(res, 'snapshot', snapshot));
|
|
251
|
+
const held = heldWhileBootstrapping;
|
|
252
|
+
heldWhileBootstrapping = null;
|
|
253
|
+
// Events that landed while the snapshot was being built: anything at or
|
|
254
|
+
// below snapshot.seq is already inside it; the rest flush in seq order.
|
|
255
|
+
for (const event of held) {
|
|
256
|
+
if (event.seq > floorSeq) writeSafe(() => sendSseEvent(res, event));
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
for (const event of backlog) {
|
|
260
|
+
writeSafe(() => sendSseEvent(res, event));
|
|
261
|
+
}
|
|
204
262
|
}
|
|
205
263
|
|
|
206
264
|
// A named event (not an SSE comment) so EventSource clients can observe it
|
|
@@ -224,6 +282,7 @@ function handleStream(req, res, query) {
|
|
|
224
282
|
module.exports = {
|
|
225
283
|
handleDocOpen,
|
|
226
284
|
handleDocUpdate,
|
|
285
|
+
handleDocWrite,
|
|
227
286
|
handleEvents,
|
|
228
287
|
handleResourceEmit,
|
|
229
288
|
handleResourceList,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const assert = require('node:assert/strict');
|
|
4
|
-
const test = require('node:test');
|
|
4
|
+
const { after, test } = require('node:test');
|
|
5
5
|
|
|
6
6
|
// Transport semantics: in attached mode a session's identity is established
|
|
7
7
|
// once at attach (daemon tab selection + a stamp on the guest document) and
|
|
@@ -15,6 +15,16 @@ const test = require('node:test');
|
|
|
15
15
|
const attach = require('../browser/attach');
|
|
16
16
|
const { surfaces, knownSessions } = require('../browser/sessions');
|
|
17
17
|
|
|
18
|
+
// These transport tests assert their exact daemon command sequence. A real
|
|
19
|
+
// browser-auth bundle on the developer machine would prepend `state load`, so
|
|
20
|
+
// keep the fixture hermetic and restore the caller's environment afterward.
|
|
21
|
+
const savedCookieSource = process.env.AMALGM_BROWSER_COOKIE_SOURCE;
|
|
22
|
+
process.env.AMALGM_BROWSER_COOKIE_SOURCE = '0';
|
|
23
|
+
after(() => {
|
|
24
|
+
if (savedCookieSource === undefined) delete process.env.AMALGM_BROWSER_COOKIE_SOURCE;
|
|
25
|
+
else process.env.AMALGM_BROWSER_COOKIE_SOURCE = savedCookieSource;
|
|
26
|
+
});
|
|
27
|
+
|
|
18
28
|
// Patch attach.run before engine destructures it (engine binds at require).
|
|
19
29
|
const calls = [];
|
|
20
30
|
let nextResults = [];
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Three-way merge on external disk edits: a writer that read the file, then
|
|
4
|
+
// wrote a full text based on that stale read while a human typed via Yjs,
|
|
5
|
+
// must not delete the human's live edits (the audit's silent-loss finding).
|
|
6
|
+
// Unit tests cover state/merge3; integration tests cover the docs.js wiring:
|
|
7
|
+
// merged splices, conflict sidecars, the event's `conflict` field, and the
|
|
8
|
+
// watcher ignoring sidecars.
|
|
9
|
+
|
|
10
|
+
const test = require('node:test');
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-docs-merge-test-'));
|
|
17
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
18
|
+
|
|
19
|
+
const Y = require('yjs');
|
|
20
|
+
const { closeLocalDb } = require('../state/db');
|
|
21
|
+
const docs = require('../state/docs');
|
|
22
|
+
const { merge3 } = require('../state/merge3');
|
|
23
|
+
const { listEventsAfter, currentSeq } = require('../state/events');
|
|
24
|
+
|
|
25
|
+
const docDir = path.join(tempRoot, 'workspace');
|
|
26
|
+
fs.mkdirSync(docDir, { recursive: true });
|
|
27
|
+
|
|
28
|
+
function writeDoc(name, text) {
|
|
29
|
+
const target = path.join(docDir, name);
|
|
30
|
+
fs.writeFileSync(target, text, 'utf8');
|
|
31
|
+
return target;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sleep(ms) {
|
|
35
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clientFromState(stateBase64) {
|
|
39
|
+
const doc = new Y.Doc();
|
|
40
|
+
Y.applyUpdate(doc, new Uint8Array(Buffer.from(stateBase64, 'base64')));
|
|
41
|
+
return doc;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodeLocalEdit(doc, mutate) {
|
|
45
|
+
let captured = null;
|
|
46
|
+
const listener = (update) => { captured = update; };
|
|
47
|
+
doc.on('update', listener);
|
|
48
|
+
mutate(doc.getText('content'));
|
|
49
|
+
doc.off('update', listener);
|
|
50
|
+
assert.ok(captured, 'edit should produce an update');
|
|
51
|
+
return Buffer.from(captured).toString('base64');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sidecarsFor(target) {
|
|
55
|
+
const ext = path.extname(target);
|
|
56
|
+
const stem = path.basename(target, ext);
|
|
57
|
+
const pattern = new RegExp(
|
|
58
|
+
`^${stem}\\.conflicted-\\d{8}-\\d{6}(?:-\\d+)?${ext.replace('.', '\\.')}$`,
|
|
59
|
+
);
|
|
60
|
+
return fs.readdirSync(path.dirname(target))
|
|
61
|
+
.filter((name) => pattern.test(name))
|
|
62
|
+
.map((name) => path.join(path.dirname(target), name));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test.after(() => {
|
|
66
|
+
docs.closeAllDocs();
|
|
67
|
+
closeLocalDb();
|
|
68
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// merge3 unit tests
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
test('merge3: fast paths short-circuit when one side did not move', () => {
|
|
76
|
+
const base = 'a\nb\nc\n';
|
|
77
|
+
assert.deepEqual(merge3(base, base, 'a\nB\nc\n'), { text: 'a\nB\nc\n', conflicts: [] });
|
|
78
|
+
assert.deepEqual(merge3(base, 'a\nB\nc\n', base), { text: 'a\nB\nc\n', conflicts: [] });
|
|
79
|
+
assert.deepEqual(merge3(base, 'same\n', 'same\n'), { text: 'same\n', conflicts: [] });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('merge3: disjoint edits both survive', () => {
|
|
83
|
+
const base = 'l1\nl2\nl3\nl4\nl5\n';
|
|
84
|
+
const ours = 'l1 mine\nl2\nl3\nl4\nl5\n';
|
|
85
|
+
const theirs = 'l1\nl2\nl3\nl4\nl5 theirs\n';
|
|
86
|
+
const merged = merge3(base, ours, theirs);
|
|
87
|
+
assert.equal(merged.text, 'l1 mine\nl2\nl3\nl4\nl5 theirs\n');
|
|
88
|
+
assert.equal(merged.conflicts.length, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('merge3: adjacent-but-not-overlapping lines merge cleanly', () => {
|
|
92
|
+
const base = 'l1\nl2\nl3\nl4\n';
|
|
93
|
+
const ours = 'l1\nl2 mine\nl3\nl4\n';
|
|
94
|
+
const theirs = 'l1\nl2\nl3 theirs\nl4\n';
|
|
95
|
+
const merged = merge3(base, ours, theirs);
|
|
96
|
+
assert.equal(merged.text, 'l1\nl2 mine\nl3 theirs\nl4\n');
|
|
97
|
+
assert.equal(merged.conflicts.length, 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('merge3: overlapping edits conflict and keep OURS', () => {
|
|
101
|
+
const base = 'l1\nl2\nl3\n';
|
|
102
|
+
const ours = 'l1\nl2 mine\nl3\n';
|
|
103
|
+
const theirs = 'l1\nl2 theirs\nl3\n';
|
|
104
|
+
const merged = merge3(base, ours, theirs);
|
|
105
|
+
assert.equal(merged.text, 'l1\nl2 mine\nl3\n');
|
|
106
|
+
assert.equal(merged.conflicts.length, 1);
|
|
107
|
+
assert.equal(merged.conflicts[0].ours, 'l2 mine\n');
|
|
108
|
+
assert.equal(merged.conflicts[0].theirs, 'l2 theirs\n');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('merge3: identical overlapping changes apply once, no conflict', () => {
|
|
112
|
+
const base = 'l1\nl2\nl3\nl4\n';
|
|
113
|
+
// Both sides made the SAME change to l2; ours also changed l4 on its own.
|
|
114
|
+
const ours = 'l1\nl2 same-change\nl3\nl4 mine\n';
|
|
115
|
+
const theirs = 'l1\nl2 same-change\nl3\nl4\n';
|
|
116
|
+
const merged = merge3(base, ours, theirs);
|
|
117
|
+
assert.equal(merged.text, 'l1\nl2 same-change\nl3\nl4 mine\n');
|
|
118
|
+
assert.equal(merged.conflicts.length, 0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('merge3: insertions from both sides at the same point conflict', () => {
|
|
122
|
+
const base = 'l1\nl2\n';
|
|
123
|
+
const ours = 'l1\nours inserted\nl2\n';
|
|
124
|
+
const theirs = 'l1\ntheirs inserted\nl2\n';
|
|
125
|
+
const merged = merge3(base, ours, theirs);
|
|
126
|
+
assert.equal(merged.conflicts.length, 1);
|
|
127
|
+
assert.equal(merged.text, ours, 'conflicted insertion keeps OURS');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('merge3: whole-file rewrite vs one-line local edit conflicts, local line survives', () => {
|
|
131
|
+
const base = 'l1\nl2\nl3\n';
|
|
132
|
+
const ours = 'l1\nl2 human edit\nl3\n';
|
|
133
|
+
const theirs = 'completely\ndifferent\nfile\ncontents\n';
|
|
134
|
+
const merged = merge3(base, ours, theirs);
|
|
135
|
+
assert.equal(merged.conflicts.length, 1);
|
|
136
|
+
assert.ok(merged.text.includes('l2 human edit\n'), 'local edit must survive the rewrite');
|
|
137
|
+
assert.equal(merged.text, ours, 'a total-overlap rewrite keeps the live doc text');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Integration: stale-based external writes against live Yjs edits.
|
|
142
|
+
// Timing mirrors state-docs.test.js: the watcher reconcile debounce is 200ms
|
|
143
|
+
// and the doc's own write-back 300ms, so the external write lands immediately
|
|
144
|
+
// after the (unflushed) local edit and reconcile sees both sides diverged.
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
test('disjoint external write merges: human edit and agent edit both land', async () => {
|
|
148
|
+
const base = 'l1\nl2\nl3\nl4\nl5\n';
|
|
149
|
+
const target = writeDoc('merge-disjoint.md', base);
|
|
150
|
+
const opened = docs.openDoc(target);
|
|
151
|
+
const client = clientFromState(opened.state);
|
|
152
|
+
const before = currentSeq();
|
|
153
|
+
|
|
154
|
+
// Human types line 1 via Yjs (write-back still debounced: disk is stale)…
|
|
155
|
+
docs.applyDocUpdates(target, [
|
|
156
|
+
encodeLocalEdit(client, (ytext) => ytext.insert('l1'.length, ' human')),
|
|
157
|
+
]);
|
|
158
|
+
// …while an agent that read the ORIGINAL file writes a full text with only
|
|
159
|
+
// line 5 changed — based on the old content, without the human's edit.
|
|
160
|
+
fs.writeFileSync(target, 'l1\nl2\nl3\nl4\nl5 agent\n', 'utf8');
|
|
161
|
+
await sleep(900);
|
|
162
|
+
|
|
163
|
+
assert.equal(
|
|
164
|
+
docs.getDocText(target),
|
|
165
|
+
'l1 human\nl2\nl3\nl4\nl5 agent\n',
|
|
166
|
+
'both the live edit and the disjoint external edit must survive',
|
|
167
|
+
);
|
|
168
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'l1 human\nl2\nl3\nl4\nl5 agent\n');
|
|
169
|
+
assert.equal(sidecarsFor(target).length, 0, 'clean merge writes no sidecar');
|
|
170
|
+
const conflictEvents = listEventsAfter(before)
|
|
171
|
+
.filter((e) => e.resource === opened.resource && e.patch?.conflict);
|
|
172
|
+
assert.equal(conflictEvents.length, 0, 'clean merge publishes no conflict');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('overlapping external write conflicts: OURS kept, THEIRS preserved in sidecar, conflict published', async () => {
|
|
176
|
+
const base = 'alpha\nbeta\ngamma\n';
|
|
177
|
+
const target = writeDoc('merge-conflict.md', base);
|
|
178
|
+
const opened = docs.openDoc(target);
|
|
179
|
+
const client = clientFromState(opened.state);
|
|
180
|
+
const before = currentSeq();
|
|
181
|
+
|
|
182
|
+
docs.applyDocUpdates(target, [
|
|
183
|
+
encodeLocalEdit(client, (ytext) => ytext.insert('alpha\nbeta'.length, ' (human)')),
|
|
184
|
+
]);
|
|
185
|
+
const theirs = 'alpha\nbeta (agent)\ngamma\n';
|
|
186
|
+
fs.writeFileSync(target, theirs, 'utf8');
|
|
187
|
+
await sleep(900);
|
|
188
|
+
|
|
189
|
+
// The live doc keeps the human's line — never silently replaced.
|
|
190
|
+
assert.equal(docs.getDocText(target), 'alpha\nbeta (human)\ngamma\n');
|
|
191
|
+
// The merged text overwrote THEIRS on disk (intended: THEIRS is in the sidecar).
|
|
192
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'alpha\nbeta (human)\ngamma\n');
|
|
193
|
+
|
|
194
|
+
const sidecars = sidecarsFor(target);
|
|
195
|
+
assert.equal(sidecars.length, 1, 'conflict must write exactly one sidecar');
|
|
196
|
+
assert.equal(fs.readFileSync(sidecars[0], 'utf8'), theirs, 'sidecar holds the full incoming disk text');
|
|
197
|
+
|
|
198
|
+
const conflictEvents = listEventsAfter(before)
|
|
199
|
+
.filter((e) => e.resource === opened.resource && e.patch?.conflict);
|
|
200
|
+
assert.equal(conflictEvents.length, 1);
|
|
201
|
+
const { conflict } = conflictEvents[0].patch;
|
|
202
|
+
assert.equal(conflict.sidecarPath, sidecars[0]);
|
|
203
|
+
assert.equal(conflict.hunks, 1);
|
|
204
|
+
assert.ok(typeof conflict.at === 'string' && !Number.isNaN(Date.parse(conflict.at)));
|
|
205
|
+
|
|
206
|
+
// Sidecars can never become live docs…
|
|
207
|
+
assert.throws(() => docs.openDoc(sidecars[0]), /sidecar/);
|
|
208
|
+
// …and the watcher ignores them: poking the sidecar emits nothing.
|
|
209
|
+
const settled = currentSeq();
|
|
210
|
+
fs.writeFileSync(sidecars[0], `${theirs}poke\n`, 'utf8');
|
|
211
|
+
await sleep(700);
|
|
212
|
+
const after = listEventsAfter(settled).filter((e) => e.resource === opened.resource);
|
|
213
|
+
assert.equal(after.length, 0, `sidecar writes must not re-ingest (got ${JSON.stringify(after)})`);
|
|
214
|
+
assert.equal(docs.getDocText(target), 'alpha\nbeta (human)\ngamma\n');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('mixed external write: conflicting hunk keeps OURS, disjoint hunk still lands', async () => {
|
|
218
|
+
const base = 'h1\nh2\nh3\nh4\nh5\n';
|
|
219
|
+
const target = writeDoc('merge-mixed.md', base);
|
|
220
|
+
const opened = docs.openDoc(target);
|
|
221
|
+
const client = clientFromState(opened.state);
|
|
222
|
+
const before = currentSeq();
|
|
223
|
+
|
|
224
|
+
docs.applyDocUpdates(target, [
|
|
225
|
+
encodeLocalEdit(client, (ytext) => ytext.insert('h1'.length, ' human')),
|
|
226
|
+
]);
|
|
227
|
+
// The agent's stale-based write touches line 1 (conflicts with the human)
|
|
228
|
+
// AND line 5 (disjoint — must merge in).
|
|
229
|
+
const theirs = 'h1 agent\nh2\nh3\nh4\nh5 agent\n';
|
|
230
|
+
fs.writeFileSync(target, theirs, 'utf8');
|
|
231
|
+
await sleep(900);
|
|
232
|
+
|
|
233
|
+
assert.equal(
|
|
234
|
+
docs.getDocText(target),
|
|
235
|
+
'h1 human\nh2\nh3\nh4\nh5 agent\n',
|
|
236
|
+
'human line kept on conflict, disjoint agent line merged in',
|
|
237
|
+
);
|
|
238
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'h1 human\nh2\nh3\nh4\nh5 agent\n');
|
|
239
|
+
|
|
240
|
+
const sidecars = sidecarsFor(target);
|
|
241
|
+
assert.equal(sidecars.length, 1);
|
|
242
|
+
assert.equal(fs.readFileSync(sidecars[0], 'utf8'), theirs);
|
|
243
|
+
|
|
244
|
+
// The merged splice and the conflict ride the same doc event.
|
|
245
|
+
const conflictEvents = listEventsAfter(before)
|
|
246
|
+
.filter((e) => e.resource === opened.resource && e.patch?.conflict);
|
|
247
|
+
assert.equal(conflictEvents.length, 1);
|
|
248
|
+
assert.ok(conflictEvents[0].patch.yjs, 'merged splice and conflict share one event patch');
|
|
249
|
+
assert.equal(conflictEvents[0].patch.conflict.hunks, 1);
|
|
250
|
+
assert.equal(conflictEvents[0].source, 'doc:disk');
|
|
251
|
+
});
|