amalgm 0.1.221 → 0.1.234
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/state/attachments.js +7 -1
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +39 -2
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +180 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +47 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +28 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +152 -12
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +6 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +105 -67
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +183 -23
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +99 -206
- package/runtime/scripts/amalgm-mcp/workspace/card.js +30 -9
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +3 -2
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +194 -20
- package/runtime/scripts/amalgm-mcp/workspace/git.js +23 -5
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +24 -186
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +13 -1
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +323 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +115 -101
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +106 -137
package/package.json
CHANGED
|
@@ -129,7 +129,13 @@ function acknowledgeAttachment(attachmentIdInput, payload, options = {}) {
|
|
|
129
129
|
|
|
130
130
|
const installed = payload?.contract === 'workspace-tree@1'
|
|
131
131
|
? require('../workspace/tree-store').installCloudTreeSnapshot(attachment.localPath, payload, { database })
|
|
132
|
-
:
|
|
132
|
+
: payload?.contract === 'code-project-stream@1'
|
|
133
|
+
? require('../workspace/project-stream').installSnapshot(
|
|
134
|
+
attachment.localPath,
|
|
135
|
+
payload,
|
|
136
|
+
{ database },
|
|
137
|
+
)
|
|
138
|
+
: require('./docs').installCloudDocSnapshot(attachment.localPath, payload);
|
|
133
139
|
const now = new Date().toISOString();
|
|
134
140
|
cachedStatement(database, `
|
|
135
141
|
UPDATE shared_resource_attachments
|
|
@@ -118,9 +118,35 @@ async function handleWorkspaceContentDiscover(body, sendJson) {
|
|
|
118
118
|
|
|
119
119
|
async function handlePromotionStart(body, sendJson) {
|
|
120
120
|
try {
|
|
121
|
+
const codeProject = body?.contract === 'code-project-stream@1';
|
|
121
122
|
const requestedResourceId = typeof body?.resourceId === 'string' && body.resourceId.trim()
|
|
122
123
|
? body.resourceId.trim()
|
|
123
124
|
: null;
|
|
125
|
+
if (codeProject) {
|
|
126
|
+
if (!requestedResourceId) {
|
|
127
|
+
const error = new Error('code project promotion requires a resourceId');
|
|
128
|
+
error.statusCode = 409;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
const projectStream = require('../workspace/project-stream');
|
|
132
|
+
const localPath = require('path').resolve(body?.path || '');
|
|
133
|
+
const snapshot = projectStream.readSnapshot(localPath, body?.project);
|
|
134
|
+
const bytes = Buffer.from(`${JSON.stringify(snapshot)}\n`, 'utf8');
|
|
135
|
+
const result = require('./promotions').enqueuePromotion({
|
|
136
|
+
promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
137
|
+
resourceId: requestedResourceId,
|
|
138
|
+
localResource: `${projectStream.LOCAL_RESOURCE_PREFIX}${requestedResourceId}`,
|
|
139
|
+
localPath,
|
|
140
|
+
contract: projectStream.STREAM_CONTRACT,
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
snapshotBase64: bytes.toString('base64'),
|
|
143
|
+
contentBytes: bytes.length,
|
|
144
|
+
name: body?.name,
|
|
145
|
+
mediaType: 'application/vnd.amalgm.code-project+json',
|
|
146
|
+
});
|
|
147
|
+
sendJson(result.duplicate ? 200 : 202, result);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
124
150
|
const requestedNodeId = requestedResourceId
|
|
125
151
|
&& require('../workspace/tree/identity').isUuid(requestedResourceId)
|
|
126
152
|
? requestedResourceId.toLowerCase()
|
|
@@ -214,13 +240,22 @@ async function handlePromotionFail(body, sendJson) {
|
|
|
214
240
|
async function handleAttachmentStart(body, sendJson) {
|
|
215
241
|
try {
|
|
216
242
|
const tree = body?.contract === 'workspace-tree@1';
|
|
217
|
-
const
|
|
243
|
+
const codeProject = body?.contract === 'code-project-stream@1';
|
|
244
|
+
const localPath = tree || codeProject
|
|
245
|
+
? require('path').resolve(body?.path || '')
|
|
246
|
+
: require('./docs').openDoc(body?.path).path;
|
|
218
247
|
if (tree) {
|
|
219
248
|
const stats = fs.statSync(localPath);
|
|
220
249
|
if (!stats.isDirectory() || fs.readdirSync(localPath).length > 0) {
|
|
221
250
|
sendJson(409, { error: 'cloud workspace attachment requires an empty folder' });
|
|
222
251
|
return;
|
|
223
252
|
}
|
|
253
|
+
} else if (codeProject) {
|
|
254
|
+
const contents = fs.existsSync(localPath) ? fs.readFileSync(localPath, 'utf8').trim() : '';
|
|
255
|
+
if (contents) {
|
|
256
|
+
sendJson(409, { error: 'code project attachment requires an empty local projection' });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
224
259
|
} else if (require('./docs').getDocText(localPath).length > 0) {
|
|
225
260
|
sendJson(409, { error: 'cloud attachment requires an empty local text file' });
|
|
226
261
|
return;
|
|
@@ -331,7 +366,9 @@ async function handleMutationIngest(body, sendJson) {
|
|
|
331
366
|
? require('../browser/cookie-jar').applyCloudCookieMutation(body)
|
|
332
367
|
: contract === 'workspace-tree@1'
|
|
333
368
|
? require('../workspace/tree-store').applyCloudTreeMutation(body)
|
|
334
|
-
:
|
|
369
|
+
: contract === 'code-project-stream@1'
|
|
370
|
+
? require('../workspace/project-stream').applyCloudMutation(body)
|
|
371
|
+
: require('./docs').applyCloudDocMutation(body);
|
|
335
372
|
sendJson(200, result);
|
|
336
373
|
} catch (error) {
|
|
337
374
|
sendHandlerError(error, sendJson);
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-code-project-stream-'));
|
|
11
|
+
process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
|
|
12
|
+
process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
|
|
13
|
+
process.env.AMALGM_COMPUTER_ID = 'machine-a';
|
|
14
|
+
process.env.AMALGM_DEVICE_ID = 'machine-a';
|
|
15
|
+
fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
path.join(process.env.AMALGM_DIR, 'computer.json'),
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
computer_id: 'machine-a',
|
|
20
|
+
user_id: '11111111-1111-4111-8111-111111111111',
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const { closeLocalDb } = require('../state/db');
|
|
25
|
+
const attachments = require('../state/attachments');
|
|
26
|
+
const promotions = require('../state/promotions');
|
|
27
|
+
const shared = require('../state/shared-rest');
|
|
28
|
+
const projectStream = require('../workspace/project-stream');
|
|
29
|
+
|
|
30
|
+
test.after(() => {
|
|
31
|
+
closeLocalDb();
|
|
32
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function call(handler, body) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
handler(body, (status, payload) => {
|
|
38
|
+
if (status >= 400) reject(Object.assign(new Error(payload?.error), { status, payload }));
|
|
39
|
+
else resolve({ status, payload });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function record(project, machine, mutationId, marker) {
|
|
45
|
+
return {
|
|
46
|
+
wall: projectStream.WALL_CONTRACT,
|
|
47
|
+
project,
|
|
48
|
+
machine,
|
|
49
|
+
mutationId,
|
|
50
|
+
publishedAt: '2026-07-26T12:00:00.000Z',
|
|
51
|
+
cards: [{ marker }],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function encodedSnapshot(snapshot) {
|
|
56
|
+
const bytes = Buffer.from(`${JSON.stringify(snapshot)}\n`, 'utf8');
|
|
57
|
+
return {
|
|
58
|
+
snapshotBase64: bytes.toString('base64'),
|
|
59
|
+
snapshotChecksum: crypto.createHash('sha256').update(bytes).digest('hex'),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('a code project promotes as a compact snapshot, not as a text document', async () => {
|
|
64
|
+
const project = 'root:promotion';
|
|
65
|
+
const resourceId = 'shr_code_project_promotion_0001';
|
|
66
|
+
const file = path.join(tempRoot, 'promotion.json');
|
|
67
|
+
projectStream.writeSnapshot(file, projectStream._test.emptySnapshot(project));
|
|
68
|
+
|
|
69
|
+
const started = await call(shared.handlePromotionStart, {
|
|
70
|
+
resourceId,
|
|
71
|
+
path: file,
|
|
72
|
+
project,
|
|
73
|
+
contract: projectStream.STREAM_CONTRACT,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(started.status, 202);
|
|
76
|
+
assert.equal(started.payload.promotion.contract, projectStream.STREAM_CONTRACT);
|
|
77
|
+
assert.equal(started.payload.promotion.localResource, `${projectStream.LOCAL_RESOURCE_PREFIX}${resourceId}`);
|
|
78
|
+
|
|
79
|
+
const promotion = promotions.getPromotion(started.payload.promotion.promotionId);
|
|
80
|
+
promotions.acknowledgePromotion(promotion.promotionId, {
|
|
81
|
+
resourceId,
|
|
82
|
+
authorityEpoch: 1,
|
|
83
|
+
headVersion: 0,
|
|
84
|
+
snapshotVersion: 0,
|
|
85
|
+
snapshotChecksum: promotion.snapshotChecksum,
|
|
86
|
+
snapshotKey: 'shared/code-project/promotion.bin',
|
|
87
|
+
});
|
|
88
|
+
const replica = require('../state/replicas').getReplicaByResourceId(resourceId);
|
|
89
|
+
assert.equal(replica.contract, projectStream.STREAM_CONTRACT);
|
|
90
|
+
assert.equal(replica.localPath, file);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('empty attachment hydrates through N, then applies only event N+1', async () => {
|
|
94
|
+
const project = 'root:hydrate';
|
|
95
|
+
const resourceId = 'shr_code_project_attachment_0001';
|
|
96
|
+
const file = path.join(tempRoot, 'attachment.json');
|
|
97
|
+
const started = await call(shared.handleAttachmentStart, {
|
|
98
|
+
resourceId,
|
|
99
|
+
path: file,
|
|
100
|
+
contract: projectStream.STREAM_CONTRACT,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(started.status, 202);
|
|
103
|
+
|
|
104
|
+
const mutationA = 'mut_code_project_snapshot_a';
|
|
105
|
+
const snapshot = {
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
project,
|
|
108
|
+
version: 2,
|
|
109
|
+
records: [{ ...record(project, 'machine-a', mutationA, 'snapshot'), version: 2 }],
|
|
110
|
+
};
|
|
111
|
+
const encoded = encodedSnapshot(snapshot);
|
|
112
|
+
await call(shared.handleAttachmentAck, {
|
|
113
|
+
attachmentId: started.payload.attachment.attachmentId,
|
|
114
|
+
resourceId,
|
|
115
|
+
authorityEpoch: 1,
|
|
116
|
+
headVersion: 2,
|
|
117
|
+
snapshotVersion: 2,
|
|
118
|
+
contract: projectStream.STREAM_CONTRACT,
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
...encoded,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
assert.equal(attachments.getAttachmentByResource(resourceId).state, 'attached');
|
|
124
|
+
assert.deepEqual(projectStream.readSnapshot(file, project), snapshot);
|
|
125
|
+
|
|
126
|
+
const mutationB = 'mut_code_project_cloud_b';
|
|
127
|
+
await call(shared.handleMutationIngest, {
|
|
128
|
+
version: 3,
|
|
129
|
+
committedAt: '2026-07-26T12:00:03.000Z',
|
|
130
|
+
envelope: {
|
|
131
|
+
resourceId,
|
|
132
|
+
authorityEpoch: 1,
|
|
133
|
+
mutationId: mutationB,
|
|
134
|
+
deviceId: 'machine-b',
|
|
135
|
+
baseVersion: 2,
|
|
136
|
+
contract: projectStream.STREAM_CONTRACT,
|
|
137
|
+
schemaVersion: 1,
|
|
138
|
+
operationKind: 'code.publish',
|
|
139
|
+
operation: {
|
|
140
|
+
kind: 'code.publish',
|
|
141
|
+
record: record(project, 'machine-b', mutationB, 'event-3'),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
projectStream.readSnapshot(file, project).records.map((entry) => [
|
|
147
|
+
entry.machine,
|
|
148
|
+
entry.version,
|
|
149
|
+
entry.cards[0].marker,
|
|
150
|
+
]),
|
|
151
|
+
[
|
|
152
|
+
['machine-a', 2, 'snapshot'],
|
|
153
|
+
['machine-b', 3, 'event-3'],
|
|
154
|
+
],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const beforeGap = fs.readFileSync(file, 'utf8');
|
|
158
|
+
await assert.rejects(
|
|
159
|
+
call(shared.handleMutationIngest, {
|
|
160
|
+
version: 5,
|
|
161
|
+
committedAt: '2026-07-26T12:00:05.000Z',
|
|
162
|
+
envelope: {
|
|
163
|
+
resourceId,
|
|
164
|
+
authorityEpoch: 1,
|
|
165
|
+
mutationId: 'mut_code_project_gap_005',
|
|
166
|
+
deviceId: 'machine-c',
|
|
167
|
+
baseVersion: 4,
|
|
168
|
+
contract: projectStream.STREAM_CONTRACT,
|
|
169
|
+
schemaVersion: 1,
|
|
170
|
+
operationKind: 'code.publish',
|
|
171
|
+
operation: {
|
|
172
|
+
kind: 'code.publish',
|
|
173
|
+
record: record(project, 'machine-c', 'mut_code_project_gap_005', 'gap'),
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
/expected 4, received 5/,
|
|
178
|
+
);
|
|
179
|
+
assert.equal(fs.readFileSync(file, 'utf8'), beforeGap);
|
|
180
|
+
});
|
|
@@ -16,6 +16,7 @@ process.env.AMALGM_COMPUTER_ID = 'computer-test';
|
|
|
16
16
|
|
|
17
17
|
const { closeLocalDb, openLocalDb } = require('../state/db');
|
|
18
18
|
const {
|
|
19
|
+
_test: cardTest,
|
|
19
20
|
buildRepoCard,
|
|
20
21
|
cardFingerprint,
|
|
21
22
|
discoverWorktrees,
|
|
@@ -399,3 +400,49 @@ test('the fingerprint holds across observations and moves on any real change', a
|
|
|
399
400
|
const third = await buildRepoCard(dir);
|
|
400
401
|
assert.notEqual(cardFingerprint(second), cardFingerprint(third));
|
|
401
402
|
});
|
|
403
|
+
|
|
404
|
+
test('unchanged commits reuse their card object source and one new oid is resolved once', async () => {
|
|
405
|
+
const dir = await makeRepo('object-source-cache');
|
|
406
|
+
await run(dir, 'branch', 'side');
|
|
407
|
+
const local = await buildRepoCard(dir);
|
|
408
|
+
await run(dir, 'remote', 'add', 'origin', 'https://github.com/amalgm/object-source-cache.git');
|
|
409
|
+
const previous = {
|
|
410
|
+
...local,
|
|
411
|
+
repo: { ...local.repo, remote: 'https://github.com/amalgm/object-source-cache' },
|
|
412
|
+
objects: { kind: 'remote' },
|
|
413
|
+
refs: local.refs.map((ref) => ({ ...ref, objects: { kind: 'remote' } })),
|
|
414
|
+
};
|
|
415
|
+
const objects = require('../workspace/objects');
|
|
416
|
+
const original = objects.objectSourceFor;
|
|
417
|
+
let calls = 0;
|
|
418
|
+
objects.objectSourceFor = async () => {
|
|
419
|
+
calls += 1;
|
|
420
|
+
return { kind: 'pending' };
|
|
421
|
+
};
|
|
422
|
+
try {
|
|
423
|
+
const unchanged = await buildRepoCard(dir, previous);
|
|
424
|
+
assert.equal(calls, 0);
|
|
425
|
+
assert.equal(unchanged.objects.kind, 'remote');
|
|
426
|
+
assert.ok(unchanged.refs.every((ref) => ref.objects.kind === 'remote'));
|
|
427
|
+
|
|
428
|
+
write(dir, 'next.txt', 'next\n');
|
|
429
|
+
await run(dir, 'add', '.');
|
|
430
|
+
await run(dir, 'commit', '-m', 'next');
|
|
431
|
+
const changed = await buildRepoCard(dir, unchanged);
|
|
432
|
+
assert.equal(calls, 1, 'HEAD and its branch share one content-addressed lookup');
|
|
433
|
+
assert.equal(changed.objects.kind, 'pending');
|
|
434
|
+
assert.equal(changed.refs.find((ref) => ref.name === 'main').objects.kind, 'pending');
|
|
435
|
+
assert.equal(changed.refs.find((ref) => ref.name === 'side').objects.kind, 'remote');
|
|
436
|
+
} finally {
|
|
437
|
+
objects.objectSourceFor = original;
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('only complete object sources are reusable', () => {
|
|
442
|
+
assert.deepEqual(
|
|
443
|
+
cardTest.reusableObjectSource('a', 'a', { kind: 'bundle', checksum: 'ready' }),
|
|
444
|
+
{ kind: 'bundle', checksum: 'ready' },
|
|
445
|
+
);
|
|
446
|
+
assert.equal(cardTest.reusableObjectSource('a', 'b', { kind: 'remote' }), null);
|
|
447
|
+
assert.equal(cardTest.reusableObjectSource('a', 'a', { kind: 'pending' }), null);
|
|
448
|
+
});
|
|
@@ -57,51 +57,44 @@ test('rail-ordered checkpoint transitions combine and a later revert removes onl
|
|
|
57
57
|
const treeA = await merge.treeOfPatch(repo, oid, patchA);
|
|
58
58
|
const treeB = await merge.treeOfPatch(repo, oid, patchB);
|
|
59
59
|
|
|
60
|
-
const afterA = await merge.reduceTree(repo, clean, clean, treeA
|
|
61
|
-
const afterB = await merge.reduceTree(repo, afterA.tree, clean, treeB
|
|
60
|
+
const afterA = await merge.reduceTree(repo, clean, clean, treeA);
|
|
61
|
+
const afterB = await merge.reduceTree(repo, afterA.tree, clean, treeB);
|
|
62
62
|
assert.equal(await treeText(repo, afterB.tree, 'a.txt'), 'a1\n');
|
|
63
63
|
assert.equal(await treeText(repo, afterB.tree, 'b.txt'), 'b1\n');
|
|
64
|
-
assert.deepEqual(afterB.conflicts, []);
|
|
65
64
|
|
|
66
65
|
// A's next operation says "from A+B to B": the reducer can remove A even
|
|
67
66
|
// though B's latest checkpoint already incorporated A.
|
|
68
|
-
const reverted = await merge.reduceTree(repo, afterB.tree, afterB.tree, treeB
|
|
67
|
+
const reverted = await merge.reduceTree(repo, afterB.tree, afterB.tree, treeB);
|
|
69
68
|
assert.equal(await treeText(repo, reverted.tree, 'a.txt'), 'a0\n');
|
|
70
69
|
assert.equal(await treeText(repo, reverted.tree, 'b.txt'), 'b1\n');
|
|
71
70
|
});
|
|
72
71
|
|
|
73
|
-
test('
|
|
74
|
-
const { repo, oid, tree: clean } = await makeRepo('
|
|
72
|
+
test('a later overlapping transition supplies the exact final bytes', async () => {
|
|
73
|
+
const { repo, oid, tree: clean } = await makeRepo('later-wins');
|
|
75
74
|
const leftPatch = await patchFor(repo, { 'same.txt': 'left\nline 2\n' });
|
|
76
75
|
const rightPatch = await patchFor(repo, { 'same.txt': 'right\nline 2\n' });
|
|
77
76
|
const left = await merge.treeOfPatch(repo, oid, leftPatch);
|
|
78
77
|
const right = await merge.treeOfPatch(repo, oid, rightPatch);
|
|
79
78
|
|
|
80
|
-
const current = await merge.reduceTree(repo, clean, clean, left
|
|
81
|
-
const first = await merge.reduceTree(repo, current.tree, clean, right
|
|
82
|
-
const second = await merge.reduceTree(repo, current.tree, clean, right
|
|
79
|
+
const current = await merge.reduceTree(repo, clean, clean, left);
|
|
80
|
+
const first = await merge.reduceTree(repo, current.tree, clean, right);
|
|
81
|
+
const second = await merge.reduceTree(repo, current.tree, clean, right);
|
|
83
82
|
|
|
84
83
|
assert.equal(first.tree, second.tree);
|
|
85
|
-
assert.
|
|
86
|
-
assert.equal(first.conflicts[0].kind, 'content-overlap');
|
|
87
|
-
const content = await treeText(repo, first.tree, 'same.txt');
|
|
88
|
-
assert.match(content, /<<<<<<< current/);
|
|
89
|
-
assert.match(content, /left/);
|
|
90
|
-
assert.match(content, /right/);
|
|
84
|
+
assert.equal(await treeText(repo, first.tree, 'same.txt'), 'right\nline 2\n');
|
|
91
85
|
});
|
|
92
86
|
|
|
93
87
|
test('an echoed transition that is already present reduces to a no-op', async () => {
|
|
94
88
|
const { repo, oid, tree: clean } = await makeRepo('echo');
|
|
95
89
|
const patch = await patchFor(repo, { 'a.txt': 'shared\n' });
|
|
96
90
|
const shared = await merge.treeOfPatch(repo, oid, patch);
|
|
97
|
-
const current = await merge.reduceTree(repo, clean, clean, shared
|
|
98
|
-
const echoed = await merge.reduceTree(repo, current.tree, clean, shared
|
|
91
|
+
const current = await merge.reduceTree(repo, clean, clean, shared);
|
|
92
|
+
const echoed = await merge.reduceTree(repo, current.tree, clean, shared);
|
|
99
93
|
assert.equal(echoed.tree, current.tree);
|
|
100
|
-
assert.deepEqual(echoed.conflicts, []);
|
|
101
94
|
});
|
|
102
95
|
|
|
103
|
-
test('a deletion
|
|
104
|
-
const { repo, oid, tree: clean } = await makeRepo('delete
|
|
96
|
+
test('a later deletion removes a file changed by an earlier writer', async () => {
|
|
97
|
+
const { repo, oid, tree: clean } = await makeRepo('later-delete');
|
|
105
98
|
const localPatch = await patchFor(repo, { 'a.txt': 'precious local edit\n' });
|
|
106
99
|
const localTree = await merge.treeOfPatch(repo, oid, localPatch);
|
|
107
100
|
fs.rmSync(path.join(repo, 'a.txt'));
|
|
@@ -109,8 +102,19 @@ test('a deletion never removes a file another writer edited', async () => {
|
|
|
109
102
|
await git(repo, 'reset', '--hard', 'HEAD');
|
|
110
103
|
const deletedTree = await merge.treeOfPatch(repo, oid, deletePatch);
|
|
111
104
|
|
|
112
|
-
const current = await merge.reduceTree(repo, clean, clean, localTree
|
|
113
|
-
const outcome = await merge.reduceTree(repo, current.tree, clean, deletedTree
|
|
114
|
-
assert.
|
|
115
|
-
|
|
105
|
+
const current = await merge.reduceTree(repo, clean, clean, localTree);
|
|
106
|
+
const outcome = await merge.reduceTree(repo, current.tree, clean, deletedTree);
|
|
107
|
+
await assert.rejects(treeText(repo, outcome.tree, 'a.txt'));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('an earlier inbound event advances underneath a pending local write', async () => {
|
|
111
|
+
const { repo, oid, tree: clean } = await makeRepo('pending-local');
|
|
112
|
+
const inboundPatch = await patchFor(repo, { 'same.txt': 'inbound\n' });
|
|
113
|
+
const inbound = await merge.treeOfPatch(repo, oid, inboundPatch);
|
|
114
|
+
|
|
115
|
+
fs.writeFileSync(path.join(repo, 'same.txt'), 'pending local\n');
|
|
116
|
+
const outcome = await merge.advance(repo, clean, inbound);
|
|
117
|
+
|
|
118
|
+
assert.equal(fs.readFileSync(path.join(repo, 'same.txt'), 'utf8'), 'pending local\n');
|
|
119
|
+
assert.equal(await treeText(repo, outcome.heldTree, 'same.txt'), 'inbound\n');
|
|
116
120
|
});
|
|
@@ -23,18 +23,27 @@ fs.writeFileSync(
|
|
|
23
23
|
);
|
|
24
24
|
|
|
25
25
|
const { closeLocalDb } = require('../state/db');
|
|
26
|
-
const {
|
|
26
|
+
const {
|
|
27
|
+
_test: gitTest,
|
|
28
|
+
captureCheckpoint,
|
|
29
|
+
captureWorktreeDiff,
|
|
30
|
+
} = require('../workspace/git');
|
|
27
31
|
const merge = require('../workspace/merge');
|
|
28
32
|
const { applyPatch } = require('../workspace/transition');
|
|
29
33
|
const {
|
|
30
34
|
_test,
|
|
31
35
|
CHECKPOINT_CONTRACT,
|
|
32
36
|
checkpointFile,
|
|
37
|
+
checkpointValue,
|
|
33
38
|
checkpointValueReady,
|
|
34
39
|
readCheckpointValue,
|
|
35
40
|
revisionOf,
|
|
36
41
|
sameTransition,
|
|
37
42
|
} = require('../workspace/checkpoint');
|
|
43
|
+
const {
|
|
44
|
+
acknowledgeUpload,
|
|
45
|
+
claimUpload,
|
|
46
|
+
} = require('../workspace/objects');
|
|
38
47
|
|
|
39
48
|
test.after(() => {
|
|
40
49
|
closeLocalDb();
|
|
@@ -54,6 +63,23 @@ test('checkpoint publication waits until the reducer finishes writing both repo
|
|
|
54
63
|
assert.equal(_test.checkpointMayPublish(repoKey), true);
|
|
55
64
|
});
|
|
56
65
|
|
|
66
|
+
test('a checkpoint read across two different HEADs is discarded', () => {
|
|
67
|
+
assert.deepEqual(gitTest.checkpointCaptureResult({
|
|
68
|
+
baseOid: 'old-head',
|
|
69
|
+
observedOid: 'new-head',
|
|
70
|
+
indexDiff: '',
|
|
71
|
+
worktreeDiff: '',
|
|
72
|
+
bytes: 0,
|
|
73
|
+
maxBytes: Infinity,
|
|
74
|
+
}), {
|
|
75
|
+
baseOid: 'old-head',
|
|
76
|
+
indexDiff: null,
|
|
77
|
+
worktreeDiff: null,
|
|
78
|
+
reason: 'head-moved',
|
|
79
|
+
bytes: 0,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
57
83
|
async function run(cwd, ...args) {
|
|
58
84
|
await execFileAsync(
|
|
59
85
|
'git',
|
|
@@ -145,6 +171,23 @@ test('capture is stable: two reads of an untouched tree produce the same revisio
|
|
|
145
171
|
assert.equal(revisionOf(first.baseOid, first.diff), revisionOf(second.baseOid, second.diff));
|
|
146
172
|
});
|
|
147
173
|
|
|
174
|
+
test('checkpoint identity ignores Git file-patch order', async () => {
|
|
175
|
+
const { author } = await makeRepoPair();
|
|
176
|
+
fs.appendFileSync(path.join(author, 'README.md'), 'tracked edit\n');
|
|
177
|
+
fs.writeFileSync(path.join(author, 'A-new-file.txt'), 'untracked edit\n');
|
|
178
|
+
|
|
179
|
+
const captured = await captureCheckpoint(author);
|
|
180
|
+
const indexTree = await merge.treeOfPatch(author, captured.baseOid, captured.indexDiff);
|
|
181
|
+
const worktreeTree = await merge.treeOfPatch(author, indexTree, captured.worktreeDiff);
|
|
182
|
+
const normalized = await merge.diffOfTree(author, indexTree, worktreeTree);
|
|
183
|
+
|
|
184
|
+
assert.notEqual(captured.worktreeDiff, normalized);
|
|
185
|
+
assert.equal(
|
|
186
|
+
revisionOf(captured.baseOid, captured.indexDiff, captured.worktreeDiff),
|
|
187
|
+
revisionOf(captured.baseOid, captured.indexDiff, normalized),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
148
191
|
test('one checkpoint preserves the staged and unstaged layers separately', async () => {
|
|
149
192
|
const { author } = await makeRepoPair();
|
|
150
193
|
fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(2);\n');
|
|
@@ -180,7 +223,7 @@ test('checkpoint captures are local cache; their operations travel on the one wa
|
|
|
180
223
|
assert.equal(first, checkpointFile('m1', 'https://github.com/a/b'));
|
|
181
224
|
assert.notEqual(first, checkpointFile('m2', 'https://github.com/a/b'));
|
|
182
225
|
assert.notEqual(first, checkpointFile('m1', 'https://github.com/a/c'));
|
|
183
|
-
assert.match(first, /repo-checkpoints-
|
|
226
|
+
assert.match(first, /repo-checkpoints-v9/);
|
|
184
227
|
});
|
|
185
228
|
|
|
186
229
|
test('a materialized peer card is immediately a valid checkpoint base', () => {
|
|
@@ -233,6 +276,60 @@ test('an uploading checkpoint payload is not published as a transition', () => {
|
|
|
233
276
|
assert.equal(checkpointValueReady({ revision: null, indexDiff: '', worktreeDiff: '' }), true);
|
|
234
277
|
});
|
|
235
278
|
|
|
279
|
+
test('a small edit after a large file reuses the large file payload', () => {
|
|
280
|
+
const repoKey = 'https://example.test/granular-checkpoint.git';
|
|
281
|
+
const baseOid = 'c'.repeat(40);
|
|
282
|
+
const largePatch = [
|
|
283
|
+
'diff --git a/blob.bin b/blob.bin',
|
|
284
|
+
'new file mode 100644',
|
|
285
|
+
'--- /dev/null',
|
|
286
|
+
'+++ b/blob.bin',
|
|
287
|
+
`+${'x'.repeat(700_000)}`,
|
|
288
|
+
'',
|
|
289
|
+
].join('\n');
|
|
290
|
+
const smallPatch = [
|
|
291
|
+
'diff --git a/z.txt b/z.txt',
|
|
292
|
+
'new file mode 100644',
|
|
293
|
+
'--- /dev/null',
|
|
294
|
+
'+++ b/z.txt',
|
|
295
|
+
'+small edit',
|
|
296
|
+
'',
|
|
297
|
+
].join('\n');
|
|
298
|
+
|
|
299
|
+
assert.equal(
|
|
300
|
+
checkpointValue('machine-a', repoKey, baseOid, '', largePatch).sections.worktree[0].payload.kind,
|
|
301
|
+
'pending',
|
|
302
|
+
);
|
|
303
|
+
for (;;) {
|
|
304
|
+
const upload = claimUpload();
|
|
305
|
+
if (!upload) break;
|
|
306
|
+
acknowledgeUpload(upload.resourceId, upload.sha256);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const first = checkpointValue('machine-a', repoKey, baseOid, '', largePatch);
|
|
310
|
+
assert.equal(checkpointValueReady(first), true);
|
|
311
|
+
assert.equal(first.sections.worktree[0].payload.kind, 'chunks');
|
|
312
|
+
|
|
313
|
+
const second = checkpointValue(
|
|
314
|
+
'machine-a',
|
|
315
|
+
repoKey,
|
|
316
|
+
baseOid,
|
|
317
|
+
'',
|
|
318
|
+
`${largePatch}${smallPatch}`,
|
|
319
|
+
);
|
|
320
|
+
assert.equal(
|
|
321
|
+
checkpointValueReady(second),
|
|
322
|
+
true,
|
|
323
|
+
'the unchanged large file must not be uploaded again',
|
|
324
|
+
);
|
|
325
|
+
assert.equal(second.sections.worktree[0].payload.checksum, first.sections.worktree[0].payload.checksum);
|
|
326
|
+
assert.equal(claimUpload(), null);
|
|
327
|
+
|
|
328
|
+
const decoded = readCheckpointValue(baseOid, second);
|
|
329
|
+
assert.equal(decoded.state, 'ready');
|
|
330
|
+
assert.equal(decoded.worktreeDiff, `${largePatch}${smallPatch}`);
|
|
331
|
+
});
|
|
332
|
+
|
|
236
333
|
test('a clean losing commit travels as parked metadata on a no-op checkpoint', () => {
|
|
237
334
|
const sharedValue = { revision: null, indexDiff: '', worktreeDiff: '' };
|
|
238
335
|
const parked = [{
|
|
@@ -241,15 +338,6 @@ test('a clean losing commit travels as parked metadata on a no-op checkpoint', (
|
|
|
241
338
|
objects: { kind: 'bundle', checksum: 'b'.repeat(64) },
|
|
242
339
|
}];
|
|
243
340
|
const checkpoint = _test.checkpointForSharedState({
|
|
244
|
-
checkpoint: CHECKPOINT_CONTRACT,
|
|
245
|
-
machine: 'machine-a',
|
|
246
|
-
repoKey: 'root:test',
|
|
247
|
-
branch: 'old',
|
|
248
|
-
baseOid: 'c'.repeat(40),
|
|
249
|
-
from: sharedValue,
|
|
250
|
-
to: sharedValue,
|
|
251
|
-
parked: [],
|
|
252
|
-
}, {
|
|
253
341
|
machine: 'machine-a',
|
|
254
342
|
repoKey: 'root:test',
|
|
255
343
|
branch: 'main',
|
|
@@ -267,6 +355,58 @@ test('a clean losing commit travels as parked metadata on a no-op checkpoint', (
|
|
|
267
355
|
assert.deepEqual(checkpoint.parked, parked);
|
|
268
356
|
});
|
|
269
357
|
|
|
358
|
+
test('shared content clears the local pending checkpoint instead of publishing an acknowledgement', () => {
|
|
359
|
+
const sharedValue = { revision: 'shared', indexDiff: '', worktreeDiff: 'shared-patch' };
|
|
360
|
+
assert.equal(_test.checkpointForSharedState({
|
|
361
|
+
machine: 'machine-a',
|
|
362
|
+
repoKey: 'root:test',
|
|
363
|
+
branch: 'main',
|
|
364
|
+
baseOid: 'a'.repeat(40),
|
|
365
|
+
sharedValue,
|
|
366
|
+
parked: [],
|
|
367
|
+
}), null);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('an acknowledged checkpoint remains a silent receipt until newer state replaces it', () => {
|
|
371
|
+
const oid = 'a'.repeat(40);
|
|
372
|
+
const checkpoint = {
|
|
373
|
+
baseOid: oid,
|
|
374
|
+
branch: 'main',
|
|
375
|
+
to: { revision: 'local-revision' },
|
|
376
|
+
};
|
|
377
|
+
const card = { head: { oid, branch: 'main' } };
|
|
378
|
+
|
|
379
|
+
assert.equal(
|
|
380
|
+
_test.checkpointWasAcknowledged(checkpoint, card, 'local-revision', 51),
|
|
381
|
+
true,
|
|
382
|
+
);
|
|
383
|
+
assert.equal(
|
|
384
|
+
_test.checkpointWasAcknowledged(checkpoint, card, 'newer-revision', 51),
|
|
385
|
+
false,
|
|
386
|
+
);
|
|
387
|
+
assert.equal(
|
|
388
|
+
_test.checkpointWasAcknowledged(checkpoint, card, 'local-revision', null),
|
|
389
|
+
false,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('unpublished parked work uses a no-op checkpoint over the current shared value', () => {
|
|
394
|
+
const sharedValue = { revision: 'shared', indexDiff: '', worktreeDiff: 'shared-patch' };
|
|
395
|
+
const parked = [{ oid: 'b'.repeat(40), objects: { kind: 'bundle' } }];
|
|
396
|
+
const checkpoint = _test.checkpointForSharedState({
|
|
397
|
+
machine: 'machine-a',
|
|
398
|
+
repoKey: 'root:test',
|
|
399
|
+
branch: 'main',
|
|
400
|
+
baseOid: 'a'.repeat(40),
|
|
401
|
+
sharedValue,
|
|
402
|
+
parked,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
assert.deepEqual(checkpoint.from, sharedValue);
|
|
406
|
+
assert.deepEqual(checkpoint.to, sharedValue);
|
|
407
|
+
assert.deepEqual(checkpoint.parked, parked);
|
|
408
|
+
});
|
|
409
|
+
|
|
270
410
|
test('only this machine keeps announcing its parked commits', () => {
|
|
271
411
|
const baseOid = 'a'.repeat(40);
|
|
272
412
|
const localOid = 'b'.repeat(40);
|
|
@@ -288,7 +428,7 @@ test('only this machine keeps announcing its parked commits', () => {
|
|
|
288
428
|
|
|
289
429
|
assert.deepEqual(
|
|
290
430
|
_test.localParkedEntries(existing, reduced, card).map((entry) => entry.oid),
|
|
291
|
-
[
|
|
431
|
+
[pendingOid],
|
|
292
432
|
);
|
|
293
433
|
assert.deepEqual(
|
|
294
434
|
_test.localParkedEntries(existing, reduced, {
|
|
@@ -129,4 +129,10 @@ test('a large checkpoint uses the same verified chunk lane', () => {
|
|
|
129
129
|
const received = readPayload(locator);
|
|
130
130
|
assert.equal(received.state, 'ready');
|
|
131
131
|
assert.deepEqual(received.bytes, content);
|
|
132
|
+
assert.equal(
|
|
133
|
+
publishPayload('same-content-after-download', content, 'repo:test').kind,
|
|
134
|
+
'chunks',
|
|
135
|
+
'verified downloaded content is immediately reusable by a later checkpoint',
|
|
136
|
+
);
|
|
137
|
+
assert.equal(claimUpload(), null);
|
|
132
138
|
});
|