amalgm 0.1.261 → 0.1.263

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 (24) hide show
  1. package/lib/cli.js +9 -3
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/lib/prepared.js +27 -0
  4. package/runtime/scripts/amalgm-mcp/observer/README.md +4 -1
  5. package/runtime/scripts/amalgm-mcp/observer/coverage.js +52 -0
  6. package/runtime/scripts/amalgm-mcp/observer/index.js +186 -44
  7. package/runtime/scripts/amalgm-mcp/observer/store.js +41 -27
  8. package/runtime/scripts/amalgm-mcp/observer/watch.js +6 -2
  9. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +54 -19
  10. package/runtime/scripts/amalgm-mcp/registration/index.js +58 -12
  11. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +43 -4
  12. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +15 -5
  13. package/runtime/scripts/amalgm-mcp/registration/service.js +121 -16
  14. package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
  15. package/runtime/scripts/amalgm-mcp/registry/evidence.js +34 -13
  16. package/runtime/scripts/amalgm-mcp/registry/store.js +30 -26
  17. package/runtime/scripts/amalgm-mcp/repocard/capture.js +45 -3
  18. package/runtime/scripts/amalgm-mcp/repocard/follow.js +65 -10
  19. package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +90 -0
  20. package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +236 -0
  21. package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
  22. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +30 -3
  23. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
  24. package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
@@ -15,6 +15,8 @@
15
15
  * is durable, or its loss is explicit.
16
16
  */
17
17
 
