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
@@ -27,6 +27,7 @@
27
27
  * goes through transition.applyMergedFiles so it lands in the journal.
28
28
  */
29
29
 
30
+ const crypto = require('crypto');
30
31
  const fs = require('fs');
31
32
  const os = require('os');
32
33
  const path = require('path');
@@ -102,6 +103,21 @@ async function treeOfPatch(worktreeRoot, baseOid, diff) {
102
103
  });
103
104
  }
104
105
 
106
+ /** The exact binary patch that turns a commit into a tree. */
107
+ async function diffOfTree(worktreeRoot, baseOid, treeOid) {
108
+ const { stdout } = await run(
109
+ ['-c', 'core.quotepath=false', 'diff', '--binary', '--full-index', baseOid, treeOid],
110
+ worktreeRoot,
111
+ );
112
+ return stdout;
113
+ }
114
+
115
+ /** The exact tree currently represented by Git's index. */
116
+ async function indexTree(worktreeRoot) {
117
+ const { stdout } = await run(['write-tree'], worktreeRoot);
118
+ return stdout.trim();
119
+ }
120
+
105
121
  /** Paths that differ between two trees, no rename detection — exact adds/deletes. */
106
122
  async function changedPaths(worktreeRoot, treeA, treeB) {
107
123
  const { stdout } = await run(
@@ -158,7 +174,7 @@ function isText(buffer) {
158
174
  }
159
175
 
160
176
  /** Content-level three-way merge; { ok, content } — never writes markers to disk. */
161
- async function mergeFileContents(ours, base, theirs) {
177
+ async function mergeFileContents(ours, base, theirs, labels = {}) {
162
178
  return withScratch(async (dir) => {
163
179
  const oursFile = path.join(dir, 'ours');
164
180
  const baseFile = path.join(dir, 'base');
@@ -168,7 +184,13 @@ async function mergeFileContents(ours, base, theirs) {
168
184
  await fs.promises.writeFile(theirsFile, theirs);
169
185
  const conflictCodes = Array.from({ length: 127 }, (_, i) => i + 1);
170
186
  const result = await run(
171
- ['merge-file', '-p', '-L', 'local', '-L', 'base', '-L', 'peer', oursFile, baseFile, theirsFile],
187
+ [
188
+ 'merge-file', '-p',
189
+ '-L', labels.ours || 'current',
190
+ '-L', labels.base || 'base',
191
+ '-L', labels.theirs || 'incoming',
192
+ oursFile, baseFile, theirsFile,
193
+ ],
172
194
  dir,
173
195
  { okCodes: conflictCodes, binary: true },
174
196
  );
@@ -176,6 +198,98 @@ async function mergeFileContents(ours, base, theirs) {
176
198
  });
177
199
  }
178
200
 
201
+ function sameEntry(left, right) {
202
+ return left === null
203
+ ? right === null
204
+ : right !== null && left.mode === right.mode && left.type === right.type && left.oid === right.oid;
205
+ }
206
+
207
+ async function storedBlob(worktreeRoot, content, scratchDir, name) {
208
+ const file = path.join(scratchDir, name);
209
+ await fs.promises.writeFile(file, content);
210
+ const { stdout } = await run(['hash-object', '-w', file], worktreeRoot);
211
+ return stdout.trim();
212
+ }
213
+
214
+ function conflictCopyPath(relPath, label) {
215
+ const digest = crypto.createHash('sha256').update(`${label}\0${relPath}`).digest('hex').slice(0, 16);
216
+ return `.amalgm-conflicts/${digest}/${relPath}`;
217
+ }
218
+
219
+ /**
220
+ * Pure checkpoint reduction. Apply one published transition
221
+ * (`baseTree → targetTree`) to the currently reduced tree. No working file
222
+ * is read or written. Disjoint changes combine, identical changes fold, and
223
+ * overlaps produce the same conflict bytes and paths on every machine.
224
+ */
225
+ async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree, { label = 'rail' } = {}) {
226
+ const updates = [];
227
+ const conflicts = [];
228
+
229
+ await withScratch(async (dir) => {
230
+ let stored = 0;
231
+ for (const relPath of await changedPaths(worktreeRoot, baseTree, targetTree)) {
232
+ if (unsafePath(relPath)) {
233
+ conflicts.push({ path: relPath, kind: 'unsafe-path', label });
234
+ continue;
235
+ }
236
+ const base = await entryAt(worktreeRoot, baseTree, relPath);
237
+ const current = await entryAt(worktreeRoot, currentTree, relPath);
238
+ const target = await entryAt(worktreeRoot, targetTree, relPath);
239
+
240
+ if (sameEntry(current, target) || sameEntry(base, target)) continue;
241
+ if (sameEntry(current, base)) {
242
+ updates.push({ path: relPath, entry: target });
243
+ continue;
244
+ }
245
+
246
+ const baseBytes = base?.type === 'blob' ? await readBlob(worktreeRoot, base.oid) : null;
247
+ const currentBytes = current?.type === 'blob' ? await readBlob(worktreeRoot, current.oid) : null;
248
+ const targetBytes = target?.type === 'blob' ? await readBlob(worktreeRoot, target.oid) : null;
249
+ const mergeable = base && current && target
250
+ && base.type === 'blob' && current.type === 'blob' && target.type === 'blob'
251
+ && base.mode !== '120000' && current.mode !== '120000' && target.mode !== '120000'
252
+ && isText(baseBytes) && isText(currentBytes) && isText(targetBytes);
253
+
254
+ if (mergeable) {
255
+ const merged = await mergeFileContents(currentBytes, baseBytes, targetBytes, {
256
+ ours: 'current',
257
+ base: 'shared-base',
258
+ theirs: `rail-${label}`,
259
+ });
260
+ const oid = await storedBlob(worktreeRoot, merged.content, dir, `merged-${stored++}`);
261
+ updates.push({ path: relPath, entry: { mode: current.mode, type: 'blob', oid } });
262
+ if (!merged.ok) conflicts.push({ path: relPath, kind: 'content-overlap', label });
263
+ continue;
264
+ }
265
+
266
+ // Binary/delete/add conflicts retain the current path and, when the
267
+ // incoming side has bytes, preserve those bytes at one deterministic
268
+ // conflict path. The reducer's conflict record preserves delete intent.
269
+ const kind = !target
270
+ ? 'delete-vs-change'
271
+ : !current
272
+ ? 'change-vs-delete'
273
+ : !base
274
+ ? 'add-add'
275
+ : 'binary-overlap';
276
+ const conflict = { path: relPath, kind, label };
277
+ if (targetBytes) {
278
+ const copyPath = conflictCopyPath(relPath, label);
279
+ const oid = await storedBlob(worktreeRoot, targetBytes, dir, `conflict-${stored++}`);
280
+ updates.push({ path: copyPath, entry: { mode: '100644', type: 'blob', oid } });
281
+ conflict.copyPath = copyPath;
282
+ }
283
+ conflicts.push(conflict);
284
+ }
285
+ });
286
+
287
+ return {
288
+ tree: updates.length ? await updateHeldTree(worktreeRoot, currentTree, updates) : currentTree,
289
+ conflicts,
290
+ };
291
+ }
292
+
179
293
  /** heldTree with the given entries replaced/removed — via a scratch index. */
180
294
  async function updateHeldTree(worktreeRoot, heldTree, applied) {
181
295
  return withScratch(async (dir) => {
@@ -284,7 +398,10 @@ async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
284
398
 
285
399
  module.exports = {
286
400
  advance,
401
+ diffOfTree,
287
402
  hasTree,
403
+ indexTree,
404
+ reduceTree,
288
405
  treeOf,
289
406
  treeOfPatch,
290
407
  };
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * A card names one content-addressed bundle when origin cannot supply its
7
7
  * commit. The bundle is split into fixed-size opaque chunks; the tunnel
8
- * stores and retrieves those chunks under the authenticated user. Chunking
8
+ * stores and retrieves those chunks under the authorized code project. Chunking
9
9
  * changes transport size, never the two-value repo model.
10
10
  */
11
11
 
@@ -63,6 +63,7 @@ function writeJson(file, value) {
63
63
  function publicLocator(manifest) {
64
64
  return {
65
65
  contract: CONTRACT,
66
+ resourceId: manifest.resourceId,
66
67
  oid: manifest.oid,
67
68
  ref: manifest.ref,
68
69
  checksum: manifest.checksum,
@@ -75,6 +76,7 @@ function publicPayloadLocator(manifest) {
75
76
  return {
76
77
  kind: 'chunks',
77
78
  contract: PAYLOAD_CONTRACT,
79
+ resourceId: manifest.resourceId,
78
80
  checksum: manifest.checksum,
79
81
  bytes: manifest.bytes,
80
82
  chunks: manifest.chunks.map(({ sha256: digest, bytes }) => ({ sha256: digest, bytes })),
@@ -97,7 +99,7 @@ function wake(op, id) {
97
99
  appendStateEvent({ resource: 'repo-objects', op, id, source: 'repo-objects' });
98
100
  }
99
101
 
100
- async function prepareBundle(worktreeRoot, repoKey, oid, file) {
102
+ async function prepareBundle(worktreeRoot, repoKey, resourceId, oid, file) {
101
103
  ensureDirs();
102
104
  const bundleFile = path.join(ROOT, `${crypto.randomBytes(12).toString('hex')}.bundle`);
103
105
  const ref = `refs/amalgm/exports/${oid}`;
@@ -107,6 +109,7 @@ async function prepareBundle(worktreeRoot, repoKey, oid, file) {
107
109
  const chunks = writeChunks(bundle);
108
110
  writeJson(file, {
109
111
  contract: CONTRACT,
112
+ resourceId,
110
113
  repoKey,
111
114
  oid,
112
115
  ref,
@@ -121,15 +124,21 @@ async function prepareBundle(worktreeRoot, repoKey, oid, file) {
121
124
  }
122
125
  }
123
126
 
124
- function publishPayload(key, bytesInput) {
127
+ function publishPayload(key, bytesInput, repoKey) {
125
128
  ensureDirs();
126
129
  const bytes = Buffer.isBuffer(bytesInput) ? bytesInput : Buffer.from(bytesInput);
127
130
  const file = payloadFile(key);
128
131
  let manifest = readJson(file);
129
132
  const checksum = sha256(bytes);
130
133
  if (!manifest || manifest.checksum !== checksum) {
134
+ const project = require('./wall').projectForRepo(repoKey);
135
+ if (!project?.resourceId) {
136
+ return { kind: 'pending', contract: PAYLOAD_CONTRACT, checksum };
137
+ }
131
138
  manifest = {
132
139
  contract: PAYLOAD_CONTRACT,
140
+ resourceId: project.resourceId,
141
+ repoKey,
133
142
  checksum,
134
143
  bytes: bytes.length,
135
144
  chunks: writeChunks(bytes),
@@ -143,10 +152,8 @@ function publishPayload(key, bytesInput) {
143
152
  : { kind: 'pending', contract: PAYLOAD_CONTRACT, checksum };
144
153
  }
145
154
 
146
- async function objectSourceFor(worktreeRoot, repoKey, oid) {
155
+ async function bundleSourceFor(worktreeRoot, repoKey, oid) {
147
156
  if (!oid || !repoKey) return null;
148
- if (await originContains(worktreeRoot, oid)) return { kind: 'remote' };
149
-
150
157
  const file = manifestFile(repoKey, oid);
151
158
  const manifest = readJson(file);
152
159
  if (manifest?.contract === CONTRACT && manifest.oid === oid) {
@@ -155,8 +162,10 @@ async function objectSourceFor(worktreeRoot, repoKey, oid) {
155
162
  : { kind: 'pending' };
156
163
  }
157
164
 
165
+ const project = require('./wall').projectForRepo(repoKey);
166
+ if (!project?.resourceId) return { kind: 'pending' };
158
167
  if (!preparing.has(file)) {
159
- const promise = prepareBundle(worktreeRoot, repoKey, oid, file)
168
+ const promise = prepareBundle(worktreeRoot, repoKey, project.resourceId, oid, file)
160
169
  .catch((error) => console.warn(`[RepoObjects] bundle ${oid} failed:`, error.message))
161
170
  .finally(() => {
162
171
  preparing.delete(file);
@@ -168,6 +177,12 @@ async function objectSourceFor(worktreeRoot, repoKey, oid) {
168
177
  return { kind: 'pending' };
169
178
  }
170
179
 
180
+ async function objectSourceFor(worktreeRoot, repoKey, oid) {
181
+ if (!oid || !repoKey) return null;
182
+ if (await originContains(worktreeRoot, oid)) return { kind: 'remote' };
183
+ return bundleSourceFor(worktreeRoot, repoKey, oid);
184
+ }
185
+
171
186
  function claimUpload() {
172
187
  ensureDirs();
173
188
  for (const directory of [MANIFESTS, PAYLOADS]) {
@@ -176,13 +191,18 @@ function claimUpload() {
176
191
  const chunk = manifest?.chunks?.find((entry) => !entry.uploaded);
177
192
  if (!chunk) continue;
178
193
  const bytes = fs.readFileSync(path.join(CHUNKS, `${chunk.sha256}.bin`));
179
- return { sha256: chunk.sha256, bytes: bytes.length, dataBase64: bytes.toString('base64') };
194
+ return {
195
+ resourceId: manifest.resourceId,
196
+ sha256: chunk.sha256,
197
+ bytes: bytes.length,
198
+ dataBase64: bytes.toString('base64'),
199
+ };
180
200
  }
181
201
  }
182
202
  return null;
183
203
  }
184
204
 
185
- function acknowledgeUpload(digest) {
205
+ function acknowledgeUpload(resourceId, digest) {
186
206
  if (!/^[0-9a-f]{64}$/.test(String(digest || ''))) return { changed: false };
187
207
  ensureDirs();
188
208
  let changed = false;
@@ -191,7 +211,7 @@ function acknowledgeUpload(digest) {
191
211
  for (const name of fs.readdirSync(directory)) {
192
212
  const file = path.join(directory, name);
193
213
  const manifest = readJson(file);
194
- if (!manifest?.chunks) continue;
214
+ if (manifest?.resourceId !== resourceId || !manifest?.chunks) continue;
195
215
  let touched = false;
196
216
  for (const chunk of manifest.chunks) {
197
217
  if (chunk.sha256 === digest && !chunk.uploaded) {
@@ -220,6 +240,7 @@ function validatePayloadLocator(locator) {
220
240
  if (
221
241
  locator?.kind !== 'chunks'
222
242
  || locator?.contract !== PAYLOAD_CONTRACT
243
+ || !/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(locator.resourceId || ''))
223
244
  || !/^[0-9a-f]{64}$/.test(String(locator.checksum || ''))
224
245
  || !Number.isSafeInteger(locator.bytes)
225
246
  || locator.bytes < 0
@@ -240,7 +261,10 @@ function readPayload(locator) {
240
261
  for (const chunk of locator.chunks) {
241
262
  const file = path.join(CHUNKS, `${chunk.sha256}.bin`);
242
263
  if (fs.existsSync(file) && fs.statSync(file).size === chunk.bytes) continue;
243
- writeJson(requestFile(chunk.sha256), { sha256: chunk.sha256 });
264
+ writeJson(requestFile(locator.resourceId, chunk.sha256), {
265
+ resourceId: locator.resourceId,
266
+ sha256: chunk.sha256,
267
+ });
244
268
  waiting = true;
245
269
  }
246
270
  if (waiting) {
@@ -261,8 +285,8 @@ function readPayload(locator) {
261
285
  return { state: 'ready', bytes };
262
286
  }
263
287
 
264
- function requestFile(digest) {
265
- return path.join(REQUESTS, `${digest}.json`);
288
+ function requestFile(resourceId, digest) {
289
+ return path.join(REQUESTS, `${sha256(Buffer.from(`${resourceId}\n${digest}`))}.json`);
266
290
  }
267
291
 
268
292
  function claimDownload() {
@@ -273,21 +297,25 @@ function claimDownload() {
273
297
  return request?.sha256 ? request : null;
274
298
  }
275
299
 
276
- function receiveDownload(digest, dataBase64) {
300
+ function receiveDownload(resourceId, digest, dataBase64) {
301
+ if (!/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(resourceId || ''))) {
302
+ throw new Error('invalid repo object project');
303
+ }
277
304
  if (!/^[0-9a-f]{64}$/.test(String(digest || ''))) throw new Error('invalid repo object checksum');
278
305
  ensureDirs();
279
306
  const bytes = Buffer.from(String(dataBase64 || ''), 'base64');
280
307
  if (sha256(bytes) !== digest) throw new Error('repo object checksum mismatch');
281
308
  fs.writeFileSync(path.join(CHUNKS, `${digest}.bin`), bytes, { mode: 0o600 });
282
- fs.rmSync(requestFile(digest), { force: true });
309
+ fs.rmSync(requestFile(resourceId, digest), { force: true });
283
310
  wake('update', digest);
284
- require('./reconcile').reconcileNow().catch(() => {});
311
+ require('./reducer').reduceNow().catch(() => {});
285
312
  return { stored: true, sha256: digest, bytes: bytes.length };
286
313
  }
287
314
 
288
315
  function validateLocator(locator) {
289
316
  if (
290
317
  locator?.contract !== CONTRACT
318
+ || !/^shr_[A-Za-z0-9_-]{16,128}$/.test(String(locator.resourceId || ''))
291
319
  || !/^[0-9a-f]{40,64}$/.test(String(locator.oid || ''))
292
320
  || !/^[0-9a-f]{64}$/.test(String(locator.checksum || ''))
293
321
  || !Array.isArray(locator.chunks)
@@ -307,7 +335,10 @@ async function importFromLocator(worktreeRoot, locator) {
307
335
  for (const chunk of locator.chunks) {
308
336
  const file = path.join(CHUNKS, `${chunk.sha256}.bin`);
309
337
  if (fs.existsSync(file) && fs.statSync(file).size === chunk.bytes) continue;
310
- writeJson(requestFile(chunk.sha256), { sha256: chunk.sha256 });
338
+ writeJson(requestFile(locator.resourceId, chunk.sha256), {
339
+ resourceId: locator.resourceId,
340
+ sha256: chunk.sha256,
341
+ });
311
342
  waiting = true;
312
343
  }
313
344
  if (waiting) {
@@ -339,6 +370,7 @@ module.exports = {
339
370
  CONTRACT,
340
371
  PAYLOAD_CONTRACT,
341
372
  acknowledgeUpload,
373
+ bundleSourceFor,
342
374
  claimDownload,
343
375
  claimUpload,
344
376
  importFromLocator,