amalgm 0.1.217 → 0.1.219

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.217",
3
+ "version": "0.1.219",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -180,7 +180,7 @@ test('checkpoint captures are local cache; their operations travel on the one wa
180
180
  assert.equal(first, checkpointFile('m1', 'https://github.com/a/b'));
181
181
  assert.notEqual(first, checkpointFile('m2', 'https://github.com/a/b'));
182
182
  assert.notEqual(first, checkpointFile('m1', 'https://github.com/a/c'));
183
- assert.match(first, /repo-checkpoints-v7/);
183
+ assert.match(first, /repo-checkpoints-v8/);
184
184
  });
185
185
 
186
186
  test('a materialized peer card is immediately a valid checkpoint base', () => {
@@ -258,7 +258,7 @@ test('two concurrent compacting publishers converge without losing either latest
258
258
  const Y = require('yjs');
259
259
  const repoKey = 'root:compaction-collision';
260
260
  const line = (machine, value) => JSON.stringify({
261
- wall: 'code-project@3',
261
+ wall: 'code-project@4',
262
262
  project: repoKey,
263
263
  machine,
264
264
  mutationId: `mut_${machine}_${value}`,
@@ -152,6 +152,56 @@ test('wall reduction chooses one rail winner and orders checkpoint transitions b
152
152
  ]);
153
153
  });
154
154
 
155
+ test('a checkpoint base replaces covered history and keeps only newer mutations', async () => {
156
+ const { repo, oid } = await makeRepo('checkpoint-base');
157
+ const patchA = await patchFor(repo, { 'a.txt': 'a1\n' });
158
+ const patchAB = await patchFor(repo, { 'a.txt': 'a1\n', 'b.txt': 'b1\n' });
159
+ const current = card(repo, oid, 1);
160
+ current.checkpointBase = {
161
+ checkpoint: CHECKPOINT_CONTRACT,
162
+ baseOid: oid,
163
+ branch: 'main',
164
+ through: 4,
165
+ value: value(oid, patchA),
166
+ };
167
+ current.checkpoints = [
168
+ transition('machine-a', 2, oid, '', patchA),
169
+ transition('machine-a', 5, oid, patchA, patchAB),
170
+ ];
171
+ const plan = reduceWall([{ machine: 'machine-a', cards: [current] }]).get('root:test');
172
+ assert.equal(plan.checkpointBase.through, 4);
173
+ assert.deepEqual(plan.transitions.map((entry) => entry.version), [5]);
174
+ const reduced = await reduceCheckpoint(current, plan);
175
+ assert.equal(reduced.state, 'ready');
176
+ assert.equal(reduced.checkpointRevision, revisionOf(oid, patchAB, ''));
177
+ });
178
+
179
+ test('different snapshots claiming the same rail position stop instead of choosing a winner', () => {
180
+ const oid = 'a'.repeat(40);
181
+ const first = card('/first', oid, 1);
182
+ first.checkpointBase = {
183
+ checkpoint: CHECKPOINT_CONTRACT,
184
+ baseOid: oid,
185
+ branch: 'main',
186
+ through: 4,
187
+ value: value(oid, 'patch-a'),
188
+ };
189
+ const second = card('/second', oid, 1);
190
+ second.checkpointBase = {
191
+ checkpoint: CHECKPOINT_CONTRACT,
192
+ baseOid: oid,
193
+ branch: 'main',
194
+ through: 4,
195
+ value: value(oid, 'patch-b'),
196
+ };
197
+ const plan = reduceWall([
198
+ { machine: 'machine-a', cards: [first] },
199
+ { machine: 'machine-b', cards: [second] },
200
+ ]).get('root:test');
201
+ assert.equal(plan.checkpointBase, null);
202
+ assert.equal(plan.checkpointBaseConflict, true);
203
+ });
204
+
155
205
  test('a ready parked bundle never regresses to pending', () => {
156
206
  const oid = 'c'.repeat(40);
157
207
  const ready = card('/ready', 'a'.repeat(40), 1);
@@ -635,6 +685,51 @@ test('a local commit made after the verified card snapshot is never parked or ov
635
685
  );
636
686
  });
637
687
 
688
+ test('a pending descendant commit catches up without becoming a losing commit', async () => {
689
+ const { repo, oid: baseOid } = await makeRepo('pending-descendant-card');
690
+ const firstPatch = await patchFor(repo, { 'a.txt': 'before commit\n' });
691
+ const secondPatch = await patchFor(repo, {
692
+ 'a.txt': 'before commit\n',
693
+ 'b.txt': 'after commit\n',
694
+ });
695
+ const winningCard = card(repo, baseOid, 1);
696
+ const before = {
697
+ repoKey: 'root:test',
698
+ card: winningCard,
699
+ transitions: [transition('machine-peer', 2, baseOid, '', firstPatch)],
700
+ parked: [],
701
+ };
702
+
703
+ await materializeRepo(winningCard, before, 2);
704
+ await git(repo, 'add', '.');
705
+ await git(repo, 'commit', '-m', 'local pending card');
706
+ const pendingOid = (await git(repo, 'rev-parse', 'HEAD')).trim();
707
+
708
+ const pendingCard = card(repo, pendingOid, null);
709
+ const caughtUp = await materializeRepo(pendingCard, {
710
+ ...before,
711
+ transitions: [
712
+ ...before.transitions,
713
+ transition('machine-peer', 3, baseOid, firstPatch, secondPatch),
714
+ ],
715
+ }, 3);
716
+
717
+ assert.equal(caughtUp.state, 'in-sync');
718
+ assert.equal((await git(repo, 'rev-parse', 'HEAD')).trim(), pendingOid);
719
+ assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'before commit\n');
720
+ assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'after commit\n');
721
+ const staged = (await git(repo, 'diff', '--cached', '--name-only')).trim();
722
+ const status = (await git(repo, 'status', '--short')).trim();
723
+ assert.equal(staged, 'b.txt', status);
724
+ await assert.rejects(
725
+ git(repo, 'rev-parse', `refs/amalgm/parked/${pendingOid}`),
726
+ );
727
+ const state = readRepoState('root:test');
728
+ assert.equal(state.baseOid, pendingOid);
729
+ assert.equal(state.appliedThrough, 3);
730
+ assert.equal(state.cardVersion, null);
731
+ });
732
+
638
733
  test('an empty index advance cannot overwrite a commit that just finished', async () => {
639
734
  const { repo } = await makeRepo('index-commit-race');
640
735
  const observedIndex = (await git(repo, 'write-tree')).trim();
@@ -35,6 +35,7 @@ const { upsertWorkspace } = require('../workspace/store');
35
35
  const { refreshRepoCards } = require('../workspace/cards');
36
36
  const { checkpointFile, repoKeyOfCard } = require('../workspace/checkpoint');
37
37
  const {
38
+ _test: wallTest,
38
39
  bindProject,
39
40
  compactWallText,
40
41
  hasLocalContribution,
@@ -147,8 +148,17 @@ test('only a value different from both the materialized baseline and the rail is
147
148
  branches: [{ name: 'main', oid: oidC }],
148
149
  };
149
150
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline), false);
151
+ assert.equal(hasLocalContribution({
152
+ ...local,
153
+ head: { branch: 'main', oid: oidB, detached: false },
154
+ refs: [{ name: 'main', oid: oidB }],
155
+ }, remoteAdvance, {
156
+ ...baseline,
157
+ baseOid: oidB,
158
+ pendingCard: true,
159
+ }), true);
150
160
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline, {
151
- checkpoint: 'repo-checkpoint@7',
161
+ checkpoint: 'repo-checkpoint@8',
152
162
  machine: 'machine-a',
153
163
  branch: 'main',
154
164
  baseOid: oidA,
@@ -218,7 +228,7 @@ test('only a value different from both the materialized baseline and the rail is
218
228
  },
219
229
  ), true);
