amalgm 0.1.175 → 0.1.176
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/lib/shared-realtime-tunnel.js +28 -12
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +5 -1
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +13 -6
- package/runtime/scripts/amalgm-mcp/state/migrations.js +0 -4
- package/runtime/scripts/amalgm-mcp/state/mutations.js +13 -1
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +25 -1
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +9 -0
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +30 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +4 -5
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +37 -11
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +10 -2
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +14 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +324 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-store.test.js +36 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +81 -48
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -1
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +9 -21
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +187 -122
- package/runtime/scripts/amalgm-mcp/workspace/git.js +71 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +119 -2
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +49 -17
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +582 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +15 -1
- package/runtime/scripts/amalgm-mcp/workspace/store.js +8 -18
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +67 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +3 -4
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +331 -180
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +0 -359
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +0 -96
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +0 -452
|
@@ -282,26 +282,38 @@ function createSharedRealtimeTunnel({
|
|
|
282
282
|
if (repoUploadInflight.size === 0) {
|
|
283
283
|
const result = await localStateJson('POST', '/state/repo-objects/uploads/claim', {});
|
|
284
284
|
const upload = result?.upload;
|
|
285
|
-
if (upload?.sha256 && upload?.dataBase64) {
|
|
285
|
+
if (upload?.resourceId && upload?.sha256 && upload?.dataBase64) {
|
|
286
286
|
const requestId = `repo_put_${crypto.randomBytes(12).toString('hex')}`;
|
|
287
287
|
if (sendLarge({
|
|
288
288
|
type: 'repo.object.put',
|
|
289
289
|
request_id: requestId,
|
|
290
|
+
resource_id: upload.resourceId,
|
|
290
291
|
sha256: upload.sha256,
|
|
291
292
|
bytes: upload.bytes,
|
|
292
293
|
data_b64: upload.dataBase64,
|
|
293
294
|
})) {
|
|
294
|
-
repoUploadInflight.set(requestId,
|
|
295
|
+
repoUploadInflight.set(requestId, {
|
|
296
|
+
resourceId: upload.resourceId,
|
|
297
|
+
sha256: upload.sha256,
|
|
298
|
+
});
|
|
295
299
|
}
|
|
296
300
|
}
|
|
297
301
|
}
|
|
298
302
|
if (repoDownloadInflight.size === 0) {
|
|
299
303
|
const result = await localStateJson('POST', '/state/repo-objects/downloads/claim', {});
|
|
300
304
|
const download = result?.download;
|
|
301
|
-
if (download?.sha256) {
|
|
305
|
+
if (download?.resourceId && download?.sha256) {
|
|
302
306
|
const requestId = `repo_get_${crypto.randomBytes(12).toString('hex')}`;
|
|
303
|
-
if (send({
|
|
304
|
-
|
|
307
|
+
if (send({
|
|
308
|
+
type: 'repo.object.get',
|
|
309
|
+
request_id: requestId,
|
|
310
|
+
resource_id: download.resourceId,
|
|
311
|
+
sha256: download.sha256,
|
|
312
|
+
})) {
|
|
313
|
+
repoDownloadInflight.set(requestId, {
|
|
314
|
+
resourceId: download.resourceId,
|
|
315
|
+
sha256: download.sha256,
|
|
316
|
+
});
|
|
305
317
|
}
|
|
306
318
|
}
|
|
307
319
|
}
|
|
@@ -450,9 +462,12 @@ function createSharedRealtimeTunnel({
|
|
|
450
462
|
}
|
|
451
463
|
|
|
452
464
|
function handleRepoObjectStored(frame) {
|
|
453
|
-
const
|
|
454
|
-
if (!
|
|
455
|
-
void localStateJson('POST', '/state/repo-objects/uploads/ack', {
|
|
465
|
+
const object = repoUploadInflight.get(frame.request_id);
|
|
466
|
+
if (!object || object.sha256 !== frame.sha256) return;
|
|
467
|
+
void localStateJson('POST', '/state/repo-objects/uploads/ack', {
|
|
468
|
+
resourceId: object.resourceId,
|
|
469
|
+
sha256: object.sha256,
|
|
470
|
+
})
|
|
456
471
|
.then(() => {
|
|
457
472
|
repoUploadInflight.delete(frame.request_id);
|
|
458
473
|
scheduleRepoObjects();
|
|
@@ -464,16 +479,17 @@ function createSharedRealtimeTunnel({
|
|
|
464
479
|
}
|
|
465
480
|
|
|
466
481
|
function handleRepoObjectData(frame) {
|
|
467
|
-
const
|
|
468
|
-
if (!
|
|
482
|
+
const object = repoDownloadInflight.get(frame.request_id);
|
|
483
|
+
if (!object || object.sha256 !== frame.sha256) return;
|
|
469
484
|
void localStateJson('POST', '/state/repo-objects/downloads/receive', {
|
|
470
|
-
|
|
485
|
+
resourceId: object.resourceId,
|
|
486
|
+
sha256: object.sha256,
|
|
471
487
|
dataBase64: frame.data_b64,
|
|
472
488
|
}).then(() => {
|
|
473
489
|
repoDownloadInflight.delete(frame.request_id);
|
|
474
490
|
scheduleRepoObjects();
|
|
475
491
|
}).catch((error) => {
|
|
476
|
-
warn(`repo object ${
|
|
492
|
+
warn(`repo object ${object.sha256} was not stored locally: ${error?.message || error}`);
|
|
477
493
|
repoDownloadInflight.delete(frame.request_id);
|
|
478
494
|
scheduleRepoObjects(RETRY_MS);
|
|
479
495
|
});
|
package/package.json
CHANGED
|
@@ -132,7 +132,7 @@ async function boot() {
|
|
|
132
132
|
require('./workspace/tree-store').startTreeEnrollmentService();
|
|
133
133
|
require('./workspace/wall').startWallService();
|
|
134
134
|
require('./workspace/checkpoint').startCheckpointService();
|
|
135
|
-
require('./workspace/
|
|
135
|
+
require('./workspace/reducer').startWorkspaceReducer();
|
|
136
136
|
|
|
137
137
|
await startRegisteredApps();
|
|
138
138
|
startAppRouteSyncLoop();
|
|
@@ -53,7 +53,10 @@ async function handleStateRoutes(ctx) {
|
|
|
53
53
|
}
|
|
54
54
|
if (ctx.pathname === '/state/repo-objects/uploads/ack') {
|
|
55
55
|
const body = await ctx.readJsonBody();
|
|
56
|
-
ctx.sendJson(200, require('../../workspace/objects').acknowledgeUpload(
|
|
56
|
+
ctx.sendJson(200, require('../../workspace/objects').acknowledgeUpload(
|
|
57
|
+
body?.resourceId,
|
|
58
|
+
body?.sha256,
|
|
59
|
+
));
|
|
57
60
|
return true;
|
|
58
61
|
}
|
|
59
62
|
if (ctx.pathname === '/state/repo-objects/downloads/claim') {
|
|
@@ -63,6 +66,7 @@ async function handleStateRoutes(ctx) {
|
|
|
63
66
|
if (ctx.pathname === '/state/repo-objects/downloads/receive') {
|
|
64
67
|
const body = await ctx.readJsonBody();
|
|
65
68
|
ctx.sendJson(200, require('../../workspace/objects').receiveDownload(
|
|
69
|
+
body?.resourceId,
|
|
66
70
|
body?.sha256,
|
|
67
71
|
body?.dataBase64,
|
|
68
72
|
));
|
|
@@ -63,6 +63,10 @@ async function handleWorkspaceRoutes(ctx) {
|
|
|
63
63
|
workspaceRest.handleWorkspaceSyncStats(ctx.sendJson);
|
|
64
64
|
return true;
|
|
65
65
|
}
|
|
66
|
+
if (ctx.pathname === '/workspace/sync/project' && ctx.method === 'POST') {
|
|
67
|
+
workspaceRest.handleCodeProjectJoin(await ctx.readJsonBody(), ctx.sendJson);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
66
70
|
if (ctx.pathname === '/workspace/machines' && ctx.method === 'GET') {
|
|
67
71
|
workspaceRest.handleWorkspaceMachines(ctx.sendJson);
|
|
68
72
|
return true;
|
|
@@ -294,11 +294,11 @@ function isLowSurrogate(code) {
|
|
|
294
294
|
* API caller got no acknowledgement and retries). Returns null when the text
|
|
295
295
|
* is already current.
|
|
296
296
|
*/
|
|
297
|
-
function spliceDocText(Y, entry, nextText, source) {
|
|
297
|
+
function spliceDocText(Y, entry, nextText, source, mutationId = newMutationId()) {
|
|
298
298
|
const origin = { amalgmMutation: true, source, patch: null };
|
|
299
299
|
const changed = spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), nextText, origin);
|
|
300
300
|
if (!changed) return null;
|
|
301
|
-
return { mutationId
|
|
301
|
+
return { mutationId, update: origin.patch.yjs, bytes: null };
|
|
302
302
|
}
|
|
303
303
|
|
|
304
304
|
function canCurrentUserEditDoc(entry) {
|
|
@@ -931,13 +931,19 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
931
931
|
if (Buffer.byteLength(textInput, 'utf8') > MAX_DOC_BYTES) {
|
|
932
932
|
throw invalid(`"text" exceeds the ${MAX_DOC_BYTES}-byte live document limit`, 413);
|
|
933
933
|
}
|
|
934
|
-
const { baseHash, baseSeq } = options;
|
|
934
|
+
const { baseHash, baseSeq, mutationId } = options;
|
|
935
935
|
if (baseHash !== undefined && !(typeof baseHash === 'string' && /^[0-9a-f]{64}$/.test(baseHash))) {
|
|
936
936
|
throw invalid(`"baseHash" must be the lowercase sha256 hex (64 chars) of the exact text you read — the "sha256" field returned by open/write, e.g. ${WRITE_EXAMPLE} (got ${JSON.stringify(baseHash)})`);
|
|
937
937
|
}
|
|
938
938
|
if (baseSeq !== undefined && !(Number.isInteger(baseSeq) && baseSeq >= 0)) {
|
|
939
939
|
throw invalid(`"baseSeq" must be the non-negative integer event seq at which you read the doc, e.g. {"path": "notes.md", "text": "...", "baseSeq": 42} (got ${JSON.stringify(baseSeq)})`);
|
|
940
940
|
}
|
|
941
|
+
if (
|
|
942
|
+
mutationId !== undefined
|
|
943
|
+
&& !(typeof mutationId === 'string' && /^[A-Za-z0-9_-]{16,160}$/.test(mutationId))
|
|
944
|
+
) {
|
|
945
|
+
throw invalid('"mutationId" must be a stable opaque id using letters, numbers, _ or -');
|
|
946
|
+
}
|
|
941
947
|
|
|
942
948
|
const entry = loadDocEntry(pathInput);
|
|
943
949
|
entry.lastAccess = Date.now();
|
|
@@ -951,11 +957,12 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
951
957
|
const ytext = entry.doc.getText(TEXT_KEY);
|
|
952
958
|
const ours = ytext.toString();
|
|
953
959
|
|
|
954
|
-
const respond = (merge, finalText, conflict) => ({
|
|
960
|
+
const respond = (merge, finalText, conflict, committedMutationId = null) => ({
|
|
955
961
|
merge,
|
|
956
962
|
seq: currentSeq(),
|
|
957
963
|
sha256: hashText(finalText),
|
|
958
964
|
conflict: conflict || null,
|
|
965
|
+
mutationId: committedMutationId,
|
|
959
966
|
});
|
|
960
967
|
|
|
961
968
|
if (textInput === ours) return respond('noop', ours, null);
|
|
@@ -1011,10 +1018,10 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
1011
1018
|
}
|
|
1012
1019
|
|
|
1013
1020
|
entry.pendingConflict = conflict;
|
|
1014
|
-
const edit = spliceDocText(Y, entry, nextText, 'doc:write');
|
|
1021
|
+
const edit = spliceDocText(Y, entry, nextText, 'doc:write', mutationId);
|
|
1015
1022
|
if (edit) commitDocMutation(Y, entry, edit, 'doc:write');
|
|
1016
1023
|
else publishConflictOnly(entry, 'doc:write');
|
|
1017
|
-
return respond(merge, nextText, conflict);
|
|
1024
|
+
return respond(merge, nextText, conflict, edit?.mutationId || null);
|
|
1018
1025
|
}
|
|
1019
1026
|
|
|
1020
1027
|
function normalizeDocMutations(Y, mutationsInput) {
|
|
@@ -135,10 +135,6 @@ function migrateWorkspaceIds(database) {
|
|
|
135
135
|
SET id = ?, workspace_json = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
136
136
|
WHERE id = ?
|
|
137
137
|
`).run(workspaceId, JSON.stringify(workspaceJson), row.id);
|
|
138
|
-
database.prepare(`
|
|
139
|
-
INSERT OR IGNORE INTO workspace_id_aliases(alias_id, workspace_id)
|
|
140
|
-
VALUES (?, ?)
|
|
141
|
-
`).run(row.id, workspaceId);
|
|
142
138
|
database.prepare('UPDATE project_context SET project_id = ? WHERE project_id = ?')
|
|
143
139
|
.run(workspaceId, row.id);
|
|
144
140
|
}
|
|
@@ -700,8 +700,9 @@ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknow
|
|
|
700
700
|
64,
|
|
701
701
|
);
|
|
702
702
|
const database = options.database || openLocalDb();
|
|
703
|
+
let event = null;
|
|
703
704
|
|
|
704
|
-
|
|
705
|
+
const result = database.transaction(() => {
|
|
705
706
|
const row = findCloudMutationRow(database, sharedResourceId, mutationId);
|
|
706
707
|
if (!row) throw invalid(`unknown shared mutation ${mutationId}`, 404);
|
|
707
708
|
if (row.mutation_state === 'synced') {
|
|
@@ -726,6 +727,15 @@ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknow
|
|
|
726
727
|
WHERE journal_id = ?
|
|
727
728
|
`).run(version, committedAt, now, row.journal_id);
|
|
728
729
|
advanceReplicaCursor(database, sharedResourceId);
|
|
730
|
+
event = insertStateEvent(database, {
|
|
731
|
+
resource: 'shared-mutations',
|
|
732
|
+
op: 'ack',
|
|
733
|
+
id: mutationId,
|
|
734
|
+
mutationId,
|
|
735
|
+
sharedResourceId,
|
|
736
|
+
resourceVersion: version,
|
|
737
|
+
source: 'cloud-ack',
|
|
738
|
+
});
|
|
729
739
|
trace('cloud-ack', { mutationId, shared: sharedResourceId, version });
|
|
730
740
|
return {
|
|
731
741
|
mutation: normalizeJournalRow(
|
|
@@ -735,6 +745,8 @@ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknow
|
|
|
735
745
|
duplicate: false,
|
|
736
746
|
};
|
|
737
747
|
})();
|
|
748
|
+
if (event) publishStateEvent(event);
|
|
749
|
+
return { ...result, event };
|
|
738
750
|
}
|
|
739
751
|
|
|
740
752
|
function failCloudMutation(sharedResourceIdInput, mutationIdInput, failure, options = {}) {
|
|
@@ -118,10 +118,34 @@ async function handleWorkspaceContentDiscover(body, sendJson) {
|
|
|
118
118
|
|
|
119
119
|
async function handlePromotionStart(body, sendJson) {
|
|
120
120
|
try {
|
|
121
|
+
const requestedResourceId = typeof body?.resourceId === 'string' && body.resourceId.trim()
|
|
122
|
+
? body.resourceId.trim()
|
|
123
|
+
: null;
|
|
124
|
+
const requestedNodeId = requestedResourceId
|
|
125
|
+
&& require('../workspace/tree/identity').isUuid(requestedResourceId)
|
|
126
|
+
? requestedResourceId.toLowerCase()
|
|
127
|
+
: null;
|
|
128
|
+
if (!requestedResourceId || requestedNodeId) {
|
|
129
|
+
require('../workspace/tree-store').ensureNodeForPath(body?.path);
|
|
130
|
+
}
|
|
121
131
|
const opened = require('./docs').openDoc(body?.path);
|
|
132
|
+
if (!requestedResourceId && !opened.nodeId) {
|
|
133
|
+
const error = new Error('document must belong to an enrolled workspace before it can be shared');
|
|
134
|
+
error.code = 'workspace_identity_required';
|
|
135
|
+
error.statusCode = 409;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
if (requestedNodeId && opened.nodeId !== requestedNodeId) {
|
|
139
|
+
const error = new Error('resourceId must equal the document node UUID');
|
|
140
|
+
error.code = 'workspace_identity_mismatch';
|
|
141
|
+
error.statusCode = 409;
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
122
144
|
const result = require('./promotions').enqueuePromotion({
|
|
123
145
|
promotionId: body?.promotionId || `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
124
|
-
|
|
146
|
+
// Workspace documents never acquire a second cloud identity. Explicit
|
|
147
|
+
// non-UUID ids remain available to internal protocol resources.
|
|
148
|
+
resourceId: requestedResourceId || opened.nodeId,
|
|
125
149
|
localResource: opened.resource,
|
|
126
150
|
localPath: opened.path,
|
|
127
151
|
contract: body?.contract || 'text-yjs@1',
|
|
@@ -187,6 +187,10 @@ test('materialization failure stays durable but invisible until recovery complet
|
|
|
187
187
|
|
|
188
188
|
test('shared rows alone form the leased cloud outbox and acknowledgements deduplicate', () => {
|
|
189
189
|
const db = openLocalDb();
|
|
190
|
+
const acknowledgedEvents = [];
|
|
191
|
+
const unsubscribe = subscribeStateEvents((event) => {
|
|
192
|
+
if (event.resource === 'shared-mutations') acknowledgedEvents.push(event);
|
|
193
|
+
});
|
|
190
194
|
const result = commitLocalMutation(sharedInput, {
|
|
191
195
|
database: db,
|
|
192
196
|
materialize() {},
|
|
@@ -248,6 +252,11 @@ test('shared rows alone form the leased cloud outbox and acknowledgements dedupl
|
|
|
248
252
|
{ database: db },
|
|
249
253
|
);
|
|
250
254
|
assert.equal(duplicate.duplicate, true);
|
|
255
|
+
unsubscribe();
|
|
256
|
+
assert.equal(acknowledgedEvents.length, 1);
|
|
257
|
+
assert.equal(acknowledgedEvents[0].mutationId, sharedInput.mutationId);
|
|
258
|
+
assert.equal(acknowledgedEvents[0].sharedResourceId, sharedResourceId);
|
|
259
|
+
assert.equal(acknowledgedEvents[0].version, 1);
|
|
251
260
|
assert.equal(claimCloudMutations({ database: db }).length, 0);
|
|
252
261
|
});
|
|
253
262
|
|
|
@@ -28,6 +28,9 @@ fs.writeFileSync(
|
|
|
28
28
|
|
|
29
29
|
const { closeLocalDb } = require('../state/db');
|
|
30
30
|
const { handlePromotionStart } = require('../state/shared-rest');
|
|
31
|
+
const { upsertWorkspace } = require('../workspace/store');
|
|
32
|
+
const { enrollWorkspace, nodeForPath } = require('../workspace/tree-store');
|
|
33
|
+
const { isUuidV7, uuidV7 } = require('../workspace/tree/identity');
|
|
31
34
|
|
|
32
35
|
test.after(() => {
|
|
33
36
|
closeLocalDb();
|
|
@@ -43,9 +46,9 @@ async function run(cwd, ...args) {
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
/** Run the handler, capture what it would send over the wire. */
|
|
46
|
-
async function promote(filePath) {
|
|
49
|
+
async function promote(filePath, input = {}) {
|
|
47
50
|
let sent;
|
|
48
|
-
await handlePromotionStart({ path: filePath }, (statusCode, payload) => {
|
|
51
|
+
await handlePromotionStart({ path: filePath, ...input }, (statusCode, payload) => {
|
|
49
52
|
sent = { statusCode, payload };
|
|
50
53
|
});
|
|
51
54
|
return sent;
|
|
@@ -59,9 +62,13 @@ test('a git-tracked text file promotes through the document rail', async () => {
|
|
|
59
62
|
fs.writeFileSync(tracked, '# tracked\n');
|
|
60
63
|
await run(repo, 'add', '.');
|
|
61
64
|
await run(repo, 'commit', '-m', 'initial');
|
|
65
|
+
const workspace = upsertWorkspace({ path: repo, name: 'Repo', source: 'computer' });
|
|
66
|
+
enrollWorkspace(workspace.id);
|
|
62
67
|
|
|
63
68
|
const { statusCode, payload } = await promote(tracked);
|
|
64
69
|
assert.equal(statusCode, 202, JSON.stringify(payload));
|
|
70
|
+
assert.equal(payload.promotion.resourceId, nodeForPath(tracked).node.id);
|
|
71
|
+
assert.equal(isUuidV7(payload.promotion.resourceId), true);
|
|
65
72
|
});
|
|
66
73
|
|
|
67
74
|
test('an untracked file inside a repo has no git owner and promotes', async () => {
|
|
@@ -71,12 +78,32 @@ test('an untracked file inside a repo has no git owner and promotes', async () =
|
|
|
71
78
|
|
|
72
79
|
const { statusCode, payload } = await promote(untracked);
|
|
73
80
|
assert.equal(statusCode, 202, JSON.stringify(payload));
|
|
81
|
+
assert.equal(payload.promotion.resourceId, nodeForPath(untracked).node.id);
|
|
82
|
+
assert.equal(isUuidV7(payload.promotion.resourceId), true);
|
|
74
83
|
});
|
|
75
84
|
|
|
76
|
-
test('a
|
|
85
|
+
test('a user document outside an enrolled workspace never receives a fallback shared id', async () => {
|
|
77
86
|
const loose = path.join(tempRoot, 'loose.md');
|
|
78
87
|
fs.writeFileSync(loose, '# loose\n');
|
|
79
88
|
|
|
80
89
|
const { statusCode, payload } = await promote(loose);
|
|
90
|
+
assert.equal(statusCode, 409, JSON.stringify(payload));
|
|
91
|
+
assert.equal(payload.code, 'workspace_identity_required');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('an explicit workspace resource id must equal the local document UUID', async () => {
|
|
95
|
+
const tracked = path.join(tempRoot, 'repo', 'README.md');
|
|
96
|
+
const { statusCode, payload } = await promote(tracked, { resourceId: uuidV7() });
|
|
97
|
+
assert.equal(statusCode, 409, JSON.stringify(payload));
|
|
98
|
+
assert.equal(payload.code, 'workspace_identity_mismatch');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('internal protocol documents may retain an explicit shared-resource namespace', async () => {
|
|
102
|
+
const protocolFile = path.join(tempRoot, 'machine-card.json');
|
|
103
|
+
fs.writeFileSync(protocolFile, '{}\n');
|
|
104
|
+
|
|
105
|
+
const resourceId = 'shr_machine_card_test_0001';
|
|
106
|
+
const { statusCode, payload } = await promote(protocolFile, { resourceId });
|
|
81
107
|
assert.equal(statusCode, 202, JSON.stringify(payload));
|
|
108
|
+
assert.equal(payload.promotion.resourceId, resourceId);
|
|
82
109
|
});
|
|
@@ -85,21 +85,20 @@ test('refresh cards the tracked world once, and a no-change refresh writes nothi
|
|
|
85
85
|
assert.equal(cardEvents().length, eventsAfterFirst.length);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
-
test('
|
|
88
|
+
test('repo cards contain observations, not a machine-clock ordering field', async () => {
|
|
89
89
|
const repo = path.join(tempRoot, 'projects/app');
|
|
90
90
|
const before = findCard(listRepoCards(), repo);
|
|
91
|
-
assert.equal(before
|
|
91
|
+
assert.equal(Object.hasOwn(before, 'positionAt'), false);
|
|
92
92
|
|
|
93
93
|
fs.writeFileSync(path.join(repo, 'position.txt'), 'working tree\n');
|
|
94
94
|
const dirty = findCard((await refreshRepoCards()).cards, repo);
|
|
95
|
-
assert.equal(dirty
|
|
95
|
+
assert.equal(Object.hasOwn(dirty, 'positionAt'), false);
|
|
96
96
|
|
|
97
97
|
await run(repo, 'add', 'position.txt');
|
|
98
98
|
await run(repo, 'commit', '-m', 'move committed position');
|
|
99
99
|
const committed = findCard((await refreshRepoCards()).cards, repo);
|
|
100
100
|
assert.notEqual(committed.head.oid, before.head.oid);
|
|
101
|
-
assert.equal(committed
|
|
102
|
-
assert.notEqual(committed.positionAt, before.positionAt);
|
|
101
|
+
assert.equal(Object.hasOwn(committed, 'positionAt'), false);
|
|
103
102
|
});
|
|
104
103
|
|
|
105
104
|
test('a 4,000-file branch switch is exactly one generation bump and one event', async () => {
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
const { execFile } = require('child_process');
|
|
9
|
+
const { promisify } = require('util');
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-checkpoint-reducer-'));
|
|
13
|
+
const merge = require('../workspace/merge');
|
|
14
|
+
|
|
15
|
+
test.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
16
|
+
|
|
17
|
+
async function git(cwd, ...args) {
|
|
18
|
+
const { stdout } = await execFileAsync(
|
|
19
|
+
'git',
|
|
20
|
+
['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
|
|
21
|
+
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
22
|
+
);
|
|
23
|
+
return stdout;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function makeRepo(name) {
|
|
27
|
+
const repo = path.join(root, name);
|
|
28
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
29
|
+
await git(repo, 'init', '-b', 'main');
|
|
30
|
+
fs.writeFileSync(path.join(repo, 'a.txt'), 'a0\n');
|
|
31
|
+
fs.writeFileSync(path.join(repo, 'b.txt'), 'b0\n');
|
|
32
|
+
fs.writeFileSync(path.join(repo, 'same.txt'), 'line 1\nline 2\n');
|
|
33
|
+
await git(repo, 'add', '.');
|
|
34
|
+
await git(repo, 'commit', '-m', 'base');
|
|
35
|
+
const oid = (await git(repo, 'rev-parse', 'HEAD')).trim();
|
|
36
|
+
const tree = await merge.treeOf(repo, oid);
|
|
37
|
+
return { repo, oid, tree };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function patchFor(repo, edits) {
|
|
41
|
+
for (const [name, content] of Object.entries(edits)) {
|
|
42
|
+
fs.writeFileSync(path.join(repo, name), content);
|
|
43
|
+
}
|
|
44
|
+
const diff = await git(repo, 'diff', 'HEAD', '--binary', '--full-index');
|
|
45
|
+
await git(repo, 'reset', '--hard', 'HEAD');
|
|
46
|
+
return diff;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function treeText(repo, tree, file) {
|
|
50
|
+
return git(repo, 'show', `${tree}:${file}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('rail-ordered checkpoint transitions combine and a later revert removes only its change', async () => {
|
|
54
|
+
const { repo, oid, tree: clean } = await makeRepo('combine');
|
|
55
|
+
const patchA = await patchFor(repo, { 'a.txt': 'a1\n' });
|
|
56
|
+
const patchB = await patchFor(repo, { 'b.txt': 'b1\n' });
|
|
57
|
+
const treeA = await merge.treeOfPatch(repo, oid, patchA);
|
|
58
|
+
const treeB = await merge.treeOfPatch(repo, oid, patchB);
|
|
59
|
+
|
|
60
|
+
const afterA = await merge.reduceTree(repo, clean, clean, treeA, { label: '1' });
|
|
61
|
+
const afterB = await merge.reduceTree(repo, afterA.tree, clean, treeB, { label: '2' });
|
|
62
|
+
assert.equal(await treeText(repo, afterB.tree, 'a.txt'), 'a1\n');
|
|
63
|
+
assert.equal(await treeText(repo, afterB.tree, 'b.txt'), 'b1\n');
|
|
64
|
+
assert.deepEqual(afterB.conflicts, []);
|
|
65
|
+
|
|
66
|
+
// A's next operation says "from A+B to B": the reducer can remove A even
|
|
67
|
+
// though B's latest checkpoint already incorporated A.
|
|
68
|
+
const reverted = await merge.reduceTree(repo, afterB.tree, afterB.tree, treeB, { label: '3' });
|
|
69
|
+
assert.equal(await treeText(repo, reverted.tree, 'a.txt'), 'a0\n');
|
|
70
|
+
assert.equal(await treeText(repo, reverted.tree, 'b.txt'), 'b1\n');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('an overlapping transition produces deterministic conflict bytes', async () => {
|
|
74
|
+
const { repo, oid, tree: clean } = await makeRepo('conflict');
|
|
75
|
+
const leftPatch = await patchFor(repo, { 'same.txt': 'left\nline 2\n' });
|
|
76
|
+
const rightPatch = await patchFor(repo, { 'same.txt': 'right\nline 2\n' });
|
|
77
|
+
const left = await merge.treeOfPatch(repo, oid, leftPatch);
|
|
78
|
+
const right = await merge.treeOfPatch(repo, oid, rightPatch);
|
|
79
|
+
|
|
80
|
+
const current = await merge.reduceTree(repo, clean, clean, left, { label: '10' });
|
|
81
|
+
const first = await merge.reduceTree(repo, current.tree, clean, right, { label: '11' });
|
|
82
|
+
const second = await merge.reduceTree(repo, current.tree, clean, right, { label: '11' });
|
|
83
|
+
|
|
84
|
+
assert.equal(first.tree, second.tree);
|
|
85
|
+
assert.deepEqual(first.conflicts, second.conflicts);
|
|
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/);
|
|
91
|
+
});
|
|
@@ -23,9 +23,10 @@ fs.writeFileSync(
|
|
|
23
23
|
);
|
|
24
24
|
|
|
25
25
|
const { closeLocalDb } = require('../state/db');
|
|
26
|
-
const { captureWorktreeDiff } = require('../workspace/git');
|
|
26
|
+
const { captureCheckpoint, captureWorktreeDiff } = require('../workspace/git');
|
|
27
|
+
const merge = require('../workspace/merge');
|
|
27
28
|
const { applyPatch } = require('../workspace/transition');
|
|
28
|
-
const {
|
|
29
|
+
const { checkpointFile, revisionOf } = require('../workspace/checkpoint');
|
|
29
30
|
|
|
30
31
|
test.after(() => {
|
|
31
32
|
closeLocalDb();
|
|
@@ -40,6 +41,15 @@ async function run(cwd, ...args) {
|
|
|
40
41
|
);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
async function runOutput(cwd, ...args) {
|
|
45
|
+
const { stdout } = await execFileAsync(
|
|
46
|
+
'git',
|
|
47
|
+
args,
|
|
48
|
+
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
|
|
49
|
+
);
|
|
50
|
+
return stdout;
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
async function makeRepoPair() {
|
|
44
54
|
const stamp = Math.random().toString(36).slice(2, 8);
|
|
45
55
|
const author = path.join(tempRoot, `author-${stamp}`);
|
|
@@ -114,6 +124,25 @@ test('capture is stable: two reads of an untouched tree produce the same revisio
|
|
|
114
124
|
assert.equal(revisionOf(first.baseOid, first.diff), revisionOf(second.baseOid, second.diff));
|
|
115
125
|
});
|
|
116
126
|
|
|
127
|
+
test('one checkpoint preserves the staged and unstaged layers separately', async () => {
|
|
128
|
+
const { author } = await makeRepoPair();
|
|
129
|
+
fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(2);\n');
|
|
130
|
+
await run(author, 'add', 'src/index.js');
|
|
131
|
+
fs.writeFileSync(path.join(author, 'src', 'index.js'), 'console.log(3);\n');
|
|
132
|
+
fs.appendFileSync(path.join(author, 'README.md'), 'unstaged\n');
|
|
133
|
+
|
|
134
|
+
const captured = await captureCheckpoint(author);
|
|
135
|
+
assert.ok(captured.indexDiff.includes('console.log(2)'));
|
|
136
|
+
assert.ok(!captured.indexDiff.includes('console.log(3)'));
|
|
137
|
+
assert.ok(captured.worktreeDiff.includes('console.log(3)'));
|
|
138
|
+
assert.ok(captured.worktreeDiff.includes('unstaged'));
|
|
139
|
+
|
|
140
|
+
const indexTree = await merge.treeOfPatch(author, captured.baseOid, captured.indexDiff);
|
|
141
|
+
const worktreeTree = await merge.treeOfPatch(author, indexTree, captured.worktreeDiff);
|
|
142
|
+
assert.equal(await runOutput(author, 'show', `${indexTree}:src/index.js`), 'console.log(2);\n');
|
|
143
|
+
assert.equal(await runOutput(author, 'show', `${worktreeTree}:src/index.js`), 'console.log(3);\n');
|
|
144
|
+
});
|
|
145
|
+
|
|
117
146
|
test('a clean tree captures as an empty patch, and oversized state is a named refusal', async () => {
|
|
118
147
|
const { author } = await makeRepoPair();
|
|
119
148
|
const clean = await captureWorktreeDiff(author);
|
|
@@ -125,13 +154,10 @@ test('a clean tree captures as an empty patch, and oversized state is a named re
|
|
|
125
154
|
assert.equal(bounded.reason, 'too-large');
|
|
126
155
|
});
|
|
127
156
|
|
|
128
|
-
test('checkpoint
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
assert.
|
|
132
|
-
assert.
|
|
133
|
-
assert.
|
|
134
|
-
assert.notEqual(id, checkpointResourceId('m1', 'https://github.com/a/c', user));
|
|
135
|
-
assert.notEqual(id, checkpointResourceId('m1', 'https://github.com/a/b', 'other-user'));
|
|
136
|
-
assert.equal(checkpointResourceId(null, 'x', user), null);
|
|
157
|
+
test('checkpoint captures are local cache; their operations travel on the one wall stream', () => {
|
|
158
|
+
const first = checkpointFile('m1', 'https://github.com/a/b');
|
|
159
|
+
assert.equal(first, checkpointFile('m1', 'https://github.com/a/b'));
|
|
160
|
+
assert.notEqual(first, checkpointFile('m2', 'https://github.com/a/b'));
|
|
161
|
+
assert.notEqual(first, checkpointFile('m1', 'https://github.com/a/c'));
|
|
162
|
+
assert.match(first, /repo-checkpoints-v4/);
|
|
137
163
|
});
|
|
@@ -20,6 +20,7 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
20
20
|
const uploadSha = crypto.createHash('sha256').update(uploadBytes).digest('hex');
|
|
21
21
|
const downloadBytes = Buffer.from('peer checkpoint chunk');
|
|
22
22
|
const downloadSha = crypto.createHash('sha256').update(downloadBytes).digest('hex');
|
|
23
|
+
const resourceId = 'shr_cp1abcdefghijklmnopqrstuvwx';
|
|
23
24
|
let uploadClaimed = false;
|
|
24
25
|
let downloadClaimed = false;
|
|
25
26
|
const calls = [];
|
|
@@ -42,6 +43,7 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
42
43
|
}
|
|
43
44
|
if (req.url === '/state/repo-objects/uploads/claim') {
|
|
44
45
|
const upload = uploadClaimed ? null : {
|
|
46
|
+
resourceId,
|
|
45
47
|
sha256: uploadSha,
|
|
46
48
|
bytes: uploadBytes.length,
|
|
47
49
|
dataBase64: uploadBytes.toString('base64'),
|
|
@@ -51,7 +53,7 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
51
53
|
return;
|
|
52
54
|
}
|
|
53
55
|
if (req.url === '/state/repo-objects/downloads/claim') {
|
|
54
|
-
const download = downloadClaimed ? null : { sha256: downloadSha };
|
|
56
|
+
const download = downloadClaimed ? null : { resourceId, sha256: downloadSha };
|
|
55
57
|
downloadClaimed = true;
|
|
56
58
|
res.end(JSON.stringify({ download }));
|
|
57
59
|
return;
|
|
@@ -80,8 +82,10 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
80
82
|
const put = frames.find((frame) => frame.type === 'repo.object.put');
|
|
81
83
|
const get = frames.find((frame) => frame.type === 'repo.object.get');
|
|
82
84
|
assert.equal(put.sha256, uploadSha);
|
|
85
|
+
assert.equal(put.resource_id, resourceId);
|
|
83
86
|
assert.deepEqual(Buffer.from(put.data_b64, 'base64'), uploadBytes);
|
|
84
87
|
assert.equal(get.sha256, downloadSha);
|
|
88
|
+
assert.equal(get.resource_id, resourceId);
|
|
85
89
|
|
|
86
90
|
tunnel.handleFrame({
|
|
87
91
|
type: 'repo.object.stored',
|
|
@@ -107,7 +111,11 @@ test('repo object chunks travel through the shared tunnel one at a time', async
|
|
|
107
111
|
);
|
|
108
112
|
assert.deepEqual(
|
|
109
113
|
calls.find((call) => call.path === '/state/repo-objects/downloads/receive').body,
|
|
110
|
-
{
|
|
114
|
+
{
|
|
115
|
+
resourceId,
|
|
116
|
+
sha256: downloadSha,
|
|
117
|
+
dataBase64: downloadBytes.toString('base64'),
|
|
118
|
+
},
|
|
111
119
|
);
|
|
112
120
|
} finally {
|
|
113
121
|
tunnel.stop();
|