amalgm 0.1.196 → 0.1.198

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.196",
3
+ "version": "0.1.198",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -23,6 +23,7 @@ fs.writeFileSync(
23
23
  const { closeLocalDb } = require('../state/db');
24
24
  const { CHECKPOINT_CONTRACT, revisionOf } = require('../workspace/checkpoint');
25
25
  const {
26
+ advanceIndex,
26
27
  captureIsOnRail,
27
28
  hydrateRepo,
28
29
  localCardMayMaterialize,
@@ -530,6 +531,36 @@ test('a local commit made after the verified card snapshot is never parked or ov
530
531
  );
531
532
  });
532
533
 
534
+ test('an empty index advance cannot overwrite a commit that just finished', async () => {
535
+ const { repo } = await makeRepo('index-commit-race');
536
+ const observedIndex = (await git(repo, 'write-tree')).trim();
537
+
538
+ fs.writeFileSync(path.join(repo, 'binary.bin'), Buffer.alloc(32 * 1024, 1));
539
+ await git(repo, 'add', 'binary.bin');
540
+ await git(repo, 'commit', '-m', 'concurrent binary commit');
541
+ const committedOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
542
+
543
+ const outcome = await advanceIndex(repo, observedIndex, observedIndex);
544
+ assert.deepEqual(outcome, { state: 'ready', moved: false });
545
+ assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), committedOid);
546
+ assert.equal((await git(repo, 'status', '--porcelain')).trim(), '');
547
+ });
548
+
549
+ test('an index advance preserves unrelated work staged by another writer', async () => {
550
+ const { repo } = await makeRepo('index-writers');
551
+ const observedIndex = (await git(repo, 'write-tree')).trim();
552
+ const peerPatch = await patchFor(repo, { 'b.txt': 'peer\n' });
553
+ const peerTree = await require('../workspace/merge').treeOfPatch(repo, 'HEAD', peerPatch);
554
+
555
+ fs.writeFileSync(path.join(repo, 'a.txt'), 'local\n');
556
+ await git(repo, 'add', 'a.txt');
557
+ const outcome = await advanceIndex(repo, observedIndex, peerTree);
558
+
559
+ assert.deepEqual(outcome, { state: 'ready', moved: true });
560
+ const staged = await git(repo, 'diff', '--cached', '--name-only');
561
+ assert.deepEqual(staged.trim().split('\n').sort(), ['a.txt', 'b.txt']);
562
+ });
563
+
533
564
  test('a new card folds checkpoint work already on the rail instead of replaying it as local work', async () => {
534
565
  const { repo, oid: baseOid } = await makeRepo('card-folds-shared-checkpoint');
535
566
  const sharedPatch = await patchFor(repo, { 'a.txt': 'shared checkpoint\n' });
@@ -201,7 +201,7 @@ test('a collaborator binds the same repo to the owner project, not a parallel pr
201
201
  assert.equal(projectForRepo('repo-shared').path, localPath);
202
202
  });
203
203
 
204
- test('a repo project carries exactly one line per machine, replaced in place', async () => {
204
+ test('a repo project appends immutable machine records', async () => {
205
205
  const repo = path.join(tempRoot, 'projects/app');
206
206
  fs.mkdirSync(repo, { recursive: true });
207
207
  await run(repo, 'init', '-b', 'main');
@@ -243,13 +243,16 @@ test('a repo project carries exactly one line per machine, replaced in place', a
243
243
  committedAt: '2026-07-23T00:00:00.000Z',
244
244
  });
245
245
  const sealed = publishWall(repoKey);
246
- assert.equal(sealed.published, true);
246
+ assert.equal(sealed.published, false);
247
+ assert.equal(sealed.reason, 'shared-state-covered');
247
248
  const sealedCard = readWall(repoKey)[0].cards[0];
248
249
  assert.equal(sealedCard.positionMutationId, first.mutationId);
249
250
  assert.equal(sealedCard.positionVersion, 1);
251
+ const sealedLines = getDocText(project.file).trim().split('\n');
252
+ assert.equal(sealedLines.length, 1);
250
253
  assert.equal(
251
- JSON.parse(getDocText(project.file).trim()).cards[0].positionVersion,
252
- 1,
254
+ JSON.parse(sealedLines.at(-1)).cards[0].positionVersion,
255
+ null,
253
256
  );
254
257
  assert.equal(publishedCardFor(refreshed.cards[0]).positionVersion, 1);
255
258
 
@@ -295,10 +298,11 @@ test('checkpoint transitions append until the next card and carry their official
295
298
  committedAt: '2026-07-23T00:00:02.000Z',
296
299
  });
297
300
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].version, 2);
298
- assert.equal(publishWall(repoKey).published, true);
301
+ assert.equal(publishWall(repoKey).published, false);
302
+ const checkpointLines = getDocText(project.file).trim().split('\n');
299
303
  assert.equal(
300
- JSON.parse(getDocText(project.file).trim()).cards[0].checkpoints[0].version,
301
- 2,
304
+ JSON.parse(checkpointLines.at(-1)).cards[0].checkpoints[0].version ?? null,
305
+ null,
302
306
  );
303
307
 
304
308
  fs.writeFileSync(file, `${JSON.stringify({
@@ -342,7 +346,8 @@ test('side branches keep one rail operation while the checked-out card advances'
342
346
  assert.equal(card.branches.find((entry) => entry.name === branch).mutationId, created.mutationId);
343
347
  });
344
348
 
345
- test('a peer machine line is preserved byte-for-byte through local publishes', async () => {
349
+ test('peer records and earlier local history survive later publishes', async () => {
350
+ const beforeLines = getDocText(project.file).trim().split('\n');
346
351
  const peerLine = JSON.stringify({
347
352
  wall: 'code-project@3',
348
353
  project: repoKey,
@@ -361,11 +366,41 @@ test('a peer machine line is preserved byte-for-byte through local publishes', a
361
366
  assert.equal(result.published, true);
362
367
 
363
368
  const lines = getDocText(project.file).trim().split('\n');
364
- assert.equal(lines.length, 2);
369
+ assert.equal(lines.length, beforeLines.length + 2);
370
+ for (const line of beforeLines) assert.ok(lines.includes(line));
365
371
  assert.ok(lines.includes(peerLine));
366
372
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
367
373
  });
368
374
 
375
+ test('three concurrent append records stay whole under Yjs ordering', () => {
376
+ const Y = require('yjs');
377
+ const base = new Y.Doc();
378
+ base.getText('content').insert(0, '{"machine":"existing"}\n');
379
+ const baseUpdate = Y.encodeStateAsUpdate(base);
380
+ const updates = ['machine-a', 'machine-b', 'machine-c'].map((machine) => {
381
+ const writer = new Y.Doc();
382
+ Y.applyUpdate(writer, baseUpdate);
383
+ let update = null;
384
+ writer.on('update', (value) => {
385
+ update = value;
386
+ });
387
+ const text = writer.getText('content');
388
+ text.insert(text.length, `${JSON.stringify({ machine })}\n`);
389
+ return update;
390
+ });
391
+ const replicas = [updates, [...updates].reverse()].map((order) => {
392
+ const replica = new Y.Doc();
393
+ Y.applyUpdate(replica, baseUpdate);
394
+ for (const update of order) Y.applyUpdate(replica, update);
395
+ return replica.getText('content').toString();
396
+ });
397
+ assert.equal(replicas[0], replicas[1]);
398
+ assert.deepEqual(
399
+ replicas[0].trim().split('\n').map((line) => JSON.parse(line).machine).sort(),
400
+ ['existing', 'machine-a', 'machine-b', 'machine-c'],
401
+ );
402
+ });
403
+
369
404
  test('a torn line is ignored by readers, never fatal', () => {
370
405
  writeDocText(
371
406
  project.file,
@@ -510,7 +510,8 @@ async function materializeRepo(localCardInput, plan) {
510
510
  const applied = await merge.advance(localCard.path, heldWorktree, reduced.worktreeTree, {
511
511
  label: `checkpoint-${reduced.checkpointRevision || 'clean'}`,
512
512
  });
513
- await transition.setIndexTree(localCard.path, reducedLocalIndex.tree);
513
+ const indexResult = await advanceIndex(localCard.path, currentIndex, reducedLocalIndex.tree);
514
+ if (indexResult.state !== 'ready') return indexResult;
514
515
  const state = writeRepoState(plan.repoKey, {
515
516
  path: localCard.path,
516
517
  replica: localCard.replica,
@@ -550,6 +551,20 @@ async function materializeRepo(localCardInput, plan) {
550
551
  };
551
552
  }
552
553
 
554
+ async function advanceIndex(worktreeRoot, observedTree, targetTree) {
555
+ if (observedTree === targetTree) return { state: 'ready', moved: false };
556
+ const patch = await merge.diffOfTree(worktreeRoot, observedTree, targetTree);
557
+ try {
558
+ await transition.applyIndexPatch(worktreeRoot, patch);
559
+ return { state: 'ready', moved: true };
560
+ } catch (error) {
561
+ if (error instanceof transition.TransitionError) {
562
+ return { state: 'waiting', reason: 'local-index-moving', moved: false };
563
+ }
564
+ throw error;
565
+ }
566
+ }
567
+
553
568
  function localCardMayMaterialize(publishedCard, publication) {
554
569
  return Boolean(publishedCard) || publication?.reason === 'shared-state-covered';
555
570
  }
@@ -700,6 +715,7 @@ function reducerStats() {
700
715
  }
701
716
 
702
717
  module.exports = {
718
+ advanceIndex,
703
719
  captureIsOnRail,
704
720
  hydrateRepo,
705
721
  localCardMayMaterialize,
@@ -294,6 +294,27 @@ function applyPatch(worktreeRoot, patchFile, { reverse = false } = {}) {
294
294
  ], { timeoutMs: 60_000 });
295
295
  }
296
296
 
297
+ /**
298
+ * Apply only the index change the reducer computed. Git locks the index and
299
+ * verifies the patch's preimage, so a concurrent commit is serialized instead
300
+ * of being replaced by an old whole-index snapshot.
301
+ */
302
+ async function applyIndexPatch(worktreeRoot, patch) {
303
+ if (!patch) return;
304
+ const stateDir = path.join(AMALGM_DIR, 'state');
305
+ fs.mkdirSync(stateDir, { recursive: true });
306
+ const dir = fs.mkdtempSync(path.join(stateDir, 'index-patch-'));
307
+ const patchFile = path.join(dir, 'patch');
308
+ try {
309
+ fs.writeFileSync(patchFile, patch);
310
+ await run('apply-index-patch', worktreeRoot, [
311
+ '-C', worktreeRoot, 'apply', '--cached', '--whitespace=nowarn', patchFile,
312
+ ]);
313
+ } finally {
314
+ fs.rmSync(dir, { recursive: true, force: true });
315
+ }
316
+ }
317
+
297
318
  /** Set only Git's index; working files are left exactly as they are. */
298
319
  function setIndexTree(worktreeRoot, treeOid) {
299
320
  return run('set-index-tree', worktreeRoot, [
@@ -345,6 +366,7 @@ function applyMergedFiles(worktreeRoot, writes, label) {
345
366
 
346
367
  module.exports = {
347
368
  TransitionError,
369
+ applyIndexPatch,
348
370
  applyMergedFiles,
349
371
  applyPatch,
350
372
  checkoutInitialCard,
@@ -287,21 +287,6 @@ function sealedVersion(resourceId, mutationId, ...versions) {
287
287
  || null;
288
288
  }
289
289
 
290
- function entryNeedsSeal(entry, resourceId) {
291
- return Boolean(entry?.cards?.some((card) => (
292
- (!validVersion(card.positionVersion)
293
- && validVersion(officialVersion(resourceId, card.positionMutationId)))
294
- || (card.branches || []).some((ref) => (
295
- !validVersion(ref.version)
296
- && validVersion(officialVersion(resourceId, ref.mutationId))
297
- ))
298
- || (card.checkpoints || []).some((checkpoint) => (
299
- !validVersion(checkpoint.version)
300
- && validVersion(officialVersion(resourceId, checkpoint.mutationId))
301
- ))
302
- )));
303
- }
304
-
305
290
  function stampBranches(
306
291
  refs,
307
292
  previousBranches,
@@ -453,7 +438,7 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
453
438
  function withoutPublishedAt(line) {
454
439
  const entry = parseWallLine(line);
455
440
  if (!entry) return line;
456
- const { publishedAt, ...rest } = entry;
441
+ const { publishedAt, mutationId, ...rest } = entry;
457
442
  return JSON.stringify(rest);
458
443
  }
459
444
 
@@ -465,27 +450,14 @@ function publishProject(project, rawCards) {
465
450
  }
466
451
  const docs = require('../state/docs');
467
452
  const current = docs.getDocText(project.file);
468
- const lines = [];
469
453
  let previous = null;
470
454
  let previousEntry = null;
471
- let slot = -1;
472
- let dropped = 0;
473
455
  for (const line of current.split('\n')) {
474
456
  if (!line) continue;
475
457
  const entry = parseWallLine(line);
476
- if (!entry || entry.project !== project.repoKey) {
477
- dropped += 1;
478
- } else if (entry.machine === machineId()) {
479
- if (previous === null) {
480
- previous = line;
481
- previousEntry = entry;
482
- slot = lines.length;
483
- lines.push('');
484
- } else {
485
- dropped += 1;
486
- }
487
- } else {
488
- lines.push(line);
458
+ if (entry?.project === project.repoKey && entry.machine === machineId()) {
459
+ previous = line;
460
+ previousEntry = entry;
489
461
  }
490
462
  }
491
463
 
@@ -500,7 +472,7 @@ function publishProject(project, rawCards) {
500
472
  return hasLocalContribution(card, sharedPlan, baseline, checkpoint)
501
473
  || hasObjectUpgrade(card, sharedPlan);
502
474
  });
503
- if (!removed && !contributes && !entryNeedsSeal(previousEntry, project.resourceId)) {
475
+ if (!removed && !contributes) {
504
476
  service.counters.publishSkips += 1;
505
477
  return { published: false, reason: 'shared-state-covered', repoKey: project.repoKey };
506
478
  }
@@ -517,22 +489,21 @@ function publishProject(project, rawCards) {
517
489
  wall: WALL_CONTRACT,
518
490
  project: project.repoKey,
519
491
  machine: machineId(),
492
+ mutationId,
520
493
  publishedAt: new Date().toISOString(),
521
494
  cards,
522
495
  });
523
- if (slot < 0) lines.push(mine);
524
- else lines[slot] = mine;
525
- const unchanged = dropped === 0
526
- && previous !== null
496
+ const unchanged = previous !== null
527
497
  && withoutPublishedAt(previous) === withoutPublishedAt(mine);
528
498
  if (unchanged) {
529
499
  service.counters.publishSkips += 1;
530
500
  return { published: false, reason: 'unchanged', repoKey: project.repoKey };
531
501
  }
532
502
 
503
+ const separator = current && !current.endsWith('\n') ? '\n' : '';
533
504
  const result = docs.writeDocText(
534
505
  project.file,
535
- `${lines.join('\n')}\n`,
506
+ `${current}${separator}${mine}\n`,
536
507
  { baseHash: sha256(current), mutationId },
537
508
  );
538
509
  service.counters.publishes += 1;
@@ -544,7 +515,6 @@ function publishProject(project, rawCards) {
544
515
  mutationId: result.mutationId,
545
516
  merge: result.merge,
546
517
  cards: cards.length,
547
- dropped,
548
518
  };
549
519
  }
550
520