amalgm 0.1.216 → 0.1.217

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.217",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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 = {
@@ -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,
@@ -360,8 +364,9 @@ test('one materializer converges to the reduced checkpoint and later removes a r
360
364
  transition('machine-b', 3, oid, patchA, `${patchA}${patchB}`),
361
365
  ],
362
366
  };
363
- const applied = await materializeRepo(localCard, first);
367
+ const applied = await materializeRepo(localCard, first, 3);
364
368
  assert.equal(applied.state, 'in-sync');
369
+ assert.equal(readRepoState('root:test').appliedThrough, 3);
365
370
  assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a1\n');
366
371
  assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
367
372
 
@@ -374,8 +379,9 @@ test('one materializer converges to the reduced checkpoint and later removes a r
374
379
  transition('machine-a', 4, oid, combined, patchB),
375
380
  ],
376
381
  };
377
- const reverted = await materializeRepo(localCard, second);
382
+ const reverted = await materializeRepo(localCard, second, 4);
378
383
  assert.equal(reverted.state, 'in-sync');
384
+ assert.equal(readRepoState('root:test').appliedThrough, 4);
379
385
  assert.equal(fs.readFileSync(path.join(repo, 'a.txt'), 'utf8'), 'a0\n');
380
386
  assert.equal(fs.readFileSync(path.join(repo, 'b.txt'), 'utf8'), 'b1\n');
381
387
  });
@@ -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,7 +25,10 @@ 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');
@@ -48,6 +52,29 @@ const {
48
52
 
49
53
  let repoKey;
50
54
  let project;
55
+ let nextCloudVersion = 1;
56
+
57
+ function markReducerApplied(version) {
58
+ const digest = crypto.createHash('sha256').update(repoKey).digest('hex').slice(0, 24);
59
+ const file = path.join(process.env.AMALGM_DIR, 'state', 'repo-reducer', `${digest}.json`);
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, `${JSON.stringify({ repoKey, appliedThrough: version })}\n`);
62
+ }
63
+
64
+ function settleProject() {
65
+ const versions = [];
66
+ for (const mutation of listPendingCloudMutations(project.resourceId)) {
67
+ const version = nextCloudVersion;
68
+ nextCloudVersion += 1;
69
+ acknowledgeCloudMutation(project.resourceId, mutation.mutationId, {
70
+ version,
71
+ committedAt: `2026-07-23T00:00:${String(version).padStart(2, '0')}.000Z`,
72
+ });
73
+ versions.push(version);
74
+ }
75
+ if (versions.length > 0) markReducerApplied(versions.at(-1));
76
+ return versions;
77
+ }
51
78
 
52
79
  test.after(() => {
53
80
  closeLocalDb();
@@ -317,10 +344,7 @@ test('a repo project appends immutable machine records', async () => {
317
344
  false,
318
345
  );
319
346
 
320
- acknowledgeCloudMutation(project.resourceId, first.mutationId, {
321
- version: 1,
322
- committedAt: '2026-07-23T00:00:00.000Z',
323
- });
347
+ assert.deepEqual(settleProject(), [1]);
324
348
  const sealed = publishWall(repoKey);
325
349
  assert.equal(sealed.published, false);
326
350
  assert.equal(sealed.reason, 'shared-state-covered');
@@ -381,10 +405,7 @@ test('checkpoint transitions append until the next card and carry their official
381
405
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints.length, 1);
382
406
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].mutationId, first.mutationId);
383
407
 
384
- acknowledgeCloudMutation(project.resourceId, first.mutationId, {
385
- version: 2,
386
- committedAt: '2026-07-23T00:00:02.000Z',
387
- });
408
+ assert.deepEqual(settleProject(), [2]);
388
409
  assert.equal(readWall(repoKey)[0].cards[0].checkpoints[0].version, 2);
389
410
  assert.equal(publishWall(repoKey).published, false);
390
411
  const checkpointLines = getDocText(project.file).trim().split('\n');
@@ -404,6 +425,7 @@ test('checkpoint transitions append until the next card and carry their official
404
425
  const history = readWall(repoKey)[0].cards[0].checkpoints;
405
426
  assert.equal(history.length, 2);
406
427
  assert.deepEqual(history.map((entry) => entry.mutationId), [first.mutationId, second.mutationId]);
428
+ assert.deepEqual(settleProject(), [3]);
407
429
  });
408
430
 
