amalgm 0.1.215 → 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.215",
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),
@@ -0,0 +1,312 @@
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-publication-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 = 'machine-a';
13
+ process.env.AMALGM_DEVICE_ID = 'device-a';
14
+ fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
15
+ fs.writeFileSync(
16
+ path.join(process.env.AMALGM_DIR, 'computer.json'),
17
+ JSON.stringify({
18
+ computer_id: 'machine-a',
19
+ user_id: '11111111-1111-4111-8111-111111111111',
20
+ }),
21
+ );
22
+
23
+ const { closeLocalDb } = require('../state/db');
24
+ const {
25
+ _test: checkpointTest,
26
+ CHECKPOINT_CONTRACT,
27
+ checkpointFile,
28
+ sameTransition,
29
+ } = require('../workspace/checkpoint');
30
+ const { parkedForState } = require('../workspace/reducer');
31
+ const {
32
+ compactWallText,
33
+ hasLocalContribution,
34
+ publicationStatus,
35
+ stampCards,
36
+ } = require('../workspace/wall');
37
+
38
+ test.after(() => {
39
+ closeLocalDb();
40
+ fs.rmSync(tempRoot, { recursive: true, force: true });
41
+ });
42
+
43
+ const rules = {
44
+ card: {
45
+ identity: 'branch-and-commit-content',
46
+ ownership: 'originator-publishes',
47
+ liveness: 'shared-wall-outbox',
48
+ gating: 'defines-winning-card',
49
+ },
50
+ branch: {
51
+ identity: 'ref-name-and-commit-content',
52
+ ownership: 'originator-publishes',
53
+ liveness: 'shared-wall-outbox',
54
+ gating: 'per-ref-rail-order',
55
+ },
56
+ checkpoint: {
57
+ identity: 'from-and-to-content',
58
+ ownership: 'originator-publishes',
59
+ liveness: 'shared-wall-outbox',
60
+ gating: 'winning-card-only',
61
+ },
62
+ parked: {
63
+ identity: 'commit-and-object-content',
64
+ ownership: 'holder-publishes-until-acknowledged',
65
+ liveness: 'shared-wall-outbox',
66
+ gating: 'inside-winning-card-checkpoint',
67
+ },
68
+ compaction: {
69
+ identity: 'derived-deletion',
70
+ ownership: 'any-machine-may-compact',
71
+ liveness: 'not-semantic-state',
72
+ gating: 'commutative-with-writers',
73
+ },
74
+ };
75
+
76
+ test('the publication model has one explicit rule for every value and property', () => {
77
+ assert.deepEqual(Object.keys(rules), ['card', 'branch', 'checkpoint', 'parked', 'compaction']);
78
+ for (const rule of Object.values(rules)) {
79
+ assert.deepEqual(Object.keys(rule), ['identity', 'ownership', 'liveness', 'gating']);
80
+ assert.equal(Object.values(rule).every(Boolean), true);
81
+ }
82
+ assert.deepEqual(
83
+ new Set(['card', 'branch', 'checkpoint', 'parked'].map((name) => rules[name].liveness)),
84
+ new Set(['shared-wall-outbox']),
85
+ );
86
+ });
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
+
125
+ test('server rail numbers do not change checkpoint or parked identity', () => {
126
+ const oid = 'a'.repeat(40);
127
+ const transition = {
128
+ checkpoint: CHECKPOINT_CONTRACT,
129
+ machine: 'machine-a',
130
+ repoKey: 'root:test',
131
+ branch: 'main',
132
+ baseOid: oid,
133
+ from: { revision: null, indexDiff: '', worktreeDiff: '' },
134
+ to: { revision: 'work-a', indexDiff: '', worktreeDiff: 'patch-a' },
135
+ parked: [{
136
+ oid: 'b'.repeat(40),
137
+ ref: `refs/amalgm/parked/${'b'.repeat(40)}`,
138
+ objects: { kind: 'bundle', checksum: 'c'.repeat(64) },
139
+ }],
140
+ };
141
+ assert.equal(sameTransition(transition, {
142
+ ...transition,
143
+ mutationId: 'mut_server_metadata',
144
+ version: 41,
145
+ parked: transition.parked.map((entry) => ({ ...entry, version: 41 })),
146
+ }), true);
147
+ assert.equal(sameTransition(transition, {
148
+ ...transition,
149
+ to: { ...transition.to, revision: 'different-work' },
150
+ }), false);
151
+ assert.equal(sameTransition(transition, {
152
+ ...transition,
153
+ parked: transition.parked.map((entry) => ({
154
+ ...entry,
155
+ objects: { ...entry.objects, checksum: 'd'.repeat(64) },
156
+ })),
157
+ }), false);
158
+ });
159
+
160
+ test('receivers stay silent and only unacknowledged local parked work republishes', () => {
161
+ const head = 'a'.repeat(40);
162
+ const local = {
163
+ head: { branch: 'main', oid: head, detached: false },
164
+ refs: [{ name: 'main', oid: head }],
165
+ };
166
+ const transition = {
167
+ checkpoint: CHECKPOINT_CONTRACT,
168
+ machine: 'machine-a',
169
+ repoKey: 'root:test',
170
+ branch: 'main',
171
+ baseOid: head,
172
+ from: { revision: null, indexDiff: '', worktreeDiff: '' },
173
+ to: { revision: 'shared-work', indexDiff: '', worktreeDiff: 'patch' },
174
+ parked: [],
175
+ };
176
+ const plan = {
177
+ card: { head: { branch: 'main', oid: head, detached: false } },
178
+ branches: [{ name: 'main', oid: head }],
179
+ transitions: [{ ...transition, version: 9 }],
180
+ };
181
+ const baseline = {
182
+ branch: 'main',
183
+ baseOid: head,
184
+ branches: [{ name: 'main', oid: head }],
185
+ };
186
+ assert.equal(hasLocalContribution(local, plan, baseline, transition), false);
187
+
188
+ const localParked = { oid: 'b'.repeat(40), objects: { kind: 'bundle', checksum: 'local' } };
189
+ const unsentParked = { oid: 'c'.repeat(40), objects: { kind: 'bundle', checksum: 'unsent' } };
190
+ const peerParked = {
191
+ oid: 'd'.repeat(40),
192
+ objects: { kind: 'bundle', checksum: 'peer' },
193
+ version: 9,
194
+ };
195
+ assert.deepEqual(
196
+ checkpointTest.localParkedEntries(
197
+ { ...transition, parked: [localParked] },
198
+ { parked: [peerParked, unsentParked] },
199
+ local,
200
+ ),
201
+ [localParked, unsentParked],
202
+ );
203
+ assert.deepEqual(parkedForState([localParked], [], []), [localParked]);
204
+ assert.deepEqual(
205
+ parkedForState([localParked], [{ ...localParked, version: 10 }], []),
206
+ [{ ...localParked, version: 10 }],
207
+ );
208
+ assert.deepEqual(parkedForState([{ ...localParked, version: 10 }], [], []), []);
209
+ });
210
+
211
+ test('card, branch, checkpoint, and parked work ride one wall mutation', () => {
212
+ const repoKey = 'https://github.com/amalgm-inc/publication-invariant-test';
213
+ const head = 'a'.repeat(40);
214
+ const parkedOid = 'b'.repeat(40);
215
+ const checkpoint = {
216
+ checkpoint: CHECKPOINT_CONTRACT,
217
+ machine: 'machine-a',
218
+ repoKey,
219
+ branch: 'main',
220
+ baseOid: head,
221
+ from: { revision: null, indexDiff: '', worktreeDiff: '' },
222
+ to: { revision: 'work-a', indexDiff: '', worktreeDiff: 'patch-a' },
223
+ parked: [{
224
+ oid: parkedOid,
225
+ ref: `refs/amalgm/parked/${parkedOid}`,
226
+ objects: { kind: 'bundle', checksum: 'c'.repeat(64) },
227
+ }],
228
+ };
229
+ const file = checkpointFile('machine-a', repoKey);
230
+ fs.mkdirSync(path.dirname(file), { recursive: true });
231
+ fs.writeFileSync(file, `${JSON.stringify(checkpoint)}\n`);
232
+
233
+ const mutationId = 'mut_one_shared_wall_outbox';
234
+ const stamped = stampCards([{
235
+ card: 'repo-card@2',
236
+ machine: 'machine-a',
237
+ replica: 'rpl_publication_test',
238
+ kind: 'primary',
239
+ repo: { remote: repoKey },
240
+ head: { branch: 'main', oid: head, detached: false },
241
+ refs: [
242
+ { name: 'main', oid: head },
243
+ { name: 'feature', oid: head },
244
+ ],
245
+ health: 'healthy',
246
+ }], [], null, null, mutationId, {
247
+ repoKey,
248
+ resourceId: 'shr_publication_invariant_test',
249
+ })[0];
250
+
251
+ assert.equal(stamped.positionMutationId, mutationId);
252
+ assert.equal(stamped.branches.every((ref) => ref.mutationId === mutationId), true);
253
+ assert.equal(stamped.checkpoints[0].mutationId, mutationId);
254
+ assert.equal(stamped.checkpoints[0].parked[0].oid, parkedOid);
255
+ });
256
+
257
+ test('two concurrent compacting publishers converge without losing either latest value', () => {
258
+ const Y = require('yjs');
259
+ const repoKey = 'root:compaction-collision';
260
+ const line = (machine, value) => JSON.stringify({
261
+ wall: 'code-project@3',
262
+ project: repoKey,
263
+ machine,
264
+ mutationId: `mut_${machine}_${value}`,
265
+ cards: [{ value }],
266
+ });
267
+ const baseText = [
268
+ line('machine-a', 'old-a'),
269
+ line('machine-b', 'old-b'),
270
+ line('machine-a', 'current-a'),
271
+ line('machine-b', 'current-b'),
272
+ '',
273
+ ].join('\n');
274
+ const compacted = compactWallText(baseText, repoKey);
275
+ const removedBytes = baseText.length - compacted.length;
276
+ assert.ok(removedBytes > 0);
277
+
278
+ const base = new Y.Doc();
279
+ base.getText('content').insert(0, baseText);
280
+ const baseUpdate = Y.encodeStateAsUpdate(base);
281
+ const baseVector = Y.encodeStateVector(base);
282
+ const publisherUpdate = (machine, value) => {
283
+ const writer = new Y.Doc();
284
+ Y.applyUpdate(writer, baseUpdate);
285
+ const text = writer.getText('content');
286
+ text.delete(0, removedBytes);
287
+ text.insert(text.length, `${line(machine, value)}\n`);
288
+ return Y.encodeStateAsUpdate(writer, baseVector);
289
+ };
290
+ const updateA = publisherUpdate('machine-a', 'latest-a');
291
+ const updateB = publisherUpdate('machine-b', 'latest-b');
292
+ const replicas = [
293
+ [updateA, updateB],
294
+ [updateB, updateA],
295
+ ].map((order) => {
296
+ const replica = new Y.Doc();
297
+ Y.applyUpdate(replica, baseUpdate);
298
+ for (const update of order) Y.applyUpdate(replica, update);
299
+ return replica.getText('content').toString();
300
+ });
301
+
302
+ assert.equal(replicas[0], replicas[1]);
303
+ const latest = new Map();
304
+ for (const raw of replicas[0].trim().split('\n')) {
305
+ const entry = JSON.parse(raw);
306
+ latest.set(entry.machine, entry.cards[0].value);
307
+ }
308
+ assert.deepEqual(Object.fromEntries(latest), {
309
+ 'machine-a': 'latest-a',
310
+ 'machine-b': 'latest-b',
311
+ });
312
+ });
@@ -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
  });
