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 +1 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +43 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +8 -2
- package/runtime/scripts/amalgm-mcp/tests/workspace-rest-delete.test.js +128 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +39 -17
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +8 -2
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +13 -5
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +43 -1
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +34 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|