amalgm 0.1.257 → 0.1.259
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 +207 -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/observer/index.js +65 -4
- package/runtime/scripts/amalgm-mcp/observer/scan.js +60 -1
- package/runtime/scripts/amalgm-mcp/observer/store.js +43 -8
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +186 -32
- 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 +117 -11
- package/runtime/scripts/amalgm-mcp/registry/index.js +1 -1
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +16 -8
- 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/observer.test.js +85 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +95 -3
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +21 -0
- 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
|
@@ -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,66 @@ 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
|
+
function cloudPathOf(uuid) {
|
|
822
|
+
const names = [];
|
|
823
|
+
const seen = new Set();
|
|
824
|
+
let record = cloudByUuid.get(uuid) || null;
|
|
825
|
+
while (record) {
|
|
826
|
+
if (seen.has(record.uuid)) return null;
|
|
827
|
+
seen.add(record.uuid);
|
|
828
|
+
names.unshift(record.name);
|
|
829
|
+
if (record.parentUUID === null) break;
|
|
830
|
+
record = cloudByUuid.get(record.parentUUID) || null;
|
|
831
|
+
if (!record) return null;
|
|
832
|
+
}
|
|
833
|
+
return names.length > 0 ? names.join('/') : null;
|
|
834
|
+
}
|
|
835
|
+
const roots = observer.status().map((root) => {
|
|
836
|
+
const tree = boundary.current.tree(root.path);
|
|
837
|
+
const record = tree ? tree.root() : null;
|
|
838
|
+
return {
|
|
839
|
+
uuid: record?.uuid || null,
|
|
840
|
+
path: root.path,
|
|
841
|
+
kind: root.kind,
|
|
842
|
+
watching: root.watching,
|
|
843
|
+
degraded: root.degraded,
|
|
844
|
+
addressWatching: root.addressWatching,
|
|
845
|
+
addressDegraded: root.addressDegraded,
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
const rootByUuid = new Map(roots.filter((root) => root.uuid !== null).map((root) => [root.uuid, root]));
|
|
849
|
+
const uuids = new Set([...localByUuid.keys(), ...cloudByUuid.keys()]);
|
|
850
|
+
const entities = [...uuids].sort().map((uuid) => {
|
|
851
|
+
const local = localByUuid.get(uuid) || null;
|
|
852
|
+
const record = cloudByUuid.get(uuid) || local;
|
|
853
|
+
let localPath = null;
|
|
854
|
+
if (local) {
|
|
855
|
+
try { localPath = boundary.current.addressOf(uuid); } catch {}
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
...record,
|
|
859
|
+
// A local journal projects its intent into the catalog before the
|
|
860
|
+
// gateway acknowledges it. Call that fact `catalogued`, not
|
|
861
|
+
// `cloudBacked`: the enclosing cloud summary says whether the
|
|
862
|
+
// catalog is actually pinned to an authoritative current head.
|
|
863
|
+
catalogued: cloudByUuid.has(uuid),
|
|
864
|
+
cloudPath: cloudByUuid.has(uuid) ? cloudPathOf(uuid) : null,
|
|
865
|
+
localPath,
|
|
866
|
+
root: rootByUuid.get(uuid) || null,
|
|
867
|
+
};
|
|
868
|
+
});
|
|
869
|
+
return { cloud, roots, entities };
|
|
870
|
+
}
|
|
871
|
+
|
|
770
872
|
return {
|
|
771
873
|
dir,
|
|
772
874
|
homeDir,
|
|
@@ -820,9 +922,13 @@ function createRegistrationService({
|
|
|
820
922
|
const received = entityCloud.receiveDownload({
|
|
821
923
|
resourceId, contentHash, kind, partIndex, sha256, dataBase64,
|
|
822
924
|
});
|
|
823
|
-
|
|
925
|
+
// A mid-file part cannot complete any waiting tree; only a finished
|
|
926
|
+
// content head can. Advancing on every part re-walks every waiting
|
|
927
|
+
// job once per part, which is quadratic over a large download.
|
|
928
|
+
if (received?.complete) advanceMaterializations();
|
|
824
929
|
return received;
|
|
825
930
|
},
|
|
931
|
+
status,
|
|
826
932
|
advanceMaterializations,
|
|
827
933
|
bootstrapUserHome,
|
|
828
934
|
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
|
|
@@ -70,15 +70,23 @@ function untrackedFiles(repoPath) {
|
|
|
70
70
|
// its own card; the parent's checkpoint must not contain it.
|
|
71
71
|
if (rel.endsWith('/')) return [];
|
|
72
72
|
const abs = path.join(repoPath, rel);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
try {
|
|
74
|
+
const stat = fs.lstatSync(abs);
|
|
75
|
+
if (stat.isSymbolicLink()) {
|
|
76
|
+
const content = Buffer.from(fs.readlinkSync(abs));
|
|
77
|
+
return [{ path: rel, mode: '120000', content, sha256: sha256(content) }];
|
|
78
|
+
}
|
|
79
|
+
if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
|
|
80
|
+
const content = fs.readFileSync(abs);
|
|
81
|
+
const mode = stat.mode & 0o111 ? '100755' : '100644';
|
|
82
|
+
return [{ path: rel, mode, content, sha256: sha256(content) }];
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Git's file listing is necessarily a point-in-time observation. A file
|
|
85
|
+
// may be removed or renamed before it can be read; the next watch event
|
|
86
|
+
// captures its replacement, while this capture truthfully omits it.
|
|
87
|
+
if (error && error.code === 'ENOENT') return [];
|
|
88
|
+
throw error;
|
|
77
89
|
}
|
|
78
|
-
if (stat.isDirectory()) return []; // same boundary, unmarked: never read a directory as a file
|
|
79
|
-
const content = fs.readFileSync(abs);
|
|
80
|
-
const mode = stat.mode & 0o111 ? '100755' : '100644';
|
|
81
|
-
return [{ path: rel, mode, content, sha256: sha256(content) }];
|
|
82
90
|
});
|
|
83
91
|
}
|
|
84
92
|
|
|
@@ -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',
|
|
@@ -655,3 +655,88 @@ test('watcher death rebuilds the doorbell and reconciles the gap once', () => {
|
|
|
655
655
|
fs.watch = realWatch;
|
|
656
656
|
}
|
|
657
657
|
});
|
|
658
|
+
|
|
659
|
+
// --- the scoped scan ------------------------------------------------------
|
|
660
|
+
// A NAMED doorbell earns a look at its own directory, never a walk of the
|
|
661
|
+
// world; anything structural escalates to the full walk unchanged. The
|
|
662
|
+
// injected enrollment policy is the witness: the full walk asks it about
|
|
663
|
+
// every directory's entries, so a scoped look must never ask about a
|
|
664
|
+
// sibling directory's ground.
|
|
665
|
+
|
|
666
|
+
const { fakeWatchKit } = require('./observer.rig');
|
|
667
|
+
|
|
668
|
+
function scopedRig() {
|
|
669
|
+
const dir = makeDir('scoped');
|
|
670
|
+
fs.mkdirSync(path.join(dir, 'a'));
|
|
671
|
+
fs.mkdirSync(path.join(dir, 'b'));
|
|
672
|
+
fs.writeFileSync(path.join(dir, 'a', 'one.md'), 'v1');
|
|
673
|
+
fs.writeFileSync(path.join(dir, 'b', 'two.md'), 'v1');
|
|
674
|
+
const asked = [];
|
|
675
|
+
const bells = fakeWatchKit();
|
|
676
|
+
const { observer, events } = makeObserver({
|
|
677
|
+
settleMs: 15,
|
|
678
|
+
watch: bells.watch,
|
|
679
|
+
shouldEnroll: (relPath) => {
|
|
680
|
+
asked.push(relPath);
|
|
681
|
+
return true;
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
observer.enrollRoot(dir);
|
|
685
|
+
observer.start();
|
|
686
|
+
events.length = 0;
|
|
687
|
+
asked.length = 0;
|
|
688
|
+
return { dir, asked, bells, observer, events };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async function settled(events, arrived, deadlineMs = 5_000) {
|
|
692
|
+
const deadline = Date.now() + deadlineMs;
|
|
693
|
+
while (!arrived() && Date.now() < deadline) {
|
|
694
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
695
|
+
}
|
|
696
|
+
return arrived();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
test('a named doorbell scans only its own directory', async () => {
|
|
700
|
+
const { dir, asked, bells, observer, events } = scopedRig();
|
|
701
|
+
fs.writeFileSync(path.join(dir, 'a', 'one.md'), 'v2');
|
|
702
|
+
bells.ring(dir, 'a/one.md');
|
|
703
|
+
assert.ok(await settled(events, () => eventsOf(events, 'file.changed').length > 0));
|
|
704
|
+
observer.stop();
|
|
705
|
+
|
|
706
|
+
const changed = eventsOf(events, 'file.changed');
|
|
707
|
+
assert.equal(changed.length, 1);
|
|
708
|
+
assert.equal(changed[0].path, 'a/one.md');
|
|
709
|
+
assert.ok(asked.includes('a/one.md'), 'the rung directory was read');
|
|
710
|
+
assert.ok(!asked.includes('b/two.md'), `the sibling directory was never walked: ${asked.join(', ')}`);
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
test('a create under a named doorbell escalates to the full walk', async () => {
|
|
714
|
+
const { dir, asked, bells, observer, events } = scopedRig();
|
|
715
|
+
fs.writeFileSync(path.join(dir, 'a', 'new.md'), 'fresh');
|
|
716
|
+
bells.ring(dir, 'a/new.md');
|
|
717
|
+
assert.ok(await settled(events, () => eventsOf(events, 'file.created').length > 0));
|
|
718
|
+
observer.stop();
|
|
719
|
+
|
|
720
|
+
const created = eventsOf(events, 'file.created');
|
|
721
|
+
assert.equal(created.length, 1);
|
|
722
|
+
assert.equal(created[0].path, 'a/new.md');
|
|
723
|
+
assert.ok(asked.includes('b/two.md'), 'structural news pays for the whole walk');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('a cross-directory move naming only the new side still concludes a rename', async () => {
|
|
727
|
+
const { dir, bells, observer, events } = scopedRig();
|
|
728
|
+
fs.renameSync(path.join(dir, 'a', 'one.md'), path.join(dir, 'b', 'moved.md'));
|
|
729
|
+
// A rename may lawfully deliver only the NEW name: the scoped look sees
|
|
730
|
+
// an orphan entry, refuses to conclude, and the full walk's inode rescue
|
|
731
|
+
// names the move — never a create beside a ghost.
|
|
732
|
+
bells.ring(dir, 'b/moved.md');
|
|
733
|
+
assert.ok(await settled(events, () => eventsOf(events, 'file.renamed').length > 0));
|
|
734
|
+
observer.stop();
|
|
735
|
+
|
|
736
|
+
const renamed = eventsOf(events, 'file.renamed');
|
|
737
|
+
assert.equal(renamed.length, 1);
|
|
738
|
+
assert.equal(renamed[0].from, 'a/one.md');
|
|
739
|
+
assert.equal(renamed[0].to, 'b/moved.md');
|
|
740
|
+
assert.equal(eventsOf(events, 'file.created').length, 0);
|
|
741
|
+
assert.equal(eventsOf(events, 'file.deleted').length, 0);
|
|
742
|
+
});
|
|
@@ -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,
|
|
@@ -393,6 +435,56 @@ test('a signed-in register call writes every entity delivery intent in identity.
|
|
|
393
435
|
}
|
|
394
436
|
});
|
|
395
437
|
|
|
438
|
+
test('service status exposes only a verified cloud path as a navigable workspace', async () => {
|
|
439
|
+
const service = createRegistrationService({
|
|
440
|
+
dir: path.join(tempRoot, 'state-cloud-status'),
|
|
441
|
+
homeDir: path.join(tempRoot, 'cloud-status-home'),
|
|
442
|
+
cloudUserId: 'user-cloud-status',
|
|
443
|
+
cloudDeviceId: 'computer-cloud-status',
|
|
444
|
+
});
|
|
445
|
+
const bootstrap = service.entityCloud.bootstrap(service.registration.registry.syncRecords());
|
|
446
|
+
service.entityCloud.install({
|
|
447
|
+
registry: service.registration.registry,
|
|
448
|
+
resourceId: bootstrap.resourceId,
|
|
449
|
+
authorityEpoch: 1,
|
|
450
|
+
headVersion: 0,
|
|
451
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
452
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
453
|
+
});
|
|
454
|
+
const ground = makeGround('cloud-status');
|
|
455
|
+
fs.mkdirSync(path.join(ground, 'src'));
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const registered = await service.register(ground);
|
|
459
|
+
const beforeAcknowledgement = service.status();
|
|
460
|
+
assert.equal(beforeAcknowledgement.cloud.catalog.current, false,
|
|
461
|
+
'a pending local registration is visible to status but not a navigable verified cloud workspace');
|
|
462
|
+
|
|
463
|
+
let version = 0;
|
|
464
|
+
for (;;) {
|
|
465
|
+
const [claim] = service.entityCloud.claim();
|
|
466
|
+
if (!claim) break;
|
|
467
|
+
version += 1;
|
|
468
|
+
service.entityCloud.acknowledge({
|
|
469
|
+
resourceId: claim.resourceId,
|
|
470
|
+
mutationId: claim.mutationId,
|
|
471
|
+
version,
|
|
472
|
+
committedAt: '2026-08-05T00:00:00.000Z',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const status = service.status();
|
|
477
|
+
assert.equal(status.cloud.catalog.current, true);
|
|
478
|
+
const root = status.entities.find((entity) => entity.uuid === registered.record.uuid);
|
|
479
|
+
assert.equal(root.cloudPath, path.basename(ground));
|
|
480
|
+
const child = status.entities.find((entity) => entity.cloudPath === `${path.basename(ground)}/src`);
|
|
481
|
+
assert.ok(child, 'the cloud path is derived from canonical parentage, never a local absolute path');
|
|
482
|
+
assert.equal(child.localPath, path.join(ground, 'src'));
|
|
483
|
+
} finally {
|
|
484
|
+
service.close();
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
396
488
|
test('a large signed-in register call publishes one complete cloud graph before Watch begins', async () => {
|
|
397
489
|
const service = createRegistrationService({
|
|
398
490
|
dir: path.join(tempRoot, 'state-cloud-register-large'),
|
|
@@ -175,6 +175,27 @@ test('an unborn repo with staged and untracked work round-trips', () => {
|
|
|
175
175
|
assert.equal(state.card.bundle, null);
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
test('a file that disappears after Git lists it is omitted instead of aborting capture', () => {
|
|
179
|
+
const source = makeRepo('vanishing-untracked');
|
|
180
|
+
const gone = path.join(source, 'gone-during-capture.md');
|
|
181
|
+
fs.writeFileSync(gone, 'transient');
|
|
182
|
+
const originalLstat = fs.lstatSync;
|
|
183
|
+
fs.lstatSync = (candidate, ...args) => {
|
|
184
|
+
if (candidate === gone) {
|
|
185
|
+
const error = new Error('gone');
|
|
186
|
+
error.code = 'ENOENT';
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return originalLstat(candidate, ...args);
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const state = capture(source, { withBundle: false });
|
|
193
|
+
assert.equal(state.checkpoint.untracked.some((file) => file.path === 'gone-during-capture.md'), false);
|
|
194
|
+
} finally {
|
|
195
|
+
fs.lstatSync = originalLstat;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
178
199
|
// --- identity ------------------------------------------------------------------
|
|
179
200
|
|
|
180
201
|
test('capture is deterministic: same state, same ids, twice', () => {
|