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 +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 +312 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +11 -3
- 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 +19 -6
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +43 -1
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +15 -0
- 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),
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
@@ -360,7 +360,8 @@ async function moveToWinningCard(localCard, targetCard) {
|
|
|
360
360
|
|
|
361
361
|
async function materializeParked(localCard, entries) {
|
|
362
362
|
const waiting = [];
|
|
363
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|