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
package/package.json
CHANGED
|
@@ -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) {
|
|
@@ -588,8 +592,9 @@ function createObserver(options) {
|
|
|
588
592
|
censusRepoRoot(root);
|
|
589
593
|
// Catch-up semantics: the observer cannot know what happened to a
|
|
590
594
|
// repo while nobody watched; the follower's ids turn "maybe" into
|
|
591
|
-
// silence, so an unconditional doorbell is the honest answer
|
|
592
|
-
|
|
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 });
|
|
593
598
|
}
|
|
594
599
|
}
|
|
595
600
|
// A scan that leaves no look pending resets the backoff: the
|
|
@@ -613,6 +618,10 @@ function createObserver(options) {
|
|
|
613
618
|
function ring(root, relPath) {
|
|
614
619
|
const state = live.get(root.rootId);
|
|
615
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;
|
|
616
625
|
state.retryMs = settleMs; // new evidence resets a backoff
|
|
617
626
|
|
|
618
627
|
if (state.kind === 'file') {
|
|
@@ -661,6 +670,7 @@ function createObserver(options) {
|
|
|
661
670
|
// Only absence buries — and an unsettled look keeps the news
|
|
662
671
|
// pending: the retry looks again, no second doorbell needed.
|
|
663
672
|
if (childTruth === 'unsettled') armRetry(root, state);
|
|
673
|
+
else forwardToTerritory(root, territory, relPath);
|
|
664
674
|
return;
|
|
665
675
|
}
|
|
666
676
|
const known = store.rootByPath(childPath);
|
|
@@ -744,17 +754,22 @@ function createObserver(options) {
|
|
|
744
754
|
}
|
|
745
755
|
}
|
|
746
756
|
// Forwarded raw and instantly: quieting is the follower's job, and a
|
|
747
|
-
// 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.
|
|
748
762
|
const areas = relPath === null
|
|
749
763
|
? ['git', 'worktree']
|
|
750
764
|
: relPath === '.git' || relPath.startsWith('.git/') ? ['git'] : ['worktree'];
|
|
751
|
-
publish({ type: 'repo.changed', rootId: root.rootId, areas });
|
|
765
|
+
publish({ type: 'repo.changed', rootId: root.rootId, areas, hint: relPath });
|
|
752
766
|
return;
|
|
753
767
|
}
|
|
754
768
|
|
|
755
|
-
// A child
|
|
756
|
-
//
|
|
757
|
-
//
|
|
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.
|
|
758
773
|
const filesTerritory = relPath === null ? null : coveringTerritory(state, relPath);
|
|
759
774
|
if (filesTerritory !== null) {
|
|
760
775
|
const territoryTruth = gitDirTruth(path.join(root.path, filesTerritory));
|
|
@@ -762,7 +777,10 @@ function createObserver(options) {
|
|
|
762
777
|
armRetry(root, state); // no conclusion from a failed look — the retry decides
|
|
763
778
|
return;
|
|
764
779
|
}
|
|
765
|
-
if (territoryTruth === 'present')
|
|
780
|
+
if (territoryTruth === 'present') {
|
|
781
|
+
forwardToTerritory(root, filesTerritory, relPath);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
766
784
|
}
|
|
767
785
|
// Enrollment policy applies to doorbells too — except .git itself: it is
|
|
768
786
|
// never enrollable, but its appearance is territory news and must scan.
|
|
@@ -779,6 +797,19 @@ function createObserver(options) {
|
|
|
779
797
|
armSettle(root, state);
|
|
780
798
|
}
|
|
781
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
|
+
|
|
782
813
|
function openWatcher(root) {
|
|
783
814
|
// A single-file root's doorbell lives on its parent directory: a
|
|
784
815
|
// watcher on the file itself would follow the inode and go deaf on
|
|
@@ -809,6 +840,34 @@ function createObserver(options) {
|
|
|
809
840
|
);
|
|
810
841
|
}
|
|
811
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
|
+
|
|
812
871
|
/** A root enclosed by watched ground has its address observed already. */
|
|
813
872
|
function enclosed(root) {
|
|
814
873
|
return store.listRoots().some((other) =>
|
|
@@ -925,29 +984,81 @@ function createObserver(options) {
|
|
|
925
984
|
const state = live.get(root.rootId);
|
|
926
985
|
if (!state || !started) return;
|
|
927
986
|
state.watcher = openWatcher(root);
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
+
}
|
|
932
996
|
}
|
|
933
997
|
}
|
|
934
998
|
|
|
935
|
-
|
|
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) {
|
|
936
1007
|
if (!state.watcher || state.watcher.degraded) {
|
|
937
1008
|
state.watcher?.close();
|
|
938
1009
|
state.watcher = openWatcher(root);
|
|
939
1010
|
}
|
|
940
|
-
|
|
941
|
-
|
|
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) {
|
|
942
1023
|
state.addressWatcher?.close();
|
|
943
1024
|
state.addressWatcher = openAddressWatcher(root);
|
|
944
1025
|
}
|
|
945
1026
|
}
|
|
946
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
|
+
|
|
947
1055
|
function attach(root, { watch = true } = {}) {
|
|
948
1056
|
const existing = live.get(root.rootId);
|
|
949
1057
|
if (existing) {
|
|
950
|
-
if (started && watch)
|
|
1058
|
+
if (started && watch) {
|
|
1059
|
+
existing.watchEnabled = true;
|
|
1060
|
+
resolveCoverage();
|
|
1061
|
+
}
|
|
951
1062
|
return existing;
|
|
952
1063
|
}
|
|
953
1064
|
const state = {
|
|
@@ -960,12 +1071,18 @@ function createObserver(options) {
|
|
|
960
1071
|
dirtyPaths: new Set(),
|
|
961
1072
|
checkAll: false,
|
|
962
1073
|
territories: new Set(),
|
|
1074
|
+
watchEnabled: false,
|
|
1075
|
+
coveredBy: null,
|
|
1076
|
+
held: false,
|
|
963
1077
|
};
|
|
964
1078
|
for (const child of childRepoRoots(root)) {
|
|
965
1079
|
state.territories.add(child.path.slice(root.path.length + 1));
|
|
966
1080
|
}
|
|
967
1081
|
live.set(root.rootId, state);
|
|
968
|
-
if (started && watch)
|
|
1082
|
+
if (started && watch) {
|
|
1083
|
+
state.watchEnabled = true;
|
|
1084
|
+
resolveCoverage();
|
|
1085
|
+
}
|
|
969
1086
|
return state;
|
|
970
1087
|
}
|
|
971
1088
|
|
|
@@ -976,6 +1093,9 @@ function createObserver(options) {
|
|
|
976
1093
|
state.addressWatcher?.close();
|
|
977
1094
|
if (state.settleTimer) clearTimeout(state.settleTimer);
|
|
978
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();
|
|
979
1099
|
}
|
|
980
1100
|
|
|
981
1101
|
return {
|
|
@@ -1172,6 +1292,27 @@ function createObserver(options) {
|
|
|
1172
1292
|
}
|
|
1173
1293
|
},
|
|
1174
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
|
+
|
|
1175
1316
|
start(rootId = null) {
|
|
1176
1317
|
const firstStart = !started;
|
|
1177
1318
|
if (firstStart) started = true;
|
|
@@ -1181,8 +1322,12 @@ function createObserver(options) {
|
|
|
1181
1322
|
: store.listRoots().filter((root) => root.rootId === rootId);
|
|
1182
1323
|
// Watch begins after registration. The initial scan makes the fresh
|
|
1183
1324
|
// structural tree concrete; changes during registration are outside
|
|
1184
|
-
// this operation's contract and are not reconstructed here
|
|
1185
|
-
|
|
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
|
+
}
|
|
1186
1331
|
try {
|
|
1187
1332
|
for (const root of roots) scanNow(root.rootId);
|
|
1188
1333
|
} catch (error) {
|
|
@@ -1196,18 +1341,7 @@ function createObserver(options) {
|
|
|
1196
1341
|
},
|
|
1197
1342
|
|
|
1198
1343
|
status() {
|
|
1199
|
-
return store.listRoots().map((root) =>
|
|
1200
|
-
const state = live.get(root.rootId);
|
|
1201
|
-
return {
|
|
1202
|
-
...root,
|
|
1203
|
-
watching: Boolean(state?.watcher) && !state.watcher.degraded,
|
|
1204
|
-
degraded: Boolean(state?.watcher?.degraded),
|
|
1205
|
-
addressRequired: root.kind !== 'file' && !enclosed(root),
|
|
1206
|
-
addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
|
|
1207
|
-
addressDegraded: Boolean(state?.addressWatcher?.degraded),
|
|
1208
|
-
files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
|
|
1209
|
-
};
|
|
1210
|
-
});
|
|
1344
|
+
return store.listRoots().map((root) => statusOf(root));
|
|
1211
1345
|
},
|
|
1212
1346
|
};
|
|
1213
1347
|
}
|
|
@@ -23,13 +23,17 @@ function install(dirPath, recursive, onDirty, onDead) {
|
|
|
23
23
|
watcher = fs.watch(dirPath, { recursive, persistent: false }, (_event, filename) => {
|
|
24
24
|
onDirty(filename ? String(filename).split('\\').join('/') : null);
|
|
25
25
|
});
|
|
26
|
-
} catch {
|
|
26
|
+
} catch (error) {
|
|
27
27
|
handle.degraded = true;
|
|
28
|
+
// WHY the birth failed travels with the handle: ENOSPC (the kernel's
|
|
29
|
+
// file-watch budget) earns the caller an actionable message, not a shrug.
|
|
30
|
+
handle.reason = error?.code || 'unknown';
|
|
28
31
|
return handle;
|
|
29
32
|
}
|
|
30
|
-
watcher.on('error', () => {
|
|
33
|
+
watcher.on('error', (error) => {
|
|
31
34
|
if (handle.degraded) return; // dead is dead: one death, one report
|
|
32
35
|
handle.degraded = true;
|
|
36
|
+
handle.reason = error?.code || 'unknown';
|
|
33
37
|
try { watcher.close(); } catch { /* already dead */ }
|
|
34
38
|
if (onDead) onDead();
|
|
35
39
|
});
|
|
@@ -92,6 +92,31 @@ function assertRecord(value) {
|
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The repo travel law: a record with a `repo.git` PROPER ancestor never
|
|
97
|
+
* travels to the cloud on its own. The repository is the sync boundary —
|
|
98
|
+
* its one Card + Checkpoint state carries the worktree's bytes AND its
|
|
99
|
+
* children's permanent UUIDs (the transport identity map), so a register
|
|
100
|
+
* or a rebase is one parcel, never one postcard per file. A nested
|
|
101
|
+
* repository inside another repo's worktree is enclosed too: it becomes
|
|
102
|
+
* cloud-visible only when registered explicitly as its own tree.
|
|
103
|
+
* Missing ancestry keeps the record traveling rather than risking a
|
|
104
|
+
* silently unsynced entity.
|
|
105
|
+
*/
|
|
106
|
+
function enclosedByRepositoryAncestor(record, lookup) {
|
|
107
|
+
const seen = new Set([record.uuid]);
|
|
108
|
+
let parentUUID = record.parentUUID;
|
|
109
|
+
while (parentUUID !== null && parentUUID !== undefined) {
|
|
110
|
+
if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
|
|
111
|
+
seen.add(parentUUID);
|
|
112
|
+
const parent = lookup(parentUUID);
|
|
113
|
+
if (!parent) return false;
|
|
114
|
+
if (parent.type === 'repo.git') return true;
|
|
115
|
+
parentUUID = parent.parentUUID;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
function snapshotFromRecords(records) {
|
|
96
121
|
const normalized = records.map(assertRecord).sort((left, right) => left.uuid.localeCompare(right.uuid));
|
|
97
122
|
if (new Set(normalized.map((record) => record.uuid)).size !== normalized.length) {
|
|
@@ -180,28 +205,14 @@ function createEntityCloud({
|
|
|
180
205
|
return fallback ? cloudRecord(fallback) : null;
|
|
181
206
|
};
|
|
182
207
|
|
|
183
|
-
function enclosedByRepository(record) {
|
|
184
|
-
const seen = new Set([record.uuid]);
|
|
185
|
-
let parentUUID = record.parentUUID;
|
|
186
|
-
while (parentUUID !== null) {
|
|
187
|
-
if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
|
|
188
|
-
seen.add(parentUUID);
|
|
189
|
-
const parent = lookup(parentUUID);
|
|
190
|
-
// Missing ancestry retains content rather than risking an ordinary
|
|
191
|
-
// file whose only bytes were silently skipped.
|
|
192
|
-
if (!parent) return false;
|
|
193
|
-
if (parent.type === 'repo.git') return true;
|
|
194
|
-
parentUUID = parent.parentUUID;
|
|
195
|
-
}
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
208
|
const heads = [];
|
|
200
209
|
const seen = new Set();
|
|
201
210
|
for (const record of normalized) {
|
|
202
211
|
const contentHash = contentHead(record);
|
|
203
212
|
if (!contentHash) continue;
|
|
204
|
-
|
|
213
|
+
// Missing ancestry retains content rather than risking an ordinary
|
|
214
|
+
// file whose only bytes were silently skipped.
|
|
215
|
+
if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepositoryAncestor(record, lookup)) continue;
|
|
205
216
|
if (seen.has(contentHash)) continue;
|
|
206
217
|
seen.add(contentHash);
|
|
207
218
|
heads.push({ record, contentHash });
|
|
@@ -432,6 +443,14 @@ function createEntityCloud({
|
|
|
432
443
|
// Bootstrap roots are included in the first immutable snapshot. No
|
|
433
444
|
// unscoped row is written before the cloud replica exists.
|
|
434
445
|
if (!current || current.state !== 'active') return;
|
|
446
|
+
// The repo travel law: a record enclosed by a repository never mails
|
|
447
|
+
// its own postcard. The repo's one settled state is the parcel — the
|
|
448
|
+
// transport carries the worktree's bytes and its children's UUIDs, so
|
|
449
|
+
// a 14k-file repo register (or a 300-file rebase) is ONE mutation on
|
|
450
|
+
// the repo entity, never thousands of child mutations.
|
|
451
|
+
if (enclosedByRepositoryAncestor(record, (uuid) => (typeof recordAt === 'function' ? recordAt(uuid) : null))) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
435
454
|
// The SQLite mutation is the durable delivery intent. Its post-commit
|
|
436
455
|
// content drain creates the filesystem upload cursor; doing that here
|
|
437
456
|
// would let a rolled-back identity write upload unreachable bytes.
|
|
@@ -470,7 +489,15 @@ function createEntityCloud({
|
|
|
470
489
|
if (Number(unsettled.count) !== 0) {
|
|
471
490
|
throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
|
|
472
491
|
}
|
|
473
|
-
|
|
492
|
+
// The repo travel law applies to the atomic graph too: repo children
|
|
493
|
+
// stay out of the snapshot, so a workspace of large repositories
|
|
494
|
+
// publishes a graph proportional to its traveling records — the
|
|
495
|
+
// repos' bytes and child identity ride each repo's transport.
|
|
496
|
+
const byUuid = new Map(records.map((candidate) => [candidate.uuid, candidate]));
|
|
497
|
+
const traveling = records.filter((candidate) => (
|
|
498
|
+
!enclosedByRepositoryAncestor(candidate, (uuid) => byUuid.get(uuid) || null)
|
|
499
|
+
));
|
|
500
|
+
const snapshot = cloudSnapshot(traveling);
|
|
474
501
|
const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
|
|
475
502
|
const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
|
|
476
503
|
if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
|
|
@@ -671,7 +698,14 @@ function createEntityCloud({
|
|
|
671
698
|
}
|
|
672
699
|
if (!bootstrapping) {
|
|
673
700
|
const cloudByUuid = new Map(snapshot.records.map((record) => [record.uuid, record]));
|
|
674
|
-
|
|
701
|
+
const locals = registry.syncRecords();
|
|
702
|
+
const localByUuid = new Map(locals.map((record) => [record.uuid, record]));
|
|
703
|
+
for (const local of locals) {
|
|
704
|
+
// Repo-enclosed local records evolve locally (Detect corrects
|
|
705
|
+
// their types and payloads) without journaling; a snapshot from
|
|
706
|
+
// before the repo travel law may still carry their stale rows.
|
|
707
|
+
// They are not cloud truth, so they cannot veto an install.
|
|
708
|
+
if (enclosedByRepositoryAncestor(local, (uuid) => localByUuid.get(uuid) || null)) continue;
|
|
675
709
|
const remote = cloudByUuid.get(local.uuid);
|
|
676
710
|
if (remote && !sameRecord(cloudRecord(local), remote)) {
|
|
677
711
|
throw new Error('cloud entity graph conflicts with locally bound ground; resolve the local mutation before importing a new cloud head');
|
|
@@ -1196,6 +1230,7 @@ module.exports = {
|
|
|
1196
1230
|
ENTITY_CLOUD_CONTRACT,
|
|
1197
1231
|
ENTITY_CLOUD_SCHEMA_VERSION,
|
|
1198
1232
|
createEntityCloud,
|
|
1233
|
+
enclosedByRepositoryAncestor,
|
|
1199
1234
|
parseSnapshot,
|
|
1200
1235
|
privateEntityResourceId,
|
|
1201
1236
|
snapshotFromRecords,
|