amalgm 0.1.262 → 0.1.263
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/observer/README.md +4 -1
- package/runtime/scripts/amalgm-mcp/observer/coverage.js +52 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +171 -37
- package/runtime/scripts/amalgm-mcp/observer/watch.js +6 -2
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +54 -19
- package/runtime/scripts/amalgm-mcp/registration/index.js +58 -12
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +43 -4
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +15 -5
- package/runtime/scripts/amalgm-mcp/registration/service.js +99 -12
- package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +45 -3
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +65 -10
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +90 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +236 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +9 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
- package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
|
@@ -95,12 +95,24 @@ function createFollower(options) {
|
|
|
95
95
|
last = null, // resume seed: { cardId, checkpointId }
|
|
96
96
|
// May return a promise (e.g. a capture worker): the follower awaits it,
|
|
97
97
|
// and any doorbell that lands mid-pair re-arms a retry (`pendingWake`).
|
|
98
|
-
|
|
98
|
+
// Called with { suspects }: the worktree paths rung since the last
|
|
99
|
+
// settled state, or null when everything is suspect.
|
|
100
|
+
captureFn = (repo, { suspects } = {}) => capture(repo, { withBundle: false, suspects }),
|
|
99
101
|
} = options;
|
|
100
102
|
if (!repoPath) throw new Error('createFollower requires { repoPath }');
|
|
101
103
|
if (typeof emit !== 'function') throw new Error('createFollower requires { emit }');
|
|
102
104
|
|
|
103
105
|
let known = last ? { cardId: last.cardId, checkpointId: last.checkpointId } : null;
|
|
106
|
+
// The warm-checkpoint scope: which worktree paths the doorbell has named
|
|
107
|
+
// since the last settled state. Each entry remembers the GENERATION of
|
|
108
|
+
// the ring that named it, so a path rung again mid-pair keeps its
|
|
109
|
+
// suspicion when the pair's success consumes the older hints. A nameless
|
|
110
|
+
// ring — or any entrance that may follow missed doorbells (birth, flush,
|
|
111
|
+
// a command ending) — makes EVERYTHING suspect until a pair settles.
|
|
112
|
+
const SCOPE_CAP = 512;
|
|
113
|
+
const scope = new Map(); // repo-relative path -> generation
|
|
114
|
+
let scopeGen = 0;
|
|
115
|
+
let scopeAllGen = 0; // >0: everything is suspect (set at birth, flush, command end, nameless ring)
|
|
104
116
|
let owned = 0; // depth of amalgm-owned commands in flight
|
|
105
117
|
let blocked = null; // lock name while the last capture attempt met a held lock
|
|
106
118
|
let blockedPath = null;
|
|
@@ -210,12 +222,15 @@ function createFollower(options) {
|
|
|
210
222
|
|
|
211
223
|
// The torn-read guard: two consecutive reads must agree before a state
|
|
212
224
|
// may travel — a repo mid-mutation cannot produce the same ids twice.
|
|
225
|
+
// Both halves of the pair read the SAME scope: the paths rung so far.
|
|
226
|
+
const genNow = scopeGen;
|
|
227
|
+
const suspects = scopeAllGen > 0 ? null : [...scope.keys()];
|
|
213
228
|
let first;
|
|
214
229
|
let second;
|
|
215
230
|
try {
|
|
216
|
-
first = await captureFn(repoPath);
|
|
231
|
+
first = await captureFn(repoPath, { suspects });
|
|
217
232
|
if (closed || owned > 0) return; // the pair is stale before it finished
|
|
218
|
-
second = first && !first.busy ? await captureFn(repoPath) : null;
|
|
233
|
+
second = first && !first.busy ? await captureFn(repoPath, { suspects }) : null;
|
|
219
234
|
} catch (error) {
|
|
220
235
|
if (closed) return;
|
|
221
236
|
// A read tripping over a mid-flight writer (a file vanishing between
|
|
@@ -234,11 +249,34 @@ function createFollower(options) {
|
|
|
234
249
|
retry(settleMs);
|
|
235
250
|
return;
|
|
236
251
|
}
|
|
237
|
-
if (!speak(second))
|
|
252
|
+
if (!speak(second)) {
|
|
253
|
+
retry(capMs); // consumer failure: patient, loud, never lost
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// The settled pair proved the ground the scope described: hints from
|
|
257
|
+
// BEFORE the pair began are consumed; anything rung mid-pair keeps its
|
|
258
|
+
// newer generation and stays suspect for the pendingWake retry.
|
|
259
|
+
if (scopeAllGen > 0 && scopeAllGen <= genNow) scopeAllGen = 0;
|
|
260
|
+
for (const [rel, gen] of scope) {
|
|
261
|
+
if (gen <= genNow) scope.delete(rel);
|
|
262
|
+
}
|
|
238
263
|
}
|
|
239
264
|
|
|
240
|
-
function ring() {
|
|
265
|
+
function ring(hint) {
|
|
241
266
|
if (closed || owned > 0) return; // the command's end is the boundary
|
|
267
|
+
// Accumulate the warm-checkpoint scope. A .git-area hint never touches
|
|
268
|
+
// untracked ground, so it schedules a capture without widening the
|
|
269
|
+
// scope; a nameless hint (or an overflowing scope) suspects everything.
|
|
270
|
+
scopeGen += 1;
|
|
271
|
+
if (hint === null || hint === undefined) {
|
|
272
|
+
scopeAllGen = scopeGen;
|
|
273
|
+
} else if (hint !== '.git' && !hint.startsWith('.git/')) {
|
|
274
|
+
scope.set(hint, scopeGen);
|
|
275
|
+
if (scope.size > SCOPE_CAP) {
|
|
276
|
+
scope.clear();
|
|
277
|
+
scopeAllGen = scopeGen;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
242
280
|
if (settleTimer) clearTimeout(settleTimer);
|
|
243
281
|
settleTimer = setTimeout(() => {
|
|
244
282
|
settleTimer = null;
|
|
@@ -262,7 +300,13 @@ function createFollower(options) {
|
|
|
262
300
|
|
|
263
301
|
function endCommand() {
|
|
264
302
|
owned = Math.max(0, owned - 1);
|
|
265
|
-
|
|
303
|
+
// Rings were suppressed while the command ran: its writes are nobody's
|
|
304
|
+
// hints, so the capture at the boundary suspects everything.
|
|
305
|
+
if (owned === 0 && !closed) {
|
|
306
|
+
scopeGen += 1;
|
|
307
|
+
scopeAllGen = scopeGen;
|
|
308
|
+
fire();
|
|
309
|
+
}
|
|
266
310
|
}
|
|
267
311
|
|
|
268
312
|
/** Run an amalgm-owned command: no captures while it runs, one at its end. */
|
|
@@ -284,10 +328,21 @@ function createFollower(options) {
|
|
|
284
328
|
|
|
285
329
|
return {
|
|
286
330
|
ring,
|
|
287
|
-
/** Birth: capture once, immediately
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
/** Birth: capture once, immediately, suspecting everything — rings
|
|
332
|
+
* before birth were nobody's. With a seeded `last`, an unchanged
|
|
333
|
+
* repo is silence. */
|
|
334
|
+
start: () => {
|
|
335
|
+
scopeGen += 1;
|
|
336
|
+
scopeAllGen = scopeGen;
|
|
337
|
+
return fire();
|
|
338
|
+
},
|
|
339
|
+
/** Consumer-requested capture — the "scanNow" of this layer. Its whole
|
|
340
|
+
* purpose is catching a LOST doorbell, so it never trusts the scope. */
|
|
341
|
+
flush: () => {
|
|
342
|
+
scopeGen += 1;
|
|
343
|
+
scopeAllGen = scopeGen;
|
|
344
|
+
return fire();
|
|
345
|
+
},
|
|
291
346
|
beginCommand,
|
|
292
347
|
endCommand,
|
|
293
348
|
command,
|
|
@@ -371,3 +371,93 @@ test('add follows in-order mirrored mutations and refuses after a version gap un
|
|
|
371
371
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
372
372
|
}
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
// --- the repo travel law -----------------------------------------------------
|
|
376
|
+
|
|
377
|
+
function activeCloud({ content = { has: () => true, ensureUpload: () => {} }, repoTransportFor = null } = {}) {
|
|
378
|
+
const db = new Database(':memory:');
|
|
379
|
+
let registry = null;
|
|
380
|
+
const cloud = createEntityCloud({
|
|
381
|
+
db,
|
|
382
|
+
userId: 'user-1',
|
|
383
|
+
deviceId: 'device-1',
|
|
384
|
+
content,
|
|
385
|
+
repoTransportFor,
|
|
386
|
+
recordAt: (uuid) => {
|
|
387
|
+
try { return registry.syncRecord(uuid); } catch { return null; }
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
391
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
|
|
392
|
+
const bootstrap = cloud.bootstrap(registry.syncRecords());
|
|
393
|
+
cloud.install({
|
|
394
|
+
registry,
|
|
395
|
+
resourceId: bootstrap.resourceId,
|
|
396
|
+
authorityEpoch: 1,
|
|
397
|
+
headVersion: 0,
|
|
398
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
399
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
400
|
+
});
|
|
401
|
+
return { db, cloud, registry, root, resourceId: bootstrap.resourceId };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
test('a record enclosed by a repository never journals its own cloud mutation — the repo state parcel is the carrier', () => {
|
|
405
|
+
const transportHead = 'a'.repeat(64);
|
|
406
|
+
const { db, registry, root, resourceId } = activeCloud({
|
|
407
|
+
repoTransportFor: () => transportHead,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const repo = registry.create({ type: 'repo.git', parentUUID: root.uuid, name: 'engine', payloadVersion: 'state-1' });
|
|
411
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
412
|
+
'the repository record itself travels');
|
|
413
|
+
|
|
414
|
+
const folder = registry.create({ type: 'folder', parentUUID: repo.uuid, name: 'src' });
|
|
415
|
+
registry.create({ type: 'file.text', parentUUID: folder.uuid, name: 'index.js', payloadVersion: 'b'.repeat(64) });
|
|
416
|
+
registry.create({ type: 'link', parentUUID: repo.uuid, name: 'current', payloadVersion: 'src' });
|
|
417
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
418
|
+
'worktree children mail no postcards: a register or a rebase is one parcel on the repo entity');
|
|
419
|
+
|
|
420
|
+
const catalog = db.prepare('SELECT entity_uuid FROM entity_cloud_catalog WHERE resource_id = ? ORDER BY entity_uuid').all(resourceId)
|
|
421
|
+
.map((row) => row.entity_uuid).sort();
|
|
422
|
+
assert.deepEqual(catalog, [root.uuid, repo.uuid].sort(),
|
|
423
|
+
'the cloud catalog holds the traveling graph only — no rows for repo-enclosed children');
|
|
424
|
+
|
|
425
|
+
// A later Detect correction on the child (type/payload) stays local too.
|
|
426
|
+
const inner = registry.activeAt(folder.uuid, 'index.js');
|
|
427
|
+
registry.attest(inner.uuid, { type: 'file.binary', payloadVersion: 'c'.repeat(64) });
|
|
428
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
429
|
+
'the census drip is dead: child corrections never reach the outbox');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('an atomic snapshot replacement excludes repo-enclosed records and never demands their bytes', () => {
|
|
433
|
+
const transportHead = 'd'.repeat(64);
|
|
434
|
+
const childHead = 'e'.repeat(64);
|
|
435
|
+
const { db, cloud, registry, root, resourceId } = activeCloud({
|
|
436
|
+
repoTransportFor: () => transportHead,
|
|
437
|
+
content: {
|
|
438
|
+
ensureUpload: () => {},
|
|
439
|
+
has: ({ contentHash }) => {
|
|
440
|
+
assert.notEqual(contentHash, childHead, 'an enclosed child head must never be demanded as separate cloud cargo');
|
|
441
|
+
return true;
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
registry.withoutJournal(() => {
|
|
447
|
+
const repo = registry.create({ type: 'repo.git', parentUUID: root.uuid, name: 'engine', payloadVersion: 'state-1' });
|
|
448
|
+
registry.create({ type: 'file.text', parentUUID: repo.uuid, name: 'inner.md', payloadVersion: childHead });
|
|
449
|
+
registry.create({ type: 'file.text', parentUUID: root.uuid, name: 'loose.md', payloadVersion: 'f'.repeat(64) });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
cloud.replaceSnapshot(registry.syncRecords());
|
|
453
|
+
const operation = JSON.parse(db.prepare(`
|
|
454
|
+
SELECT operation_json FROM entity_cloud_mutations
|
|
455
|
+
WHERE resource_id = ? ORDER BY sequence DESC LIMIT 1
|
|
456
|
+
`).get(resourceId).operation_json);
|
|
457
|
+
assert.equal(operation.kind, 'entity.snapshot.replace');
|
|
458
|
+
const types = operation.snapshot.records.map((record) => record.type).sort();
|
|
459
|
+
assert.deepEqual(types, ['file.text', 'repo.git', 'workspace'],
|
|
460
|
+
'the atomic graph carries the workspace, the loose file, and ONE record per repository');
|
|
461
|
+
assert.ok(!operation.snapshot.records.some((record) => record.name === 'inner.md'),
|
|
462
|
+
'repo children stay out of the published graph');
|
|
463
|
+
});
|
|
@@ -461,3 +461,239 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
461
461
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
462
462
|
}
|
|
463
463
|
});
|
|
464
|
+
|
|
465
|
+
test('the repo travel law end to end: one traveling record, child UUIDs ride the transport map, and add binds the exact same identities', async () => {
|
|
466
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repo-travel-law-'));
|
|
467
|
+
const sourceState = path.join(rootDir, 'source-state');
|
|
468
|
+
const targetState = path.join(rootDir, 'target-state');
|
|
469
|
+
const sourceRepo = path.join(rootDir, 'engine');
|
|
470
|
+
const targetParent = path.join(rootDir, 'checkout');
|
|
471
|
+
fs.mkdirSync(path.join(sourceRepo, 'src'), { recursive: true });
|
|
472
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
473
|
+
git(sourceRepo, ['init', '--quiet']);
|
|
474
|
+
git(sourceRepo, ['config', 'user.email', 'test@example.com']);
|
|
475
|
+
git(sourceRepo, ['config', 'user.name', 'Amalgm Test']);
|
|
476
|
+
fs.writeFileSync(path.join(sourceRepo, 'tracked.md'), '# committed\n');
|
|
477
|
+
fs.writeFileSync(path.join(sourceRepo, 'src', 'index.js'), 'module.exports = 1;\n');
|
|
478
|
+
git(sourceRepo, ['add', '.']);
|
|
479
|
+
git(sourceRepo, ['commit', '--quiet', '-m', 'initial']);
|
|
480
|
+
fs.writeFileSync(path.join(sourceRepo, 'untracked.txt'), 'not committed\n');
|
|
481
|
+
|
|
482
|
+
const { unpack } = require('../registration/repo-states');
|
|
483
|
+
const source = createRegistrationService({
|
|
484
|
+
dir: sourceState, homeDir: path.join(rootDir, 'source-home'),
|
|
485
|
+
cloudUserId: 'user-1', cloudDeviceId: 'source-device',
|
|
486
|
+
});
|
|
487
|
+
const target = createRegistrationService({
|
|
488
|
+
dir: targetState, homeDir: path.join(rootDir, 'target-home'),
|
|
489
|
+
cloudUserId: 'user-1', cloudDeviceId: 'target-device',
|
|
490
|
+
});
|
|
491
|
+
const mutationOps = (stateDir) => {
|
|
492
|
+
const handle = new Database(path.join(stateDir, 'identity.db'), { readonly: true });
|
|
493
|
+
try {
|
|
494
|
+
return handle.prepare('SELECT operation_json FROM entity_cloud_mutations ORDER BY sequence').all()
|
|
495
|
+
.map((row) => JSON.parse(row.operation_json));
|
|
496
|
+
} finally {
|
|
497
|
+
handle.close();
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
try {
|
|
501
|
+
const bootstrap = source.entityCloud.bootstrap(source.registration.registry.syncRecords());
|
|
502
|
+
source.entityCloud.install({
|
|
503
|
+
registry: source.registration.registry, resourceId: bootstrap.resourceId,
|
|
504
|
+
authorityEpoch: 1, headVersion: 0, snapshotBase64: bootstrap.snapshotBase64,
|
|
505
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
506
|
+
});
|
|
507
|
+
const registered = await source.register(sourceRepo);
|
|
508
|
+
|
|
509
|
+
// ONE traveling record: the repository. No child postcards, ever.
|
|
510
|
+
const ops = mutationOps(sourceState);
|
|
511
|
+
assert.equal(ops.length, 1, `a repository register is one cloud mutation, got ${JSON.stringify(ops.map((op) => op.record?.name))}`);
|
|
512
|
+
assert.equal(ops[0].kind, 'entity.upsert');
|
|
513
|
+
assert.equal(ops[0].record.uuid, registered.record.uuid);
|
|
514
|
+
assert.ok(ops[0].record.transportVersion, 'the one record names its Repocard transport');
|
|
515
|
+
|
|
516
|
+
// The transport parcel carries the worktree identity map, and the map
|
|
517
|
+
// names the exact UUIDs the local registry minted.
|
|
518
|
+
const transport = unpack(source.entityContent.bytes({ contentHash: ops[0].record.transportVersion }).toString('utf8'));
|
|
519
|
+
assert.ok(Array.isArray(transport.identity), 'the transport carries the identity map');
|
|
520
|
+
const mapByPath = new Map(transport.identity.map((entry) => [entry.path, entry]));
|
|
521
|
+
const localByPath = new Map();
|
|
522
|
+
const walkLocal = (parentUuid, base) => {
|
|
523
|
+
for (const child of source.registration.registry.children(parentUuid)) {
|
|
524
|
+
if (child.status !== 'active') continue;
|
|
525
|
+
const childPath = base ? `${base}/${child.name}` : child.name;
|
|
526
|
+
localByPath.set(childPath, child.uuid);
|
|
527
|
+
walkLocal(child.uuid, childPath);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
walkLocal(registered.record.uuid, '');
|
|
531
|
+
for (const relPath of ['tracked.md', 'src', 'src/index.js', 'untracked.txt']) {
|
|
532
|
+
assert.equal(mapByPath.get(relPath)?.uuid, localByPath.get(relPath),
|
|
533
|
+
`the transport map and the local registry agree on ${relPath}`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// The gateway would fold that one mutation into the active head: the
|
|
537
|
+
// authoritative snapshot contains exactly the traveling graph.
|
|
538
|
+
const snapshot = snapshotFromRecords([ops[0].record]);
|
|
539
|
+
const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
540
|
+
target.entityCloud.install({
|
|
541
|
+
registry: target.registration.registry, resourceId: bootstrap.resourceId,
|
|
542
|
+
authorityEpoch: 1, headVersion: 1, snapshotBase64: snapshotBytes.toString('base64'),
|
|
543
|
+
snapshotChecksum: sha256(snapshotBytes),
|
|
544
|
+
});
|
|
545
|
+
const uploaded = [];
|
|
546
|
+
while (true) {
|
|
547
|
+
const part = source.entityCloud.claimContent();
|
|
548
|
+
if (!part) break;
|
|
549
|
+
uploaded.push(part);
|
|
550
|
+
source.entityCloud.acknowledgeContent({
|
|
551
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash,
|
|
552
|
+
kind: part.kind, partIndex: part.partIndex,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
|
|
556
|
+
target.receiveCloudContent({
|
|
557
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
|
|
558
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
559
|
+
sha256: part.sha256, dataBase64: part.dataBase64,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
|
|
563
|
+
assert.equal(job.state, 'complete', job.error);
|
|
564
|
+
const destination = path.join(targetParent, 'engine');
|
|
565
|
+
assert.equal(verify(destination, source.registration.repoState(registered.record.uuid)).ok, true);
|
|
566
|
+
|
|
567
|
+
// The receiving machine wears the SAME child UUIDs — bound from the
|
|
568
|
+
// transport map, never minted as competitors.
|
|
569
|
+
for (const [relPath, uuid] of localByPath) {
|
|
570
|
+
const bound = target.registration.registry.syncRecord(uuid);
|
|
571
|
+
assert.equal(bound?.name, path.basename(relPath), `${relPath} keeps its permanent UUID across machines`);
|
|
572
|
+
}
|
|
573
|
+
assert.equal(mutationOps(targetState).length, 0, 'the add binding echoes nothing upstream');
|
|
574
|
+
|
|
575
|
+
// A live edit settles as the NEXT parcel: still one mutation on the
|
|
576
|
+
// repo entity, still no child postcards, map still riding along.
|
|
577
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
578
|
+
fs.writeFileSync(path.join(sourceRepo, 'later.md'), 'a later change\n');
|
|
579
|
+
const deadline = Date.now() + 30_000;
|
|
580
|
+
while (mutationOps(sourceState).length < 2) {
|
|
581
|
+
if (Date.now() > deadline) assert.fail('timed out waiting for the settled repo parcel');
|
|
582
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
583
|
+
}
|
|
584
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
585
|
+
const settledOps = mutationOps(sourceState);
|
|
586
|
+
assert.ok(settledOps.every((op) => op.record?.uuid === registered.record.uuid),
|
|
587
|
+
`every traveling mutation is the repo entity itself, got ${JSON.stringify(settledOps.map((op) => op.record?.name))}`);
|
|
588
|
+
const settled = settledOps[settledOps.length - 1];
|
|
589
|
+
const settledTransport = unpack(source.entityContent.bytes({ contentHash: settled.record.transportVersion }).toString('utf8'));
|
|
590
|
+
assert.equal(
|
|
591
|
+
new Map(settledTransport.identity.map((entry) => [entry.path, entry])).get('tracked.md')?.uuid,
|
|
592
|
+
localByPath.get('tracked.md'),
|
|
593
|
+
'a settled parcel repacks the registry-held identity map with the same permanent UUIDs',
|
|
594
|
+
);
|
|
595
|
+
} finally {
|
|
596
|
+
source.close();
|
|
597
|
+
target.close();
|
|
598
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
test('a workspace containing a repository travels as its loose records plus ONE repo record, and add binds repo children from the map', async () => {
|
|
603
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-repo-travel-'));
|
|
604
|
+
const sourceState = path.join(rootDir, 'source-state');
|
|
605
|
+
const targetState = path.join(rootDir, 'target-state');
|
|
606
|
+
const workspace = path.join(rootDir, 'ambient');
|
|
607
|
+
const repo = path.join(workspace, 'engine');
|
|
608
|
+
const targetParent = path.join(rootDir, 'checkout');
|
|
609
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
610
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
611
|
+
fs.writeFileSync(path.join(workspace, 'notes.md'), '# loose ground\n');
|
|
612
|
+
git(repo, ['init', '--quiet']);
|
|
613
|
+
git(repo, ['config', 'user.email', 'test@example.com']);
|
|
614
|
+
git(repo, ['config', 'user.name', 'Amalgm Test']);
|
|
615
|
+
fs.writeFileSync(path.join(repo, 'tracked.md'), '# committed\n');
|
|
616
|
+
git(repo, ['add', '.']);
|
|
617
|
+
git(repo, ['commit', '--quiet', '-m', 'initial']);
|
|
618
|
+
|
|
619
|
+
const { unpack } = require('../registration/repo-states');
|
|
620
|
+
const source = createRegistrationService({
|
|
621
|
+
dir: sourceState, homeDir: path.join(rootDir, 'source-home'),
|
|
622
|
+
cloudUserId: 'user-1', cloudDeviceId: 'source-device',
|
|
623
|
+
});
|
|
624
|
+
const target = createRegistrationService({
|
|
625
|
+
dir: targetState, homeDir: path.join(rootDir, 'target-home'),
|
|
626
|
+
cloudUserId: 'user-1', cloudDeviceId: 'target-device',
|
|
627
|
+
});
|
|
628
|
+
try {
|
|
629
|
+
const bootstrap = source.entityCloud.bootstrap(source.registration.registry.syncRecords());
|
|
630
|
+
source.entityCloud.install({
|
|
631
|
+
registry: source.registration.registry, resourceId: bootstrap.resourceId,
|
|
632
|
+
authorityEpoch: 1, headVersion: 0, snapshotBase64: bootstrap.snapshotBase64,
|
|
633
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
634
|
+
});
|
|
635
|
+
const registered = await source.register(workspace);
|
|
636
|
+
|
|
637
|
+
const sourceDb = new Database(path.join(sourceState, 'identity.db'), { readonly: true });
|
|
638
|
+
const ops = sourceDb.prepare('SELECT operation_json FROM entity_cloud_mutations ORDER BY sequence').all()
|
|
639
|
+
.map((row) => JSON.parse(row.operation_json));
|
|
640
|
+
sourceDb.close();
|
|
641
|
+
const traveled = ops.map((op) => op.record);
|
|
642
|
+
// A loose file lawfully journals twice (structural create, then its
|
|
643
|
+
// first Detect attest); what matters is WHICH entities travel at all.
|
|
644
|
+
assert.deepEqual(
|
|
645
|
+
[...new Set(traveled.map((record) => record.name))].sort(),
|
|
646
|
+
['ambient', 'engine', 'notes.md'],
|
|
647
|
+
'the workspace, its loose file, and ONE record per repository travel — repo children do not',
|
|
648
|
+
);
|
|
649
|
+
const repoRecord = traveled.find((record) => record.type === 'repo.git');
|
|
650
|
+
const trackedUuid = (() => {
|
|
651
|
+
const children = source.registration.registry.children(repoRecord.uuid);
|
|
652
|
+
return children.find((child) => child.name === 'tracked.md').uuid;
|
|
653
|
+
})();
|
|
654
|
+
|
|
655
|
+
// The gateway's fold keeps the LAST record per UUID; mirror that here.
|
|
656
|
+
const snapshot = snapshotFromRecords([...new Map(traveled.map((record) => [record.uuid, record])).values()]);
|
|
657
|
+
const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
658
|
+
target.entityCloud.install({
|
|
659
|
+
registry: target.registration.registry, resourceId: bootstrap.resourceId,
|
|
660
|
+
authorityEpoch: 1, headVersion: 3, snapshotBase64: snapshotBytes.toString('base64'),
|
|
661
|
+
snapshotChecksum: sha256(snapshotBytes),
|
|
662
|
+
});
|
|
663
|
+
const uploaded = [];
|
|
664
|
+
while (true) {
|
|
665
|
+
const part = source.entityCloud.claimContent();
|
|
666
|
+
if (!part) break;
|
|
667
|
+
uploaded.push(part);
|
|
668
|
+
source.entityCloud.acknowledgeContent({
|
|
669
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash,
|
|
670
|
+
kind: part.kind, partIndex: part.partIndex,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
|
|
674
|
+
target.receiveCloudContent({
|
|
675
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
|
|
676
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
677
|
+
sha256: part.sha256, dataBase64: part.dataBase64,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
|
|
682
|
+
assert.equal(job.state, 'complete', job.error);
|
|
683
|
+
const destination = path.join(targetParent, 'ambient');
|
|
684
|
+
assert.equal(fs.readFileSync(path.join(destination, 'engine', 'tracked.md'), 'utf8'), '# committed\n');
|
|
685
|
+
assert.equal(target.registration.registry.syncRecord(trackedUuid)?.name, 'tracked.md',
|
|
686
|
+
'a file inside the nested repository keeps its permanent UUID across machines via the transport map');
|
|
687
|
+
const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
|
|
688
|
+
try {
|
|
689
|
+
assert.equal(targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 0,
|
|
690
|
+
'binding the workspace echoes nothing upstream');
|
|
691
|
+
} finally {
|
|
692
|
+
targetDb.close();
|
|
693
|
+
}
|
|
694
|
+
} finally {
|
|
695
|
+
source.close();
|
|
696
|
+
target.close();
|
|
697
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
698
|
+
}
|
|
699
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The coverage law under exam: one OS watcher per outermost tree.
|
|
5
|
+
*
|
|
6
|
+
* Claims, each proven against a REAL fs.watch or the deterministic kit:
|
|
7
|
+
* 1. A nested repo opens ZERO watchers of its own — the encloser's
|
|
8
|
+
* doorbell is its doorbell, and dings still reach its follower raw.
|
|
9
|
+
* 2. Adoption demotes: a self-owned root that becomes enclosed closes
|
|
10
|
+
* its handle. Departure promotes: losing the encloser reopens it.
|
|
11
|
+
* 3. Held doorbells (watchNow) prove the watcher without concluding
|
|
12
|
+
* anything; start() releases them into the ordinary catch-up.
|
|
13
|
+
* 4. An owner's death reconciles the whole covered family, not just
|
|
14
|
+
* the owner.
|
|
15
|
+
* 5. status() tells the truth for covered roots: their health IS the
|
|
16
|
+
* owner's health.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const assert = require('node:assert/strict');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const test = require('node:test');
|
|
23
|
+
|
|
24
|
+
const {
|
|
25
|
+
cleanup, eventsOf, fakeWatchKit, makeDir, makeObserver, nudgeUntil, realRepo,
|
|
26
|
+
} = require('./observer.rig');
|
|
27
|
+
|
|
28
|
+
test.after(() => cleanup());
|
|
29
|
+
|
|
30
|
+
/** Watcher handles the kit has open, by watched path. */
|
|
31
|
+
function kitWithBells() {
|
|
32
|
+
const kit = fakeWatchKit();
|
|
33
|
+
const open = new Set();
|
|
34
|
+
const inner = kit.watch;
|
|
35
|
+
kit.watch = (rootPath, onDirty, onDead) => {
|
|
36
|
+
open.add(rootPath);
|
|
37
|
+
const handle = inner(rootPath, onDirty, onDead);
|
|
38
|
+
const close = handle.close;
|
|
39
|
+
handle.close = () => { open.delete(rootPath); close(); };
|
|
40
|
+
return handle;
|
|
41
|
+
};
|
|
42
|
+
kit.open = open;
|
|
43
|
+
return kit;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('a nested repo owns no watcher: the encloser holds the only handle', () => {
|
|
47
|
+
const dir = makeDir('coverage-dedup');
|
|
48
|
+
const repo = path.join(dir, 'project');
|
|
49
|
+
fs.mkdirSync(repo);
|
|
50
|
+
fs.mkdirSync(path.join(repo, '.git'));
|
|
51
|
+
fs.writeFileSync(path.join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
52
|
+
fs.writeFileSync(path.join(repo, 'app.js'), 'code');
|
|
53
|
+
|
|
54
|
+
const kit = kitWithBells();
|
|
55
|
+
const { observer } = makeObserver({ watch: kit.watch });
|
|
56
|
+
observer.enrollRoot(dir);
|
|
57
|
+
observer.start();
|
|
58
|
+
|
|
59
|
+
const roots = observer.listRoots();
|
|
60
|
+
assert.equal(roots.length, 2, 'workspace and nested repo are both roots');
|
|
61
|
+
assert.deepEqual([...kit.open], [dir],
|
|
62
|
+
'exactly ONE recursive handle exists, on the outermost tree');
|
|
63
|
+
|
|
64
|
+
const repoRoot = roots.find((root) => root.kind === 'repo');
|
|
65
|
+
const covered = observer.status().find((row) => row.rootId === repoRoot.rootId);
|
|
66
|
+
assert.equal(covered.coveredBy, roots.find((root) => root.kind === 'files').rootId);
|
|
67
|
+
assert.ok(covered.watching, 'covered ground reports watching through its owner');
|
|
68
|
+
observer.stop();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a covered repo still hears raw dings: the encloser forwards, rebased', () => {
|
|
72
|
+
const dir = makeDir('coverage-forward');
|
|
73
|
+
const repo = path.join(dir, 'project');
|
|
74
|
+
fs.mkdirSync(repo);
|
|
75
|
+
fs.mkdirSync(path.join(repo, '.git'));
|
|
76
|
+
fs.writeFileSync(path.join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
77
|
+
fs.writeFileSync(path.join(repo, 'app.js'), 'code');
|
|
78
|
+
|
|
79
|
+
const kit = kitWithBells();
|
|
80
|
+
const { observer, events } = makeObserver({ watch: kit.watch });
|
|
81
|
+
observer.enrollRoot(dir);
|
|
82
|
+
observer.start();
|
|
83
|
+
const repoRoot = observer.listRoots().find((root) => root.kind === 'repo');
|
|
84
|
+
const mark = events.length;
|
|
85
|
+
|
|
86
|
+
// The ding lands on the WORKSPACE watcher, named from the workspace.
|
|
87
|
+
kit.ring(dir, 'project/app.js');
|
|
88
|
+
const spoken = events.slice(mark).filter((event) => event.type === 'repo.changed');
|
|
89
|
+
assert.deepEqual(spoken, [{
|
|
90
|
+
type: 'repo.changed', rootId: repoRoot.rootId, areas: ['worktree'], hint: 'app.js',
|
|
91
|
+
}], 'one raw repo doorbell, instantly, rebased to the repo\'s own ground');
|
|
92
|
+
|
|
93
|
+
// A git-area ding forwards with its area intact.
|
|
94
|
+
kit.ring(dir, 'project/.git/HEAD');
|
|
95
|
+
const gitSpoken = events.filter((event) => event.type === 'repo.changed'
|
|
96
|
+
&& event.areas.length === 1 && event.areas[0] === 'git');
|
|
97
|
+
assert.equal(gitSpoken.length, 1, 'the .git ding forwards as git-area news');
|
|
98
|
+
observer.stop();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('adoption demotes and departure promotes: handles follow the outermost tree', () => {
|
|
102
|
+
const parent = makeDir('coverage-parent');
|
|
103
|
+
const repoDir = path.join(parent, 'standalone');
|
|
104
|
+
fs.mkdirSync(repoDir);
|
|
105
|
+
fs.mkdirSync(path.join(repoDir, '.git'));
|
|
106
|
+
fs.writeFileSync(path.join(repoDir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
107
|
+
fs.writeFileSync(path.join(repoDir, 'main.js'), 'code');
|
|
108
|
+
|
|
109
|
+
const kit = kitWithBells();
|
|
110
|
+
const { observer } = makeObserver({ watch: kit.watch });
|
|
111
|
+
observer.enrollRoot(repoDir);
|
|
112
|
+
observer.start();
|
|
113
|
+
assert.deepEqual([...kit.open], [repoDir], 'standalone: the repo owns its handle');
|
|
114
|
+
|
|
115
|
+
// A workspace registered ABOVE it adopts the coverage.
|
|
116
|
+
observer.enrollRoot(parent);
|
|
117
|
+
assert.deepEqual([...kit.open], [parent],
|
|
118
|
+
'enclosed by watched ground: the repo handle closes, the workspace handle is the doorbell');
|
|
119
|
+
|
|
120
|
+
// The workspace leaving promotes the repo back to self-owned.
|
|
121
|
+
const workspaceRoot = observer.listRoots().find((root) => root.path === parent);
|
|
122
|
+
observer.removeRoot(workspaceRoot.rootId);
|
|
123
|
+
assert.deepEqual([...kit.open], [repoDir], 'departure promotes: the repo reopens its own handle');
|
|
124
|
+
observer.stop();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('watchNow proves the doorbell and holds it; start() releases into catch-up', () => {
|
|
128
|
+
const dir = makeDir('coverage-held');
|
|
129
|
+
fs.writeFileSync(path.join(dir, 'note.md'), 'v1');
|
|
130
|
+
const kit = kitWithBells();
|
|
131
|
+
const { observer, events } = makeObserver({ watch: kit.watch });
|
|
132
|
+
const root = observer.enrollRoot(dir, { scan: false, watch: false });
|
|
133
|
+
|
|
134
|
+
const health = observer.watchNow(root.rootId);
|
|
135
|
+
assert.ok(health.watching, 'the watcher is born and provable before anything commits');
|
|
136
|
+
assert.deepEqual([...kit.open], [dir], 'the handle exists during the hold');
|
|
137
|
+
|
|
138
|
+
kit.ring(dir, 'note.md');
|
|
139
|
+
assert.deepEqual(events, [], 'held dings conclude nothing — no scan, no event');
|
|
140
|
+
|
|
141
|
+
observer.start(root.rootId);
|
|
142
|
+
assert.ok(eventsOf(events, 'file.created').some((event) => event.path === 'note.md'),
|
|
143
|
+
'start() releases the hold and its scan is the catch-up');
|
|
144
|
+
observer.stop();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('an owner death reconciles the covered family', async () => {
|
|
148
|
+
const dir = makeDir('coverage-death');
|
|
149
|
+
const repo = path.join(dir, 'project');
|
|
150
|
+
fs.mkdirSync(repo);
|
|
151
|
+
fs.mkdirSync(path.join(repo, '.git'));
|
|
152
|
+
fs.writeFileSync(path.join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
153
|
+
|
|
154
|
+
let dieNow = null;
|
|
155
|
+
const kit = kitWithBells();
|
|
156
|
+
const inner = kit.watch;
|
|
157
|
+
kit.watch = (rootPath, onDirty, onDead) => {
|
|
158
|
+
const handle = inner(rootPath, onDirty, onDead);
|
|
159
|
+
if (rootPath === dir) dieNow = () => { handle.degraded = true; onDead?.(); };
|
|
160
|
+
return handle;
|
|
161
|
+
};
|
|
162
|
+
const { observer, events } = makeObserver({ watch: kit.watch });
|
|
163
|
+
observer.enrollRoot(dir);
|
|
164
|
+
observer.start();
|
|
165
|
+
const repoRoot = observer.listRoots().find((root) => root.kind === 'repo');
|
|
166
|
+
const mark = events.length;
|
|
167
|
+
|
|
168
|
+
dieNow();
|
|
169
|
+
const caughtUp = events.slice(mark).filter((event) => event.type === 'repo.changed'
|
|
170
|
+
&& event.rootId === repoRoot.rootId && event.hint === null);
|
|
171
|
+
assert.equal(caughtUp.length, 1,
|
|
172
|
+
'the revived owner reconciles the covered repo too: one unconditional catch-up doorbell');
|
|
173
|
+
observer.stop();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('live ground truth: a real nested repo change reaches its follower through the shared watcher', async () => {
|
|
177
|
+
const dir = makeDir('coverage-live');
|
|
178
|
+
const repo = path.join(dir, 'project');
|
|
179
|
+
fs.mkdirSync(repo);
|
|
180
|
+
fs.mkdirSync(path.join(repo, '.git'));
|
|
181
|
+
fs.writeFileSync(path.join(repo, '.git', 'HEAD'), 'ref: refs/heads/main\n');
|
|
182
|
+
fs.writeFileSync(path.join(repo, 'app.js'), 'v1');
|
|
183
|
+
|
|
184
|
+
const { observer, events } = makeObserver(); // REAL fs.watch
|
|
185
|
+
observer.enrollRoot(dir);
|
|
186
|
+
observer.start();
|
|
187
|
+
const repoRoot = observer.listRoots().find((root) => root.kind === 'repo');
|
|
188
|
+
const mark = events.length;
|
|
189
|
+
|
|
190
|
+
const arrived = await nudgeUntil(
|
|
191
|
+
() => fs.writeFileSync(path.join(repo, 'app.js'), 'v2'),
|
|
192
|
+
() => events.slice(mark).some((event) => event.type === 'repo.changed'
|
|
193
|
+
&& event.rootId === repoRoot.rootId),
|
|
194
|
+
);
|
|
195
|
+
assert.ok(arrived, 'a real OS event on covered ground reaches the repo through the one shared watcher');
|
|
196
|
+
observer.stop();
|
|
197
|
+
});
|