amalgm 0.1.206 → 0.1.208
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/shared-realtime-tunnel.js +13 -0
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/state/docs.js +5 -3
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +19 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +56 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +57 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +22 -13
|
@@ -17,6 +17,7 @@ const CLAIM_LIMIT = 16;
|
|
|
17
17
|
const CLAIM_LEASE_MS = 30_000;
|
|
18
18
|
const RETRY_MS = 1_000;
|
|
19
19
|
const REAUTHORIZE_MS = 30_000;
|
|
20
|
+
const REPO_OBJECT_REQUEST_TIMEOUT_MS = 2_000;
|
|
20
21
|
|
|
21
22
|
function createSharedRealtimeTunnel({
|
|
22
23
|
send,
|
|
@@ -24,6 +25,7 @@ function createSharedRealtimeTunnel({
|
|
|
24
25
|
localPort,
|
|
25
26
|
localHeaders,
|
|
26
27
|
warn,
|
|
28
|
+
repoObjectRequestTimeoutMs = REPO_OBJECT_REQUEST_TIMEOUT_MS,
|
|
27
29
|
}) {
|
|
28
30
|
const mutationInflight = new Map();
|
|
29
31
|
const promotionInflight = new Map();
|
|
@@ -277,8 +279,17 @@ function createSharedRealtimeTunnel({
|
|
|
277
279
|
repoObjectTimer.unref?.();
|
|
278
280
|
}
|
|
279
281
|
|
|
282
|
+
function expireRepoObjectRequests(now = Date.now()) {
|
|
283
|
+
for (const inflight of [repoUploadInflight, repoDownloadInflight]) {
|
|
284
|
+
for (const [requestId, object] of inflight) {
|
|
285
|
+
if (now - object.sentAt >= repoObjectRequestTimeoutMs) inflight.delete(requestId);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
280
290
|
async function drainRepoObjects() {
|
|
281
291
|
if (!enabled) return;
|
|
292
|
+
expireRepoObjectRequests();
|
|
282
293
|
if (repoUploadInflight.size === 0) {
|
|
283
294
|
const result = await localStateJson('POST', '/state/repo-objects/uploads/claim', {});
|
|
284
295
|
const upload = result?.upload;
|
|
@@ -295,6 +306,7 @@ function createSharedRealtimeTunnel({
|
|
|
295
306
|
repoUploadInflight.set(requestId, {
|
|
296
307
|
resourceId: upload.resourceId,
|
|
297
308
|
sha256: upload.sha256,
|
|
309
|
+
sentAt: Date.now(),
|
|
298
310
|
});
|
|
299
311
|
}
|
|
300
312
|
}
|
|
@@ -313,6 +325,7 @@ function createSharedRealtimeTunnel({
|
|
|
313
325
|
repoDownloadInflight.set(requestId, {
|
|
314
326
|
resourceId: download.resourceId,
|
|
315
327
|
sha256: download.sha256,
|
|
328
|
+
sentAt: Date.now(),
|
|
316
329
|
});
|
|
317
330
|
}
|
|
318
331
|
}
|
package/package.json
CHANGED
|
@@ -120,9 +120,11 @@ function hashText(text) {
|
|
|
120
120
|
function isInternalStateDoc(filePath) {
|
|
121
121
|
const resolved = path.resolve(filePath);
|
|
122
122
|
const parent = path.dirname(resolved);
|
|
123
|
+
const collection = path.basename(parent);
|
|
124
|
+
const isStateCollection = path.basename(path.dirname(parent)) === 'state';
|
|
123
125
|
return (
|
|
124
|
-
|
|
125
|
-
&&
|
|
126
|
+
isStateCollection
|
|
127
|
+
&& (collection === 'repo-checkpoints' || collection === 'code-projects')
|
|
126
128
|
) || /^machine-cards-v\d+\.jsonl$/.test(path.basename(resolved));
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -221,7 +223,7 @@ function resolveDocPath(pathInput) {
|
|
|
221
223
|
}
|
|
222
224
|
const stats = fs.statSync(resolved);
|
|
223
225
|
if (!stats.isFile()) throw invalid('path is not a file');
|
|
224
|
-
if (stats.size > MAX_DOC_BYTES) {
|
|
226
|
+
if (stats.size > MAX_DOC_BYTES && !isInternalStateDoc(resolved)) {
|
|
225
227
|
throw invalid(`file exceeds the ${MAX_DOC_BYTES}-byte live document limit`, 413);
|
|
226
228
|
}
|
|
227
229
|
return resolved;
|
|
@@ -135,19 +135,29 @@ test('external disk edits reconcile into the live doc via the watcher', async ()
|
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
test('internal protocol documents never treat their own disk flush as another writer', async () => {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
const checkpointDir = path.join(process.env.AMALGM_DIR, 'state', 'repo-checkpoints');
|
|
139
|
+
const projectDir = path.join(process.env.AMALGM_DIR, 'state', 'code-projects');
|
|
140
|
+
fs.mkdirSync(checkpointDir, { recursive: true });
|
|
141
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
142
|
+
const target = path.join(checkpointDir, 'protocol.json');
|
|
143
|
+
const project = path.join(projectDir, 'project.jsonl');
|
|
141
144
|
fs.writeFileSync(target, '{"value":1}\n', 'utf8');
|
|
145
|
+
fs.writeFileSync(project, '{"value":1}\n', 'utf8');
|
|
142
146
|
const opened = docs.openDoc(target);
|
|
147
|
+
const openedProject = docs.openDoc(project);
|
|
143
148
|
const before = currentSeq();
|
|
144
149
|
|
|
145
150
|
assert.equal(docs._private.isInternalStateDoc(target), true);
|
|
151
|
+
assert.equal(docs._private.isInternalStateDoc(project), true);
|
|
146
152
|
fs.writeFileSync(target, '{"value":2}\n', 'utf8');
|
|
153
|
+
fs.writeFileSync(project, '{"value":2}\n', 'utf8');
|
|
147
154
|
await sleep(700);
|
|
148
155
|
|
|
149
156
|
assert.equal(docs.getDocText(target), '{"value":1}\n');
|
|
150
|
-
|
|
157
|
+
assert.equal(docs.getDocText(project), '{"value":1}\n');
|
|
158
|
+
const events = listEventsAfter(before).filter((event) => (
|
|
159
|
+
event.resource === opened.resource || event.resource === openedProject.resource
|
|
160
|
+
));
|
|
151
161
|
assert.equal(events.length, 0);
|
|
152
162
|
});
|
|
153
163
|
|
|
@@ -203,6 +213,11 @@ test('guards: binary, missing, oversized, and malformed inputs are rejected', ()
|
|
|
203
213
|
|
|
204
214
|
const big = writeDoc('big.md', 'x'.repeat(2 * 1024 * 1024 + 1));
|
|
205
215
|
assert.throws(() => docs.openDoc(big), /live document limit/);
|
|
216
|
+
const projectDir = path.join(process.env.AMALGM_DIR, 'state', 'code-projects');
|
|
217
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
218
|
+
const largeProtocol = path.join(projectDir, 'large-project.jsonl');
|
|
219
|
+
fs.writeFileSync(largeProtocol, 'x'.repeat(2 * 1024 * 1024 + 1));
|
|
220
|
+
assert.doesNotThrow(() => docs.openDoc(largeProtocol));
|
|
206
221
|
|
|
207
222
|
const target = writeDoc('guarded.md', 'ok\n');
|
|
208
223
|
assert.throws(() => docs.applyDocUpdates(target, [123]), /base64-encoded/);
|
|
@@ -122,3 +122,59 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
122
122
|
server.close();
|
|
123
123
|
}
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
test('repo object chunks retry when an acknowledgement is lost', async () => {
|
|
127
|
+
const uploadBytes = Buffer.from('retry this content-addressed chunk');
|
|
128
|
+
const uploadSha = crypto.createHash('sha256').update(uploadBytes).digest('hex');
|
|
129
|
+
const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
|
|
130
|
+
const server = http.createServer((req, res) => {
|
|
131
|
+
req.resume();
|
|
132
|
+
req.on('end', () => {
|
|
133
|
+
res.setHeader('content-type', 'application/json');
|
|
134
|
+
if (req.url === '/state/shared/replicas') {
|
|
135
|
+
res.end('{"replicas":[]}');
|
|
136
|
+
} else if (req.url.startsWith('/state/stream')) {
|
|
137
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
138
|
+
res.write('\n');
|
|
139
|
+
} else if (req.url === '/state/repo-objects/uploads/claim') {
|
|
140
|
+
res.end(JSON.stringify({
|
|
141
|
+
upload: {
|
|
142
|
+
resourceId,
|
|
143
|
+
sha256: uploadSha,
|
|
144
|
+
bytes: uploadBytes.length,
|
|
145
|
+
dataBase64: uploadBytes.toString('base64'),
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
} else if (req.url === '/state/repo-objects/downloads/claim') {
|
|
149
|
+
res.end('{"download":null}');
|
|
150
|
+
} else {
|
|
151
|
+
res.end('{}');
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve); });
|
|
156
|
+
|
|
157
|
+
const frames = [];
|
|
158
|
+
const tunnel = createSharedRealtimeTunnel({
|
|
159
|
+
send: (frame) => { frames.push(frame); return true; },
|
|
160
|
+
sendLarge: (frame) => { frames.push(frame); return true; },
|
|
161
|
+
localPort: () => server.address().port,
|
|
162
|
+
localHeaders: (headers) => headers,
|
|
163
|
+
warn: () => {},
|
|
164
|
+
repoObjectRequestTimeoutMs: 25,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
tunnel.enable(['shared.mutation.v1']);
|
|
169
|
+
await until(
|
|
170
|
+
() => frames.filter((frame) => frame.type === 'repo.object.put').length >= 2,
|
|
171
|
+
'the unacknowledged upload retry',
|
|
172
|
+
);
|
|
173
|
+
const puts = frames.filter((frame) => frame.type === 'repo.object.put');
|
|
174
|
+
assert.notEqual(puts[0].request_id, puts[1].request_id);
|
|
175
|
+
assert.equal(puts[0].sha256, puts[1].sha256);
|
|
176
|
+
} finally {
|
|
177
|
+
tunnel.stop();
|
|
178
|
+
server.close();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
@@ -120,6 +120,39 @@ test('only a value different from both the materialized baseline and the rail is
|
|
|
120
120
|
branches: [{ name: 'main', oid: oidC }],
|
|
121
121
|
};
|
|
122
122
|
assert.equal(hasLocalContribution(local, remoteAdvance, baseline), false);
|
|
123
|
+
assert.equal(hasLocalContribution(
|
|
124
|
+
{
|
|
125
|
+
head: { branch: 'main', oid: oidB, detached: false },
|
|
126
|
+
refs: [{ name: 'main', oid: oidB }],
|
|
127
|
+
},
|
|
128
|
+
remoteAdvance,
|
|
129
|
+
{
|
|
130
|
+
branch: 'main',
|
|
131
|
+
baseOid: oidB,
|
|
132
|
+
branches: [{ name: 'main', oid: oidB }],
|
|
133
|
+
},
|
|
134
|
+
null,
|
|
135
|
+
{
|
|
136
|
+
head: { branch: 'main', oid: oidA, detached: false },
|
|
137
|
+
branches: [{ name: 'main', oid: oidA }],
|
|
138
|
+
},
|
|
139
|
+
), false);
|
|
140
|
+
assert.equal(hasLocalContribution(
|
|
141
|
+
local,
|
|
142
|
+
plan,
|
|
143
|
+
{
|
|
144
|
+
...baseline,
|
|
145
|
+
branches: [...baseline.branches, { name: 'feature', oid: null }],
|
|
146
|
+
},
|
|
147
|
+
null,
|
|
148
|
+
{
|
|
149
|
+
head: { branch: 'main', oid: oidA, detached: false },
|
|
150
|
+
branches: [
|
|
151
|
+
{ name: 'main', oid: oidA },
|
|
152
|
+
{ name: 'feature', oid: oidB },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
), true);
|
|
123
156
|
assert.equal(hasLocalContribution(local, plan, baseline, {
|
|
124
157
|
checkpoint: 'repo-checkpoint@7',
|
|
125
158
|
machine: 'machine-a',
|
|
@@ -478,6 +511,30 @@ test('an unordered legacy record cannot pin wall compaction', () => {
|
|
|
478
511
|
);
|
|
479
512
|
});
|
|
480
513
|
|
|
514
|
+
test('retired machine values stay represented without pinning obsolete active history', () => {
|
|
515
|
+
const line = (machine, value) => JSON.stringify({
|
|
516
|
+
wall: 'code-project@3',
|
|
517
|
+
project: repoKey,
|
|
518
|
+
machine,
|
|
519
|
+
mutationId: `mut_project_${machine}_${value}`,
|
|
520
|
+
cards: [{ value }],
|
|
521
|
+
});
|
|
522
|
+
const retiredA = line('retired-a', 'latest-retired-a');
|
|
523
|
+
const retiredB = line('retired-b', 'latest-retired-b');
|
|
524
|
+
const oldA = line('machine-a', 'old-a');
|
|
525
|
+
const oldB = line('machine-b', 'old-b');
|
|
526
|
+
const latestA = line('machine-a', 'latest-a');
|
|
527
|
+
const latestB = line('machine-b', 'latest-b');
|
|
528
|
+
|
|
529
|
+
assert.equal(
|
|
530
|
+
compactWallText(
|
|
531
|
+
`${retiredA}\n${retiredB}\n${oldA}\n${oldB}\n${latestA}\n${latestB}\n`,
|
|
532
|
+
repoKey,
|
|
533
|
+
),
|
|
534
|
+
`${retiredA}\n${retiredB}\n${latestA}\n${latestB}\n`,
|
|
535
|
+
);
|
|
536
|
+
});
|
|
537
|
+
|
|
481
538
|
test('a concurrent deletion-only compaction and append commute in Yjs', () => {
|
|
482
539
|
const Y = require('yjs');
|
|
483
540
|
const line = (machine, value) => JSON.stringify({
|
|
@@ -245,11 +245,13 @@ function hasLocalContribution(card, plan, baseline, checkpoint = null, previousC
|
|
|
245
245
|
const current = currentRefs.get(name) ?? null;
|
|
246
246
|
const shared = railRefs.get(name) ?? null;
|
|
247
247
|
const previous = baselineRefs.get(name) ?? null;
|
|
248
|
-
const
|
|
249
|
-
|
|
248
|
+
const published = previousRefs.get(name) ?? null;
|
|
249
|
+
const reversesPublishedSharedValue = previousRefs.has(name)
|
|
250
|
+
&& published === shared
|
|
251
|
+
&& current !== published;
|
|
250
252
|
if (
|
|
251
253
|
current !== shared
|
|
252
|
-
&& (!baselineRefs.has(name) || current !== previous ||
|
|
254
|
+
&& (!baselineRefs.has(name) || current !== previous || reversesPublishedSharedValue)
|
|
253
255
|
) return true;
|
|
254
256
|
}
|
|
255
257
|
|
|
@@ -456,12 +458,10 @@ function withoutPublishedAt(line) {
|
|
|
456
458
|
}
|
|
457
459
|
|
|
458
460
|
/**
|
|
459
|
-
* Keep
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* Compaction is deletion-only. Concurrent deletions and appends commute in
|
|
464
|
-
* Yjs, so this bounds the wall without bringing back whole-document races.
|
|
461
|
+
* Keep every machine's newest complete record and delete the largest span
|
|
462
|
+
* containing none of them. Repeating this as the wall grows removes all
|
|
463
|
+
* obsolete history, even when a retired machine's last record is near the
|
|
464
|
+
* beginning. Each pass is one deletion, so concurrent appends commute in Yjs.
|
|
465
465
|
*/
|
|
466
466
|
function compactWallText(current, repoKey) {
|
|
467
467
|
const latest = new Map();
|
|
@@ -470,12 +470,21 @@ function compactWallText(current, repoKey) {
|
|
|
470
470
|
for (let index = 0; index < lines.length; index += 1) {
|
|
471
471
|
const line = lines[index];
|
|
472
472
|
const entry = line ? parseWallLine(line) : null;
|
|
473
|
-
|
|
474
|
-
|
|
473
|
+
const end = offset + line.length + (index < lines.length - 1 ? 1 : 0);
|
|
474
|
+
if (entry?.project === repoKey) latest.set(entry.machine, { start: offset, end });
|
|
475
|
+
offset = end;
|
|
475
476
|
}
|
|
476
477
|
if (latest.size === 0) return current;
|
|
477
|
-
const
|
|
478
|
-
|
|
478
|
+
const records = [...latest.values()].sort((left, right) => left.start - right.start);
|
|
479
|
+
const gaps = [{ start: 0, end: records[0].start }];
|
|
480
|
+
for (let index = 0; index < records.length - 1; index += 1) {
|
|
481
|
+
gaps.push({ start: records[index].end, end: records[index + 1].start });
|
|
482
|
+
}
|
|
483
|
+
const gap = gaps.reduce((largest, candidate) => (
|
|
484
|
+
candidate.end - candidate.start > largest.end - largest.start ? candidate : largest
|
|
485
|
+
));
|
|
486
|
+
if (gap.end <= gap.start) return current;
|
|
487
|
+
return `${current.slice(0, gap.start)}${current.slice(gap.end)}`;
|
|
479
488
|
}
|
|
480
489
|
|
|
481
490
|
function publishProject(project, rawCards) {
|