220
230
  assert.equal(hasLocalContribution(local, plan, baseline, {
221
- checkpoint: 'repo-checkpoint@7',
231
+ checkpoint: 'repo-checkpoint@8',
222
232
  machine: 'machine-a',
223
233
  branch: 'main',
224
234
  baseOid: oidA,
@@ -328,7 +338,7 @@ test('a repo project appends immutable machine records', async () => {
328
338
  assert.equal(first.published, true);
329
339
  const entries = readWall(repoKey);
330
340
  assert.equal(entries.length, 1);
331
- assert.equal(entries[0].wall, 'code-project@3');
341
+ assert.equal(entries[0].wall, 'code-project@4');
332
342
  assert.equal(entries[0].project, repoKey);
333
343
  assert.equal(entries[0].machine, 'machine-a');
334
344
  assert.equal(entries[0].cards.length, 1);
@@ -384,12 +394,12 @@ test('a repo project appends immutable machine records', async () => {
384
394
  assert.equal(getDocText(project.file).trim().split('\n').length, 1);
385
395
  });
386
396
 
387
- test('checkpoint transitions append until the next card and carry their official rail numbers', async () => {
397
+ test('a checkpoint base replaces history already covered by the applied rail', async () => {
388
398
  const card = readWall(repoKey)[0].cards[0];
389
399
  const file = checkpointFile('machine-a', repoKey);
390
400
  fs.mkdirSync(path.dirname(file), { recursive: true });
391
401
  const firstCheckpoint = {
392
- checkpoint: 'repo-checkpoint@7',
402
+ checkpoint: 'repo-checkpoint@8',
393
403
  machine: 'machine-a',
394
404
  repoKey,
395
405
  branch: card.head.branch,
@@ -413,6 +423,20 @@ test('checkpoint transitions append until the next card and carry their official
413
423
  JSON.parse(checkpointLines.at(-1)).cards[0].checkpoints[0].version ?? null,
414
424
  null,
415
425
  );
426
+ const reducerFile = path.join(
427
+ process.env.AMALGM_DIR,
428
+ 'state',
429
+ 'repo-reducer',
430
+ `${crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24)}.json`,
431
+ );
432
+ fs.writeFileSync(reducerFile, `${JSON.stringify({
433
+ repoKey,
434
+ baseOid: card.head.oid,
435
+ branch: card.head.branch,
436
+ appliedThrough: 2,
437
+ indexDiff: '',
438
+ worktreeDiff: 'patch-a',
439
+ })}\n`);
416
440
 
417
441
  fs.writeFileSync(file, `${JSON.stringify({
418
442
  ...firstCheckpoint,
@@ -422,12 +446,26 @@ test('checkpoint transitions append until the next card and carry their official
422
446
  })}\n`);
423
447
  const second = publishWall(repoKey);
424
448
  assert.equal(second.published, true);
425
- const history = readWall(repoKey)[0].cards[0].checkpoints;
426
- assert.equal(history.length, 2);
427
- assert.deepEqual(history.map((entry) => entry.mutationId), [first.mutationId, second.mutationId]);
449
+ const latestCard = readWall(repoKey)[0].cards[0];
450
+ assert.equal(latestCard.checkpointBase.through, 2);
451
+ assert.equal(latestCard.checkpoints.length, 1);
452
+ assert.deepEqual(latestCard.checkpoints.map((entry) => entry.mutationId), [second.mutationId]);
428
453
  assert.deepEqual(settleProject(), [3]);
429
454
  });
430
455
 
456
+ test('one failed project publisher cannot block the next repo', () => {
457
+ const selected = [{ repoKey: 'root:first' }, { repoKey: 'root:second' }];
458
+ const calls = [];
459
+ const results = wallTest.publishSelected(selected, [], (selectedProject) => {
460
+ calls.push(selectedProject.repoKey);
461
+ if (selectedProject.repoKey === 'root:first') throw new Error('too large');
462
+ return { published: true, repoKey: selectedProject.repoKey };
463
+ });
464
+ assert.deepEqual(calls, ['root:first', 'root:second']);
465
+ assert.equal(results[0].reason, 'publish-failed');
466
+ assert.equal(results[1].published, true);
467
+ });
468
+
431
469
  test('side branches keep one rail operation while the checked-out card advances', async () => {
432
470
  const repo = path.join(tempRoot, 'projects/app');
433
471
  const before = readWall(repoKey)[0].cards[0];
@@ -475,7 +513,7 @@ test('side branches keep one rail operation while the checked-out card advances'
475
513
  test('peer records and earlier local history survive later publishes', async () => {
476
514
  const beforeLines = getDocText(project.file).trim().split('\n');
477
515
  const peerLine = JSON.stringify({
478
- wall: 'code-project@3',
516
+ wall: 'code-project@4',
479
517
  project: repoKey,
480
518
  machine: 'machine-b',
481
519
  mutationId: 'mut_project_peer',
@@ -532,7 +570,7 @@ test('three concurrent append records stay whole under Yjs ordering', () => {
532
570
 
533
571
  test('wall compaction deletes only records superseded for every machine', () => {
534
572
  const line = (machine, value) => JSON.stringify({
535
- wall: 'code-project@3',
573
+ wall: 'code-project@4',
536
574
  project: repoKey,
537
575
  machine,
538
576
  mutationId: `mut_project_${machine}_${value}`,
@@ -541,7 +579,7 @@ test('wall compaction deletes only records superseded for every machine', () =>
541
579
  const oldA = line('machine-a', 'old-a');
542
580
  const oldB = line('machine-b', 'old-b');
543
581
  const latestA = line('machine-a', 'latest-a');
544
- const torn = '{"wall":"code-project@3","machine":"torn"';
582
+ const torn = '{"wall":"code-project@4","machine":"torn"';
545
583
  const latestB = line('machine-b', 'latest-b');
546
584
  const current = `${oldA}\n${oldB}\n${latestA}\n${torn}\n${latestB}\n`;
547
585
  const compacted = compactWallText(current, repoKey);
@@ -553,13 +591,13 @@ test('wall compaction deletes only records superseded for every machine', () =>
553
591
 
554
592
  test('an unordered legacy record cannot pin wall compaction', () => {
555
593
  const unordered = JSON.stringify({
556
- wall: 'code-project@3',
594
+ wall: 'code-project@4',
557
595
  project: repoKey,
558
596
  machine: '16.612Z',
559
597
  cards: [{ value: 'corrupted-legacy-record' }],
560
598
  });
561
599
  const ordered = (machine) => JSON.stringify({
562
- wall: 'code-project@3',
600
+ wall: 'code-project@4',
563
601
  project: repoKey,
564
602
  machine,
565
603
  mutationId: `mut_project_${machine}`,
@@ -576,7 +614,7 @@ test('an unordered legacy record cannot pin wall compaction', () => {
576
614
 
577
615
  test('retired machine values stay represented without pinning obsolete active history', () => {
578
616
  const line = (machine, value) => JSON.stringify({
579
- wall: 'code-project@3',
617
+ wall: 'code-project@4',
580
618
  project: repoKey,
581
619
  machine,
582
620
  mutationId: `mut_project_${machine}_${value}`,
@@ -601,7 +639,7 @@ test('retired machine values stay represented without pinning obsolete active hi
601
639
  test('a concurrent deletion-only compaction and append commute in Yjs', () => {
602
640
  const Y = require('yjs');
603
641
  const line = (machine, value) => JSON.stringify({
604
- wall: 'code-project@3',
642
+ wall: 'code-project@4',
605
643
  project: repoKey,
606
644
  machine,
607
645
  mutationId: `mut_project_${machine}_${value}`,
@@ -654,7 +692,7 @@ test('a concurrent deletion-only compaction and append commute in Yjs', () => {
654
692
  test('a torn line is ignored by readers, never fatal', () => {
655
693
  writeDocText(
656
694
  project.file,
657
- `${getDocText(project.file)}{"wall":"code-project@3","project":"${repoKey}","machine":"machine-c","car\n`,
695
+ `${getDocText(project.file)}{"wall":"code-project@4","project":"${repoKey}","machine":"machine-c","car\n`,
658
696
  );
659
697
  settleProject();
660
698
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
@@ -682,7 +720,7 @@ test('publishing compacts an oversized history before appending the new record',
682
720
  test('project stats name every project resource, binding, and publish counter', () => {
683
721
  const stats = wallStats();
684
722
  const row = stats.projects.find((entry) => entry.repoKey === repoKey);
685
- assert.equal(stats.contract, 'code-project@3');
723
+ assert.equal(stats.contract, 'code-project@4');
686
724
  assert.equal(row.resourceId, project.resourceId);
687
725
  assert.equal(row.replica.appliedVersion >= 0, true);
688
726
  assert.equal(row.machines, 2);
@@ -25,7 +25,7 @@ const { machineId } = require('./identity');
25
25
  const { listRepoCards } = require('./cards');
26
26
  const { captureCheckpoint } = require('./git');
27
27
 
28
- const CHECKPOINT_CONTRACT = 'repo-checkpoint@7';
28
+ const CHECKPOINT_CONTRACT = 'repo-checkpoint@8';
29
29
  const MAX_INLINE_DIFF_BYTES = 512 * 1024;
30
30
  const PUBLISH_QUIET_MS = 25;
31
31
  const RECONCILE_INTERVAL_MS = 30_000;
@@ -54,7 +54,7 @@ function checkpointFile(machine, repoKey) {
54
54
  .update(`${CHECKPOINT_CONTRACT}|${machine}|${repoKey}`)
55
55
  .digest('hex')
56
56
  .slice(0, 24);
57
- return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v7', `${digest}.json`);
57
+ return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v8', `${digest}.json`);
58
58
  }
59
59
 
60
60
  function revisionOf(baseOid, indexDiff, worktreeDiff = '') {
@@ -416,6 +416,7 @@ module.exports = {
416
416
  MAX_INLINE_DIFF_BYTES,
417
417
  checkpointFile,
418
418
  checkpointStats,
419
+ checkpointValue,
419
420
  checkpointValueReady,
420
421
  parseCheckpoint,
421
422
  publishCheckpoints,
@@ -29,6 +29,7 @@ const {
29
29
  } = require('./git');
30
30
  const {
31
31
  CHECKPOINT_CONTRACT,
32
+ checkpointValueReady,
32
33
  readCheckpointValue,
33
34
  repoKeyOfCard,
34
35
  revisionOf,
@@ -108,7 +109,14 @@ function reduceWall(entries) {
108
109
  ) continue;
109
110
  let repo = repos.get(repoKey);
110
111
  if (!repo) {
111
- repo = { repoKey, card: null, transitions: [], parked: [], branchMap: new Map() };
112
+ repo = {
113
+ repoKey,
114
+ card: null,
115
+ checkpointBases: [],
116
+ transitions: [],
117
+ parked: [],
118
+ branchMap: new Map(),
119
+ };
112
120
  repos.set(repoKey, repo);
113
121
  }
114
122
  if (!repo.card || card.positionVersion > repo.card.positionVersion) {
@@ -128,13 +136,39 @@ function reduceWall(entries) {
128
136
  repo.transitions.push(checkpoint);
129
137
  }
130
138
  }
139
+ const checkpointBase = card.checkpointBase;
140
+ if (
141
+ checkpointBase?.checkpoint === CHECKPOINT_CONTRACT
142
+ && Number.isInteger(checkpointBase.through)
143
+ && checkpointBase.through >= 0
144
+ && checkpointBase.baseOid === card.head?.oid
145
+ && checkpointBase.branch === card.head?.branch
146
+ ) {
147
+ repo.checkpointBases.push(checkpointBase);
148
+ }
131
149
  }
132
150
  }
133
151
  for (const repo of repos.values()) {
152
+ const matchingBases = repo.checkpointBases
153
+ .filter((entry) => (
154
+ entry.baseOid === repo.card.head?.oid
155
+ && entry.branch === repo.card.head?.branch
156
+ ))
157
+ .sort((left, right) => right.through - left.through);
158
+ const newestThrough = matchingBases[0]?.through ?? null;
159
+ const newestBases = matchingBases
160
+ .filter((entry) => entry.through === newestThrough)
161
+ .sort((left, right) => (
162
+ Number(checkpointValueReady(right.value)) - Number(checkpointValueReady(left.value))
163
+ ));
164
+ const baseRevisions = new Set(newestBases.map((entry) => entry.value?.revision ?? null));
165
+ repo.checkpointBaseConflict = baseRevisions.size > 1;
166
+ repo.checkpointBase = repo.checkpointBaseConflict ? null : newestBases[0] || null;
134
167
  repo.transitions = repo.transitions
135
168
  .filter((entry) => (
136
169
  entry.baseOid === repo.card.head?.oid
137
170
  && entry.branch === repo.card.head?.branch
171
+ && (!repo.checkpointBase || entry.version > repo.checkpointBase.through)
138
172
  ))
139
173
  .sort((left, right) => left.version - right.version);
140
174
  repo.parked = uniqueParked(repo.transitions.flatMap((checkpoint) => (
@@ -157,6 +191,7 @@ function reduceWall(entries) {
157
191
  }
158
192
  repo.branches = [...repo.branchMap.values()].sort((left, right) => left.name.localeCompare(right.name));
159
193
  delete repo.branchMap;
194
+ delete repo.checkpointBases;
160
195
  }
161
196
  return repos;
162
197
  }
@@ -253,6 +288,14 @@ async function moveToWinningCard(localCard, targetCard) {
253
288
  && current.head.branch === targetCard.head?.branch
254
289
  ) return { state: 'ready', moved: false };
255
290
 
291
+ const pendingCard = current.head.oid
292
+ && targetCard.head?.oid
293
+ && current.head.branch === targetCard.head?.branch
294
+ && await isAncestor(localCard.path, targetCard.head.oid, current.head.oid);
295
+ if (pendingCard) {
296
+ return { state: 'ready', moved: false, pendingCard: true, conflicts: [] };
297
+ }
298
+
256
299
  const objects = await obtainCardObjects(localCard, targetCard);
257
300
  if (objects.state !== 'ready') return { state: 'waiting', reason: objects.state };
258
301
 
@@ -433,6 +476,21 @@ async function reduceCheckpoint(localCard, plan) {
433
476
  let indexTree = baseTree;
434
477
  let worktreeTree = baseTree;
435
478
  const conflicts = [];
479
+ if (plan.checkpointBaseConflict) {
480
+ return { state: 'waiting', reason: 'checkpoint-base-disagrees' };
481
+ }
482
+ if (plan.checkpointBase) {
483
+ const snapshot = readCheckpointValue(baseOid, plan.checkpointBase.value);
484
+ if (snapshot.state !== 'ready') {
485
+ return { state: 'waiting', reason: `checkpoint-base-${snapshot.state}` };
486
+ }
487
+ indexTree = await merge.treeOfPatch(localCard.path, baseOid, snapshot.indexDiff);
488
+ worktreeTree = await merge.treeOfPatch(
489
+ localCard.path,
490
+ indexTree,
491
+ snapshot.worktreeDiff,
492
+ );
493
+ }
436
494
 
437
495
  for (const entry of plan.parked || []) {
438
496
  const commonOid = await mergeBase(localCard.path, baseOid, entry.oid);
@@ -544,38 +602,87 @@ async function materializeRepo(localCardInput, plan, appliedThrough = null) {
544
602
  const reduced = await reduceCheckpoint(localCard, plan);
545
603
  if (reduced.state !== 'ready') return reduced;
546
604
 
547
- const baseTree = await merge.treeOf(localCard.path, plan.card.head.oid);
548
- const captured = await captureCheckpoint(localCard.path);
549
- const represented = captureIsOnRail(plan, captured);
550
- const capturedIndex = represented
551
- ? await merge.treeOfPatch(localCard.path, plan.card.head.oid, captured.indexDiff)
552
- : null;
553
- const capturedWorktree = represented
554
- ? await merge.treeOfPatch(localCard.path, capturedIndex, captured.worktreeDiff)
555
- : null;
556
- const heldWorktree = capturedWorktree || (
605
+ const sharedBaseTree = await merge.treeOf(localCard.path, plan.card.head.oid);
606
+ const previousHeldWorktree = (
557
607
  previous?.baseOid === plan.card.head.oid
558
608
  && previous.heldTree
559
609
  && await merge.hasTree(localCard.path, previous.heldTree)
560
610
  ? previous.heldTree
561
- : baseTree
611
+ : sharedBaseTree
562
612
  );
563
- const heldIndex = capturedIndex || (
613
+ const previousHeldIndex = (
564
614
  previous?.baseOid === plan.card.head.oid
565
615
  && previous.heldIndexTree
566
616
  && await merge.hasTree(localCard.path, previous.heldIndexTree)
567
617
  ? previous.heldIndexTree
568
- : baseTree
618
+ : sharedBaseTree
569
619
  );
620
+ let stateBaseOid = plan.card.head.oid;
621
+ let stateBranch = plan.card.head.branch;
622
+ let stateCardVersion = plan.card.positionVersion;
623
+ let stateCardMutationId = plan.card.positionMutationId;
624
+ let stateIndexDiff = reduced.indexDiff;
625
+ let stateWorktreeDiff = reduced.worktreeDiff;
626
+ let heldWorktree;
627
+ let heldIndex;
628
+ let targetWorktree;
629
+ let targetIndex;
630
+ const pendingConflicts = [];
631
+
632
+ if (cardResult.pendingCard) {
633
+ const pendingBaseTree = await merge.treeOf(localCard.path, localCard.head.oid);
634
+ const rebasedIndex = await merge.reduceTree(
635
+ localCard.path,
636
+ pendingBaseTree,
637
+ previousHeldIndex,
638
+ reduced.indexTree,
639
+ { label: `pending-card-index-${appliedThrough ?? 'local'}` },
640
+ );
641
+ const rebasedWorktree = await merge.reduceTree(
642
+ localCard.path,
643
+ pendingBaseTree,
644
+ previousHeldWorktree,
645
+ reduced.worktreeTree,
646
+ { label: `pending-card-worktree-${appliedThrough ?? 'local'}` },
647
+ );
648
+ heldIndex = pendingBaseTree;
649
+ heldWorktree = pendingBaseTree;
650
+ targetIndex = rebasedIndex.tree;
651
+ targetWorktree = rebasedWorktree.tree;
652
+ stateBaseOid = localCard.head.oid;
653
+ stateBranch = localCard.head.branch;
654
+ stateCardVersion = null;
655
+ stateCardMutationId = null;
656
+ stateIndexDiff = await merge.diffOfTree(localCard.path, stateBaseOid, targetIndex);
657
+ stateWorktreeDiff = await merge.diffOfTree(localCard.path, targetIndex, targetWorktree);
658
+ pendingConflicts.push(
659
+ ...rebasedIndex.conflicts.map((item) => ({ ...item, layer: 'pending-card-index' })),
660
+ ...rebasedWorktree.conflicts.map((item) => ({ ...item, layer: 'pending-card-worktree' })),
661
+ );
662
+ } else {
663
+ const captured = await captureCheckpoint(localCard.path);
664
+ const represented = captureIsOnRail(plan, captured);
665
+ const capturedIndex = represented
666
+ ? await merge.treeOfPatch(localCard.path, plan.card.head.oid, captured.indexDiff)
667
+ : null;
668
+ const capturedWorktree = represented
669
+ ? await merge.treeOfPatch(localCard.path, capturedIndex, captured.worktreeDiff)
670
+ : null;
671
+ heldWorktree = capturedWorktree || previousHeldWorktree;
672
+ heldIndex = capturedIndex || previousHeldIndex;
673
+ targetWorktree = reduced.worktreeTree;
674
+ targetIndex = reduced.indexTree;
675
+ }
676
+
570
677
  const currentIndex = await merge.indexTree(localCard.path);
571
678
  const reducedLocalIndex = await merge.reduceTree(
572
679
  localCard.path,
573
680
  currentIndex,
574
681
  heldIndex,
575
- reduced.indexTree,
682
+ targetIndex,
576
683
  { label: `index-${reduced.checkpointRevision || 'clean'}` },
577
684
  );
578
- const applied = await merge.advance(localCard.path, heldWorktree, reduced.worktreeTree, {
685
+ const applied = await merge.advance(localCard.path, heldWorktree, targetWorktree, {
579
686
  label: `checkpoint-${reduced.checkpointRevision || 'clean'}`,
580
687
  });
581
688
  const indexResult = await advanceIndex(localCard.path, currentIndex, reducedLocalIndex.tree);
@@ -583,28 +690,30 @@ async function materializeRepo(localCardInput, plan, appliedThrough = null) {
583
690
  const state = writeRepoState(plan.repoKey, {
584
691
  path: localCard.path,
585
692
  replica: localCard.replica,
586
- branch: plan.card.head.branch,
587
- baseOid: plan.card.head.oid,
588
- cardVersion: plan.card.positionVersion,
589
- cardMutationId: plan.card.positionMutationId,
693
+ branch: stateBranch,
694
+ baseOid: stateBaseOid,
695
+ cardVersion: stateCardVersion,
696
+ cardMutationId: stateCardMutationId,
697
+ pendingCard: Boolean(cardResult.pendingCard),
590
698
  appliedThrough,
591
- checkpointRevision: reduced.checkpointRevision,
592
- indexDiff: reduced.indexDiff,
593
- worktreeDiff: reduced.worktreeDiff,
699
+ checkpointRevision: revisionOf(stateBaseOid, stateIndexDiff, stateWorktreeDiff),
700
+ indexDiff: stateIndexDiff,
701
+ worktreeDiff: stateWorktreeDiff,
594
702
  checkpointVersions: plan.transitions.map((entry) => entry.version),
595
703
  branches: plan.branches || [],
596
704
  heldTree: applied.heldTree,
597
- heldIndexTree: reduced.indexTree,
705
+ heldIndexTree: targetIndex,
598
706
  parked,
599
707
  parkedWaiting: [...parkedWaiting, ...branchResult.waiting],
600
708
  conflicts: [
601
709
  ...(cardResult.conflicts || []),
602
710
  ...reduced.conflicts,
711
+ ...pendingConflicts,
603
712
  ...reducedLocalIndex.conflicts.map((item) => ({ ...item, layer: 'index-materialize' })),
604
713
  ...applied.conflicts,
605
714
  ],
606
- materialized: applied.heldTree === reduced.worktreeTree
607
- && reducedLocalIndex.tree === reduced.indexTree,
715
+ materialized: applied.heldTree === targetWorktree
716
+ && reducedLocalIndex.tree === targetIndex,
608
717
  });
609
718
  appendStateEvent({
610
719
  resource: 'workspace-reducer',
@@ -17,9 +17,15 @@ const { AMALGM_DIR } = require('../config');
17
17
  const { subscribeStateEvents } = require('../state/events');
18
18
  const { machineId, userId } = require('./identity');
19
19
  const { listRepoCards } = require('./cards');
20
- const { readCheckpoint, repoKeyOfCard, sameTransition } = require('./checkpoint');
21
-
22
- const WALL_CONTRACT = 'code-project@3';
20
+ const {
21
+ CHECKPOINT_CONTRACT,
22
+ checkpointValue,
23
+ readCheckpoint,
24
+ repoKeyOfCard,
25
+ sameTransition,
26
+ } = require('./checkpoint');
27
+
28
+ const WALL_CONTRACT = 'code-project@4';
23
29
  const PROJECT_DIR = path.join(AMALGM_DIR, 'state', 'code-projects');
24
30
  const REGISTRY_FILE = path.join(PROJECT_DIR, 'projects.json');
25
31
  const PUBLISH_QUIET_MS = 25;
@@ -246,7 +252,8 @@ function hasLocalContribution(card, plan, baseline, checkpoint = null, previousC
246
252
  const onRail = samePosition(card, plan.card);
247
253
  const atBaseline = baseline
248
254
  && card.head?.branch === baseline.branch
249
- && card.head?.oid === baseline.baseOid;
255
+ && card.head?.oid === baseline.baseOid
256
+ && !baseline.pendingCard;
250
257
  const alreadyPublished = validVersion(previousCard?.positionVersion)
251
258
  && samePosition(card, previousCard);
252
259
  const reversesPublishedSharedPosition = previousCard
@@ -309,6 +316,46 @@ function hasObjectUpgrade(card, plan) {
309
316
  });
310
317
  }
311
318
 
319
+ function checkpointBaseFor(card, baseline, project, shared = null) {
320
+ if (
321
+ !baseline
322
+ || baseline.baseOid !== card.head?.oid
323
+ || baseline.branch !== card.head?.branch
324
+ || !Number.isInteger(baseline.appliedThrough)
325
+ || baseline.appliedThrough < 0
326
+ ) return null;
327
+ const value = checkpointValue(
328
+ machineId(),
329
+ project.repoKey,
330
+ card.head.oid,
331
+ baseline.indexDiff || '',
332
+ baseline.worktreeDiff || '',
333
+ );
334
+ const reusableThrough = shared
335
+ && shared.baseOid === card.head.oid
336
+ && shared.branch === card.head.branch
337
+ && shared.value?.revision === value.revision
338
+ && Number.isInteger(shared.through)
339
+ && shared.through >= 0;
340
+ return {
341
+ checkpoint: CHECKPOINT_CONTRACT,
342
+ baseOid: card.head.oid,
343
+ branch: card.head.branch,
344
+ through: reusableThrough ? shared.through : baseline.appliedThrough,
345
+ value,
346
+ };
347
+ }
348
+
349
+ function hasCheckpointBaseUpgrade(card, plan, baseline, project) {
350
+ const shared = plan?.card?.checkpointBase;
351
+ const candidate = checkpointBaseFor(card, baseline, project, shared);
352
+ return Boolean(shared && candidate) && (
353
+ candidate.through === shared.through
354
+ && candidate.value?.revision === shared.value?.revision
355
+ && JSON.stringify(candidate.value) !== JSON.stringify(shared.value)
356
+ );
357
+ }
358
+
312
359
  function validVersion(value) {
313
360
  return Number.isInteger(value) && value > 0;
314
361
  }
@@ -443,6 +490,12 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
443
490
  && checkpoint.branch === card.head?.branch
444
491
  ? checkpoint
445
492
  : null;
493
+ const checkpointBase = checkpointBaseFor(
494
+ card,
495
+ baseline,
496
+ project,
497
+ sharedPlan?.card?.checkpointBase,
498
+ );
446
499
  const sameBase = previous?.head?.oid === card.head?.oid
447
500
  && previous?.head?.branch === card.head?.branch;
448
501
  const history = sameBase && Array.isArray(previous?.checkpoints)
@@ -453,7 +506,10 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
453
506
  entry.mutationId,
454
507
  entry.version,
455
508
  ),
456
- }))
509
+ })).filter((entry) => (
510
+ validVersion(entry.version)
511
+ && (!checkpointBase || entry.version > checkpointBase.through)
512
+ ))
457
513
  : [];
458
514
  const last = history.at(-1);
459
515
  const checkpoints = currentCheckpoint
@@ -473,6 +529,7 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
473
529
  mutationId,
474
530
  project.resourceId,
475
531
  ),
532
+ ...(checkpointBase ? { checkpointBase } : {}),
476
533
  ...(checkpoints.length ? { checkpoints } : {}),
477
534
  };
478
535
  });
@@ -549,7 +606,8 @@ function publishProject(project, rawCards) {
549
606
  const checkpoint = readCheckpoint(machineId(), project.repoKey);
550
607
  const previousCard = previousCards.find((entry) => entry.replica === card.replica);
551
608
  return hasLocalContribution(card, sharedPlan, baseline, checkpoint, previousCard)
552
- || hasObjectUpgrade(card, sharedPlan);
609
+ || hasObjectUpgrade(card, sharedPlan)
610
+ || hasCheckpointBaseUpgrade(card, sharedPlan, baseline, project);
553
611
  });
554
612
  if (!removed && !contributes) {
555
613
  service.counters.publishSkips += 1;
@@ -618,16 +676,33 @@ function publishProject(project, rawCards) {
618
676
  };
619
677
  }
620
678
 
679
+ function publishSelected(selected, allCards, publisher = publishProject) {
680
+ return selected.map((project) => {
681
+ try {
682
+ return publisher(
683
+ project,
684
+ allCards.filter((card) => (
685
+ repoKeyOfCard(card) === project.repoKey
686
+ && isCommittedCard(card)
687
+ )),
688
+ );
689
+ } catch (error) {
690
+ service.counters.publishFailures += 1;
691
+ console.warn(`[CodeProject] ${project.repoKey} publish failed:`, error.message);
692
+ return {
693
+ published: false,
694
+ reason: 'publish-failed',
695
+ repoKey: project.repoKey,
696
+ error: error.message,
697
+ };
698
+ }
699
+ });
700
+ }
701
+
621
702
  function publishWall(repoKey = null) {
622
703
  const allCards = listRepoCards();
623
704
  const selected = repoKey ? [projectForRepo(repoKey)].filter(Boolean) : projects();
624
- const results = selected.map((project) => publishProject(
625
- project,
626
- allCards.filter((card) => (
627
- repoKeyOfCard(card) === project.repoKey
628
- && isCommittedCard(card)
629
- )),
630
- ));
705
+ const results = publishSelected(selected, allCards);
631
706
  if (repoKey) return results[0] || { published: false, reason: 'project-missing', repoKey };
632
707
  return {
633
708
  published: results.some((result) => result.published),
@@ -788,6 +863,7 @@ function wallStats() {
788
863
  }
789
864
 
790
865
  module.exports = {
866
+ _test: { publishSelected },
791
867
  WALL_CONTRACT,
792
868
  bindProject,
793
869
  compactWallText,