amalgm 0.1.250 → 0.1.252

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.250",
3
+ "version": "0.1.252",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -14,6 +14,8 @@ const crypto = require('crypto');
14
14
  const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@1';
15
15
  const ENTITY_CLOUD_SCHEMA_VERSION = 1;
16
16
  const BOOTSTRAP_RECONCILIATION_STATE = 'reconciling-bootstrap';
17
+ const SNAPSHOT_REPLACE_OPERATION = 'entity.snapshot.replace';
18
+ const SNAPSHOT_REPLACE_MAX_BYTES = 8 * 1024 * 1024;
17
19
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
20
  const TYPES = new Set([
19
21
  'workspace', 'folder', 'repo.git', 'file.text', 'file.binary', 'link', 'reference',
@@ -57,12 +59,21 @@ function assertRecord(value) {
57
59
  || typeof value.name !== 'string' || !value.name || value.name.includes('/') || value.name.includes('\0')
58
60
  || !['active', 'trashed', 'deleted'].includes(value.status)
59
61
  || typeof value.version !== 'string' || !/^[0-9a-f]{64}$/.test(value.version)
60
- || (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')) {
62
+ || (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')
63
+ || (value.transportVersion !== undefined && value.transportVersion !== null
64
+ && (typeof value.transportVersion !== 'string' || !CONTENT_HEAD.test(value.transportVersion)))) {
61
65
  throw new Error('cloud entity snapshot has an invalid record');
62
66
  }
63
67
  if (value.parentUUID === null && !ROOT_TYPES.has(value.type)) {
64
68
  throw new Error(`cloud entity ${value.uuid} is not a valid tree root`);
65
69
  }
70
+ const transportVersion = value.transportVersion ?? null;
71
+ if (value.type === 'repo.git' && transportVersion !== null && !CONTENT_HEAD.test(transportVersion)) {
72
+ throw new Error('cloud repository transport head is invalid');
73
+ }
74
+ if (value.type !== 'repo.git' && transportVersion !== null) {
75
+ throw new Error('only repository entities may declare a transport head');
76
+ }
66
77
  return {
67
78
  uuid: value.uuid.toLowerCase(),
68
79
  type: value.type,
@@ -71,6 +82,7 @@ function assertRecord(value) {
71
82
  status: value.status,
72
83
  version: value.version,
73
84
  payloadVersion: value.payloadVersion,
85
+ transportVersion,
74
86
  };
75
87
  }
76
88
 
@@ -106,26 +118,48 @@ function parseSnapshot(bytes) {
106
118
  * no watcher imports: a cloud record may be known on this machine long before
107
119
  * `amalgm add <uuid>` chooses and validates local ground for it.
108
120
  */
109
- function createEntityCloud({ db, userId, deviceId, content = null }) {
121
+ function createEntityCloud({ db, userId, deviceId, content = null, repoStates = null, repoTransportFor = null }) {
110
122
  if (!db) throw new Error('createEntityCloud requires an identity database');
111
123
  const ownerUserId = typeof userId === 'string' && userId ? userId : null;
112
124
  const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
113
125
 
126
+ function cloudRecord(record) {
127
+ const normalized = assertRecord(record);
128
+ if (normalized.type !== 'repo.git' || normalized.transportVersion !== null) return normalized;
129
+ const supplied = repoTransportFor?.(normalized) || null;
130
+ const state = repoStates?.get(normalized.uuid);
131
+ const transportVersion = supplied || state?.transportVersion || null;
132
+ if ((state && state.stateId !== normalized.payloadVersion) || !CONTENT_HEAD.test(String(transportVersion || ''))) {
133
+ throw new Error(`repository ${normalized.uuid} has no immutable Repocard transport for state ${normalized.payloadVersion || 'none'}`);
134
+ }
135
+ return { ...normalized, transportVersion };
136
+ }
137
+
114
138
  function contentHead(record) {
115
- return ['file.text', 'file.binary'].includes(record?.type)
116
- && typeof record?.payloadVersion === 'string'
117
- && CONTENT_HEAD.test(record.payloadVersion);
139
+ if (['file.text', 'file.binary'].includes(record?.type)
140
+ && typeof record?.payloadVersion === 'string' && CONTENT_HEAD.test(record.payloadVersion)) {
141
+ return record.payloadVersion;
142
+ }
143
+ if (record?.type === 'repo.git' && typeof record?.transportVersion === 'string'
144
+ && CONTENT_HEAD.test(record.transportVersion)) return record.transportVersion;
145
+ return null;
118
146
  }
119
147
 
120
- function queueContent(record, resourceId, { queue = false } = {}) {
121
- if (!contentHead(record)) return;
148
+ function cloudSnapshot(records) {
149
+ return snapshotFromRecords(records.map(cloudRecord));
150
+ }
151
+
152
+ function queueContent(input, resourceId, { queue = false } = {}) {
153
+ const record = cloudRecord(input);
154
+ const head = contentHead(record);
155
+ if (!head) return;
122
156
  if (!content || typeof content.has !== 'function') {
123
157
  throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
124
158
  }
125
- if (!content.has({ contentHash: record.payloadVersion })) {
159
+ if (!content.has({ contentHash: head })) {
126
160
  throw new Error('file entity bytes were not captured before its cloud head committed');
127
161
  }
128
- if (queue) content.ensureUpload({ resourceId, contentHash: record.payloadVersion });
162
+ if (queue) content.ensureUpload({ resourceId, contentHash: head });
129
163
  }
130
164
 
131
165
  function replica() {
@@ -301,15 +335,16 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
301
335
  // The SQLite mutation is the durable delivery intent. Its post-commit
302
336
  // content drain creates the filesystem upload cursor; doing that here
303
337
  // would let a rolled-back identity write upload unreachable bytes.
304
- queueContent(record, current.resourceId);
338
+ const cloud = cloudRecord(record);
339
+ queueContent(cloud, current.resourceId);
305
340
  upsertCatalogRecord({
306
341
  resourceId: current.resourceId,
307
342
  authorityEpoch: current.authorityEpoch,
308
343
  headVersion: current.headVersion,
309
- record,
344
+ record: cloud,
310
345
  });
311
346
  const mutationId = `ent_${crypto.randomBytes(18).toString('base64url')}`;
312
- const operation = { kind: 'entity.upsert', record: assertRecord(record) };
347
+ const operation = { kind: 'entity.upsert', record: cloud };
313
348
  const createdAt = now();
314
349
  db.prepare(`
315
350
  INSERT INTO entity_cloud_mutations (
@@ -319,6 +354,45 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
319
354
  `).run(current.resourceId, mutationId, stableJson(operation), createdAt, createdAt);
320
355
  }
321
356
 
357
+ /** Publish one complete logical graph as one atomic cloud operation. A
358
+ * large registration must never expose a half-created parent/child graph
359
+ * between individual acknowledgements. Ordinary watcher edits remain
360
+ * single-record operations. */
361
+ function replaceSnapshot(records) {
362
+ const current = replica();
363
+ if (!current || current.state !== 'active') {
364
+ throw new Error('entity cloud registry is not ready yet; wait for the signed-in runtime to finish its first cloud handshake');
365
+ }
366
+ const unsettled = db.prepare(`
367
+ SELECT COUNT(*) AS count FROM entity_cloud_mutations
368
+ WHERE resource_id = ? AND mutation_state <> 'synced'
369
+ `).get(current.resourceId);
370
+ if (Number(unsettled.count) !== 0) {
371
+ throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
372
+ }
373
+ const snapshot = cloudSnapshot(records);
374
+ const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
375
+ const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
376
+ if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
377
+ throw new Error(`registered entity graph is ${bytes} bytes; the atomic cloud registration limit is ${SNAPSHOT_REPLACE_MAX_BYTES} bytes`);
378
+ }
379
+ for (const record of snapshot.records) queueContent(record, current.resourceId, { queue: true });
380
+ const createdAt = now();
381
+ db.prepare(`
382
+ INSERT INTO entity_cloud_mutations (
383
+ resource_id, mutation_id, operation_json, mutation_state,
384
+ created_at, updated_at
385
+ ) VALUES (?, ?, ?, 'pending', ?, ?)
386
+ `).run(
387
+ current.resourceId,
388
+ `ent_${crypto.randomBytes(18).toString('base64url')}`,
389
+ stableJson(operation),
390
+ createdAt,
391
+ createdAt,
392
+ );
393
+ return { records: snapshot.records.length, bytes };
394
+ }
395
+
322
396
  function enqueueBootstrapReconciliation(records, current) {
323
397
  const pending = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
324
398
  .get(current.resourceId);
@@ -327,7 +401,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
327
401
  }
328
402
  const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
329
403
  const createdAt = now();
330
- for (const record of snapshotFromRecords(records).records) {
404
+ for (const record of cloudSnapshot(records).records) {
331
405
  queueContent(record, current.resourceId, { queue: true });
332
406
  insert.run(
333
407
  current.resourceId,
@@ -357,7 +431,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
357
431
  if (existing?.state === 'active') {
358
432
  throw new Error('entity cloud registry already exists locally; cloud lookup must not create it again');
359
433
  }
360
- const snapshot = snapshotFromRecords(registry.syncRecords());
434
+ const snapshot = cloudSnapshot(registry.syncRecords());
361
435
  const cutoff = db.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get().sequence;
362
436
  saveReplica({
363
437
  resourceId: currentIdentity.resourceId,
@@ -397,7 +471,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
397
471
  || !Number.isInteger(Number(headVersion)) || Number(headVersion) < 0) {
398
472
  throw new Error('entity cloud registry authority is invalid');
399
473
  }
400
- const local = snapshotFromRecords(registry.syncRecords());
474
+ const local = cloudSnapshot(registry.syncRecords());
401
475
  const current = replica();
402
476
  const bootstrapping = current?.state === 'bootstrapping';
403
477
  const reconciling = current?.state === BOOTSTRAP_RECONCILIATION_STATE;
@@ -526,7 +600,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
526
600
  if (typeof targetPath !== 'string' || !targetPath) throw new Error('materialization requires a destination path');
527
601
  const tree = catalogTree({ resourceId: current.resourceId, uuid });
528
602
  if (tree[0].status !== 'active') throw new Error(`cloud entity ${uuid} is not active`);
529
- const unsupported = tree.find((record) => ['repo.git', 'link', 'reference'].includes(record.type));
603
+ const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
530
604
  if (unsupported) {
531
605
  throw new Error(`cloud entity ${unsupported.uuid} is ${unsupported.type}; this first materializer supports ordinary workspace and file trees only`);
532
606
  }
@@ -563,13 +637,14 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
563
637
  if (job.resourceId !== current.resourceId) continue;
564
638
  const tree = catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
565
639
  for (const record of tree) {
566
- if (!['file.text', 'file.binary'].includes(record.type) || record.status !== 'active') continue;
567
- if (!contentHead(record)) throw new Error(`cloud file ${record.uuid} has no immutable content head`);
568
- const part = content.missingPart({ contentHash: record.payloadVersion });
640
+ if (record.status !== 'active') continue;
641
+ const head = contentHead(record);
642
+ if (!head) continue;
643
+ const part = content.missingPart({ contentHash: head });
569
644
  if (!part) continue;
570
645
  return {
571
646
  resourceId: job.resourceId,
572
- contentHash: record.payloadVersion,
647
+ contentHash: head,
573
648
  kind: part.kind,
574
649
  partIndex: part.partIndex,
575
650
  sha256: part.sha256,
@@ -644,7 +719,9 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
644
719
  `).all(replica()?.resourceId || '');
645
720
  return rows.some((row) => {
646
721
  try {
647
- return JSON.parse(row.operation_json)?.record?.uuid === uuid;
722
+ const operation = JSON.parse(row.operation_json);
723
+ return operation?.record?.uuid === uuid
724
+ || operation?.snapshot?.records?.some((record) => record?.uuid === uuid) === true;
648
725
  } catch {
649
726
  // A corrupt local outbox must never authorize an overwrite.
650
727
  return true;
@@ -680,8 +757,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
680
757
  // The logical mutation is intentionally held behind the immutable
681
758
  // content manifest. A cloud graph never advances to a file head
682
759
  // whose bytes have not reached private R2 storage.
683
- if (contentHead(operation?.record)
684
- && (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: operation.record.payloadVersion }))) {
760
+ const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
761
+ ? operation?.snapshot?.records
762
+ : [operation?.record];
763
+ if ((records || []).some((record) => {
764
+ const head = contentHead(record);
765
+ return head && (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: head }));
766
+ })) {
685
767
  return [];
686
768
  }
687
769
  claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
@@ -693,7 +775,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
693
775
  baseVersion: current.headVersion,
694
776
  contract: ENTITY_CLOUD_CONTRACT,
695
777
  schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
696
- operationKind: 'entity.upsert',
778
+ operationKind: operation.kind,
697
779
  operation,
698
780
  }];
699
781
  });
@@ -712,8 +794,11 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
712
794
  ORDER BY sequence ASC
713
795
  `).all(current.resourceId);
714
796
  for (const row of rows) {
715
- const record = JSON.parse(row.operation_json)?.record;
716
- queueContent(record, current.resourceId, { queue: true });
797
+ const operation = JSON.parse(row.operation_json);
798
+ const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
799
+ ? operation?.snapshot?.records
800
+ : [operation?.record];
801
+ for (const record of records || []) queueContent(record, current.resourceId, { queue: true });
717
802
  }
718
803
  return content.claim({ resourceId: current.resourceId, leaseMs });
719
804
  }
@@ -799,9 +884,18 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
799
884
  }
800
885
  if (envelope?.resourceId !== current.resourceId
801
886
  || envelope?.contract !== ENTITY_CLOUD_CONTRACT
802
- || Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION
803
- || envelope?.operationKind !== 'entity.upsert'
804
- || envelope?.operation?.kind !== 'entity.upsert') {
887
+ || Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION) {
888
+ throw new Error('cloud entity mutation does not belong to this registry');
889
+ }
890
+ if (envelope?.operationKind === SNAPSHOT_REPLACE_OPERATION
891
+ && envelope?.operation?.kind === SNAPSHOT_REPLACE_OPERATION) {
892
+ // The complete replacement is imported only from the active snapshot
893
+ // the gateway makes before acknowledgement. Consuming its tail would
894
+ // reintroduce the very partial-graph state this operation prevents.
895
+ if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
896
+ return { duplicate: true, version: Number(version), snapshotRequired: true, materializations: [] };
897
+ }
898
+ if (envelope?.operationKind !== 'entity.upsert' || envelope?.operation?.kind !== 'entity.upsert') {
805
899
  throw new Error('cloud entity mutation does not belong to this registry');
806
900
  }
807
901
  const record = assertRecord(envelope.operation.record);
@@ -846,6 +940,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
846
940
  return {
847
941
  identity,
848
942
  journal,
943
+ replaceSnapshot,
849
944
  assertRegistrationReady,
850
945
  bootstrap,
851
946
  install,
@@ -168,6 +168,34 @@ function createEntityContent({ dir }) {
168
168
  return installed;
169
169
  }
170
170
 
171
+ /** Capture immutable bytes that do not originate as a standalone file.
172
+ * Repocard transport is the first caller: its Git bundle plus checkpoint
173
+ * are one verified payload, while identity.db stores only the resulting
174
+ * content head. The supplied bytes must already equal that head; this is
175
+ * the same content-addressed rule as captureFile, not a second storage
176
+ * format. */
177
+ function captureBytes({ bytes, contentHash = null }) {
178
+ const fullBytes = Buffer.from(bytes || []);
179
+ const head = contentHash || digest(fullBytes);
180
+ assertHash(head);
181
+ if (digest(fullBytes) !== head) throw new Error('entity content bytes do not match their declared head');
182
+ ensureDirs();
183
+ const existing = manifest(head);
184
+ if (existing) return existing;
185
+ const chunks = [];
186
+ for (let offset = 0; offset < fullBytes.length; offset += CHUNK_BYTES) {
187
+ const part = fullBytes.subarray(offset, Math.min(offset + CHUNK_BYTES, fullBytes.length));
188
+ const checksum = digest(part);
189
+ writeChunk(checksum, part);
190
+ chunks.push({ sha256: checksum, bytes: part.length });
191
+ }
192
+ const captured = { contract: CONTENT_CONTRACT, contentHash: head, bytes: fullBytes.length, chunks };
193
+ writeJson(manifestFile(head), captured);
194
+ const installed = manifest(head);
195
+ if (!installed) throw new Error('captured entity content bytes did not persist');
196
+ return installed;
197
+ }
198
+
171
199
  function initialUpload(resourceId, contentHash) {
172
200
  return {
173
201
  contract: CONTENT_CONTRACT,
@@ -414,6 +442,7 @@ function createEntityContent({ dir }) {
414
442
 
415
443
  return {
416
444
  captureFile,
445
+ captureBytes,
417
446
  ensureUpload,
418
447
  queueRecords,
419
448
  claim,
@@ -84,6 +84,11 @@ function createRegistration(options) {
84
84
  }
85
85
 
86
86
  const opened = openIdentity();
87
+ const captureRepo = opened.captureRepo || ((repoPath) => capture(repoPath, { withBundle: false }));
88
+ const repoStateAt = opened.repoStateAt ?? null;
89
+ if (repoStateAt !== null && typeof repoStateAt !== 'function') {
90
+ throw new Error('openIdentity.repoStateAt must be a function when supplied');
91
+ }
87
92
  const registry = createRegistry({
88
93
  db: opened.db,
89
94
  sentinelPath: opened.sentinelPath,
@@ -92,7 +97,7 @@ function createRegistration(options) {
92
97
  journal: opened.journal ?? null,
93
98
  });
94
99
  const memory = createEvidenceMemory(opened.db);
95
- const repoStates = createRepoStates(opened.db);
100
+ const repoStates = opened.repoStates || createRepoStates(opened.db);
96
101
  const lenses = new Map(); // rootId (permanent witness) -> evidence lens
97
102
  let reported = []; // the latest edge pass's refusals
98
103
  let edgesDirty = false;
@@ -102,6 +107,7 @@ function createRegistration(options) {
102
107
  observer,
103
108
  registry,
104
109
  repoStates,
110
+ prepareRepo: opened.prepareRepo ?? null,
105
111
  recordForRoot: repoRecordForRoot,
106
112
  onSettled: reconcileSettledRepo,
107
113
  });
@@ -237,8 +243,9 @@ function createRegistration(options) {
237
243
  registry,
238
244
  memory,
239
245
  shouldEnroll: observer.shouldEnroll,
240
- captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
246
+ captureRepo,
241
247
  repoStates,
248
+ repoStateAt,
242
249
  });
243
250
  } catch (error) {
244
251
  // An observer root is only coverage until its one structural
@@ -520,8 +527,9 @@ function createRegistration(options) {
520
527
  registry,
521
528
  memory,
522
529
  shouldEnroll: observer.shouldEnroll,
523
- captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
530
+ captureRepo,
524
531
  repoStates,
532
+ repoStateAt,
525
533
  identityAt,
526
534
  });
527
535
  if (suppressJournal) registry.withoutJournal(register);
@@ -745,6 +753,10 @@ function createRegistration(options) {
745
753
  /** The latest edge pass's refusals — visible, never silent. */
746
754
  reported: () => [...reported],
747
755
  addressOf,
756
+ // Composition owns the one database handle; exposing this narrow state
757
+ // store lets the cloud adapter name verified repository cargo without
758
+ // opening a competing registry over the same SQLite file.
759
+ repoStates,
748
760
  repoState: (uuid) => repoStates.get(uuid),
749
761
  tree: treeAt,
750
762
  trees: () => new Map(treeRoots()
@@ -25,7 +25,7 @@
25
25
 
26
26
  const { createFollower, createCaptureWorker } = require('../repocard');
27
27
 
28
- function createRepoFollowers({ observer, registry, repoStates, recordForRoot, onSettled }) {
28
+ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, onSettled, prepareRepo = null }) {
29
29
  if (!observer || !registry || !repoStates || typeof recordForRoot !== 'function'
30
30
  || typeof onSettled !== 'function') {
31
31
  throw new Error('createRepoFollowers requires { observer, registry, repoStates, recordForRoot, onSettled }');
@@ -71,7 +71,13 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
71
71
  const follower = createFollower({
72
72
  repoPath: root.path,
73
73
  last: baseline && { cardId: baseline.cardId, checkpointId: baseline.checkpointId },
74
- captureFn: (repoPath) => worker.capture(repoPath, { withBundle: false }),
74
+ // The service can turn a full capture into immutable cloud cargo
75
+ // before the one stateId attestation commits. Local-only callers
76
+ // 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;
80
+ },
75
81
  emit: (next) => {
76
82
  // ONE settled Card + Checkpoint state is ONE registry transition on
77
83
  // the repo entity. Raw filesystem events never write Git state.
@@ -25,17 +25,22 @@ function decodeBytes(encoded) {
25
25
  return Buffer.from(encoded || '', 'base64');
26
26
  }
27
27
 
28
- function pack(state) {
28
+ /** The durable local state deliberately omits a Git bundle. Bundles are
29
+ * transport cargo: keeping them in identity.db would turn a small registry
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 } = {}) {
29
33
  return JSON.stringify({
30
34
  stateId: state.stateId,
31
35
  cardId: state.cardId,
32
36
  checkpointId: state.checkpointId,
37
+ transportVersion,
33
38
  card: {
34
39
  ...state.card,
35
- bundle: state.card.bundle && {
40
+ bundle: includeBundle && state.card.bundle ? {
36
41
  ...state.card.bundle,
37
42
  bytes: encodeBytes(state.card.bundle.bytes),
38
- },
43
+ } : null,
39
44
  },
40
45
  checkpoint: {
41
46
  ...state.checkpoint,
@@ -53,6 +58,7 @@ function unpack(text) {
53
58
  const state = JSON.parse(text);
54
59
  return {
55
60
  ...state,
61
+ transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
56
62
  card: {
57
63
  ...state.card,
58
64
  bundle: state.card.bundle && {
@@ -72,6 +78,22 @@ function unpack(text) {
72
78
  };
73
79
  }
74
80
 
81
+ /** A Repocard transport document contains its bundle and checkpoint bytes,
82
+ * but never names itself: its SHA-256 is the immutable cloud content head.
83
+ * 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');
87
+ }
88
+
89
+ function localState(state, transportVersion) {
90
+ return {
91
+ ...state,
92
+ transportVersion,
93
+ card: { ...state.card, bundle: null },
94
+ };
95
+ }
96
+
75
97
  function createRepoStates(db) {
76
98
  if (!db) throw new Error('createRepoStates requires the identity database');
77
99
  const read = db.prepare('SELECT state_json FROM repo_states WHERE entity_uuid = ?');
@@ -106,4 +128,4 @@ function createRepoStates(db) {
106
128
  };
107
129
  }
108
130
 
109
- module.exports = { createRepoStates };
131
+ module.exports = { createRepoStates, transportBytes, localState, unpack };
@@ -53,11 +53,14 @@ const { classifyFile } = require('./classify');
53
53
  const { createEntityCloud } = require('./entity-cloud');
54
54
  const { createEntityContent } = require('./entity-content');
55
55
  const { createRegistration } = require('./index');
56
+ const { capture, apply: applyRepo } = require('../repocard');
57
+ const { transportBytes, localState, unpack } = require('./repo-states');
56
58
  const { machineId, userId } = require('../workspace/identity');
57
59
 
58
60
  const ENTITIES_DIR = path.join(STORAGE_DIR, 'entities');
59
61
  const USER_HOME_CLOUD_CONTRACT = 'amalgm-user-home@1';
60
62
  const USER_HOME_CLOUD_SCHEMA_VERSION = 1;
63
+ const ATOMIC_REGISTRATION_THRESHOLD = 500;
61
64
 
62
65
  let live = null;
63
66
 
@@ -80,11 +83,32 @@ function createRegistrationService({
80
83
  const evidenceDb = new Database(path.join(dir, 'observer.db'));
81
84
  const identityDb = new Database(path.join(dir, 'identity.db'));
82
85
  const entityContent = createEntityContent({ dir });
86
+ // createRegistration owns the schema migration. Give entity-cloud a stable
87
+ // lookup facade now, then connect it to that one migrated state store once
88
+ // the boundary exists; never open a second registry just to read Git state.
89
+ let repoStates = null;
90
+ const repoStateLookup = { get: (uuid) => repoStates?.get(uuid) || null };
91
+ const repoTransportHeads = new Map(); // semantic stateId -> immutable content hash
92
+ const importedRepoStates = new Map(); // cloud UUID -> verified bundle-free state
93
+
94
+ /** Git transport is immutable content, not identity-database cargo. The
95
+ * semantic repo stateId remains the entity head; this prepares the exact
96
+ * Card + Checkpoint + bundle payload and leaves only its content hash in
97
+ * SQLite so a large clone cannot bloat the device registry. */
98
+ function prepareRepo(captured) {
99
+ if (captured?.busy) return captured;
100
+ const artifact = entityContent.captureBytes({ bytes: transportBytes(captured) });
101
+ repoTransportHeads.set(captured.stateId, artifact.contentHash);
102
+ return localState(captured, artifact.contentHash);
103
+ }
104
+
83
105
  const entityCloud = createEntityCloud({
84
106
  db: identityDb,
85
107
  userId: cloudUserId,
86
108
  deviceId: cloudDeviceId,
87
109
  content: entityContent,
110
+ repoStates: repoStateLookup,
111
+ repoTransportFor: (record) => repoTransportHeads.get(record.payloadVersion) || null,
88
112
  });
89
113
 
90
114
  // The observer emits before the boundary exists (its constructor
@@ -103,12 +127,41 @@ function createRegistrationService({
103
127
  db: identityDb,
104
128
  sentinelPath: path.join(dir, 'identity.epoch'),
105
129
  journal: entityCloud.journal,
130
+ captureRepo: (repoPath) => prepareRepo(capture(repoPath, { withBundle: true })),
131
+ prepareRepo,
132
+ repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
106
133
  }),
107
134
  classify: classifyFile,
108
135
  });
136
+ repoStates = boundary.current.repoStates;
109
137
 
110
138
  let closed = false;
111
139
 
140
+ /** Count only the same eligible shape that registration will see. This is
141
+ * not a second registration walker: it is a bounded routing decision that
142
+ * lets a large tree use one atomic cloud graph replacement instead of
143
+ * thousands of individually compacted mutations. */
144
+ function estimatedRegistrationRecords(targetPath, limit = ATOMIC_REGISTRATION_THRESHOLD) {
145
+ let total = 0;
146
+ const visit = (candidate) => {
147
+ total += 1;
148
+ if (total >= limit) return;
149
+ let stat;
150
+ try { stat = fs.lstatSync(candidate); } catch { return; }
151
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return;
152
+ let entries;
153
+ try { entries = fs.readdirSync(candidate, { withFileTypes: true }); } catch { return; }
154
+ for (const entry of entries) {
155
+ if (total >= limit) return;
156
+ const child = path.join(candidate, entry.name);
157
+ if (!shouldEnroll(child)) continue;
158
+ visit(child);
159
+ }
160
+ };
161
+ visit(targetPath);
162
+ return total;
163
+ }
164
+
112
165
  const publicRecord = (record) => ({
113
166
  uuid: record.uuid,
114
167
  type: record.type,
@@ -304,14 +357,27 @@ function createRegistrationService({
304
357
  group.push(record);
305
358
  children.set(record.parentUUID, group);
306
359
  }
360
+ function repoState(record) {
361
+ if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
362
+ const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
363
+ if (state.stateId !== record.payloadVersion) {
364
+ throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
365
+ }
366
+ return state;
367
+ }
368
+
307
369
  for (const record of active) {
308
370
  if (['file.text', 'file.binary'].includes(record.type)
309
371
  && !entityContent.complete({ contentHash: record.payloadVersion })) {
310
372
  return false;
311
373
  }
374
+ if (record.type === 'repo.git'
375
+ && (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) {
376
+ return false;
377
+ }
312
378
  }
313
- if (!['workspace', 'file.text', 'file.binary'].includes(root.type)) {
314
- throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace and file trees only`);
379
+ if (!['workspace', 'repo.git', 'file.text', 'file.binary'].includes(root.type)) {
380
+ throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace, repository, and file trees only`);
315
381
  }
316
382
 
317
383
  const parent = fs.realpathSync(job.targetPath);
@@ -330,6 +396,10 @@ function createRegistrationService({
330
396
  }
331
397
  return;
332
398
  }
399
+ if (record.type === 'repo.git') {
400
+ applyRepo(target, repoState(record));
401
+ return;
402
+ }
333
403
  if (record.type === 'file.text' || record.type === 'file.binary') {
334
404
  fs.writeFileSync(target, entityContent.bytes({ contentHash: record.payloadVersion }), { mode: 0o600, flag: 'wx' });
335
405
  return;
@@ -351,6 +421,14 @@ function createRegistrationService({
351
421
  write(root, staging);
352
422
  fs.renameSync(staging, destination);
353
423
  promoted = true;
424
+ // Bind imported repo UUIDs to the verified state before registration's
425
+ // structural walk. The walk then reuses these facts instead of making
426
+ // a second bundle while it arms the local Git follower.
427
+ for (const record of active.filter((candidate) => candidate.type === 'repo.git')) {
428
+ const state = repoState(record);
429
+ importedRepoStates.set(record.uuid, localState(state, record.transportVersion));
430
+ repoTransportHeads.set(state.stateId, record.transportVersion);
431
+ }
354
432
  const bound = boundary.current.add(destination, { identityAt, suppressJournal: true });
355
433
  if (bound.record.uuid !== root.uuid) {
356
434
  throw new Error(`materialized cloud root ${root.uuid} bound to unexpected local UUID ${bound.record.uuid}`);
@@ -379,16 +457,18 @@ function createRegistrationService({
379
457
  const tree = entityCloud.catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
380
458
  const root = tree[0];
381
459
  const active = tree.filter((record) => record.status === 'active');
382
- const unsupported = tree.find((record) => ['repo.git', 'link', 'reference'].includes(record.type));
460
+ const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
383
461
  if (unsupported) {
384
462
  throw new Error(`live cloud add for ${unsupported.type} is not available yet; the binding was left unchanged`);
385
463
  }
386
- if (!['workspace', 'file.text', 'file.binary'].includes(root.type)) {
387
- throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace and file trees only`);
464
+ if (!['workspace', 'repo.git', 'file.text', 'file.binary'].includes(root.type)) {
465
+ throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace, repository, and file trees only`);
388
466
  }
389
467
  for (const record of active) {
390
468
  if (['file.text', 'file.binary'].includes(record.type)
391
469
  && !entityContent.complete({ contentHash: record.payloadVersion })) return false;
470
+ if (record.type === 'repo.git'
471
+ && (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) return false;
392
472
  }
393
473
 
394
474
  const parent = fs.realpathSync(job.targetPath);
@@ -419,6 +499,15 @@ function createRegistrationService({
419
499
  return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
420
500
  }
421
501
 
502
+ function repoState(record) {
503
+ if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
504
+ const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
505
+ if (state.stateId !== record.payloadVersion) {
506
+ throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
507
+ }
508
+ return state;
509
+ }
510
+
422
511
  // Before changing logical identity, prove that every local byte we could
423
512
  // overwrite is still the byte named by the old authoritative record.
424
513
  // An unsent local edit or an out-of-band edit is a conflict, never an
@@ -429,6 +518,16 @@ function createRegistrationService({
429
518
  if (entityCloud.hasUnsettledLocalMutation(record.uuid)) {
430
519
  throw new Error(`local change for ${record.uuid} is still waiting for cloud; refusing to overwrite it`);
431
520
  }
521
+ if (current.type === 'repo.git') {
522
+ const currentPath = addressOf(record.uuid);
523
+ const observed = currentPath && fs.existsSync(currentPath)
524
+ ? capture(currentPath, { withBundle: false })
525
+ : null;
526
+ if (!observed || observed.busy || observed.stateId !== current.payloadVersion) {
527
+ throw new Error(`local repository ${currentPath || record.uuid} changed outside Amalgm; refusing to overwrite it`);
528
+ }
529
+ continue;
530
+ }
432
531
  if (!['file.text', 'file.binary'].includes(current.type)) continue;
433
532
  const currentPath = addressOf(record.uuid);
434
533
  if (!currentPath || !fs.existsSync(currentPath)) {
@@ -499,6 +598,10 @@ function createRegistrationService({
499
598
  }
500
599
  return;
501
600
  }
601
+ if (record.type === 'repo.git') {
602
+ applyRepo(target, repoState(record));
603
+ return;
604
+ }
502
605
  if (!['file.text', 'file.binary'].includes(record.type)) {
503
606
  throw new Error(`cloud entity ${record.uuid} is ${record.type}; this live materializer cannot write it`);
504
607
  }
@@ -511,6 +614,11 @@ function createRegistrationService({
511
614
  }
512
615
 
513
616
  boundary.current.registry.withoutJournal(() => {
617
+ for (const record of active.filter((candidate) => candidate.type === 'repo.git')) {
618
+ const state = repoState(record);
619
+ repoStates.put(record.uuid, localState(state, record.transportVersion));
620
+ repoTransportHeads.set(state.stateId, record.transportVersion);
621
+ }
514
622
  applyKnownRecords();
515
623
  project(root, destination);
516
624
  const observerRoot = observer.listRoots().find((entry) => entry.path === destination);
@@ -545,12 +653,26 @@ function createRegistrationService({
545
653
  /** `amalgm register <path>` — register, then begin Watch + Detect. */
546
654
  register: async (targetPath) => {
547
655
  entityCloud.assertRegistrationReady();
548
- return boundary.current.add(targetPath);
656
+ if (estimatedRegistrationRecords(targetPath) < ATOMIC_REGISTRATION_THRESHOLD) {
657
+ return boundary.current.add(targetPath);
658
+ }
659
+ const result = boundary.current.registry.withoutJournal(() => boundary.current.add(targetPath));
660
+ entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
661
+ return result;
549
662
  },
550
- /** `amalgm register <path>...` — the same primitive for each selected root. */
663
+ /** `amalgm register <path>...` — the same primitive for each selected root.
664
+ * A large tree has one atomic cloud replacement record: partial graph
665
+ * acknowledgements would make an R2 active head that no receiver may
666
+ * materialize. Small selections retain their ordinary per-entity intent. */
551
667
  registerMany: async (targetPaths) => {
552
668
  entityCloud.assertRegistrationReady();
553
- return boundary.current.addMany(targetPaths);
669
+ const needsAtomicReplacement = targetPaths.some((targetPath) => (
670
+ estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
671
+ ));
672
+ if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
673
+ const results = boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
674
+ entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
675
+ return results;
554
676
  },
555
677
  /** `amalgm add <cloud-root-uuid> [parent-directory]` — cloud to local. */
556
678
  add: async ({ uuid, targetPath }) => {
@@ -28,6 +28,7 @@
28
28
 
29
29
  const fs = require('fs');
30
30
  const path = require('path');
31
+ const crypto = require('crypto');
31
32
 
32
33
  function rootType(rootPath, kind) {
33
34
  if (kind === 'repo') return 'repo.git';
@@ -55,7 +56,7 @@ function isRepository(dirPath) {
55
56
  * directory ground. The caller supplies a root that is already known to the
56
57
  * observer but is not yet watched or scanned.
57
58
  */
58
- function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoStates, identityAt = null }) {
59
+ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoStates, identityAt = null, repoStateAt = null }) {
59
60
  if (!root || !registry || !memory || typeof shouldEnroll !== 'function'
60
61
  || typeof captureRepo !== 'function' || !repoStates) {
61
62
  throw new Error('registerTree requires { root, registry, memory, shouldEnroll, captureRepo, repoStates }');
@@ -93,23 +94,42 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
93
94
  };
94
95
  }
95
96
 
97
+ /** An imported repository already has a verified Repocard artifact in the
98
+ * local state table before it is bound to Watch. Reuse that exact state;
99
+ * recapturing would waste a second Git bundle and could race the import.
100
+ * Ordinary local registration has no seeded UUID and captures fresh truth. */
101
+ function repoStateFor(identity, repoPath) {
102
+ if (identity.uuid) {
103
+ const imported = repoStateAt?.(identity.uuid);
104
+ if (imported?.stateId === identity.payloadVersion) return imported;
105
+ const saved = repoStates.get(identity.uuid);
106
+ if (saved?.stateId === identity.payloadVersion) return saved;
107
+ }
108
+ return captureInitialRepo(repoPath, captureRepo);
109
+ }
110
+
96
111
  return registry.transaction(() => {
97
- const initialState = root.kind === 'repo' ? captureInitialRepo(root.path, captureRepo) : null;
98
112
  const rootIdentity = seeded('', {
99
113
  type: rootType(root.path, root.kind), parentUUID: null, name: path.basename(root.path), root: true,
100
114
  });
115
+ const initialState = root.kind === 'repo' ? repoStateFor(rootIdentity, root.path) : null;
116
+ // A repo's immutable transport head is already held by the injected
117
+ // cloud-cargo lookup before its entity journal runs. Allocate the UUID,
118
+ // create the FK parent, then persist its bundle-free state in this same
119
+ // transaction. A rollback removes both durable facts together.
120
+ const rootUuid = rootIdentity.uuid || (initialState ? crypto.randomUUID() : undefined);
101
121
  const record = registry.enrollRoot({
102
122
  type: rootIdentity.type,
103
123
  name: path.basename(root.path),
104
124
  payloadVersion: initialState?.stateId ?? rootIdentity.payloadVersion ?? null,
105
- uuid: rootIdentity.uuid,
125
+ uuid: rootUuid,
106
126
  });
107
127
  memory.bind(root.rootId, root.rootId, record.uuid);
108
128
  if (initialState) repoStates.put(record.uuid, initialState);
109
129
 
110
130
  if (root.kind === 'files' || root.kind === 'repo') {
111
131
  registerDirectory(root.path, record.uuid, {
112
- registry, shouldEnroll, captureRepo, repoStates, identityAt, seeded, relativePath: '',
132
+ registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: '',
113
133
  });
114
134
  }
115
135
  return record;
@@ -128,7 +148,7 @@ function captureInitialRepo(repoPath, captureRepo) {
128
148
  }
129
149
 
130
150
  function registerDirectory(dirPath, parentUUID, {
131
- registry, shouldEnroll, captureRepo, repoStates, identityAt, seeded, relativePath,
151
+ registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath,
132
152
  }) {
133
153
  let entries;
134
154
  try {
@@ -157,20 +177,21 @@ function registerDirectory(dirPath, parentUUID, {
157
177
  }
158
178
  if (stat.isDirectory()) {
159
179
  const type = isRepository(entryPath) ? 'repo.git' : 'folder';
160
- const state = type === 'repo.git' ? captureInitialRepo(entryPath, captureRepo) : null;
161
180
  const identity = seeded(entryRelativePath, {
162
181
  type, parentUUID, name: entry.name, root: false,
163
182
  });
183
+ const state = type === 'repo.git' ? repoStateFor(identity, entryPath) : null;
184
+ const uuid = identity.uuid || (state ? crypto.randomUUID() : undefined);
164
185
  const record = registry.create({
165
186
  type: identity.type,
166
187
  parentUUID,
167
188
  name: entry.name,
168
189
  payloadVersion: state?.stateId ?? identity.payloadVersion ?? null,
169
- uuid: identity.uuid,
190
+ uuid,
170
191
  });
171
192
  if (state) repoStates.put(record.uuid, state);
172
193
  registerDirectory(entryPath, record.uuid, {
173
- registry, shouldEnroll, captureRepo, repoStates, identityAt, seeded, relativePath: entryRelativePath,
194
+ registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: entryRelativePath,
174
195
  });
175
196
  continue;
176
197
  }
@@ -3,36 +3,10 @@
3
3
  const { activeAgentConversations } = require('../../agents/talk');
4
4
 
5
5
  // /healthz is a liveness signal: the parent supervisor kills this process
6
- // after three slow answers, so the handler must never touch disk. Counts are
7
- // informational — serve a cached snapshot and refresh it off the request path.
8
- const COUNTS_TTL_MS = 30_000;
9
- let cachedCounts = { automations: 0, enabledAutomations: 0, apps: 0, customAgents: 0 };
10
- let countsRefreshedAt = 0;
11
- let countsRefreshing = false;
12
-
13
- function refreshCountsSoon() {
14
- if (countsRefreshing || Date.now() - countsRefreshedAt < COUNTS_TTL_MS) return;
15
- countsRefreshing = true;
16
- setImmediate(() => {
17
- try {
18
- const { loadAgents } = require('../../agents/store');
19
- const { loadApps } = require('../../apps/store');
20
- const { listAutomations } = require('../../automations/store');
21
- const automations = listAutomations();
22
- cachedCounts = {
23
- automations: automations.length,
24
- enabledAutomations: automations.filter((automation) => automation.enabled !== false).length,
25
- apps: loadApps().apps.length,
26
- customAgents: loadAgents().agents.length,
27
- };
28
- countsRefreshedAt = Date.now();
29
- } catch {
30
- // Counts stay stale; liveness must not depend on store reads succeeding.
31
- } finally {
32
- countsRefreshing = false;
33
- }
34
- });
35
- }
6
+ // after three slow answers. It therefore performs no I/O and starts no
7
+ // follow-up work. Operational counts belong on diagnostic routes: allowing a
8
+ // liveness probe to inspect the user's state store made a large, healthy
9
+ // installation look dead by blocking its own event loop.
36
10
 
37
11
  async function handleHealthRoutes(ctx) {
38
12
  if (ctx.pathname !== '/healthz') return false;
@@ -43,10 +17,8 @@ async function handleHealthRoutes(ctx) {
43
17
  runtime_label: process.env.AMALGM_RUNTIME_LABEL || 'main',
44
18
  runtime_instance_id: process.env.AMALGM_RUNTIME_INSTANCE_ID || '',
45
19
  pid: process.pid,
46
- ...cachedCounts,
47
20
  activeAgentConversations: activeAgentConversations.size,
48
21
  });
49
- refreshCountsSoon();
50
22
  return true;
51
23
  }
52
24
 
@@ -76,6 +76,56 @@ test('a signed-in registration is refused until the cloud entity resource is act
76
76
  assert.doesNotThrow(() => cloud.assertRegistrationReady());
77
77
  });
78
78
 
79
+ test('a large registration replaces the complete graph in one cloud operation and a recipient waits for its compacted snapshot', () => {
80
+ const db = new Database(':memory:');
81
+ const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
82
+ const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
83
+ const root = registry.enrollRoot({ type: 'workspace', name: 'large-ground' });
84
+ const bootstrap = cloud.bootstrap(registry);
85
+ cloud.install({
86
+ registry,
87
+ resourceId: bootstrap.resourceId,
88
+ authorityEpoch: 1,
89
+ headVersion: 0,
90
+ snapshotBase64: bootstrap.snapshotBase64,
91
+ snapshotChecksum: bootstrap.snapshotChecksum,
92
+ });
93
+ registry.withoutJournal(() => {
94
+ for (let index = 0; index < 600; index += 1) {
95
+ registry.create({ type: 'folder', parentUUID: root.uuid, name: `folder-${index}` });
96
+ }
97
+ });
98
+ const replacement = cloud.replaceSnapshot(registry.syncRecords());
99
+ assert.equal(replacement.records, 601);
100
+ assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1);
101
+ const claim = cloud.claim();
102
+ assert.equal(claim.length, 1);
103
+ assert.equal(claim[0].operationKind, 'entity.snapshot.replace');
104
+ assert.equal(claim[0].operation.snapshot.records.length, 601);
105
+
106
+ const recipientDb = new Database(':memory:');
107
+ const recipient = createEntityCloud({ db: recipientDb, userId: 'user-1', deviceId: 'device-2' });
108
+ const recipientRegistry = createRegistry({ db: recipientDb, ephemeral: true, emit: () => {}, journal: recipient.journal });
109
+ recipient.install({
110
+ registry: recipientRegistry,
111
+ resourceId: bootstrap.resourceId,
112
+ authorityEpoch: 1,
113
+ headVersion: 0,
114
+ snapshotBase64: bootstrap.snapshotBase64,
115
+ snapshotChecksum: bootstrap.snapshotChecksum,
116
+ });
117
+ const inbound = recipient.ingest({
118
+ registry: recipientRegistry,
119
+ envelope: { ...claim[0], operationKind: 'entity.snapshot.replace' },
120
+ version: 1,
121
+ committedAt: '2026-08-05T00:00:00.000Z',
122
+ });
123
+ assert.equal(inbound.snapshotRequired, true);
124
+ assert.equal(recipient.replica().headVersion, 1);
125
+ assert.deepEqual(recipientRegistry.syncRecords(), [], 'the replacement tail never becomes partially bound local identity');
126
+ recipientDb.close();
127
+ });
128
+
79
129
  test('only an exact verified bootstrap graph can repair a legacy empty entity resource', () => {
80
130
  const db = new Database(':memory:');
81
131
  const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
@@ -3,6 +3,7 @@
3
3
  const test = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
5
  const crypto = require('crypto');
6
+ const { execFileSync } = require('child_process');
6
7
  const fs = require('fs');
7
8
  const os = require('os');
8
9
  const path = require('path');
@@ -12,6 +13,8 @@ const { createRegistry } = require('../registry');
12
13
  const { createEntityCloud, snapshotFromRecords } = require('../registration/entity-cloud');
13
14
  const { createEntityContent } = require('../registration/entity-content');
14
15
  const { createRegistrationService } = require('../registration/service');
16
+ const { capture, verify } = require('../repocard');
17
+ const { transportBytes, localState } = require('../registration/repo-states');
15
18
 
16
19
  function sha256(bytes) {
17
20
  return crypto.createHash('sha256').update(bytes).digest('hex');
@@ -28,6 +31,10 @@ function uploadedParts(content, resourceId, contentHash) {
28
31
  }
29
32
  }
30
33
 
34
+ function git(repo, args) {
35
+ execFileSync('git', args, { cwd: repo, stdio: 'pipe' });
36
+ }
37
+
31
38
  test('add materializes one compacted cloud head, preserves its UUIDs, and starts a local watcher without creating an outbound history replay', async () => {
32
39
  const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-materialization-'));
33
40
  const sourceDir = path.join(rootDir, 'source');
@@ -93,7 +100,7 @@ test('add materializes one compacted cloud head, preserves its UUIDs, and starts
93
100
  }
94
101
 
95
102
  const job = await target.add({ uuid: root.uuid, targetPath: targetParent });
96
- assert.equal(job.state, 'complete');
103
+ assert.equal(job.state, 'complete', job.error);
97
104
  const destination = path.join(targetParent, 'project');
98
105
  assert.deepEqual(fs.readFileSync(path.join(destination, 'readme.md')), bytes);
99
106
  assert.equal(target.registration.registry.syncRecord(root.uuid).uuid, root.uuid);
@@ -221,3 +228,132 @@ test('an added ordinary workspace stays live: a later cloud file head replaces l
221
228
  fs.rmSync(rootDir, { recursive: true, force: true });
222
229
  }
223
230
  });
231
+
232
+ test('add recreates an actual Git repository from verified Repocard cargo, then arms its local watcher', async () => {
233
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-repo-materialization-'));
234
+ const sourceState = path.join(rootDir, 'source-state');
235
+ const targetState = path.join(rootDir, 'target-state');
236
+ const sourceRepo = path.join(rootDir, 'source-repo');
237
+ const targetParent = path.join(rootDir, 'checkout');
238
+ fs.mkdirSync(sourceRepo, { recursive: true });
239
+ fs.mkdirSync(targetParent, { recursive: true });
240
+ git(sourceRepo, ['init', '--quiet']);
241
+ git(sourceRepo, ['config', 'user.email', 'test@example.com']);
242
+ git(sourceRepo, ['config', 'user.name', 'Amalgm Test']);
243
+ fs.writeFileSync(path.join(sourceRepo, 'tracked.md'), '# committed\n');
244
+ git(sourceRepo, ['add', 'tracked.md']);
245
+ git(sourceRepo, ['commit', '--quiet', '-m', 'initial']);
246
+ fs.writeFileSync(path.join(sourceRepo, 'tracked.md'), '# changed locally\n');
247
+ fs.writeFileSync(path.join(sourceRepo, 'untracked.txt'), 'not committed\n');
248
+
249
+ const source = createRegistrationService({
250
+ dir: sourceState, homeDir: path.join(rootDir, 'source-home'),
251
+ cloudUserId: 'user-1', cloudDeviceId: 'source-device',
252
+ });
253
+ const target = createRegistrationService({
254
+ dir: targetState, homeDir: path.join(rootDir, 'target-home'),
255
+ cloudUserId: 'user-1', cloudDeviceId: 'target-device',
256
+ });
257
+ try {
258
+ const bootstrap = source.entityCloud.bootstrap(source.registration.registry);
259
+ source.entityCloud.install({
260
+ registry: source.registration.registry, resourceId: bootstrap.resourceId,
261
+ authorityEpoch: 1, headVersion: 0, snapshotBase64: bootstrap.snapshotBase64,
262
+ snapshotChecksum: bootstrap.snapshotChecksum,
263
+ });
264
+ const registered = await source.register(sourceRepo);
265
+ const sourceDb = new Database(path.join(sourceState, 'identity.db'), { readonly: true });
266
+ const operations = sourceDb.prepare('SELECT operation_json FROM entity_cloud_mutations ORDER BY sequence').all()
267
+ .map((row) => JSON.parse(row.operation_json));
268
+ sourceDb.close();
269
+ const repoCloudRecord = operations.map((operation) => operation.record)
270
+ .find((record) => record?.uuid === registered.record.uuid);
271
+ assert.ok(repoCloudRecord?.transportVersion, 'the repo entity names immutable Repocard transport cargo');
272
+ const records = source.registration.registry.syncRecords().map((record) => (
273
+ record.uuid === registered.record.uuid ? repoCloudRecord : record
274
+ ));
275
+ const snapshot = snapshotFromRecords(records);
276
+ const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
277
+ target.entityCloud.install({
278
+ registry: target.registration.registry, resourceId: bootstrap.resourceId,
279
+ authorityEpoch: 1, headVersion: 1, snapshotBase64: snapshotBytes.toString('base64'),
280
+ snapshotChecksum: sha256(snapshotBytes),
281
+ });
282
+
283
+ const heads = new Set(records.flatMap((record) => {
284
+ if (['file.text', 'file.binary'].includes(record.type) && record.payloadVersion) return [record.payloadVersion];
285
+ if (record.type === 'repo.git' && record.transportVersion) return [record.transportVersion];
286
+ return [];
287
+ }));
288
+ for (const head of heads) {
289
+ const parts = uploadedParts(source.entityContent, bootstrap.resourceId, head);
290
+ for (const part of [...parts.filter((entry) => entry.kind === 'manifest'), ...parts.filter((entry) => entry.kind === 'chunk')]) {
291
+ target.receiveCloudContent({
292
+ resourceId: bootstrap.resourceId, contentHash: head, kind: part.kind,
293
+ partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
294
+ sha256: part.sha256, dataBase64: part.dataBase64,
295
+ });
296
+ }
297
+ }
298
+
299
+ const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
300
+ assert.equal(job.state, 'complete', job.error);
301
+ const destination = path.join(targetParent, path.basename(sourceRepo));
302
+ const expected = source.registration.repoState(registered.record.uuid);
303
+ assert.equal(verify(destination, expected).ok, true, 'the downloaded Git state reproduces its Card + Checkpoint IDs');
304
+ assert.equal(target.registration.registry.syncRecord(registered.record.uuid).uuid, registered.record.uuid);
305
+ assert.equal(target.observer.status().find((entry) => entry.path === fs.realpathSync(destination))?.watching, true,
306
+ 'the same successful add starts the destination repository watcher');
307
+
308
+ // A Git-only change exercises the ongoing Add binding without also
309
+ // changing child-file heads: the repo entity must remain a live source
310
+ // of truth after its first materialization, and the receiver must not
311
+ // echo its own applied state back into the cloud outbox.
312
+ git(sourceRepo, ['tag', 'amalgm-live-proof']);
313
+ const changedState = capture(sourceRepo, { withBundle: true });
314
+ const changedTransport = source.entityContent.captureBytes({ bytes: transportBytes(changedState) });
315
+ const changedLocalState = localState(changedState, changedTransport.contentHash);
316
+ let changedRecord;
317
+ source.registration.registry.transaction(() => {
318
+ source.registration.repoStates.put(registered.record.uuid, changedLocalState);
319
+ changedRecord = source.registration.registry.attest(registered.record.uuid, {
320
+ type: 'repo.git', payloadVersion: changedState.stateId,
321
+ });
322
+ });
323
+ const latest = new Database(path.join(sourceState, 'identity.db'), { readonly: true });
324
+ const remoteRecord = JSON.parse(latest.prepare(`
325
+ SELECT operation_json FROM entity_cloud_mutations
326
+ WHERE json_extract(operation_json, '$.record.uuid') = ?
327
+ ORDER BY sequence DESC LIMIT 1
328
+ `).get(registered.record.uuid).operation_json).record;
329
+ latest.close();
330
+ assert.equal(remoteRecord.transportVersion, changedTransport.contentHash);
331
+ const ingested = target.ingestCloudMutation({
332
+ envelope: {
333
+ resourceId: bootstrap.resourceId, mutationId: 'ent_remote_repo_change',
334
+ contract: 'amalgm-entity-registry@1', schemaVersion: 1,
335
+ operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: remoteRecord },
336
+ },
337
+ version: 2, committedAt: '2026-08-05T00:00:00.000Z',
338
+ });
339
+ assert.equal(ingested.materializations.length, 1, 'the finished repository add becomes a live pending application');
340
+ const changedParts = uploadedParts(source.entityContent, bootstrap.resourceId, changedTransport.contentHash);
341
+ for (const part of [...changedParts.filter((entry) => entry.kind === 'manifest'), ...changedParts.filter((entry) => entry.kind === 'chunk')]) {
342
+ target.receiveCloudContent({
343
+ resourceId: bootstrap.resourceId, contentHash: changedTransport.contentHash,
344
+ kind: part.kind, partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
345
+ sha256: part.sha256, dataBase64: part.dataBase64,
346
+ });
347
+ }
348
+ assert.equal(verify(destination, changedLocalState).ok, true,
349
+ 'a later cloud repo state re-materializes exactly on the existing checkout');
350
+ const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
351
+ assert.equal(targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 0,
352
+ 'the receiver never echoes its downloaded Git state upstream');
353
+ targetDb.close();
354
+ } finally {
355
+ source.close();
356
+ target.close();
357
+ fs.rmSync(rootDir, { recursive: true, force: true });
358
+ }
359
+ });
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ test('/healthz is an I/O-free liveness response', async () => {
7
+ const { handleHealthRoutes } = require('../server/routes/health');
8
+ const replies = [];
9
+ const handled = await handleHealthRoutes({
10
+ pathname: '/healthz',
11
+ sendJson: (status, body) => replies.push({ status, body }),
12
+ });
13
+
14
+ assert.equal(handled, true);
15
+ assert.equal(replies.length, 1);
16
+ assert.equal(replies[0].status, 200);
17
+ assert.equal(replies[0].body.status, 'ok');
18
+ assert.equal(Object.hasOwn(replies[0].body, 'automations'), false);
19
+ assert.equal(Object.hasOwn(replies[0].body, 'apps'), false);
20
+ });
@@ -303,6 +303,43 @@ test('a signed-in register call writes every entity delivery intent in identity.
303
303
  }
304
304
  });
305
305
 
306
+ test('a large signed-in register call publishes one complete cloud graph before Watch begins', async () => {
307
+ const service = createRegistrationService({
308
+ dir: path.join(tempRoot, 'state-cloud-register-large'),
309
+ homeDir: path.join(tempRoot, 'cloud-user-home-large'),
310
+ cloudUserId: 'user-cloud-register-large',
311
+ cloudDeviceId: 'computer-cloud-register-large',
312
+ });
313
+ const bootstrap = service.entityCloud.bootstrap(service.registration.registry);
314
+ service.entityCloud.install({
315
+ registry: service.registration.registry,
316
+ resourceId: bootstrap.resourceId,
317
+ authorityEpoch: 1,
318
+ headVersion: 0,
319
+ snapshotBase64: bootstrap.snapshotBase64,
320
+ snapshotChecksum: bootstrap.snapshotChecksum,
321
+ });
322
+ const ground = makeGround('cloud-register-large');
323
+ for (let index = 0; index < 500; index += 1) fs.mkdirSync(path.join(ground, `folder-${index}`));
324
+
325
+ try {
326
+ const registered = await service.register(ground);
327
+ const db = new Database(path.join(service.dir, 'identity.db'), { readonly: true });
328
+ const rows = db.prepare('SELECT operation_json, mutation_state FROM entity_cloud_mutations ORDER BY sequence').all();
329
+ db.close();
330
+ assert.equal(rows.length, 1, 'the cloud never sees a partially registered large tree');
331
+ const operation = JSON.parse(rows[0].operation_json);
332
+ assert.equal(operation.kind, 'entity.snapshot.replace');
333
+ assert.equal(operation.snapshot.records.length, 501, 'the root and every folder share one authoritative graph');
334
+ assert.equal(rows[0].mutation_state, 'pending');
335
+ assert.equal(service.observer.status().some((root) => (
336
+ root.path === registered.rootPath && root.watching && !root.degraded
337
+ )), true, 'the same registered root begins local watching');
338
+ } finally {
339
+ service.close();
340
+ }
341
+ });
342
+
306
343
  test('register registers the named ground and every file beneath it', async () => {
307
344
  const service = makeService('add');
308
345
  const ground = makeGround('add');