18
+ const { preparer } = require('../lib/prepared');
19
+
18
20
  const SCHEMA = `
19
21
  CREATE TABLE IF NOT EXISTS observer_roots (
20
22
  root_id TEXT PRIMARY KEY,
@@ -141,6 +143,8 @@ function createStore(db) {
141
143
  db.exec(SCHEMA);
142
144
  db.pragma(`user_version = ${GENERATION}`);
143
145
 
146
+ const prepared = preparer(db);
147
+
144
148
  /**
145
149
  * One directory's immediate child rows — the scoped scan's slice of
146
150
  * memory, served by the (root_id, rel_path) unique index. '0' is the
@@ -150,33 +154,43 @@ function createStore(db) {
150
154
  */
151
155
  function immediateRows(table, rootId, relDir) {
152
156
  if (relDir === '') {
153
- return db.prepare(`SELECT * FROM ${table} WHERE root_id = ? AND instr(rel_path, '/') = 0 ORDER BY rel_path`)
157
+ return prepared(`SELECT * FROM ${table} WHERE root_id = ? AND instr(rel_path, '/') = 0 ORDER BY rel_path`)
154
158
  .all(rootId);
155
159
  }
156
160
  const prefix = `${relDir}/`;
157
- return db.prepare(`SELECT * FROM ${table} WHERE root_id = ? AND rel_path >= ? AND rel_path < ? ORDER BY rel_path`)
161
+ return prepared(`SELECT * FROM ${table} WHERE root_id = ? AND rel_path >= ? AND rel_path < ? ORDER BY rel_path`)
158
162
  .all(rootId, prefix, `${relDir}0`)
159
163
  .filter((row) => !row.rel_path.slice(prefix.length).includes('/'));
160
164
  }
161
165
 
162
166
  return {
167
+ /**
168
+ * One scan's whole apply phase commits as one durable transition.
169
+ * Row-level verbs each pay a full journal cycle when run bare, so a
170
+ * census over thousands of channels was thousands of commits; batched,
171
+ * it is one. Nesting is safe — SQLite savepoints — and a crash
172
+ * mid-batch leaves the previous truth whole, exactly like applyDirDiff.
173
+ */
174
+ transaction(fn) {
175
+ return db.transaction(fn)();
176
+ },
163
177
  addRoot({ rootId, path, kind, device = null, inode = null }) {
164
- db.prepare('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
178
+ prepared('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
165
179
  .run(rootId, path, kind, device, inode);
166
180
  },
167
181
  rootByPath(path) {
168
- const row = db.prepare('SELECT * FROM observer_roots WHERE path = ?').get(path);
182
+ const row = prepared('SELECT * FROM observer_roots WHERE path = ?').get(path);
169
183
  return row ? rowToRoot(row) : null;
170
184
  },
171
185
  listRoots() {
172
- return db.prepare('SELECT * FROM observer_roots ORDER BY path').all().map(rowToRoot);
186
+ return prepared('SELECT * FROM observer_roots ORDER BY path').all().map(rowToRoot);
173
187
  },
174
188
  setRootKind(rootId, kind) {
175
- db.prepare('UPDATE observer_roots SET kind = ? WHERE root_id = ?').run(kind, rootId);
189
+ prepared('UPDATE observer_roots SET kind = ? WHERE root_id = ?').run(kind, rootId);
176
190
  },
177
191
  /** Identity persists; local presence is separate evidence. */
178
192
  setRootPresent(rootId, present) {
179
- db.prepare('UPDATE observer_roots SET present = ? WHERE root_id = ?').run(present ? 1 : 0, rootId);
193
+ prepared('UPDATE observer_roots SET present = ? WHERE root_id = ?').run(present ? 1 : 0, rootId);
180
194
  },
181
195
  /**
182
196
  * A root's identity is permanent; its address is not — and a family
@@ -186,7 +200,7 @@ function createStore(db) {
186
200
  * Device and inode are continuity itself and never churn on a rename.
187
201
  */
188
202
  moveRoots(moves) {
189
- const move = db.prepare('UPDATE observer_roots SET path = ? WHERE root_id = ?');
203
+ const move = prepared('UPDATE observer_roots SET path = ? WHERE root_id = ?');
190
204
  db.transaction(() => {
191
205
  for (const { rootId, path } of moves) move.run(path, rootId);
192
206
  })();
@@ -195,18 +209,18 @@ function createStore(db) {
195
209
  * new identity at a known address (see enrollRoot) — the one write
196
210
  * where a root's own device:inode may change. */
197
211
  setRootIdentity(rootId, device, inode) {
198
- db.prepare('UPDATE observer_roots SET device = ?, inode = ? WHERE root_id = ?')
212
+ prepared('UPDATE observer_roots SET device = ?, inode = ? WHERE root_id = ?')
199
213
  .run(device, inode, rootId);
200
214
  },
201
215
  removeRoot(rootId) {
202
- db.prepare('DELETE FROM observer_files WHERE root_id = ?').run(rootId);
203
- db.prepare('DELETE FROM observer_dirs WHERE root_id = ?').run(rootId);
204
- db.prepare('DELETE FROM observer_links WHERE root_id = ?').run(rootId);
205
- db.prepare('DELETE FROM observer_roots WHERE root_id = ?').run(rootId);
216
+ prepared('DELETE FROM observer_files WHERE root_id = ?').run(rootId);
217
+ prepared('DELETE FROM observer_dirs WHERE root_id = ?').run(rootId);
218
+ prepared('DELETE FROM observer_links WHERE root_id = ?').run(rootId);
219
+ prepared('DELETE FROM observer_roots WHERE root_id = ?').run(rootId);
206
220
  },
207
221
 
208
222
  filesForRoot(rootId) {
209
- return db.prepare('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
223
+ return prepared('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
210
224
  .map(rowToFile);
211
225
  },
212
226
  filesInDir(rootId, relDir) {
@@ -220,11 +234,11 @@ function createStore(db) {
220
234
  },
221
235
  fileByPath(rootId, relPath) {
222
236
  return rowToFile(
223
- db.prepare('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
237
+ prepared('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
224
238
  );
225
239
  },
226
240
  insertFile(file) {
227
- db.prepare(`
241
+ prepared(`
228
242
  INSERT INTO observer_files (file_id, root_id, rel_path, device, inode, size, mtime_ms, content_hash, is_binary, hashed_at_ms)
229
243
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
230
244
  `).run(
@@ -233,18 +247,18 @@ function createStore(db) {
233
247
  );
234
248
  },
