amalgm 0.1.261 → 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/lib/cli.js +9 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/lib/prepared.js +27 -0
- 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 +186 -44
- package/runtime/scripts/amalgm-mcp/observer/store.js +41 -27
- 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 +121 -16
- package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +34 -13
- package/runtime/scripts/amalgm-mcp/registry/store.js +30 -26
- 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 +30 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
- package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
package/lib/cli.js
CHANGED
|
@@ -1215,10 +1215,16 @@ function selectEntity(status, selector) {
|
|
|
1215
1215
|
const uuid = selector && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(selector)
|
|
1216
1216
|
? selector.toLowerCase()
|
|
1217
1217
|
: null;
|
|
1218
|
+
// A UUID is an entity's globally unique address. Do not let rows without
|
|
1219
|
+
// an optional display path turn that exact address into a fuzzy selector.
|
|
1220
|
+
if (uuid) {
|
|
1221
|
+
const match = entities.find((entity) => entity.uuid === uuid);
|
|
1222
|
+
if (!match) throw new Error(`No cloud-backed entity matches ${selector}.`);
|
|
1223
|
+
return match;
|
|
1224
|
+
}
|
|
1218
1225
|
const localPath = selector && !uuid ? path.resolve(selector) : null;
|
|
1219
|
-
const cloudPath =
|
|
1220
|
-
const matches = entities.filter((entity) => entity.
|
|
1221
|
-
|| entity.localPath === localPath
|
|
1226
|
+
const cloudPath = String(selector).replace(/^\/+|\/+$/g, '');
|
|
1227
|
+
const matches = entities.filter((entity) => entity.localPath === localPath
|
|
1222
1228
|
|| entity.cloudPath === cloudPath
|
|
1223
1229
|
|| entity.name === selector);
|
|
1224
1230
|
if (matches.length === 0) throw new Error(`No cloud-backed entity matches ${selector}.`);
|
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Memoized statement preparation for one database handle.
|
|
5
|
+
*
|
|
6
|
+
* better-sqlite3 compiles the SQL text on EVERY `db.prepare()` call — it
|
|
7
|
+
* keeps no statement cache of its own. The stores' row-level verbs run
|
|
8
|
+
* thousands of times per scan (a first census inserts one row per file),
|
|
9
|
+
* so recompiling identical SQL each call was a measured double-digit
|
|
10
|
+
* share of a large registration. Each store builds one preparer over its
|
|
11
|
+
* injected handle and asks it instead; the SQL string is the whole key,
|
|
12
|
+
* so a handful of table-templated queries memoize to a handful of
|
|
13
|
+
* statements, never an unbounded set.
|
|
14
|
+
*/
|
|
15
|
+
function preparer(db) {
|
|
16
|
+
const statements = new Map();
|
|
17
|
+
return (sql) => {
|
|
18
|
+
let statement = statements.get(sql);
|
|
19
|
+
if (!statement) {
|
|
20
|
+
statement = db.prepare(sql);
|
|
21
|
+
statements.set(sql, statement);
|
|
22
|
+
}
|
|
23
|
+
return statement;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { preparer };
|
|
@@ -80,7 +80,10 @@ never merges, never decides truth — it emits facts for the layers above.
|
|
|
80
80
|
never traversed — an explicitly enrolled symlink enrolls as the
|
|
81
81
|
pointer itself.
|
|
82
82
|
2. **The doorbell is a hint, never a fact — but a hint only ever raises
|
|
83
|
-
scrutiny.** OS
|
|
83
|
+
scrutiny.** One OS watcher per outermost tree (coverage.js — see
|
|
84
|
+
docs/watchers.md): enclosed roots hold no handles; the encloser
|
|
85
|
+
forwards their dings, rebased, synchronously. OS watchers are lossy
|
|
86
|
+
by contract. Truth is always:
|
|
84
87
|
settle → walk → compare → hash. Stats may excuse a file from hashing
|
|
85
88
|
only when all three hold: nothing named it, its stats are identical,
|
|
86
89
|
and its mtime is strictly older than the moment we last read its bytes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The coverage law: one OS watcher per outermost tree.
|
|
5
|
+
*
|
|
6
|
+
* A recursive watcher on a directory hears every event beneath it, at any
|
|
7
|
+
* depth — so a root enclosed by watched ground already has its content
|
|
8
|
+
* observed, exactly as its ADDRESS is already observed (the enclosed()
|
|
9
|
+
* rule that predates this module). Opening a second watcher over enclosed
|
|
10
|
+
* ground buys nothing on any platform and, on Linux — where a recursive
|
|
11
|
+
* watch is silently one inotify slot PER DIRECTORY against a small kernel
|
|
12
|
+
* budget — it is what exhausted the T3 machine. Doorbells are shared;
|
|
13
|
+
* what a root DOES with its dings (files channels, repo Card+Checkpoint)
|
|
14
|
+
* stays its own type's business, routed by ring().
|
|
15
|
+
*
|
|
16
|
+
* Pure functions over root rows — no handles, no state. index.js owns
|
|
17
|
+
* opening and closing; this module only answers "who guards whom".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
/** The ground a root's doorbell must cover: a directory root guards
|
|
23
|
+
* itself; a single-file root's bell lives on its parent directory (the
|
|
24
|
+
* file's own inode goes deaf on atomic saves). */
|
|
25
|
+
function doorbellGround(root) {
|
|
26
|
+
return root.kind === 'file' ? path.dirname(root.path) : root.path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The root whose OS watcher covers `root`'s doorbell ground, or null when
|
|
31
|
+
* `root` guards itself. The OUTERMOST enclosing directory root wins: with
|
|
32
|
+
* A ⊃ B ⊃ C only A holds a handle, and every deeper root — B, C, nested
|
|
33
|
+
* repos, single files — rides it. A file root can never cover anything.
|
|
34
|
+
*/
|
|
35
|
+
function watchOwnerOf(root, roots) {
|
|
36
|
+
const ground = doorbellGround(root);
|
|
37
|
+
let owner = null;
|
|
38
|
+
for (const other of roots) {
|
|
39
|
+
if (other.rootId === root.rootId || other.kind === 'file') continue;
|
|
40
|
+
if (ground !== other.path && !ground.startsWith(`${other.path}/`)) continue;
|
|
41
|
+
if (!owner || other.path.length < owner.path.length) owner = other;
|
|
42
|
+
}
|
|
43
|
+
return owner;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every live root covered by `owner` (owner excluded). */
|
|
47
|
+
function coveredRoots(owner, roots) {
|
|
48
|
+
return roots.filter((root) => root.rootId !== owner.rootId
|
|
49
|
+
&& watchOwnerOf(root, roots)?.rootId === owner.rootId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { coveredRoots, doorbellGround, watchOwnerOf };
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
* Nested repositories are territories too: a repo inside a repo is simply
|
|
22
22
|
* another repo. A census at lifecycle moments (enrollment, conversion,
|
|
23
23
|
* catch-up scans) plus doorbells live make each nested boundary its own
|
|
24
|
-
* root
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* root — but never its own OS watcher: one watcher per outermost tree
|
|
25
|
+
* (coverage.js), and the encloser FORWARDS a child territory's dings,
|
|
26
|
+
* rebased, synchronous, still raw. A doorbell's filename never proves
|
|
27
|
+
* anything — it only says where to look, and disk decides what it means
|
|
28
|
+
* (a repo moved in whole rings only the directory that landed, never a
|
|
29
|
+
* path spelling ".git").
|
|
28
30
|
*
|
|
29
31
|
* Enrollment is amalgm's OWN policy (`shouldEnroll`), applied uniformly to
|
|
30
32
|
* files territory and repo boundaries alike. Git's ignore rules never
|
|
@@ -42,6 +44,7 @@ const { createContinuity } = require('./continuity');
|
|
|
42
44
|
const { createStore } = require('./store');
|
|
43
45
|
const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, listDir, walkRoot } = require('./scan');
|
|
44
46
|
const { createVerify } = require('./verify');
|
|
47
|
+
const { coveredRoots, watchOwnerOf } = require('./coverage');
|
|
45
48
|
const { watchDir, watchRecursive } = require('./watch');
|
|
46
49
|
const { adapterFor } = require('../adapters');
|
|
47
50
|
const { lookFailed } = require('../adapters/truth');
|
|
@@ -80,7 +83,8 @@ function createObserver(options) {
|
|
|
80
83
|
if (typeof emit !== 'function') throw new Error('createObserver requires { emit }');
|
|
81
84
|
|
|
82
85
|
const store = createStore(db);
|
|
83
|
-
// rootId -> { kind, watcher, settleTimer, dirtyPaths,
|
|
86
|
+
// rootId -> { kind, watcher, addressWatcher, settleTimer, dirtyPaths,
|
|
87
|
+
// checkAll, territories, watchEnabled, coveredBy, held }
|
|
84
88
|
const live = new Map();
|
|
85
89
|
let started = false;
|
|
86
90
|
|
|
@@ -338,7 +342,7 @@ function createObserver(options) {
|
|
|
338
342
|
detach(root.rootId);
|
|
339
343
|
store.removeRoot(root.rootId);
|
|
340
344
|
live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
|
|
341
|
-
publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'] });
|
|
345
|
+
publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'], hint: null });
|
|
342
346
|
return;
|
|
343
347
|
}
|
|
344
348
|
if (parent) {
|
|
@@ -407,8 +411,10 @@ function createObserver(options) {
|
|
|
407
411
|
|| linkDiff.renames.length || linkDiff.creates.length || linkDiff.deletes.length
|
|
408
412
|
|| fileDiff.renames.length || fileDiff.creates.length || fileDiff.deletes.length;
|
|
409
413
|
if (structural) return false;
|
|
410
|
-
|
|
411
|
-
|
|
414
|
+
store.transaction(() => {
|
|
415
|
+
applyLinkDiff(root, linkDiff); // changes and retags: payload-local facts
|
|
416
|
+
applyDiff(root, fileDiff); // checks only: content truth on existing channels
|
|
417
|
+
});
|
|
412
418
|
return true;
|
|
413
419
|
}
|
|
414
420
|
|
|
@@ -448,11 +454,17 @@ function createObserver(options) {
|
|
|
448
454
|
: { ...diff, deletes: diff.deletes.filter((row) => !underUnsettled(row.relPath)) });
|
|
449
455
|
if (unsettled.length > 0 && state) armRetry(root, state);
|
|
450
456
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
457
|
+
// One walk's conclusions are one durable transition: a census over
|
|
458
|
+
// thousands of channels commits once, not once per row. Events stay
|
|
459
|
+
// hints — a consumer told early about a rolled-back row re-reads
|
|
460
|
+
// store truth and finds the old state, which concludes nothing.
|
|
461
|
+
store.transaction(() => {
|
|
462
|
+
applyDirDiff(root, concluded(diffDirState(store.dirsForRoot(root.rootId), dirs)));
|
|
463
|
+
applyLinkDiff(root, concluded(diffLinkState(store.linksForRoot(root.rootId), links)));
|
|
464
|
+
const rows = store.filesForRoot(root.rootId);
|
|
465
|
+
const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
|
|
466
|
+
applyDiff(root, concluded(diffRootState(rows, entries, mustCheck)));
|
|
467
|
+
});
|
|
456
468
|
}
|
|
457
469
|
|
|
458
470
|
/**
|
|
@@ -580,8 +592,9 @@ function createObserver(options) {
|
|
|
580
592
|
censusRepoRoot(root);
|
|
581
593
|
// Catch-up semantics: the observer cannot know what happened to a
|
|
582
594
|
// repo while nobody watched; the follower's ids turn "maybe" into
|
|
583
|
-
// silence, so an unconditional doorbell is the honest answer
|
|
584
|
-
|
|
595
|
+
// silence, so an unconditional doorbell is the honest answer —
|
|
596
|
+
// and a null path, because anything could have changed.
|
|
597
|
+
publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'], hint: null });
|
|
585
598
|
}
|
|
586
599
|
}
|
|
587
600
|
// A scan that leaves no look pending resets the backoff: the
|
|
@@ -605,6 +618,10 @@ function createObserver(options) {
|
|
|
605
618
|
function ring(root, relPath) {
|
|
606
619
|
const state = live.get(root.rootId);
|
|
607
620
|
if (!state) return;
|
|
621
|
+
// Held doorbells: the watcher is proven alive but registration has not
|
|
622
|
+
// committed. Dings conclude nothing — start(rootId)'s initial scan and
|
|
623
|
+
// census are the catch-up for everything heard during the hold.
|
|
624
|
+
if (state.held) return;
|
|
608
625
|
state.retryMs = settleMs; // new evidence resets a backoff
|
|
609
626
|
|
|
610
627
|
if (state.kind === 'file') {
|
|
@@ -653,6 +670,7 @@ function createObserver(options) {
|
|
|
653
670
|
// Only absence buries — and an unsettled look keeps the news
|
|
654
671
|
// pending: the retry looks again, no second doorbell needed.
|
|
655
672
|
if (childTruth === 'unsettled') armRetry(root, state);
|
|
673
|
+
else forwardToTerritory(root, territory, relPath);
|
|
656
674
|
return;
|
|
657
675
|
}
|
|
658
676
|
const known = store.rootByPath(childPath);
|
|
@@ -736,17 +754,22 @@ function createObserver(options) {
|
|
|
736
754
|
}
|
|
737
755
|
}
|
|
738
756
|
// Forwarded raw and instantly: quieting is the follower's job, and a
|
|
739
|
-
// delayed doorbell would blind its torn-read guard.
|
|
757
|
+
// delayed doorbell would blind its torn-read guard. The named path
|
|
758
|
+
// travels as a HINT — scope for the checkpoint to patch one file
|
|
759
|
+
// instead of re-reading the world; null means "anywhere". A hint is
|
|
760
|
+
// never a conviction: the grammar's voice (path/from/to) stays
|
|
761
|
+
// wording-independent, exactly as the wording grid ratifies.
|
|
740
762
|
const areas = relPath === null
|
|
741
763
|
? ['git', 'worktree']
|
|
742
764
|
: relPath === '.git' || relPath.startsWith('.git/') ? ['git'] : ['worktree'];
|
|
743
|
-
publish({ type: 'repo.changed', rootId: root.rootId, areas });
|
|
765
|
+
publish({ type: 'repo.changed', rootId: root.rootId, areas, hint: relPath });
|
|
744
766
|
return;
|
|
745
767
|
}
|
|
746
768
|
|
|
747
|
-
// A child
|
|
748
|
-
//
|
|
749
|
-
//
|
|
769
|
+
// A child territory's dings arrive on THIS root's watcher (one watcher
|
|
770
|
+
// per outermost tree) and are forwarded to the child — while the
|
|
771
|
+
// boundary is alive on disk. A dead one is territory news: fall through
|
|
772
|
+
// and let the scan's reconcile bury it and reclaim the ground.
|
|
750
773
|
const filesTerritory = relPath === null ? null : coveringTerritory(state, relPath);
|
|
751
774
|
if (filesTerritory !== null) {
|
|
752
775
|
const territoryTruth = gitDirTruth(path.join(root.path, filesTerritory));
|
|
@@ -754,7 +777,10 @@ function createObserver(options) {
|
|
|
754
777
|
armRetry(root, state); // no conclusion from a failed look — the retry decides
|
|
755
778
|
return;
|
|
756
779
|
}
|
|
757
|
-
if (territoryTruth === 'present')
|
|
780
|
+
if (territoryTruth === 'present') {
|
|
781
|
+
forwardToTerritory(root, filesTerritory, relPath);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
758
784
|
}
|
|
759
785
|
// Enrollment policy applies to doorbells too — except .git itself: it is
|
|
760
786
|
// never enrollable, but its appearance is territory news and must scan.
|
|
@@ -771,6 +797,19 @@ function createObserver(options) {
|
|
|
771
797
|
armSettle(root, state);
|
|
772
798
|
}
|
|
773
799
|
|
|
800
|
+
/**
|
|
801
|
+
* A covered child's dings arrive on its encloser's watcher; the encloser
|
|
802
|
+
* forwards them — synchronously, so repo doorbells stay raw and instant —
|
|
803
|
+
* rebased to the child's own ground. A ding naming the territory itself
|
|
804
|
+
* forwards as null (anything in there could have changed). Ringing the
|
|
805
|
+
* child recurses naturally through deeper territories.
|
|
806
|
+
*/
|
|
807
|
+
function forwardToTerritory(root, territory, relPath) {
|
|
808
|
+
const child = store.rootByPath(path.join(root.path, territory));
|
|
809
|
+
if (!child) return; // racing a burial: the next reconcile owns it
|
|
810
|
+
ring(child, relPath === territory ? null : relPath.slice(territory.length + 1));
|
|
811
|
+
}
|
|
812
|
+
|
|
774
813
|
function openWatcher(root) {
|
|
775
814
|
// A single-file root's doorbell lives on its parent directory: a
|
|
776
815
|
// watcher on the file itself would follow the inode and go deaf on
|
|
@@ -801,6 +840,34 @@ function createObserver(options) {
|
|
|
801
840
|
);
|
|
802
841
|
}
|
|
803
842
|
|
|
843
|
+
/**
|
|
844
|
+
* One root's watch health, coverage-aware: a covered root is watching
|
|
845
|
+
* exactly when its OWNER's watcher is live — it holds no handle of its
|
|
846
|
+
* own, by law, and pretending otherwise would hide a dead doorbell.
|
|
847
|
+
* Self-owned roots report their own handle, as always.
|
|
848
|
+
*/
|
|
849
|
+
function statusOf(root) {
|
|
850
|
+
const state = live.get(root.rootId);
|
|
851
|
+
const ownerState = state?.coveredBy ? live.get(state.coveredBy) : null;
|
|
852
|
+
const watching = state?.coveredBy
|
|
853
|
+
? Boolean(ownerState?.watcher) && !ownerState.watcher.degraded
|
|
854
|
+
: Boolean(state?.watcher) && !state.watcher.degraded;
|
|
855
|
+
const degraded = state?.coveredBy
|
|
856
|
+
? Boolean(ownerState?.watcher?.degraded)
|
|
857
|
+
: Boolean(state?.watcher?.degraded);
|
|
858
|
+
return {
|
|
859
|
+
...root,
|
|
860
|
+
watching,
|
|
861
|
+
degraded,
|
|
862
|
+
degradedReason: (state?.coveredBy ? ownerState : state)?.watcher?.reason ?? null,
|
|
863
|
+
coveredBy: state?.coveredBy ?? null,
|
|
864
|
+
addressRequired: root.kind !== 'file' && !enclosed(root),
|
|
865
|
+
addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
|
|
866
|
+
addressDegraded: Boolean(state?.addressWatcher?.degraded),
|
|
867
|
+
files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
|
|
804
871
|
/** A root enclosed by watched ground has its address observed already. */
|
|
805
872
|
function enclosed(root) {
|
|
806
873
|
return store.listRoots().some((other) =>
|
|
@@ -917,29 +984,81 @@ function createObserver(options) {
|
|
|
917
984
|
const state = live.get(root.rootId);
|
|
918
985
|
if (!state || !started) return;
|
|
919
986
|
state.watcher = openWatcher(root);
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
987
|
+
// Only owners hold watchers, so this death silenced every covered
|
|
988
|
+
// root too: the gap reconcile belongs to the whole family.
|
|
989
|
+
const family = [root, ...coveredRoots(root, watchCandidates())];
|
|
990
|
+
for (const member of family) {
|
|
991
|
+
try {
|
|
992
|
+
scanNow(member.rootId);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
console.warn(`[Observer] post-revive scan failed for "${member.path}":`, error?.message || error);
|
|
995
|
+
}
|
|
924
996
|
}
|
|
925
997
|
}
|
|
926
998
|
|
|
927
|
-
|
|
999
|
+
/** The roots that participate in watch coverage: attached, and asked to
|
|
1000
|
+
* watch (a registration-phase address-only root is neither an owner nor
|
|
1001
|
+
* covered — it is invisible to coverage until its watch begins). */
|
|
1002
|
+
function watchCandidates() {
|
|
1003
|
+
return store.listRoots().filter((root) => live.get(root.rootId)?.watchEnabled);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function ensureOwnWatcher(root, state) {
|
|
928
1007
|
if (!state.watcher || state.watcher.degraded) {
|
|
929
1008
|
state.watcher?.close();
|
|
930
1009
|
state.watcher = openWatcher(root);
|
|
931
1010
|
}
|
|
932
|
-
|
|
933
|
-
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function ensureAddressWatcher(root, state) {
|
|
1014
|
+
if (root.kind === 'file') return; // its main doorbell IS the parent
|
|
1015
|
+
if (enclosed(root)) {
|
|
1016
|
+
// The encloser's walk is its address evidence — and adoption can
|
|
1017
|
+
// make this true of a root that once held one: close it.
|
|
1018
|
+
state.addressWatcher?.close();
|
|
1019
|
+
state.addressWatcher = null;
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (!state.addressWatcher || state.addressWatcher.degraded) {
|
|
934
1023
|
state.addressWatcher?.close();
|
|
935
1024
|
state.addressWatcher = openAddressWatcher(root);
|
|
936
1025
|
}
|
|
937
1026
|
}
|
|
938
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* THE one decision about who holds OS handles, re-made whenever a root
|
|
1030
|
+
* joins, leaves, or moves: each watch-enabled root is either self-owned
|
|
1031
|
+
* (outermost — opens its own recursive doorbell) or covered (rides its
|
|
1032
|
+
* owner's; ring() forwards). Owners open FIRST, covered close after, so
|
|
1033
|
+
* an adoption never leaves a gap with no watcher over the ground.
|
|
1034
|
+
* Idempotent — settled roots are untouched.
|
|
1035
|
+
*/
|
|
1036
|
+
function resolveCoverage() {
|
|
1037
|
+
if (!started) return;
|
|
1038
|
+
const candidates = watchCandidates();
|
|
1039
|
+
const owned = [];
|
|
1040
|
+
const covered = [];
|
|
1041
|
+
for (const root of candidates) {
|
|
1042
|
+
const state = live.get(root.rootId);
|
|
1043
|
+
const owner = watchOwnerOf(root, candidates);
|
|
1044
|
+
state.coveredBy = owner?.rootId ?? null;
|
|
1045
|
+
(owner ? covered : owned).push([root, state]);
|
|
1046
|
+
}
|
|
1047
|
+
for (const [root, state] of owned) ensureOwnWatcher(root, state);
|
|
1048
|
+
for (const [, state] of covered) {
|
|
1049
|
+
state.watcher?.close();
|
|
1050
|
+
state.watcher = null;
|
|
1051
|
+
}
|
|
1052
|
+
for (const [root, state] of [...owned, ...covered]) ensureAddressWatcher(root, state);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
939
1055
|
function attach(root, { watch = true } = {}) {
|
|
940
1056
|
const existing = live.get(root.rootId);
|
|
941
1057
|
if (existing) {
|
|
942
|
-
if (started && watch)
|
|
1058
|
+
if (started && watch) {
|
|
1059
|
+
existing.watchEnabled = true;
|
|
1060
|
+
resolveCoverage();
|
|
1061
|
+
}
|
|
943
1062
|
return existing;
|
|
944
1063
|
}
|
|
945
1064
|
const state = {
|
|
@@ -952,12 +1071,18 @@ function createObserver(options) {
|
|
|
952
1071
|
dirtyPaths: new Set(),
|
|
953
1072
|
checkAll: false,
|
|
954
1073
|
territories: new Set(),
|
|
1074
|
+
watchEnabled: false,
|
|
1075
|
+
coveredBy: null,
|
|
1076
|
+
held: false,
|
|
955
1077
|
};
|
|
956
1078
|
for (const child of childRepoRoots(root)) {
|
|
957
1079
|
state.territories.add(child.path.slice(root.path.length + 1));
|
|
958
1080
|
}
|
|
959
1081
|
live.set(root.rootId, state);
|
|
960
|
-
if (started && watch)
|
|
1082
|
+
if (started && watch) {
|
|
1083
|
+
state.watchEnabled = true;
|
|
1084
|
+
resolveCoverage();
|
|
1085
|
+
}
|
|
961
1086
|
return state;
|
|
962
1087
|
}
|
|
963
1088
|
|
|
@@ -968,6 +1093,9 @@ function createObserver(options) {
|
|
|
968
1093
|
state.addressWatcher?.close();
|
|
969
1094
|
if (state.settleTimer) clearTimeout(state.settleTimer);
|
|
970
1095
|
live.delete(rootId);
|
|
1096
|
+
// A departed owner promotes whoever it covered (stop() sets
|
|
1097
|
+
// started=false first, so teardown never reopens handles).
|
|
1098
|
+
resolveCoverage();
|
|
971
1099
|
}
|
|
972
1100
|
|
|
973
1101
|
return {
|
|
@@ -1164,6 +1292,27 @@ function createObserver(options) {
|
|
|
1164
1292
|
}
|
|
1165
1293
|
},
|
|
1166
1294
|
|
|
1295
|
+
/**
|
|
1296
|
+
* Open and prove a root's doorbells WITHOUT scanning — the
|
|
1297
|
+
* registration boundary's "watcher first" step. The watcher is born
|
|
1298
|
+
* (or refuses to be, visibly), coverage is resolved, and its dings
|
|
1299
|
+
* are HELD: nothing is scanned, published, or recorded until
|
|
1300
|
+
* start(rootId) releases the hold — whose initial scan and census
|
|
1301
|
+
* are the catch-up for anything heard meanwhile. Returns the root's
|
|
1302
|
+
* coverage-aware status row so the caller can refuse BEFORE any
|
|
1303
|
+
* identity commits.
|
|
1304
|
+
*/
|
|
1305
|
+
watchNow(rootId) {
|
|
1306
|
+
started = true; // watching has begun for this runtime
|
|
1307
|
+
const root = store.listRoots().find((row) => row.rootId === rootId);
|
|
1308
|
+
if (!root) throw new Error(`watchNow requires a live root: ${rootId}`);
|
|
1309
|
+
const state = attach(root, { watch: false });
|
|
1310
|
+
state.held = true;
|
|
1311
|
+
state.watchEnabled = true;
|
|
1312
|
+
resolveCoverage();
|
|
1313
|
+
return statusOf(root);
|
|
1314
|
+
},
|
|
1315
|
+
|
|
1167
1316
|
start(rootId = null) {
|
|
1168
1317
|
const firstStart = !started;
|
|
1169
1318
|
if (firstStart) started = true;
|
|
@@ -1173,8 +1322,12 @@ function createObserver(options) {
|
|
|
1173
1322
|
: store.listRoots().filter((root) => root.rootId === rootId);
|
|
1174
1323
|
// Watch begins after registration. The initial scan makes the fresh
|
|
1175
1324
|
// structural tree concrete; changes during registration are outside
|
|
1176
|
-
// this operation's contract and are not reconstructed here
|
|
1177
|
-
|
|
1325
|
+
// this operation's contract and are not reconstructed here — held
|
|
1326
|
+
// doorbells (watchNow) release into that same catch-up.
|
|
1327
|
+
for (const root of roots) {
|
|
1328
|
+
const state = attach(root, { watch: true });
|
|
1329
|
+
state.held = false;
|
|
1330
|
+
}
|
|
1178
1331
|
try {
|
|
1179
1332
|
for (const root of roots) scanNow(root.rootId);
|
|
1180
1333
|
} catch (error) {
|
|
@@ -1188,18 +1341,7 @@ function createObserver(options) {
|
|
|
1188
1341
|
},
|
|
1189
1342
|
|
|
1190
1343
|
status() {
|
|
1191
|
-
return store.listRoots().map((root) =>
|
|
1192
|
-
const state = live.get(root.rootId);
|
|
1193
|
-
return {
|
|
1194
|
-
...root,
|
|
1195
|
-
watching: Boolean(state?.watcher) && !state.watcher.degraded,
|
|
1196
|
-
degraded: Boolean(state?.watcher?.degraded),
|
|
1197
|
-
addressRequired: root.kind !== 'file' && !enclosed(root),
|
|
1198
|
-
addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
|
|
1199
|
-
addressDegraded: Boolean(state?.addressWatcher?.degraded),
|
|
1200
|
-
files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
|
|
1201
|
-
};
|
|
1202
|
-
});
|
|
1344
|
+
return store.listRoots().map((root) => statusOf(root));
|
|
1203
1345
|
},
|
|
1204
1346
|
};
|
|
1205
1347
|
}
|