amalgm 0.1.216 → 0.1.218

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.216",
3
+ "version": "0.1.218",
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', () => {
@@ -188,13 +188,37 @@ test('a materialized peer card is immediately a valid checkpoint base', () => {
188
188
  assert.equal(_test.checkpointBaseReady({
189
189
  head: { branch: 'main', oid },
190
190
  }, {
191
+ appliedThrough: 12,
191
192
  branch: 'main',
192
193
  baseOid: oid,
193
194
  indexDiff: '',
194
195
  worktreeDiff: '',
196
+ }, {
197
+ receivedThrough: 12,
198
+ appliedThrough: 12,
199
+ pending: 0,
200
+ current: true,
195
201
  }), true);
196
202
  });
197
203
 
204
+ test('checkpoint publication waits when the reducer is behind the rail', () => {
205
+ const oid = 'a'.repeat(40);
206
+ const card = { head: { branch: 'main', oid } };
207
+ const reduced = {
208
+ appliedThrough: 11,
209
+ branch: 'main',
210
+ baseOid: oid,
211
+ indexDiff: '',
212
+ worktreeDiff: '',
213
+ };
214
+ assert.equal(_test.checkpointBaseReady(card, reduced, {
215
+ receivedThrough: 12,
216
+ appliedThrough: 11,
217
+ pending: 0,
218
+ current: false,
219
+ }), false);
220
+ });
221
+
198
222
  test('an uploading checkpoint payload is not published as a transition', () => {
199
223
  const pending = {
200
224
  revision: 'a'.repeat(64),
@@ -28,7 +28,12 @@ const {
28
28
  sameTransition,
29
29
  } = require('../workspace/checkpoint');
30
30
  const { parkedForState } = require('../workspace/reducer');
31
- const { compactWallText, hasLocalContribution, stampCards } = require('../workspace/wall');
31
+ const {
32
+ compactWallText,
33
+ hasLocalContribution,
34
+ publicationStatus,
35
+ stampCards,
36
+ } = require('../workspace/wall');
32
37
 
33
38
  test.after(() => {
34
39
  closeLocalDb();
@@ -80,6 +85,43 @@ test('the publication model has one explicit rule for every value and property',
80
85
  );
81
86
  });
82
87
 
88
+ test('publication requires one current applied rail position and no unresolved send', () => {
89
+ const repoKey = 'root:currentness';
90
+ const resourceId = require('../workspace/wall').projectResourceId(repoKey);
91
+ const file = path.join(tempRoot, 'currentness.jsonl');
92
+ fs.writeFileSync(file, '');
93
+ const opened = require('../state/docs').openDoc(file);
94
+ require('../state/replicas').registerReplica({
95
+ resourceId,
96
+ localResource: opened.resource,
97
+ localPath: file,
98
+ contract: 'text-yjs@1',
99
+ schemaVersion: 1,
100
+ authorityEpoch: 1,
101
+ appliedVersion: 12,
102
+ });
103
+
104
+ assert.deepEqual(publicationStatus(repoKey, { appliedThrough: 11 }), {
105
+ receivedThrough: 12,
106
+ appliedThrough: 11,
107
+ pending: 0,
108
+ current: false,
109
+ });
110
+ assert.deepEqual(publicationStatus(repoKey, { appliedThrough: 12 }), {
111
+ receivedThrough: 12,
112
+ appliedThrough: 12,
113
+ pending: 0,
114
+ current: true,
115
+ });
116
+ require('../state/docs').writeDocText(file, 'pending\n');
117
+ assert.deepEqual(publicationStatus(repoKey, { appliedThrough: 12 }), {
118
+ receivedThrough: 12,
119
+ appliedThrough: 12,
120
+ pending: 1,
121
+ current: false,
122
+ });
123
+ });
124
+
83
125
  test('server rail numbers do not change checkpoint or parked identity', () => {
84
126
  const oid = 'a'.repeat(40);
85
127
  const transition = {
@@ -216,7 +258,7 @@ test('two concurrent compacting publishers converge without losing either latest
216
258
  const Y = require('yjs');
217
259
  const repoKey = 'root:compaction-collision';
218
260
  const line = (machine, value) => JSON.stringify({
219
- wall: 'code-project@3',
261
+ wall: 'code-project@4',
220
262
  project: repoKey,
221
263
  machine,
222
264
  mutationId: `mut_${machine}_${value}`,
@@ -40,6 +40,10 @@ test('a materialized baseline can follow the rail without publishing an echo', (
40
40
  published: false,
41
41
  reason: 'shared-state-covered',
42
42
  }), true);
43
+ assert.equal(localCardMayMaterialize(null, {
44
+ published: false,
45
+ reason: 'project-not-current',
46
+ }), true);
43
47
  assert.equal(localCardMayMaterialize({ positionVersion: 7 }, null), true);
44
48
  assert.equal(localCardMayMaterialize(null, {
45
49
  published: true,
@@ -148,6 +152,56 @@ test('wall reduction chooses one rail winner and orders checkpoint transitions b
148
152
  ]);
149
153
  });
150
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
+
151
205
  test('a ready parked bundle never regresses to pending', () => {
152
206
  const oid = 'c'.repeat(40);
153
207
  const ready = card('/ready', 'a'.repeat(40), 1);
@@ -360,8 +414,9 @@ test('one materializer converges to the reduced checkpoint and later removes a r
360
414
  transition('machine-b', 3, oid, patchA, `${patchA}${patchB}`),
361
415
  ],
362
416
  };
363
- const applied = await materializeRepo(localCard, first);
417
+ const applied = await materializeRepo(localCard, first, 3);
364
418
  assert.equal(applied.state, 'in-sync');
419
+ assert.equal(readRepoState('root:test').appliedThrough, 3);
365
420
  assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a1\n');
366
421
  assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
367
422
 
@@ -374,8 +429,9 @@ test('one materializer converges to the reduced checkpoint and later removes a r
374
429
  transition('machine-a', 4, oid, combined, patchB),
375
430
  ],
376
431
  };
377
- const reverted = await materializeRepo(localCard, second);
432
+ const reverted = await materializeRepo(localCard, second, 4);
378
433
  assert.equal(reverted.state, 'in-sync');
434
+ assert.equal(readRepoState('root:test').appliedThrough, 4);
379
435
  assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a0\n');
380
436
  assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
381
437
  });
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-rest-delete-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
12
+ process.env.AMALGM_COMPUTER_ID = 'computer-test';
13
+
14
+ const { closeLocalDb } = require('../state/db');
15
+ const { listWorkspaces, upsertWorkspace } = require('../workspace/store');
16
+ const { handleProjectDelete } = require('../workspace/rest');
17
+
18
+ const WORKSPACES_ROOT = process.env.AMALGM_WORKSPACES_DIR;
19
+ const TRASH_ROOT = path.join(process.env.AMALGM_DIR, 'trash', 'workspaces');
20
+
21
+ test.after(() => {
22
+ closeLocalDb();
23
+ fs.rmSync(tempRoot, { recursive: true, force: true });
24
+ });
25
+
26
+ function captureJson() {
27
+ const sent = {};
28
+ return {
29
+ sent,
30
+ sendJson: (status, body) => {
31
+ sent.status = status;
32
+ sent.body = body;
33
+ },
34
+ };
35
+ }
36
+
37
+ test('deleting a managed workspace trashes its folder instead of leaving a corpse', async () => {
38
+ const folder = path.join(WORKSPACES_ROOT, 'managed-project');
39
+ fs.mkdirSync(folder, { recursive: true });
40
+ fs.writeFileSync(path.join(folder, 'keep.md'), 'survives the trash move');
41
+
42
+ const workspace = upsertWorkspace({
43
+ path: folder,
44
+ name: 'managed-project',
45
+ source: 'managed',
46
+ managed: true,
47
+ }, { workspaceRoot: WORKSPACES_ROOT });
48
+
49
+ const { sent, sendJson } = captureJson();
50
+ await handleProjectDelete({ id: workspace.id }, sendJson);
51
+
52
+ assert.equal(sent.status, 200);
53
+ assert.equal(sent.body.success, true);
54
+ assert.ok(sent.body.trashedTo, 'delete reports where the folder went');
55
+ assert.ok(
56
+ sent.body.trashedTo.startsWith(TRASH_ROOT + path.sep),
57
+ `trashed into the trash root, got ${sent.body.trashedTo}`,
58
+ );
59
+ assert.equal(fs.existsSync(folder), false, 'the workspaces root is clean');
60
+ assert.equal(
61
+ fs.readFileSync(path.join(sent.body.trashedTo, 'keep.md'), 'utf8'),
62
+ 'survives the trash move',
63
+ 'contents survive the move',
64
+ );
65
+ assert.equal(
66
+ listWorkspaces().some((entry) => entry.id === workspace.id),
67
+ false,
68
+ 'the registry row is gone',
69
+ );
70
+ });
71
+
72
+ test('deleting an unmanaged workspace never touches its folder', async () => {
73
+ const folder = path.join(tempRoot, 'my-own-checkout');
74
+ fs.mkdirSync(folder, { recursive: true });
75
+ fs.writeFileSync(path.join(folder, 'precious.md'), 'user data');
76
+
77
+ const workspace = upsertWorkspace({
78
+ path: folder,
79
+ name: 'my-own-checkout',
80
+ source: 'computer',
81
+ }, { workspaceRoot: WORKSPACES_ROOT });
82
+ assert.equal(workspace.managed, false);
83
+
84
+ const { sent, sendJson } = captureJson();
85
+ await handleProjectDelete({ id: workspace.id }, sendJson);
86
+
87
+ assert.equal(sent.status, 200);
88
+ assert.equal(sent.body.trashedTo, null);
89
+ assert.equal(fs.existsSync(path.join(folder, 'precious.md')), true, 'folder untouched');
90
+ });
91
+
92
+ test('a managed flag on a path outside the workspaces root still never moves it', async () => {
93
+ const folder = path.join(tempRoot, 'mislabeled-managed');
94
+ fs.mkdirSync(folder, { recursive: true });
95
+
96
+ const workspace = upsertWorkspace({
97
+ path: folder,
98
+ name: 'mislabeled-managed',
99
+ source: 'managed',
100
+ managed: true,
101
+ }, { workspaceRoot: WORKSPACES_ROOT });
102
+
103
+ const { sent, sendJson } = captureJson();
104
+ await handleProjectDelete({ id: workspace.id }, sendJson);
105
+
106
+ assert.equal(sent.status, 200);
107
+ assert.equal(sent.body.trashedTo, null, 'outside the root nothing moves');
108
+ assert.equal(fs.existsSync(folder), true);
109
+ });
110
+
111
+ test('deleting a managed workspace whose folder is already gone succeeds', async () => {
112
+ const folder = path.join(WORKSPACES_ROOT, 'already-gone');
113
+ fs.mkdirSync(folder, { recursive: true });
114
+ const workspace = upsertWorkspace({
115
+ path: folder,
116
+ name: 'already-gone',
117
+ source: 'managed',
118
+ managed: true,
119
+ }, { workspaceRoot: WORKSPACES_ROOT });
120
+ fs.rmSync(folder, { recursive: true, force: true });
121
+
122
+ const { sent, sendJson } = captureJson();
123
+ await handleProjectDelete({ id: workspace.id }, sendJson);
124
+
125
+ assert.equal(sent.status, 200);
126
+ assert.equal(sent.body.success, true);
127
+ assert.equal(sent.body.trashedTo, null);
128
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  const test = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
+ const crypto = require('crypto');
5
6
  const fs = require('fs');
6
7
  const os = require('os');
7
8
  const path = require('path');
@@ -24,13 +25,17 @@ fs.writeFileSync(
24
25
 
25
26
  const { closeLocalDb } = require('../state/db');
26
27
  const { appendStateEvent } = require('../state/events');
27
- const { acknowledgeCloudMutation } = require('../state/mutations');
28
+ const {
29
+ acknowledgeCloudMutation,
30
+ listPendingCloudMutations,
31
+ } = require('../state/mutations');
28
32
  const { registerReplica } = require('../state/replicas');
29
33
  const { openDoc, getDocText, writeDocText } = require('../state/docs');
30
34
  const { upsertWorkspace } = require('../workspace/store');
31
35
  const { refreshRepoCards } = require('../workspace/cards');
32
36
  const { checkpointFile, repoKeyOfCard } = require('../workspace/checkpoint');
33
37
  const {
38
+ _test: wallTest,
34
39
  bindProject,
35
40
  compactWallText,
36
41
  hasLocalContribution,
@@ -48,6 +53,29 @@ const {
48
53
 
49
54
  let repoKey;
50
55
  let project;
56
+ let nextCloudVersion = 1;
57
+
58
+ function markReducerApplied(version) {
59
+ const digest = crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24);
60
+ const file = path.join(process.env.AMALGM_DIR, 'state', 'repo-reducer', `${digest}.json`);
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ fs.writeFileSync(file, `${JSON.stringify({ repoKey, appliedThrough: version })}\n`);
63
+ }
64
+
65
+ function settleProject() {
66
+ const versions = [];
67
+ for (const mutation of listPendingCloudMutations(project.resourceId)) {
68
+ const version = nextCloudVersion;
69
+ nextCloudVersion += 1;
70
+ acknowledgeCloudMutation(project.resourceId, mutation.mutationId, {
71
+ version,
72
+ committedAt: `2026-07-23T00:00:${String(version).padStart(2, '0')}.000Z`,
73
+ });
74
+ versions.push(version);
75
+ }
76
+ if (versions.length > 0) markReducerApplied(versions.at(-1));
77
+ return versions;
78
+ }
51
79
 
52
80
  test.after(() => {
53
81
  closeLocalDb();
@@ -121,7 +149,7 @@ test('only a value different from both the materialized baseline and the rail is
121
149
  };
122
150
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline), false);
123
151
  assert.equal(hasLocalContribution(local, remoteAdvance, baseline, {
124
- checkpoint: 'repo-checkpoint@7',
152
+ checkpoint: 'repo-checkpoint@8',
125
153
  machine: 'machine-a',
126
154
  branch: 'main',
127
155
  baseOid: oidA,
@@ -191,7 +219,7 @@ test('only a value different from both the materialized baseline and the rail is
191
219
  },
192
220
  ), true);
193
221
  assert.equal(hasLocalContribution(local, plan, baseline, {
194
- checkpoint: 'repo-checkpoint@7',
222
+ checkpoint: 'repo-checkpoint@8',
195
223
  machine: 'machine-a',
196
224
  branch: 'main',
197
225
  baseOid: oidA,
@@ -301,7 +329,7 @@ test('a repo project appends immutable machine records', async () => {
301
329
  assert.equal(first.published, true);
302
330
  const entries = readWall(repoKey);
303
331
  assert.equal(entries.length, 1);
304
- assert.equal(entries[0].wall, 'code-project@3');
332
+ assert.equal(entries[0].wall, 'code-project@4');
305
333
  assert.equal(entries[0].project, repoKey);
306
334
  assert.equal(entries[0].machine, 'machine-a');
307
335
  assert.equal(entries[0].cards.length, 1);
@@ -317,10 +345,7 @@ test('a repo project appends immutable machine records', async () => {
317
345
  false,
318
346
  );
319
347
 
320
- acknowledgeCloudMutation(project.resourceId, first.mutationId, {
321
- version: 1,
322
- committedAt: '2026-07-23T00:00:00.000Z',
323
- });
348
+ assert.deepEqual(settleProject(), [1]);
324
349
  const sealed = publishWall(repoKey);
325
350
  assert.equal(sealed.published, false);
326
351
  assert.equal(sealed.reason, 'shared-state-covered');
@@ -360,12 +385,12 @@ test('a repo project appends immutable machine records', async () => {
360
385
  assert.equal(getDocText(project.file).trim().split('\n').length, 1);
361
386
  });
362
387
 
363
- test('checkpoint transitions append until the next card and carry their official rail numbers', async () => {
388
+ test('a checkpoint base replaces history already covered by the applied rail', async () => {
364
389
  const card = readWall(repoKey)[0].cards[0];
365
390
  const file = checkpointFile('machine-a', repoKey);
366
391
  fs.mkdirSync(path.dirname(file), { recursive: true });
367
392
  const firstCheckpoint = {
368
- checkpoint: 'repo-checkpoint@7',
393
+ checkpoint: 'repo-checkpoint@8',
369
394
  machine: 'machine-a',
370
395
  repoKey,
371
396
  branch: card.head.branch,
@@ -381,10 +406,7 @@ test('checkpoint transitions append until the next card and carry their official
381
406
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints.length, 1);
382
407
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].mutationId, first.mutationId);
383
408
 
384
- acknowledgeCloudMutation(project.resourceId, first.mutationId, {
385
- version: 2,
386
- committedAt: '2026-07-23T00:00:02.000Z',
387
- });
409
+ assert.deepEqual(settleProject(), [2]);
388
410
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].version, 2);
389
411
  assert.equal(publishWall(repoKey).published, false);
390
412
  const checkpointLines = getDocText(project.file).trim().split('\n');
@@ -392,6 +414,20 @@ test('checkpoint transitions append until the next card and carry their official
392
414
  JSON.parse(checkpointLines.at(-1)).cards[0].checkpoints[0].version ?? null,
393
415
  null,
394
416
  );
417
+ const reducerFile = path.join(
418
+ process.env.AMALGM_DIR,
419
+ 'state',
420
+ 'repo-reducer',
421
+ `${crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24)}.json`,
422
+ );
423
+ fs.writeFileSync(reducerFile, `${JSON.stringify({
424
+ repoKey,
425
+ baseOid: card.head.oid,
426
+ branch: card.head.branch,
427
+ appliedThrough: 2,
428
+ indexDiff: '',
429
+ worktreeDiff: 'patch-a',
430
+ })}\n`);
395
431
 
396
432
  fs.writeFileSync(file, `${JSON.stringify({
397
433
  ...firstCheckpoint,
@@ -401,9 +437,24 @@ test('checkpoint transitions append until the next card and carry their official
401
437
  })}\n`);
402
438
  const second = publishWall(repoKey);
403
439
  assert.equal(second.published, true);
404
- const history = readWall(repoKey)[0].cards[0].checkpoints;
405
- assert.equal(history.length, 2);
406
- assert.deepEqual(history.map((entry) => entry.mutationId), [first.mutationId, second.mutationId]);
440
+ const latestCard = readWall(repoKey)[0].cards[0];
441
+ assert.equal(latestCard.checkpointBase.through, 2);
442
+ assert.equal(latestCard.checkpoints.length, 1);
443
+ assert.deepEqual(latestCard.checkpoints.map((entry) => entry.mutationId), [second.mutationId]);
444
+ assert.deepEqual(settleProject(), [3]);
445
+ });
446
+
447
+ test('one failed project publisher cannot block the next repo', () => {
448
+ const selected = [{ repoKey: 'root:first' }, { repoKey: 'root:second' }];
449
+ const calls = [];
450
+ const results = wallTest.publishSelected(selected, [], (selectedProject) => {
451
+ calls.push(selectedProject.repoKey);
452
+ if (selectedProject.repoKey === 'root:first') throw new Error('too large');
453
+ return { published: true, repoKey: selectedProject.repoKey };
454
+ });
455
+ assert.deepEqual(calls, ['root:first', 'root:second']);
456
+ assert.equal(results[0].reason, 'publish-failed');
457
+ assert.equal(results[1].published, true);
407
458
  });
408
459
 
409
460
  test('side branches keep one rail operation while the checked-out card advances', async () => {
@@ -429,10 +480,7 @@ test('side branches keep one rail operation while the checked-out card advances'
429
480
  assert.equal(card.positionMutationId, before.positionMutationId);
430
481
  assert.equal(ref.mutationId, created.mutationId);
431
482
 
432
- acknowledgeCloudMutation(project.resourceId, created.mutationId, {
433
- version: 3,
434
- committedAt: '2026-07-23T00:00:04.000Z',
435
- });
483
+ assert.deepEqual(settleProject(), [4]);
436
484
  await run(repo, 'add', 'new.txt');
437
485
  await run(repo, 'commit', '-m', 'advance without moving side branch');
438
486
  await refreshRepoCards();
@@ -442,10 +490,7 @@ test('side branches keep one rail operation while the checked-out card advances'
442
490
  assert.equal(card.positionMutationId, advanced.mutationId);
443
491
  assert.equal(card.branches.find((entry) => entry.name === branch).mutationId, created.mutationId);
444
492
 
445
- acknowledgeCloudMutation(project.resourceId, advanced.mutationId, {
446
- version: 4,
447
- committedAt: '2026-07-23T00:00:05.000Z',
448
- });
493
+ assert.deepEqual(settleProject(), [5]);
449
494
  await run(repo, 'branch', '-D', branch);
450
495
  await refreshRepoCards();
451
496
  const deleted = publishWall(repoKey);
@@ -453,12 +498,13 @@ test('side branches keep one rail operation while the checked-out card advances'
453
498
  card = readWall(repoKey)[0].cards[0];
454
499
  assert.equal(card.branches.find((entry) => entry.name === branch).oid, null);
455
500
  assert.equal(card.branches.find((entry) => entry.name === branch).mutationId, deleted.mutationId);
501
+ assert.deepEqual(settleProject(), [6]);
456
502
  });
457
503
 
458
504
  test('peer records and earlier local history survive later publishes', async () => {
459
505
  const beforeLines = getDocText(project.file).trim().split('\n');
460
506
  const peerLine = JSON.stringify({
461
- wall: 'code-project@3',
507
+ wall: 'code-project@4',
462
508
  project: repoKey,
463
509
  machine: 'machine-b',
464
510
  mutationId: 'mut_project_peer',
@@ -466,6 +512,7 @@ test('peer records and earlier local history survive later publishes', async ()
466
512
  cards: [{ card: 'repo-card@2', machine: 'machine-b', replica: 'rpl_peer', path: '/work/app', head: { branch: 'preview' } }],
467
513
  });
468
514
  writeDocText(project.file, `${getDocText(project.file)}${peerLine}\n`);
515
+ assert.deepEqual(settleProject(), [7]);
469
516
 
470
517
  const repo = path.join(tempRoot, 'projects/app');
471
518
  fs.writeFileSync(path.join(repo, 'another.txt'), 'y\n');
@@ -480,6 +527,7 @@ test('peer records and earlier local history survive later publishes', async ()
480
527
  for (const line of beforeLines) assert.ok(lines.includes(line));
481
528
  assert.ok(lines.includes(peerLine));
482
529
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
530
+ assert.deepEqual(settleProject(), [8]);
483
531
  });
484
532
 
485
533
  test('three concurrent append records stay whole under Yjs ordering', () => {
@@ -513,7 +561,7 @@ test('three concurrent append records stay whole under Yjs ordering', () => {
513
561
 
514
562
  test('wall compaction deletes only records superseded for every machine', () => {
515
563
  const line = (machine, value) => JSON.stringify({
516
- wall: 'code-project@3',
564
+ wall: 'code-project@4',
517
565
  project: repoKey,
518
566
  machine,
519
567
  mutationId: `mut_project_${machine}_${value}`,
@@ -522,7 +570,7 @@ test('wall compaction deletes only records superseded for every machine', () =>
522
570
  const oldA = line('machine-a', 'old-a');
523
571
  const oldB = line('machine-b', 'old-b');
524
572
  const latestA = line('machine-a', 'latest-a');
525
- const torn = '{"wall":"code-project@3","machine":"torn"';
573
+ const torn = '{"wall":"code-project@4","machine":"torn"';
526
574
  const latestB = line('machine-b', 'latest-b');
527
575
  const current = `${oldA}\n${oldB}\n${latestA}\n${torn}\n${latestB}\n`;
528
576
  const compacted = compactWallText(current, repoKey);
@@ -534,13 +582,13 @@ test('wall compaction deletes only records superseded for every machine', () =>
534
582
 
535
583
  test('an unordered legacy record cannot pin wall compaction', () => {
536
584
  const unordered = JSON.stringify({
537
- wall: 'code-project@3',
585
+ wall: 'code-project@4',
538
586
  project: repoKey,
539
587
  machine: '16.612Z',
540
588
  cards: [{ value: 'corrupted-legacy-record' }],
541
589
  });
542
590
  const ordered = (machine) => JSON.stringify({
543
- wall: 'code-project@3',
591
+ wall: 'code-project@4',
544
592
  project: repoKey,
545
593
  machine,
546
594
  mutationId: `mut_project_${machine}`,
@@ -557,7 +605,7 @@ test('an unordered legacy record cannot pin wall compaction', () => {
557
605
 
558
606
  test('retired machine values stay represented without pinning obsolete active history', () => {
559
607
  const line = (machine, value) => JSON.stringify({
560
- wall: 'code-project@3',
608
+ wall: 'code-project@4',
561
609
  project: repoKey,
562
610
  machine,
563
611
  mutationId: `mut_project_${machine}_${value}`,
@@ -582,7 +630,7 @@ test('retired machine values stay represented without pinning obsolete active hi
582
630
  test('a concurrent deletion-only compaction and append commute in Yjs', () => {
583
631
  const Y = require('yjs');
584
632
  const line = (machine, value) => JSON.stringify({
585
- wall: 'code-project@3',
633
+ wall: 'code-project@4',
586
634
  project: repoKey,
587
635
  machine,
588
636
  mutationId: `mut_project_${machine}_${value}`,
@@ -635,14 +683,16 @@ test('a concurrent deletion-only compaction and append commute in Yjs', () => {
635
683
  test('a torn line is ignored by readers, never fatal', () => {
636
684
  writeDocText(
637
685
  project.file,
638
- `${getDocText(project.file)}{"wall":"code-project@3","project":"${repoKey}","machine":"machine-c","car\n`,
686
+ `${getDocText(project.file)}{"wall":"code-project@4","project":"${repoKey}","machine":"machine-c","car\n`,
639
687
  );
688
+ settleProject();
640
689
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
641
690
  });
642
691
 
643
692
  test('publishing compacts an oversized history before appending the new record', async () => {
644
693
  const before = getDocText(project.file);
645
694
  writeDocText(project.file, `${'obsolete\n'.repeat(132_000)}${before}`);
695
+ settleProject();
646
696
  assert.ok(Buffer.byteLength(getDocText(project.file), 'utf8') > 1024 * 1024);
647
697
 
648
698
  const repo = path.join(tempRoot, 'projects/app');
@@ -655,12 +705,13 @@ test('publishing compacts an oversized history before appending the new record',
655
705
  assert.equal(result.published, true);
656
706
  assert.ok(Buffer.byteLength(getDocText(project.file), 'utf8') < 1024 * 1024);
657
707
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
708
+ settleProject();
658
709
  });
659
710
 
660
711
  test('project stats name every project resource, binding, and publish counter', () => {
661
712
  const stats = wallStats();
662
713
  const row = stats.projects.find((entry) => entry.repoKey === repoKey);
663
- assert.equal(stats.contract, 'code-project@3');
714
+ assert.equal(stats.contract, 'code-project@4');
664
715
  assert.equal(row.resourceId, project.resourceId);
665
716
  assert.equal(row.replica.appliedVersion >= 0, true);
666
717
  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 = '') {
@@ -197,9 +197,10 @@ function readCheckpointValue(baseOid, value) {
197
197
  return { state: 'ready', indexDiff, worktreeDiff };
198
198
  }
199
199
 
200
- function checkpointBaseReady(card, reduced) {
200
+ function checkpointBaseReady(card, reduced, publication) {
201
201
  return Boolean(
202
202
  reduced
203
+ && publication?.current
203
204
  && reduced.baseOid === card.head?.oid
204
205
  && reduced.branch === card.head?.branch
205
206
  && typeof reduced.indexDiff === 'string'
@@ -225,7 +226,8 @@ async function publishPass(replicaId = null) {
225
226
  }
226
227
  try {
227
228
  const reduced = require('./reducer').readRepoState(repoKey);
228
- if (!checkpointBaseReady(card, reduced)) {
229
+ const wall = require('./wall');
230
+ if (!checkpointBaseReady(card, reduced, wall.publicationStatus(repoKey, reduced))) {
229
231
  run.waiting += 1;
230
232
  continue;
231
233
  }
@@ -243,6 +245,10 @@ async function publishPass(replicaId = null) {
243
245
  ...entry,
244
246
  objects: await require('./objects').bundleSourceFor(card.path, repoKey, entry.oid),
245
247
  })));
248
+ if (!wall.publicationStatus(repoKey, reduced).current) {
249
+ run.waiting += 1;
250
+ continue;
251
+ }
246
252
 
247
253
  // Exact equality means the working tree merely materialized the shared
248
254
  // checkpoint. Keep the writer's last transition because it remains part
@@ -410,6 +416,7 @@ module.exports = {
410
416
  MAX_INLINE_DIFF_BYTES,
411
417
  checkpointFile,
412
418
  checkpointStats,
419
+ checkpointValue,
413
420
  checkpointValueReady,
414
421
  parseCheckpoint,
415
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
  }
@@ -433,6 +468,21 @@ async function reduceCheckpoint(localCard, plan) {
433
468
  let indexTree = baseTree;
434
469
  let worktreeTree = baseTree;
435
470
  const conflicts = [];
471
+ if (plan.checkpointBaseConflict) {
472
+ return { state: 'waiting', reason: 'checkpoint-base-disagrees' };
473
+ }
474
+ if (plan.checkpointBase) {
475
+ const snapshot = readCheckpointValue(baseOid, plan.checkpointBase.value);
476
+ if (snapshot.state !== 'ready') {
477
+ return { state: 'waiting', reason: `checkpoint-base-${snapshot.state}` };
478
+ }
479
+ indexTree = await merge.treeOfPatch(localCard.path, baseOid, snapshot.indexDiff);
480
+ worktreeTree = await merge.treeOfPatch(
481
+ localCard.path,
482
+ indexTree,
483
+ snapshot.worktreeDiff,
484
+ );
485
+ }
436
486
 
437
487
  for (const entry of plan.parked || []) {
438
488
  const commonOid = await mergeBase(localCard.path, baseOid, entry.oid);
@@ -520,7 +570,7 @@ function captureIsOnRail(plan, captured, localMachine = machineId()) {
520
570
  );
521
571
  }
522
572
 
523
- async function materializeRepo(localCardInput, plan) {
573
+ async function materializeRepo(localCardInput, plan, appliedThrough = null) {
524
574
  const refreshed = localCardInput;
525
575
  const cardResult = await moveToWinningCard(refreshed, plan.card);
526
576
  if (cardResult.state !== 'ready') return cardResult;
@@ -587,6 +637,7 @@ async function materializeRepo(localCardInput, plan) {
587
637
  baseOid: plan.card.head.oid,
588
638
  cardVersion: plan.card.positionVersion,
589
639
  cardMutationId: plan.card.positionMutationId,
640
+ appliedThrough,
590
641
  checkpointRevision: reduced.checkpointRevision,
591
642
  indexDiff: reduced.indexDiff,
592
643
  worktreeDiff: reduced.worktreeDiff,
@@ -634,7 +685,9 @@ async function advanceIndex(worktreeRoot, observedTree, targetTree) {
634
685
  }
635
686
 
636
687
  function localCardMayMaterialize(publishedCard, publication) {
637
- return Boolean(publishedCard) || publication?.reason === 'shared-state-covered';
688
+ return Boolean(publishedCard)
689
+ || publication?.reason === 'shared-state-covered'
690
+ || publication?.reason === 'project-not-current';
638
691
  }
639
692
 
640
693
  function pathForRepo(localCard, project) {
@@ -691,7 +744,8 @@ async function runReducer() {
691
744
  service.counters.waiting += 1;
692
745
  continue;
693
746
  }
694
- const currentPlan = reduceWall(wall.readWall()).get(repoKey);
747
+ const receivedThrough = wall.publicationStatus(repoKey).receivedThrough;
748
+ const currentPlan = reduceWall(wall.readWall(repoKey)).get(repoKey);
695
749
  if (!currentPlan?.card) {
696
750
  const outcome = { state: 'waiting', reason: 'official-card-settling', moved: false };
697
751
  service.repos.set(repoKey, {
@@ -705,7 +759,7 @@ async function runReducer() {
705
759
  }
706
760
  const outcome = await withMaterialization(
707
761
  repoKey,
708
- () => materializeRepo(refreshed, currentPlan),
762
+ () => materializeRepo(refreshed, currentPlan, receivedThrough),
709
763
  );
710
764
  service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
711
765
  if (outcome.state === 'waiting') service.counters.waiting += 1;
@@ -762,11 +816,15 @@ function startWorkspaceReducer() {
762
816
  if (service.started) return;
763
817
  service.started = true;
764
818
  service.unsubscribe = subscribeStateEvents((event) => {
765
- const wallPaths = require('./wall').wallStats().paths;
819
+ const wallState = require('./wall').wallStats();
820
+ const wallPaths = wallState.paths;
821
+ const acknowledged = event.resource === 'shared-mutations'
822
+ && wallState.projects.some((project) => project.resourceId === event.sharedResourceId);
766
823
  if (
767
824
  event.resource === 'repo-cards'
768
825
  || event.resource === 'repo-checkpoints'
769
826
  || wallPaths.includes(event.id)
827
+ || acknowledged
770
828
  ) scheduleReduce();
771
829
  });
772
830
  service.interval = setInterval(scheduleReduce, REDUCE_INTERVAL_MS);
@@ -250,6 +250,40 @@ async function handleProjectUpdate(body, sendJson) {
250
250
  }
251
251
  }
252
252
 
253
+ // A removed managed workspace's folder moves here (same volume, instant
254
+ // rename) — leaving it in the workspaces root grows a graveyard of corpses
255
+ // that later same-name creates dodge with `-2`, `-3`… suffixes. Never a
256
+ // hard delete: the user empties the trash, not the runtime.
257
+ function workspaceTrashDir() {
258
+ return path.join(AMALGM_DIR, 'trash', 'workspaces');
259
+ }
260
+
261
+ async function trashManagedWorkspaceFolder(workspace) {
262
+ if (!workspace?.managed || !workspace.path) return null;
263
+
264
+ const workspaceRoot = getWorkspaceRoot();
265
+ let realTarget;
266
+ let realRoot;
267
+ try {
268
+ [realTarget, realRoot] = await Promise.all([
269
+ fs.promises.realpath(workspace.path),
270
+ fs.promises.realpath(workspaceRoot),
271
+ ]);
272
+ } catch {
273
+ return null; // Folder already gone — nothing to trash.
274
+ }
275
+ // Only folders strictly inside the workspaces root move; a registered
276
+ // path anywhere else is the user's own directory and is never touched.
277
+ if (realTarget === realRoot || !isWithin(realRoot, realTarget)) return null;
278
+
279
+ const trashRoot = workspaceTrashDir();
280
+ await fs.promises.mkdir(trashRoot, { recursive: true });
281
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
282
+ const destination = path.join(trashRoot, `${path.basename(realTarget)}-${stamp}`);
283
+ await fs.promises.rename(realTarget, destination);
284
+ return destination;
285
+ }
286
+
253
287
  async function handleProjectDelete(body, sendJson) {
254
288
  const identifier = body?.id || body?.path;
255
289
  if (!identifier || typeof identifier !== 'string') {
@@ -262,7 +296,15 @@ async function handleProjectDelete(body, sendJson) {
262
296
  if (workspace?.id) {
263
297
  require('../project-context/store').removeProjectContext(workspace.id, { source: 'workspace:delete' });
264
298
  }
265
- sendJson(200, { success: true, workspace });
299
+ let trashedTo = null;
300
+ try {
301
+ trashedTo = await trashManagedWorkspaceFolder(workspace);
302
+ } catch (error) {
303
+ // The registry row is gone either way; a stuck folder is reported,
304
+ // not fatal.
305
+ console.warn('[workspace] failed to trash managed folder:', error?.message || error);
306
+ }
307
+ sendJson(200, { success: true, workspace, trashedTo });
266
308
  } catch (error) {
267
309
  sendJson(500, {
268
310
  error: error instanceof Error ? error.message : 'Failed to remove workspace',
@@ -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;
@@ -128,6 +134,29 @@ function wallReplica(project) {
128
134
  return require('../state/replicas').getReplicaByResourceId(project.resourceId);
129
135
  }
130
136
 
137
+ function publicationStatus(repoKey, reduced = undefined) {
138
+ const project = projectForRepo(repoKey);
139
+ const replica = project ? wallReplica(project) : null;
140
+ const receivedThrough = replica?.appliedVersion ?? null;
141
+ const baseline = reduced === undefined
142
+ ? require('./reducer').readRepoState(repoKey)
143
+ : reduced;
144
+ const appliedThrough = Number.isInteger(baseline?.appliedThrough)
145
+ ? baseline.appliedThrough
146
+ : receivedThrough === 0 && !baseline ? 0 : null;
147
+ const pending = project
148
+ ? require('../state/mutations').listPendingCloudMutations(project.resourceId).length
149
+ : 0;
150
+ return {
151
+ receivedThrough,
152
+ appliedThrough,
153
+ pending,
154
+ current: receivedThrough !== null
155
+ && appliedThrough === receivedThrough
156
+ && pending === 0,
157
+ };
158
+ }
159
+
131
160
  function parseWallLine(line) {
132
161
  try {
133
162
  const entry = JSON.parse(line);
@@ -286,6 +315,46 @@ function hasObjectUpgrade(card, plan) {
286
315
  });
287
316
  }
288
317
 
318
+ function checkpointBaseFor(card, baseline, project, shared = null) {
319
+ if (
320
+ !baseline
321
+ || baseline.baseOid !== card.head?.oid
322
+ || baseline.branch !== card.head?.branch
323
+ || !Number.isInteger(baseline.appliedThrough)
324
+ || baseline.appliedThrough < 0
325
+ ) return null;
326
+ const value = checkpointValue(
327
+ machineId(),
328
+ project.repoKey,
329
+ card.head.oid,
330
+ baseline.indexDiff || '',
331
+ baseline.worktreeDiff || '',
332
+ );
333
+ const reusableThrough = shared
334
+ && shared.baseOid === card.head.oid
335
+ && shared.branch === card.head.branch
336
+ && shared.value?.revision === value.revision
337
+ && Number.isInteger(shared.through)
338
+ && shared.through >= 0;
339
+ return {
340
+ checkpoint: CHECKPOINT_CONTRACT,
341
+ baseOid: card.head.oid,
342
+ branch: card.head.branch,
343
+ through: reusableThrough ? shared.through : baseline.appliedThrough,
344
+ value,
345
+ };
346
+ }
347
+
348
+ function hasCheckpointBaseUpgrade(card, plan, baseline, project) {
349
+ const shared = plan?.card?.checkpointBase;
350
+ const candidate = checkpointBaseFor(card, baseline, project, shared);
351
+ return Boolean(shared && candidate) && (
352
+ candidate.through === shared.through
353
+ && candidate.value?.revision === shared.value?.revision
354
+ && JSON.stringify(candidate.value) !== JSON.stringify(shared.value)
355
+ );
356
+ }
357
+
289
358
  function validVersion(value) {
290
359
  return Number.isInteger(value) && value > 0;
291
360
  }
@@ -420,6 +489,12 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
420
489
  && checkpoint.branch === card.head?.branch
421
490
  ? checkpoint
422
491
  : null;
492
+ const checkpointBase = checkpointBaseFor(
493
+ card,
494
+ baseline,
495
+ project,
496
+ sharedPlan?.card?.checkpointBase,
497
+ );
423
498
  const sameBase = previous?.head?.oid === card.head?.oid
424
499
  && previous?.head?.branch === card.head?.branch;
425
500
  const history = sameBase && Array.isArray(previous?.checkpoints)
@@ -430,7 +505,10 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
430
505
  entry.mutationId,
431
506
  entry.version,
432
507
  ),
433
- }))
508
+ })).filter((entry) => (
509
+ validVersion(entry.version)
510
+ && (!checkpointBase || entry.version > checkpointBase.through)
511
+ ))
434
512
  : [];
435
513
  const last = history.at(-1);
436
514
  const checkpoints = currentCheckpoint
@@ -450,6 +528,7 @@ function stampCards(cards, previousCards, sharedPlan, baseline, mutationId, proj
450
528
  mutationId,
451
529
  project.resourceId,
452
530
  ),
531
+ ...(checkpointBase ? { checkpointBase } : {}),
453
532
  ...(checkpoints.length ? { checkpoints } : {}),
454
533
  };
455
534
  });
@@ -526,7 +605,8 @@ function publishProject(project, rawCards) {
526
605
  const checkpoint = readCheckpoint(machineId(), project.repoKey);
527
606
  const previousCard = previousCards.find((entry) => entry.replica === card.replica);
528
607
  return hasLocalContribution(card, sharedPlan, baseline, checkpoint, previousCard)
529
- || hasObjectUpgrade(card, sharedPlan);
608
+ || hasObjectUpgrade(card, sharedPlan)
609
+ || hasCheckpointBaseUpgrade(card, sharedPlan, baseline, project);
530
610
  });
531
611
  if (!removed && !contributes) {
532
612
  service.counters.publishSkips += 1;
@@ -555,6 +635,16 @@ function publishProject(project, rawCards) {
555
635
  service.counters.publishSkips += 1;
556
636
  return { published: false, reason: 'unchanged', repoKey: project.repoKey };
557
637
  }
638
+ const status = publicationStatus(project.repoKey, baseline);
639
+ if (!status.current) {
640
+ service.counters.publishSkips += 1;
641
+ return {
642
+ published: false,
643
+ reason: 'project-not-current',
644
+ repoKey: project.repoKey,
645
+ ...status,
646
+ };
647
+ }
558
648
 
559
649
  const separator = current && !current.endsWith('\n') ? '\n' : '';
560
650
  if (Buffer.byteLength(`${current}${separator}${mine}\n`, 'utf8') > COMPACT_AT_BYTES) {
@@ -585,16 +675,33 @@ function publishProject(project, rawCards) {
585
675
  };
586
676
  }
587
677
 
678
+ function publishSelected(selected, allCards, publisher = publishProject) {
679
+ return selected.map((project) => {
680
+ try {
681
+ return publisher(
682
+ project,
683
+ allCards.filter((card) => (
684
+ repoKeyOfCard(card) === project.repoKey
685
+ && isCommittedCard(card)
686
+ )),
687
+ );
688
+ } catch (error) {
689
+ service.counters.publishFailures += 1;
690
+ console.warn(`[CodeProject] ${project.repoKey} publish failed:`, error.message);
691
+ return {
692
+ published: false,
693
+ reason: 'publish-failed',
694
+ repoKey: project.repoKey,
695
+ error: error.message,
696
+ };
697
+ }
698
+ });
699
+ }
700
+
588
701
  function publishWall(repoKey = null) {
589
702
  const allCards = listRepoCards();
590
703
  const selected = repoKey ? [projectForRepo(repoKey)].filter(Boolean) : projects();
591
- const results = selected.map((project) => publishProject(
592
- project,
593
- allCards.filter((card) => (
594
- repoKeyOfCard(card) === project.repoKey
595
- && isCommittedCard(card)
596
- )),
597
- ));
704
+ const results = publishSelected(selected, allCards);
598
705
  if (repoKey) return results[0] || { published: false, reason: 'project-missing', repoKey };
599
706
  return {
600
707
  published: results.some((result) => result.published),
@@ -755,6 +862,7 @@ function wallStats() {
755
862
  }
756
863
 
757
864
  module.exports = {
865
+ _test: { publishSelected },
758
866
  WALL_CONTRACT,
759
867
  bindProject,
760
868
  compactWallText,
@@ -763,6 +871,7 @@ module.exports = {
763
871
  projectFile,
764
872
  projectForRepo,
765
873
  projectResourceId,
874
+ publicationStatus,
766
875
  publishedCardFor,
767
876
  publishWall,
768
877
  readWall,