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