amalgm 0.1.195 → 0.1.197
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 +1 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +31 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +44 -9
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +17 -1
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +22 -0
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +9 -39
package/package.json
CHANGED
|
@@ -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
|
|
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,
|
|
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(
|
|
252
|
-
|
|
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,
|
|
301
|
+
assert.equal(publishWall(repoKey).published, false);
|
|
302
|
+
const checkpointLines = getDocText(project.file).trim().split('\n');
|
|
299
303
|
assert.equal(
|
|
300
|
-
JSON.parse(
|
|
301
|
-
|
|
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('
|
|
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
|
|
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 (
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
|