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.
Files changed (34) hide show
  1. package/lib/shared-realtime-tunnel.js +28 -12
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/index.js +1 -1
  4. package/runtime/scripts/amalgm-mcp/server/routes/state.js +5 -1
  5. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/state/docs.js +13 -6
  7. package/runtime/scripts/amalgm-mcp/state/migrations.js +0 -4
  8. package/runtime/scripts/amalgm-mcp/state/mutations.js +13 -1
  9. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +25 -1
  10. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +9 -0
  11. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +30 -3
  12. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +4 -5
  13. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +91 -0
  14. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +37 -11
  15. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +10 -2
  16. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +14 -6
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +324 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-store.test.js +36 -1
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +81 -48
  20. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -1
  21. package/runtime/scripts/amalgm-mcp/workspace/cards.js +9 -21
  22. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +187 -122
  23. package/runtime/scripts/amalgm-mcp/workspace/git.js +71 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/merge.js +119 -2
  25. package/runtime/scripts/amalgm-mcp/workspace/objects.js +49 -17
  26. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +582 -0
  27. package/runtime/scripts/amalgm-mcp/workspace/rest.js +15 -1
  28. package/runtime/scripts/amalgm-mcp/workspace/store.js +8 -18
  29. package/runtime/scripts/amalgm-mcp/workspace/transition.js +67 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +3 -4
  31. package/runtime/scripts/amalgm-mcp/workspace/wall.js +331 -180
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +0 -359
  33. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +0 -96
  34. 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, upload.sha256);
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({ type: 'repo.object.get', request_id: requestId, sha256: download.sha256 })) {
304
- repoDownloadInflight.set(requestId, download.sha256);
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 digest = repoUploadInflight.get(frame.request_id);
454
- if (!digest || digest !== frame.sha256) return;
455
- void localStateJson('POST', '/state/repo-objects/uploads/ack', { sha256: digest })
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 digest = repoDownloadInflight.get(frame.request_id);
468
- if (!digest || digest !== frame.sha256) return;
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
- sha256: digest,
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 ${digest} was not stored locally: ${error?.message || error}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.175",
3
+ "version": "0.1.176",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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/reconcile').startWorkspaceReconciler();
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(body?.sha256));
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: newMutationId(), update: origin.patch.yjs, bytes: null };
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
- return database.transaction(() => {
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
- resourceId: body?.resourceId || opened.nodeId || `shr_${crypto.randomBytes(18).toString('base64url')}`,
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 file outside any repository promotes', async () => {
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('working-tree changes preserve the committed-position clock', async () => {
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.positionAt, before.observedAt);
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.positionAt, before.positionAt);
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.positionAt, committed.observedAt);
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 { checkpointResourceId, revisionOf } = require('../workspace/checkpoint');
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 resource ids are stable per (user, machine, repo) and distinct across each', () => {
129
- const user = '11111111-1111-4111-8111-111111111111';
130
- const id = checkpointResourceId('m1', 'https://github.com/a/b', user);
131
- assert.equal(id, checkpointResourceId('m1', 'https://github.com/a/b', user));
132
- assert.match(id, /^shr_[A-Za-z0-9_-]{16,128}$/);
133
- assert.notEqual(id, checkpointResourceId('m2', 'https://github.com/a/b', user));
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
- { sha256: downloadSha, dataBase64: downloadBytes.toString('base64') },
114
+ {
115
+ resourceId,
116
+ sha256: downloadSha,
117
+ dataBase64: downloadBytes.toString('base64'),
118
+ },
111
119
  );
112
120
  } finally {
113
121
  tunnel.stop();