amalgm 0.1.245 → 0.1.246
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/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,1146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The observer: the single place where a local change is born.
|
|
5
|
+
*
|
|
6
|
+
* Wiring only — watch.js rings, scan.js compares, store.js remembers, and
|
|
7
|
+
* this file turns compare results into events. A doorbell never emits
|
|
8
|
+
* anything itself; it only makes the next scan happen sooner (axiom 3).
|
|
9
|
+
*
|
|
10
|
+
* Territory: a .git claims its subtree. Files territory gets channels and
|
|
11
|
+
* hashes — directories included: a directory is a channel with physical
|
|
12
|
+
* identity and no content of its own, so folder renames survive by inode
|
|
13
|
+
* exactly like file renames, and empty folders exist the moment disk holds
|
|
14
|
+
* them. Repo territory gets a raw, instant doorbell (`repo.changed`) — the
|
|
15
|
+
* observer never editorializes repo events, because quieting and identity
|
|
16
|
+
* belong to the follower (repocard/follow.js), and a delayed doorbell would
|
|
17
|
+
* blind its torn-read guard. Territory is decided by disk, not by callers:
|
|
18
|
+
* a .git appearing anywhere converts the ground under it, and its
|
|
19
|
+
* disappearance converts it back.
|
|
20
|
+
*
|
|
21
|
+
* Nested repositories are territories too: a repo inside a repo is simply
|
|
22
|
+
* another repo. A census at lifecycle moments (enrollment, conversion,
|
|
23
|
+
* catch-up scans) plus doorbells live make each nested boundary its own
|
|
24
|
+
* root with its own watcher; the parent stays silent for a child's ground.
|
|
25
|
+
* A doorbell's filename never proves anything — it only says where to
|
|
26
|
+
* look, and disk decides what it means (a repo moved in whole rings only
|
|
27
|
+
* the directory that landed, never a path spelling ".git").
|
|
28
|
+
*
|
|
29
|
+
* Enrollment is amalgm's OWN policy (`shouldEnroll`), applied uniformly to
|
|
30
|
+
* files territory and repo boundaries alike. Git's ignore rules never
|
|
31
|
+
* decide it (entity axiom 2, docs/entity-model.md): a gitignored worktree
|
|
32
|
+
* or nested repo in enrolled ground is a territory; a repo inside
|
|
33
|
+
* amalgm-excluded ground (node_modules, caches) is not.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const fs = require('fs');
|
|
38
|
+
const path = require('path');
|
|
39
|
+
|
|
40
|
+
const { createApply } = require('./apply');
|
|
41
|
+
const { createContinuity } = require('./continuity');
|
|
42
|
+
const { createStore } = require('./store');
|
|
43
|
+
const { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot } = require('./scan');
|
|
44
|
+
const { createVerify } = require('./verify');
|
|
45
|
+
const { watchDir, watchRecursive } = require('./watch');
|
|
46
|
+
const { adapterFor } = require('../adapters');
|
|
47
|
+
const { lookFailed } = require('../adapters/truth');
|
|
48
|
+
|
|
49
|
+
// The production stitch (ratified 2026-08-02): the observer's content
|
|
50
|
+
// decision reads file truth through the adapter socket — the same
|
|
51
|
+
// three-state answer every surface speaks. Both file kinds declare the
|
|
52
|
+
// same truth (bytes), so either selects the one filesystem adapter;
|
|
53
|
+
// the text/binary split is classification policy downstream.
|
|
54
|
+
const fileTruth = adapterFor('file.binary').rails.detect.truth;
|
|
55
|
+
|
|
56
|
+
const DEFAULT_SETTLE_MS = 200;
|
|
57
|
+
// Surviving suspicion backs off exponentially from settleMs to here:
|
|
58
|
+
// permanently unsettled ground costs a heartbeat, never a hot loop.
|
|
59
|
+
const RETRY_CAP_MS = 60_000;
|
|
60
|
+
|
|
61
|
+
function defaultShouldEnroll(relPath) {
|
|
62
|
+
return require('../workspace/tree/inclusion').shouldEnroll(relPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createObserver(options) {
|
|
66
|
+
const {
|
|
67
|
+
db,
|
|
68
|
+
emit,
|
|
69
|
+
shouldEnroll = defaultShouldEnroll,
|
|
70
|
+
settleMs = DEFAULT_SETTLE_MS,
|
|
71
|
+
// The entity-content cache is injected by the registration composition
|
|
72
|
+
// root. Detection still owns the observed hash; this hook only retains
|
|
73
|
+
// the exact bytes behind that hash for the later cloud rail.
|
|
74
|
+
captureContent = null,
|
|
75
|
+
// The doorbell implementation. Injectable so tests can ring through the
|
|
76
|
+
// production wiring what fs.watch rarely delivers (a null filename).
|
|
77
|
+
watch = watchRecursive,
|
|
78
|
+
} = options;
|
|
79
|
+
if (!db) throw new Error('createObserver requires { db }');
|
|
80
|
+
if (typeof emit !== 'function') throw new Error('createObserver requires { emit }');
|
|
81
|
+
|
|
82
|
+
const store = createStore(db);
|
|
83
|
+
// rootId -> { kind, watcher, settleTimer, dirtyPaths, checkAll, territories }
|
|
84
|
+
const live = new Map();
|
|
85
|
+
let started = false;
|
|
86
|
+
|
|
87
|
+
function publish(event) {
|
|
88
|
+
try {
|
|
89
|
+
emit(event);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn('[Observer] emit failed:', error?.message || error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A file's settled bytes right now, through the adapter socket —
|
|
96
|
+
* their fingerprint AND their kind, from the one read that produced
|
|
97
|
+
* both — or null when its truth is absent, unsettled, or no longer a
|
|
98
|
+
* file's. For the writer's own record (noteWrite), settled-or-null
|
|
99
|
+
* is the whole question; the SCAN's content read is scanContent
|
|
100
|
+
* below, where unsettled suspicion survives, and the referee
|
|
101
|
+
* (verify) reads the three states directly. */
|
|
102
|
+
function safeContent(absPath) {
|
|
103
|
+
const read = fileTruth(absPath);
|
|
104
|
+
if (read.truth !== 'present' || read.state.kind !== 'file') return null;
|
|
105
|
+
try {
|
|
106
|
+
captureContent?.({ filePath: absPath, contentHash: read.state.sha256 });
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
return { contentHash: read.state.sha256, binary: read.state.binary };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One pending look per root, earliest due wins: a doorbell asks at
|
|
114
|
+
* settleMs and a surviving suspicion asks at its backed-off delay —
|
|
115
|
+
* a shorter ask replaces a longer wait (new evidence resets a
|
|
116
|
+
* backoff), while a pending sooner look absorbs later asks (rings
|
|
117
|
+
* inside the settle window still batch into one scan). */
|
|
118
|
+
function armSettle(root, state, delay = settleMs) {
|
|
119
|
+
const due = Date.now() + delay;
|
|
120
|
+
if (state.settleTimer) {
|
|
121
|
+
if (due >= state.settleDue) return;
|
|
122
|
+
clearTimeout(state.settleTimer);
|
|
123
|
+
}
|
|
124
|
+
state.settleDue = due;
|
|
125
|
+
state.settleTimer = setTimeout(() => {
|
|
126
|
+
state.settleTimer = null;
|
|
127
|
+
try {
|
|
128
|
+
scanNow(root.rootId);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.warn(`[Observer] scan failed for "${root.path}":`, error?.message || error);
|
|
131
|
+
}
|
|
132
|
+
}, delay);
|
|
133
|
+
state.settleTimer.unref?.();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one scheduling rule for suspicion that survives a scan:
|
|
137
|
+
* retries coalesce on the root's single pending look and back off
|
|
138
|
+
* exponentially toward RETRY_CAP_MS. Any new doorbell evidence
|
|
139
|
+
* resets the delay — ring() asks at settleMs, which replaces a
|
|
140
|
+
* longer wait in armSettle. */
|
|
141
|
+
function armRetry(root, state) {
|
|
142
|
+
if (state.settleTimer) return; // coalesce: one escalation per armed look
|
|
143
|
+
const delay = state.retryMs;
|
|
144
|
+
state.retryMs = Math.min(RETRY_CAP_MS, state.retryMs * 2);
|
|
145
|
+
armSettle(root, state, delay);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The scan's content read, all three truths honored: settled bytes
|
|
149
|
+
* for a present file; null when the ground is absent or no longer a
|
|
150
|
+
* file's — the walk's next look decides; and on UNSETTLED the
|
|
151
|
+
* suspicion SURVIVES: the path returns to the dirty set and the
|
|
152
|
+
* retry re-arms, so the read that reaches present or absent is
|
|
153
|
+
* scheduled by the observer itself — an unsettled read means "read
|
|
154
|
+
* again", and detection is who reads again. One transiently
|
|
155
|
+
* unreadable moment can never swallow a change. */
|
|
156
|
+
function scanContent(root, relPath) {
|
|
157
|
+
const read = fileTruth(path.join(root.path, relPath));
|
|
158
|
+
if (read.truth === 'unsettled') {
|
|
159
|
+
const state = live.get(root.rootId);
|
|
160
|
+
if (state) {
|
|
161
|
+
state.dirtyPaths.add(relPath);
|
|
162
|
+
armRetry(root, state);
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (read.truth !== 'present' || read.state.kind !== 'file') return null;
|
|
167
|
+
try {
|
|
168
|
+
captureContent?.({ filePath: path.join(root.path, relPath), contentHash: read.state.sha256 });
|
|
169
|
+
} catch {
|
|
170
|
+
// A file that changed between the observer's hash and the immutable
|
|
171
|
+
// capture is not a stable cloud head yet. Preserve suspicion and read
|
|
172
|
+
// again; recording the hash without its bytes would create a dangling
|
|
173
|
+
// cloud entity head.
|
|
174
|
+
const state = live.get(root.rootId);
|
|
175
|
+
if (state) {
|
|
176
|
+
state.dirtyPaths.add(relPath);
|
|
177
|
+
armRetry(root, state);
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return { contentHash: read.state.sha256, binary: read.state.binary };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Symlinks are never followed, matching the walks. */
|
|
185
|
+
function isDirectory(absPath) {
|
|
186
|
+
try {
|
|
187
|
+
return fs.lstatSync(absPath).isDirectory();
|
|
188
|
+
} catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Compare results become truth in apply.js — events + store rows,
|
|
194
|
+
// deterministic order, per channel kind. The content read is injected:
|
|
195
|
+
// scanContent keeps unsettled suspicion on the root's retry, above.
|
|
196
|
+
const { applyDiff, applyDirDiff, applyLinkDiff } = createApply({ store, publish, scanContent });
|
|
197
|
+
|
|
198
|
+
function childRepoRoots(filesRoot) {
|
|
199
|
+
return store.listRoots().filter(
|
|
200
|
+
(root) => root.kind === 'repo' && root.path.startsWith(`${filesRoot.path}/`),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Make the enrolled repo roots match the territories the walk reported:
|
|
206
|
+
* a new .git births a repo root, a vanished one dies. The files walk
|
|
207
|
+
* already skipped the claimed subtrees, so channels and territories can
|
|
208
|
+
* never overlap.
|
|
209
|
+
*/
|
|
210
|
+
function reconcileTerritories(root, state, repoDirs) {
|
|
211
|
+
const claimed = new Set(repoDirs);
|
|
212
|
+
for (const relDir of repoDirs) {
|
|
213
|
+
const childPath = path.join(root.path, relDir);
|
|
214
|
+
if (store.rootByPath(childPath)) continue;
|
|
215
|
+
birthRepo(childPath);
|
|
216
|
+
}
|
|
217
|
+
for (const child of childRepoRoots(root)) {
|
|
218
|
+
const relDir = child.path.slice(root.path.length + 1);
|
|
219
|
+
if (claimed.has(relDir)) continue;
|
|
220
|
+
const territory = gitDirTruth(child.path);
|
|
221
|
+
if (territory !== 'absent') {
|
|
222
|
+
claimed.add(relDir); // deeper than the walk descends, or unreadable — only absence buries
|
|
223
|
+
if (territory === 'unsettled' && state) armRetry(root, state); // and unsettled stays pending
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
detach(child.rootId);
|
|
227
|
+
store.removeRoot(child.rootId);
|
|
228
|
+
publish({ type: 'repo.removed', rootId: child.rootId, path: child.path });
|
|
229
|
+
}
|
|
230
|
+
if (state) state.territories = claimed;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Birth a repo territory: enroll, watch, announce, census its own children.
|
|
235
|
+
*
|
|
236
|
+
* TEMPORARY IDENTITY CONTRACT: minting a fresh rootId here (and deleting
|
|
237
|
+
* it in removeRepoChild) is internal behavior, not the model. Under the
|
|
238
|
+
* entity axioms (docs/entity-model.md) a nested folder ↔ repo change is a
|
|
239
|
+
* TYPE TRANSITION on one permanent UUID; that lands with folder entities.
|
|
240
|
+
* Standalone roots already behave correctly (becomeRepo/becomeFiles keep
|
|
241
|
+
* their rootId) — do not build on the nested births' identity churn.
|
|
242
|
+
*/
|
|
243
|
+
function birthRepo(childPath) {
|
|
244
|
+
let stat = null;
|
|
245
|
+
try {
|
|
246
|
+
stat = fs.lstatSync(childPath);
|
|
247
|
+
} catch {
|
|
248
|
+
stat = null; // vanished mid-birth; identity arrives with the next census
|
|
249
|
+
}
|
|
250
|
+
const child = {
|
|
251
|
+
rootId: crypto.randomUUID(), path: childPath, kind: 'repo',
|
|
252
|
+
device: stat?.dev ?? null, inode: stat?.ino ?? null,
|
|
253
|
+
};
|
|
254
|
+
store.addRoot(child);
|
|
255
|
+
attach(child);
|
|
256
|
+
publish({ type: 'repo.discovered', rootId: child.rootId, path: child.path });
|
|
257
|
+
censusRepoRoot(child);
|
|
258
|
+
return child;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function removeRepoChild(child) {
|
|
262
|
+
detach(child.rootId);
|
|
263
|
+
store.removeRoot(child.rootId);
|
|
264
|
+
publish({ type: 'repo.removed', rootId: child.rootId, path: child.path });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Nested repositories are territories too — a repo inside a repo is just
|
|
269
|
+
* another repo. Census the ground for `.git` boundaries, birth the
|
|
270
|
+
* missing, bury the dead. Runs at lifecycle moments (enrollment,
|
|
271
|
+
* conversion, catch-up scans), never on a timer; between moments the
|
|
272
|
+
* `.git` doorbells in ring() carry the news live.
|
|
273
|
+
*/
|
|
274
|
+
function censusRepoRoot(root) {
|
|
275
|
+
const state = live.get(root.rootId);
|
|
276
|
+
const { boundaries, unsettled } = censusBoundaries(root.path, shouldEnroll);
|
|
277
|
+
const claimed = new Set(boundaries);
|
|
278
|
+
for (const relDir of boundaries) {
|
|
279
|
+
const childPath = path.join(root.path, relDir);
|
|
280
|
+
if (!store.rootByPath(childPath)) birthRepo(childPath);
|
|
281
|
+
}
|
|
282
|
+
for (const child of store.listRoots()) {
|
|
283
|
+
if (child.kind !== 'repo' || child.rootId === root.rootId) continue;
|
|
284
|
+
if (!child.path.startsWith(`${root.path}/`)) continue;
|
|
285
|
+
const relDir = child.path.slice(root.path.length + 1);
|
|
286
|
+
if (claimed.has(relDir)) continue;
|
|
287
|
+
if ([...claimed].some((owned) => relDir.startsWith(`${owned}/`))) continue; // a child's child
|
|
288
|
+
const territory = gitDirTruth(child.path);
|
|
289
|
+
if (territory !== 'absent') {
|
|
290
|
+
claimed.add(relDir); // existence wins — and only absence buries
|
|
291
|
+
if (territory === 'unsettled' && state) armRetry(root, state);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
removeRepoChild(child);
|
|
295
|
+
}
|
|
296
|
+
// Unlisted ground may hide a boundary the census could not see:
|
|
297
|
+
// suspicion stays pending until the whole ground can be read.
|
|
298
|
+
if (unsettled.length > 0 && state) armRetry(root, state);
|
|
299
|
+
if (state) state.territories = claimed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The root itself grew a .git: every channel closes, one repo is born. */
|
|
303
|
+
function becomeRepo(root, state) {
|
|
304
|
+
const rows = store.filesForRoot(root.rootId)
|
|
305
|
+
.sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
|
|
306
|
+
for (const row of rows) {
|
|
307
|
+
store.removeFile(row.fileId);
|
|
308
|
+
publish({ type: 'file.deleted', rootId: root.rootId, fileId: row.fileId, path: row.relPath });
|
|
309
|
+
}
|
|
310
|
+
for (const row of store.linksForRoot(root.rootId)) {
|
|
311
|
+
store.removeLink(row.linkId);
|
|
312
|
+
publish({ type: 'link.deleted', rootId: root.rootId, linkId: row.linkId, path: row.relPath });
|
|
313
|
+
}
|
|
314
|
+
for (const row of store.dirsForRoot(root.rootId)) {
|
|
315
|
+
store.removeDir(row.dirId);
|
|
316
|
+
publish({ type: 'dir.deleted', rootId: root.rootId, dirId: row.dirId, path: row.relPath });
|
|
317
|
+
}
|
|
318
|
+
store.setRootKind(root.rootId, 'repo');
|
|
319
|
+
if (state) {
|
|
320
|
+
state.kind = 'repo';
|
|
321
|
+
state.dirtyPaths.clear();
|
|
322
|
+
state.checkAll = false;
|
|
323
|
+
state.territories = new Set();
|
|
324
|
+
}
|
|
325
|
+
publish({ type: 'repo.discovered', rootId: root.rootId, path: root.path });
|
|
326
|
+
censusRepoRoot(root);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A repo root's .git is gone: the ground reverts to whoever encloses it. */
|
|
330
|
+
function becomeFiles(root, state) {
|
|
331
|
+
publish({ type: 'repo.removed', rootId: root.rootId, path: root.path });
|
|
332
|
+
const parent = store.listRoots()
|
|
333
|
+
.filter((other) => other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`))
|
|
334
|
+
.sort((a, b) => b.path.length - a.path.length)[0]; // nearest encloser decides
|
|
335
|
+
if (parent && parent.kind === 'repo') {
|
|
336
|
+
// Born inside repo territory, returned to it: the ground is the
|
|
337
|
+
// parent's worktree again, and its next capture is the reclaim.
|
|
338
|
+
detach(root.rootId);
|
|
339
|
+
store.removeRoot(root.rootId);
|
|
340
|
+
live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
|
|
341
|
+
publish({ type: 'repo.changed', rootId: parent.rootId, areas: ['worktree'] });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (parent) {
|
|
345
|
+
// Born from a files root, returned to it: the parent's next scan
|
|
346
|
+
// reclaims the files as creates.
|
|
347
|
+
detach(root.rootId);
|
|
348
|
+
store.removeRoot(root.rootId);
|
|
349
|
+
live.get(parent.rootId)?.territories.delete(root.path.slice(parent.path.length + 1));
|
|
350
|
+
scanFilesRoot(parent);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
store.setRootKind(root.rootId, 'files');
|
|
354
|
+
if (state) state.kind = 'files';
|
|
355
|
+
scanFilesRoot({ ...root, kind: 'files' });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function scanFilesRoot(root, { allowRootRepository = false } = {}) {
|
|
359
|
+
// Drain the doorbell's named hints first: anything that rings during the
|
|
360
|
+
// walk below belongs to the next scan, not this one.
|
|
361
|
+
const state = live.get(root.rootId);
|
|
362
|
+
const checkAll = Boolean(state?.checkAll);
|
|
363
|
+
const rung = state ? new Set(state.dirtyPaths) : new Set();
|
|
364
|
+
if (state) {
|
|
365
|
+
state.dirtyPaths.clear();
|
|
366
|
+
state.checkAll = false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const { files: entries, dirs, links, repoDirs, unsettled } = walkRoot(
|
|
370
|
+
root.path,
|
|
371
|
+
shouldEnroll,
|
|
372
|
+
{ allowRootRepository },
|
|
373
|
+
);
|
|
374
|
+
if (repoDirs.includes('')) {
|
|
375
|
+
becomeRepo(root, state);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
reconcileTerritories(root, state, repoDirs);
|
|
379
|
+
|
|
380
|
+
// Only present or absent may conclude: ground the walk could not
|
|
381
|
+
// read keeps every belief beneath it — a delete is a conclusion,
|
|
382
|
+
// and none may be drawn there — while the suspicion stays pending
|
|
383
|
+
// on the root's backed-off retry.
|
|
384
|
+
const underUnsettled = (relPath) =>
|
|
385
|
+
unsettled.some((dir) => dir === '' || relPath === dir || relPath.startsWith(`${dir}/`));
|
|
386
|
+
const concluded = (diff) => (unsettled.length === 0 ? diff
|
|
387
|
+
: { ...diff, deletes: diff.deletes.filter((row) => !underUnsettled(row.relPath)) });
|
|
388
|
+
if (unsettled.length > 0 && state) armRetry(root, state);
|
|
389
|
+
|
|
390
|
+
applyDirDiff(root, concluded(diffDirState(store.dirsForRoot(root.rootId), dirs)));
|
|
391
|
+
applyLinkDiff(root, concluded(diffLinkState(store.linksForRoot(root.rootId), links)));
|
|
392
|
+
const rows = store.filesForRoot(root.rootId);
|
|
393
|
+
const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
|
|
394
|
+
applyDiff(root, concluded(diffRootState(rows, entries, mustCheck)));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* A single-ground root: one channel, the same laws, the smallest ground
|
|
399
|
+
* there is. The root's path IS the ground, its relPath is '' — and disk
|
|
400
|
+
* decides the channel's kind each scan: a regular file feeds the file
|
|
401
|
+
* compare (path claims the channel, so an atomic save's inode churn is
|
|
402
|
+
* absorbed; the racily-clean stat excuse and the doorbell-named-must-hash
|
|
403
|
+
* rule apply verbatim), a symlink feeds the link compare (the written
|
|
404
|
+
* target is the whole payload, read fresh, never followed — an
|
|
405
|
+
* explicitly added pointer is a root of the pointer kind). Ground that
|
|
406
|
+
* is neither never reaches this scan: the root's own address is the
|
|
407
|
+
* evidence horizon's edge, so its absence is undecidable and freezes
|
|
408
|
+
* upstream (addressUndecided) rather than closing the channel.
|
|
409
|
+
*/
|
|
410
|
+
function scanFileRoot(root) {
|
|
411
|
+
const state = live.get(root.rootId);
|
|
412
|
+
const checkAll = Boolean(state?.checkAll);
|
|
413
|
+
const rung = state ? new Set(state.dirtyPaths) : new Set();
|
|
414
|
+
if (state) {
|
|
415
|
+
state.dirtyPaths.clear();
|
|
416
|
+
state.checkAll = false;
|
|
417
|
+
}
|
|
418
|
+
let stat = null;
|
|
419
|
+
try {
|
|
420
|
+
stat = fs.lstatSync(root.path);
|
|
421
|
+
} catch {
|
|
422
|
+
stat = null;
|
|
423
|
+
}
|
|
424
|
+
const entries = stat && stat.isFile()
|
|
425
|
+
? [{ relPath: '', device: stat.dev, inode: stat.ino, size: stat.size, mtimeMs: Math.floor(stat.mtimeMs) }]
|
|
426
|
+
: [];
|
|
427
|
+
let linkEntries = [];
|
|
428
|
+
if (stat && stat.isSymbolicLink()) {
|
|
429
|
+
try {
|
|
430
|
+
linkEntries = [{ relPath: '', device: stat.dev, inode: stat.ino, target: fs.readlinkSync(root.path) }];
|
|
431
|
+
} catch (error) {
|
|
432
|
+
// Vanished mid-read is honest absence (the empty compare below
|
|
433
|
+
// concludes it); a failed look concludes nothing — the one
|
|
434
|
+
// channel keeps its belief and the retry reads again.
|
|
435
|
+
if (lookFailed(error).truth === 'unsettled') {
|
|
436
|
+
if (state) armRetry(root, state);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
linkEntries = [];
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
applyLinkDiff(root, diffLinkState(store.linksForRoot(root.rootId), linkEntries));
|
|
443
|
+
const mustCheck = checkAll ? new Set(entries.map((entry) => entry.relPath)) : rung;
|
|
444
|
+
applyDiff(root, diffRootState(store.filesForRoot(root.rootId), entries, mustCheck));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Continuity questions and the presence bit live in continuity.js;
|
|
448
|
+
// the ACTIONS a verdict triggers (rebindRoot, decideUndecidedAddress)
|
|
449
|
+
// stay here, because they re-aim watchers and scans.
|
|
450
|
+
const {
|
|
451
|
+
addressUndecided, findMovedRoot, markAbsent, markPresent, rootIdentity,
|
|
452
|
+
} = createContinuity({ store, publish });
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The rescue: same ground, new address. The root — and every enrolled
|
|
456
|
+
* territory inside it, whose store rows record absolute paths — moves
|
|
457
|
+
* as an ADDRESS ONLY: rootId, channels, and bindings all survive, and
|
|
458
|
+
* the follow-up scan proves silence when the ground truly just moved.
|
|
459
|
+
*/
|
|
460
|
+
function rebindRoot(root, newPath) {
|
|
461
|
+
const oldPath = root.path;
|
|
462
|
+
const family = store.listRoots()
|
|
463
|
+
.filter((row) => row.rootId === root.rootId || row.path.startsWith(`${oldPath}/`));
|
|
464
|
+
// Truth first, as one transition — the whole family lands or none of
|
|
465
|
+
// it does, so a failure mid-move tears nothing. Doorbells re-aim only
|
|
466
|
+
// after truth has landed, exactly like the directory batches inside.
|
|
467
|
+
store.moveRoots(family.map((member) => ({
|
|
468
|
+
rootId: member.rootId,
|
|
469
|
+
path: member.rootId === root.rootId ? newPath : newPath + member.path.slice(oldPath.length),
|
|
470
|
+
})));
|
|
471
|
+
for (const member of family) {
|
|
472
|
+
detach(member.rootId);
|
|
473
|
+
attach(store.listRoots().find((row) => row.rootId === member.rootId));
|
|
474
|
+
}
|
|
475
|
+
publish({ type: 'root.moved', rootId: root.rootId, from: oldPath, to: newPath });
|
|
476
|
+
try {
|
|
477
|
+
scanNow(root.rootId);
|
|
478
|
+
} catch (error) {
|
|
479
|
+
console.warn(`[Observer] post-rebind scan failed for "${newPath}":`, error?.message || error);
|
|
480
|
+
}
|
|
481
|
+
return store.listRoots().find((row) => row.rootId === root.rootId);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function scanNow(rootId) {
|
|
485
|
+
const roots = rootId
|
|
486
|
+
? store.listRoots().filter((root) => root.rootId === rootId)
|
|
487
|
+
: store.listRoots();
|
|
488
|
+
for (const stale of roots) {
|
|
489
|
+
// A scan earlier in this loop may have converted or removed a later
|
|
490
|
+
// root (territory handoff); the store row is the fresh truth.
|
|
491
|
+
const root = store.rootByPath(stale.path);
|
|
492
|
+
if (!root) continue;
|
|
493
|
+
if (addressUndecided(root)) {
|
|
494
|
+
// The same decision the address doorbell makes — whichever
|
|
495
|
+
// entrance finds the undecided address first, the verdict is
|
|
496
|
+
// identical, so a settle scan winning the race against a slow
|
|
497
|
+
// parent delivery follows instead of flapping presence.
|
|
498
|
+
decideUndecidedAddress(root);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
markPresent(root);
|
|
502
|
+
if (root.kind === 'file') {
|
|
503
|
+
scanFileRoot(root);
|
|
504
|
+
} else if (root.kind === 'files') {
|
|
505
|
+
scanFilesRoot(root);
|
|
506
|
+
} else {
|
|
507
|
+
const territory = gitDirTruth(root.path);
|
|
508
|
+
if (territory === 'absent') {
|
|
509
|
+
becomeFiles(root, live.get(root.rootId));
|
|
510
|
+
} else if (territory === 'unsettled') {
|
|
511
|
+
// A failed look concludes nothing: the territory stands, the
|
|
512
|
+
// suspicion stays pending, and the settled look will bring
|
|
513
|
+
// the catch-up doorbell.
|
|
514
|
+
const state = live.get(root.rootId);
|
|
515
|
+
if (state) armRetry(root, state);
|
|
516
|
+
} else {
|
|
517
|
+
// Territory changes while nobody watched left no doorbell
|
|
518
|
+
// either: the catch-up census is their event.
|
|
519
|
+
censusRepoRoot(root);
|
|
520
|
+
// Catch-up semantics: the observer cannot know what happened to a
|
|
521
|
+
// repo while nobody watched; the follower's ids turn "maybe" into
|
|
522
|
+
// silence, so an unconditional doorbell is the honest answer.
|
|
523
|
+
publish({ type: 'repo.changed', rootId: root.rootId, areas: ['git', 'worktree'] });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
// A scan that leaves no look pending resets the backoff: the
|
|
527
|
+
// next unsettled episode starts fresh from the settle delay.
|
|
528
|
+
const after = live.get(root.rootId);
|
|
529
|
+
if (after && !after.settleTimer) after.retryMs = settleMs;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function coveringTerritory(state, relPath) {
|
|
534
|
+
for (const territory of state.territories) {
|
|
535
|
+
if (relPath === territory || relPath.startsWith(`${territory}/`)) return territory;
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function underTerritory(state, relPath) {
|
|
541
|
+
return coveringTerritory(state, relPath) !== null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function ring(root, relPath) {
|
|
545
|
+
const state = live.get(root.rootId);
|
|
546
|
+
if (!state) return;
|
|
547
|
+
state.retryMs = settleMs; // new evidence resets a backoff
|
|
548
|
+
|
|
549
|
+
if (state.kind === 'file') {
|
|
550
|
+
// The parent-directory doorbell already filtered to this file (or
|
|
551
|
+
// to nothing named, which could be anything): the one channel is
|
|
552
|
+
// suspect either way, and a named event always hashes.
|
|
553
|
+
state.dirtyPaths.add('');
|
|
554
|
+
armSettle(root, state);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (state.kind === 'repo') {
|
|
559
|
+
const gitArea = relPath === null || relPath === '.git' || relPath.startsWith('.git/');
|
|
560
|
+
if (gitArea) {
|
|
561
|
+
const territory = gitDirTruth(root.path);
|
|
562
|
+
if (territory === 'absent') {
|
|
563
|
+
becomeFiles(root, state);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (territory === 'unsettled') {
|
|
567
|
+
// A failed look concludes nothing: no conversion, no doorbell
|
|
568
|
+
// forwarded on unknown ground — the retry looks again, and the
|
|
569
|
+
// settled look brings the catch-up doorbell.
|
|
570
|
+
armRetry(root, state);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// The filename only says where to look; disk decides what it means.
|
|
575
|
+
// Four tiers, cheapest first; every check is a single shot on this
|
|
576
|
+
// event, never a poll. (Files roots need none of this: their scans
|
|
577
|
+
// re-walk the ground, and reconcileTerritories is their reconcile.)
|
|
578
|
+
if (relPath === null) {
|
|
579
|
+
// No name at all: the only honest reading is a full reconcile.
|
|
580
|
+
censusRepoRoot(root);
|
|
581
|
+
} else {
|
|
582
|
+
const territory = coveringTerritory(state, relPath);
|
|
583
|
+
if (territory !== null) {
|
|
584
|
+
// A live child territory's ground answers to its own root: its
|
|
585
|
+
// watcher rings it, its follower captures it. A dead boundary is
|
|
586
|
+
// territory news that never spells ".git" (a move-out rings only
|
|
587
|
+
// the directory that left): bury the child, the ground is the
|
|
588
|
+
// parent's worktree again, and the forward below is the reclaim.
|
|
589
|
+
const childPath = path.join(root.path, territory);
|
|
590
|
+
const childTruth = gitDirTruth(childPath);
|
|
591
|
+
if (childTruth !== 'absent') {
|
|
592
|
+
// Only absence buries — and an unsettled look keeps the news
|
|
593
|
+
// pending: the retry looks again, no second doorbell needed.
|
|
594
|
+
if (childTruth === 'unsettled') armRetry(root, state);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const known = store.rootByPath(childPath);
|
|
598
|
+
if (known && known.kind === 'repo') removeRepoChild(known);
|
|
599
|
+
state.territories.delete(territory);
|
|
600
|
+
} else {
|
|
601
|
+
// Territories BELOW the named path: a move-out may name only an
|
|
602
|
+
// ancestor folder of a boundary (`mv vendor` away when the
|
|
603
|
+
// territory is vendor/lib). Existence is checked for every
|
|
604
|
+
// territory the named path contains.
|
|
605
|
+
for (const below of [...state.territories]) {
|
|
606
|
+
if (!below.startsWith(`${relPath}/`)) continue;
|
|
607
|
+
const belowPath = path.join(root.path, below);
|
|
608
|
+
const belowTruth = gitDirTruth(belowPath);
|
|
609
|
+
if (belowTruth !== 'absent') {
|
|
610
|
+
if (belowTruth === 'unsettled') armRetry(root, state); // only absence buries; unsettled stays pending
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
const known = store.rootByPath(belowPath);
|
|
614
|
+
if (known && known.kind === 'repo') removeRepoChild(known);
|
|
615
|
+
state.territories.delete(below);
|
|
616
|
+
}
|
|
617
|
+
// Unclaimed ground: any directory on the doorbell's path could be
|
|
618
|
+
// a boundary that never spelled ".git" (a move-in rings only the
|
|
619
|
+
// directory that landed; git init rings ".git/HEAD" under it).
|
|
620
|
+
// Walk the named path's prefixes. The nearest boundary claims;
|
|
621
|
+
// deeper ground is the child's own census's business.
|
|
622
|
+
const segments = relPath.split('/');
|
|
623
|
+
const gitIdx = segments.indexOf('.git');
|
|
624
|
+
const prefixEnd = gitIdx >= 0 ? gitIdx : segments.length;
|
|
625
|
+
let claimed = false;
|
|
626
|
+
let pruned = false;
|
|
627
|
+
for (let i = 1; i <= prefixEnd; i++) {
|
|
628
|
+
const rel = segments.slice(0, i).join('/');
|
|
629
|
+
// The policy prunes at the directory, exactly like the walks:
|
|
630
|
+
// excluded ground excludes everything under it, so the boundary
|
|
631
|
+
// may not be asked about point-wise.
|
|
632
|
+
if (!shouldEnroll(rel)) {
|
|
633
|
+
pruned = true;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
const childPath = path.join(root.path, rel);
|
|
637
|
+
const prefixTruth = gitDirTruth(childPath);
|
|
638
|
+
if (prefixTruth !== 'present') {
|
|
639
|
+
if (prefixTruth === 'unsettled') armRetry(root, state); // only presence births; unsettled stays pending
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const known = store.rootByPath(childPath);
|
|
643
|
+
if (known) {
|
|
644
|
+
if (known.kind === 'repo') state.territories.add(rel); // heal a raced set
|
|
645
|
+
} else {
|
|
646
|
+
birthRepo(childPath);
|
|
647
|
+
state.territories.add(rel);
|
|
648
|
+
}
|
|
649
|
+
claimed = true;
|
|
650
|
+
break;
|
|
651
|
+
}
|
|
652
|
+
// A boundary may also sit DEEPER than the doorbell names (`mv
|
|
653
|
+
// pkg/` in when the repo lives at pkg/lib): when the named path
|
|
654
|
+
// is itself a plain directory on unclaimed, enrollable ground,
|
|
655
|
+
// walk it once for boundaries. The walk's paths are relative to
|
|
656
|
+
// the named directory, but the policy's contract is root-relative
|
|
657
|
+
// — re-prefix before asking, or a policy pruning `pkg/private`
|
|
658
|
+
// would not prune it here.
|
|
659
|
+
// (When gitIdx is -1 the loop's last prefix IS relPath, so an
|
|
660
|
+
// unpruned exit already vetted the named path itself.)
|
|
661
|
+
if (!claimed && !pruned && gitIdx === -1 && isDirectory(path.join(root.path, relPath))) {
|
|
662
|
+
const scopedEnroll = (rel) => shouldEnroll(`${relPath}/${rel}`);
|
|
663
|
+
const walked = censusBoundaries(path.join(root.path, relPath), scopedEnroll);
|
|
664
|
+
for (const sub of walked.boundaries) {
|
|
665
|
+
const rel = `${relPath}/${sub}`;
|
|
666
|
+
const childPath = path.join(root.path, rel);
|
|
667
|
+
if (!store.rootByPath(childPath)) {
|
|
668
|
+
birthRepo(childPath);
|
|
669
|
+
state.territories.add(rel);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Unlisted ground may hide a boundary: suspicion stays pending.
|
|
673
|
+
if (walked.unsettled.length > 0) armRetry(root, state);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Forwarded raw and instantly: quieting is the follower's job, and a
|
|
678
|
+
// delayed doorbell would blind its torn-read guard.
|
|
679
|
+
const areas = relPath === null
|
|
680
|
+
? ['git', 'worktree']
|
|
681
|
+
: relPath === '.git' || relPath.startsWith('.git/') ? ['git'] : ['worktree'];
|
|
682
|
+
publish({ type: 'repo.changed', rootId: root.rootId, areas });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// A child repo's own doorbell covers its territory — while the boundary
|
|
687
|
+
// is alive on disk. A dead one is territory news: fall through and let
|
|
688
|
+
// the scan's reconcile bury it and reclaim the ground.
|
|
689
|
+
const filesTerritory = relPath === null ? null : coveringTerritory(state, relPath);
|
|
690
|
+
if (filesTerritory !== null) {
|
|
691
|
+
const territoryTruth = gitDirTruth(path.join(root.path, filesTerritory));
|
|
692
|
+
if (territoryTruth === 'unsettled') {
|
|
693
|
+
armRetry(root, state); // no conclusion from a failed look — the retry decides
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (territoryTruth === 'present') return;
|
|
697
|
+
}
|
|
698
|
+
// Enrollment policy applies to doorbells too — except .git itself: it is
|
|
699
|
+
// never enrollable, but its appearance is territory news and must scan.
|
|
700
|
+
const territoryNews = filesTerritory !== null
|
|
701
|
+
|| (relPath !== null && relPath.split('/').includes('.git'));
|
|
702
|
+
if (relPath !== null && !territoryNews && !shouldEnroll(relPath)) return;
|
|
703
|
+
if (relPath === null) {
|
|
704
|
+
state.checkAll = true; // No filename given: every file is suspect.
|
|
705
|
+
} else if (!territoryNews) {
|
|
706
|
+
// A delivered event names its file; that file will be hashed no matter
|
|
707
|
+
// what its stats claim (timestamps can be backdated, doorbells can't).
|
|
708
|
+
state.dirtyPaths.add(relPath);
|
|
709
|
+
}
|
|
710
|
+
armSettle(root, state);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function openWatcher(root) {
|
|
714
|
+
// A single-file root's doorbell lives on its parent directory: a
|
|
715
|
+
// watcher on the file itself would follow the inode and go deaf on
|
|
716
|
+
// the first atomic save. The doorbell hears EVERYTHING in the parent
|
|
717
|
+
// — a rename may lawfully deliver only the NEW name — so events
|
|
718
|
+
// naming this file (or nothing, which could be anything) ring the
|
|
719
|
+
// content scan, and any foreign name is possible address news,
|
|
720
|
+
// answered by the cheap address check. Directory roots get the
|
|
721
|
+
// injected recursive doorbell.
|
|
722
|
+
if (root.kind === 'file') {
|
|
723
|
+
const name = path.basename(root.path);
|
|
724
|
+
return watchDir(path.dirname(root.path), (rung) => {
|
|
725
|
+
if (rung === null || rung === name) {
|
|
726
|
+
ring(root, rung);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
try {
|
|
730
|
+
checkAddress(root.rootId);
|
|
731
|
+
} catch (error) {
|
|
732
|
+
console.warn(`[Observer] address check failed for "${root.path}":`, error?.message || error);
|
|
733
|
+
}
|
|
734
|
+
}, () => reviveWatcher(root));
|
|
735
|
+
}
|
|
736
|
+
return watch(
|
|
737
|
+
root.path,
|
|
738
|
+
(relPath) => ring(root, relPath),
|
|
739
|
+
() => reviveWatcher(root),
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** A root enclosed by watched ground has its address observed already. */
|
|
744
|
+
function enclosed(root) {
|
|
745
|
+
return store.listRoots().some((other) =>
|
|
746
|
+
other.rootId !== root.rootId && root.path.startsWith(`${other.path}/`));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* The parent observes address changes; the entity observes content
|
|
751
|
+
* changes. A top-level directory root's own recursive watcher can
|
|
752
|
+
* never see its address change, so a non-recursive doorbell on the
|
|
753
|
+
* parent directory — the same `watchDir` single-file roots ride —
|
|
754
|
+
* rings `checkAddress` on EVERY event there: a rename may deliver
|
|
755
|
+
* only the new name, so no name filter is lawful, and the cheap
|
|
756
|
+
* address check is what decides (an intact, present address returns
|
|
757
|
+
* in one lstat). A root inside another watched tree needs none: its
|
|
758
|
+
* encloser's walk is its address evidence (that arrival is adoption,
|
|
759
|
+
* one level up). Single-file roots' main doorbell IS a parent
|
|
760
|
+
* watcher, so their address news rings through openWatcher.
|
|
761
|
+
*/
|
|
762
|
+
function openAddressWatcher(root) {
|
|
763
|
+
return watchDir(path.dirname(root.path), () => {
|
|
764
|
+
try {
|
|
765
|
+
checkAddress(root.rootId);
|
|
766
|
+
} catch (error) {
|
|
767
|
+
console.warn(`[Observer] address check failed for "${root.path}":`, error?.message || error);
|
|
768
|
+
}
|
|
769
|
+
}, () => reviveAddressWatcher(root));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function reviveAddressWatcher(root) {
|
|
773
|
+
const state = live.get(root.rootId);
|
|
774
|
+
if (!state || !started) return;
|
|
775
|
+
state.addressWatcher = openAddressWatcher(root);
|
|
776
|
+
try {
|
|
777
|
+
checkAddress(root.rootId);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
console.warn(`[Observer] post-revive address check failed for "${root.path}":`, error?.message || error);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* The address doorbell rang: decide what the root's address now means.
|
|
785
|
+
* Intact ground settles through the ordinary scan (which also flips
|
|
786
|
+
* presence back if the ground returned). Undecided ground goes to the
|
|
787
|
+
* one shared decision below — the same one every scan entrance makes.
|
|
788
|
+
*/
|
|
789
|
+
function checkAddress(rootId) {
|
|
790
|
+
const root = store.listRoots().find((row) => row.rootId === rootId);
|
|
791
|
+
if (!root) return;
|
|
792
|
+
if (!addressUndecided(root)) {
|
|
793
|
+
// Intact address: content news is the root's own watcher's job —
|
|
794
|
+
// the parent doorbell also rings when the root's direct children
|
|
795
|
+
// change its entry, and scanning on that would double every scan.
|
|
796
|
+
// Only frozen ground RETURNING earns a reconcile here.
|
|
797
|
+
if (!root.present) scanNow(root.rootId);
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
decideUndecidedAddress(root);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* THE one decision for an undecided address, at EVERY entrance — the
|
|
805
|
+
* address doorbell, the settle scan racing a slow parent delivery,
|
|
806
|
+
* the catch-up scan at start. Two independent doorbells can learn of
|
|
807
|
+
* one rename in either order; the verdict may not depend on which
|
|
808
|
+
* arrived first, so both funnel here. Undecided ground of EVERY kind
|
|
809
|
+
* gets one directed look at the parent — a same-parent rename is the
|
|
810
|
+
* common move, and the root's kind-appropriate WITNESSED identity (a
|
|
811
|
+
* dir root its own inode; a single ground its channel row's, as of
|
|
812
|
+
* the last completed scan) claims the new name under the usual
|
|
813
|
+
* rules: exactly one candidate of the root's own shape. The path
|
|
814
|
+
* still claims first — ground is only ever undecided when nothing of
|
|
815
|
+
* the root's shape holds the address — and ambiguity (two entries
|
|
816
|
+
* wearing one inode: a hardlink beside a rename) refuses to guess.
|
|
817
|
+
* So does missing evidence: an atomic save renamed away inside its
|
|
818
|
+
* own settle window left ground no scan ever witnessed, and the
|
|
819
|
+
* follow rides witnessed continuity, never clairvoyance. Anything
|
|
820
|
+
* else is honest absence: `root.unavailable`, identity frozen, never
|
|
821
|
+
* a conclusion. Returns the root's fresh row after a follow, or null
|
|
822
|
+
* when the ground is absent.
|
|
823
|
+
*/
|
|
824
|
+
function decideUndecidedAddress(root) {
|
|
825
|
+
const identity = rootIdentity(root);
|
|
826
|
+
if (identity) {
|
|
827
|
+
const wantDir = root.kind !== 'file';
|
|
828
|
+
const parent = path.dirname(root.path);
|
|
829
|
+
let entries = [];
|
|
830
|
+
try {
|
|
831
|
+
entries = fs.readdirSync(parent);
|
|
832
|
+
} catch {
|
|
833
|
+
entries = []; // the parent itself is gone: absence below
|
|
834
|
+
}
|
|
835
|
+
const found = [];
|
|
836
|
+
for (const name of entries) {
|
|
837
|
+
try {
|
|
838
|
+
const stat = fs.lstatSync(path.join(parent, name));
|
|
839
|
+
if (stat.isDirectory() === wantDir && stat.dev === identity.device && stat.ino === identity.inode) {
|
|
840
|
+
found.push(path.join(parent, name));
|
|
841
|
+
}
|
|
842
|
+
} catch { /* raced away mid-look; absence decides below */ }
|
|
843
|
+
}
|
|
844
|
+
if (found.length === 1) return rebindRoot(root, found[0]);
|
|
845
|
+
}
|
|
846
|
+
markAbsent(root);
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Watcher death is an event: install a fresh doorbell, then reconcile the
|
|
852
|
+
* gap once. A replacement that cannot even be born stays degraded (visible
|
|
853
|
+
* in status()) until the next lifecycle moment retries — no timers.
|
|
854
|
+
*/
|
|
855
|
+
function reviveWatcher(root) {
|
|
856
|
+
const state = live.get(root.rootId);
|
|
857
|
+
if (!state || !started) return;
|
|
858
|
+
state.watcher = openWatcher(root);
|
|
859
|
+
try {
|
|
860
|
+
scanNow(root.rootId);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
console.warn(`[Observer] post-revive scan failed for "${root.path}":`, error?.message || error);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function startWatching(root, state) {
|
|
867
|
+
if (!state.watcher || state.watcher.degraded) {
|
|
868
|
+
state.watcher?.close();
|
|
869
|
+
state.watcher = openWatcher(root);
|
|
870
|
+
}
|
|
871
|
+
if ((!state.addressWatcher || state.addressWatcher.degraded)
|
|
872
|
+
&& root.kind !== 'file' && !enclosed(root)) {
|
|
873
|
+
state.addressWatcher?.close();
|
|
874
|
+
state.addressWatcher = openAddressWatcher(root);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function attach(root, { watch = true } = {}) {
|
|
879
|
+
const existing = live.get(root.rootId);
|
|
880
|
+
if (existing) {
|
|
881
|
+
if (started && watch) startWatching(root, existing);
|
|
882
|
+
return existing;
|
|
883
|
+
}
|
|
884
|
+
const state = {
|
|
885
|
+
kind: root.kind,
|
|
886
|
+
watcher: null,
|
|
887
|
+
addressWatcher: null,
|
|
888
|
+
settleTimer: null,
|
|
889
|
+
settleDue: 0,
|
|
890
|
+
retryMs: settleMs,
|
|
891
|
+
dirtyPaths: new Set(),
|
|
892
|
+
checkAll: false,
|
|
893
|
+
territories: new Set(),
|
|
894
|
+
};
|
|
895
|
+
for (const child of childRepoRoots(root)) {
|
|
896
|
+
state.territories.add(child.path.slice(root.path.length + 1));
|
|
897
|
+
}
|
|
898
|
+
live.set(root.rootId, state);
|
|
899
|
+
if (started && watch) startWatching(root, state);
|
|
900
|
+
return state;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function detach(rootId) {
|
|
904
|
+
const state = live.get(rootId);
|
|
905
|
+
if (!state) return;
|
|
906
|
+
state.watcher?.close();
|
|
907
|
+
state.addressWatcher?.close();
|
|
908
|
+
if (state.settleTimer) clearTimeout(state.settleTimer);
|
|
909
|
+
live.delete(rootId);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
/**
|
|
914
|
+
* Enrollment names ground by ADDRESS: the parent chain is resolved so
|
|
915
|
+
* the address is canonical, but the last segment is inspected, never
|
|
916
|
+
* traversed — a symlink enrolled explicitly enrolls AS the pointer,
|
|
917
|
+
* the smallest root of the pointer kind (only real ground ever
|
|
918
|
+
* arrives here through edge-following, which resolves first).
|
|
919
|
+
* Identity is permanent and the address is not: ground that moved out
|
|
920
|
+
* from under a known root is rescued by physical continuity — the
|
|
921
|
+
* root keeps its rootId, every channel inside keeps its identity, and
|
|
922
|
+
* `root.moved` carries the news.
|
|
923
|
+
*/
|
|
924
|
+
enrollRoot(rootPath, { kind, scan = true, watch = true } = {}) {
|
|
925
|
+
const parent = fs.realpathSync(path.dirname(rootPath));
|
|
926
|
+
const address = path.join(parent, path.basename(rootPath));
|
|
927
|
+
let stat = fs.lstatSync(address);
|
|
928
|
+
const resolved = stat.isSymbolicLink() ? address : fs.realpathSync(address);
|
|
929
|
+
if (!stat.isSymbolicLink() && resolved !== address) stat = fs.lstatSync(resolved);
|
|
930
|
+
const existing = store.rootByPath(resolved);
|
|
931
|
+
if (existing) {
|
|
932
|
+
// A known address wearing a different directory identity is a
|
|
933
|
+
// frozen reoccupation (see addressUndecided) — and THIS is the
|
|
934
|
+
// moment that resolves it: presence proved nothing, but naming
|
|
935
|
+
// the address is a deliberate act, so the root adopts the ground
|
|
936
|
+
// and a full scan turns the frozen beliefs into honest evidence.
|
|
937
|
+
// (Single-ground roots need no adoption — their address lawfully
|
|
938
|
+
// churns identity and the file laws absorb it at the next scan.)
|
|
939
|
+
if (existing.kind !== 'file' && stat.isDirectory() && existing.device != null
|
|
940
|
+
&& (stat.dev !== existing.device || stat.ino !== existing.inode)) {
|
|
941
|
+
store.setRootIdentity(existing.rootId, stat.dev, stat.ino);
|
|
942
|
+
attach(existing, { watch });
|
|
943
|
+
if (scan) scanNow(existing.rootId);
|
|
944
|
+
return store.rootByPath(resolved);
|
|
945
|
+
}
|
|
946
|
+
attach(existing, { watch });
|
|
947
|
+
return existing;
|
|
948
|
+
}
|
|
949
|
+
const moved = findMovedRoot(stat);
|
|
950
|
+
if (moved) return rebindRoot(moved, resolved);
|
|
951
|
+
// Territory is decided by disk: a directory with a .git is a repo,
|
|
952
|
+
// a plain directory is files ground, and a file or pointer is the
|
|
953
|
+
// smallest root there is — one channel. Disk must actually answer:
|
|
954
|
+
// a kind is a durable conclusion, so unsettled ground refuses
|
|
955
|
+
// loudly instead of guessing — the caller's dirty/retry rule owns
|
|
956
|
+
// the next attempt, and no wrongly typed root is ever minted.
|
|
957
|
+
const territory = (kind || !stat.isDirectory()) ? null : gitDirTruth(resolved);
|
|
958
|
+
if (territory === 'unsettled') {
|
|
959
|
+
throw new Error(`cannot enroll ${resolved}: the ground could not be read, so its kind is unsettled — enroll again when a look settles`);
|
|
960
|
+
}
|
|
961
|
+
const resolvedKind = kind
|
|
962
|
+
?? (!stat.isDirectory() ? 'file' : territory === 'present' ? 'repo' : 'files');
|
|
963
|
+
const root = {
|
|
964
|
+
rootId: crypto.randomUUID(), path: resolved, kind: resolvedKind,
|
|
965
|
+
device: stat.dev, inode: stat.ino,
|
|
966
|
+
};
|
|
967
|
+
store.addRoot(root);
|
|
968
|
+
attach(root, { watch });
|
|
969
|
+
if (!scan) return root;
|
|
970
|
+
if (resolvedKind === 'repo') {
|
|
971
|
+
publish({ type: 'repo.discovered', rootId: root.rootId, path: root.path });
|
|
972
|
+
censusRepoRoot(root); // enrollment is also the birth of child territories
|
|
973
|
+
} else if (resolvedKind === 'file') {
|
|
974
|
+
scanFileRoot(root); // Enrollment is the birth of the one channel.
|
|
975
|
+
} else {
|
|
976
|
+
scanFilesRoot(root); // Enrollment is the birth of channels.
|
|
977
|
+
}
|
|
978
|
+
return root;
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
removeRoot(rootId) {
|
|
982
|
+
detach(rootId);
|
|
983
|
+
store.removeRoot(rootId);
|
|
984
|
+
},
|
|
985
|
+
|
|
986
|
+
listRoots: () => store.listRoots(),
|
|
987
|
+
filesForRoot: (rootId) => store.filesForRoot(rootId),
|
|
988
|
+
dirsForRoot: (rootId) => store.dirsForRoot(rootId),
|
|
989
|
+
linksForRoot: (rootId) => store.linksForRoot(rootId),
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Every enrolled root exposes its symlink edges, whatever its kind —
|
|
993
|
+
* the enrollment-policy surface (edges.js) reads THIS, never the
|
|
994
|
+
* tables directly. Files ground answers from detected truth (its
|
|
995
|
+
* links are channels — a single-ground root holding a pointer
|
|
996
|
+
* included: its one edge is the pointer itself); repo ground answers
|
|
997
|
+
* from a fresh worktree census. Its changes travel as one Card +
|
|
998
|
+
* Checkpoint state; stored child rows are refreshed only after that
|
|
999
|
+
* state settles, never by this raw edge query. The answer is
|
|
1000
|
+
* three-state: `unsettled` names ground the census could not read —
|
|
1001
|
+
* an edge may hide there, so the surface never claims completeness
|
|
1002
|
+
* it did not earn.
|
|
1003
|
+
*/
|
|
1004
|
+
linkEdges(rootId) {
|
|
1005
|
+
const root = store.listRoots().find((row) => row.rootId === rootId);
|
|
1006
|
+
if (!root) return { edges: [], unsettled: [] };
|
|
1007
|
+
if (root.kind === 'repo') {
|
|
1008
|
+
const { links, unsettled } = censusLinks(root.path, shouldEnroll);
|
|
1009
|
+
return { edges: links, unsettled };
|
|
1010
|
+
}
|
|
1011
|
+
return {
|
|
1012
|
+
edges: store.linksForRoot(rootId).map(({ relPath, target }) => ({ relPath, target })),
|
|
1013
|
+
unsettled: [],
|
|
1014
|
+
};
|
|
1015
|
+
},
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The one enrollment policy, exposed so EVERY route that expands
|
|
1019
|
+
* coverage — walks, censuses, edge-following, the registration
|
|
1020
|
+
* boundary's explicit add — asks the same rule, about the COMPLETE
|
|
1021
|
+
* candidate path: the policy excludes by path segment (.ssh,
|
|
1022
|
+
* node_modules, ...), so a benign final name inside excluded ground
|
|
1023
|
+
* is still excluded. Walks ask with tree-relative paths, external
|
|
1024
|
+
* candidates with their whole address. No route may enroll ground
|
|
1025
|
+
* the policy excludes; an explicit override is a future deliberate
|
|
1026
|
+
* operation, never a default.
|
|
1027
|
+
*/
|
|
1028
|
+
shouldEnroll: (candidatePath) => shouldEnroll(candidatePath),
|
|
1029
|
+
/**
|
|
1030
|
+
* Reconcile registered Git worktree children after Repocard has settled
|
|
1031
|
+
* a new Card + Checkpoint. This is deliberately NOT called by raw
|
|
1032
|
+
* filesystem doorbells: Git state is the filter that authorizes this
|
|
1033
|
+
* ordinary structural comparison. The root .git is skipped while nested
|
|
1034
|
+
* repositories remain their own territories.
|
|
1035
|
+
*/
|
|
1036
|
+
reconcileRepoWorktree(rootId) {
|
|
1037
|
+
const root = store.listRoots().find((row) => row.rootId === rootId);
|
|
1038
|
+
if (!root || root.kind !== 'repo') {
|
|
1039
|
+
throw new Error(`reconcileRepoWorktree requires a live repo root: ${rootId}`);
|
|
1040
|
+
}
|
|
1041
|
+
scanFilesRoot(root, { allowRootRepository: true });
|
|
1042
|
+
},
|
|
1043
|
+
scanNow,
|
|
1044
|
+
|
|
1045
|
+
// The referee (verify.js): re-hash every enrolled byte, report every
|
|
1046
|
+
// divergence — read-only, and deliberately none of the machinery above.
|
|
1047
|
+
verify: createVerify({ store, shouldEnroll }),
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* A writer that changed disk itself records the result here so the next
|
|
1051
|
+
* scan finds silence instead of manufacturing a change (axiom 4).
|
|
1052
|
+
*/
|
|
1053
|
+
noteWrite(absPath) {
|
|
1054
|
+
const resolved = fs.realpathSync(absPath);
|
|
1055
|
+
for (const root of store.listRoots()) {
|
|
1056
|
+
if (root.kind === 'file' && resolved === root.path) {
|
|
1057
|
+
let stat;
|
|
1058
|
+
try {
|
|
1059
|
+
stat = fs.lstatSync(resolved);
|
|
1060
|
+
} catch {
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const hashedAtMs = Date.now(); // stamped BEFORE reading, like every hash here
|
|
1064
|
+
const content = safeContent(resolved);
|
|
1065
|
+
if (content === null) return;
|
|
1066
|
+
const fields = {
|
|
1067
|
+
relPath: '', device: stat.dev, inode: stat.ino, size: stat.size,
|
|
1068
|
+
mtimeMs: Math.floor(stat.mtimeMs), ...content, hashedAtMs,
|
|
1069
|
+
};
|
|
1070
|
+
const row = store.fileByPath(root.rootId, '');
|
|
1071
|
+
if (row) store.updateFile(row.fileId, fields);
|
|
1072
|
+
else store.insertFile({ fileId: crypto.randomUUID(), rootId: root.rootId, ...fields });
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (root.kind !== 'files') continue;
|
|
1076
|
+
const rel = path.relative(root.path, resolved);
|
|
1077
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
1078
|
+
const relPath = rel.split(path.sep).join('/');
|
|
1079
|
+
const state = live.get(root.rootId);
|
|
1080
|
+
if (state && underTerritory(state, relPath)) return; // repo territory: not a channel
|
|
1081
|
+
let stat;
|
|
1082
|
+
try {
|
|
1083
|
+
stat = fs.lstatSync(resolved);
|
|
1084
|
+
} catch {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const hashedAtMs = Date.now();
|
|
1088
|
+
const content = safeContent(resolved);
|
|
1089
|
+
if (content === null) return;
|
|
1090
|
+
const fields = {
|
|
1091
|
+
relPath,
|
|
1092
|
+
device: stat.dev,
|
|
1093
|
+
inode: stat.ino,
|
|
1094
|
+
size: stat.size,
|
|
1095
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
1096
|
+
...content,
|
|
1097
|
+
hashedAtMs,
|
|
1098
|
+
};
|
|
1099
|
+
const row = store.fileByPath(root.rootId, relPath);
|
|
1100
|
+
if (row) store.updateFile(row.fileId, fields);
|
|
1101
|
+
else store.insertFile({ fileId: crypto.randomUUID(), rootId: root.rootId, ...fields });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
|
|
1106
|
+
start(rootId = null) {
|
|
1107
|
+
const firstStart = !started;
|
|
1108
|
+
if (firstStart) started = true;
|
|
1109
|
+
if (!firstStart && rootId === null) return;
|
|
1110
|
+
const roots = rootId === null
|
|
1111
|
+
? store.listRoots()
|
|
1112
|
+
: store.listRoots().filter((root) => root.rootId === rootId);
|
|
1113
|
+
// Watch begins after registration. The initial scan makes the fresh
|
|
1114
|
+
// structural tree concrete; changes during registration are outside
|
|
1115
|
+
// this operation's contract and are not reconstructed here.
|
|
1116
|
+
for (const root of roots) attach(root, { watch: true });
|
|
1117
|
+
try {
|
|
1118
|
+
for (const root of roots) scanNow(root.rootId);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
console.warn('[Observer] initial scan failed:', error?.message || error);
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
|
|
1124
|
+
stop() {
|
|
1125
|
+
started = false;
|
|
1126
|
+
for (const rootId of [...live.keys()]) detach(rootId);
|
|
1127
|
+
},
|
|
1128
|
+
|
|
1129
|
+
status() {
|
|
1130
|
+
return store.listRoots().map((root) => {
|
|
1131
|
+
const state = live.get(root.rootId);
|
|
1132
|
+
return {
|
|
1133
|
+
...root,
|
|
1134
|
+
watching: Boolean(state?.watcher) && !state.watcher.degraded,
|
|
1135
|
+
degraded: Boolean(state?.watcher?.degraded),
|
|
1136
|
+
addressRequired: root.kind !== 'file' && !enclosed(root),
|
|
1137
|
+
addressWatching: Boolean(state?.addressWatcher) && !state.addressWatcher.degraded,
|
|
1138
|
+
addressDegraded: Boolean(state?.addressWatcher?.degraded),
|
|
1139
|
+
files: root.kind === 'repo' ? null : store.filesForRoot(root.rootId).length,
|
|
1140
|
+
};
|
|
1141
|
+
});
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
module.exports = { createObserver };
|