@@ -544,7 +550,9 @@ test('a losing commit is parked, the winning card lands exactly, and losing work
544
550
 
545
551
  assert.equal(folded.state, 'in-sync');
546
552
  assert.deepEqual(readRepoState('root:test').parked, []);
547
- assert.equal((await git(repo, 'rev-parse', `refs/amalgm/parked/${losingOid}`)).trim(), losingOid);
553
+ await assert.rejects(
554
+ git(repo, 'rev-parse', '--verify', `refs/amalgm/parked/${losingOid}`),
555
+ );
548
556
  });
549
557
 
550
558
  test('rail-parked peer commits become one staged checkpoint on every machine', async () => {
@@ -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
@@ -360,7 +360,8 @@ async function moveToWinningCard(localCard, targetCard) {
360
360
 
361
361
  async function materializeParked(localCard, entries) {
362
362
  const waiting = [];
363
- for (const entry of uniqueParked(entries)) {
363
+ const active = uniqueParked(entries);
364
+ for (const entry of active) {
364
365
  if (!(await hasCommit(localCard.path, entry.oid))) {
365
366
  if (entry.objects?.kind === 'bundle') {
366
367
  const imported = await require('./objects').importFromLocator(localCard.path, entry.objects);
@@ -375,6 +376,10 @@ async function materializeParked(localCard, entries) {
375
376
  }
376
377
  await transition.parkCommit(localCard.path, entry.oid);
377
378
  }
379
+ await transition.pruneParkedCommits(
380
+ localCard.path,
381
+ new Set(active.map((entry) => entry.oid)),
382
+ );
378
383
  return waiting;
379
384
  }
380
385
 
@@ -515,7 +520,7 @@ function captureIsOnRail(plan, captured, localMachine = machineId()) {
515
520
  );
516
521
  }
517
522
 
518
- async function materializeRepo(localCardInput, plan) {
523
+ async function materializeRepo(localCardInput, plan, appliedThrough = null) {
519
524
  const refreshed = localCardInput;
520
525
  const cardResult = await moveToWinningCard(refreshed, plan.card);
521
526
  if (cardResult.state !== 'ready') return cardResult;
@@ -582,6 +587,7 @@ async function materializeRepo(localCardInput, plan) {
582
587
  baseOid: plan.card.head.oid,
583
588
  cardVersion: plan.card.positionVersion,
584
589
  cardMutationId: plan.card.positionMutationId,
590
+ appliedThrough,
585
591
  checkpointRevision: reduced.checkpointRevision,
586
592
  indexDiff: reduced.indexDiff,
587
593
  worktreeDiff: reduced.worktreeDiff,
@@ -629,7 +635,9 @@ async function advanceIndex(worktreeRoot, observedTree, targetTree) {
629
635
  }
630
636
 
631
637
  function localCardMayMaterialize(publishedCard, publication) {
632
- return Boolean(publishedCard) || publication?.reason === 'shared-state-covered';
638
+ return Boolean(publishedCard)
639
+ || publication?.reason === 'shared-state-covered'
640
+ || publication?.reason === 'project-not-current';
633
641
  }
634
642
 
635
643
  function pathForRepo(localCard, project) {
@@ -686,7 +694,8 @@ async function runReducer() {
686
694
  service.counters.waiting += 1;
687
695
  continue;
688
696
  }
689
- const currentPlan = reduceWall(wall.readWall()).get(repoKey);
697
+ const receivedThrough = wall.publicationStatus(repoKey).receivedThrough;
698
+ const currentPlan = reduceWall(wall.readWall(repoKey)).get(repoKey);
690
699
  if (!currentPlan?.card) {
691
700
  const outcome = { state: 'waiting', reason: 'official-card-settling', moved: false };
692
701
  service.repos.set(repoKey, {
@@ -700,7 +709,7 @@ async function runReducer() {
700
709
  }
701
710
  const outcome = await withMaterialization(
702
711
  repoKey,
703
- () => materializeRepo(refreshed, currentPlan),
712
+ () => materializeRepo(refreshed, currentPlan, receivedThrough),
704
713
  );
705
714
  service.repos.set(repoKey, { repoKey, path: local.path, ...outcome, at: new Date().toISOString() });
706
715
  if (outcome.state === 'waiting') service.counters.waiting += 1;
@@ -757,11 +766,15 @@ function startWorkspaceReducer() {
757
766
  if (service.started) return;
758
767
  service.started = true;
759
768
  service.unsubscribe = subscribeStateEvents((event) => {
760
- 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);
761
773
  if (
762
774
  event.resource === 'repo-cards'
763
775
  || event.resource === 'repo-checkpoints'
764
776
  || wallPaths.includes(event.id)
777
+ || acknowledged
765
778
  ) scheduleReduce();
766
779
  });
767
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',
@@ -236,6 +236,20 @@ function parkCommit(worktreeRoot, oid) {
236
236
  ]).then(() => ref);
237
237
  }
238
238
 
239
+ /** Delete internal parked refs that are no longer part of shared checkpoint state. */
240
+ async function pruneParkedCommits(worktreeRoot, keepOids) {
241
+ const output = await run('list-parked-commits', worktreeRoot, [
242
+ '-C', worktreeRoot, 'for-each-ref', '--format=%(refname)', 'refs/amalgm/parked',
243
+ ], { journalResult: false });
244
+ const prefix = 'refs/amalgm/parked/';
245
+ for (const ref of output.trim().split('\n').filter(Boolean)) {
246
+ if (!ref.startsWith(prefix) || keepOids.has(ref.slice(prefix.length))) continue;
247
+ await run('remove-parked-commit', worktreeRoot, [
248
+ '-C', worktreeRoot, 'update-ref', '-d', ref,
249
+ ]);
250
+ }
251
+ }
252
+
239
253
  function setBranchRef(worktreeRoot, branch, oid) {
240
254
  return run('set-branch-ref', worktreeRoot, [
241
255
  '-C', worktreeRoot, 'update-ref', `refs/heads/${branch}`, oid,
@@ -380,6 +394,7 @@ module.exports = {
380
394
  initializeRepo,
381
395
  moveToCard,
382
396
  parkCommit,
397
+ pruneParkedCommits,
383
398
  restoreInitialFiles,
384
399
  setBranchRef,
385
400
  setIndexTree,
@@ -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,