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 +1 -1
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +124 -29
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +29 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +15 -3
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +8 -2
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +26 -4
- package/runtime/scripts/amalgm-mcp/registration/service.js +130 -8
- package/runtime/scripts/amalgm-mcp/registration/tree.js +29 -8
- package/runtime/scripts/amalgm-mcp/server/routes/health.js +4 -32
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +50 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +137 -1
- package/runtime/scripts/amalgm-mcp/tests/health.test.js +20 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +37 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
116
|
-
&& typeof record?.payloadVersion === 'string'
|
|
117
|
-
|
|
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
|
|
121
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
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 =
|
|
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) => ['
|
|
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 (
|
|
567
|
-
|
|
568
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
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:
|
|
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
|
|
716
|
-
|
|
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
|
-
|
|
804
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) => ['
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
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');
|