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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.206",
3
+ "version": "0.1.208",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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
- path.basename(parent) === 'repo-checkpoints'
125
- && path.basename(path.dirname(parent)) === 'state'
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 stateDir = path.join(process.env.AMALGM_DIR, 'state', 'repo-checkpoints');
139
- fs.mkdirSync(stateDir, { recursive: true });
140
- const target = path.join(stateDir, 'protocol.json');
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
- const events = listEventsAfter(before).filter((event) => event.resource === opened.resource);
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 changedFromPublished = previousRefs.has(name)
249
- && current !== (previousRefs.get(name) ?? null);
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 || changedFromPublished)
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 the newest complete record for every machine, plus anything after
460
- * the earliest of those records. Everything before that point is obsolete:
461
- * every machine represented there has a newer complete value later.
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
- if (entry?.project === repoKey) latest.set(entry.machine, offset);
474
- offset += line.length + (index < lines.length - 1 ? 1 : 0);
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 cutoff = Math.min(...latest.values());
478
- return cutoff > 0 ? current.slice(cutoff) : current;
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) {