409
431
  test('side branches keep one rail operation while the checked-out card advances', async () => {
@@ -429,10 +451,7 @@ test('side branches keep one rail operation while the checked-out card advances'
429
451
  assert.equal(card.positionMutationId, before.positionMutationId);
430
452
  assert.equal(ref.mutationId, created.mutationId);
431
453
 
432
- acknowledgeCloudMutation(project.resourceId, created.mutationId, {
433
- version: 3,
434
- committedAt: '2026-07-23T00:00:04.000Z',
435
- });
454
+ assert.deepEqual(settleProject(), [4]);
436
455
  await run(repo, 'add', 'new.txt');
437
456
  await run(repo, 'commit', '-m', 'advance without moving side branch');
438
457
  await refreshRepoCards();
@@ -442,10 +461,7 @@ test('side branches keep one rail operation while the checked-out card advances'
442
461
  assert.equal(card.positionMutationId, advanced.mutationId);
443
462
  assert.equal(card.branches.find((entry) => entry.name === branch).mutationId, created.mutationId);
444
463
 
445
- acknowledgeCloudMutation(project.resourceId, advanced.mutationId, {
446
- version: 4,
447
- committedAt: '2026-07-23T00:00:05.000Z',
448
- });
464
+ assert.deepEqual(settleProject(), [5]);
449
465
  await run(repo, 'branch', '-D', branch);
450
466
  await refreshRepoCards();
451
467
  const deleted = publishWall(repoKey);
@@ -453,6 +469,7 @@ test('side branches keep one rail operation while the checked-out card advances'
453
469
  card = readWall(repoKey)[0].cards[0];
454
470
  assert.equal(card.branches.find((entry) => entry.name === branch).oid, null);
455
471
  assert.equal(card.branches.find((entry) => entry.name === branch).mutationId, deleted.mutationId);
472
+ assert.deepEqual(settleProject(), [6]);
456
473
  });
457
474
 
458
475
  test('peer records and earlier local history survive later publishes', async () => {
@@ -466,6 +483,7 @@ test('peer records and earlier local history survive later publishes', async ()
466
483
  cards: [{ card: 'repo-card@2', machine: 'machine-b', replica: 'rpl_peer', path: '/work/app', head: { branch: 'preview' } }],
467
484
  });
468
485
  writeDocText(project.file, `${getDocText(project.file)}${peerLine}\n`);
486
+ assert.deepEqual(settleProject(), [7]);
469
487
 
470
488
  const repo = path.join(tempRoot, 'projects/app');
471
489
  fs.writeFileSync(path.join(repo, 'another.txt'), 'y\n');
@@ -480,6 +498,7 @@ test('peer records and earlier local history survive later publishes', async ()
480
498
  for (const line of beforeLines) assert.ok(lines.includes(line));
481
499
  assert.ok(lines.includes(peerLine));
482
500
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
501
+ assert.deepEqual(settleProject(), [8]);
483
502
  });
484
503
 
485
504
  test('three concurrent append records stay whole under Yjs ordering', () => {
@@ -637,12 +656,14 @@ test('a torn line is ignored by readers, never fatal', () => {
637
656
  project.file,
638
657
  `${getDocText(project.file)}{"wall":"code-project@3","project":"${repoKey}","machine":"machine-c","car\n`,
639
658
  );
659
+ settleProject();
640
660
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
641
661
  });
642
662
 
643
663
  test('publishing compacts an oversized history before appending the new record', async () => {
644
664
  const before = getDocText(project.file);
645
665
  writeDocText(project.file, `${'obsolete\n'.repeat(132_000)}${before}`);
666
+ settleProject();
646
667
  assert.ok(Buffer.byteLength(getDocText(project.file), 'utf8') > 1024 * 1024);
647
668
 
648
669
  const repo = path.join(tempRoot, 'projects/app');
@@ -655,6 +676,7 @@ test('publishing compacts an oversized history before appending the new record',
655
676
  assert.equal(result.published, true);
656
677
  assert.ok(Buffer.byteLength(getDocText(project.file), 'utf8') < 1024 * 1024);
657
678
  assert.deepEqual(readWall(repoKey).map((entry) => entry.machine).sort(), ['machine-a', 'machine-b']);
679
+ settleProject();
658
680
  });
659
681
 
660
682
  test('project stats name every project resource, binding, and publish counter', () => {
@@ -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
@@ -520,7 +520,7 @@ function captureIsOnRail(plan, captured, localMachine = machineId()) {
520
520
  );
521
521
  }
522
522
 
523
- async function materializeRepo(localCardInput, plan) {
523
+ async function materializeRepo(localCardInput, plan, appliedThrough = null) {
524
524
  const refreshed = localCardInput;
525
525
  const cardResult = await moveToWinningCard(refreshed, plan.card);
526
526
  if (cardResult.state !== 'ready') return cardResult;
@@ -587,6 +587,7 @@ async function materializeRepo(localCardInput, plan) {
587
587
  baseOid: plan.card.head.oid,
588
588
  cardVersion: plan.card.positionVersion,
589
589
  cardMutationId: plan.card.positionMutationId,
590
+ appliedThrough,
590
591
  checkpointRevision: reduced.checkpointRevision,
591
592
  indexDiff: reduced.indexDiff,
592
593
  worktreeDiff: reduced.worktreeDiff,
@@ -634,7 +635,9 @@ async function advanceIndex(worktreeRoot, observedTree, targetTree) {
634
635
  }
635
636
 
636
637
  function localCardMayMaterialize(publishedCard, publication) {
637
- return Boolean(publishedCard) || publication?.reason === 'shared-state-covered';
638
+ return Boolean(publishedCard)
639
+ || publication?.reason === 'shared-state-covered'
640
+ || publication?.reason === 'project-not-current';
638
641
  }
639
642
 
640
643
  function pathForRepo(localCard, project) {
@@ -691,7 +694,8 @@ async function runReducer() {
691
694
  service.counters.waiting += 1;
692
695
  continue;
693
696
  }
694
- const currentPlan = reduceWall(wall.readWall()).get(repoKey);
697
+ const receivedThrough = wall.publicationStatus(repoKey).receivedThrough;
698
+ const currentPlan = reduceWall(wall.readWall(repoKey)).get(repoKey);
695
699
  if (!currentPlan?.card) {
696
700
  const outcome = { state: 'waiting', reason: 'official-card-settling', moved: false };
697
701
  service.repos.set(repoKey, {
@@ -705,7 +709,7 @@ async function runReducer() {
705
709
  }
706
710
  const outcome = await withMaterialization(
707
711
  repoKey,
708
- () => materializeRepo(refreshed, currentPlan),
712
+ () => materializeRepo(refreshed, currentPlan, receivedThrough),
709
713
  );
710
714
  service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
711
715
  if (outcome.state === 'waiting') service.counters.waiting += 1;
@@ -762,11 +766,15 @@ function startWorkspaceReducer() {
762
766
  if (service.started) return;
763
767
  service.started = true;
764
768
  service.unsubscribe = subscribeStateEvents((event) => {
765
- const wallPaths = require('./wall').wallStats().paths;
769
+ const wallState = require('./wall').wallStats();
770
+ const wallPaths = wallState.paths;
771
+ const acknowledged = event.resource === 'shared-mutations'
772
+ && wallState.projects.some((project) => project.resourceId === event.sharedResourceId);
766
773
  if (
767
774
  event.resource === 'repo-cards'
768
775
  || event.resource === 'repo-checkpoints'
769
776
  || wallPaths.includes(event.id)
777
+ || acknowledged
770
778
  ) scheduleReduce();
771
779
  });
772
780
  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',
@@ -128,6 +128,29 @@ function wallReplica(project) {
128
128
  return require('../state/replicas').getReplicaByResourceId(project.resourceId);
129
129
  }
130
130
 
131
+ function publicationStatus(repoKey, reduced = undefined) {
132
+ const project = projectForRepo(repoKey);
133
+ const replica = project ? wallReplica(project) : null;
134
+ const receivedThrough = replica?.appliedVersion ?? null;
135
+ const baseline = reduced === undefined
136
+ ? require('./reducer').readRepoState(repoKey)
137
+ : reduced;
138
+ const appliedThrough = Number.isInteger(baseline?.appliedThrough)
139
+ ? baseline.appliedThrough
140
+ : receivedThrough === 0 && !baseline ? 0 : null;
141
+ const pending = project
142
+ ? require('../state/mutations').listPendingCloudMutations(project.resourceId).length
143
+ : 0;
144
+ return {
145
+ receivedThrough,
146
+ appliedThrough,
147
+ pending,
148
+ current: receivedThrough !== null
149
+ && appliedThrough === receivedThrough
150
+ && pending === 0,
151
+ };
152
+ }
153
+
131
154
  function parseWallLine(line) {
132
155
  try {
133
156
  const entry = JSON.parse(line);
@@ -555,6 +578,16 @@ function publishProject(project, rawCards) {
555
578
  service.counters.publishSkips += 1;
556
579
  return { published: false, reason: 'unchanged', repoKey: project.repoKey };
557
580
  }
581
+ const status = publicationStatus(project.repoKey, baseline);
582
+ if (!status.current) {
583
+ service.counters.publishSkips += 1;
584
+ return {
585
+ published: false,
586
+ reason: 'project-not-current',
587
+ repoKey: project.repoKey,
588
+ ...status,
589
+ };
590
+ }
558
591
 
559
592
  const separator = current && !current.endsWith('\n') ? '\n' : '';
560
593
  if (Buffer.byteLength(`${current}${separator}${mine}\n`, 'utf8') > COMPACT_AT_BYTES) {
@@ -763,6 +796,7 @@ module.exports = {
763
796
  projectFile,
764
797
  projectForRepo,
765
798
  projectResourceId,
799
+ publicationStatus,
766
800
  publishedCardFor,
767
801
  publishWall,
768
802
  readWall,