235
249
  updateFile(fileId, { relPath, device, inode, size, mtimeMs, contentHash, binary, hashedAtMs }) {
236
- db.prepare(`
250
+ prepared(`
237
251
  UPDATE observer_files
238
252
  SET rel_path = ?, device = ?, inode = ?, size = ?, mtime_ms = ?, content_hash = ?, is_binary = ?, hashed_at_ms = ?
239
253
  WHERE file_id = ?
240
254
  `).run(relPath, device, inode, size, mtimeMs, contentHash, binary ? 1 : 0, hashedAtMs, fileId);
241
255
  },
242
256
  removeFile(fileId) {
243
- db.prepare('DELETE FROM observer_files WHERE file_id = ?').run(fileId);
257
+ prepared('DELETE FROM observer_files WHERE file_id = ?').run(fileId);
244
258
  },
245
259
 
246
260
  dirsForRoot(rootId) {
247
- return db.prepare('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
261
+ return prepared('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
248
262
  .map(rowToDir);
249
263
  },
250
264
  /**
@@ -257,9 +271,9 @@ function createStore(db) {
257
271
  * the next scan re-detects the same batch.
258
272
  */
259
273
  applyDirDiff({ deletes, moves, retags, creates }) {
260
- const remove = db.prepare('DELETE FROM observer_dirs WHERE dir_id = ?');
261
- const insert = db.prepare('INSERT INTO observer_dirs (dir_id, root_id, rel_path, device, inode) VALUES (?, ?, ?, ?, ?)');
262
- const retag = db.prepare('UPDATE observer_dirs SET rel_path = ?, device = ?, inode = ? WHERE dir_id = ?');
274
+ const remove = prepared('DELETE FROM observer_dirs WHERE dir_id = ?');
275
+ const insert = prepared('INSERT INTO observer_dirs (dir_id, root_id, rel_path, device, inode) VALUES (?, ?, ?, ?, ?)');
276
+ const retag = prepared('UPDATE observer_dirs SET rel_path = ?, device = ?, inode = ? WHERE dir_id = ?');
263
277
  db.transaction(() => {
264
278
  for (const dirId of deletes) remove.run(dirId);
265
279
  for (const dir of moves) remove.run(dir.dirId);
@@ -269,7 +283,7 @@ function createStore(db) {
269
283
  })();
270
284
  },
271
285
  removeDir(dirId) {
272
- db.prepare('DELETE FROM observer_dirs WHERE dir_id = ?').run(dirId);
286
+ prepared('DELETE FROM observer_dirs WHERE dir_id = ?').run(dirId);
273
287
  },
274
288
 
275
289
  // Link channels: a pointer's whole payload is its target string, read
@@ -277,19 +291,19 @@ function createStore(db) {
277
291
  // a rename only ever targets a row-free slot), so link rows commit
278
292
  // per-row like files — every commit is a valid last-known truth.
279
293
  linksForRoot(rootId) {
280
- return db.prepare('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
294
+ return prepared('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
281
295
  .map(rowToLink);
282
296
  },
283
297
  insertLink({ linkId, rootId, relPath, device, inode, target }) {
284
- db.prepare('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
298
+ prepared('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
285
299
  .run(linkId, rootId, relPath, device, inode, target);
286
300
  },
287
301
  updateLink(linkId, { relPath, device, inode, target }) {
288
- db.prepare('UPDATE observer_links SET rel_path = ?, device = ?, inode = ?, target = ? WHERE link_id = ?')
302
+ prepared('UPDATE observer_links SET rel_path = ?, device = ?, inode = ?, target = ? WHERE link_id = ?')
289
303
  .run(relPath, device, inode, target, linkId);
290
304
  },
291
305
  removeLink(linkId) {
292
- db.prepare('DELETE FROM observer_links WHERE link_id = ?').run(linkId);
306
+ prepared('DELETE FROM observer_links WHERE link_id = ?').run(linkId);
293
307
  },
294
308
  };
295
309
  }
@@ -23,13 +23,17 @@ function install(dirPath, recursive, onDirty, onDead) {
23
23
  watcher = fs.watch(dirPath, { recursive, persistent: false }, (_event, filename) => {
24
24
  onDirty(filename ? String(filename).split('\\').join('/') : null);
25
25
  });
26
- } catch {
26
+ } catch (error) {
27
27
  handle.degraded = true;
28
+ // WHY the birth failed travels with the handle: ENOSPC (the kernel's
29
+ // file-watch budget) earns the caller an actionable message, not a shrug.
30
+ handle.reason = error?.code || 'unknown';
28
31
  return handle;
29
32
  }
30
- watcher.on('error', () => {
33
+ watcher.on('error', (error) => {
31
34
  if (handle.degraded) return; // dead is dead: one death, one report
32
35
  handle.degraded = true;
36
+ handle.reason = error?.code || 'unknown';
33
37
  try { watcher.close(); } catch { /* already dead */ }
34
38
  if (onDead) onDead();
35
39
  });
@@ -92,6 +92,31 @@ function assertRecord(value) {
92
92
  };
93
93
  }
94
94
 
95
+ /**
96
+ * The repo travel law: a record with a `repo.git` PROPER ancestor never
97
+ * travels to the cloud on its own. The repository is the sync boundary —
98
+ * its one Card + Checkpoint state carries the worktree's bytes AND its
99
+ * children's permanent UUIDs (the transport identity map), so a register
100
+ * or a rebase is one parcel, never one postcard per file. A nested
101
+ * repository inside another repo's worktree is enclosed too: it becomes
102
+ * cloud-visible only when registered explicitly as its own tree.
103
+ * Missing ancestry keeps the record traveling rather than risking a
104
+ * silently unsynced entity.
105
+ */
106
+ function enclosedByRepositoryAncestor(record, lookup) {
107
+ const seen = new Set([record.uuid]);
108
+ let parentUUID = record.parentUUID;
109
+ while (parentUUID !== null && parentUUID !== undefined) {
110
+ if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
111
+ seen.add(parentUUID);
112
+ const parent = lookup(parentUUID);
113
+ if (!parent) return false;
114
+ if (parent.type === 'repo.git') return true;
115
+ parentUUID = parent.parentUUID;
116
+ }
117
+ return false;
118
+ }
119
+
95
120
  function snapshotFromRecords(records) {
96
121
  const normalized = records.map(assertRecord).sort((left, right) => left.uuid.localeCompare(right.uuid));
97
122
  if (new Set(normalized.map((record) => record.uuid)).size !== normalized.length) {
@@ -180,28 +205,14 @@ function createEntityCloud({
180
205
  return fallback ? cloudRecord(fallback) : null;
181
206
  };
182
207
 
183
- function enclosedByRepository(record) {
184
- const seen = new Set([record.uuid]);
185
- let parentUUID = record.parentUUID;
186
- while (parentUUID !== null) {
187
- if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
188
- seen.add(parentUUID);
189
- const parent = lookup(parentUUID);
190
- // Missing ancestry retains content rather than risking an ordinary
191
- // file whose only bytes were silently skipped.
192
- if (!parent) return false;
193
- if (parent.type === 'repo.git') return true;
194
- parentUUID = parent.parentUUID;
195
- }
196
- return false;
197
- }
198
-
199
208
  const heads = [];
200
209
  const seen = new Set();
201
210
  for (const record of normalized) {
202
211
  const contentHash = contentHead(record);
203
212
  if (!contentHash) continue;
204
- if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepository(record)) continue;
213
+ // Missing ancestry retains content rather than risking an ordinary
214
+ // file whose only bytes were silently skipped.
215
+ if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepositoryAncestor(record, lookup)) continue;
205
216
  if (seen.has(contentHash)) continue;
206
217
  seen.add(contentHash);
207
218
  heads.push({ record, contentHash });
@@ -432,6 +443,14 @@ function createEntityCloud({
432
443
  // Bootstrap roots are included in the first immutable snapshot. No
433
444
  // unscoped row is written before the cloud replica exists.
434
445
  if (!current || current.state !== 'active') return;
446
+ // The repo travel law: a record enclosed by a repository never mails
447
+ // its own postcard. The repo's one settled state is the parcel — the
448
+ // transport carries the worktree's bytes and its children's UUIDs, so
449
+ // a 14k-file repo register (or a 300-file rebase) is ONE mutation on
450
+ // the repo entity, never thousands of child mutations.
451
+ if (enclosedByRepositoryAncestor(record, (uuid) => (typeof recordAt === 'function' ? recordAt(uuid) : null))) {
452
+ return;
453
+ }
435
454
  // The SQLite mutation is the durable delivery intent. Its post-commit
436
455
  // content drain creates the filesystem upload cursor; doing that here
437
456
  // would let a rolled-back identity write upload unreachable bytes.
@@ -470,7 +489,15 @@ function createEntityCloud({
470
489
  if (Number(unsettled.count) !== 0) {
471
490
  throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
472
491
  }
473
- const snapshot = cloudSnapshot(records);
492
+ // The repo travel law applies to the atomic graph too: repo children
493
+ // stay out of the snapshot, so a workspace of large repositories
494
+ // publishes a graph proportional to its traveling records — the
495
+ // repos' bytes and child identity ride each repo's transport.
496
+ const byUuid = new Map(records.map((candidate) => [candidate.uuid, candidate]));
497
+ const traveling = records.filter((candidate) => (
498
+ !enclosedByRepositoryAncestor(candidate, (uuid) => byUuid.get(uuid) || null)
499
+ ));
500
+ const snapshot = cloudSnapshot(traveling);
474
501
  const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
475
502
  const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
476
503
  if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
@@ -671,7 +698,14 @@ function createEntityCloud({
671
698
  }
672
699
  if (!bootstrapping) {
673
700
  const cloudByUuid = new Map(snapshot.records.map((record) => [record.uuid, record]));
674
- for (const local of registry.syncRecords()) {
701
+ const locals = registry.syncRecords();
702
+ const localByUuid = new Map(locals.map((record) => [record.uuid, record]));
703
+ for (const local of locals) {
704
+ // Repo-enclosed local records evolve locally (Detect corrects
705
+ // their types and payloads) without journaling; a snapshot from
706
+ // before the repo travel law may still carry their stale rows.
707
+ // They are not cloud truth, so they cannot veto an install.
708
+ if (enclosedByRepositoryAncestor(local, (uuid) => localByUuid.get(uuid) || null)) continue;
675
709
  const remote = cloudByUuid.get(local.uuid);
676
710
  if (remote && !sameRecord(cloudRecord(local), remote)) {
677
711
  throw new Error('cloud entity graph conflicts with locally bound ground; resolve the local mutation before importing a new cloud head');
@@ -1196,6 +1230,7 @@ module.exports = {
1196
1230
  ENTITY_CLOUD_CONTRACT,
1197
1231
  ENTITY_CLOUD_SCHEMA_VERSION,
1198
1232
  createEntityCloud,
1233
+ enclosedByRepositoryAncestor,
1199
1234
  parseSnapshot,
1200
1235
  privateEntityResourceId,
1201
1236
  snapshotFromRecords,
@@ -359,6 +359,43 @@ function createRegistration(options) {
359
359
  * genuinely live. A filesystem watcher that could not open is not a
360
360
  * warning; it means the requested Register → Watch operation is incomplete
361
361
  * and the caller must hear that. */
362
+ /**
363
+ * Watcher FIRST: prove the doorbell can be born before any identity
364
+ * commits. The birth is held (observer.watchNow) — the watcher exists
365
+ * and is judged, but concludes nothing until start() releases it. One
366
+ * retry absorbs a transient failure; a genuine refusal names its reason,
367
+ * because ENOSPC is a kernel watch budget the caller can actually fix.
368
+ */
369
+ function requireWatchBirth(root) {
370
+ const unhealthy = (row) => !row.watching || row.degraded
371
+ || (row.addressRequired && (!row.addressWatching || row.addressDegraded));
372
+ let health = observer.watchNow(root.rootId);
373
+ if (unhealthy(health)) health = observer.watchNow(root.rootId);
374
+ if (!unhealthy(health)) return;
375
+ const reason = health.degradedReason ? ` (${health.degradedReason})` : '';
376
+ const remedy = health.degradedReason === 'ENOSPC'
377
+ ? ' — the OS file-watch budget is exhausted; raise fs.inotify.max_user_watches or unregister unused ground, then retry'
378
+ : '';
379
+ throw refusal(`refusing to register ${root.path}: its watcher could not be born${reason}${remedy}; nothing was registered`);
380
+ }
381
+
382
+ /**
383
+ * The commit's last word, spoken INSIDE the identity transaction: the
384
+ * watchers proven before the walk are still live as the rows land. A
385
+ * watcher that died mid-walk rolls the whole registration — records and
386
+ * cloud journal together — back to nothing. This is an environment
387
+ * fault, never the caller's path.
388
+ */
389
+ function assertWatchedThroughCommit(rootPath) {
390
+ const dead = observer.status().filter((row) => (row.path === rootPath
391
+ || row.path.startsWith(`${rootPath}/`))
392
+ && (!row.watching || row.degraded));
393
+ if (dead.length > 0) {
394
+ throw new Error(`watcher(s) died during registration of ${rootPath}: `
395
+ + `${dead.map((row) => row.path).join(', ')} — the registration was rolled back`);
396
+ }
397
+ }
398
+
362
399
  function requireLiveWatchers(rootPath) {
363
400
  const belongs = (root) => root.path === rootPath || root.path.startsWith(`${rootPath}/`);
364
401
  let unavailable = observer.status().filter((root) => belongs(root)
@@ -525,24 +562,33 @@ function createRegistration(options) {
525
562
  }
526
563
  const root = observer.enrollRoot(ground, { scan: false, watch: false });
527
564
  try {
565
+ // WATCHER FIRST. Registration is synchronous, never optimistic: the
566
+ // doorbell is born and proven (held — concluding nothing) before one
567
+ // identity row exists, and the commit's own last word re-checks it.
568
+ // A record and its cloud journal land together inside registerTree's
569
+ // transaction, so the outcome is registered-and-watched, or nothing.
570
+ requireWatchBirth(root);
528
571
  assertWitnessed({ observer, registry, memory });
529
- const register = () => registerTree({
530
- root,
531
- registry,
532
- memory,
533
- shouldEnroll: observer.shouldEnroll,
534
- captureRepo,
535
- repoStates,
536
- repoStateAt,
537
- identityAt,
572
+ const register = () => registry.transaction(() => {
573
+ registerTree({
574
+ root,
575
+ registry,
576
+ memory,
577
+ shouldEnroll: observer.shouldEnroll,
578
+ captureRepo,
579
+ repoStates,
580
+ repoStateAt,
581
+ identityAt,
582
+ });
583
+ assertWatchedThroughCommit(root.path);
538
584
  });
539
585
  if (suppressJournal) registry.withoutJournal(register);
540
586
  else register();
541
587
  } catch (error) {
542
588
  // The observer root is only an address until structural registration
543
- // commits. Do not leave an unregistered address behind after a refused
544
- // or failed walk; a committed tree, by contrast, is durable truth and
545
- // is never rolled back by a later Watch/Detect fault.
589
+ // commits — and its held watchers are only handles. A refused or
590
+ // failed walk leaves NOTHING behind: removeRoot closes the doorbells
591
+ // and the transaction above rolled every row and journal entry back.
546
592
  observer.removeRoot(root.rootId);
547
593
  throw error;
548
594
  }
@@ -35,6 +35,24 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
35
35
  const followers = new Map(); // observer rootId -> { path, uuid, follower }
36
36
  let stopped = false;
37
37
 
38
+ /**
39
+ * A scoped capture reuses an unsuspected untracked entry WITHOUT its
40
+ * bytes. The persisted state must stay whole — transport cargo packs
41
+ * untracked content straight from it — so the bytes are carried forward
42
+ * from the previous settled state: a memory copy, never a disk read.
43
+ * The transient `reused` flag never persists.
44
+ */
45
+ function hydrateReusedUntracked(next, previous) {
46
+ const entries = next?.checkpoint?.untracked;
47
+ if (!entries?.some((file) => file.reused)) return;
48
+ const prior = new Map((previous?.checkpoint?.untracked ?? []).map((file) => [file.path, file]));
49
+ next.checkpoint.untracked = entries.map((file) => {
50
+ if (!file.reused) return file;
51
+ const { reused, ...kept } = file;
52
+ return { ...kept, content: prior.get(file.path)?.content ?? null };
53
+ });
54
+ }
55
+
38
56
  function close(rootId) {
39
57
  const entry = followers.get(rootId);
40
58
  if (!entry) return;
@@ -74,14 +92,32 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
74
92
  // The service can turn a full capture into immutable cloud cargo
75
93
  // before the one stateId attestation commits. Local-only callers
76
94
  // retain the lightweight bundle-free capture used historically.
77
- captureFn: async (repoPath) => {
78
- const captured = await worker.capture(repoPath, { withBundle: typeof prepareRepo === 'function' });
79
- return typeof prepareRepo === 'function' && !captured?.busy ? prepareRepo(captured) : captured;
95
+ // The repo UUID rides along so the transport packs the current
96
+ // worktree identity map from the registry.
97
+ captureFn: async (repoPath, { suspects } = {}) => {
98
+ const withBundle = typeof prepareRepo === 'function';
99
+ // The warm checkpoint: the last settled state's untracked entries
100
+ // ((path, mode, sha256) — content is never persisted) let a scoped
101
+ // capture reuse every unsuspected file without opening it. Cloud
102
+ // cargo captures (withBundle) need content and read everything;
103
+ // capture() enforces that side of the contract itself.
104
+ const previous = withBundle ? null : repoStates.get(record.uuid);
105
+ const captured = await worker.capture(repoPath, {
106
+ withBundle,
107
+ suspects,
108
+ previousUntracked: previous?.checkpoint?.untracked?.map(
109
+ ({ path: rel, mode, sha256 }) => ({ path: rel, mode, sha256 }),
110
+ ) ?? null,
111
+ });
112
+ return withBundle && !captured?.busy
113
+ ? prepareRepo(captured, { entityUuid: record.uuid })
114
+ : captured;
80
115
  },
81
116
  emit: (next) => {
82
117
  // ONE settled Card + Checkpoint state is ONE registry transition on
83
118
  // the repo entity. Raw filesystem events never write Git state.
84
119
  const previous = repoStates.get(record.uuid);
120
+ hydrateReusedUntracked(next, previous);
85
121
  registry.transaction(() => {
86
122
  repoStates.put(record.uuid, next);
87
123
  registry.attest(record.uuid, { type: 'repo.git', payloadVersion: next.stateId });
@@ -115,7 +151,10 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
115
151
  sync();
116
152
  return;
117
153
  }
118
- if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring();
154
+ // The ding's hint (the named path, rebased to the repo's ground, or
155
+ // null for "anywhere") rides into the follower's scope: it is what
156
+ // lets the next capture patch one file instead of re-reading the world.
157
+ if (event.type === 'repo.changed') followers.get(event.rootId)?.follower.ring(event.hint ?? null);
119
158
  }
120
159
 
121
160
  function stop() {
@@ -28,13 +28,18 @@ function decodeBytes(encoded) {
28
28
  /** The durable local state deliberately omits a Git bundle. Bundles are
29
29
  * transport cargo: keeping them in identity.db would turn a small registry
30
30
  * into a second, unbounded object store. The immutable content store owns
31
- * the bundle bytes; SQLite retains only the content head that names them. */
32
- function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null } = {}) {
31
+ * the bundle bytes; SQLite retains only the content head that names them.
32
+ * The identity map is transport cargo too: it names the repo's enrolled
33
+ * worktree entities so a receiving machine binds the SAME child UUIDs
34
+ * instead of minting competitors — derived from the registry whenever a
35
+ * transport is packed, never persisted beside the local state. */
36
+ function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null, identity = null } = {}) {
33
37
  return JSON.stringify({
34
38
  stateId: state.stateId,
35
39
  cardId: state.cardId,
36
40
  checkpointId: state.checkpointId,
37
41
  transportVersion,
42
+ ...(identity ? { identity } : {}),
38
43
  card: {
39
44
  ...state.card,
40
45
  bundle: includeBundle && state.card.bundle ? {
@@ -59,6 +64,7 @@ function unpack(text) {
59
64
  return {
60
65
  ...state,
61
66
  transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
67
+ identity: Array.isArray(state.identity) ? state.identity : null,
62
68
  card: {
63
69
  ...state.card,
64
70
  bundle: state.card.bundle && {
@@ -81,15 +87,19 @@ function unpack(text) {
81
87
  /** A Repocard transport document contains its bundle and checkpoint bytes,
82
88
  * but never names itself: its SHA-256 is the immutable cloud content head.
83
89
  * The semantic `stateId` remains on the repo entity, so bundle packing can
84
- * vary without changing repository identity. */
85
- function transportBytes(state) {
86
- return Buffer.from(pack(state, { includeBundle: true, transportVersion: null }), 'utf8');
90
+ * vary without changing repository identity. `identity` is the enrolled
91
+ * worktree's path → { uuid, type } entries: repo children never travel as
92
+ * individual cloud records, so their permanent UUIDs ride here — inside
93
+ * the one state parcel — and `amalgm add` binds them from this map. */
94
+ function transportBytes(state, identity = null) {
95
+ return Buffer.from(pack(state, { includeBundle: true, transportVersion: null, identity }), 'utf8');
87
96
  }
88
97
 
89
98
  function localState(state, transportVersion) {
90
99
  return {
91
100
  ...state,
92
101
  transportVersion,
102
+ identity: null,
93
103
  card: { ...state.card, bundle: null },
94
104
  };
95
105
  }