@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.
Files changed (49) hide show
  1. package/README.md +22 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.zip → gno-browser-clipper-v1.35.0.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +4 -1
  6. package/spec/cli.md +43 -0
  7. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  8. package/src/config/index.ts +4 -0
  9. package/src/config/types.ts +14 -0
  10. package/src/core/path-rules.ts +34 -0
  11. package/src/ingestion/index.ts +21 -0
  12. package/src/ingestion/record-container.ts +23 -1
  13. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  14. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  15. package/src/ingestion/source-availability/directory.ts +402 -0
  16. package/src/ingestion/source-availability/index.ts +74 -0
  17. package/src/ingestion/source-availability/readers.ts +360 -0
  18. package/src/ingestion/source-availability/resolve.ts +28 -0
  19. package/src/ingestion/source-availability/types.ts +170 -0
  20. package/src/ingestion/sync.ts +565 -108
  21. package/src/ingestion/types.ts +45 -3
  22. package/src/ingestion/walker.ts +263 -5
  23. package/src/serve/public/globals.built.css +1 -1
  24. package/src/serve/watch-reconciliation-fallback-disk.ts +359 -0
  25. package/src/serve/watch-reconciliation-fallback.ts +434 -0
  26. package/src/serve/watch-reconciliation-shared.ts +348 -0
  27. package/src/serve/watch-reconciliation.ts +129 -0
  28. package/src/serve/watch-service-events.ts +261 -0
  29. package/src/serve/watch-service-flush-generation.ts +140 -0
  30. package/src/serve/watch-service-flush-helpers.ts +147 -0
  31. package/src/serve/watch-service-flush.ts +443 -0
  32. package/src/serve/watch-service-hosts.ts +109 -0
  33. package/src/serve/watch-service-lifecycle.ts +221 -0
  34. package/src/serve/watch-service-run-flush.ts +236 -0
  35. package/src/serve/watch-service-snapshot.ts +125 -0
  36. package/src/serve/watch-service-state.ts +146 -0
  37. package/src/serve/watch-service.ts +266 -306
  38. package/src/serve/watch-snapshot-availability.ts +51 -0
  39. package/src/serve/watch-snapshot-handles.ts +365 -0
  40. package/src/serve/watch-snapshot-libc.ts +510 -0
  41. package/src/serve/watch-snapshot-ops.ts +541 -0
  42. package/src/serve/watch-snapshot-resolve.ts +246 -0
  43. package/src/serve/watch-snapshot-scan.ts +300 -0
  44. package/src/serve/watch-snapshot-types.ts +392 -0
  45. package/src/serve/watch-snapshot.ts +51 -0
  46. package/src/store/index.ts +1 -1
  47. package/src/store/sqlite/adapter.ts +191 -0
  48. package/src/store/types.ts +66 -0
  49. 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
+ }