amalgm 0.1.256 → 0.1.258
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/lib/cli.js +72 -3
- package/lib/layout.js +30 -9
- package/lib/shared-realtime-tunnel.js +12 -14
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/lib/layout.js +32 -9
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +186 -27
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +33 -6
- package/runtime/scripts/amalgm-mcp/registration/index.js +21 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +102 -11
- package/runtime/scripts/amalgm-mcp/registry/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +6 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +67 -8
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +3 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +45 -3
- package/runtime/scripts/amalgm-mcp/tests/system-catalog.test.js +3 -6
- package/runtime/scripts/amalgm-mcp/tests/toolbox-artifacts.test.js +88 -0
- package/runtime/scripts/amalgm-mcp/tests/toolbox-cloud-registration.test.js +91 -0
- package/runtime/scripts/amalgm-mcp/toolbox/artifacts.js +225 -0
- package/runtime/scripts/amalgm-mcp/toolbox/store.js +59 -10
- package/runtime/scripts/amalgm-mcp/toolbox/system-catalog.js +14 -5
|
@@ -15,7 +15,10 @@ const fs = require('fs');
|
|
|
15
15
|
const path = require('path');
|
|
16
16
|
|
|
17
17
|
const CONTENT_CONTRACT = 'amalgm-entity-content@1';
|
|
18
|
-
|
|
18
|
+
// One chunk is one WAN round trip. The gateway accepts parts up to this same
|
|
19
|
+
// bound (ENTITY_CONTENT_PART_BYTES); older 256KB chunks remain valid because
|
|
20
|
+
// the manifest rule is `bytes <= CHUNK_BYTES`, never an exact size.
|
|
21
|
+
const CHUNK_BYTES = 4 * 1024 * 1024;
|
|
19
22
|
const HASH = /^[0-9a-f]{64}$/;
|
|
20
23
|
|
|
21
24
|
function digest(bytes) {
|
|
@@ -41,6 +44,10 @@ function createEntityContent({ dir }) {
|
|
|
41
44
|
const chunksDir = path.join(root, 'chunks');
|
|
42
45
|
const manifestsDir = path.join(root, 'manifests');
|
|
43
46
|
const uploadsDir = path.join(root, 'uploads');
|
|
47
|
+
// Unfinished uploads per resource, so claim() does not re-read the whole
|
|
48
|
+
// ledger directory for every part it sends. The on-disk ledger stays the
|
|
49
|
+
// durable truth; this set is rebuilt from it once per process.
|
|
50
|
+
const pendingUploads = new Map();
|
|
44
51
|
|
|
45
52
|
function ensureDirs() {
|
|
46
53
|
for (const target of [chunksDir, manifestsDir, uploadsDir]) {
|
|
@@ -236,14 +243,27 @@ function createEntityContent({ dir }) {
|
|
|
236
243
|
}
|
|
237
244
|
}
|
|
238
245
|
|
|
246
|
+
function pendingSet(resourceId) {
|
|
247
|
+
let pending = pendingUploads.get(resourceId);
|
|
248
|
+
if (!pending) {
|
|
249
|
+
pending = new Set(uploadEntries(resourceId));
|
|
250
|
+
pendingUploads.set(resourceId, pending);
|
|
251
|
+
}
|
|
252
|
+
return pending;
|
|
253
|
+
}
|
|
254
|
+
|
|
239
255
|
function ensureUpload({ resourceId, contentHash }) {
|
|
240
256
|
if (typeof resourceId !== 'string' || !resourceId) throw new Error('entity content upload requires a resource id');
|
|
241
257
|
const content = manifest(contentHash);
|
|
242
258
|
if (!content) throw new Error(`content ${contentHash} was not captured before its entity record committed`);
|
|
243
259
|
const current = readUpload(resourceId, contentHash, content);
|
|
244
|
-
if (current)
|
|
260
|
+
if (current) {
|
|
261
|
+
if (!current.manifestUploaded) pendingSet(resourceId).add(contentHash);
|
|
262
|
+
return current;
|
|
263
|
+
}
|
|
245
264
|
const created = initialUpload(resourceId, contentHash);
|
|
246
265
|
saveUpload(created);
|
|
266
|
+
pendingSet(resourceId).add(contentHash);
|
|
247
267
|
return created;
|
|
248
268
|
}
|
|
249
269
|
|
|
@@ -269,11 +289,15 @@ function createEntityContent({ dir }) {
|
|
|
269
289
|
function claim({ resourceId, leaseMs = 30_000 }) {
|
|
270
290
|
ensureDirs();
|
|
271
291
|
const at = Date.now();
|
|
272
|
-
|
|
292
|
+
const pending = pendingSet(resourceId);
|
|
293
|
+
for (const contentHash of pending) {
|
|
273
294
|
const content = manifest(contentHash);
|
|
274
295
|
if (!content) throw new Error(`entity content ${contentHash} disappeared from the local cache`);
|
|
275
296
|
const upload = readUpload(resourceId, contentHash, content);
|
|
276
|
-
if (!upload || upload.manifestUploaded)
|
|
297
|
+
if (!upload || upload.manifestUploaded) {
|
|
298
|
+
pending.delete(contentHash);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
277
301
|
if (upload.leaseUntil && Date.parse(upload.leaseUntil) > at) continue;
|
|
278
302
|
const next = { ...upload, leaseUntil: new Date(at + Math.max(1_000, Number(leaseMs) || 30_000)).toISOString(), updatedAt: now() };
|
|
279
303
|
if (next.nextPart < content.chunks.length) {
|
|
@@ -333,6 +357,7 @@ function createEntityContent({ dir }) {
|
|
|
333
357
|
current.leaseUntil = null;
|
|
334
358
|
current.updatedAt = now();
|
|
335
359
|
saveUpload(current);
|
|
360
|
+
if (current.manifestUploaded) pendingUploads.get(resourceId)?.delete(contentHash);
|
|
336
361
|
return { duplicate: false, uploaded: current.manifestUploaded };
|
|
337
362
|
}
|
|
338
363
|
|
|
@@ -412,9 +437,11 @@ function createEntityContent({ dir }) {
|
|
|
412
437
|
if (!current) return { kind: 'manifest', partIndex: 0, sha256: null };
|
|
413
438
|
for (let partIndex = 0; partIndex < current.chunks.length; partIndex += 1) {
|
|
414
439
|
const chunk = current.chunks[partIndex];
|
|
440
|
+
// Presence + size only: every chunk file was hash-verified before it
|
|
441
|
+
// was written (capture or receive), writes are write-once, and final
|
|
442
|
+
// assembly (`bytes`) re-verifies the whole content head.
|
|
415
443
|
try {
|
|
416
|
-
|
|
417
|
-
if (bytes.length === chunk.bytes && digest(bytes) === chunk.sha256) continue;
|
|
444
|
+
if (fs.statSync(chunkFile(chunk.sha256)).size === chunk.bytes) continue;
|
|
418
445
|
} catch (error) {
|
|
419
446
|
if (error.code !== 'ENOENT') throw error;
|
|
420
447
|
}
|
|
@@ -596,6 +596,26 @@ function createRegistration(options) {
|
|
|
596
596
|
return normalized.map(({ targetPath, identityAt }) => add(targetPath, { identityAt }));
|
|
597
597
|
}
|
|
598
598
|
|
|
599
|
+
/** Reconcile one already-watched tree after the platform has materialized
|
|
600
|
+
* a newly declared exact default inside it. This is deliberately not part
|
|
601
|
+
* of ordinary re-registration: the caller must have already named the
|
|
602
|
+
* exact platform node it needs, and the normal Detect → Record path still
|
|
603
|
+
* decides the resulting record. */
|
|
604
|
+
function reconcileDeclared(targetPath) {
|
|
605
|
+
let ground;
|
|
606
|
+
try {
|
|
607
|
+
ground = fs.lstatSync(targetPath).isSymbolicLink() ? path.resolve(targetPath) : fs.realpathSync(targetPath);
|
|
608
|
+
} catch {
|
|
609
|
+
throw new Error(`declared user-home root is unavailable: ${targetPath}`);
|
|
610
|
+
}
|
|
611
|
+
const covering = treeRootCovering(ground);
|
|
612
|
+
if (!covering) throw new Error(`declared user-home root was not registered: ${targetPath}`);
|
|
613
|
+
observer.scanNow(covering.rootId);
|
|
614
|
+
flushIfDirty();
|
|
615
|
+
requireLiveWatchers(covering.path);
|
|
616
|
+
return { rootPath: covering.path, record: rootRecordOf(covering.rootId) };
|
|
617
|
+
}
|
|
618
|
+
|
|
599
619
|
/** The boundary's whole convergence pass, one act: loss convicted
|
|
600
620
|
* first (loudly — a conviction is never a retry), edges followed,
|
|
601
621
|
* lenses reconciled, renderings projected. Startup, the explicit
|
|
@@ -626,6 +646,7 @@ function createRegistration(options) {
|
|
|
626
646
|
registry,
|
|
627
647
|
add,
|
|
628
648
|
addMany,
|
|
649
|
+
reconcileDeclared,
|
|
629
650
|
|
|
630
651
|
/**
|
|
631
652
|
* `amalgm register <existing-path> --as <address>` — the managed
|
|
@@ -47,6 +47,10 @@ const Database = require('better-sqlite3');
|
|
|
47
47
|
const { STORAGE_DIR } = require('../config');
|
|
48
48
|
const { ensureDir } = require('../lib/storage');
|
|
49
49
|
const { ensureUserHome } = require('../lib/layout');
|
|
50
|
+
const {
|
|
51
|
+
materializeSystemToolArtifacts,
|
|
52
|
+
migrateLegacyToolboxCatalog,
|
|
53
|
+
} = require('../toolbox/artifacts');
|
|
50
54
|
const { createObserver } = require('../observer');
|
|
51
55
|
const { shouldEnroll } = require('../workspace/tree/inclusion');
|
|
52
56
|
const { classifyFile } = require('./classify');
|
|
@@ -58,8 +62,8 @@ const { transportBytes, localState, unpack } = require('./repo-states');
|
|
|
58
62
|
const { machineId, userId } = require('../workspace/identity');
|
|
59
63
|
|
|
60
64
|
const ENTITIES_DIR = path.join(STORAGE_DIR, 'entities');
|
|
61
|
-
const USER_HOME_CLOUD_CONTRACT = 'amalgm-user-home@
|
|
62
|
-
const USER_HOME_CLOUD_SCHEMA_VERSION =
|
|
65
|
+
const USER_HOME_CLOUD_CONTRACT = 'amalgm-user-home@2';
|
|
66
|
+
const USER_HOME_CLOUD_SCHEMA_VERSION = 2;
|
|
63
67
|
const ATOMIC_REGISTRATION_THRESHOLD = 500;
|
|
64
68
|
|
|
65
69
|
let live = null;
|
|
@@ -260,6 +264,19 @@ function createRegistrationService({
|
|
|
260
264
|
*/
|
|
261
265
|
async function bootstrapUserHome({ identitySnapshot = null } = {}) {
|
|
262
266
|
const declaration = ensureUserHome(homeDir);
|
|
267
|
+
// Materialize every built-in tool before the shared registration boundary
|
|
268
|
+
// first sees `toolbox/`. Each file is an exact managed child; user tools
|
|
269
|
+
// remain ordinary sibling files. A legacy aggregate is moved outside the
|
|
270
|
+
// watched tree only after per-tool replacements exist.
|
|
271
|
+
const toolbox = declaration.roots.find((entry) => entry.key === 'toolbox');
|
|
272
|
+
if (!toolbox) throw new Error('declared user-home is missing its toolbox root');
|
|
273
|
+
const migrated = migrateLegacyToolboxCatalog({ toolboxDir: toolbox.path });
|
|
274
|
+
const systemTools = materializeSystemToolArtifacts({ toolboxDir: toolbox.path });
|
|
275
|
+
declaration.materializedExactPaths = [
|
|
276
|
+
...(declaration.materializedExactPaths || []),
|
|
277
|
+
...(migrated.artifactPaths || []).map((target) => path.relative(homeDir, target).split(path.sep).join('/')),
|
|
278
|
+
...(systemTools.changedRelativePaths || []),
|
|
279
|
+
];
|
|
263
280
|
const seeds = identitySeeds(identitySnapshot, declaration);
|
|
264
281
|
const addedRoots = boundary.current.addMany(declaration.roots.map((entry) => ({
|
|
265
282
|
targetPath: entry.path,
|
|
@@ -274,25 +291,50 @@ function createRegistrationService({
|
|
|
274
291
|
};
|
|
275
292
|
});
|
|
276
293
|
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const recordAt = (relativePath) => {
|
|
294
|
+
const recordAt = (rootKey, relativePath, { required = true } = {}) => {
|
|
295
|
+
const root = roots.find((entry) => entry.key === rootKey);
|
|
296
|
+
if (!root) throw new Error(`declared user-home root was not registered: ${rootKey}`);
|
|
297
|
+
const tree = boundary.current.tree(root.path);
|
|
298
|
+
if (!tree?.root()) throw new Error(`declared user-home root was not registered: ${root.path}`);
|
|
283
299
|
let record = tree.root();
|
|
284
300
|
for (const name of relativePath.split('/')) {
|
|
285
301
|
record = tree.registry.activeAt(record.uuid, name);
|
|
286
|
-
if (!record)
|
|
302
|
+
if (!record) {
|
|
303
|
+
if (!required) return null;
|
|
304
|
+
throw new Error(`declared user-home target was not registered: ${rootKey}/${relativePath}`);
|
|
305
|
+
}
|
|
287
306
|
}
|
|
288
307
|
return record;
|
|
289
308
|
};
|
|
290
309
|
|
|
310
|
+
// An upgraded installation can already watch an older declared root.
|
|
311
|
+
// Re-registering that root must remain only an availability check, so the
|
|
312
|
+
// narrow exception is a root holding an exact node that the layout just
|
|
313
|
+
// materialized or that the registry proves missing. Detect and Record
|
|
314
|
+
// still create/update its identity through the shared boundary.
|
|
315
|
+
const rootsToReconcile = new Set((declaration.materializedExactPaths || [])
|
|
316
|
+
.map((relativePath) => relativePath.split('/')[0]));
|
|
317
|
+
for (const entry of declaration.exactManaged) {
|
|
318
|
+
if (entry.relativePath === entry.rootKey) continue;
|
|
319
|
+
const relativePath = entry.relativePath.slice(entry.rootKey.length + 1);
|
|
320
|
+
if (!recordAt(entry.rootKey, relativePath, { required: false })) {
|
|
321
|
+
rootsToReconcile.add(entry.rootKey);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
for (const rootKey of rootsToReconcile) {
|
|
325
|
+
const root = roots.find((candidate) => candidate.key === rootKey);
|
|
326
|
+
if (!root) throw new Error(`declared user-home root was not registered: ${rootKey}`);
|
|
327
|
+
boundary.current.reconcileDeclared(root.path);
|
|
328
|
+
roots = roots.map((candidate) => candidate.key === rootKey
|
|
329
|
+
? { ...candidate, record: boundary.current.tree(candidate.path).root() }
|
|
330
|
+
: candidate);
|
|
331
|
+
}
|
|
332
|
+
|
|
291
333
|
let exactManaged = declaration.exactManaged.map((entry) => {
|
|
292
334
|
const root = roots.find((candidate) => candidate.key === entry.rootKey);
|
|
293
335
|
const isRoot = entry.relativePath === entry.rootKey;
|
|
294
336
|
const relativePath = isRoot ? '' : entry.relativePath.slice(entry.rootKey.length + 1);
|
|
295
|
-
const record = isRoot ? root?.record : recordAt(relativePath);
|
|
337
|
+
const record = isRoot ? root?.record : recordAt(entry.rootKey, relativePath);
|
|
296
338
|
if (!record) throw new Error(`declared user-home managed target was not registered: ${entry.relativePath}`);
|
|
297
339
|
return {
|
|
298
340
|
key: entry.key,
|
|
@@ -767,6 +809,51 @@ function createRegistrationService({
|
|
|
767
809
|
}
|
|
768
810
|
}
|
|
769
811
|
|
|
812
|
+
/** The status surface composes each layer's own facts without creating a
|
|
813
|
+
* second registry or treating local paths as cloud metadata. A cloud-only
|
|
814
|
+
* catalog record is intentionally visible with `localPath: null`; only
|
|
815
|
+
* `amalgm add` creates a watcher binding for it. */
|
|
816
|
+
function status() {
|
|
817
|
+
const cloud = entityCloud.status();
|
|
818
|
+
const localRecords = boundary.current.registry.syncRecords();
|
|
819
|
+
const localByUuid = new Map(localRecords.map((record) => [record.uuid, record]));
|
|
820
|
+
const cloudByUuid = new Map(cloud.catalog.records.map((record) => [record.uuid, record]));
|
|
821
|
+
const roots = observer.status().map((root) => {
|
|
822
|
+
const tree = boundary.current.tree(root.path);
|
|
823
|
+
const record = tree ? tree.root() : null;
|
|
824
|
+
return {
|
|
825
|
+
uuid: record?.uuid || null,
|
|
826
|
+
path: root.path,
|
|
827
|
+
kind: root.kind,
|
|
828
|
+
watching: root.watching,
|
|
829
|
+
degraded: root.degraded,
|
|
830
|
+
addressWatching: root.addressWatching,
|
|
831
|
+
addressDegraded: root.addressDegraded,
|
|
832
|
+
};
|
|
833
|
+
});
|
|
834
|
+
const rootByUuid = new Map(roots.filter((root) => root.uuid !== null).map((root) => [root.uuid, root]));
|
|
835
|
+
const uuids = new Set([...localByUuid.keys(), ...cloudByUuid.keys()]);
|
|
836
|
+
const entities = [...uuids].sort().map((uuid) => {
|
|
837
|
+
const local = localByUuid.get(uuid) || null;
|
|
838
|
+
const record = cloudByUuid.get(uuid) || local;
|
|
839
|
+
let localPath = null;
|
|
840
|
+
if (local) {
|
|
841
|
+
try { localPath = boundary.current.addressOf(uuid); } catch {}
|
|
842
|
+
}
|
|
843
|
+
return {
|
|
844
|
+
...record,
|
|
845
|
+
// A local journal projects its intent into the catalog before the
|
|
846
|
+
// gateway acknowledges it. Call that fact `catalogued`, not
|
|
847
|
+
// `cloudBacked`: the enclosing cloud summary says whether the
|
|
848
|
+
// catalog is actually pinned to an authoritative current head.
|
|
849
|
+
catalogued: cloudByUuid.has(uuid),
|
|
850
|
+
localPath,
|
|
851
|
+
root: rootByUuid.get(uuid) || null,
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
return { cloud, roots, entities };
|
|
855
|
+
}
|
|
856
|
+
|
|
770
857
|
return {
|
|
771
858
|
dir,
|
|
772
859
|
homeDir,
|
|
@@ -820,9 +907,13 @@ function createRegistrationService({
|
|
|
820
907
|
const received = entityCloud.receiveDownload({
|
|
821
908
|
resourceId, contentHash, kind, partIndex, sha256, dataBase64,
|
|
822
909
|
});
|
|
823
|
-
|
|
910
|
+
// A mid-file part cannot complete any waiting tree; only a finished
|
|
911
|
+
// content head can. Advancing on every part re-walks every waiting
|
|
912
|
+
// job once per part, which is quadratic over a large download.
|
|
913
|
+
if (received?.complete) advanceMaterializations();
|
|
824
914
|
return received;
|
|
825
915
|
},
|
|
916
|
+
status,
|
|
826
917
|
advanceMaterializations,
|
|
827
918
|
bootstrapUserHome,
|
|
828
919
|
exactUserHomeCloudRecords,
|
|
@@ -686,7 +686,7 @@ function createRegistry(options) {
|
|
|
686
686
|
|
|
687
687
|
/**
|
|
688
688
|
* First cloud bootstrap may meet a pre-cloud local safe home. Its six
|
|
689
|
-
* roots and
|
|
689
|
+
* roots and ten exact managed nodes are platform truth, so they may
|
|
690
690
|
* adopt the authenticated cloud UUIDs without changing any user-owned
|
|
691
691
|
* descendant identity. This is one atomic graph rekey, never a delete or
|
|
692
692
|
* local-wins merge, and callers must prove that no ordinary entity-cloud
|
|
@@ -316,6 +316,12 @@ async function handleEntityRoutes(ctx) {
|
|
|
316
316
|
return true;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
if (ctx.pathname === '/entities/status' && ctx.method === 'GET') {
|
|
320
|
+
ctx.res.setHeader('Cache-Control', 'no-store');
|
|
321
|
+
ctx.sendJson(200, registrationService().status());
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
319
325
|
const materializationMatch = /^\/entities\/add\/(mat_[A-Za-z0-9_-]+)$/.exec(ctx.pathname);
|
|
320
326
|
if (materializationMatch && ctx.method === 'GET') {
|
|
321
327
|
const service = registrationService();
|
|
@@ -76,6 +76,37 @@ 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('entity-cloud status reports an unsettled catalog as catching up without repairing it', () => {
|
|
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: 'ground' });
|
|
84
|
+
const bootstrap = cloud.bootstrap(registry.syncRecords());
|
|
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
|
+
|
|
94
|
+
const current = cloud.status();
|
|
95
|
+
assert.equal(current.catalog.current, true);
|
|
96
|
+
assert.equal(current.catalog.records[0].uuid, root.uuid);
|
|
97
|
+
assert.equal(current.outbox.pending, 0);
|
|
98
|
+
|
|
99
|
+
const child = registry.create({ type: 'folder', parentUUID: root.uuid, name: 'child' });
|
|
100
|
+
const catchingUp = cloud.status();
|
|
101
|
+
assert.equal(catchingUp.catalog.current, false, 'an unacknowledged local mutation never masquerades as an active cloud head');
|
|
102
|
+
assert.equal(catchingUp.outbox.pending, 1);
|
|
103
|
+
assert.equal(catchingUp.catalog.records.some((record) => record.uuid === child.uuid), true,
|
|
104
|
+
'status may show the local catalog projection while naming it catching up');
|
|
105
|
+
|
|
106
|
+
const after = cloud.status();
|
|
107
|
+
assert.deepEqual(after, catchingUp, 'status is read-only: it does not claim, retry, or repair delivery');
|
|
108
|
+
});
|
|
109
|
+
|
|
79
110
|
test('a large registration replaces the complete graph in one cloud operation and a recipient waits for its compacted snapshot', () => {
|
|
80
111
|
const db = new Database(':memory:');
|
|
81
112
|
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
|
|
@@ -244,7 +275,7 @@ test('a compacted remote snapshot enters the cloud catalog, not locally watched
|
|
|
244
275
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
245
276
|
});
|
|
246
277
|
|
|
247
|
-
test('add
|
|
278
|
+
test('add follows in-order mirrored mutations and refuses after a version gap until the compacted snapshot returns', () => {
|
|
248
279
|
const sourceDb = new Database(':memory:');
|
|
249
280
|
const sourceCloud = createEntityCloud({ db: sourceDb, userId: 'user-1', deviceId: 'source-device' });
|
|
250
281
|
const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
|
|
@@ -277,7 +308,7 @@ test('add accepts only a catalog installed by the active compacted snapshot, nev
|
|
|
277
308
|
envelope: {
|
|
278
309
|
resourceId,
|
|
279
310
|
mutationId: 'ent_remote_change',
|
|
280
|
-
contract: 'amalgm-entity-registry@
|
|
311
|
+
contract: 'amalgm-entity-registry@3',
|
|
281
312
|
schemaVersion: 1,
|
|
282
313
|
operationKind: 'entity.upsert',
|
|
283
314
|
operation: { kind: 'entity.upsert', record: changed },
|
|
@@ -286,25 +317,53 @@ test('add accepts only a catalog installed by the active compacted snapshot, nev
|
|
|
286
317
|
committedAt: '2026-08-04T00:00:00.000Z',
|
|
287
318
|
});
|
|
288
319
|
|
|
320
|
+
// Version 13 arrived in official order on a snapshot-consistent catalog:
|
|
321
|
+
// the catalog IS the compacted head, so add needs no re-download.
|
|
322
|
+
const inOrder = path.join(targetDir, 'in-order');
|
|
323
|
+
fs.mkdirSync(inOrder);
|
|
324
|
+
assert.equal(
|
|
325
|
+
targetCloud.startMaterialization({ uuid: root.uuid, targetPath: inOrder }).headVersion,
|
|
326
|
+
13,
|
|
327
|
+
'an in-order mirrored mutation keeps the catalog materializable at the new head',
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Version 15 skips 14: the catalog can no longer prove it equals the
|
|
331
|
+
// head, so add refuses until a fresh compacted snapshot is installed.
|
|
332
|
+
const gapped = { ...changed, name: 'gapped.md', version: 'd'.repeat(64) };
|
|
333
|
+
targetCloud.ingest({
|
|
334
|
+
registry: targetRegistry,
|
|
335
|
+
envelope: {
|
|
336
|
+
resourceId,
|
|
337
|
+
mutationId: 'ent_remote_gap',
|
|
338
|
+
contract: 'amalgm-entity-registry@3',
|
|
339
|
+
schemaVersion: 1,
|
|
340
|
+
operationKind: 'entity.upsert',
|
|
341
|
+
operation: { kind: 'entity.upsert', record: gapped },
|
|
342
|
+
},
|
|
343
|
+
version: 15,
|
|
344
|
+
committedAt: '2026-08-04T00:00:01.000Z',
|
|
345
|
+
});
|
|
346
|
+
const afterGap = path.join(targetDir, 'after-gap');
|
|
347
|
+
fs.mkdirSync(afterGap);
|
|
289
348
|
assert.throws(
|
|
290
|
-
() => targetCloud.startMaterialization({ uuid: root.uuid, targetPath:
|
|
349
|
+
() => targetCloud.startMaterialization({ uuid: root.uuid, targetPath: afterGap }),
|
|
291
350
|
/catalog is not pinned to the active cloud head/,
|
|
292
|
-
'
|
|
351
|
+
'a version gap drops the snapshot barrier until compaction returns',
|
|
293
352
|
);
|
|
294
353
|
|
|
295
354
|
const cloudRoot = initialSnapshot.records.find((record) => record.uuid === root.uuid);
|
|
296
|
-
const compactedBytes = Buffer.from(JSON.stringify(snapshotFromRecords([cloudRoot,
|
|
355
|
+
const compactedBytes = Buffer.from(JSON.stringify(snapshotFromRecords([cloudRoot, gapped])), 'utf8');
|
|
297
356
|
targetCloud.install({
|
|
298
357
|
registry: targetRegistry,
|
|
299
358
|
resourceId,
|
|
300
359
|
authorityEpoch: 3,
|
|
301
|
-
headVersion:
|
|
360
|
+
headVersion: 15,
|
|
302
361
|
snapshotBase64: compactedBytes.toString('base64'),
|
|
303
362
|
snapshotChecksum: require('crypto').createHash('sha256').update(compactedBytes).digest('hex'),
|
|
304
363
|
});
|
|
305
364
|
assert.equal(
|
|
306
|
-
targetCloud.startMaterialization({ uuid: root.uuid, targetPath:
|
|
307
|
-
|
|
365
|
+
targetCloud.startMaterialization({ uuid: root.uuid, targetPath: afterGap }).headVersion,
|
|
366
|
+
15,
|
|
308
367
|
'the fresh compacted snapshot—not a history replay—makes add available again',
|
|
309
368
|
);
|
|
310
369
|
} finally {
|
|
@@ -189,7 +189,7 @@ test('an added ordinary workspace stays live: a later cloud file head replaces l
|
|
|
189
189
|
envelope: {
|
|
190
190
|
resourceId: sourceCloud.identity().resourceId,
|
|
191
191
|
mutationId: 'ent_remote_file_change',
|
|
192
|
-
contract: 'amalgm-entity-registry@
|
|
192
|
+
contract: 'amalgm-entity-registry@3',
|
|
193
193
|
schemaVersion: 1,
|
|
194
194
|
operationKind: 'entity.upsert',
|
|
195
195
|
operation: { kind: 'entity.upsert', record: remoteRecord },
|
|
@@ -296,7 +296,7 @@ test('add preserves a symlink UUID and later retargets the same literal link wit
|
|
|
296
296
|
const ingested = target.ingestCloudMutation({
|
|
297
297
|
envelope: {
|
|
298
298
|
resourceId: sourceCloud.identity().resourceId, mutationId: 'ent_remote_link_change',
|
|
299
|
-
contract: 'amalgm-entity-registry@
|
|
299
|
+
contract: 'amalgm-entity-registry@3', schemaVersion: 1,
|
|
300
300
|
operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: sourceRegistry.syncRecord(changed.uuid) },
|
|
301
301
|
},
|
|
302
302
|
version: 42, committedAt: '2026-08-05T00:00:00.000Z',
|
|
@@ -420,7 +420,7 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
420
420
|
const ingested = target.ingestCloudMutation({
|
|
421
421
|
envelope: {
|
|
422
422
|
resourceId: bootstrap.resourceId, mutationId: 'ent_remote_repo_change',
|
|
423
|
-
contract: 'amalgm-entity-registry@
|
|
423
|
+
contract: 'amalgm-entity-registry@3', schemaVersion: 1,
|
|
424
424
|
operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: remoteRecord },
|
|
425
425
|
},
|
|
426
426
|
version: 2, committedAt: '2026-08-05T00:00:00.000Z',
|
|
@@ -54,6 +54,7 @@ const { ENTITIES_DIR, createRegistrationService, registrationService } = require
|
|
|
54
54
|
const { adapterFor } = require('../adapters');
|
|
55
55
|
const { createRuntimeHttpServer } = require('../server/http-service');
|
|
56
56
|
const { createLocalServiceRouter } = require('../server/local-service-router');
|
|
57
|
+
const { SYSTEM_TOOL_ARTIFACTS } = require('../lib/layout');
|
|
57
58
|
|
|
58
59
|
const services = [];
|
|
59
60
|
let server;
|
|
@@ -203,6 +204,7 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
203
204
|
['agents-root', 'exact'], ['apps-root', 'exact'], ['automations-root', 'exact'],
|
|
204
205
|
['toolbox-root', 'exact'], ['workspaces-root', 'exact'], ['system-root', 'exact'],
|
|
205
206
|
['home-manifest', 'exact'], ['defaults', 'exact'], ['policy', 'exact'],
|
|
207
|
+
...SYSTEM_TOOL_ARTIFACTS.map((entry) => [entry.key, 'exact']),
|
|
206
208
|
],
|
|
207
209
|
);
|
|
208
210
|
for (const entry of bootstrapped.exactManaged) {
|
|
@@ -218,7 +220,7 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
const cloudRecords = await service.exactUserHomeCloudRecords();
|
|
221
|
-
assert.equal(cloudRecords.length,
|
|
223
|
+
assert.equal(cloudRecords.length, 16,
|
|
222
224
|
'the declared home is the entire graph eligible to repair a legacy empty cloud registry');
|
|
223
225
|
assert.deepEqual(
|
|
224
226
|
new Set(cloudRecords.map((record) => record.uuid)),
|
|
@@ -233,6 +235,46 @@ test('user-home bootstrap creates only declared roots and registers them through
|
|
|
233
235
|
);
|
|
234
236
|
});
|
|
235
237
|
|
|
238
|
+
test('an upgraded declared home registers every built-in tool through Detect and Record', async () => {
|
|
239
|
+
const home = path.join(tempRoot, 'user-home-upgrade');
|
|
240
|
+
const service = createRegistrationService({
|
|
241
|
+
dir: path.join(tempRoot, 'state-user-home-upgrade'),
|
|
242
|
+
homeDir: home,
|
|
243
|
+
});
|
|
244
|
+
services.push(service);
|
|
245
|
+
|
|
246
|
+
for (const root of ['agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system']) {
|
|
247
|
+
fs.mkdirSync(path.join(home, root), { recursive: true });
|
|
248
|
+
}
|
|
249
|
+
fs.mkdirSync(path.join(home, 'system', 'defaults'));
|
|
250
|
+
fs.mkdirSync(path.join(home, 'system', 'policy'));
|
|
251
|
+
fs.writeFileSync(path.join(home, 'system', 'home.json'), JSON.stringify({
|
|
252
|
+
schemaVersion: 1,
|
|
253
|
+
kind: 'amalgm-user-home',
|
|
254
|
+
roots: [],
|
|
255
|
+
exactManaged: [],
|
|
256
|
+
}));
|
|
257
|
+
service.registration.addMany(['agents', 'apps', 'automations', 'toolbox', 'workspaces', 'system']
|
|
258
|
+
.map((root) => path.join(home, root)));
|
|
259
|
+
|
|
260
|
+
const bootstrapped = await service.bootstrapUserHome();
|
|
261
|
+
const toolbox = bootstrapped.roots.find((entry) => entry.key === 'toolbox');
|
|
262
|
+
const browser = bootstrapped.exactManaged.find((entry) => entry.key === 'amalgm-browser');
|
|
263
|
+
const manifest = bootstrapped.exactManaged.find((entry) => entry.key === 'home-manifest');
|
|
264
|
+
assert.equal(browser.record.parentUUID, toolbox.record.uuid,
|
|
265
|
+
'the declared browser tool is a real child of the existing toolbox root');
|
|
266
|
+
assert.equal(browser.record.type, 'file.text');
|
|
267
|
+
assert.equal(service.registration.registry.syncRecord(manifest.record.uuid).payloadVersion, require('crypto')
|
|
268
|
+
.createHash('sha256')
|
|
269
|
+
.update(fs.readFileSync(path.join(home, 'system', 'home.json')))
|
|
270
|
+
.digest('hex'), 'the rewritten exact manifest is observed before its cloud identity is returned');
|
|
271
|
+
for (const artifact of SYSTEM_TOOL_ARTIFACTS) {
|
|
272
|
+
assert.ok(fs.existsSync(path.join(home, artifact.relativePath)), `${artifact.key} is materialized`);
|
|
273
|
+
assert.ok(bootstrapped.exactManaged.some((entry) => entry.key === artifact.key),
|
|
274
|
+
`${artifact.key} is an exact managed entity`);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
236
278
|
test('entity-cloud bootstrap publishes only the verified user home when an old local repo exists', async () => {
|
|
237
279
|
const service = createRegistrationService({
|
|
238
280
|
dir: path.join(tempRoot, 'state-user-home-cloud-boundary'),
|
|
@@ -248,13 +290,13 @@ test('entity-cloud bootstrap publishes only the verified user home when an old l
|
|
|
248
290
|
});
|
|
249
291
|
|
|
250
292
|
const records = await service.exactUserHomeCloudRecords();
|
|
251
|
-
assert.equal(records.length,
|
|
293
|
+
assert.equal(records.length, 16);
|
|
252
294
|
assert.equal(records.some((record) => record.uuid === oldRepo.uuid), false,
|
|
253
295
|
'login never turns existing user-selected ground into an implicit cloud registration');
|
|
254
296
|
|
|
255
297
|
const bootstrap = service.entityCloud.bootstrap(records);
|
|
256
298
|
const snapshot = JSON.parse(Buffer.from(bootstrap.snapshotBase64, 'base64').toString('utf8'));
|
|
257
|
-
assert.equal(snapshot.records.length,
|
|
299
|
+
assert.equal(snapshot.records.length, 16);
|
|
258
300
|
service.entityCloud.install({
|
|
259
301
|
registry: service.registration.registry,
|
|
260
302
|
resourceId: bootstrap.resourceId,
|
|
@@ -35,7 +35,7 @@ test('system toolbox catalog exposes first-party default tools', () => {
|
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
-
test('system toolbox catalog merge preserves
|
|
38
|
+
test('system toolbox catalog merge preserves user records but keeps built-ins authoritative', () => {
|
|
39
39
|
const merged = mergeSystemToolboxCatalog({
|
|
40
40
|
version: 1,
|
|
41
41
|
tools: {
|
|
@@ -59,12 +59,9 @@ test('system toolbox catalog merge preserves persisted records', () => {
|
|
|
59
59
|
},
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
assert.equal(merged.tools.browser.name, '
|
|
62
|
+
assert.equal(merged.tools.browser.name, 'Browser');
|
|
63
63
|
assert.equal(merged.tools['custom.cli'].name, 'Custom CLI');
|
|
64
|
-
assert.equal(
|
|
65
|
-
merged.toolActions['browser.browser_open'].name,
|
|
66
|
-
'persisted_navigate',
|
|
67
|
-
);
|
|
64
|
+
assert.equal(merged.toolActions['browser.browser_open'], undefined);
|
|
68
65
|
assert.equal(merged.toolActions['apps.apps_list'].name, 'apps_list');
|
|
69
66
|
});
|
|
70
67
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
SYSTEM_TOOL_ARTIFACTS,
|
|
11
|
+
artifactPathForTool,
|
|
12
|
+
isSystemToolId,
|
|
13
|
+
materializeSystemToolArtifacts,
|
|
14
|
+
migrateLegacyToolboxCatalog,
|
|
15
|
+
readToolArtifacts,
|
|
16
|
+
writeToolArtifact,
|
|
17
|
+
} = require('../toolbox/artifacts');
|
|
18
|
+
|
|
19
|
+
function temporaryToolbox() {
|
|
20
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-toolbox-artifacts-'));
|
|
21
|
+
const toolboxDir = path.join(home, 'toolbox');
|
|
22
|
+
fs.mkdirSync(toolboxDir);
|
|
23
|
+
return { home, toolboxDir };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('each built-in tool materializes as one distinct managed artifact', () => {
|
|
27
|
+
const { home, toolboxDir } = temporaryToolbox();
|
|
28
|
+
try {
|
|
29
|
+
const first = materializeSystemToolArtifacts({ toolboxDir });
|
|
30
|
+
assert.deepEqual(first.changedRelativePaths, SYSTEM_TOOL_ARTIFACTS.map((entry) => entry.relativePath));
|
|
31
|
+
const catalog = readToolArtifacts({ toolboxDir });
|
|
32
|
+
assert.deepEqual(Object.keys(catalog.tools).sort(), SYSTEM_TOOL_ARTIFACTS.map((entry) => entry.toolId).sort());
|
|
33
|
+
for (const entry of SYSTEM_TOOL_ARTIFACTS) {
|
|
34
|
+
const tool = catalog.tools[entry.toolId];
|
|
35
|
+
assert.equal(tool.origin, 'system');
|
|
36
|
+
assert.equal(tool.owner, 'amalgm');
|
|
37
|
+
assert.deepEqual(tool.source, { transport: 'amalgm-runtime', service: 'mcp' },
|
|
38
|
+
'the cloud artifact has no machine-local port');
|
|
39
|
+
assert.equal(path.basename(artifactPathForTool(tool, { toolboxDir })), path.basename(entry.relativePath));
|
|
40
|
+
assert.equal(isSystemToolId(tool.id), true);
|
|
41
|
+
}
|
|
42
|
+
assert.deepEqual(materializeSystemToolArtifacts({ toolboxDir }).changedRelativePaths, [],
|
|
43
|
+
'rerunning login does not rewrite an unchanged built-in tool');
|
|
44
|
+
} finally {
|
|
45
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('a user tool is a sibling artifact and remains independent of built-in ownership', () => {
|
|
50
|
+
const { home, toolboxDir } = temporaryToolbox();
|
|
51
|
+
try {
|
|
52
|
+
materializeSystemToolArtifacts({ toolboxDir });
|
|
53
|
+
const tool = { id: 'aayush.stock-price', name: 'Stock Price', origin: 'user', owner: 'aayush' };
|
|
54
|
+
const actions = [{ id: 'aayush.stock-price.lookup', toolId: tool.id, name: 'lookup' }];
|
|
55
|
+
const target = writeToolArtifact({ tool, actions }, { toolboxDir });
|
|
56
|
+
assert.equal(path.basename(target), 'tool-aayush.stock-price.json');
|
|
57
|
+
const catalog = readToolArtifacts({ toolboxDir });
|
|
58
|
+
assert.equal(catalog.tools[tool.id].origin, 'user');
|
|
59
|
+
assert.equal(catalog.toolActions[actions[0].id].toolId, tool.id);
|
|
60
|
+
assert.equal(isSystemToolId(tool.id), false);
|
|
61
|
+
} finally {
|
|
62
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a legacy aggregate catalog becomes user tool artifacts before leaving the registered toolbox tree', () => {
|
|
67
|
+
const { home, toolboxDir } = temporaryToolbox();
|
|
68
|
+
try {
|
|
69
|
+
fs.writeFileSync(path.join(toolboxDir, 'toolbox.json'), JSON.stringify({
|
|
70
|
+
version: 1,
|
|
71
|
+
tools: {
|
|
72
|
+
'user.example': { id: 'user.example', name: 'Example', origin: 'user', owner: 'user' },
|
|
73
|
+
},
|
|
74
|
+
toolActions: {
|
|
75
|
+
'user.example.run': { id: 'user.example.run', toolId: 'user.example', name: 'run' },
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
const migrated = migrateLegacyToolboxCatalog({ toolboxDir });
|
|
79
|
+
assert.equal(migrated.migrated, true);
|
|
80
|
+
assert.equal(fs.existsSync(path.join(toolboxDir, 'toolbox.json')), false);
|
|
81
|
+
assert.equal(fs.existsSync(path.join(home, 'toolbox.legacy.json')), true);
|
|
82
|
+
const catalog = readToolArtifacts({ toolboxDir });
|
|
83
|
+
assert.equal(catalog.tools['user.example'].name, 'Example');
|
|
84
|
+
assert.equal(catalog.toolActions['user.example.run'].name, 'run');
|
|
85
|
+
} finally {
|
|
86
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|