@gmickel/gno 1.34.5 → 1.35.0
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 +22 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.zip → gno-browser-clipper-v1.35.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +4 -1
- package/spec/cli.md +43 -0
- package/spec/output-schemas/mcp-job-status.schema.json +6 -2
- package/src/config/index.ts +4 -0
- package/src/config/types.ts +14 -0
- package/src/core/path-rules.ts +34 -0
- package/src/ingestion/index.ts +21 -0
- package/src/ingestion/record-container.ts +23 -1
- package/src/ingestion/source-availability/darwin-io.ts +295 -0
- package/src/ingestion/source-availability/darwin-path.ts +58 -0
- package/src/ingestion/source-availability/directory.ts +402 -0
- package/src/ingestion/source-availability/index.ts +74 -0
- package/src/ingestion/source-availability/readers.ts +360 -0
- package/src/ingestion/source-availability/resolve.ts +28 -0
- package/src/ingestion/source-availability/types.ts +170 -0
- package/src/ingestion/sync.ts +565 -108
- package/src/ingestion/types.ts +45 -3
- package/src/ingestion/walker.ts +263 -5
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/watch-reconciliation-fallback-disk.ts +359 -0
- package/src/serve/watch-reconciliation-fallback.ts +434 -0
- package/src/serve/watch-reconciliation-shared.ts +348 -0
- package/src/serve/watch-reconciliation.ts +129 -0
- package/src/serve/watch-service-events.ts +261 -0
- package/src/serve/watch-service-flush-generation.ts +140 -0
- package/src/serve/watch-service-flush-helpers.ts +147 -0
- package/src/serve/watch-service-flush.ts +443 -0
- package/src/serve/watch-service-hosts.ts +109 -0
- package/src/serve/watch-service-lifecycle.ts +221 -0
- package/src/serve/watch-service-run-flush.ts +236 -0
- package/src/serve/watch-service-snapshot.ts +125 -0
- package/src/serve/watch-service-state.ts +146 -0
- package/src/serve/watch-service.ts +266 -306
- package/src/serve/watch-snapshot-availability.ts +51 -0
- package/src/serve/watch-snapshot-handles.ts +365 -0
- package/src/serve/watch-snapshot-libc.ts +510 -0
- package/src/serve/watch-snapshot-ops.ts +541 -0
- package/src/serve/watch-snapshot-resolve.ts +246 -0
- package/src/serve/watch-snapshot-scan.ts +300 -0
- package/src/serve/watch-snapshot-types.ts +392 -0
- package/src/serve/watch-snapshot.ts +51 -0
- package/src/store/index.ts +1 -1
- package/src/store/sqlite/adapter.ts +191 -0
- package/src/store/types.ts +66 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.5.zip.sha256 +0 -1
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build and diff orchestration for watcher snapshots.
|
|
3
|
+
* Hint reconciliation lives in `watch-snapshot-resolve`.
|
|
4
|
+
*
|
|
5
|
+
* @module src/serve/watch-snapshot-ops
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
|
|
9
|
+
import type {
|
|
10
|
+
DiffWorkResult,
|
|
11
|
+
SnapshotEntryFingerprint,
|
|
12
|
+
WatcherSnapshot,
|
|
13
|
+
WatcherSnapshotBuildResult,
|
|
14
|
+
WatcherSnapshotDiffResult,
|
|
15
|
+
WatcherSnapshotFs,
|
|
16
|
+
WatcherSnapshotOptions,
|
|
17
|
+
} from "./watch-snapshot-types";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
directoryAllowsDescent,
|
|
21
|
+
readAvailableDirectory,
|
|
22
|
+
} from "./watch-snapshot-availability";
|
|
23
|
+
import {
|
|
24
|
+
cloneDirectoryMaps,
|
|
25
|
+
defaultClock,
|
|
26
|
+
defaultFs,
|
|
27
|
+
freezeSnapshot,
|
|
28
|
+
removeSubtreeFromMaps,
|
|
29
|
+
setDirectoryEntries,
|
|
30
|
+
type MutableSnapshotMaps,
|
|
31
|
+
} from "./watch-snapshot-scan";
|
|
32
|
+
import {
|
|
33
|
+
WATCHER_SNAPSHOT_ENTRY_CEILING,
|
|
34
|
+
fingerprintsEqual,
|
|
35
|
+
isWatcherSourceKind,
|
|
36
|
+
joinWatcherRelPath,
|
|
37
|
+
normalizeWatcherRelPath,
|
|
38
|
+
sortPathList,
|
|
39
|
+
} from "./watch-snapshot-types";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a full no-follow hierarchical snapshot of `rootAbs`.
|
|
43
|
+
* On overflow, scan failure, or unreliable metadata the last proven snapshot
|
|
44
|
+
* is untouched (this function simply does not return one).
|
|
45
|
+
*/
|
|
46
|
+
export async function buildWatcherSnapshot(
|
|
47
|
+
rootAbs: string,
|
|
48
|
+
options: WatcherSnapshotOptions = {}
|
|
49
|
+
): Promise<WatcherSnapshotBuildResult> {
|
|
50
|
+
const fs = options.fs ?? defaultFs;
|
|
51
|
+
const clock = options.clock ?? defaultClock;
|
|
52
|
+
const ceiling = options.entryCeiling ?? WATCHER_SNAPSHOT_ENTRY_CEILING;
|
|
53
|
+
const directoryAvailability = options.directoryAvailability;
|
|
54
|
+
const started = clock.nowMs();
|
|
55
|
+
|
|
56
|
+
if (!(await directoryAllowsDescent(rootAbs, "", directoryAvailability))) {
|
|
57
|
+
// Root itself is unproven — fail closed without claiming an empty tree.
|
|
58
|
+
return {
|
|
59
|
+
status: "fallback",
|
|
60
|
+
reason: "scan_failed",
|
|
61
|
+
durationMs: clock.nowMs() - started,
|
|
62
|
+
cause: new Error(
|
|
63
|
+
"Collection root availability is unproven; refusing snapshot descent"
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const state: MutableSnapshotMaps = {
|
|
69
|
+
directories: new Map(),
|
|
70
|
+
entryCount: 0,
|
|
71
|
+
unprovenSubtrees: new Set(),
|
|
72
|
+
};
|
|
73
|
+
// Cursor-index queue avoids O(n) shift on large directory sets.
|
|
74
|
+
const queue: string[] = [""];
|
|
75
|
+
let head = 0;
|
|
76
|
+
|
|
77
|
+
while (head < queue.length) {
|
|
78
|
+
const dirRel = queue[head] as string;
|
|
79
|
+
head += 1;
|
|
80
|
+
// Remaining slots before this directory map is installed.
|
|
81
|
+
const remaining = ceiling - state.entryCount;
|
|
82
|
+
if (remaining < 0) {
|
|
83
|
+
return {
|
|
84
|
+
status: "fallback",
|
|
85
|
+
reason: "overflow",
|
|
86
|
+
durationMs: clock.nowMs() - started,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const scanned = await readAvailableDirectory(
|
|
90
|
+
rootAbs,
|
|
91
|
+
dirRel,
|
|
92
|
+
fs,
|
|
93
|
+
remaining,
|
|
94
|
+
directoryAvailability
|
|
95
|
+
);
|
|
96
|
+
if (scanned.status === "unproven") {
|
|
97
|
+
if (dirRel === "") {
|
|
98
|
+
return {
|
|
99
|
+
status: "fallback",
|
|
100
|
+
reason: "scan_failed",
|
|
101
|
+
durationMs: clock.nowMs() - started,
|
|
102
|
+
cause: new Error(
|
|
103
|
+
"Collection root availability changed before snapshot enumeration"
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
state.unprovenSubtrees.add(dirRel);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (scanned.status === "missing") {
|
|
111
|
+
if (dirRel === "") {
|
|
112
|
+
return {
|
|
113
|
+
status: "fallback",
|
|
114
|
+
reason: "scan_failed",
|
|
115
|
+
durationMs: clock.nowMs() - started,
|
|
116
|
+
cause: new Error("Collection root is missing"),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Nested directory vanished mid-scan: fail closed rather than prove removals.
|
|
120
|
+
return {
|
|
121
|
+
status: "fallback",
|
|
122
|
+
reason: "scan_failed",
|
|
123
|
+
durationMs: clock.nowMs() - started,
|
|
124
|
+
cause: new Error(`Directory vanished during snapshot: ${dirRel}`),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (scanned.status !== "present") {
|
|
128
|
+
return {
|
|
129
|
+
status: "fallback",
|
|
130
|
+
reason: scanned.status,
|
|
131
|
+
durationMs: clock.nowMs() - started,
|
|
132
|
+
cause: scanned.status === "scan_failed" ? scanned.cause : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setDirectoryEntries(state, dirRel, scanned.entries);
|
|
137
|
+
if (state.entryCount > ceiling) {
|
|
138
|
+
return {
|
|
139
|
+
status: "fallback",
|
|
140
|
+
reason: "overflow",
|
|
141
|
+
durationMs: clock.nowMs() - started,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const [name, fingerprint] of scanned.entries) {
|
|
146
|
+
// No-follow: never recurse through symlinks (even if they point inside root).
|
|
147
|
+
if (fingerprint.kind === "directory") {
|
|
148
|
+
const childRel = joinWatcherRelPath(dirRel, name);
|
|
149
|
+
if (
|
|
150
|
+
!(await directoryAllowsDescent(
|
|
151
|
+
rootAbs,
|
|
152
|
+
childRel,
|
|
153
|
+
directoryAvailability
|
|
154
|
+
))
|
|
155
|
+
) {
|
|
156
|
+
// Refuse descent/enumeration; leave subtree absent from this build.
|
|
157
|
+
state.unprovenSubtrees.add(childRel);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
queue.push(childRel);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
status: "ok",
|
|
167
|
+
snapshot: freezeSnapshot(state),
|
|
168
|
+
durationMs: clock.nowMs() - started,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Diff one or more dirty directories against the last proven snapshot.
|
|
174
|
+
*
|
|
175
|
+
* - Compares direct children only
|
|
176
|
+
* - Recurses into changed or new real directories
|
|
177
|
+
* - Expands removals hierarchically from the old snapshot (O(subtree))
|
|
178
|
+
* - Emits explicit `removals` separate from present/changed `candidates`
|
|
179
|
+
* - Never mutates `previous`; overflow/failure leaves it as the last proven state
|
|
180
|
+
*/
|
|
181
|
+
export async function diffWatcherSnapshot(
|
|
182
|
+
rootAbs: string,
|
|
183
|
+
previous: WatcherSnapshot,
|
|
184
|
+
dirtyDirectories: readonly string[],
|
|
185
|
+
options: WatcherSnapshotOptions = {}
|
|
186
|
+
): Promise<WatcherSnapshotDiffResult> {
|
|
187
|
+
const fs = options.fs ?? defaultFs;
|
|
188
|
+
const clock = options.clock ?? defaultClock;
|
|
189
|
+
const ceiling = options.entryCeiling ?? WATCHER_SNAPSHOT_ENTRY_CEILING;
|
|
190
|
+
const mapHooks = options.mapHooks;
|
|
191
|
+
const directoryAvailability = options.directoryAvailability;
|
|
192
|
+
const started = clock.nowMs();
|
|
193
|
+
|
|
194
|
+
const normalizedDirs: string[] = [];
|
|
195
|
+
for (const raw of dirtyDirectories) {
|
|
196
|
+
const normalized = normalizeWatcherRelPath(raw === "" ? "." : raw);
|
|
197
|
+
// `normalizeWatcherRelPath(".")` → `""`; empty string is the root and is
|
|
198
|
+
// already valid. Accept explicit "" without going through normalize of "".
|
|
199
|
+
if (raw === "") {
|
|
200
|
+
normalizedDirs.push("");
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (normalized === null) {
|
|
204
|
+
// Invalid dirty directory: ignore as a no-op rather than failing the batch.
|
|
205
|
+
// Callers should resolve hints first; this guard keeps the primitive safe.
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
normalizedDirs.push(normalized);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (normalizedDirs.length === 0) {
|
|
212
|
+
return {
|
|
213
|
+
status: "ok",
|
|
214
|
+
candidates: [],
|
|
215
|
+
removals: [],
|
|
216
|
+
nextSnapshot: previous,
|
|
217
|
+
discoveryMs: clock.nowMs() - started,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const nextState = cloneDirectoryMaps(previous);
|
|
222
|
+
const candidates = new Set<string>();
|
|
223
|
+
const removals = new Set<string>();
|
|
224
|
+
const visited = new Set<string>();
|
|
225
|
+
|
|
226
|
+
const recordSubtreeRemovals = (dirRel: string): void => {
|
|
227
|
+
// One hierarchical collect+remove pass — no prior full-map scan.
|
|
228
|
+
for (const path of removeSubtreeFromMaps(nextState, dirRel, mapHooks)) {
|
|
229
|
+
removals.add(path);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const subtreeInventoryIsUnproven = (dirRel: string): boolean => {
|
|
234
|
+
const prefix = `${dirRel}/`;
|
|
235
|
+
for (const unproven of nextState.unprovenSubtrees) {
|
|
236
|
+
if (unproven === dirRel || unproven.startsWith(prefix)) {
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const diffDirectory = async (dirRel: string): Promise<DiffWorkResult> => {
|
|
244
|
+
if (visited.has(dirRel)) {
|
|
245
|
+
return { status: "ok" };
|
|
246
|
+
}
|
|
247
|
+
visited.add(dirRel);
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
!(await directoryAllowsDescent(rootAbs, dirRel, directoryAvailability))
|
|
251
|
+
) {
|
|
252
|
+
// Dirty target is unproven — keep prior subtree, prove nothing.
|
|
253
|
+
return { status: "ok" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Replacement frees prior slots for this directory map before the new scan.
|
|
257
|
+
const previousSize = nextState.directories.get(dirRel)?.size ?? 0;
|
|
258
|
+
const remaining = ceiling - nextState.entryCount + previousSize;
|
|
259
|
+
if (remaining < 0) {
|
|
260
|
+
return { status: "overflow" };
|
|
261
|
+
}
|
|
262
|
+
const scanned = await readAvailableDirectory(
|
|
263
|
+
rootAbs,
|
|
264
|
+
dirRel,
|
|
265
|
+
fs,
|
|
266
|
+
remaining,
|
|
267
|
+
directoryAvailability
|
|
268
|
+
);
|
|
269
|
+
if (scanned.status === "unproven") {
|
|
270
|
+
return { status: "ok" };
|
|
271
|
+
}
|
|
272
|
+
if (scanned.status === "missing") {
|
|
273
|
+
// Open-time ENOENT/ENOTDIR is never directory-deletion proof.
|
|
274
|
+
// A nested dirty target may have raced into a file/symlink/other after
|
|
275
|
+
// resolve, and recursive open may disagree with a parent listing that
|
|
276
|
+
// still observed a directory — both are inconsistent scans. Deletion is
|
|
277
|
+
// proven only when a successful containing-parent listing observes the
|
|
278
|
+
// child absent (handled via oldFp && !newFp below).
|
|
279
|
+
return {
|
|
280
|
+
status: "scan_failed",
|
|
281
|
+
cause: new Error(
|
|
282
|
+
dirRel === ""
|
|
283
|
+
? "Collection root is missing"
|
|
284
|
+
: `Directory open failed (missing or not a directory): ${dirRel}`
|
|
285
|
+
),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (scanned.status !== "present") {
|
|
289
|
+
return scanned;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const oldEntries =
|
|
293
|
+
nextState.directories.get(dirRel) ??
|
|
294
|
+
new Map<string, SnapshotEntryFingerprint>();
|
|
295
|
+
const newEntries = scanned.entries;
|
|
296
|
+
setDirectoryEntries(nextState, dirRel, new Map(newEntries));
|
|
297
|
+
nextState.unprovenSubtrees.delete(dirRel);
|
|
298
|
+
|
|
299
|
+
if (nextState.entryCount > ceiling) {
|
|
300
|
+
return { status: "overflow" };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const names = new Set([...oldEntries.keys(), ...newEntries.keys()]);
|
|
304
|
+
for (const name of names) {
|
|
305
|
+
const oldFp = oldEntries.get(name);
|
|
306
|
+
const newFp = newEntries.get(name);
|
|
307
|
+
const childRel = joinWatcherRelPath(dirRel, name);
|
|
308
|
+
|
|
309
|
+
if (oldFp && !newFp) {
|
|
310
|
+
// Removed entry: expand prior subtree for directories; files/symlinks directly.
|
|
311
|
+
// `other` was never an indexable source — drop fingerprint only.
|
|
312
|
+
if (oldFp.kind === "directory") {
|
|
313
|
+
if (subtreeInventoryIsUnproven(childRel)) {
|
|
314
|
+
return { status: "unproven_subtree" };
|
|
315
|
+
}
|
|
316
|
+
recordSubtreeRemovals(childRel);
|
|
317
|
+
} else if (isWatcherSourceKind(oldFp.kind)) {
|
|
318
|
+
removals.add(childRel);
|
|
319
|
+
}
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (newFp && !oldFp) {
|
|
324
|
+
// Added entry. Candidates are file/symlink only — ignore new FIFO/etc.
|
|
325
|
+
if (newFp.kind === "directory") {
|
|
326
|
+
if (
|
|
327
|
+
!(await directoryAllowsDescent(
|
|
328
|
+
rootAbs,
|
|
329
|
+
childRel,
|
|
330
|
+
directoryAvailability
|
|
331
|
+
))
|
|
332
|
+
) {
|
|
333
|
+
// Unproven new directory: keep fingerprint, do not enumerate.
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const built = await scanNewSubtree(
|
|
337
|
+
rootAbs,
|
|
338
|
+
childRel,
|
|
339
|
+
fs,
|
|
340
|
+
nextState,
|
|
341
|
+
candidates,
|
|
342
|
+
ceiling,
|
|
343
|
+
directoryAvailability
|
|
344
|
+
);
|
|
345
|
+
if (built.status !== "ok") {
|
|
346
|
+
return built;
|
|
347
|
+
}
|
|
348
|
+
} else if (isWatcherSourceKind(newFp.kind)) {
|
|
349
|
+
candidates.add(childRel);
|
|
350
|
+
}
|
|
351
|
+
// new `other`: fingerprint retained for future transitions; no candidate.
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (oldFp && newFp && !fingerprintsEqual(oldFp, newFp)) {
|
|
356
|
+
// Changed entry — handle kind transitions with the other-kind contract.
|
|
357
|
+
if (oldFp.kind === "directory" && newFp.kind !== "directory") {
|
|
358
|
+
// Directory → file/symlink/other: expand nested indexable removals.
|
|
359
|
+
if (subtreeInventoryIsUnproven(childRel)) {
|
|
360
|
+
return { status: "unproven_subtree" };
|
|
361
|
+
}
|
|
362
|
+
recordSubtreeRemovals(childRel);
|
|
363
|
+
if (isWatcherSourceKind(newFp.kind)) {
|
|
364
|
+
candidates.add(childRel);
|
|
365
|
+
}
|
|
366
|
+
// directory → other: no candidate for the special entry.
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (oldFp.kind !== "directory" && newFp.kind === "directory") {
|
|
371
|
+
// File/symlink → directory: old source is removable.
|
|
372
|
+
// Other → directory: prior other was never indexed — no removal.
|
|
373
|
+
if (isWatcherSourceKind(oldFp.kind)) {
|
|
374
|
+
removals.add(childRel);
|
|
375
|
+
}
|
|
376
|
+
if (
|
|
377
|
+
!(await directoryAllowsDescent(
|
|
378
|
+
rootAbs,
|
|
379
|
+
childRel,
|
|
380
|
+
directoryAvailability
|
|
381
|
+
))
|
|
382
|
+
) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const built = await scanNewSubtree(
|
|
386
|
+
rootAbs,
|
|
387
|
+
childRel,
|
|
388
|
+
fs,
|
|
389
|
+
nextState,
|
|
390
|
+
candidates,
|
|
391
|
+
ceiling,
|
|
392
|
+
directoryAvailability
|
|
393
|
+
);
|
|
394
|
+
if (built.status !== "ok") {
|
|
395
|
+
return built;
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (newFp.kind === "directory") {
|
|
401
|
+
// Directory → directory (metadata change): recurse only when available.
|
|
402
|
+
if (
|
|
403
|
+
!(await directoryAllowsDescent(
|
|
404
|
+
rootAbs,
|
|
405
|
+
childRel,
|
|
406
|
+
directoryAvailability
|
|
407
|
+
))
|
|
408
|
+
) {
|
|
409
|
+
// Preserve prior subtree under unproven directory — do not re-scan.
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const nested = await diffDirectory(childRel);
|
|
413
|
+
if (nested.status !== "ok") {
|
|
414
|
+
return nested;
|
|
415
|
+
}
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Both non-directory.
|
|
420
|
+
if (
|
|
421
|
+
isWatcherSourceKind(oldFp.kind) &&
|
|
422
|
+
isWatcherSourceKind(newFp.kind)
|
|
423
|
+
) {
|
|
424
|
+
// file↔symlink or metadata change on an indexable source.
|
|
425
|
+
candidates.add(childRel);
|
|
426
|
+
} else if (isWatcherSourceKind(oldFp.kind) && newFp.kind === "other") {
|
|
427
|
+
// file/symlink → other: remove old path; special entry is not a candidate.
|
|
428
|
+
removals.add(childRel);
|
|
429
|
+
} else if (oldFp.kind === "other" && isWatcherSourceKind(newFp.kind)) {
|
|
430
|
+
// other → file/symlink: new source is a candidate.
|
|
431
|
+
candidates.add(childRel);
|
|
432
|
+
}
|
|
433
|
+
// other → other (metadata): fingerprint only.
|
|
434
|
+
}
|
|
435
|
+
// Equal fingerprints: leave alone (do not recurse into unchanged dirs).
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return { status: "ok" };
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
for (const dir of normalizedDirs) {
|
|
442
|
+
const result = await diffDirectory(dir);
|
|
443
|
+
if (result.status !== "ok") {
|
|
444
|
+
return {
|
|
445
|
+
status: "fallback",
|
|
446
|
+
reason: result.status,
|
|
447
|
+
discoveryMs: clock.nowMs() - started,
|
|
448
|
+
cause: result.status === "scan_failed" ? result.cause : undefined,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (nextState.entryCount > ceiling) {
|
|
454
|
+
return {
|
|
455
|
+
status: "fallback",
|
|
456
|
+
reason: "overflow",
|
|
457
|
+
discoveryMs: clock.nowMs() - started,
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
status: "ok",
|
|
463
|
+
candidates: sortPathList(candidates),
|
|
464
|
+
removals: sortPathList(removals),
|
|
465
|
+
nextSnapshot: freezeSnapshot(nextState),
|
|
466
|
+
discoveryMs: clock.nowMs() - started,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function scanNewSubtree(
|
|
471
|
+
rootAbs: string,
|
|
472
|
+
dirRel: string,
|
|
473
|
+
fs: WatcherSnapshotFs,
|
|
474
|
+
state: MutableSnapshotMaps,
|
|
475
|
+
candidates: Set<string>,
|
|
476
|
+
ceiling: number,
|
|
477
|
+
directoryAvailability?: DirectoryAvailabilityPort
|
|
478
|
+
): Promise<DiffWorkResult> {
|
|
479
|
+
if (!(await directoryAllowsDescent(rootAbs, dirRel, directoryAvailability))) {
|
|
480
|
+
// Keep the directory fingerprint from the parent listing; do not enumerate.
|
|
481
|
+
state.unprovenSubtrees.add(dirRel);
|
|
482
|
+
return { status: "ok" };
|
|
483
|
+
}
|
|
484
|
+
const queue = [dirRel];
|
|
485
|
+
let head = 0;
|
|
486
|
+
while (head < queue.length) {
|
|
487
|
+
const current = queue[head] as string;
|
|
488
|
+
head += 1;
|
|
489
|
+
const previousSize = state.directories.get(current)?.size ?? 0;
|
|
490
|
+
const remaining = ceiling - state.entryCount + previousSize;
|
|
491
|
+
if (remaining < 0) {
|
|
492
|
+
return { status: "overflow" };
|
|
493
|
+
}
|
|
494
|
+
const scanned = await readAvailableDirectory(
|
|
495
|
+
rootAbs,
|
|
496
|
+
current,
|
|
497
|
+
fs,
|
|
498
|
+
remaining,
|
|
499
|
+
directoryAvailability
|
|
500
|
+
);
|
|
501
|
+
if (scanned.status === "unproven") {
|
|
502
|
+
state.unprovenSubtrees.add(current);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (scanned.status === "missing") {
|
|
506
|
+
// New directory vanished while scanning — fail closed.
|
|
507
|
+
return {
|
|
508
|
+
status: "scan_failed",
|
|
509
|
+
cause: new Error(`New directory vanished during scan: ${current}`),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (scanned.status !== "present") {
|
|
513
|
+
return scanned;
|
|
514
|
+
}
|
|
515
|
+
setDirectoryEntries(state, current, new Map(scanned.entries));
|
|
516
|
+
state.unprovenSubtrees.delete(current);
|
|
517
|
+
if (state.entryCount > ceiling) {
|
|
518
|
+
return { status: "overflow" };
|
|
519
|
+
}
|
|
520
|
+
for (const [name, fingerprint] of scanned.entries) {
|
|
521
|
+
const childRel = joinWatcherRelPath(current, name);
|
|
522
|
+
if (fingerprint.kind === "directory") {
|
|
523
|
+
if (
|
|
524
|
+
!(await directoryAllowsDescent(
|
|
525
|
+
rootAbs,
|
|
526
|
+
childRel,
|
|
527
|
+
directoryAvailability
|
|
528
|
+
))
|
|
529
|
+
) {
|
|
530
|
+
state.unprovenSubtrees.add(childRel);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
queue.push(childRel);
|
|
534
|
+
} else if (isWatcherSourceKind(fingerprint.kind)) {
|
|
535
|
+
candidates.add(childRel);
|
|
536
|
+
}
|
|
537
|
+
// Nested `other` under a new directory: fingerprint only, never a candidate.
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return { status: "ok" };
|
|
541
|
+
}
|