amalgm 0.1.262 → 0.1.264
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 +168 -18
- 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/registry/evidence.js +16 -14
- 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 +243 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +48 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
- package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
|
@@ -369,6 +369,11 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
369
369
|
snapshotChecksum: sha256(snapshotBytes),
|
|
370
370
|
});
|
|
371
371
|
|
|
372
|
+
// This is the real user destination: a repo lands below an already-live
|
|
373
|
+
// local workspaces-like container. Physical containment supplies watcher
|
|
374
|
+
// coverage only; the cloud repo must retain its parentless root identity.
|
|
375
|
+
target.registration.add(targetParent, { suppressJournal: true });
|
|
376
|
+
|
|
372
377
|
const planned = source.entityCloud.contentHeads(records);
|
|
373
378
|
assert.deepEqual(
|
|
374
379
|
planned.map(({ contentHash }) => contentHash),
|
|
@@ -406,6 +411,8 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
406
411
|
const expected = source.registration.repoState(registered.record.uuid);
|
|
407
412
|
assert.equal(verify(destination, expected).ok, true, 'the downloaded Git state reproduces its Card + Checkpoint IDs');
|
|
408
413
|
assert.equal(target.registration.registry.syncRecord(registered.record.uuid).uuid, registered.record.uuid);
|
|
414
|
+
assert.equal(target.registration.registry.syncRecord(registered.record.uuid).parentUUID, null,
|
|
415
|
+
'a repo materialized beneath an existing local workspace keeps its cloud root position');
|
|
409
416
|
assert.equal(target.observer.status().find((entry) => entry.path === fs.realpathSync(destination))?.watching, true,
|
|
410
417
|
'the same successful add starts the destination repository watcher');
|
|
411
418
|
|
|
@@ -461,3 +468,239 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
|
|
|
461
468
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
462
469
|
}
|
|
463
470
|
});
|
|
471
|
+
|
|
472
|
+
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 () => {
|
|
473
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-repo-travel-law-'));
|
|
474
|
+
const sourceState = path.join(rootDir, 'source-state');
|
|
475
|
+
const targetState = path.join(rootDir, 'target-state');
|
|
476
|
+
const sourceRepo = path.join(rootDir, 'engine');
|
|
477
|
+
const targetParent = path.join(rootDir, 'checkout');
|
|
478
|
+
fs.mkdirSync(path.join(sourceRepo, 'src'), { recursive: true });
|
|
479
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
480
|
+
git(sourceRepo, ['init', '--quiet']);
|
|
481
|
+
git(sourceRepo, ['config', 'user.email', 'test@example.com']);
|
|
482
|
+
git(sourceRepo, ['config', 'user.name', 'Amalgm Test']);
|
|
483
|
+
fs.writeFileSync(path.join(sourceRepo, 'tracked.md'), '# committed\n');
|
|
484
|
+
fs.writeFileSync(path.join(sourceRepo, 'src', 'index.js'), 'module.exports = 1;\n');
|
|
485
|
+
git(sourceRepo, ['add', '.']);
|
|
486
|
+
git(sourceRepo, ['commit', '--quiet', '-m', 'initial']);
|
|
487
|
+
fs.writeFileSync(path.join(sourceRepo, 'untracked.txt'), 'not committed\n');
|
|
488
|
+
|
|
489
|
+
const { unpack } = require('../registration/repo-states');
|
|
490
|
+
const source = createRegistrationService({
|
|
491
|
+
dir: sourceState, homeDir: path.join(rootDir, 'source-home'),
|
|
492
|
+
cloudUserId: 'user-1', cloudDeviceId: 'source-device',
|
|
493
|
+
});
|
|
494
|
+
const target = createRegistrationService({
|
|
495
|
+
dir: targetState, homeDir: path.join(rootDir, 'target-home'),
|
|
496
|
+
cloudUserId: 'user-1', cloudDeviceId: 'target-device',
|
|
497
|
+
});
|
|
498
|
+
const mutationOps = (stateDir) => {
|
|
499
|
+
const handle = new Database(path.join(stateDir, 'identity.db'), { readonly: true });
|
|
500
|
+
try {
|
|
501
|
+
return handle.prepare('SELECT operation_json FROM entity_cloud_mutations ORDER BY sequence').all()
|
|
502
|
+
.map((row) => JSON.parse(row.operation_json));
|
|
503
|
+
} finally {
|
|
504
|
+
handle.close();
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
try {
|
|
508
|
+
const bootstrap = source.entityCloud.bootstrap(source.registration.registry.syncRecords());
|
|
509
|
+
source.entityCloud.install({
|
|
510
|
+
registry: source.registration.registry, resourceId: bootstrap.resourceId,
|
|
511
|
+
authorityEpoch: 1, headVersion: 0, snapshotBase64: bootstrap.snapshotBase64,
|
|
512
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
513
|
+
});
|
|
514
|
+
const registered = await source.register(sourceRepo);
|
|
515
|
+
|
|
516
|
+
// ONE traveling record: the repository. No child postcards, ever.
|
|
517
|
+
const ops = mutationOps(sourceState);
|
|
518
|
+
assert.equal(ops.length, 1, `a repository register is one cloud mutation, got ${JSON.stringify(ops.map((op) => op.record?.name))}`);
|
|
519
|
+
assert.equal(ops[0].kind, 'entity.upsert');
|
|
520
|
+
assert.equal(ops[0].record.uuid, registered.record.uuid);
|
|
521
|
+
assert.ok(ops[0].record.transportVersion, 'the one record names its Repocard transport');
|
|
522
|
+
|
|
523
|
+
// The transport parcel carries the worktree identity map, and the map
|
|
524
|
+
// names the exact UUIDs the local registry minted.
|
|
525
|
+
const transport = unpack(source.entityContent.bytes({ contentHash: ops[0].record.transportVersion }).toString('utf8'));
|
|
526
|
+
assert.ok(Array.isArray(transport.identity), 'the transport carries the identity map');
|
|
527
|
+
const mapByPath = new Map(transport.identity.map((entry) => [entry.path, entry]));
|
|
528
|
+
const localByPath = new Map();
|
|
529
|
+
const walkLocal = (parentUuid, base) => {
|
|
530
|
+
for (const child of source.registration.registry.children(parentUuid)) {
|
|
531
|
+
if (child.status !== 'active') continue;
|
|
532
|
+
const childPath = base ? `${base}/${child.name}` : child.name;
|
|
533
|
+
localByPath.set(childPath, child.uuid);
|
|
534
|
+
walkLocal(child.uuid, childPath);
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
walkLocal(registered.record.uuid, '');
|
|
538
|
+
for (const relPath of ['tracked.md', 'src', 'src/index.js', 'untracked.txt']) {
|
|
539
|
+
assert.equal(mapByPath.get(relPath)?.uuid, localByPath.get(relPath),
|
|
540
|
+
`the transport map and the local registry agree on ${relPath}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// The gateway would fold that one mutation into the active head: the
|
|
544
|
+
// authoritative snapshot contains exactly the traveling graph.
|
|
545
|
+
const snapshot = snapshotFromRecords([ops[0].record]);
|
|
546
|
+
const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
547
|
+
target.entityCloud.install({
|
|
548
|
+
registry: target.registration.registry, resourceId: bootstrap.resourceId,
|
|
549
|
+
authorityEpoch: 1, headVersion: 1, snapshotBase64: snapshotBytes.toString('base64'),
|
|
550
|
+
snapshotChecksum: sha256(snapshotBytes),
|
|
551
|
+
});
|
|
552
|
+
const uploaded = [];
|
|
553
|
+
while (true) {
|
|
554
|
+
const part = source.entityCloud.claimContent();
|
|
555
|
+
if (!part) break;
|
|
556
|
+
uploaded.push(part);
|
|
557
|
+
source.entityCloud.acknowledgeContent({
|
|
558
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash,
|
|
559
|
+
kind: part.kind, partIndex: part.partIndex,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
|
|
563
|
+
target.receiveCloudContent({
|
|
564
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
|
|
565
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
566
|
+
sha256: part.sha256, dataBase64: part.dataBase64,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
|
|
570
|
+
assert.equal(job.state, 'complete', job.error);
|
|
571
|
+
const destination = path.join(targetParent, 'engine');
|
|
572
|
+
assert.equal(verify(destination, source.registration.repoState(registered.record.uuid)).ok, true);
|
|
573
|
+
|
|
574
|
+
// The receiving machine wears the SAME child UUIDs — bound from the
|
|
575
|
+
// transport map, never minted as competitors.
|
|
576
|
+
for (const [relPath, uuid] of localByPath) {
|
|
577
|
+
const bound = target.registration.registry.syncRecord(uuid);
|
|
578
|
+
assert.equal(bound?.name, path.basename(relPath), `${relPath} keeps its permanent UUID across machines`);
|
|
579
|
+
}
|
|
580
|
+
assert.equal(mutationOps(targetState).length, 0, 'the add binding echoes nothing upstream');
|
|
581
|
+
|
|
582
|
+
// A live edit settles as the NEXT parcel: still one mutation on the
|
|
583
|
+
// repo entity, still no child postcards, map still riding along.
|
|
584
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
585
|
+
fs.writeFileSync(path.join(sourceRepo, 'later.md'), 'a later change\n');
|
|
586
|
+
const deadline = Date.now() + 30_000;
|
|
587
|
+
while (mutationOps(sourceState).length < 2) {
|
|
588
|
+
if (Date.now() > deadline) assert.fail('timed out waiting for the settled repo parcel');
|
|
589
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
590
|
+
}
|
|
591
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
592
|
+
const settledOps = mutationOps(sourceState);
|
|
593
|
+
assert.ok(settledOps.every((op) => op.record?.uuid === registered.record.uuid),
|
|
594
|
+
`every traveling mutation is the repo entity itself, got ${JSON.stringify(settledOps.map((op) => op.record?.name))}`);
|
|
595
|
+
const settled = settledOps[settledOps.length - 1];
|
|
596
|
+
const settledTransport = unpack(source.entityContent.bytes({ contentHash: settled.record.transportVersion }).toString('utf8'));
|
|
597
|
+
assert.equal(
|
|
598
|
+
new Map(settledTransport.identity.map((entry) => [entry.path, entry])).get('tracked.md')?.uuid,
|
|
599
|
+
localByPath.get('tracked.md'),
|
|
600
|
+
'a settled parcel repacks the registry-held identity map with the same permanent UUIDs',
|
|
601
|
+
);
|
|
602
|
+
} finally {
|
|
603
|
+
source.close();
|
|
604
|
+
target.close();
|
|
605
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('a workspace containing a repository travels as its loose records plus ONE repo record, and add binds repo children from the map', async () => {
|
|
610
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-repo-travel-'));
|
|
611
|
+
const sourceState = path.join(rootDir, 'source-state');
|
|
612
|
+
const targetState = path.join(rootDir, 'target-state');
|
|
613
|
+
const workspace = path.join(rootDir, 'ambient');
|
|
614
|
+
const repo = path.join(workspace, 'engine');
|
|
615
|
+
const targetParent = path.join(rootDir, 'checkout');
|
|
616
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
617
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
618
|
+
fs.writeFileSync(path.join(workspace, 'notes.md'), '# loose ground\n');
|
|
619
|
+
git(repo, ['init', '--quiet']);
|
|
620
|
+
git(repo, ['config', 'user.email', 'test@example.com']);
|
|
621
|
+
git(repo, ['config', 'user.name', 'Amalgm Test']);
|
|
622
|
+
fs.writeFileSync(path.join(repo, 'tracked.md'), '# committed\n');
|
|
623
|
+
git(repo, ['add', '.']);
|
|
624
|
+
git(repo, ['commit', '--quiet', '-m', 'initial']);
|
|
625
|
+
|
|
626
|
+
const { unpack } = require('../registration/repo-states');
|
|
627
|
+
const source = createRegistrationService({
|
|
628
|
+
dir: sourceState, homeDir: path.join(rootDir, 'source-home'),
|
|
629
|
+
cloudUserId: 'user-1', cloudDeviceId: 'source-device',
|
|
630
|
+
});
|
|
631
|
+
const target = createRegistrationService({
|
|
632
|
+
dir: targetState, homeDir: path.join(rootDir, 'target-home'),
|
|
633
|
+
cloudUserId: 'user-1', cloudDeviceId: 'target-device',
|
|
634
|
+
});
|
|
635
|
+
try {
|
|
636
|
+
const bootstrap = source.entityCloud.bootstrap(source.registration.registry.syncRecords());
|
|
637
|
+
source.entityCloud.install({
|
|
638
|
+
registry: source.registration.registry, resourceId: bootstrap.resourceId,
|
|
639
|
+
authorityEpoch: 1, headVersion: 0, snapshotBase64: bootstrap.snapshotBase64,
|
|
640
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
641
|
+
});
|
|
642
|
+
const registered = await source.register(workspace);
|
|
643
|
+
|
|
644
|
+
const sourceDb = new Database(path.join(sourceState, 'identity.db'), { readonly: true });
|
|
645
|
+
const ops = sourceDb.prepare('SELECT operation_json FROM entity_cloud_mutations ORDER BY sequence').all()
|
|
646
|
+
.map((row) => JSON.parse(row.operation_json));
|
|
647
|
+
sourceDb.close();
|
|
648
|
+
const traveled = ops.map((op) => op.record);
|
|
649
|
+
// A loose file lawfully journals twice (structural create, then its
|
|
650
|
+
// first Detect attest); what matters is WHICH entities travel at all.
|
|
651
|
+
assert.deepEqual(
|
|
652
|
+
[...new Set(traveled.map((record) => record.name))].sort(),
|
|
653
|
+
['ambient', 'engine', 'notes.md'],
|
|
654
|
+
'the workspace, its loose file, and ONE record per repository travel — repo children do not',
|
|
655
|
+
);
|
|
656
|
+
const repoRecord = traveled.find((record) => record.type === 'repo.git');
|
|
657
|
+
const trackedUuid = (() => {
|
|
658
|
+
const children = source.registration.registry.children(repoRecord.uuid);
|
|
659
|
+
return children.find((child) => child.name === 'tracked.md').uuid;
|
|
660
|
+
})();
|
|
661
|
+
|
|
662
|
+
// The gateway's fold keeps the LAST record per UUID; mirror that here.
|
|
663
|
+
const snapshot = snapshotFromRecords([...new Map(traveled.map((record) => [record.uuid, record])).values()]);
|
|
664
|
+
const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
665
|
+
target.entityCloud.install({
|
|
666
|
+
registry: target.registration.registry, resourceId: bootstrap.resourceId,
|
|
667
|
+
authorityEpoch: 1, headVersion: 3, snapshotBase64: snapshotBytes.toString('base64'),
|
|
668
|
+
snapshotChecksum: sha256(snapshotBytes),
|
|
669
|
+
});
|
|
670
|
+
const uploaded = [];
|
|
671
|
+
while (true) {
|
|
672
|
+
const part = source.entityCloud.claimContent();
|
|
673
|
+
if (!part) break;
|
|
674
|
+
uploaded.push(part);
|
|
675
|
+
source.entityCloud.acknowledgeContent({
|
|
676
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash,
|
|
677
|
+
kind: part.kind, partIndex: part.partIndex,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
|
|
681
|
+
target.receiveCloudContent({
|
|
682
|
+
resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
|
|
683
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
684
|
+
sha256: part.sha256, dataBase64: part.dataBase64,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });
|
|
689
|
+
assert.equal(job.state, 'complete', job.error);
|
|
690
|
+
const destination = path.join(targetParent, 'ambient');
|
|
691
|
+
assert.equal(fs.readFileSync(path.join(destination, 'engine', 'tracked.md'), 'utf8'), '# committed\n');
|
|
692
|
+
assert.equal(target.registration.registry.syncRecord(trackedUuid)?.name, 'tracked.md',
|
|
693
|
+
'a file inside the nested repository keeps its permanent UUID across machines via the transport map');
|
|
694
|
+
const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
|
|
695
|
+
try {
|
|
696
|
+
assert.equal(targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 0,
|
|
697
|
+
'binding the workspace echoes nothing upstream');
|
|
698
|
+
} finally {
|
|
699
|
+
targetDb.close();
|
|
700
|
+
}
|
|
701
|
+
} finally {
|
|
702
|
+
source.close();
|
|
703
|
+
target.close();
|
|
704
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
705
|
+
}
|
|
706
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -724,6 +724,45 @@ test('a nested repository has its own baseline and watcher, while its tree remai
|
|
|
724
724
|
assert.equal(tree.registry.activeAt(src.uuid, 'live.js').type, 'file.text');
|
|
725
725
|
});
|
|
726
726
|
|
|
727
|
+
test('a repo discovered inside watched workspace ground stays unregistered until explicit register gives it one complete repo root', async () => {
|
|
728
|
+
const service = makeService('workspace-repo-admission');
|
|
729
|
+
const workspace = makeGround('workspace-repo-admission');
|
|
730
|
+
const { record: workspaceRecord } = await service.register(workspace);
|
|
731
|
+
const repo = path.join(workspace, 't3code');
|
|
732
|
+
fs.mkdirSync(repo);
|
|
733
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
734
|
+
fs.writeFileSync(path.join(repo, 'readme.md'), '# T3\n');
|
|
735
|
+
|
|
736
|
+
// This is the production ordering that convicted the live Fly run: the
|
|
737
|
+
// outer workspace watcher discovers a newly cloned repository first.
|
|
738
|
+
const workspaceRoot = service.observer.listRoots().find((root) => root.path === workspace);
|
|
739
|
+
service.observer.scanNow(workspaceRoot.rootId);
|
|
740
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
741
|
+
service.registration.flushIfDirty();
|
|
742
|
+
|
|
743
|
+
const territory = service.observer.listRoots().find((root) => root.path === repo);
|
|
744
|
+
assert.equal(territory?.kind, 'repo', 'Watch knows the local Git territory');
|
|
745
|
+
assert.notEqual(
|
|
746
|
+
service.registration.tree(workspace).registry.activeAt(workspaceRecord.uuid, 't3code')?.type,
|
|
747
|
+
'repo.git',
|
|
748
|
+
'generic filesystem evidence never mints a half-repository with no Card + Checkpoint',
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const { record } = await service.register(repo);
|
|
752
|
+
assert.equal(record.type, 'repo.git');
|
|
753
|
+
assert.equal(record.parentUUID, null,
|
|
754
|
+
'selected repo is its own cloud tree even though its local address is inside workspaces');
|
|
755
|
+
assert.equal(service.registration.tree(repo).root().uuid, record.uuid,
|
|
756
|
+
'the nested observer root now witnesses that selected tree');
|
|
757
|
+
assert.ok(service.registration.repoState(record.uuid)?.stateId,
|
|
758
|
+
'its identity and initial Card + Checkpoint were committed together');
|
|
759
|
+
assert.equal(
|
|
760
|
+
service.observer.status().some((row) => row.path === repo && row.watching && !row.degraded),
|
|
761
|
+
true,
|
|
762
|
+
'the selected repo is live after the atomic registration',
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
|
|
727
766
|
test('a reopened repo watcher resumes from the saved Card + Checkpoint baseline', async () => {
|
|
728
767
|
const service = makeService('repo-reopen');
|
|
729
768
|
const repo = makeGround('repo-reopen');
|
|
@@ -770,15 +809,21 @@ test('register registers every entity before it starts Watch', async () => {
|
|
|
770
809
|
identity.close();
|
|
771
810
|
assert.ok(nested, 'the folder already has a durable identity');
|
|
772
811
|
assert.ok(file, 'so does the file within it');
|
|
773
|
-
|
|
774
|
-
|
|
812
|
+
// Watcher FIRST, conclusions LAST: by the time identity commits, the
|
|
813
|
+
// doorbell is already alive (it was proven before the walk began) —
|
|
814
|
+
// but HELD, so nothing has been observed or concluded yet. start()
|
|
815
|
+
// releasing the hold is the first moment Watch may speak.
|
|
816
|
+
assert.equal(service.observer.status()[0].watching, true,
|
|
817
|
+
'the doorbell was born and proven before identity committed');
|
|
818
|
+
assert.equal(service.observer.filesForRoot(rootId).length, 0,
|
|
819
|
+
'and held: no observation concluded while registration was in progress');
|
|
775
820
|
checked = true;
|
|
776
821
|
return start(rootId);
|
|
777
822
|
};
|
|
778
823
|
|
|
779
824
|
const { rootPath } = await service.register(ground);
|
|
780
825
|
assert.equal(rootPath, ground);
|
|
781
|
-
assert.equal(checked, true, '
|
|
826
|
+
assert.equal(checked, true, 'the held doorbell releases only after the structural walk commits');
|
|
782
827
|
});
|
|
783
828
|
|
|
784
829
|
test('register never reserves or mutates a user filename for watching', async () => {
|
|
@@ -911,13 +911,13 @@ test('add is one operation: a bare file registers, covered ground returns its tr
|
|
|
911
911
|
'ground that would swallow a registered root refuses loudly');
|
|
912
912
|
});
|
|
913
913
|
|
|
914
|
-
test('add
|
|
914
|
+
test('add refuses before identity commits when a required watcher cannot be born', () => {
|
|
915
915
|
const dir = makeDir('degraded-add');
|
|
916
916
|
const observer = createObserver({
|
|
917
917
|
db: new Database(':memory:'),
|
|
918
918
|
emit: () => {},
|
|
919
919
|
shouldEnroll: () => true,
|
|
920
|
-
watch: () => ({ degraded: true, close: () => {} }),
|
|
920
|
+
watch: () => ({ degraded: true, reason: 'ENOSPC', close: () => {} }),
|
|
921
921
|
});
|
|
922
922
|
const identityDb = new Database(':memory:');
|
|
923
923
|
const registration = createRegistration({
|
|
@@ -926,10 +926,14 @@ test('add never claims success when a required watcher cannot open', () => {
|
|
|
926
926
|
classify: () => 'file.text',
|
|
927
927
|
});
|
|
928
928
|
try {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
929
|
+
// Registration is synchronous, never optimistic: the watcher is proven
|
|
930
|
+
// FIRST, so a doorbell that cannot be born refuses with its reason and
|
|
931
|
+
// the remedy — and leaves NOTHING behind. No half-registered ghost, no
|
|
932
|
+
// local record the cloud never heard of.
|
|
933
|
+
assert.throws(() => registration.add(dir), /watcher could not be born \(ENOSPC\).*fs\.inotify\.max_user_watches/,
|
|
934
|
+
'the refusal names the kernel budget and the fix');
|
|
935
|
+
assert.ok(!registration.tree(dir)?.root(), 'no identity row survives the refusal');
|
|
936
|
+
assert.equal(observer.listRoots().length, 0, 'no observer root survives it either — zero residue');
|
|
933
937
|
} finally {
|
|
934
938
|
registration.stop();
|
|
935
939
|
observer.stop();
|